specstream-haystack 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/PKG-INFO +11 -7
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/README.md +7 -3
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/pyproject.toml +4 -4
- specstream_haystack-0.2.0/src/haystack_integrations/components/fetchers/specstream/__init__.py +8 -0
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/src/haystack_integrations/components/fetchers/specstream/_api.py +39 -11
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/src/haystack_integrations/components/fetchers/specstream/list_fetcher.py +83 -0
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/tests/test_specstream_haystack.py +16 -2
- specstream_haystack-0.1.1/src/haystack_integrations/components/fetchers/specstream/__init__.py +0 -4
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/.gitignore +0 -0
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/LICENSE +0 -0
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/src/haystack_integrations/components/fetchers/specstream/py.typed +0 -0
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/src/haystack_integrations/tools/specstream/__init__.py +0 -0
- {specstream_haystack-0.1.1 → specstream_haystack-0.2.0}/src/haystack_integrations/tools/specstream/list_tool.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: specstream-haystack
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: The live web data engine for Haystack: Search API ($5 per 1,000 searches), Extract API ($0.80 per 1,000 pages) and Deep Search.
|
|
5
5
|
Project-URL: Homepage, https://specstream.ai
|
|
6
6
|
Project-URL: Documentation, https://specstream.ai/docs
|
|
7
|
-
Project-URL: Repository, https://github.com/
|
|
8
|
-
Project-URL: Issues, https://github.com/
|
|
7
|
+
Project-URL: Repository, https://github.com/specstream/specstream-tools/tree/main/packages/specstream-haystack
|
|
8
|
+
Project-URL: Issues, https://github.com/specstream/specstream-tools/issues
|
|
9
9
|
Project-URL: Pricing, https://specstream.ai/pricing
|
|
10
10
|
Author-email: SpecStream <support@specstream.ai>
|
|
11
11
|
License-Expression: MIT
|
|
@@ -33,14 +33,18 @@ Description-Content-Type: text/markdown
|
|
|
33
33
|
|
|
34
34
|
# specstream-haystack
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
[SpecStream](https://specstream.ai) is the live web data engine. One platform to build, search, extract, enrich, map, and monitor data across the entire web.
|
|
37
|
+
|
|
38
|
+
Ask for any dataset in plain words, and SpecStream builds it from the entire live web: every match found, extracted and enriched into structured records, then monitored for change. This package gives your Haystack pipeline or agent a fetcher component and a tool for it, with one `Document` per row. A run starts only within a price you set in code, and the model can't raise it.
|
|
39
|
+
|
|
40
|
+
Source URL and read time on every value.
|
|
37
41
|
|
|
38
42
|
```bash
|
|
39
43
|
pip install -U specstream-haystack
|
|
40
44
|
export SPECSTREAM_API_KEY=ss_live_... # Agents & API in your SpecStream workspace
|
|
41
45
|
```
|
|
42
46
|
|
|
43
|
-
A free account comes with $10 of
|
|
47
|
+
A free account comes with $10 of credit to use within 30 days and needs no card: [specstream.ai](https://specstream.ai/signup).
|
|
44
48
|
|
|
45
49
|
## In a pipeline
|
|
46
50
|
|
|
@@ -80,4 +84,4 @@ The agent writes the request and picks how many rows. You decide what one list m
|
|
|
80
84
|
|
|
81
85
|
The same request with the same limits, in the same hour, reaches the run it already started instead of starting another. A retried step never pays for the list twice.
|
|
82
86
|
|
|
83
|
-
MIT licensed. Source: [
|
|
87
|
+
MIT licensed. Source: [specstream/specstream-tools](https://github.com/specstream/specstream-tools/tree/main/packages/specstream-haystack).
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# specstream-haystack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[SpecStream](https://specstream.ai) is the live web data engine. One platform to build, search, extract, enrich, map, and monitor data across the entire web.
|
|
4
|
+
|
|
5
|
+
Ask for any dataset in plain words, and SpecStream builds it from the entire live web: every match found, extracted and enriched into structured records, then monitored for change. This package gives your Haystack pipeline or agent a fetcher component and a tool for it, with one `Document` per row. A run starts only within a price you set in code, and the model can't raise it.
|
|
6
|
+
|
|
7
|
+
Source URL and read time on every value.
|
|
4
8
|
|
|
5
9
|
```bash
|
|
6
10
|
pip install -U specstream-haystack
|
|
7
11
|
export SPECSTREAM_API_KEY=ss_live_... # Agents & API in your SpecStream workspace
|
|
8
12
|
```
|
|
9
13
|
|
|
10
|
-
A free account comes with $10 of
|
|
14
|
+
A free account comes with $10 of credit to use within 30 days and needs no card: [specstream.ai](https://specstream.ai/signup).
|
|
11
15
|
|
|
12
16
|
## In a pipeline
|
|
13
17
|
|
|
@@ -47,4 +51,4 @@ The agent writes the request and picks how many rows. You decide what one list m
|
|
|
47
51
|
|
|
48
52
|
The same request with the same limits, in the same hour, reaches the run it already started instead of starting another. A retried step never pays for the list twice.
|
|
49
53
|
|
|
50
|
-
MIT licensed. Source: [
|
|
54
|
+
MIT licensed. Source: [specstream/specstream-tools](https://github.com/specstream/specstream-tools/tree/main/packages/specstream-haystack).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "specstream-haystack"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "The live web data engine for Haystack: Search API ($5 per 1,000 searches), Extract API ($0.80 per 1,000 pages) and Deep Search."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
license = "MIT"
|
|
@@ -30,8 +30,8 @@ test = ["pytest>=8", "ruff"]
|
|
|
30
30
|
[project.urls]
|
|
31
31
|
Homepage = "https://specstream.ai"
|
|
32
32
|
Documentation = "https://specstream.ai/docs"
|
|
33
|
-
Repository = "https://github.com/
|
|
34
|
-
Issues = "https://github.com/
|
|
33
|
+
Repository = "https://github.com/specstream/specstream-tools/tree/main/packages/specstream-haystack"
|
|
34
|
+
Issues = "https://github.com/specstream/specstream-tools/issues"
|
|
35
35
|
Pricing = "https://specstream.ai/pricing"
|
|
36
36
|
|
|
37
37
|
[build-system]
|
specstream_haystack-0.2.0/src/haystack_integrations/components/fetchers/specstream/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from haystack_integrations.components.fetchers.specstream._api import SpecStreamError
|
|
2
|
+
from haystack_integrations.components.fetchers.specstream.list_fetcher import (
|
|
3
|
+
SpecStreamExtractor,
|
|
4
|
+
SpecStreamListFetcher,
|
|
5
|
+
SpecStreamWebSearch,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = ["SpecStreamError", "SpecStreamExtractor", "SpecStreamListFetcher", "SpecStreamWebSearch"]
|
|
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
|
|
12
12
|
from typing import Any, Callable
|
|
13
13
|
|
|
14
14
|
DEFAULT_BASE_URL = "https://api.specstream.ai"
|
|
15
|
-
USER_AGENT = "specstream-haystack/0.1.
|
|
15
|
+
USER_AGENT = "specstream-haystack/0.1.2"
|
|
16
16
|
# A list runs for up to about five minutes, so the tools wait a little longer than that by default.
|
|
17
17
|
DEFAULT_WAIT_SECONDS = 420.0
|
|
18
18
|
FINISHED = {"completed", "partial"}
|
|
@@ -35,15 +35,23 @@ def list_link(find_id: str) -> str:
|
|
|
35
35
|
return f"https://specstream.ai/workspace/collections/{urllib.parse.quote(find_id, safe='')}"
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
def
|
|
39
|
-
"""
|
|
38
|
+
def _sources(flat: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
"""Each value's page in the API's flat row: <field>_source beside the <field> it belongs to."""
|
|
40
|
+
return {key: value for key, value in flat.items() if key.endswith("_source") and key[: -len("_source")] in flat}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def flatten_row(find_id: str, record: dict[str, Any], flat: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
44
|
+
"""One row as flat fields: each column's label holds what the list shows. The page of each value
|
|
45
|
+
(<field>_source) and of the fact that put the row in the list (source_url, checked_at) come from
|
|
46
|
+
the API's flat row, so every tool shows the same pages."""
|
|
47
|
+
flat = flat or {}
|
|
48
|
+
sources = _sources(flat)
|
|
40
49
|
row: dict[str, Any] = {"list_id": find_id, "position": record.get("ordinal")}
|
|
41
|
-
source: dict[str, Any] | None = None
|
|
42
50
|
for cell in record.get("details") or record.get("cells") or []:
|
|
43
51
|
label = str(cell.get("label") or cell.get("column_id") or "").strip()
|
|
44
52
|
if not label:
|
|
45
53
|
continue
|
|
46
|
-
if label in RESERVED:
|
|
54
|
+
if label in RESERVED or label in sources:
|
|
47
55
|
label = f"{label} (column)"
|
|
48
56
|
if cell.get("absence"):
|
|
49
57
|
value: Any = ""
|
|
@@ -52,11 +60,9 @@ def flatten_row(find_id: str, record: dict[str, Any]) -> dict[str, Any]:
|
|
|
52
60
|
else:
|
|
53
61
|
value = cell.get("value") if cell.get("value") is not None else ""
|
|
54
62
|
row[label] = value
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
row["source_url"] = source.get("source_url", "") if source else ""
|
|
59
|
-
row["checked_at"] = source.get("observed_at", "") if source else ""
|
|
63
|
+
row.update(sources)
|
|
64
|
+
row["source_url"] = flat.get("source_url") or ""
|
|
65
|
+
row["checked_at"] = flat.get("checked_at") or ""
|
|
60
66
|
row["list_link"] = list_link(find_id)
|
|
61
67
|
return row
|
|
62
68
|
|
|
@@ -106,6 +112,24 @@ class SpecStreamAPI:
|
|
|
106
112
|
raise SpecStreamError("SpecStream returned a response this package couldn't read.")
|
|
107
113
|
return payload["data"]
|
|
108
114
|
|
|
115
|
+
|
|
116
|
+
def web_search(self, q: str, *, passages: bool = False) -> dict[str, Any]:
|
|
117
|
+
"""Search the live web with GET /v1/web-search ($5 per 1,000 searches, no quote needed)."""
|
|
118
|
+
query = (q or "").strip()
|
|
119
|
+
if not query or len(query) > 2000:
|
|
120
|
+
raise ValueError("q must be between 1 and 2,000 characters.")
|
|
121
|
+
params: dict[str, str] = {"q": query}
|
|
122
|
+
if passages:
|
|
123
|
+
params["passages"] = "true"
|
|
124
|
+
return self.request("GET", f"/v1/web-search?{urllib.parse.urlencode(params)}") or {}
|
|
125
|
+
|
|
126
|
+
def extract_pages(self, urls: list[str], *, format: str = "markdown") -> dict[str, Any]:
|
|
127
|
+
"""Extract the main text of 1 to 20 URLs live with POST /v1/extract ($0.80 per 1,000 pages)."""
|
|
128
|
+
cleaned = [str(u or "").strip() for u in (urls or []) if str(u or "").strip()]
|
|
129
|
+
if not 1 <= len(cleaned) <= 20:
|
|
130
|
+
raise ValueError("Provide 1 to 20 http or https URLs.")
|
|
131
|
+
fmt = "text" if format == "text" else "markdown"
|
|
132
|
+
return self.request("POST", "/v1/extract", {"urls": cleaned, "format": fmt}) or {}
|
|
109
133
|
def results(self, find_id: str) -> dict[str, Any]:
|
|
110
134
|
"""Every row found so far for a list, following the pages."""
|
|
111
135
|
rows: list[dict[str, Any]] = []
|
|
@@ -115,7 +139,11 @@ class SpecStreamAPI:
|
|
|
115
139
|
query = {"limit": "100", **({"cursor": cursor} if cursor else {})}
|
|
116
140
|
page = self.request("GET", f"/v1/finds/{urllib.parse.quote(find_id, safe='')}/rows?{urllib.parse.urlencode(query)}") or {}
|
|
117
141
|
status = page.get("status") or status
|
|
118
|
-
|
|
142
|
+
flat = {str(item.get("id")): item for item in page.get("flat_rows") or []}
|
|
143
|
+
rows.extend(
|
|
144
|
+
flatten_row(find_id, record, flat.get(f"{find_id}:{record.get('entity_id')}"))
|
|
145
|
+
for record in page.get("rows") or []
|
|
146
|
+
)
|
|
119
147
|
cursor = page.get("next_cursor")
|
|
120
148
|
if not cursor:
|
|
121
149
|
break
|
|
@@ -119,3 +119,86 @@ class SpecStreamListFetcher:
|
|
|
119
119
|
meta["request"] = request
|
|
120
120
|
meta["fields"] = fields
|
|
121
121
|
return Document(content=json.dumps(fields, ensure_ascii=False, default=str), meta=meta)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@component
|
|
125
|
+
class SpecStreamWebSearch:
|
|
126
|
+
"""Searches the live web in seconds with the SpecStream Search API (`GET /v1/web-search`, $5 per 1,000 searches)."""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
api_key: Secret = Secret.from_env_var("SPECSTREAM_API_KEY"),
|
|
131
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
132
|
+
):
|
|
133
|
+
self.api_key = api_key
|
|
134
|
+
self.base_url = base_url
|
|
135
|
+
|
|
136
|
+
def to_dict(self) -> dict[str, Any]:
|
|
137
|
+
return default_to_dict(self, api_key=self.api_key.to_dict(), base_url=self.base_url)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: dict[str, Any]) -> "SpecStreamWebSearch":
|
|
141
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
142
|
+
return default_from_dict(cls, data)
|
|
143
|
+
|
|
144
|
+
@component.output_types(documents=list[Document], answer=dict[str, Any])
|
|
145
|
+
def run(self, q: str, passages: bool = False) -> dict[str, Any]:
|
|
146
|
+
data = SpecStreamAPI(self.api_key.resolve_value() or "", self.base_url).web_search(q, passages=passages)
|
|
147
|
+
docs: list[Document] = []
|
|
148
|
+
for row in data.get("rows") or []:
|
|
149
|
+
if isinstance(row, dict):
|
|
150
|
+
docs.append(
|
|
151
|
+
Document(
|
|
152
|
+
content=str(row.get("snippet") or row.get("name") or ""),
|
|
153
|
+
meta={"title": row.get("name"), "source_url": row.get("page"), "fields": row.get("fields")},
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
for src in data.get("sources") or []:
|
|
157
|
+
if isinstance(src, dict):
|
|
158
|
+
docs.append(
|
|
159
|
+
Document(
|
|
160
|
+
content=str(src.get("snippet") or src.get("title") or ""),
|
|
161
|
+
meta={"title": src.get("title"), "source_url": src.get("url"), "published": src.get("published")},
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return {"documents": docs, "answer": data.get("answer") or {}}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@component
|
|
168
|
+
class SpecStreamExtractor:
|
|
169
|
+
"""Reads the main text of 1 to 20 URLs live with the SpecStream Extract API (`POST /v1/extract`, $0.80 per 1,000 pages)."""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
api_key: Secret = Secret.from_env_var("SPECSTREAM_API_KEY"),
|
|
174
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
175
|
+
):
|
|
176
|
+
self.api_key = api_key
|
|
177
|
+
self.base_url = base_url
|
|
178
|
+
|
|
179
|
+
def to_dict(self) -> dict[str, Any]:
|
|
180
|
+
return default_to_dict(self, api_key=self.api_key.to_dict(), base_url=self.base_url)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_dict(cls, data: dict[str, Any]) -> "SpecStreamExtractor":
|
|
184
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
185
|
+
return default_from_dict(cls, data)
|
|
186
|
+
|
|
187
|
+
@component.output_types(documents=list[Document])
|
|
188
|
+
def run(self, urls: list[str], format: str = "markdown") -> dict[str, Any]:
|
|
189
|
+
data = SpecStreamAPI(self.api_key.resolve_value() or "", self.base_url).extract_pages(urls, format=format)
|
|
190
|
+
docs = [
|
|
191
|
+
Document(
|
|
192
|
+
content=str(item.get("text") or ""),
|
|
193
|
+
meta={
|
|
194
|
+
"source_url": item.get("url"),
|
|
195
|
+
"title": item.get("title"),
|
|
196
|
+
"published": item.get("published"),
|
|
197
|
+
"status": item.get("status"),
|
|
198
|
+
"read_from": item.get("read_from"),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
for item in (data.get("results") or [])
|
|
202
|
+
if isinstance(item, dict)
|
|
203
|
+
]
|
|
204
|
+
return {"documents": docs}
|
|
@@ -13,6 +13,7 @@ FIND_ID = "fnd_test123"
|
|
|
13
13
|
|
|
14
14
|
def _row(ordinal, name, url):
|
|
15
15
|
return {
|
|
16
|
+
"entity_id": f"e{ordinal}",
|
|
16
17
|
"ordinal": ordinal,
|
|
17
18
|
"details": [
|
|
18
19
|
{"label": "Organization", "display": name, "provenance": {"source_url": url, "observed_at": "2026-10-14T19:56:02Z"}},
|
|
@@ -22,6 +23,18 @@ def _row(ordinal, name, url):
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
def _flat(row):
|
|
27
|
+
"""The API's flat row: the page of each value, and of the fact that put the row in the list."""
|
|
28
|
+
name = row["details"][0]
|
|
29
|
+
return {
|
|
30
|
+
"id": f"{FIND_ID}:{row['entity_id']}",
|
|
31
|
+
"name": name["display"],
|
|
32
|
+
"name_source": name["provenance"]["source_url"],
|
|
33
|
+
"source_url": f"{name['provenance']['source_url']}/filing",
|
|
34
|
+
"checked_at": "2026-10-14T19:56:02Z",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
25
38
|
class FakeApi:
|
|
26
39
|
"""Stands in for the SpecStream REST API and records the calls."""
|
|
27
40
|
|
|
@@ -43,7 +56,8 @@ class FakeApi:
|
|
|
43
56
|
if path == "/v1/capacity/executions":
|
|
44
57
|
return {"find_id": FIND_ID}
|
|
45
58
|
if "/rows" in path:
|
|
46
|
-
|
|
59
|
+
page = self.pages.pop(0) if len(self.pages) > 1 else self.pages[0]
|
|
60
|
+
return {**page, "flat_rows": [_flat(row) for row in page["rows"]]}
|
|
47
61
|
raise AssertionError(path)
|
|
48
62
|
|
|
49
63
|
def paths(self):
|
|
@@ -71,7 +85,7 @@ def test_rows_become_documents_with_source_in_meta(fake):
|
|
|
71
85
|
fake()
|
|
72
86
|
result = _fetcher().run(request="Breweries in Portland")
|
|
73
87
|
docs = result["documents"]
|
|
74
|
-
assert [d.meta["source_url"] for d in docs] == ["https://src/a", "https://src/b"]
|
|
88
|
+
assert [d.meta["source_url"] for d in docs] == ["https://src/a/filing", "https://src/b/filing"]
|
|
75
89
|
first = docs[0]
|
|
76
90
|
assert json.loads(first.content)["Organization"] == "Alpha"
|
|
77
91
|
assert first.meta["checked_at"] == "2026-10-14T19:56:02Z"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|