specstream-haystack 0.1.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.0/.gitignore +14 -0
- specstream_haystack-0.1.0/LICENSE +21 -0
- specstream_haystack-0.1.0/PKG-INFO +83 -0
- specstream_haystack-0.1.0/README.md +50 -0
- specstream_haystack-0.1.0/pyproject.toml +57 -0
- specstream_haystack-0.1.0/src/haystack_integrations/components/fetchers/specstream/__init__.py +4 -0
- specstream_haystack-0.1.0/src/haystack_integrations/components/fetchers/specstream/_api.py +217 -0
- specstream_haystack-0.1.0/src/haystack_integrations/components/fetchers/specstream/list_fetcher.py +121 -0
- specstream_haystack-0.1.0/src/haystack_integrations/components/fetchers/specstream/py.typed +0 -0
- specstream_haystack-0.1.0/src/haystack_integrations/tools/specstream/__init__.py +3 -0
- specstream_haystack-0.1.0/src/haystack_integrations/tools/specstream/list_tool.py +50 -0
- specstream_haystack-0.1.0/tests/test_specstream_haystack.py +169 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SpecStream SAS
|
|
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.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: specstream-haystack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Give a Haystack pipeline or agent any list from the live web: ask in plain words, get every matching row as a Document with the page it came from, within a price you set in code.
|
|
5
|
+
Project-URL: Homepage, https://specstream.ai
|
|
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
|
|
9
|
+
Project-URL: Pricing, https://specstream.ai/pricing
|
|
10
|
+
Author-email: SpecStream <support@specstream.ai>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agents,haystack,haystack-ai,lead-lists,live-web,research,specstream,tools,web-data
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
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 :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: docstring-parser
|
|
28
|
+
Requires-Dist: haystack-ai>=2.14.0
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
31
|
+
Requires-Dist: ruff; extra == 'test'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# specstream-haystack
|
|
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.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -U specstream-haystack
|
|
40
|
+
export SPECSTREAM_API_KEY=ss_live_... # Agents & API in your SpecStream workspace
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A free account comes with $10 of work to use within 30 days and needs no card: [specstream.ai](https://specstream.ai/signup).
|
|
44
|
+
|
|
45
|
+
## In a pipeline
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from haystack_integrations.components.fetchers.specstream import SpecStreamListFetcher
|
|
49
|
+
|
|
50
|
+
fetcher = SpecStreamListFetcher(max_price_usd=3, max_results=25)
|
|
51
|
+
result = fetcher.run(request="Breweries in Portland, Oregon that host events, with their website")
|
|
52
|
+
|
|
53
|
+
for doc in result["documents"]:
|
|
54
|
+
print(doc.meta["source_url"], doc.content)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Each `Document` is one row. `content` is the row as JSON. `meta` carries `source_url` (the page the row was read from), `checked_at`, `list_id`, `list_link`, `position`, `request` and `fields` (the row's columns as a dict).
|
|
58
|
+
|
|
59
|
+
`run` also returns `list_id`, `status`, `list_link` and a `message` when the list was still running. When the quoted price is above `max_price_usd`, or the request is too vague to price, nothing runs and `SpecStreamError` explains why.
|
|
60
|
+
|
|
61
|
+
## As an agent tool
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from haystack.components.agents import Agent
|
|
65
|
+
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
66
|
+
from haystack.dataclasses import ChatMessage
|
|
67
|
+
from haystack_integrations.tools.specstream import SpecStreamListTool
|
|
68
|
+
|
|
69
|
+
agent = Agent(
|
|
70
|
+
chat_generator=OpenAIChatGenerator(model="gpt-4o"),
|
|
71
|
+
tools=[SpecStreamListTool(max_price_usd=5)],
|
|
72
|
+
)
|
|
73
|
+
result = agent.run(messages=[ChatMessage.from_user("Find open grants for urban beekeeping in the EU and list their deadlines.")])
|
|
74
|
+
print(result["last_message"].text)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The agent writes the request and picks how many rows. You decide what one list may cost.
|
|
78
|
+
|
|
79
|
+
## Retries don't buy twice
|
|
80
|
+
|
|
81
|
+
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
|
+
|
|
83
|
+
MIT licensed. Source: [Specstreamai/specstream-tools](https://github.com/Specstreamai/specstream-tools/tree/main/packages/specstream-haystack).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# specstream-haystack
|
|
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.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install -U specstream-haystack
|
|
7
|
+
export SPECSTREAM_API_KEY=ss_live_... # Agents & API in your SpecStream workspace
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
A free account comes with $10 of work to use within 30 days and needs no card: [specstream.ai](https://specstream.ai/signup).
|
|
11
|
+
|
|
12
|
+
## In a pipeline
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from haystack_integrations.components.fetchers.specstream import SpecStreamListFetcher
|
|
16
|
+
|
|
17
|
+
fetcher = SpecStreamListFetcher(max_price_usd=3, max_results=25)
|
|
18
|
+
result = fetcher.run(request="Breweries in Portland, Oregon that host events, with their website")
|
|
19
|
+
|
|
20
|
+
for doc in result["documents"]:
|
|
21
|
+
print(doc.meta["source_url"], doc.content)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Each `Document` is one row. `content` is the row as JSON. `meta` carries `source_url` (the page the row was read from), `checked_at`, `list_id`, `list_link`, `position`, `request` and `fields` (the row's columns as a dict).
|
|
25
|
+
|
|
26
|
+
`run` also returns `list_id`, `status`, `list_link` and a `message` when the list was still running. When the quoted price is above `max_price_usd`, or the request is too vague to price, nothing runs and `SpecStreamError` explains why.
|
|
27
|
+
|
|
28
|
+
## As an agent tool
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from haystack.components.agents import Agent
|
|
32
|
+
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
33
|
+
from haystack.dataclasses import ChatMessage
|
|
34
|
+
from haystack_integrations.tools.specstream import SpecStreamListTool
|
|
35
|
+
|
|
36
|
+
agent = Agent(
|
|
37
|
+
chat_generator=OpenAIChatGenerator(model="gpt-4o"),
|
|
38
|
+
tools=[SpecStreamListTool(max_price_usd=5)],
|
|
39
|
+
)
|
|
40
|
+
result = agent.run(messages=[ChatMessage.from_user("Find open grants for urban beekeeping in the EU and list their deadlines.")])
|
|
41
|
+
print(result["last_message"].text)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The agent writes the request and picks how many rows. You decide what one list may cost.
|
|
45
|
+
|
|
46
|
+
## Retries don't buy twice
|
|
47
|
+
|
|
48
|
+
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
|
+
|
|
50
|
+
MIT licensed. Source: [Specstreamai/specstream-tools](https://github.com/Specstreamai/specstream-tools/tree/main/packages/specstream-haystack).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "specstream-haystack"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Give a Haystack pipeline or agent any list from the live web: ask in plain words, get every matching row as a Document with the page it came from, within a price you set in code."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "SpecStream", email = "support@specstream.ai" }]
|
|
10
|
+
keywords = ["haystack", "haystack-ai", "specstream", "agents", "tools", "web-data", "live-web", "lead-lists", "research"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["haystack-ai>=2.14.0", "docstring-parser"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["pytest>=8", "ruff"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://specstream.ai"
|
|
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"
|
|
35
|
+
Pricing = "https://specstream.ai/pricing"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling>=1.27,<2"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/haystack_integrations"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
pythonpath = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 140
|
|
53
|
+
target-version = "py310"
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "W"]
|
|
57
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""The SpecStream REST calls the tools share: read a request, price it, start it within a maximum, read its rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.specstream.ai"
|
|
15
|
+
USER_AGENT = "langchain-specstream/0.1.2"
|
|
16
|
+
# A list runs for up to about five minutes, so the tools wait a little longer than that by default.
|
|
17
|
+
DEFAULT_WAIT_SECONDS = 420.0
|
|
18
|
+
FINISHED = {"completed", "partial"}
|
|
19
|
+
STOPPED = {"failed", "cancelled", "canceled", "expired"}
|
|
20
|
+
# Fields every row carries; a column with the same name is renamed so it can't overwrite them.
|
|
21
|
+
RESERVED = {"list_id", "list_link", "position", "source_url", "checked_at"}
|
|
22
|
+
|
|
23
|
+
Opener = Callable[[urllib.request.Request, float], Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SpecStreamError(RuntimeError):
|
|
27
|
+
"""SpecStream refused a call or answered with something this package can't read."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _dollars(cents: int) -> str:
|
|
31
|
+
return f"${cents / 100:.2f}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_link(find_id: str) -> str:
|
|
35
|
+
return f"https://specstream.ai/workspace/collections/{urllib.parse.quote(find_id, safe='')}"
|
|
36
|
+
|
|
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."""
|
|
40
|
+
row: dict[str, Any] = {"list_id": find_id, "position": record.get("ordinal")}
|
|
41
|
+
source: dict[str, Any] | None = None
|
|
42
|
+
for cell in record.get("details") or record.get("cells") or []:
|
|
43
|
+
label = str(cell.get("label") or cell.get("column_id") or "").strip()
|
|
44
|
+
if not label:
|
|
45
|
+
continue
|
|
46
|
+
if label in RESERVED:
|
|
47
|
+
label = f"{label} (column)"
|
|
48
|
+
if cell.get("absence"):
|
|
49
|
+
value: Any = ""
|
|
50
|
+
elif cell.get("display") is not None:
|
|
51
|
+
value = cell["display"]
|
|
52
|
+
else:
|
|
53
|
+
value = cell.get("value") if cell.get("value") is not None else ""
|
|
54
|
+
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 ""
|
|
60
|
+
row["list_link"] = list_link(find_id)
|
|
61
|
+
return row
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class SpecStreamAPI:
|
|
66
|
+
api_key: str
|
|
67
|
+
base_url: str = DEFAULT_BASE_URL
|
|
68
|
+
timeout: float = 150.0
|
|
69
|
+
opener: Opener | None = None
|
|
70
|
+
sleep: Callable[[float], None] = time.sleep
|
|
71
|
+
clock: Callable[[], float] = time.monotonic
|
|
72
|
+
|
|
73
|
+
def request(self, method: str, path: str, body: dict[str, Any] | None = None, *, allow_404: bool = False) -> dict[str, Any] | None:
|
|
74
|
+
key = (self.api_key or "").strip()
|
|
75
|
+
if not key:
|
|
76
|
+
raise SpecStreamError("Set SPECSTREAM_API_KEY or pass api_key. Create a key under Agents & API in your SpecStream workspace.")
|
|
77
|
+
request = urllib.request.Request(
|
|
78
|
+
self.base_url.rstrip("/") + path,
|
|
79
|
+
data=None if body is None else json.dumps(body).encode(),
|
|
80
|
+
method=method,
|
|
81
|
+
headers={
|
|
82
|
+
"Authorization": key if key.lower().startswith("bearer ") else f"Bearer {key}",
|
|
83
|
+
"Accept": "application/json",
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
"User-Agent": USER_AGENT,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
open_ = self.opener or (lambda req, timeout: urllib.request.urlopen(req, timeout=timeout))
|
|
89
|
+
try:
|
|
90
|
+
response = open_(request, self.timeout)
|
|
91
|
+
try:
|
|
92
|
+
raw = response.read()
|
|
93
|
+
finally:
|
|
94
|
+
response.close()
|
|
95
|
+
except urllib.error.HTTPError as error:
|
|
96
|
+
if allow_404 and error.code == 404:
|
|
97
|
+
return None
|
|
98
|
+
raise SpecStreamError(_error_message(error)) from None
|
|
99
|
+
except urllib.error.URLError as error:
|
|
100
|
+
raise SpecStreamError(f"Couldn't reach SpecStream: {error.reason}") from None
|
|
101
|
+
try:
|
|
102
|
+
payload = json.loads(raw or b"{}")
|
|
103
|
+
except ValueError:
|
|
104
|
+
payload = None
|
|
105
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict):
|
|
106
|
+
raise SpecStreamError("SpecStream returned a response this package couldn't read.")
|
|
107
|
+
return payload["data"]
|
|
108
|
+
|
|
109
|
+
def results(self, find_id: str) -> dict[str, Any]:
|
|
110
|
+
"""Every row found so far for a list, following the pages."""
|
|
111
|
+
rows: list[dict[str, Any]] = []
|
|
112
|
+
cursor: str | None = None
|
|
113
|
+
status = "running"
|
|
114
|
+
while True:
|
|
115
|
+
query = {"limit": "100", **({"cursor": cursor} if cursor else {})}
|
|
116
|
+
page = self.request("GET", f"/v1/finds/{urllib.parse.quote(find_id, safe='')}/rows?{urllib.parse.urlencode(query)}") or {}
|
|
117
|
+
status = page.get("status") or status
|
|
118
|
+
rows.extend(flatten_row(find_id, record) for record in page.get("rows") or [])
|
|
119
|
+
cursor = page.get("next_cursor")
|
|
120
|
+
if not cursor:
|
|
121
|
+
break
|
|
122
|
+
return {"list_id": find_id, "status": status, "list_link": list_link(find_id), "row_count": len(rows), "rows": rows}
|
|
123
|
+
|
|
124
|
+
def find_list(
|
|
125
|
+
self,
|
|
126
|
+
request: str,
|
|
127
|
+
*,
|
|
128
|
+
max_results: int,
|
|
129
|
+
max_price_usd: float,
|
|
130
|
+
wait_seconds: float = DEFAULT_WAIT_SECONDS,
|
|
131
|
+
poll_interval: float = 3.0,
|
|
132
|
+
locale: str = "en-US",
|
|
133
|
+
key_prefix: str = "haystack",
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
q = (request or "").strip()
|
|
136
|
+
if not q:
|
|
137
|
+
raise ValueError("Describe the list to find.")
|
|
138
|
+
if not isinstance(max_results, int) or max_results < 1:
|
|
139
|
+
raise ValueError("max_results must be 1 or more.")
|
|
140
|
+
max_cents = round(float(max_price_usd) * 100)
|
|
141
|
+
if max_cents <= 0:
|
|
142
|
+
raise ValueError("max_price_usd must be greater than 0.")
|
|
143
|
+
|
|
144
|
+
# The same request with the same limits gets the run it already started in this hour or the one before,
|
|
145
|
+
# so a retried agent step never buys the list twice, even across the turn of the hour.
|
|
146
|
+
def key_for(at: float) -> str:
|
|
147
|
+
hour = time.strftime("%Y-%m-%dT%H", time.gmtime(at))
|
|
148
|
+
return f"{key_prefix}-" + hashlib.sha256(f"{q}|{max_results}|{max_cents}|{hour}".encode()).hexdigest()[:32]
|
|
149
|
+
|
|
150
|
+
now = time.time()
|
|
151
|
+
request_key = key_for(now)
|
|
152
|
+
existing = None
|
|
153
|
+
for candidate in (request_key, key_for(now - 3600)):
|
|
154
|
+
existing = self.request("GET", f"/v1/capacity/requests/{urllib.parse.quote(candidate, safe='')}", allow_404=True)
|
|
155
|
+
if existing and existing.get("find_id"):
|
|
156
|
+
break
|
|
157
|
+
if existing and existing.get("find_id"):
|
|
158
|
+
find_id = existing["find_id"]
|
|
159
|
+
maximum_cents = int(existing.get("maximum_cents") or max_cents)
|
|
160
|
+
else:
|
|
161
|
+
reading = self.request("POST", "/v1/parse", {"q": q, "locale": locale}) or {}
|
|
162
|
+
if reading.get("status") != "runnable":
|
|
163
|
+
read_as = reading.get("canonical_sentence") or q
|
|
164
|
+
return {
|
|
165
|
+
"status": "needs_edit",
|
|
166
|
+
"request": q,
|
|
167
|
+
"message": f'SpecStream needs a clearer request before it can price it. It read: "{read_as}". Add the place or what every row must meet.',
|
|
168
|
+
}
|
|
169
|
+
quote = (
|
|
170
|
+
self.request(
|
|
171
|
+
"POST", "/v1/capacity/quotes", {"ast_hash": reading["ast_hash"], "operation": "find", "requested_units": max_results}
|
|
172
|
+
)
|
|
173
|
+
or {}
|
|
174
|
+
)
|
|
175
|
+
maximum_cents = int(quote.get("maximum_cents") or 0)
|
|
176
|
+
if maximum_cents > max_cents:
|
|
177
|
+
return {
|
|
178
|
+
"status": "over_maximum",
|
|
179
|
+
"request": q,
|
|
180
|
+
"maximum_price_usd": maximum_cents / 100,
|
|
181
|
+
"message": f"Nothing ran: this list can cost up to {_dollars(maximum_cents)}, above the maximum of {_dollars(max_cents)}. Ask for fewer results or raise max_price_usd.",
|
|
182
|
+
}
|
|
183
|
+
run = (
|
|
184
|
+
self.request(
|
|
185
|
+
"POST",
|
|
186
|
+
"/v1/capacity/executions",
|
|
187
|
+
{
|
|
188
|
+
"quote_id": quote["quote_id"],
|
|
189
|
+
"quote_hash": quote["quote_hash"],
|
|
190
|
+
"confirm_max_cents": maximum_cents,
|
|
191
|
+
"idempotency_key": request_key,
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
or {}
|
|
195
|
+
)
|
|
196
|
+
find_id = run["find_id"]
|
|
197
|
+
|
|
198
|
+
deadline = self.clock() + max(0.0, wait_seconds)
|
|
199
|
+
while True:
|
|
200
|
+
result = self.results(find_id)
|
|
201
|
+
if result["status"] in FINISHED | STOPPED or self.clock() >= deadline:
|
|
202
|
+
break
|
|
203
|
+
self.sleep(poll_interval)
|
|
204
|
+
result.update({"request": q, "maximum_price_usd": maximum_cents / 100})
|
|
205
|
+
if result["status"] not in FINISHED | STOPPED:
|
|
206
|
+
result["message"] = "Still running. Run the same request again, or open list_link, for the rest."
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _error_message(error: urllib.error.HTTPError) -> str:
|
|
211
|
+
try:
|
|
212
|
+
payload = json.loads(error.read() or b"{}")
|
|
213
|
+
except ValueError:
|
|
214
|
+
payload = {}
|
|
215
|
+
detail = payload.get("error") if isinstance(payload, dict) else None
|
|
216
|
+
message = detail.get("message") if isinstance(detail, dict) else detail if isinstance(detail, str) else None
|
|
217
|
+
return f"SpecStream answered {error.code}: {message or error.reason}"
|
specstream_haystack-0.1.0/src/haystack_integrations/components/fetchers/specstream/list_fetcher.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from haystack import Document, component, default_from_dict, default_to_dict
|
|
5
|
+
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
6
|
+
|
|
7
|
+
from haystack_integrations.components.fetchers.specstream._api import (
|
|
8
|
+
DEFAULT_BASE_URL,
|
|
9
|
+
DEFAULT_WAIT_SECONDS,
|
|
10
|
+
SpecStreamAPI,
|
|
11
|
+
SpecStreamError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Row fields that say where a row came from rather than what it says.
|
|
15
|
+
_META = ("list_id", "list_link", "position", "source_url", "checked_at")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@component
|
|
19
|
+
class SpecStreamListFetcher:
|
|
20
|
+
"""
|
|
21
|
+
Turns a plain-English request into the finished list: one Document per matching row.
|
|
22
|
+
|
|
23
|
+
The Document content is the row as JSON. The metadata carries the page the row was read from
|
|
24
|
+
(`source_url`), when it was checked, the list it belongs to, and the row's fields.
|
|
25
|
+
The list starts only if its price is within `max_price_usd`, which you set in code.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from haystack_integrations.components.fetchers.specstream import SpecStreamListFetcher
|
|
29
|
+
|
|
30
|
+
fetcher = SpecStreamListFetcher(max_price_usd=3, max_results=25)
|
|
31
|
+
result = fetcher.run(request="Breweries in Portland, Oregon that host events, with their website")
|
|
32
|
+
for doc in result["documents"]:
|
|
33
|
+
print(doc.meta["source_url"], doc.content)
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
max_price_usd: float,
|
|
40
|
+
api_key: Secret = Secret.from_env_var("SPECSTREAM_API_KEY"),
|
|
41
|
+
max_results: int = 25,
|
|
42
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
43
|
+
wait_seconds: float = DEFAULT_WAIT_SECONDS,
|
|
44
|
+
poll_interval: float = 3.0,
|
|
45
|
+
locale: str = "en-US",
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
:param max_price_usd: The most one list may cost, in US dollars. If the quoted price is higher, nothing runs.
|
|
49
|
+
:param api_key: A SpecStream API key. Read from `SPECSTREAM_API_KEY` by default.
|
|
50
|
+
:param max_results: How many matching rows to find when `run` doesn't say.
|
|
51
|
+
:param base_url: The SpecStream API address.
|
|
52
|
+
:param wait_seconds: How long to wait for the list to finish before returning the rows found so far.
|
|
53
|
+
:param poll_interval: Seconds between checks while the list runs.
|
|
54
|
+
:param locale: The language the request is read in.
|
|
55
|
+
"""
|
|
56
|
+
if not max_price_usd or max_price_usd <= 0:
|
|
57
|
+
raise ValueError("max_price_usd must be greater than 0.")
|
|
58
|
+
if max_results < 1:
|
|
59
|
+
raise ValueError("max_results must be 1 or more.")
|
|
60
|
+
self.max_price_usd = max_price_usd
|
|
61
|
+
self.api_key = api_key
|
|
62
|
+
self.max_results = max_results
|
|
63
|
+
self.base_url = base_url
|
|
64
|
+
self.wait_seconds = wait_seconds
|
|
65
|
+
self.poll_interval = poll_interval
|
|
66
|
+
self.locale = locale
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
return default_to_dict(
|
|
70
|
+
self,
|
|
71
|
+
max_price_usd=self.max_price_usd,
|
|
72
|
+
api_key=self.api_key.to_dict(),
|
|
73
|
+
max_results=self.max_results,
|
|
74
|
+
base_url=self.base_url,
|
|
75
|
+
wait_seconds=self.wait_seconds,
|
|
76
|
+
poll_interval=self.poll_interval,
|
|
77
|
+
locale=self.locale,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, data: dict[str, Any]) -> "SpecStreamListFetcher":
|
|
82
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
83
|
+
return default_from_dict(cls, data)
|
|
84
|
+
|
|
85
|
+
@component.output_types(documents=list[Document], list_id=str, status=str, list_link=str, message=str)
|
|
86
|
+
def run(self, request: str, max_results: Optional[int] = None) -> dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
Find the list and return its rows.
|
|
89
|
+
|
|
90
|
+
:param request: The list to find, in plain words: what every row must be or meet, and the details to bring back.
|
|
91
|
+
:param max_results: How many matching rows to find. Defaults to the value set at init.
|
|
92
|
+
:returns: `documents` (one per row), `list_id`, `status`, `list_link` and, when the list was still running, a `message`.
|
|
93
|
+
:raises SpecStreamError: When the request is too vague to price, or the price is above `max_price_usd`.
|
|
94
|
+
"""
|
|
95
|
+
result = SpecStreamAPI(self.api_key.resolve_value() or "", self.base_url).find_list(
|
|
96
|
+
request,
|
|
97
|
+
max_results=max_results or self.max_results,
|
|
98
|
+
max_price_usd=self.max_price_usd,
|
|
99
|
+
wait_seconds=self.wait_seconds,
|
|
100
|
+
poll_interval=self.poll_interval,
|
|
101
|
+
locale=self.locale,
|
|
102
|
+
key_prefix="haystack",
|
|
103
|
+
)
|
|
104
|
+
if result["status"] in ("needs_edit", "over_maximum"):
|
|
105
|
+
raise SpecStreamError(result["message"])
|
|
106
|
+
documents = [self._to_document(row, result.get("request")) for row in result["rows"]]
|
|
107
|
+
return {
|
|
108
|
+
"documents": documents,
|
|
109
|
+
"list_id": result["list_id"],
|
|
110
|
+
"status": result["status"],
|
|
111
|
+
"list_link": result["list_link"],
|
|
112
|
+
"message": result.get("message", ""),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _to_document(row: dict[str, Any], request: Optional[str]) -> Document:
|
|
117
|
+
fields = {label: value for label, value in row.items() if label not in _META}
|
|
118
|
+
meta: dict[str, Any] = {key: row.get(key) for key in _META}
|
|
119
|
+
meta["request"] = request
|
|
120
|
+
meta["fields"] = fields
|
|
121
|
+
return Document(content=json.dumps(fields, ensure_ascii=False, default=str), meta=meta)
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from haystack.tools import ComponentTool
|
|
4
|
+
from haystack.utils import Secret
|
|
5
|
+
|
|
6
|
+
from haystack_integrations.components.fetchers.specstream import SpecStreamListFetcher
|
|
7
|
+
|
|
8
|
+
DESCRIPTION = (
|
|
9
|
+
"Find a list of real organizations, people, places or records on the live web from a plain-English request, "
|
|
10
|
+
"and get back every matching row with the page it was read from. Use it when the answer is many items that "
|
|
11
|
+
"meet conditions (companies in a city, clinics with a service, funding filings, open grants, tenders, "
|
|
12
|
+
"repositories), not a single fact. Results cost money: the list starts only if its price is within the "
|
|
13
|
+
"maximum the developer set, and only matching rows are paid for."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _rows_as_text(documents: list) -> str:
|
|
18
|
+
return "\n".join(f"{doc.content} (source: {doc.meta.get('source_url') or 'n/a'})" for doc in documents)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SpecStreamListTool(ComponentTool):
|
|
22
|
+
"""
|
|
23
|
+
A Haystack tool an agent can call to get a finished list from the live web.
|
|
24
|
+
|
|
25
|
+
The model writes the request and picks how many rows. The price ceiling is set here, in code, and the model can't raise it.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from haystack.components.agents import Agent
|
|
29
|
+
from haystack.components.generators.chat import OpenAIChatGenerator
|
|
30
|
+
from haystack.dataclasses import ChatMessage
|
|
31
|
+
from haystack_integrations.tools.specstream import SpecStreamListTool
|
|
32
|
+
|
|
33
|
+
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4o"), tools=[SpecStreamListTool(max_price_usd=5)])
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
max_price_usd: float,
|
|
40
|
+
api_key: Secret = Secret.from_env_var("SPECSTREAM_API_KEY"),
|
|
41
|
+
name: str = "specstream_find_list",
|
|
42
|
+
description: str = DESCRIPTION,
|
|
43
|
+
**fetcher_kwargs: Any,
|
|
44
|
+
):
|
|
45
|
+
super().__init__(
|
|
46
|
+
component=SpecStreamListFetcher(max_price_usd=max_price_usd, api_key=api_key, **fetcher_kwargs),
|
|
47
|
+
name=name,
|
|
48
|
+
description=description,
|
|
49
|
+
outputs_to_string={"source": "documents", "handler": _rows_as_text},
|
|
50
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from haystack import Pipeline
|
|
5
|
+
from haystack.tools import ComponentTool
|
|
6
|
+
from haystack.utils import Secret
|
|
7
|
+
|
|
8
|
+
from haystack_integrations.components.fetchers.specstream import SpecStreamError, SpecStreamListFetcher, _api
|
|
9
|
+
from haystack_integrations.tools.specstream import SpecStreamListTool
|
|
10
|
+
|
|
11
|
+
FIND_ID = "fnd_test123"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _row(ordinal, name, url):
|
|
15
|
+
return {
|
|
16
|
+
"ordinal": ordinal,
|
|
17
|
+
"details": [
|
|
18
|
+
{"label": "Organization", "display": name, "provenance": {"source_url": url, "observed_at": "2026-10-14T19:56:02Z"}},
|
|
19
|
+
{"label": "Website", "display": f"https://{name.lower()}.example"},
|
|
20
|
+
{"label": "source_url", "display": "clashes"},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FakeApi:
|
|
26
|
+
"""Stands in for the SpecStream REST API and records the calls."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, parse=None, maximum_cents=300, existing=None, pages=None):
|
|
29
|
+
self.calls = []
|
|
30
|
+
self.parse = parse or {"status": "runnable", "ast_hash": "h1"}
|
|
31
|
+
self.maximum_cents = maximum_cents
|
|
32
|
+
self.existing = existing
|
|
33
|
+
self.pages = pages or [{"status": "completed", "rows": [_row(1, "Alpha", "https://src/a"), _row(2, "Beta", "https://src/b")]}]
|
|
34
|
+
|
|
35
|
+
def request(self, api, method, path, body=None, *, allow_404=False):
|
|
36
|
+
self.calls.append((method, path, body))
|
|
37
|
+
if path == "/v1/parse":
|
|
38
|
+
return self.parse
|
|
39
|
+
if path.startswith("/v1/capacity/requests/"):
|
|
40
|
+
return self.existing
|
|
41
|
+
if path == "/v1/capacity/quotes":
|
|
42
|
+
return {"quote_id": "q1", "quote_hash": "qh", "maximum_cents": self.maximum_cents}
|
|
43
|
+
if path == "/v1/capacity/executions":
|
|
44
|
+
return {"find_id": FIND_ID}
|
|
45
|
+
if "/rows" in path:
|
|
46
|
+
return self.pages.pop(0) if len(self.pages) > 1 else self.pages[0]
|
|
47
|
+
raise AssertionError(path)
|
|
48
|
+
|
|
49
|
+
def paths(self):
|
|
50
|
+
return [p for _, p, _ in self.calls]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def fake(monkeypatch):
|
|
55
|
+
def install(**kwargs):
|
|
56
|
+
api = FakeApi(**kwargs)
|
|
57
|
+
monkeypatch.setattr(_api.SpecStreamAPI, "request", lambda self, *a, **k: api.request(self, *a, **k))
|
|
58
|
+
monkeypatch.setattr(_api.SpecStreamAPI, "sleep", lambda s: None, raising=False)
|
|
59
|
+
return api
|
|
60
|
+
|
|
61
|
+
return install
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fetcher(**kwargs):
|
|
65
|
+
return SpecStreamListFetcher(
|
|
66
|
+
max_price_usd=kwargs.pop("max_price_usd", 5), api_key=Secret.from_token("ss_test"), poll_interval=0, **kwargs
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_rows_become_documents_with_source_in_meta(fake):
|
|
71
|
+
fake()
|
|
72
|
+
result = _fetcher().run(request="Breweries in Portland")
|
|
73
|
+
docs = result["documents"]
|
|
74
|
+
assert [d.meta["source_url"] for d in docs] == ["https://src/a", "https://src/b"]
|
|
75
|
+
first = docs[0]
|
|
76
|
+
assert json.loads(first.content)["Organization"] == "Alpha"
|
|
77
|
+
assert first.meta["checked_at"] == "2026-10-14T19:56:02Z"
|
|
78
|
+
assert first.meta["list_id"] == FIND_ID
|
|
79
|
+
assert first.meta["list_link"].endswith(FIND_ID)
|
|
80
|
+
assert first.meta["fields"]["Website"] == "https://alpha.example"
|
|
81
|
+
# A column called source_url can't overwrite the real source.
|
|
82
|
+
assert "source_url (column)" in first.meta["fields"]
|
|
83
|
+
assert result["status"] == "completed" and result["list_id"] == FIND_ID
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_runs_the_quote_then_executes_with_the_quoted_ceiling_and_key(fake):
|
|
87
|
+
api = fake(maximum_cents=250)
|
|
88
|
+
_fetcher().run(request="Breweries in Portland", max_results=10)
|
|
89
|
+
quote = next(b for _, p, b in api.calls if p == "/v1/capacity/quotes")
|
|
90
|
+
assert quote["requested_units"] == 10
|
|
91
|
+
run = next(b for _, p, b in api.calls if p == "/v1/capacity/executions")
|
|
92
|
+
assert run["confirm_max_cents"] == 250
|
|
93
|
+
assert run["idempotency_key"].startswith("haystack-")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_price_above_the_ceiling_runs_nothing(fake):
|
|
97
|
+
api = fake(maximum_cents=900)
|
|
98
|
+
with pytest.raises(SpecStreamError, match="above the maximum"):
|
|
99
|
+
_fetcher(max_price_usd=5).run(request="Breweries in Portland")
|
|
100
|
+
assert "/v1/capacity/executions" not in api.paths()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_vague_request_raises_with_the_reading(fake):
|
|
104
|
+
api = fake(parse={"status": "needs_edit", "canonical_sentence": "clinics"})
|
|
105
|
+
with pytest.raises(SpecStreamError, match="clinics"):
|
|
106
|
+
_fetcher().run(request="clinics")
|
|
107
|
+
assert "/v1/capacity/quotes" not in api.paths()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_a_retried_request_reaches_the_run_it_already_started(fake):
|
|
111
|
+
api = fake(existing={"find_id": FIND_ID, "maximum_cents": 300})
|
|
112
|
+
result = _fetcher().run(request="Breweries in Portland")
|
|
113
|
+
assert result["list_id"] == FIND_ID
|
|
114
|
+
assert "/v1/capacity/executions" not in api.paths()
|
|
115
|
+
assert "/v1/parse" not in api.paths()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_follows_row_pages(fake):
|
|
119
|
+
fake(
|
|
120
|
+
pages=[
|
|
121
|
+
{"status": "running", "rows": [_row(1, "Alpha", "https://src/a")], "next_cursor": "c2"},
|
|
122
|
+
{"status": "completed", "rows": [_row(2, "Beta", "https://src/b")]},
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
assert len(_fetcher().run(request="Breweries in Portland")["documents"]) == 2
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_unfinished_list_says_so(fake, monkeypatch):
|
|
129
|
+
fake(pages=[{"status": "running", "rows": [_row(1, "Alpha", "https://src/a")]}])
|
|
130
|
+
result = _fetcher(wait_seconds=0).run(request="Breweries in Portland")
|
|
131
|
+
assert result["status"] == "running"
|
|
132
|
+
assert "Still running" in result["message"]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_missing_key_is_explained(monkeypatch):
|
|
136
|
+
monkeypatch.delenv("SPECSTREAM_API_KEY", raising=False)
|
|
137
|
+
with pytest.raises(Exception):
|
|
138
|
+
SpecStreamListFetcher(max_price_usd=1).run(request="x")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_init_rejects_a_zero_ceiling():
|
|
142
|
+
with pytest.raises(ValueError):
|
|
143
|
+
SpecStreamListFetcher(max_price_usd=0)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_serialization_round_trip_keeps_the_key_out():
|
|
147
|
+
data = SpecStreamListFetcher(max_price_usd=2, max_results=7).to_dict()
|
|
148
|
+
assert data["init_parameters"]["api_key"] == {"type": "env_var", "env_vars": ["SPECSTREAM_API_KEY"], "strict": True}
|
|
149
|
+
again = SpecStreamListFetcher.from_dict(data)
|
|
150
|
+
assert again.max_price_usd == 2 and again.max_results == 7
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_works_in_a_pipeline(fake):
|
|
154
|
+
fake()
|
|
155
|
+
pipe = Pipeline()
|
|
156
|
+
pipe.add_component("fetcher", _fetcher())
|
|
157
|
+
out = pipe.run({"fetcher": {"request": "Breweries in Portland"}})
|
|
158
|
+
assert len(out["fetcher"]["documents"]) == 2
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_tool_hides_the_price_from_the_model_and_returns_rows_with_sources(fake):
|
|
162
|
+
fake()
|
|
163
|
+
tool = SpecStreamListTool(max_price_usd=5, api_key=Secret.from_token("ss_test"), poll_interval=0)
|
|
164
|
+
assert isinstance(tool, ComponentTool)
|
|
165
|
+
assert tool.name == "specstream_find_list"
|
|
166
|
+
assert set(tool.parameters["properties"]) == {"request", "max_results"}
|
|
167
|
+
result = tool.invoke(request="Breweries in Portland")
|
|
168
|
+
text = tool.outputs_to_string["handler"](result["documents"])
|
|
169
|
+
assert "Alpha" in text and "https://src/b" in text
|