filingstudio 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- filingstudio/__init__.py +48 -0
- filingstudio/client.py +333 -0
- filingstudio/errors.py +38 -0
- filingstudio/models.py +194 -0
- filingstudio/research.py +411 -0
- filingstudio-0.4.0.dist-info/METADATA +132 -0
- filingstudio-0.4.0.dist-info/RECORD +9 -0
- filingstudio-0.4.0.dist-info/WHEEL +4 -0
- filingstudio-0.4.0.dist-info/licenses/LICENSE +21 -0
filingstudio/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Filing Studio client: search SEC filings, resolve traces, verify claims."""
|
|
2
|
+
|
|
3
|
+
from . import research
|
|
4
|
+
from .client import AsyncFilingStudio, FilingStudio
|
|
5
|
+
from .errors import FilingStudioError, NotConfigured, RateLimitError
|
|
6
|
+
from .models import (
|
|
7
|
+
ContextRow,
|
|
8
|
+
CoverageResult,
|
|
9
|
+
EvidenceLinks,
|
|
10
|
+
EvidenceSource,
|
|
11
|
+
FilingRef,
|
|
12
|
+
FilingsResult,
|
|
13
|
+
IndexState,
|
|
14
|
+
Pagination,
|
|
15
|
+
SearchCell,
|
|
16
|
+
SearchPassage,
|
|
17
|
+
SearchResult,
|
|
18
|
+
TableResult,
|
|
19
|
+
TraceDetail,
|
|
20
|
+
VerifyReceipt,
|
|
21
|
+
VerifyResult,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.4.0"
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AsyncFilingStudio",
|
|
27
|
+
"ContextRow",
|
|
28
|
+
"CoverageResult",
|
|
29
|
+
"EvidenceLinks",
|
|
30
|
+
"EvidenceSource",
|
|
31
|
+
"FilingRef",
|
|
32
|
+
"FilingStudio",
|
|
33
|
+
"FilingStudioError",
|
|
34
|
+
"FilingsResult",
|
|
35
|
+
"IndexState",
|
|
36
|
+
"NotConfigured",
|
|
37
|
+
"Pagination",
|
|
38
|
+
"RateLimitError",
|
|
39
|
+
"SearchCell",
|
|
40
|
+
"SearchPassage",
|
|
41
|
+
"SearchResult",
|
|
42
|
+
"TableResult",
|
|
43
|
+
"TraceDetail",
|
|
44
|
+
"VerifyReceipt",
|
|
45
|
+
"VerifyResult",
|
|
46
|
+
"__version__",
|
|
47
|
+
"research",
|
|
48
|
+
]
|
filingstudio/client.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Filing Studio client, sync and async. One method per /v1 door, the same
|
|
3
|
+
names as the Node `@filingstudio/client` and the browser `ProvenanceClient`.
|
|
4
|
+
|
|
5
|
+
from filingstudio import FilingStudio
|
|
6
|
+
fs = FilingStudio(api_key=os.environ["FILING_STUDIO_API_KEY"])
|
|
7
|
+
hits = fs.search("NVDA", "purchase commitments", type="prose")
|
|
8
|
+
v = fs.verify("NVDA", metric="Revenue", value=130497, period="FY2025")
|
|
9
|
+
|
|
10
|
+
The key travels only in the X-API-Key header, never in a URL, never in an
|
|
11
|
+
error message. 5xx and transport failures are retried with backoff; 4xx are
|
|
12
|
+
not. Pass `value` to verify as the raw figure you hold: the API tries every
|
|
13
|
+
printed scale a filer could use.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import random
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
import anyio
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from .errors import FilingStudioError, NotConfigured, RateLimitError
|
|
27
|
+
from .models import (
|
|
28
|
+
CoverageResult,
|
|
29
|
+
FilingsResult,
|
|
30
|
+
IndexState,
|
|
31
|
+
SearchResult,
|
|
32
|
+
TableResult,
|
|
33
|
+
TraceDetail,
|
|
34
|
+
VerifyResult,
|
|
35
|
+
parse_hit,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
DEFAULT_BASE = "https://api.filingstudio.com"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clean(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
42
|
+
return {k: v for k, v in params.items() if v is not None and v != ""}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _describe(status: int, body: Any) -> "tuple[str, Optional[str]]":
|
|
46
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
47
|
+
if isinstance(err, str):
|
|
48
|
+
return err, None
|
|
49
|
+
if isinstance(err, dict):
|
|
50
|
+
msg = err.get("message") if isinstance(err.get("message"), str) else f"HTTP {status}"
|
|
51
|
+
code = err.get("code") if isinstance(err.get("code"), str) else None
|
|
52
|
+
return msg, code
|
|
53
|
+
return f"HTTP {status}", None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _raise_for(status: int, body: Any) -> None:
|
|
57
|
+
"""Map a non-2xx answer to an exception. Never touches the key."""
|
|
58
|
+
if status == 429:
|
|
59
|
+
st = body.get("indexState") if isinstance(body, dict) else None
|
|
60
|
+
index_state = IndexState.model_validate(st) if isinstance(st, dict) else IndexState(
|
|
61
|
+
coverage="unavailable", note=_describe(429, body)[0]
|
|
62
|
+
)
|
|
63
|
+
raise RateLimitError(index_state, body)
|
|
64
|
+
msg, code = _describe(status, body)
|
|
65
|
+
raise FilingStudioError(status, msg, code, body)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Base:
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
api_key: Optional[str],
|
|
72
|
+
*,
|
|
73
|
+
base_url: Optional[str] = None,
|
|
74
|
+
timeout: float = 30.0,
|
|
75
|
+
max_retries: int = 2,
|
|
76
|
+
) -> None:
|
|
77
|
+
key = (api_key or "").strip()
|
|
78
|
+
if not key:
|
|
79
|
+
raise NotConfigured()
|
|
80
|
+
self._key = key
|
|
81
|
+
self._base = (base_url or DEFAULT_BASE).rstrip("/")
|
|
82
|
+
self._timeout = timeout
|
|
83
|
+
self._max_retries = max(0, int(max_retries))
|
|
84
|
+
|
|
85
|
+
def _headers(self, json_body: bool) -> Dict[str, str]:
|
|
86
|
+
h = {"X-API-Key": self._key, "Accept": "application/json"}
|
|
87
|
+
if json_body:
|
|
88
|
+
h["Content-Type"] = "application/json"
|
|
89
|
+
return h
|
|
90
|
+
|
|
91
|
+
def _url(self, path: str) -> str:
|
|
92
|
+
return f"{self._base}{path}"
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _backoff(attempt: int) -> float:
|
|
96
|
+
return 0.2 * (2 ** (attempt - 1)) + random.random() * 0.1
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _parse(res: httpx.Response) -> Any:
|
|
100
|
+
try:
|
|
101
|
+
return res.json()
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _unreachable(exc: Optional[BaseException]) -> FilingStudioError:
|
|
107
|
+
reason = type(exc).__name__ if exc else "network error"
|
|
108
|
+
return FilingStudioError(0, f"Filing Studio could not be reached ({reason}).", "unreachable")
|
|
109
|
+
|
|
110
|
+
# ---- result shaping, shared by sync and async ---------------------------
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _search(body: Any, default_type: str) -> SearchResult:
|
|
114
|
+
env = body if isinstance(body, dict) else {}
|
|
115
|
+
data = env.get("data") or {}
|
|
116
|
+
return SearchResult(
|
|
117
|
+
type=data.get("type") or default_type,
|
|
118
|
+
results=[parse_hit(h) for h in (data.get("results") or [])],
|
|
119
|
+
indexState=env.get("indexState") or {},
|
|
120
|
+
pagination=env.get("pagination"),
|
|
121
|
+
note=env.get("note"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _verify(body: Any) -> VerifyResult:
|
|
126
|
+
env = body if isinstance(body, dict) else {}
|
|
127
|
+
data = dict(env.get("data") or {})
|
|
128
|
+
data["indexState"] = env.get("indexState") or {}
|
|
129
|
+
return VerifyResult.model_validate(data)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _trace(body: Any) -> Optional[TraceDetail]:
|
|
133
|
+
env = body if isinstance(body, dict) else {}
|
|
134
|
+
traces = (env.get("data") or {}).get("traces") or []
|
|
135
|
+
return TraceDetail.model_validate(traces[0]) if traces else None
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _filings(body: Any, ticker: str) -> FilingsResult:
|
|
139
|
+
env = body if isinstance(body, dict) else {}
|
|
140
|
+
data = env.get("data") or {}
|
|
141
|
+
return FilingsResult(
|
|
142
|
+
ticker=data.get("ticker") or ticker,
|
|
143
|
+
filings=data.get("filings") or [],
|
|
144
|
+
indexState=env.get("indexState") or {},
|
|
145
|
+
pagination=env.get("pagination"),
|
|
146
|
+
note=env.get("note"),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _coverage(body: Any, ticker: str) -> CoverageResult:
|
|
151
|
+
env = body if isinstance(body, dict) else {}
|
|
152
|
+
data = dict(env.get("data") or {})
|
|
153
|
+
data.setdefault("ticker", ticker)
|
|
154
|
+
data["note"] = env.get("note")
|
|
155
|
+
return CoverageResult.model_validate(data)
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _table(body: Any) -> TableResult:
|
|
159
|
+
env = body if isinstance(body, dict) else {}
|
|
160
|
+
return TableResult(
|
|
161
|
+
data=env.get("data") or {},
|
|
162
|
+
provenance=env.get("provenance") or {},
|
|
163
|
+
links=env.get("links") or {},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# ---- paths ----------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _search_params(ticker, q, type, period, forms, limit, offset) -> Dict[str, Any]:
|
|
170
|
+
return _clean({"ticker": ticker.upper(), "q": q, "type": type, "period": period,
|
|
171
|
+
"forms": forms, "limit": limit, "offset": offset})
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _trace_path(trace_id: str, include_context: bool) -> str:
|
|
175
|
+
return f"/v1/trace/{quote(trace_id, safe='')}" + ("?include=context" if include_context else "")
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _table_path(ticker: str, accession: str, table_id: str) -> str:
|
|
179
|
+
return (f"/v1/tables/{quote(ticker.upper(), safe='')}/{quote(accession, safe='')}/"
|
|
180
|
+
f"{quote(table_id, safe='')}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class FilingStudio(_Base):
|
|
184
|
+
"""Synchronous client. Use as a context manager to reuse one connection."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, api_key: Optional[str], *, base_url: Optional[str] = None,
|
|
187
|
+
timeout: float = 30.0, max_retries: int = 2,
|
|
188
|
+
transport: Optional[httpx.BaseTransport] = None) -> None:
|
|
189
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
|
|
190
|
+
self._http = httpx.Client(timeout=timeout, transport=transport)
|
|
191
|
+
|
|
192
|
+
def close(self) -> None:
|
|
193
|
+
self._http.close()
|
|
194
|
+
|
|
195
|
+
def __enter__(self) -> "FilingStudio":
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def __exit__(self, *exc: object) -> None:
|
|
199
|
+
self.close()
|
|
200
|
+
|
|
201
|
+
def _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
|
202
|
+
json: Any = None) -> Any:
|
|
203
|
+
last: Optional[BaseException] = None
|
|
204
|
+
for attempt in range(self._max_retries + 1):
|
|
205
|
+
if attempt:
|
|
206
|
+
time.sleep(self._backoff(attempt))
|
|
207
|
+
try:
|
|
208
|
+
res = self._http.request(method, self._url(path), params=params, json=json,
|
|
209
|
+
headers=self._headers(json is not None))
|
|
210
|
+
except httpx.TransportError as exc:
|
|
211
|
+
last = exc
|
|
212
|
+
continue
|
|
213
|
+
body = self._parse(res)
|
|
214
|
+
if res.is_success:
|
|
215
|
+
return body if body is not None else {}
|
|
216
|
+
if res.status_code >= 500:
|
|
217
|
+
last = FilingStudioError(res.status_code, _describe(res.status_code, body)[0], None, body)
|
|
218
|
+
continue
|
|
219
|
+
_raise_for(res.status_code, body)
|
|
220
|
+
if isinstance(last, FilingStudioError):
|
|
221
|
+
raise last
|
|
222
|
+
raise self._unreachable(last)
|
|
223
|
+
|
|
224
|
+
def search(self, ticker: str, q: str, *, type: Optional[str] = None, period: Optional[str] = None,
|
|
225
|
+
forms: Optional[str] = None, limit: Optional[int] = None,
|
|
226
|
+
offset: Optional[int] = None) -> SearchResult:
|
|
227
|
+
"""Search a company's printed rows, tables, and prose by plain words."""
|
|
228
|
+
body = self._request("GET", "/v1/search",
|
|
229
|
+
params=self._search_params(ticker, q, type, period, forms, limit, offset))
|
|
230
|
+
return self._search(body, type or "all")
|
|
231
|
+
|
|
232
|
+
def verify(self, ticker: str, *, metric: Optional[str] = None, value: Optional[float] = None,
|
|
233
|
+
period: Optional[str] = None, claim: Optional[str] = None) -> VerifyResult:
|
|
234
|
+
"""Check a claim against what the filings print. Deterministic."""
|
|
235
|
+
payload = _clean({"ticker": ticker.upper(), "metric": metric, "value": value,
|
|
236
|
+
"period": period, "claim": claim})
|
|
237
|
+
return self._verify(self._request("POST", "/v1/verify", json=payload))
|
|
238
|
+
|
|
239
|
+
def trace(self, trace_id: str, *, include_context: bool = True) -> Optional[TraceDetail]:
|
|
240
|
+
"""Resolve a traceId to the exact printed line, with neighbouring rows."""
|
|
241
|
+
return self._trace(self._request("GET", self._trace_path(trace_id, include_context)))
|
|
242
|
+
|
|
243
|
+
def filings(self, ticker: str, *, form: Optional[str] = None, year: Optional[str] = None,
|
|
244
|
+
limit: Optional[int] = None) -> FilingsResult:
|
|
245
|
+
"""A company's indexed filings."""
|
|
246
|
+
t = ticker.upper()
|
|
247
|
+
body = self._request("GET", f"/v1/filings/{quote(t, safe='')}",
|
|
248
|
+
params=_clean({"form": form, "year": year, "limit": limit}))
|
|
249
|
+
return self._filings(body, t)
|
|
250
|
+
|
|
251
|
+
def coverage(self, ticker: str) -> CoverageResult:
|
|
252
|
+
"""Is there anything indexed for this ticker, and how fresh?"""
|
|
253
|
+
t = ticker.upper()
|
|
254
|
+
return self._coverage(self._request("GET", "/v1/coverage", params={"ticker": t}), t)
|
|
255
|
+
|
|
256
|
+
def table(self, ticker: str, accession: str, table_id: str, *, format: str = "records") -> TableResult:
|
|
257
|
+
"""One printed table, as filed: heading, row order, period headers, traces."""
|
|
258
|
+
return self._table(self._request("GET", self._table_path(ticker, accession, table_id),
|
|
259
|
+
params={"format": format}))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class AsyncFilingStudio(_Base):
|
|
263
|
+
"""Asynchronous client with the same methods."""
|
|
264
|
+
|
|
265
|
+
def __init__(self, api_key: Optional[str], *, base_url: Optional[str] = None,
|
|
266
|
+
timeout: float = 30.0, max_retries: int = 2,
|
|
267
|
+
transport: Optional[httpx.AsyncBaseTransport] = None) -> None:
|
|
268
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
|
|
269
|
+
self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
|
|
270
|
+
|
|
271
|
+
async def aclose(self) -> None:
|
|
272
|
+
await self._http.aclose()
|
|
273
|
+
|
|
274
|
+
async def __aenter__(self) -> "AsyncFilingStudio":
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
278
|
+
await self.aclose()
|
|
279
|
+
|
|
280
|
+
async def _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
|
281
|
+
json: Any = None) -> Any:
|
|
282
|
+
last: Optional[BaseException] = None
|
|
283
|
+
for attempt in range(self._max_retries + 1):
|
|
284
|
+
if attempt:
|
|
285
|
+
await anyio.sleep(self._backoff(attempt))
|
|
286
|
+
try:
|
|
287
|
+
res = await self._http.request(method, self._url(path), params=params, json=json,
|
|
288
|
+
headers=self._headers(json is not None))
|
|
289
|
+
except httpx.TransportError as exc:
|
|
290
|
+
last = exc
|
|
291
|
+
continue
|
|
292
|
+
body = self._parse(res)
|
|
293
|
+
if res.is_success:
|
|
294
|
+
return body if body is not None else {}
|
|
295
|
+
if res.status_code >= 500:
|
|
296
|
+
last = FilingStudioError(res.status_code, _describe(res.status_code, body)[0], None, body)
|
|
297
|
+
continue
|
|
298
|
+
_raise_for(res.status_code, body)
|
|
299
|
+
if isinstance(last, FilingStudioError):
|
|
300
|
+
raise last
|
|
301
|
+
raise self._unreachable(last)
|
|
302
|
+
|
|
303
|
+
async def search(self, ticker: str, q: str, *, type: Optional[str] = None,
|
|
304
|
+
period: Optional[str] = None, forms: Optional[str] = None,
|
|
305
|
+
limit: Optional[int] = None, offset: Optional[int] = None) -> SearchResult:
|
|
306
|
+
body = await self._request("GET", "/v1/search",
|
|
307
|
+
params=self._search_params(ticker, q, type, period, forms, limit, offset))
|
|
308
|
+
return self._search(body, type or "all")
|
|
309
|
+
|
|
310
|
+
async def verify(self, ticker: str, *, metric: Optional[str] = None, value: Optional[float] = None,
|
|
311
|
+
period: Optional[str] = None, claim: Optional[str] = None) -> VerifyResult:
|
|
312
|
+
payload = _clean({"ticker": ticker.upper(), "metric": metric, "value": value,
|
|
313
|
+
"period": period, "claim": claim})
|
|
314
|
+
return self._verify(await self._request("POST", "/v1/verify", json=payload))
|
|
315
|
+
|
|
316
|
+
async def trace(self, trace_id: str, *, include_context: bool = True) -> Optional[TraceDetail]:
|
|
317
|
+
return self._trace(await self._request("GET", self._trace_path(trace_id, include_context)))
|
|
318
|
+
|
|
319
|
+
async def filings(self, ticker: str, *, form: Optional[str] = None, year: Optional[str] = None,
|
|
320
|
+
limit: Optional[int] = None) -> FilingsResult:
|
|
321
|
+
t = ticker.upper()
|
|
322
|
+
body = await self._request("GET", f"/v1/filings/{quote(t, safe='')}",
|
|
323
|
+
params=_clean({"form": form, "year": year, "limit": limit}))
|
|
324
|
+
return self._filings(body, t)
|
|
325
|
+
|
|
326
|
+
async def coverage(self, ticker: str) -> CoverageResult:
|
|
327
|
+
t = ticker.upper()
|
|
328
|
+
return self._coverage(await self._request("GET", "/v1/coverage", params={"ticker": t}), t)
|
|
329
|
+
|
|
330
|
+
async def table(self, ticker: str, accession: str, table_id: str, *,
|
|
331
|
+
format: str = "records") -> TableResult:
|
|
332
|
+
return self._table(await self._request("GET", self._table_path(ticker, accession, table_id),
|
|
333
|
+
params={"format": format}))
|
filingstudio/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Errors. None of them ever carries the API key: it travels in a header, and
|
|
2
|
+
messages are built from the status and the API's own error body only."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from .models import IndexState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FilingStudioError(Exception):
|
|
12
|
+
"""Any non-2xx answer (or an unreachable API, status 0)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, status: int, message: str, code: Optional[str] = None, body: Any = None) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status = status
|
|
17
|
+
self.message = message
|
|
18
|
+
self.code = code
|
|
19
|
+
self.body = body
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
22
|
+
return f"{self.message} (HTTP {self.status})" if self.status else self.message
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RateLimitError(FilingStudioError):
|
|
26
|
+
"""429: the daily limit is spent. `index_state.note` is the honest sentence
|
|
27
|
+
to show a user: it says nothing about what the filings contain."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, index_state: IndexState, body: Any = None) -> None:
|
|
30
|
+
super().__init__(429, index_state.note or "Daily request limit reached.", "rate_limited", body)
|
|
31
|
+
self.index_state = index_state
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NotConfigured(FilingStudioError):
|
|
35
|
+
"""No API key was given."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
super().__init__(0, "FilingStudio requires an api_key.", "not_configured")
|
filingstudio/models.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Typed results. Every field that claims to be "from the filing"
|
|
2
|
+
(`printed_text`, `text`, context rows) is verbatim text the API returned.
|
|
3
|
+
`IndexState` keeps "searched, genuinely not found" apart from "index
|
|
4
|
+
incomplete" and "service unavailable". Unknown fields are kept, not dropped,
|
|
5
|
+
so a newer API never breaks an older client."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Literal, Optional, Union
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
Coverage = Literal["indexed", "empty", "incomplete", "unavailable", "unknown"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Model(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IndexState(_Model):
|
|
21
|
+
coverage: Coverage = "unknown"
|
|
22
|
+
note: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def searched(self) -> bool:
|
|
26
|
+
"""True only when a populated index actually answered."""
|
|
27
|
+
return self.coverage in ("indexed", "empty")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EvidenceLinks(_Model):
|
|
31
|
+
highlight: Optional[str] = None
|
|
32
|
+
viewer: Optional[str] = None
|
|
33
|
+
edgar: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EvidenceSource(_Model):
|
|
37
|
+
accession: Optional[str] = None
|
|
38
|
+
form: Optional[str] = None
|
|
39
|
+
filed_date: Optional[str] = Field(default=None, alias="filedDate")
|
|
40
|
+
table_id: Optional[str] = Field(default=None, alias="tableId")
|
|
41
|
+
row_index: Optional[int] = Field(default=None, alias="rowIndex")
|
|
42
|
+
phys_col: Optional[int] = Field(default=None, alias="physCol")
|
|
43
|
+
doc_source: Optional[str] = Field(default=None, alias="docSource")
|
|
44
|
+
block_index: Optional[int] = Field(default=None, alias="blockIndex")
|
|
45
|
+
anchor: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ContextRow(_Model):
|
|
49
|
+
label: Optional[str] = None
|
|
50
|
+
printed_text: Optional[str] = Field(default=None, alias="printedText")
|
|
51
|
+
is_source: bool = Field(default=False, alias="isSource")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TraceDetail(_Model):
|
|
55
|
+
trace_id: str = Field(alias="traceId")
|
|
56
|
+
kind: Optional[str] = None
|
|
57
|
+
ticker: Optional[str] = None
|
|
58
|
+
accession: Optional[str] = None
|
|
59
|
+
table_id: Optional[str] = Field(default=None, alias="tableId")
|
|
60
|
+
row_index: Optional[int] = Field(default=None, alias="rowIndex")
|
|
61
|
+
concept: Optional[str] = None
|
|
62
|
+
table_heading: Optional[str] = Field(default=None, alias="tableHeading")
|
|
63
|
+
row_label: Optional[str] = Field(default=None, alias="rowLabel")
|
|
64
|
+
printed_value: Optional[str] = Field(default=None, alias="printedValue")
|
|
65
|
+
numeric_value: Optional[float] = Field(default=None, alias="numericValue")
|
|
66
|
+
text: Optional[str] = None
|
|
67
|
+
source_excerpt: Optional[str] = Field(default=None, alias="sourceExcerpt")
|
|
68
|
+
header_cells: List[Dict[str, Any]] = Field(default_factory=list, alias="headerCells")
|
|
69
|
+
viewer_url: Optional[str] = Field(default=None, alias="viewerUrl")
|
|
70
|
+
links: EvidenceLinks = Field(default_factory=EvidenceLinks)
|
|
71
|
+
context: List[ContextRow] = Field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SearchCell(_Model):
|
|
75
|
+
kind: Literal["cell"] = "cell"
|
|
76
|
+
value: Optional[float] = None
|
|
77
|
+
printed_text: Optional[str] = Field(default=None, alias="printedText")
|
|
78
|
+
label: Optional[str] = None
|
|
79
|
+
period: Optional[str] = None
|
|
80
|
+
units: Optional[str] = None
|
|
81
|
+
trace_id: Optional[str] = Field(default=None, alias="traceId")
|
|
82
|
+
viewer_url: Optional[str] = Field(default=None, alias="viewerUrl")
|
|
83
|
+
links: EvidenceLinks = Field(default_factory=EvidenceLinks)
|
|
84
|
+
source: EvidenceSource = Field(default_factory=EvidenceSource)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SearchPassage(_Model):
|
|
88
|
+
kind: Literal["passage"] = "passage"
|
|
89
|
+
text: Optional[str] = None
|
|
90
|
+
trace_id: Optional[str] = Field(default=None, alias="traceId")
|
|
91
|
+
viewer_url: Optional[str] = Field(default=None, alias="viewerUrl")
|
|
92
|
+
links: EvidenceLinks = Field(default_factory=EvidenceLinks)
|
|
93
|
+
source: EvidenceSource = Field(default_factory=EvidenceSource)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
SearchHit = Union[SearchCell, SearchPassage, Dict[str, Any]]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_hit(raw: Any) -> SearchHit:
|
|
100
|
+
if isinstance(raw, dict):
|
|
101
|
+
kind = raw.get("kind")
|
|
102
|
+
if kind == "cell":
|
|
103
|
+
return SearchCell.model_validate(raw)
|
|
104
|
+
if kind == "passage":
|
|
105
|
+
return SearchPassage.model_validate(raw)
|
|
106
|
+
return raw
|
|
107
|
+
return {"value": raw}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Pagination(_Model):
|
|
111
|
+
limit: Optional[int] = None
|
|
112
|
+
offset: Optional[int] = None
|
|
113
|
+
returned: Optional[int] = None
|
|
114
|
+
next_offset: Optional[int] = Field(default=None, alias="nextOffset")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SearchResult(_Model):
|
|
118
|
+
type: str = "all"
|
|
119
|
+
results: List[Any] = Field(default_factory=list)
|
|
120
|
+
index_state: IndexState = Field(default_factory=IndexState, alias="indexState")
|
|
121
|
+
pagination: Optional[Pagination] = None
|
|
122
|
+
note: Optional[str] = None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def cells(self) -> List[SearchCell]:
|
|
126
|
+
return [h for h in self.results if isinstance(h, SearchCell)]
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def passages(self) -> List[SearchPassage]:
|
|
130
|
+
return [h for h in self.results if isinstance(h, SearchPassage)]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class FilingRef(_Model):
|
|
134
|
+
accession: str
|
|
135
|
+
form: Optional[str] = None
|
|
136
|
+
filed_date: Optional[str] = Field(default=None, alias="filedDate")
|
|
137
|
+
period_of_report: Optional[str] = Field(default=None, alias="periodOfReport")
|
|
138
|
+
viewer_url: Optional[str] = Field(default=None, alias="viewerUrl")
|
|
139
|
+
edgar_url: Optional[str] = Field(default=None, alias="edgarUrl")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class FilingsResult(_Model):
|
|
143
|
+
ticker: str
|
|
144
|
+
filings: List[FilingRef] = Field(default_factory=list)
|
|
145
|
+
index_state: IndexState = Field(default_factory=IndexState, alias="indexState")
|
|
146
|
+
pagination: Optional[Pagination] = None
|
|
147
|
+
note: Optional[str] = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class VerifyReceipt(_Model):
|
|
151
|
+
kind: Optional[str] = None
|
|
152
|
+
value: Optional[float] = None
|
|
153
|
+
printed_text: Optional[str] = Field(default=None, alias="printedText")
|
|
154
|
+
label: Optional[str] = None
|
|
155
|
+
period: Optional[str] = None
|
|
156
|
+
units: Optional[str] = None
|
|
157
|
+
trace_id: Optional[str] = Field(default=None, alias="traceId")
|
|
158
|
+
viewer_url: Optional[str] = Field(default=None, alias="viewerUrl")
|
|
159
|
+
links: EvidenceLinks = Field(default_factory=EvidenceLinks)
|
|
160
|
+
reason: Optional[str] = None
|
|
161
|
+
source: EvidenceSource = Field(default_factory=EvidenceSource)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
Verdict = Literal["supported", "unsupported", "ambiguous", "unavailable"]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class VerifyResult(_Model):
|
|
168
|
+
verdict: Verdict = "unavailable"
|
|
169
|
+
claim: Optional[Dict[str, Any]] = None
|
|
170
|
+
receipts: List[VerifyReceipt] = Field(default_factory=list)
|
|
171
|
+
conflicting: List[VerifyReceipt] = Field(default_factory=list)
|
|
172
|
+
actual: List[VerifyReceipt] = Field(default_factory=list)
|
|
173
|
+
reason: Optional[str] = None
|
|
174
|
+
index_state: IndexState = Field(default_factory=IndexState, alias="indexState")
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def supported(self) -> bool:
|
|
178
|
+
return self.verdict == "supported"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CoverageResult(_Model):
|
|
182
|
+
ticker: str
|
|
183
|
+
indexed: bool = False
|
|
184
|
+
rows_indexed: Optional[int] = Field(default=None, alias="rowsIndexed")
|
|
185
|
+
prose_blocks: Optional[int] = Field(default=None, alias="proseBlocks")
|
|
186
|
+
earliest_filed: Optional[str] = Field(default=None, alias="earliestFiled")
|
|
187
|
+
latest_filed: Optional[str] = Field(default=None, alias="latestFiled")
|
|
188
|
+
note: Optional[str] = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TableResult(_Model):
|
|
192
|
+
data: Dict[str, Any] = Field(default_factory=dict)
|
|
193
|
+
provenance: Dict[str, Any] = Field(default_factory=dict)
|
|
194
|
+
links: Dict[str, Any] = Field(default_factory=dict)
|
filingstudio/research.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EXPERIMENTAL. A research loop over the evidence layer: a model you supply
|
|
3
|
+
drives the search, and every claim in its answer has a receipt.
|
|
4
|
+
|
|
5
|
+
from filingstudio import FilingStudio
|
|
6
|
+
from filingstudio.research import answer
|
|
7
|
+
|
|
8
|
+
def llm(system: str, user: str, json_mode: bool) -> str:
|
|
9
|
+
... # any chat model; return the assistant text
|
|
10
|
+
|
|
11
|
+
res = answer("How is Data Center revenue trending?", "NVDA",
|
|
12
|
+
llm=llm, client=FilingStudio(api_key=KEY),
|
|
13
|
+
on_step=lambda label, detail: print(label, detail))
|
|
14
|
+
res.answer # prose citing [n]
|
|
15
|
+
res.sources[n-1] # the receipt behind [n]: a SearchCell or SearchPassage
|
|
16
|
+
res.hard_stop # which limit ended the loop, if any
|
|
17
|
+
|
|
18
|
+
The loop (every step reports through `on_step`):
|
|
19
|
+
|
|
20
|
+
1. PLAN the model turns the question into short search terms.
|
|
21
|
+
2. SEARCH each term runs against the company's filings; hits are pooled.
|
|
22
|
+
3. ASSESS the model reads the pool, writes a working draft citing [n],
|
|
23
|
+
and either says "enough" or asks for more terms.
|
|
24
|
+
4. repeat 2-3 until it says enough, or a HARD STOP hits.
|
|
25
|
+
5. WRITE the final answer, from the evidence only.
|
|
26
|
+
|
|
27
|
+
Hard stops: rounds, searches, model calls, evidence items, wall-clock seconds.
|
|
28
|
+
When one hits, the last model call is a REORGANIZE pass: it is handed the
|
|
29
|
+
working draft and the evidence and told to reorder and tidy, adding nothing.
|
|
30
|
+
So the loop always ends with an answer, and never with a guess.
|
|
31
|
+
|
|
32
|
+
The model writes sentences. It never invents a number or a quote: those come
|
|
33
|
+
only from search hits, which are the same receipts a drawer shows. Citations
|
|
34
|
+
to items that do not exist are stripped. With no evidence at all, the honest
|
|
35
|
+
answer is "no filing evidence attached", not a confident guess.
|
|
36
|
+
|
|
37
|
+
This module sits beside the client on purpose: the client never guesses, and
|
|
38
|
+
this helper is where a model is allowed to speak. Treat its output as prose
|
|
39
|
+
over receipts, not as evidence.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import time
|
|
47
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
48
|
+
from typing import Callable, List, Optional, Union
|
|
49
|
+
|
|
50
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
51
|
+
|
|
52
|
+
from .client import FilingStudio
|
|
53
|
+
from .models import IndexState, SearchCell, SearchPassage
|
|
54
|
+
|
|
55
|
+
LLM = Callable[[str, str, bool], str]
|
|
56
|
+
"""(system, user, json_mode) -> assistant text."""
|
|
57
|
+
OnStep = Callable[[str, Optional[str]], None]
|
|
58
|
+
|
|
59
|
+
Evidence = Union[SearchCell, SearchPassage]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ResearchSource(BaseModel):
|
|
63
|
+
n: int
|
|
64
|
+
item: Evidence
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ResearchStep(BaseModel):
|
|
68
|
+
label: str
|
|
69
|
+
detail: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ResearchAnswer(BaseModel):
|
|
73
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
74
|
+
|
|
75
|
+
ticker: str
|
|
76
|
+
question: str
|
|
77
|
+
answer: str
|
|
78
|
+
sources: List[ResearchSource] = Field(default_factory=list)
|
|
79
|
+
steps: List[ResearchStep] = Field(default_factory=list)
|
|
80
|
+
hard_stop: Optional[str] = Field(default=None, alias="hardStop")
|
|
81
|
+
evidence_thin: bool = Field(default=False, alias="evidenceThin")
|
|
82
|
+
note: Optional[str] = None
|
|
83
|
+
index_state: IndexState = Field(default_factory=IndexState, alias="indexState")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
NO_EVIDENCE = (
|
|
87
|
+
"No filing evidence attached for that. This answers only from filings it "
|
|
88
|
+
"can show a receipt for, and it found none for this question."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
_RULES = """Hard rules:
|
|
92
|
+
- Use ONLY the evidence provided. Do not add numbers, quotes, or facts that are
|
|
93
|
+
not in the evidence list.
|
|
94
|
+
- When you state something drawn from an item, cite it inline as [n] using that
|
|
95
|
+
item's number. Cite the specific item, not a range.
|
|
96
|
+
- Never wrap your own words in quotation marks as if they were from the filing.
|
|
97
|
+
Only the evidence text is the filing's words.
|
|
98
|
+
- If the evidence is thin or does not address the question, say so plainly.
|
|
99
|
+
Do not fill the gap with general knowledge."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _sys_plan(app: str) -> str:
|
|
103
|
+
return f"""You are the research analyst inside {app}.
|
|
104
|
+
Turn the user's question about a company into up to 10 hyper-relevant short
|
|
105
|
+
search terms for a full-text search over that company's SEC filings (10-K,
|
|
106
|
+
10-Q). Each term is 1-4 words the filing itself would print: product names,
|
|
107
|
+
segment names, line items, risk-factor phrases, management's own wording.
|
|
108
|
+
Cover the question from several angles (the thing itself, its drivers, its
|
|
109
|
+
risks, the numbers that measure it). No whole questions, no filler words, no
|
|
110
|
+
near-duplicates. Reply with JSON only: {{"queries": ["...", "..."]}}"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _sys_assess(app: str) -> str:
|
|
114
|
+
return f"""You are the research analyst inside {app}.
|
|
115
|
+
You are given a question, the search terms tried so far, and a numbered list of
|
|
116
|
+
EVIDENCE items found in the company's SEC filings (verbatim quotes and printed
|
|
117
|
+
figures). Write a working DRAFT answer from the evidence, then decide whether
|
|
118
|
+
more searching would help.
|
|
119
|
+
{_RULES}
|
|
120
|
+
Reply with JSON only:
|
|
121
|
+
{{"draft": "2-5 sentences citing [n]",
|
|
122
|
+
"enough": true or false,
|
|
123
|
+
"next_queries": ["up to 10 NEW short search terms not tried yet, only if enough is false"],
|
|
124
|
+
"gap": "one line on what is still missing, or empty"}}"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _sys_write(app: str) -> str:
|
|
128
|
+
return f"""You are the research analyst inside {app}.
|
|
129
|
+
You will be given a question about a company and a numbered list of EVIDENCE
|
|
130
|
+
items retrieved from that company's SEC filings. Each item is either a verbatim
|
|
131
|
+
quote from the filing or a printed figure with its label.
|
|
132
|
+
{_RULES}
|
|
133
|
+
- Be concise: a short, direct answer of 2-5 sentences. No preamble."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _sys_reorganize(app: str) -> str:
|
|
137
|
+
return f"""You are the research analyst inside {app}.
|
|
138
|
+
The research budget is spent. You are given the question, a working DRAFT, and
|
|
139
|
+
the numbered EVIDENCE the draft cites. Produce the final answer by REORDERING
|
|
140
|
+
and tidying the draft so it answers the question directly and reads cleanly.
|
|
141
|
+
Do NOT add any new information, number, quote, or claim that is not already in
|
|
142
|
+
the draft or the evidence. Keep every [n] citation pointing at the same item.
|
|
143
|
+
If the draft says the evidence is thin, keep saying so.
|
|
144
|
+
{_RULES}
|
|
145
|
+
- 2-5 sentences. No preamble."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _Budget:
|
|
149
|
+
def __init__(self, *, max_rounds: int, max_searches: int, max_llm_calls: int,
|
|
150
|
+
max_items: int, time_budget_s: float) -> None:
|
|
151
|
+
self.started = time.monotonic()
|
|
152
|
+
self.rounds = 0
|
|
153
|
+
self.searches = 0
|
|
154
|
+
self.llm_calls = 0
|
|
155
|
+
self.max_rounds = max_rounds
|
|
156
|
+
self.max_searches = max_searches
|
|
157
|
+
self.max_llm_calls = max_llm_calls
|
|
158
|
+
self.max_items = max_items
|
|
159
|
+
self.time_budget_s = time_budget_s
|
|
160
|
+
|
|
161
|
+
def stop_reason(self, items: int) -> Optional[str]:
|
|
162
|
+
if time.monotonic() - self.started > self.time_budget_s:
|
|
163
|
+
return f"time budget ({int(self.time_budget_s)}s)"
|
|
164
|
+
if self.rounds >= self.max_rounds:
|
|
165
|
+
return f"round limit ({self.max_rounds})"
|
|
166
|
+
if self.searches >= self.max_searches:
|
|
167
|
+
return f"search limit ({self.max_searches})"
|
|
168
|
+
if self.llm_calls >= self.max_llm_calls - 1: # keep one call for the ending
|
|
169
|
+
return f"model-call limit ({self.max_llm_calls})"
|
|
170
|
+
if items >= self.max_items:
|
|
171
|
+
return f"evidence limit ({self.max_items} items)"
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _key(it: Evidence) -> str:
|
|
176
|
+
if it.trace_id:
|
|
177
|
+
return it.trace_id
|
|
178
|
+
if isinstance(it, SearchCell):
|
|
179
|
+
return f"{it.label}|{it.printed_text}"
|
|
180
|
+
return f"passage|{it.text}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def evidence_prompt(ticker: str, question: str, items: List[Evidence], *,
|
|
184
|
+
tried: Optional[List[str]] = None, draft: str = "") -> str:
|
|
185
|
+
lines = [f"Company: {ticker}", f"Question: {question}"]
|
|
186
|
+
if tried:
|
|
187
|
+
lines.append("Search terms tried: " + "; ".join(tried))
|
|
188
|
+
lines += ["", "EVIDENCE:"]
|
|
189
|
+
for i, it in enumerate(items, 1):
|
|
190
|
+
if isinstance(it, SearchPassage):
|
|
191
|
+
lines.append(f'[{i}] Filing quote: "{it.text or ""}"')
|
|
192
|
+
else:
|
|
193
|
+
label = it.label or "figure"
|
|
194
|
+
printed = it.printed_text or (str(it.value) if it.value is not None else "")
|
|
195
|
+
period = f", {it.period}" if it.period else ""
|
|
196
|
+
lines.append(f'[{i}] {label}: printed "{printed}"{period}')
|
|
197
|
+
if draft:
|
|
198
|
+
lines += ["", "DRAFT:", draft]
|
|
199
|
+
return "\n".join(lines)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def drop_uncited(text: str, n_sources: int) -> str:
|
|
203
|
+
"""A citation to an item that does not exist is a hallucinated receipt;
|
|
204
|
+
strip the marker rather than render a dead link."""
|
|
205
|
+
return re.sub(r"\[(\d+)\]", lambda m: m.group(0) if 1 <= int(m.group(1)) <= n_sources else "", text)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _llm_json(llm: LLM, system: str, user: str) -> dict:
|
|
209
|
+
raw = llm(system, user, True)
|
|
210
|
+
try:
|
|
211
|
+
out = json.loads(raw)
|
|
212
|
+
except ValueError:
|
|
213
|
+
m = re.search(r"\{.*\}", raw, re.S)
|
|
214
|
+
out = json.loads(m.group(0)) if m else {}
|
|
215
|
+
return out if isinstance(out, dict) else {}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def answer(
|
|
219
|
+
question: str,
|
|
220
|
+
ticker: str,
|
|
221
|
+
*,
|
|
222
|
+
llm: Optional[LLM],
|
|
223
|
+
client: Optional[FilingStudio] = None,
|
|
224
|
+
api_key: Optional[str] = None,
|
|
225
|
+
max_rounds: int = 2,
|
|
226
|
+
terms_per_round: int = 10,
|
|
227
|
+
time_budget_s: float = 25.0,
|
|
228
|
+
max_searches: Optional[int] = None,
|
|
229
|
+
max_llm_calls: int = 5,
|
|
230
|
+
max_items: int = 30,
|
|
231
|
+
hits_per_search: int = 4,
|
|
232
|
+
workers: int = 5,
|
|
233
|
+
on_step: Optional[OnStep] = None,
|
|
234
|
+
app_name: str = "this app",
|
|
235
|
+
) -> ResearchAnswer:
|
|
236
|
+
"""Run the loop. `llm(system, user, json_mode) -> str` is any chat model.
|
|
237
|
+
With `llm=None`, one plain search runs and the evidence is returned with
|
|
238
|
+
no prose. Pass a `client` or an `api_key`."""
|
|
239
|
+
fs = client or FilingStudio(api_key)
|
|
240
|
+
t = ticker.upper()
|
|
241
|
+
steps: List[ResearchStep] = []
|
|
242
|
+
max_searches = max_searches if max_searches is not None else max_rounds * terms_per_round
|
|
243
|
+
|
|
244
|
+
def step(label: str, detail: Optional[str] = None) -> None:
|
|
245
|
+
steps.append(ResearchStep(label=label, detail=detail))
|
|
246
|
+
if on_step:
|
|
247
|
+
on_step(label, detail)
|
|
248
|
+
|
|
249
|
+
def hits_of(res) -> List[Evidence]:
|
|
250
|
+
return [h for h in res.results if isinstance(h, (SearchCell, SearchPassage))]
|
|
251
|
+
|
|
252
|
+
if llm is None:
|
|
253
|
+
step("Searching filings", question)
|
|
254
|
+
res = fs.search(t, question, limit=max(hits_per_search, 8))
|
|
255
|
+
items = hits_of(res)
|
|
256
|
+
sources = [ResearchSource(n=i + 1, item=it) for i, it in enumerate(items)]
|
|
257
|
+
return ResearchAnswer(
|
|
258
|
+
ticker=t, question=question,
|
|
259
|
+
answer=("No model was supplied, so no prose was written; the filing "
|
|
260
|
+
"evidence is below.") if sources else NO_EVIDENCE,
|
|
261
|
+
sources=sources, steps=steps, evidenceThin=len(sources) < 2,
|
|
262
|
+
indexState=res.index_state,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
budget = _Budget(max_rounds=max_rounds, max_searches=max_searches,
|
|
266
|
+
max_llm_calls=max_llm_calls, max_items=max_items,
|
|
267
|
+
time_budget_s=time_budget_s)
|
|
268
|
+
pool: List[Evidence] = []
|
|
269
|
+
seen: set = set()
|
|
270
|
+
tried: List[str] = []
|
|
271
|
+
last_state = IndexState()
|
|
272
|
+
draft = ""
|
|
273
|
+
hard_stop: Optional[str] = None
|
|
274
|
+
|
|
275
|
+
def add_hits(items: List[Evidence]) -> int:
|
|
276
|
+
added = 0
|
|
277
|
+
for it in items:
|
|
278
|
+
k = _key(it)
|
|
279
|
+
if k in seen or len(pool) >= max_items:
|
|
280
|
+
continue
|
|
281
|
+
seen.add(k)
|
|
282
|
+
pool.append(it)
|
|
283
|
+
added += 1
|
|
284
|
+
return added
|
|
285
|
+
|
|
286
|
+
def run_searches(queries: List[str]) -> None:
|
|
287
|
+
nonlocal last_state
|
|
288
|
+
todo: List[str] = []
|
|
289
|
+
lower_tried = {x.lower() for x in tried}
|
|
290
|
+
for q in queries:
|
|
291
|
+
q = str(q).strip()
|
|
292
|
+
if not q or q.lower() in lower_tried or q.lower() in {x.lower() for x in todo}:
|
|
293
|
+
continue
|
|
294
|
+
if budget.searches + len(todo) >= max_searches:
|
|
295
|
+
break
|
|
296
|
+
todo.append(q)
|
|
297
|
+
if not todo:
|
|
298
|
+
return
|
|
299
|
+
budget.searches += len(todo)
|
|
300
|
+
tried.extend(todo)
|
|
301
|
+
step("Searching filings", " · ".join(todo))
|
|
302
|
+
|
|
303
|
+
def one(q: str):
|
|
304
|
+
try:
|
|
305
|
+
return fs.search(t, q, limit=hits_per_search)
|
|
306
|
+
except Exception as exc: # noqa: BLE001 - one bad term must not sink the round
|
|
307
|
+
return exc
|
|
308
|
+
|
|
309
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
|
|
310
|
+
results = list(ex.map(one, todo))
|
|
311
|
+
for q, res in zip(todo, results):
|
|
312
|
+
if isinstance(res, Exception):
|
|
313
|
+
step("Search failed", f"“{q}”: {type(res).__name__}")
|
|
314
|
+
continue
|
|
315
|
+
last_state = res.index_state
|
|
316
|
+
n = add_hits(hits_of(res))
|
|
317
|
+
step("Found receipts", f"{n} new for “{q}” · {len(pool)} total")
|
|
318
|
+
|
|
319
|
+
# 1. PLAN
|
|
320
|
+
step("Planning searches", question)
|
|
321
|
+
try:
|
|
322
|
+
budget.llm_calls += 1
|
|
323
|
+
plan = _llm_json(llm, _sys_plan(app_name), f"Company: {t}\nQuestion: {question}")
|
|
324
|
+
queries = [str(q) for q in (plan.get("queries") or [])][:terms_per_round]
|
|
325
|
+
except Exception as exc: # noqa: BLE001 - degrade, never crash
|
|
326
|
+
queries = []
|
|
327
|
+
step("Planner unavailable", f"{type(exc).__name__}; searching the question as typed")
|
|
328
|
+
if not queries:
|
|
329
|
+
queries = [question]
|
|
330
|
+
|
|
331
|
+
# 2-4. SEARCH / ASSESS
|
|
332
|
+
while True:
|
|
333
|
+
budget.rounds += 1
|
|
334
|
+
run_searches(queries)
|
|
335
|
+
if pool:
|
|
336
|
+
step("Reading evidence", f"{len(pool)} receipts")
|
|
337
|
+
try:
|
|
338
|
+
budget.llm_calls += 1
|
|
339
|
+
verdict = _llm_json(llm, _sys_assess(app_name),
|
|
340
|
+
evidence_prompt(t, question, pool, tried=tried))
|
|
341
|
+
except Exception as exc: # noqa: BLE001
|
|
342
|
+
step("Assessment unavailable", type(exc).__name__)
|
|
343
|
+
hard_stop = "model error"
|
|
344
|
+
break
|
|
345
|
+
draft = str(verdict.get("draft") or draft)
|
|
346
|
+
if verdict.get("enough") or not verdict.get("next_queries"):
|
|
347
|
+
step("Enough evidence", verdict.get("gap") or None)
|
|
348
|
+
break
|
|
349
|
+
queries = [str(q) for q in verdict.get("next_queries") or []][:terms_per_round]
|
|
350
|
+
step("Need more", verdict.get("gap") or ", ".join(queries))
|
|
351
|
+
else:
|
|
352
|
+
step("Rethinking search terms", "no receipts yet")
|
|
353
|
+
queries = [question] if question.lower() not in {x.lower() for x in tried} else []
|
|
354
|
+
if not queries:
|
|
355
|
+
break
|
|
356
|
+
reason = budget.stop_reason(len(pool))
|
|
357
|
+
if reason:
|
|
358
|
+
hard_stop = reason
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
sources = [ResearchSource(n=i + 1, item=it) for i, it in enumerate(pool)]
|
|
362
|
+
if not sources:
|
|
363
|
+
note = last_state.note if last_state.coverage == "incomplete" else None
|
|
364
|
+
step("No evidence found", note)
|
|
365
|
+
return ResearchAnswer(ticker=t, question=question, answer=NO_EVIDENCE,
|
|
366
|
+
evidenceThin=True, note=note, steps=steps,
|
|
367
|
+
hardStop=hard_stop, indexState=last_state)
|
|
368
|
+
|
|
369
|
+
# 5. WRITE, or on a hard stop REORGANIZE the draft without adding anything.
|
|
370
|
+
try:
|
|
371
|
+
budget.llm_calls += 1
|
|
372
|
+
if hard_stop:
|
|
373
|
+
step("Hard stop", hard_stop)
|
|
374
|
+
step("Reorganizing draft into the answer", "no new information")
|
|
375
|
+
if draft:
|
|
376
|
+
text = llm(_sys_reorganize(app_name),
|
|
377
|
+
evidence_prompt(t, question, pool, draft=draft)
|
|
378
|
+
+ "\n\nReorder and tidy the DRAFT into the final answer now. Add nothing.",
|
|
379
|
+
False)
|
|
380
|
+
else:
|
|
381
|
+
text = llm(_sys_write(app_name),
|
|
382
|
+
evidence_prompt(t, question, pool)
|
|
383
|
+
+ "\n\nWrite the answer now, citing items as [n].", False)
|
|
384
|
+
else:
|
|
385
|
+
step("Writing answer", f"{len(pool)} receipts")
|
|
386
|
+
text = llm(_sys_write(app_name),
|
|
387
|
+
evidence_prompt(t, question, pool)
|
|
388
|
+
+ "\n\nWrite the answer now, citing items as [n].", False)
|
|
389
|
+
except Exception as exc: # noqa: BLE001 - degrade, never crash
|
|
390
|
+
if draft:
|
|
391
|
+
return ResearchAnswer(
|
|
392
|
+
ticker=t, question=question, answer=drop_uncited(draft, len(sources)),
|
|
393
|
+
sources=sources, evidenceThin=len(sources) < 2, steps=steps, hardStop=hard_stop,
|
|
394
|
+
note=f"Final pass failed ({type(exc).__name__}); showing the working draft.",
|
|
395
|
+
indexState=last_state)
|
|
396
|
+
return ResearchAnswer(
|
|
397
|
+
ticker=t, question=question,
|
|
398
|
+
answer="The filing evidence below was retrieved, but the writing model could not be reached to summarize it.",
|
|
399
|
+
sources=sources, evidenceThin=len(sources) < 2, steps=steps, hardStop=hard_stop,
|
|
400
|
+
note=f"Synthesis error: {type(exc).__name__}", indexState=last_state)
|
|
401
|
+
|
|
402
|
+
text = drop_uncited(str(text).strip(), len(sources))
|
|
403
|
+
step("Done", None)
|
|
404
|
+
note = None
|
|
405
|
+
if hard_stop:
|
|
406
|
+
note = (f"Stopped early at the {hard_stop}; "
|
|
407
|
+
+ ("the answer was reorganized from the working draft, nothing added."
|
|
408
|
+
if draft else "the answer was written from the receipts found so far."))
|
|
409
|
+
return ResearchAnswer(ticker=t, question=question, answer=text, sources=sources,
|
|
410
|
+
evidenceThin=len(sources) < 2, steps=steps, hardStop=hard_stop,
|
|
411
|
+
note=note, indexState=last_state)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: filingstudio
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Client for the Filing Studio API: search SEC filings, resolve traces, verify claims. One call per door, typed results, your key never in a URL.
|
|
5
|
+
Project-URL: Homepage, https://filingstudio.com
|
|
6
|
+
Project-URL: Documentation, https://filingstudio.com/docs#sdk
|
|
7
|
+
Author-email: Filing Studio <support@filingstudio.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: 10-K,10-Q,api-client,citations,edgar,filings,finance,provenance,sec
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: httpx>=0.24
|
|
28
|
+
Requires-Dist: pydantic>=2.0
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: anyio>=3; extra == 'test'
|
|
31
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# filingstudio
|
|
35
|
+
|
|
36
|
+
Python client for the [Filing Studio](https://filingstudio.com) API. Search
|
|
37
|
+
SEC filings exactly as printed, resolve any number back to the line that
|
|
38
|
+
printed it, and verify claims deterministically. One call per door, typed
|
|
39
|
+
results, and your key never in a URL.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install filingstudio
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
from filingstudio import FilingStudio
|
|
48
|
+
|
|
49
|
+
fs = FilingStudio(api_key=os.environ["FILING_STUDIO_API_KEY"])
|
|
50
|
+
|
|
51
|
+
hits = fs.search("NVDA", "purchase commitments", type="prose")
|
|
52
|
+
for p in hits.passages:
|
|
53
|
+
print(p.text, p.trace_id)
|
|
54
|
+
|
|
55
|
+
v = fs.verify("NVDA", metric="Revenue", value=130497, period="FY2025")
|
|
56
|
+
print(v.verdict) # supported | unsupported | ambiguous | unavailable
|
|
57
|
+
print(v.receipts[0].printed_text) # "130,497"
|
|
58
|
+
print(v.receipts[0].links.highlight) # opens the filing with that cell marked
|
|
59
|
+
|
|
60
|
+
t = fs.trace(v.receipts[0].trace_id) # the printed line plus its neighbours
|
|
61
|
+
for row in t.context:
|
|
62
|
+
print(row.label, row.printed_text, "<- source" if row.is_source else "")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Async is the same API:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from filingstudio import AsyncFilingStudio
|
|
69
|
+
|
|
70
|
+
async with AsyncFilingStudio(api_key=key) as fs:
|
|
71
|
+
cov = await fs.coverage("NVDA")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## The six doors
|
|
75
|
+
|
|
76
|
+
| method | what it answers |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `search(ticker, q, type=, period=, forms=, limit=, offset=)` | printed rows, tables, and prose matching plain words |
|
|
79
|
+
| `verify(ticker, metric=, value=, period=, claim=)` | is this claim what the filing prints? |
|
|
80
|
+
| `trace(trace_id, include_context=True)` | the exact printed line behind a traceId, with neighbours |
|
|
81
|
+
| `filings(ticker, form=, year=, limit=)` | a company's indexed filings |
|
|
82
|
+
| `coverage(ticker)` | is anything indexed, and how fresh |
|
|
83
|
+
| `table(ticker, accession, table_id, format="records")` | one printed table, as filed |
|
|
84
|
+
|
|
85
|
+
Pass `value` to `verify` as the raw figure you hold (130497 or 130497000000
|
|
86
|
+
alike). The API tries every printed scale a filer could use. Do not pre-scale.
|
|
87
|
+
|
|
88
|
+
## Honest answers
|
|
89
|
+
|
|
90
|
+
Every result carries `index_state`. `coverage` is one of:
|
|
91
|
+
|
|
92
|
+
- `indexed`: a populated index answered, with results
|
|
93
|
+
- `empty`: a populated index answered and had nothing. The only real negative.
|
|
94
|
+
- `incomplete`: the index could not fully answer. Says nothing about the filing.
|
|
95
|
+
- `unavailable`: the service or your quota could not answer. Same.
|
|
96
|
+
|
|
97
|
+
`RateLimitError` (HTTP 429) carries `index_state.note`, a sentence safe to
|
|
98
|
+
show a user. Other non-2xx answers raise `FilingStudioError` with `status`,
|
|
99
|
+
`code`, and the API's `message`. 5xx and network failures are retried with
|
|
100
|
+
backoff; 4xx are not. No error ever contains your key.
|
|
101
|
+
|
|
102
|
+
## Research with receipts (experimental)
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from filingstudio.research import answer
|
|
106
|
+
|
|
107
|
+
def llm(system: str, user: str, json_mode: bool) -> str:
|
|
108
|
+
... # any chat model; return the assistant text
|
|
109
|
+
|
|
110
|
+
res = answer("How is Data Center revenue trending?", "NVDA", llm=llm, client=fs,
|
|
111
|
+
on_step=lambda label, detail: print(label, detail))
|
|
112
|
+
res.answer, res.sources, res.hard_stop
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Plan, search, assess, write, with hard stops. Every `[n]` in the answer is a
|
|
116
|
+
search hit you can trace; citations to nothing are stripped; with no evidence
|
|
117
|
+
the answer says so. `llm=None` runs one search and returns the evidence only.
|
|
118
|
+
|
|
119
|
+
## Options
|
|
120
|
+
|
|
121
|
+
`FilingStudio(api_key, base_url=None, timeout=30.0, max_retries=2, transport=None)`
|
|
122
|
+
|
|
123
|
+
`transport` accepts an `httpx` transport, for tests (`httpx.MockTransport`).
|
|
124
|
+
|
|
125
|
+
## Develop
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install -e .[test]
|
|
129
|
+
pytest
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
filingstudio/__init__.py,sha256=Xd1ROtPdTinU5ldRy46FCVQMjnGKVXaHdgbHksejM5w,993
|
|
2
|
+
filingstudio/client.py,sha256=uKeRSFFJtUz9kcK_cnBHwwSR6akwz_JPtUFyo_SsDSY,14256
|
|
3
|
+
filingstudio/errors.py,sha256=IcEkePPC1wdiNe79RzU6q9fyM3UdpjS17YKILZSdbHI,1356
|
|
4
|
+
filingstudio/models.py,sha256=KZRHgUgOpjtVbmXDuSsHHNSAnseiGSY4LvM2fitKX-o,7242
|
|
5
|
+
filingstudio/research.py,sha256=qtIh_UQWuKlgpF5fMEWVKSIrRsg9_vAm0bgZXyQWcOo,17094
|
|
6
|
+
filingstudio-0.4.0.dist-info/METADATA,sha256=nwcxoe8jD3djHlKMdhDurB-dJydewnzR42fPoxOOO2U,4914
|
|
7
|
+
filingstudio-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
filingstudio-0.4.0.dist-info/licenses/LICENSE,sha256=0N9MXUpOueb0VCrG2YJoMksp8VFEVrsgbeIsEKm00RU,1070
|
|
9
|
+
filingstudio-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Filing Studio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|