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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: specstream-haystack
3
- Version: 0.1.1
4
- Summary: Give a Haystack pipeline or agent lead lists, company lists and grants from the live web. Every row comes with its source, priced per match in code.
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/Specstreamai/specstream-tools/tree/main/packages/specstream-haystack
8
- Project-URL: Issues, https://github.com/Specstreamai/specstream-tools/issues
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
- Give your Haystack pipeline or agent any list from the live web. Search components hand a pipeline pages to read; SpecStream hands it the finished list. Ask in plain words – "dental clinics in Lisbon with online booking", "US startups that filed a $5M+ Form D this week" – and get back every matching row as a `Document`, each with the page it was read from. The list starts only within a price you set in code, and the model can't raise it.
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 work to use within 30 days and needs no card: [specstream.ai](https://specstream.ai/signup).
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: [Specstreamai/specstream-tools](https://github.com/Specstreamai/specstream-tools/tree/main/packages/specstream-haystack).
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
- Give your Haystack pipeline or agent any list from the live web. Search components hand a pipeline pages to read; SpecStream hands it the finished list. Ask in plain words – "dental clinics in Lisbon with online booking", "US startups that filed a $5M+ Form D this week" – and get back every matching row as a `Document`, each with the page it was read from. The list starts only within a price you set in code, and the model can't raise it.
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 work to use within 30 days and needs no card: [specstream.ai](https://specstream.ai/signup).
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: [Specstreamai/specstream-tools](https://github.com/Specstreamai/specstream-tools/tree/main/packages/specstream-haystack).
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.1.1"
4
- description = "Give a Haystack pipeline or agent lead lists, company lists and grants from the live web. Every row comes with its source, priced per match in code."
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/Specstreamai/specstream-tools/tree/main/packages/specstream-haystack"
34
- Issues = "https://github.com/Specstreamai/specstream-tools/issues"
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]
@@ -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.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 flatten_row(find_id: str, record: dict[str, Any]) -> dict[str, Any]:
39
- """One row as flat fields: each column's label holds what the list shows, plus where the row was read."""
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
- provenance = cell.get("provenance") or {}
56
- if source is None and str(provenance.get("source_url") or "").startswith("http"):
57
- source = provenance
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
- rows.extend(flatten_row(find_id, record) for record in page.get("rows") or [])
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
- return self.pages.pop(0) if len(self.pages) > 1 else self.pages[0]
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"
@@ -1,4 +0,0 @@
1
- from haystack_integrations.components.fetchers.specstream._api import SpecStreamError
2
- from haystack_integrations.components.fetchers.specstream.list_fetcher import SpecStreamListFetcher
3
-
4
- __all__ = ["SpecStreamError", "SpecStreamListFetcher"]