live-earnings 0.4.0__tar.gz

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,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: live-earnings
3
+ Version: 0.4.0
4
+ Summary: Live Earnings — live, attributed earnings-call transcripts.
5
+ Author: Live Earnings
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://live-earnings.com/live/docs
8
+ Project-URL: Documentation, https://live-earnings.com/live/docs
9
+ Project-URL: Source, https://live-earnings.com/live/docs
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: requests>=2.31
13
+
14
+ # live-earnings
15
+
16
+ Python client for the Live Earnings API: live earnings-call transcripts, each
17
+ turn's text as it is spoken and then who said it. Python 3.9+.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install live-earnings
23
+ ```
24
+
25
+ ## Quickstart
26
+
27
+ Your API key (`ek_…`) covers one call; copy it from the call's **API Access**
28
+ tab on https://live-earnings.com/live. Run with `LE_KEY=ek_… SLUG=<call-slug>`:
29
+
30
+ ```python
31
+ import os
32
+
33
+ from live_earnings import LiveEarningsClient, Transcript
34
+
35
+ client = LiveEarningsClient(token=os.environ["LE_KEY"])
36
+ slug = os.environ["SLUG"]
37
+ transcript = Transcript()
38
+
39
+
40
+ def show(turn):
41
+ if turn:
42
+ print(f"{turn.name or '…'}: {turn.text or ''}")
43
+
44
+
45
+ # Everything so far, then live until the call ends.
46
+ last = None # the last seq seen; None = start the stream from now
47
+ ended = False
48
+ for env in client.events(slug):
49
+ show(transcript.apply(env))
50
+ last = env.seq
51
+ ended = ended or env.event.type == "done"
52
+ if not ended:
53
+ for env in client.stream(slug, last_event_id=last):
54
+ if env.event.type == "done":
55
+ break
56
+ show(transcript.apply(env))
57
+ ```
58
+
59
+ A turn prints once when its text arrives (`…: text`) and again when its
60
+ speaker is named. `Transcript` applies the rules: a person's correction beats
61
+ the model's name, `new_name == "unknown"` leaves a turn unnamed,
62
+ `basis == "early"` marks a provisional name, and a repeated envelope is
63
+ ignored. `apply()` returns the turn itself, updated in place as later
64
+ events arrive.
65
+
66
+ ## The client
67
+
68
+ | | |
69
+ |---|---|
70
+ | `meta(slug)` | the call: ticker, fiscal period, status, roster |
71
+ | `events(slug, from_=None, to=None, limit=None)` | every stored envelope, following pagination |
72
+ | `stream(slug, last_event_id=None)` | live envelopes; raises if the connection goes silent for 90 s — resume with the last `seq` you saw |
73
+ | `session_token(slug)` | a `SessionToken` (`session_token`, `expires_in`) for a browser `EventSource` (`?t=`) — call it on your server |
74
+
75
+ Hosts default to `https://live-earnings.com` (REST) and
76
+ `https://stream.live-earnings.com` (streams); override with `base_url` and
77
+ `stream_url`. Both must be `https://` unless you pass `allow_insecure=True` for
78
+ local development. Errors raise `LiveEarningsError` (`status`, `body`).
79
+
80
+ Full reference: https://live-earnings.com/live/docs
81
+
82
+ ## Changes
83
+
84
+ **0.4.0** — Default hosts (`base_url` is optional; streams go to the stream
85
+ host). `Transcript` folds events into turns. `session_token()`. `events()`
86
+ takes an optional range. Streams deliver each message as it arrives (they
87
+ were buffered in 512-byte reads). `AiInsightEvent` is deprecated — the API
88
+ does not currently send it.
89
+
90
+ **0.3.0** — `UnknownEvent.naming == "pending"` (the name follows as a
91
+ `TurnSpeakerAmendEvent`); amends carry `by` and may carry `basis == "early"`.
92
+
93
+ **0.2.0** — `stt_speaker_change` on `AttributionEvent` and `UnknownEvent`;
94
+ `ai_insight` and `turn_speaker_amend` decode to their own classes.
@@ -0,0 +1,81 @@
1
+ # live-earnings
2
+
3
+ Python client for the Live Earnings API: live earnings-call transcripts, each
4
+ turn's text as it is spoken and then who said it. Python 3.9+.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install live-earnings
10
+ ```
11
+
12
+ ## Quickstart
13
+
14
+ Your API key (`ek_…`) covers one call; copy it from the call's **API Access**
15
+ tab on https://live-earnings.com/live. Run with `LE_KEY=ek_… SLUG=<call-slug>`:
16
+
17
+ ```python
18
+ import os
19
+
20
+ from live_earnings import LiveEarningsClient, Transcript
21
+
22
+ client = LiveEarningsClient(token=os.environ["LE_KEY"])
23
+ slug = os.environ["SLUG"]
24
+ transcript = Transcript()
25
+
26
+
27
+ def show(turn):
28
+ if turn:
29
+ print(f"{turn.name or '…'}: {turn.text or ''}")
30
+
31
+
32
+ # Everything so far, then live until the call ends.
33
+ last = None # the last seq seen; None = start the stream from now
34
+ ended = False
35
+ for env in client.events(slug):
36
+ show(transcript.apply(env))
37
+ last = env.seq
38
+ ended = ended or env.event.type == "done"
39
+ if not ended:
40
+ for env in client.stream(slug, last_event_id=last):
41
+ if env.event.type == "done":
42
+ break
43
+ show(transcript.apply(env))
44
+ ```
45
+
46
+ A turn prints once when its text arrives (`…: text`) and again when its
47
+ speaker is named. `Transcript` applies the rules: a person's correction beats
48
+ the model's name, `new_name == "unknown"` leaves a turn unnamed,
49
+ `basis == "early"` marks a provisional name, and a repeated envelope is
50
+ ignored. `apply()` returns the turn itself, updated in place as later
51
+ events arrive.
52
+
53
+ ## The client
54
+
55
+ | | |
56
+ |---|---|
57
+ | `meta(slug)` | the call: ticker, fiscal period, status, roster |
58
+ | `events(slug, from_=None, to=None, limit=None)` | every stored envelope, following pagination |
59
+ | `stream(slug, last_event_id=None)` | live envelopes; raises if the connection goes silent for 90 s — resume with the last `seq` you saw |
60
+ | `session_token(slug)` | a `SessionToken` (`session_token`, `expires_in`) for a browser `EventSource` (`?t=`) — call it on your server |
61
+
62
+ Hosts default to `https://live-earnings.com` (REST) and
63
+ `https://stream.live-earnings.com` (streams); override with `base_url` and
64
+ `stream_url`. Both must be `https://` unless you pass `allow_insecure=True` for
65
+ local development. Errors raise `LiveEarningsError` (`status`, `body`).
66
+
67
+ Full reference: https://live-earnings.com/live/docs
68
+
69
+ ## Changes
70
+
71
+ **0.4.0** — Default hosts (`base_url` is optional; streams go to the stream
72
+ host). `Transcript` folds events into turns. `session_token()`. `events()`
73
+ takes an optional range. Streams deliver each message as it arrives (they
74
+ were buffered in 512-byte reads). `AiInsightEvent` is deprecated — the API
75
+ does not currently send it.
76
+
77
+ **0.3.0** — `UnknownEvent.naming == "pending"` (the name follows as a
78
+ `TurnSpeakerAmendEvent`); amends carry `by` and may carry `basis == "early"`.
79
+
80
+ **0.2.0** — `stt_speaker_change` on `AttributionEvent` and `UnknownEvent`;
81
+ `ai_insight` and `turn_speaker_amend` decode to their own classes.
@@ -0,0 +1,53 @@
1
+ """Live Earnings — Python client.
2
+
3
+ Public API:
4
+ LiveEarningsClient — the main entry point.
5
+ Transcript / Turn — fold envelopes into turns (text first, name after).
6
+ AttributionEvent / UnknownEvent / PhaseTransitionEvent / DoneEvent —
7
+ the event shapes you'll see when iterating client.stream().
8
+ EventEnvelope — the wire-level wrapper.
9
+ CallMeta — what client.meta() returns.
10
+ """
11
+
12
+ from .client import LiveEarningsClient, LiveEarningsError, SessionToken, DEFAULT_BASE_URL, DEFAULT_STREAM_URL
13
+ from .transcript import Transcript, Turn
14
+ from .types import (
15
+ AttributionEvent,
16
+ UnknownEvent,
17
+ PhaseTransitionEvent,
18
+ DoneEvent,
19
+ AiInsightEvent,
20
+ TurnSpeakerAmendEvent,
21
+ AnyEvent,
22
+ EventEnvelope,
23
+ CallMeta,
24
+ Participant,
25
+ CoveringAnalyst,
26
+ Phase,
27
+ SpeakerChange,
28
+ )
29
+
30
+ __all__ = [
31
+ "LiveEarningsClient",
32
+ "LiveEarningsError",
33
+ "DEFAULT_BASE_URL",
34
+ "DEFAULT_STREAM_URL",
35
+ "Transcript",
36
+ "Turn",
37
+ "SessionToken",
38
+ "AttributionEvent",
39
+ "UnknownEvent",
40
+ "PhaseTransitionEvent",
41
+ "DoneEvent",
42
+ "AiInsightEvent",
43
+ "TurnSpeakerAmendEvent",
44
+ "AnyEvent",
45
+ "EventEnvelope",
46
+ "CallMeta",
47
+ "Participant",
48
+ "CoveringAnalyst",
49
+ "Phase",
50
+ "SpeakerChange",
51
+ ]
52
+
53
+ __version__ = "0.4.0"
@@ -0,0 +1,295 @@
1
+ """Live Earnings client.
2
+
3
+ The full API surface is on `LiveEarningsClient`:
4
+
5
+ .meta(call_slug) → CallMeta
6
+ .events(call_slug) → iterator of EventEnvelope (stored history)
7
+ .stream(call_slug) → iterator of EventEnvelope (live SSE)
8
+ .session_token(call_slug) → a 2-hour token for browser streams
9
+
10
+ Two hosts: REST on https://live-earnings.com, streams on
11
+ https://stream.live-earnings.com (a stream requested on the REST host answers
12
+ 503). Auth is the subscriber key (`ek_*`) as a Bearer header.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from dataclasses import dataclass
19
+ from typing import Iterator, Optional
20
+ from urllib.parse import urlparse
21
+
22
+ import requests
23
+
24
+ from .types import (
25
+ CallMeta,
26
+ EventEnvelope,
27
+ parse_envelope,
28
+ parse_meta,
29
+ )
30
+
31
+ #: The REST host: /meta, /events, /sections, /report, /session-token.
32
+ DEFAULT_BASE_URL = "https://live-earnings.com"
33
+ #: The stream host: /stream, /partial-stream, /audio-stream.
34
+ DEFAULT_STREAM_URL = "https://stream.live-earnings.com"
35
+ DEFAULT_TIMEOUT = 30
36
+ #: Give up on a stream that sends nothing (not even the 25 s heartbeat) for
37
+ #: this long: it raises, so the caller can reconnect with last_event_id.
38
+ STREAM_IDLE_TIMEOUT = 90
39
+
40
+
41
+ @dataclass
42
+ class SessionToken:
43
+ """A short-lived token for opening a stream as `?t=<session_token>`."""
44
+
45
+ session_token: str
46
+ #: Seconds (7200).
47
+ expires_in: int
48
+
49
+
50
+ def _host(url: str, allow_insecure: bool, what: str) -> str:
51
+ parsed = urlparse(url)
52
+ if not parsed.netloc:
53
+ raise ValueError(f"{what} is not a URL: {url}")
54
+ if parsed.scheme != "https" and not (allow_insecure and parsed.scheme == "http"):
55
+ # Every request carries the key: it must not travel in clear text.
56
+ raise ValueError(f"{what} must be https:// (pass allow_insecure=True for local http://)")
57
+ return url.rstrip("/")
58
+
59
+
60
+ class LiveEarningsError(Exception):
61
+ """Raised on 4xx / 5xx responses.
62
+
63
+ `status` carries the HTTP code; `body` is the response body if any.
64
+ """
65
+
66
+ def __init__(self, status: int, body: str = ""):
67
+ self.status = status
68
+ self.body = body
69
+ super().__init__(f"Live Earnings API error: {status} — {body[:200]}")
70
+
71
+
72
+ def _sse_lines(r: requests.Response) -> Iterator[str]:
73
+ """The response's lines as they arrive, decoded as UTF-8.
74
+
75
+ Reads chunks as they come (chunk_size=None; a fixed size would hold short
76
+ envelopes and heartbeats back) and splits on "\\n" only — requests'
77
+ iter_lines uses str.splitlines, which also breaks on U+2028/U+0085 (legal,
78
+ unescaped, inside JSON strings) and on a CRLF split across chunks.
79
+ """
80
+ buf = b""
81
+ for chunk in r.iter_content(chunk_size=None):
82
+ buf += chunk
83
+ while True:
84
+ i = buf.find(b"\n")
85
+ if i < 0:
86
+ break
87
+ line, buf = buf[:i], buf[i + 1 :]
88
+ yield line.rstrip(b"\r").decode("utf-8", errors="replace")
89
+ if buf:
90
+ yield buf.rstrip(b"\r").decode("utf-8", errors="replace")
91
+
92
+
93
+ class LiveEarningsClient:
94
+ """Live Earnings — REST + SSE client.
95
+
96
+ Example:
97
+ >>> client = LiveEarningsClient(token="ek_...")
98
+ >>> transcript = Transcript()
99
+ >>> for env in client.stream("nvda-q3-fy27"):
100
+ ... turn = transcript.apply(env)
101
+ ... if turn:
102
+ ... print(turn.name or "…", turn.text)
103
+
104
+ Parameters
105
+ ----------
106
+ token : str
107
+ Your subscriber API key (`ek_*`). Scoped to one call slug;
108
+ cross-call use returns 401.
109
+ base_url : str, optional
110
+ REST host. Defaults to DEFAULT_BASE_URL.
111
+ stream_url : str, optional
112
+ Stream host. Defaults to DEFAULT_STREAM_URL.
113
+ allow_insecure : bool, optional
114
+ Allow http:// hosts (local development only).
115
+ timeout : int, optional
116
+ Per-request timeout in seconds for REST endpoints. SSE
117
+ streams use no timeout (they're long-lived by design).
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ token: str,
123
+ base_url: Optional[str] = None,
124
+ stream_url: Optional[str] = None,
125
+ timeout: int = DEFAULT_TIMEOUT,
126
+ allow_insecure: bool = False,
127
+ ):
128
+ if not token:
129
+ raise ValueError("token is required")
130
+ self._token = token
131
+ self._base = _host(base_url or DEFAULT_BASE_URL, allow_insecure, "base_url")
132
+ self._stream_base = _host(stream_url or DEFAULT_STREAM_URL, allow_insecure, "stream_url")
133
+ self._timeout = timeout
134
+ self._session = requests.Session()
135
+ self._session.headers["Authorization"] = f"Bearer {token}"
136
+
137
+ # ── meta ──────────────────────────────────────────────────────
138
+
139
+ def meta(self, call_slug: str) -> CallMeta:
140
+ """Fetch call metadata: ticker, status, roster, participants.
141
+
142
+ Call once on bootstrap. If you observe an envelope with
143
+ type='done', consider re-fetching to pick up the final
144
+ status='ended'.
145
+ """
146
+ r = self._session.get(
147
+ f"{self._base}/api/live/{call_slug}/meta",
148
+ timeout=self._timeout,
149
+ )
150
+ self._check(r)
151
+ return parse_meta(r.json())
152
+
153
+ # ── session token (browser streams) ──────────────────────────
154
+
155
+ def session_token(self, call_slug: str) -> SessionToken:
156
+ """A short-lived token for a stream opened where headers cannot be sent
157
+ (a browser's EventSource): `{stream_url}/api/live/{slug}/stream?t=<token>`.
158
+
159
+ It is checked when a stream opens; mint a fresh one before reconnecting
160
+ after expires_in seconds.
161
+ """
162
+ r = self._session.post(
163
+ f"{self._base}/api/live/{call_slug}/session-token",
164
+ timeout=self._timeout,
165
+ )
166
+ self._check(r)
167
+ body = r.json()
168
+ return SessionToken(session_token=body["sessionToken"], expires_in=int(body["expiresIn"]))
169
+
170
+ # ── events backfill ──────────────────────────────────────────
171
+
172
+ def events(
173
+ self,
174
+ call_slug: str,
175
+ from_: Optional[int] = None,
176
+ to: Optional[int] = None,
177
+ limit: Optional[int] = None,
178
+ ) -> Iterator[EventEnvelope]:
179
+ """Iterate the stored event log, oldest first (default: all of it).
180
+
181
+ Paginates internally, following `nextFrom`. Each request fetches at
182
+ most `limit` envelopes (server default 1000, max 5000).
183
+ """
184
+ cursor = from_
185
+ while True:
186
+ params = {k: v for k, v in (("from", cursor), ("to", to), ("limit", limit)) if v is not None}
187
+ r = self._session.get(
188
+ f"{self._base}/api/live/{call_slug}/events",
189
+ params=params,
190
+ timeout=self._timeout,
191
+ )
192
+ self._check(r)
193
+ body = r.json()
194
+ for e in body.get("events", []):
195
+ yield parse_envelope(e)
196
+ # nextFrom, not an empty page, says when to stop: a page can be
197
+ # empty when every event in it is one subscribers are not sent.
198
+ next_from = body.get("nextFrom")
199
+ if next_from is None:
200
+ return
201
+ if cursor is not None and next_from <= cursor:
202
+ raise LiveEarningsError(0, f"events paging did not advance (nextFrom {next_from} after from {cursor})")
203
+ cursor = next_from
204
+
205
+ # ── live SSE stream ──────────────────────────────────────────
206
+
207
+ def stream(
208
+ self,
209
+ call_slug: str,
210
+ last_event_id: Optional[int] = None,
211
+ ) -> Iterator[EventEnvelope]:
212
+ """Iterate envelopes as they arrive live.
213
+
214
+ Generator yields one EventEnvelope per server event. Auto-handles
215
+ the SSE framing (`id:` / `event:` / `data:` lines).
216
+
217
+ Reconnect logic is YOUR responsibility — if this iterator raises or
218
+ ends before `done`, call .stream() again with the last `env.seq` you
219
+ saw as `last_event_id`: the server replays what you missed. An
220
+ envelope near the reconnect can arrive twice; Transcript ignores
221
+ repeats.
222
+
223
+ Parameters
224
+ ----------
225
+ last_event_id : int, optional
226
+ The last seq you saw; the server replays everything after it.
227
+ Omit it when you have seen nothing yet — the stream then starts
228
+ from now, so read history first with events().
229
+
230
+ A connection silent for STREAM_IDLE_TIMEOUT seconds raises
231
+ LiveEarningsError, like any other failure, so one handler can
232
+ reconnect.
233
+ """
234
+ headers = {"Accept": "text/event-stream"}
235
+ if last_event_id is not None:
236
+ headers["Last-Event-ID"] = str(last_event_id)
237
+
238
+ # The connection lasts the whole call, so there is no total timeout —
239
+ # only a read timeout: a connection silent past several heartbeats is
240
+ # dead, and raising lets the caller reconnect.
241
+ try:
242
+ yield from self._stream(call_slug, headers)
243
+ except requests.exceptions.RequestException as e:
244
+ # requests reports a read timeout as ConnectionError; surface every
245
+ # transport failure as the SDK's own error.
246
+ raise LiveEarningsError(0, f"stream failed: {e}") from e
247
+
248
+ def _stream(self, call_slug: str, headers: dict) -> Iterator[EventEnvelope]:
249
+ with self._session.get(
250
+ f"{self._stream_base}/api/live/{call_slug}/stream",
251
+ headers=headers,
252
+ stream=True,
253
+ timeout=(10, STREAM_IDLE_TIMEOUT),
254
+ ) as r:
255
+ self._check(r)
256
+ event_name = "envelope"
257
+ data_buf: list[str] = []
258
+ # Iterate line-by-line. SSE separates events with a blank
259
+ # line; we accumulate `data:` lines and dispatch on blank.
260
+ for line in _sse_lines(r):
261
+ if line == "":
262
+ # Dispatch the accumulated event.
263
+ if data_buf and event_name == "envelope":
264
+ payload = "\n".join(data_buf)
265
+ try:
266
+ yield parse_envelope(json.loads(payload))
267
+ except (ValueError, KeyError) as e:
268
+ # Malformed payload — skip rather than
269
+ # killing the iterator. Surface via stderr.
270
+ import sys
271
+ print(
272
+ f"[live-earnings] malformed envelope: {e}",
273
+ file=sys.stderr,
274
+ )
275
+ event_name = "envelope"
276
+ data_buf = []
277
+ continue
278
+ if line.startswith(":"):
279
+ # Comment / heartbeat line — ignore.
280
+ continue
281
+ if line.startswith("event:"):
282
+ event_name = line[6:].strip()
283
+ elif line.startswith("data:"):
284
+ data_buf.append(line[5:].lstrip())
285
+ elif line.startswith("id:"):
286
+ # We don't track this explicitly — the env.seq we
287
+ # parse from `data:` is the same value.
288
+ pass
289
+
290
+ # ── helpers ──────────────────────────────────────────────────
291
+
292
+
293
+ def _check(self, r: requests.Response) -> None:
294
+ if r.status_code >= 400:
295
+ raise LiveEarningsError(r.status_code, r.text)
@@ -0,0 +1,89 @@
1
+ """Transcript — folds the event stream into turns: the text first, the name after.
2
+
3
+ A turn's text usually arrives as an `unknown` event (naming "pending") and its
4
+ speaker follows as a `turn_speaker_amend`. Some calls publish text and name
5
+ together as an `attribution`. Feed every envelope — from client.events() and
6
+ client.stream() — to apply(); it returns the turn it changed.
7
+
8
+ Rules: a person's correction (amend with `by` absent or "operator") always beats
9
+ the model's name, whatever order they arrive in. new_name "unknown" means the
10
+ turn stays unnamed. basis "early" marks a provisional name. An envelope seen
11
+ twice (a stream replay after reconnecting can repeat one) is ignored.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from typing import Dict, List, Literal, Optional, Set
19
+
20
+ from .types import EventEnvelope, Phase
21
+
22
+
23
+ @dataclass
24
+ class Turn:
25
+ turn_id: str
26
+ #: The turn's text; None if only its name has arrived so far.
27
+ text: Optional[str] = None
28
+ #: Who spoke; None while unnamed.
29
+ name: Optional[str] = None
30
+ #: Where the name came from: a person's correction, the model, or an
31
+ #: `attribution` (text and name published together).
32
+ named_by: Optional[Literal["operator", "model", "attribution"]] = None
33
+ #: A provisional name (from the turn's first sentence only).
34
+ provisional: bool = False
35
+ phase: Optional[Phase] = None
36
+
37
+
38
+ def _turn_number(turn_id: str) -> int:
39
+ m = re.search(r"-(\d+)$", turn_id)
40
+ return int(m.group(1)) if m else 2**62
41
+
42
+
43
+ class Transcript:
44
+ def __init__(self) -> None:
45
+ self._by_id: Dict[str, Turn] = {}
46
+ self._seen: Set[int] = set()
47
+
48
+ def apply(self, env: EventEnvelope) -> Optional[Turn]:
49
+ """Apply one envelope. Returns the turn it changed, or None."""
50
+ if env.seq in self._seen:
51
+ return None
52
+ self._seen.add(env.seq)
53
+ ev = env.event
54
+ if ev.type in ("unknown", "attribution"):
55
+ t = self._turn(ev.turn_id)
56
+ if ev.text is not None:
57
+ t.text = ev.text
58
+ if ev.phase is not None:
59
+ t.phase = ev.phase
60
+ if ev.type == "attribution" and t.named_by != "operator":
61
+ t.name = ev.name
62
+ t.named_by = "attribution"
63
+ t.provisional = False
64
+ return t
65
+ if ev.type == "turn_speaker_amend":
66
+ t = self._turn(ev.turn_id)
67
+ human = ev.by != "model"
68
+ if not human and t.named_by == "operator":
69
+ return None # a correction stands
70
+ t.name = None if ev.new_name == "unknown" else ev.new_name
71
+ t.named_by = "operator" if human else "model"
72
+ t.provisional = ev.basis == "early"
73
+ return t
74
+ return None
75
+
76
+ @property
77
+ def turns(self) -> List[Turn]:
78
+ """Every turn so far, in the order they were spoken."""
79
+ return sorted(self._by_id.values(), key=lambda t: _turn_number(t.turn_id))
80
+
81
+ def get(self, turn_id: str) -> Optional[Turn]:
82
+ return self._by_id.get(turn_id)
83
+
84
+ def _turn(self, turn_id: str) -> Turn:
85
+ t = self._by_id.get(turn_id)
86
+ if t is None:
87
+ t = Turn(turn_id=turn_id)
88
+ self._by_id[turn_id] = t
89
+ return t
@@ -0,0 +1,313 @@
1
+ """Wire-format types — match the public OpenAPI / AsyncAPI specs.
2
+
3
+ Everything is a `dataclass` so users can pattern-match via
4
+ `isinstance(ev, AttributionEvent)` and IDEs autocomplete the fields.
5
+
6
+ Decoded events normalize JSON keys to snake_case. The original
7
+ JSON dict is preserved on each instance as `.raw` for advanced
8
+ consumers (e.g., new optional fields we don't know about yet).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Literal, Optional, Union
15
+
16
+ Phase = Literal[
17
+ "unknown",
18
+ "operator_opening",
19
+ "ir_intro",
20
+ "cfo_remarks",
21
+ "ceo_remarks",
22
+ "qa",
23
+ "closing",
24
+ ]
25
+
26
+ # The speech engine's observation that a turn's speaker is the SAME as the
27
+ # previous turn's, NEW, or "unknown" when it cannot tell. Only the Meta Muse
28
+ # engine observes it (the default engine); AssemblyAI calls are always "unknown", as
29
+ # is the first turn on each new Muse connection and a turn right after an
30
+ # unlabelled one. It
31
+ # is what the engine heard, not truth, and never an input to the speaker
32
+ # name. "same" means "same as the turn just before": if you did not receive
33
+ # the turn with the previous turn number (turn_id "<slug>-<n-1>"), treat it
34
+ # as "unknown". Measured on two calls: every real speaker change was marked
35
+ # "new" (104 of 104) — counting the changes that fell on a turn boundary; Muse merged 3 of TTWO's 76 into the previous turn, and those carry no mark —, so a "same" mark was never wrong (176 of 176); "new"
36
+ # was right about 94% of the time on turns of more than 5 words (101 of
37
+ # 108), but only 3 of 17 on turns of 5 words or fewer.
38
+ SpeakerChange = Literal["same", "new", "unknown"]
39
+
40
+
41
+ @dataclass
42
+ class AttributionEvent:
43
+ """A finalized speaker turn with a confident attribution."""
44
+
45
+ turn_id: str
46
+ name: str
47
+ source: str # always "live-earnings" — internal pipeline labels stripped
48
+ text: Optional[str] = None
49
+ phase: Optional[Phase] = None
50
+ stt_speaker_change: SpeakerChange = "unknown"
51
+ raw: dict = field(default_factory=dict, repr=False)
52
+ type: Literal["attribution"] = "attribution"
53
+
54
+
55
+ @dataclass
56
+ class UnknownEvent:
57
+ """A finalized turn with no speaker name (yet).
58
+
59
+ naming == "pending": published before its name arrived; the name follows
60
+ as a TurnSpeakerAmendEvent with by == "model" (new_name "unknown" when it
61
+ could not be named). Otherwise the turn simply could not be attributed.
62
+ """
63
+
64
+ turn_id: str
65
+ text: Optional[str] = None
66
+ phase: Optional[Phase] = None
67
+ stt_speaker_change: SpeakerChange = "unknown"
68
+ naming: Optional[Literal["pending"]] = None
69
+ raw: dict = field(default_factory=dict, repr=False)
70
+ type: Literal["unknown"] = "unknown"
71
+
72
+
73
+ @dataclass
74
+ class PhaseTransitionEvent:
75
+ """The call moved from one phase (e.g., CFO remarks) to the next (Q&A)."""
76
+
77
+ from_phase: Phase
78
+ to_phase: Phase
79
+ trigger_turn_id: str
80
+ raw: dict = field(default_factory=dict, repr=False)
81
+ type: Literal["phase_transition"] = "phase_transition"
82
+
83
+
84
+ @dataclass
85
+ class DoneEvent:
86
+ """The call has ended; no more envelopes will follow."""
87
+
88
+ raw: dict = field(default_factory=dict, repr=False)
89
+ type: Literal["done"] = "done"
90
+
91
+
92
+ @dataclass
93
+ class AiInsightEvent:
94
+ """Deprecated: AI insights are withdrawn while they are rebuilt, and the API
95
+ does not currently send this event. Kept so code written against it runs."""
96
+
97
+ turn_id: str
98
+ insight: str
99
+ category: str # e.g. "guidance", "cfo_numbers"; new values may appear
100
+ frame: Optional[str] = None
101
+ watch_for: Optional[str] = None
102
+ raw: dict = field(default_factory=dict, repr=False)
103
+ type: Literal["ai_insight"] = "ai_insight"
104
+
105
+
106
+ @dataclass
107
+ class TurnSpeakerAmendEvent:
108
+ """A published turn's speaker, set or corrected after publish: show
109
+ `new_name` for that turn_id, at the turn's original position.
110
+
111
+ by == "model": the model's late name (new_name "unknown" = could not be
112
+ named). by None or "operator": an operator's correction, which always
113
+ wins over the model's whatever order they arrive in.
114
+
115
+ basis == "early": the model's full-text naming failed; this is the
116
+ turn's early, first-sentence name, kept. Show it as provisional."""
117
+
118
+ turn_id: str
119
+ new_name: str
120
+ reason: Optional[str] = None
121
+ by: Optional[Literal["model", "operator"]] = None
122
+ basis: Optional[Literal["early"]] = None
123
+ raw: dict = field(default_factory=dict, repr=False)
124
+ type: Literal["turn_speaker_amend"] = "turn_speaker_amend"
125
+
126
+
127
+ AnyEvent = Union[
128
+ AttributionEvent,
129
+ UnknownEvent,
130
+ PhaseTransitionEvent,
131
+ DoneEvent,
132
+ AiInsightEvent,
133
+ TurnSpeakerAmendEvent,
134
+ ]
135
+
136
+
137
+ @dataclass
138
+ class EventEnvelope:
139
+ """SSE envelope wrapping one event.
140
+
141
+ `seq` increases per (tenant, call), but numbers are skipped (internal
142
+ events are not sent): a missing number is not a lost event.
143
+ """
144
+
145
+ tenant_id: str
146
+ call_slug: str
147
+ seq: int
148
+ published_at: str # ISO-8601 UTC
149
+ event: AnyEvent
150
+ raw: dict = field(default_factory=dict, repr=False)
151
+
152
+
153
+ @dataclass
154
+ class Participant:
155
+ name: str
156
+ title: Optional[str] = None
157
+ avatar_url: Optional[str] = None
158
+ role: Optional[Literal["ceo", "cfo", "ir"]] = None
159
+ brain_slug: Optional[str] = None
160
+
161
+
162
+ @dataclass
163
+ class CoveringAnalyst:
164
+ name: str
165
+ firm: Optional[str] = None
166
+ firm_domain: Optional[str] = None
167
+
168
+
169
+ @dataclass
170
+ class Roster:
171
+ ceo: Optional[str]
172
+ cfo: Optional[str]
173
+ ir: Optional[str]
174
+ extras: list[str] = field(default_factory=list)
175
+
176
+
177
+ @dataclass
178
+ class CallMeta:
179
+ call_slug: str
180
+ tenant_id: str
181
+ ticker: Optional[str]
182
+ fiscal_period: Optional[str]
183
+ status: Literal["scheduled", "live", "ended", "unknown"]
184
+ elapsed_sec: Optional[int]
185
+ roster: Optional[Roster]
186
+ participants: list[Participant] = field(default_factory=list)
187
+ covering_analysts: list[CoveringAnalyst] = field(default_factory=list)
188
+ roster_source: Optional[Literal["db", "none", "error"]] = None
189
+ roster_updated_at: Optional[str] = None
190
+ # Operator-auth responses only (never sent to subscribers):
191
+ stt_engine: Optional[str] = None # "aai" | "muse" | a raw invalid value
192
+ stt_engine_source: Optional[Literal["row", "default", "invalid"]] = None
193
+ roster_problems: Optional[list[str]] = None
194
+ operator_audio_url: Optional[str] = None
195
+ raw: dict = field(default_factory=dict, repr=False)
196
+
197
+
198
+ def parse_event(d: dict) -> AnyEvent:
199
+ """Decode the inner `event` dict from a /stream envelope."""
200
+ t = d.get("type")
201
+ if t == "attribution":
202
+ return AttributionEvent(
203
+ turn_id=d["turnId"],
204
+ name=d["name"],
205
+ source=d.get("source", "live-earnings"),
206
+ text=d.get("text"),
207
+ phase=d.get("phase"),
208
+ stt_speaker_change=d.get("sttSpeakerChange", "unknown"),
209
+ raw=d,
210
+ )
211
+ if t == "unknown":
212
+ return UnknownEvent(
213
+ turn_id=d["turnId"],
214
+ text=d.get("text"),
215
+ phase=d.get("phase"),
216
+ stt_speaker_change=d.get("sttSpeakerChange", "unknown"),
217
+ naming=d.get("naming"),
218
+ raw=d,
219
+ )
220
+ if t == "ai_insight":
221
+ return AiInsightEvent(
222
+ turn_id=d["turnId"],
223
+ insight=d["insight"],
224
+ category=d.get("category", "general"),
225
+ frame=d.get("frame"),
226
+ watch_for=d.get("watchFor"),
227
+ raw=d,
228
+ )
229
+ if t == "turn_speaker_amend":
230
+ return TurnSpeakerAmendEvent(
231
+ turn_id=d["turnId"],
232
+ new_name=d["newName"],
233
+ reason=d.get("reason"),
234
+ by=d.get("by"),
235
+ basis=d.get("basis"),
236
+ raw=d,
237
+ )
238
+ if t == "phase_transition":
239
+ return PhaseTransitionEvent(
240
+ from_phase=d["fromPhase"],
241
+ to_phase=d["toPhase"],
242
+ trigger_turn_id=d["triggerTurnId"],
243
+ raw=d,
244
+ )
245
+ if t == "done":
246
+ return DoneEvent(raw=d)
247
+ # Forward-compatible: unknown event types degrade to UnknownEvent
248
+ # rather than throw. The full dict is preserved on `.raw`.
249
+ return UnknownEvent(turn_id=d.get("turnId", ""), text=None, raw=d)
250
+
251
+
252
+ def parse_envelope(d: dict) -> EventEnvelope:
253
+ return EventEnvelope(
254
+ tenant_id=d["tenantId"],
255
+ call_slug=d["callSlug"],
256
+ seq=d["seq"],
257
+ published_at=d["publishedAt"],
258
+ event=parse_event(d["event"]),
259
+ raw=d,
260
+ )
261
+
262
+
263
+ def parse_meta(d: dict) -> CallMeta:
264
+ roster_raw = d.get("roster")
265
+ roster = (
266
+ Roster(
267
+ ceo=roster_raw.get("ceo"),
268
+ cfo=roster_raw.get("cfo"),
269
+ ir=roster_raw.get("ir"),
270
+ extras=list(roster_raw.get("extras", [])),
271
+ )
272
+ if roster_raw
273
+ else None
274
+ )
275
+ return CallMeta(
276
+ call_slug=d["callSlug"],
277
+ tenant_id=d["tenantId"],
278
+ ticker=d.get("ticker"),
279
+ fiscal_period=d.get("fiscalPeriod"),
280
+ status=d.get("status", "unknown"),
281
+ elapsed_sec=d.get("elapsedSec"),
282
+ roster=roster,
283
+ participants=[
284
+ Participant(
285
+ name=p["name"],
286
+ title=p.get("title"),
287
+ avatar_url=p.get("avatarUrl"),
288
+ role=p.get("role"),
289
+ brain_slug=p.get("brainSlug"),
290
+ )
291
+ for p in d.get("participants", [])
292
+ ],
293
+ covering_analysts=[
294
+ CoveringAnalyst(
295
+ name=a["name"],
296
+ firm=a.get("firm"),
297
+ firm_domain=a.get("firmDomain"),
298
+ )
299
+ for a in d.get("coveringAnalysts", [])
300
+ ],
301
+ roster_source=d.get("rosterSource"),
302
+ roster_updated_at=d.get("rosterUpdatedAt"),
303
+ stt_engine=d.get("sttEngine"),
304
+ stt_engine_source=d.get("sttEngineSource"),
305
+ roster_problems=d.get("rosterProblems"),
306
+ operator_audio_url=d.get("operatorAudioUrl"),
307
+ raw=d,
308
+ )
309
+
310
+
311
+ def _decode_any(d: Any) -> Any:
312
+ """Reserved for future shape evolution."""
313
+ return d
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: live-earnings
3
+ Version: 0.4.0
4
+ Summary: Live Earnings — live, attributed earnings-call transcripts.
5
+ Author: Live Earnings
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://live-earnings.com/live/docs
8
+ Project-URL: Documentation, https://live-earnings.com/live/docs
9
+ Project-URL: Source, https://live-earnings.com/live/docs
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: requests>=2.31
13
+
14
+ # live-earnings
15
+
16
+ Python client for the Live Earnings API: live earnings-call transcripts, each
17
+ turn's text as it is spoken and then who said it. Python 3.9+.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install live-earnings
23
+ ```
24
+
25
+ ## Quickstart
26
+
27
+ Your API key (`ek_…`) covers one call; copy it from the call's **API Access**
28
+ tab on https://live-earnings.com/live. Run with `LE_KEY=ek_… SLUG=<call-slug>`:
29
+
30
+ ```python
31
+ import os
32
+
33
+ from live_earnings import LiveEarningsClient, Transcript
34
+
35
+ client = LiveEarningsClient(token=os.environ["LE_KEY"])
36
+ slug = os.environ["SLUG"]
37
+ transcript = Transcript()
38
+
39
+
40
+ def show(turn):
41
+ if turn:
42
+ print(f"{turn.name or '…'}: {turn.text or ''}")
43
+
44
+
45
+ # Everything so far, then live until the call ends.
46
+ last = None # the last seq seen; None = start the stream from now
47
+ ended = False
48
+ for env in client.events(slug):
49
+ show(transcript.apply(env))
50
+ last = env.seq
51
+ ended = ended or env.event.type == "done"
52
+ if not ended:
53
+ for env in client.stream(slug, last_event_id=last):
54
+ if env.event.type == "done":
55
+ break
56
+ show(transcript.apply(env))
57
+ ```
58
+
59
+ A turn prints once when its text arrives (`…: text`) and again when its
60
+ speaker is named. `Transcript` applies the rules: a person's correction beats
61
+ the model's name, `new_name == "unknown"` leaves a turn unnamed,
62
+ `basis == "early"` marks a provisional name, and a repeated envelope is
63
+ ignored. `apply()` returns the turn itself, updated in place as later
64
+ events arrive.
65
+
66
+ ## The client
67
+
68
+ | | |
69
+ |---|---|
70
+ | `meta(slug)` | the call: ticker, fiscal period, status, roster |
71
+ | `events(slug, from_=None, to=None, limit=None)` | every stored envelope, following pagination |
72
+ | `stream(slug, last_event_id=None)` | live envelopes; raises if the connection goes silent for 90 s — resume with the last `seq` you saw |
73
+ | `session_token(slug)` | a `SessionToken` (`session_token`, `expires_in`) for a browser `EventSource` (`?t=`) — call it on your server |
74
+
75
+ Hosts default to `https://live-earnings.com` (REST) and
76
+ `https://stream.live-earnings.com` (streams); override with `base_url` and
77
+ `stream_url`. Both must be `https://` unless you pass `allow_insecure=True` for
78
+ local development. Errors raise `LiveEarningsError` (`status`, `body`).
79
+
80
+ Full reference: https://live-earnings.com/live/docs
81
+
82
+ ## Changes
83
+
84
+ **0.4.0** — Default hosts (`base_url` is optional; streams go to the stream
85
+ host). `Transcript` folds events into turns. `session_token()`. `events()`
86
+ takes an optional range. Streams deliver each message as it arrives (they
87
+ were buffered in 512-byte reads). `AiInsightEvent` is deprecated — the API
88
+ does not currently send it.
89
+
90
+ **0.3.0** — `UnknownEvent.naming == "pending"` (the name follows as a
91
+ `TurnSpeakerAmendEvent`); amends carry `by` and may carry `basis == "early"`.
92
+
93
+ **0.2.0** — `stt_speaker_change` on `AttributionEvent` and `UnknownEvent`;
94
+ `ai_insight` and `turn_speaker_amend` decode to their own classes.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ live_earnings/__init__.py
4
+ live_earnings/client.py
5
+ live_earnings/transcript.py
6
+ live_earnings/types.py
7
+ live_earnings.egg-info/PKG-INFO
8
+ live_earnings.egg-info/SOURCES.txt
9
+ live_earnings.egg-info/dependency_links.txt
10
+ live_earnings.egg-info/requires.txt
11
+ live_earnings.egg-info/top_level.txt
12
+ tests/test_sdk.py
@@ -0,0 +1 @@
1
+ requests>=2.31
@@ -0,0 +1 @@
1
+ live_earnings
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "live-earnings"
7
+ version = "0.4.0"
8
+ description = "Live Earnings — live, attributed earnings-call transcripts."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "Live Earnings" }]
13
+ dependencies = ["requests>=2.31"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://live-earnings.com/live/docs"
17
+ Documentation = "https://live-earnings.com/live/docs"
18
+ Source = "https://live-earnings.com/live/docs"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["."]
22
+ include = ["live_earnings*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,194 @@
1
+ """Python SDK: hosts, the https guard, and the Transcript fold.
2
+
3
+ Run: cd sdks/python && python3 -m unittest discover -s tests
4
+ """
5
+
6
+ import unittest
7
+
8
+ from live_earnings import DEFAULT_BASE_URL, DEFAULT_STREAM_URL, LiveEarningsClient, Transcript
9
+ from live_earnings.types import parse_envelope
10
+
11
+
12
+ def env(seq, event):
13
+ return parse_envelope({"tenantId": "t", "callSlug": "s", "seq": seq, "publishedAt": "1970-01-01T00:00:00Z", "event": event})
14
+
15
+
16
+ def unk(n, text=None):
17
+ return {"type": "unknown", "turnId": f"s-{n}", "text": text or f"turn {n} text", "sttSpeakerChange": "new", "naming": "pending"}
18
+
19
+
20
+ def amend(n, name, **extra):
21
+ return {"type": "turn_speaker_amend", "turnId": f"s-{n}", "newName": name, **extra}
22
+
23
+
24
+ def attribution(n, name, text="final"):
25
+ return {"type": "attribution", "turnId": f"s-{n}", "name": name, "source": "live-earnings", "text": text, "sttSpeakerChange": "same"}
26
+
27
+
28
+ class Hosts(unittest.TestCase):
29
+ def test_defaults_to_the_two_production_hosts(self):
30
+ self.assertEqual(DEFAULT_BASE_URL, "https://live-earnings.com")
31
+ self.assertEqual(DEFAULT_STREAM_URL, "https://stream.live-earnings.com")
32
+ c = LiveEarningsClient(token="ek_test")
33
+ self.assertEqual(c._base, "https://live-earnings.com")
34
+ self.assertEqual(c._stream_base, "https://stream.live-earnings.com")
35
+
36
+ def test_refuses_http_unless_allowed(self):
37
+ with self.assertRaises(ValueError):
38
+ LiveEarningsClient(token="t", base_url="http://live-earnings.com")
39
+ with self.assertRaises(ValueError):
40
+ LiveEarningsClient(token="t", stream_url="http://x.test")
41
+ with self.assertRaises(ValueError):
42
+ LiveEarningsClient(token="t", base_url="not a url")
43
+ LiveEarningsClient(token="t", base_url="http://localhost:3000", stream_url="http://localhost:3000", allow_insecure=True)
44
+
45
+
46
+ class _FakeResponse:
47
+ def __init__(self, chunks):
48
+ self._chunks = chunks
49
+
50
+ def iter_content(self, chunk_size=None):
51
+ yield from self._chunks
52
+
53
+
54
+ class StreamLines(unittest.TestCase):
55
+ def test_lines_split_on_newline_only_as_utf8(self):
56
+ from live_earnings.client import _sse_lines
57
+
58
+ # A CRLF split across chunks, a multi-byte character split across chunks,
59
+ # and U+2028 inside a JSON string (str.splitlines would break there).
60
+ text = 'data: {"text":"a
b — ’"}\r\n\r\n: hb\n'.encode("utf-8")
61
+ chunks = [text[:26], text[26:27], text[27:40], text[40:]]
62
+ self.assertEqual(list(_sse_lines(_FakeResponse(chunks))), ['data: {"text":"a
b — ’"}', "", ": hb"])
63
+
64
+ def test_session_token_is_a_snake_case_dataclass(self):
65
+ from live_earnings import SessionToken
66
+
67
+ c = LiveEarningsClient(token="t")
68
+
69
+ class R:
70
+ status_code = 200
71
+
72
+ def json(self):
73
+ return {"sessionToken": "abc", "expiresIn": 7200}
74
+
75
+ c._session.post = lambda *a, **k: R()
76
+ self.assertEqual(c.session_token("s"), SessionToken(session_token="abc", expires_in=7200))
77
+
78
+
79
+ class _Json:
80
+ status_code = 200
81
+
82
+ def __init__(self, body):
83
+ self._body = body
84
+
85
+ def json(self):
86
+ return self._body
87
+
88
+
89
+ class Paging(unittest.TestCase):
90
+ def test_keeps_paging_past_an_empty_page_while_next_from_says_there_is_more(self):
91
+ pages = {None: {"events": [], "nextFrom": 1001}, 1001: {"events": [{"tenantId": "t", "callSlug": "s", "seq": 1001, "publishedAt": "x", "event": unk(1)}], "nextFrom": None}}
92
+ c = LiveEarningsClient(token="t")
93
+ c._session.get = lambda url, params=None, **k: _Json(pages[params.get("from")])
94
+ self.assertEqual([e.seq for e in c.events("s")], [1001])
95
+
96
+ def test_refuses_a_next_from_that_does_not_advance(self):
97
+ from live_earnings import LiveEarningsError
98
+
99
+ calls = []
100
+
101
+ def get(url, params=None, **k):
102
+ calls.append(params)
103
+ if len(calls) > 3:
104
+ raise AssertionError("paged forever on a nextFrom that never advances")
105
+ return _Json({"events": [], "nextFrom": 5})
106
+
107
+ c = LiveEarningsClient(token="t")
108
+ c._session.get = get
109
+ with self.assertRaises(LiveEarningsError):
110
+ list(c.events("s", from_=5))
111
+
112
+
113
+ class StreamFailures(unittest.TestCase):
114
+ def test_sets_a_read_timeout_and_raises_the_sdk_error_when_it_fires(self):
115
+ import requests
116
+
117
+ from live_earnings import LiveEarningsError
118
+ from live_earnings.client import STREAM_IDLE_TIMEOUT
119
+
120
+ seen = {}
121
+
122
+ def get(url, **k):
123
+ seen.update(k)
124
+ raise requests.exceptions.ConnectionError("Read timed out.")
125
+
126
+ c = LiveEarningsClient(token="t")
127
+ c._session.get = get
128
+ with self.assertRaises(LiveEarningsError):
129
+ list(c.stream("s"))
130
+ self.assertEqual(seen["timeout"], (10, STREAM_IDLE_TIMEOUT))
131
+ self.assertNotIn("Last-Event-ID", seen["headers"])
132
+
133
+
134
+ class TranscriptFold(unittest.TestCase):
135
+ def test_text_first_then_the_models_name(self):
136
+ t = Transcript()
137
+ first = t.apply(env(1, unk(1)))
138
+ self.assertEqual((first.text, first.name), ("turn 1 text", None))
139
+ named = t.apply(env(2, amend(1, "Colette Kress", by="model")))
140
+ self.assertEqual((named.name, named.named_by, named.provisional), ("Colette Kress", "model", False))
141
+
142
+ def test_a_persons_correction_beats_the_model_in_either_order(self):
143
+ a = Transcript()
144
+ a.apply(env(1, unk(1)))
145
+ a.apply(env(2, amend(1, "Jensen Huang"))) # by absent = operator
146
+ self.assertIsNone(a.apply(env(3, amend(1, "Colette Kress", by="model"))))
147
+ self.assertEqual(a.get("s-1").name, "Jensen Huang")
148
+ b = Transcript()
149
+ b.apply(env(1, unk(1)))
150
+ b.apply(env(2, amend(1, "Colette Kress", by="model")))
151
+ b.apply(env(3, amend(1, "Jensen Huang", by="operator")))
152
+ self.assertEqual((b.get("s-1").name, b.get("s-1").named_by), ("Jensen Huang", "operator"))
153
+
154
+ def test_unknown_name_and_early_basis(self):
155
+ t = Transcript()
156
+ t.apply(env(1, unk(1)))
157
+ self.assertIsNone(t.apply(env(2, amend(1, "unknown", by="model"))).name)
158
+ t.apply(env(3, unk(2)))
159
+ self.assertTrue(t.apply(env(4, amend(2, "Colette Kress", by="model", basis="early"))).provisional)
160
+
161
+ def test_attribution_sets_both_and_clears_provisional_but_not_over_a_correction(self):
162
+ t = Transcript()
163
+ t.apply(env(1, unk(1)))
164
+ t.apply(env(2, amend(1, "Colette Kress", by="model", basis="early")))
165
+ t.apply(env(3, attribution(1, "Jensen Huang")))
166
+ turn = t.get("s-1")
167
+ self.assertEqual((turn.name, turn.named_by, turn.provisional, turn.text), ("Jensen Huang", "attribution", False, "final"))
168
+ t.apply(env(4, amend(1, "Stacy Rasgon")))
169
+ t.apply(env(5, attribution(1, "Jensen Huang")))
170
+ self.assertEqual(t.get("s-1").name, "Stacy Rasgon")
171
+
172
+ def test_a_repeated_envelope_is_ignored(self):
173
+ t = Transcript()
174
+ t.apply(env(1, unk(1)))
175
+ self.assertIsNone(t.apply(env(1, unk(1, "different"))))
176
+ self.assertEqual(t.get("s-1").text, "turn 1 text")
177
+
178
+ def test_a_name_before_its_text_and_turns_in_spoken_order(self):
179
+ t = Transcript()
180
+ t.apply(env(5, amend(10, "Colette Kress", by="model")))
181
+ t.apply(env(6, unk(10)))
182
+ t.apply(env(7, unk(2)))
183
+ self.assertEqual((t.get("s-10").name, t.get("s-10").text), ("Colette Kress", "turn 10 text"))
184
+ self.assertEqual([x.turn_id for x in t.turns], ["s-2", "s-10"])
185
+
186
+ def test_other_events_change_nothing(self):
187
+ t = Transcript()
188
+ self.assertIsNone(t.apply(env(1, {"type": "phase_transition", "fromPhase": "ir_intro", "toPhase": "qa", "triggerTurnId": "s-1"})))
189
+ self.assertIsNone(t.apply(env(2, {"type": "done"})))
190
+ self.assertEqual(t.turns, [])
191
+
192
+
193
+ if __name__ == "__main__":
194
+ unittest.main()