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.
@@ -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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.