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.
- live_earnings-0.4.0/PKG-INFO +94 -0
- live_earnings-0.4.0/README.md +81 -0
- live_earnings-0.4.0/live_earnings/__init__.py +53 -0
- live_earnings-0.4.0/live_earnings/client.py +295 -0
- live_earnings-0.4.0/live_earnings/transcript.py +89 -0
- live_earnings-0.4.0/live_earnings/types.py +313 -0
- live_earnings-0.4.0/live_earnings.egg-info/PKG-INFO +94 -0
- live_earnings-0.4.0/live_earnings.egg-info/SOURCES.txt +12 -0
- live_earnings-0.4.0/live_earnings.egg-info/dependency_links.txt +1 -0
- live_earnings-0.4.0/live_earnings.egg-info/requires.txt +1 -0
- live_earnings-0.4.0/live_earnings.egg-info/top_level.txt +1 -0
- live_earnings-0.4.0/pyproject.toml +22 -0
- live_earnings-0.4.0/setup.cfg +4 -0
- live_earnings-0.4.0/tests/test_sdk.py +194 -0
|
@@ -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
|
+
|
|
@@ -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,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()
|