langchain-hyperresearch 0.1.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.
- langchain_hyperresearch/__init__.py +27 -0
- langchain_hyperresearch/_client.py +301 -0
- langchain_hyperresearch/py.typed +0 -0
- langchain_hyperresearch/retrievers.py +78 -0
- langchain_hyperresearch/tools.py +202 -0
- langchain_hyperresearch-0.1.0.dist-info/METADATA +194 -0
- langchain_hyperresearch-0.1.0.dist-info/RECORD +9 -0
- langchain_hyperresearch-0.1.0.dist-info/WHEEL +4 -0
- langchain_hyperresearch-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""LangChain integration for Hyperresearch Answers."""
|
|
2
|
+
|
|
3
|
+
from langchain_hyperresearch._client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
HyperresearchClient,
|
|
6
|
+
HyperresearchError,
|
|
7
|
+
__version__,
|
|
8
|
+
)
|
|
9
|
+
from langchain_hyperresearch.retrievers import HyperresearchRetriever
|
|
10
|
+
from langchain_hyperresearch.tools import (
|
|
11
|
+
HyperresearchAnswerInput,
|
|
12
|
+
HyperresearchAnswerTool,
|
|
13
|
+
HyperresearchSearchInput,
|
|
14
|
+
HyperresearchSearchTool,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_BASE_URL",
|
|
19
|
+
"HyperresearchAnswerInput",
|
|
20
|
+
"HyperresearchAnswerTool",
|
|
21
|
+
"HyperresearchClient",
|
|
22
|
+
"HyperresearchError",
|
|
23
|
+
"HyperresearchRetriever",
|
|
24
|
+
"HyperresearchSearchInput",
|
|
25
|
+
"HyperresearchSearchTool",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""A small client for the Hyperresearch Answers REST API.
|
|
2
|
+
|
|
3
|
+
POST /v1/answers ask: one short answer checked against quoted sources, or an honest not_found
|
|
4
|
+
POST /v1/answers/search search: ranked sources with word-for-word passages, no synthesis
|
|
5
|
+
GET /v1/answers/{id} a stored call (used to collect an ask that came back 202 pending)
|
|
6
|
+
|
|
7
|
+
Auth is ``Authorization: Bearer <hr_live_... key>`` with the ``answers:ask`` scope.
|
|
8
|
+
Every call is billed from the account's prepaid Answers balance.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Literal, Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.hyperresearch.ai"
|
|
22
|
+
API_KEY_ENV = "HYPERRESEARCH_API_KEY"
|
|
23
|
+
BASE_URL_ENV = "HYPERRESEARCH_BASE_URL"
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
AskMode = Literal["fast", "standard", "max"]
|
|
27
|
+
SearchMode = Literal["standard", "max"]
|
|
28
|
+
|
|
29
|
+
POLL_WAIT_SECONDS = 45
|
|
30
|
+
MAX_RETRY_DELAY_S = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HyperresearchError(Exception):
|
|
34
|
+
"""A non-2xx response, carrying the API's error envelope."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
status: int,
|
|
39
|
+
message: str,
|
|
40
|
+
*,
|
|
41
|
+
type: Optional[str] = None,
|
|
42
|
+
code: Optional[str] = None,
|
|
43
|
+
param: Optional[str] = None,
|
|
44
|
+
request_id: Optional[str] = None,
|
|
45
|
+
retry_after: Optional[float] = None,
|
|
46
|
+
body: Any = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.status = status
|
|
50
|
+
self.message = message
|
|
51
|
+
self.type = type
|
|
52
|
+
self.code = code
|
|
53
|
+
self.param = param
|
|
54
|
+
self.request_id = request_id
|
|
55
|
+
self.retry_after = retry_after
|
|
56
|
+
self.body = body
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return f"HyperresearchError(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse(response: httpx.Response) -> dict[str, Any]:
|
|
63
|
+
try:
|
|
64
|
+
data = response.json() if response.content else {}
|
|
65
|
+
except ValueError:
|
|
66
|
+
data = {"raw": response.text}
|
|
67
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _err(body: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
+
e = body.get("error")
|
|
72
|
+
return e if isinstance(e, dict) else {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _retry_after(response: httpx.Response, body: dict[str, Any]) -> Optional[float]:
|
|
76
|
+
err = _err(body)
|
|
77
|
+
if isinstance(err.get("retry_after"), (int, float)):
|
|
78
|
+
return float(err["retry_after"])
|
|
79
|
+
try:
|
|
80
|
+
return float(response.headers.get("Retry-After", ""))
|
|
81
|
+
except ValueError:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _error(response: httpx.Response, body: dict[str, Any]) -> HyperresearchError:
|
|
86
|
+
err = _err(body)
|
|
87
|
+
return HyperresearchError(
|
|
88
|
+
response.status_code,
|
|
89
|
+
err.get("message") or f"Hyperresearch API error {response.status_code}",
|
|
90
|
+
type=err.get("type"),
|
|
91
|
+
code=err.get("code"),
|
|
92
|
+
param=err.get("param"),
|
|
93
|
+
request_id=err.get("request_id"),
|
|
94
|
+
retry_after=_retry_after(response, body),
|
|
95
|
+
body=body,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class HyperresearchClient:
|
|
100
|
+
"""Sync and async access to the Answers API.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
api_key: Defaults to the ``HYPERRESEARCH_API_KEY`` environment variable.
|
|
104
|
+
base_url: Defaults to ``HYPERRESEARCH_BASE_URL``, then https://api.hyperresearch.ai.
|
|
105
|
+
ask_timeout: Seconds an ask may take end to end, including polling. Default 360 (max mode runs up to about 5 minutes).
|
|
106
|
+
search_timeout: Seconds a search may take. Default 90.
|
|
107
|
+
max_retries: Retries on 429/503 (capacity), honouring Retry-After. Default 2.
|
|
108
|
+
transport / async_transport: httpx transports (tests, proxies).
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
api_key: Optional[str] = None,
|
|
114
|
+
base_url: Optional[str] = None,
|
|
115
|
+
*,
|
|
116
|
+
ask_timeout: float = 360.0,
|
|
117
|
+
search_timeout: float = 90.0,
|
|
118
|
+
max_retries: int = 2,
|
|
119
|
+
headers: Optional[dict[str, str]] = None,
|
|
120
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
121
|
+
async_transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self.api_key = api_key or os.environ.get(API_KEY_ENV)
|
|
124
|
+
self.base_url = (base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL).rstrip("/")
|
|
125
|
+
self.ask_timeout = ask_timeout
|
|
126
|
+
self.search_timeout = search_timeout
|
|
127
|
+
self.max_retries = max_retries
|
|
128
|
+
self.extra_headers = dict(headers or {})
|
|
129
|
+
self._transport = transport
|
|
130
|
+
self._async_transport = async_transport
|
|
131
|
+
# Test seams.
|
|
132
|
+
self._sleep = time.sleep
|
|
133
|
+
self._asleep = asyncio.sleep
|
|
134
|
+
|
|
135
|
+
# ---- shared ----
|
|
136
|
+
|
|
137
|
+
def _headers(self, extra: Optional[dict[str, str]] = None) -> dict[str, str]:
|
|
138
|
+
if not self.api_key:
|
|
139
|
+
raise HyperresearchError(
|
|
140
|
+
401,
|
|
141
|
+
f"No Hyperresearch API key. Set {API_KEY_ENV} or pass api_key. "
|
|
142
|
+
"Create one with the answers:ask scope at https://console.hyperresearch.ai/#/keys",
|
|
143
|
+
code="missing_api_key",
|
|
144
|
+
)
|
|
145
|
+
return {
|
|
146
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
147
|
+
"Accept": "application/json",
|
|
148
|
+
"User-Agent": f"langchain-hyperresearch/{__version__}",
|
|
149
|
+
**self.extra_headers,
|
|
150
|
+
**(extra or {}),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _ask_body(question: str, mode: str, as_of: Optional[str], max_cost_cents: Optional[float]) -> dict[str, Any]:
|
|
155
|
+
body: dict[str, Any] = {"question": question, "mode": mode}
|
|
156
|
+
if as_of is not None:
|
|
157
|
+
body["as_of"] = as_of
|
|
158
|
+
if max_cost_cents is not None:
|
|
159
|
+
body["max_cost_cents"] = max_cost_cents
|
|
160
|
+
return body
|
|
161
|
+
|
|
162
|
+
def _delay(self, attempt: int, retry_after: Optional[float]) -> float:
|
|
163
|
+
return min(retry_after if retry_after is not None else 2.0**attempt, MAX_RETRY_DELAY_S)
|
|
164
|
+
|
|
165
|
+
# ---- sync ----
|
|
166
|
+
|
|
167
|
+
def _request(
|
|
168
|
+
self, client: httpx.Client, method: str, path: str, *, json: Any = None, headers: Optional[dict[str, str]] = None
|
|
169
|
+
) -> tuple[int, dict[str, Any]]:
|
|
170
|
+
attempt = 0
|
|
171
|
+
while True:
|
|
172
|
+
response = client.request(method, f"{self.base_url}{path}", json=json, headers=self._headers(headers))
|
|
173
|
+
body = _parse(response)
|
|
174
|
+
if response.is_success:
|
|
175
|
+
return response.status_code, body
|
|
176
|
+
if response.status_code in (429, 503) and attempt < self.max_retries:
|
|
177
|
+
self._sleep(self._delay(attempt, _retry_after(response, body)))
|
|
178
|
+
attempt += 1
|
|
179
|
+
continue
|
|
180
|
+
raise _error(response, body)
|
|
181
|
+
|
|
182
|
+
def ask(self, question: str, mode: AskMode = "standard", *, as_of: Optional[str] = None, max_cost_cents: Optional[float] = None) -> dict[str, Any]:
|
|
183
|
+
"""Ask one question. A 202 ``pending`` is collected from GET /v1/answers/{id} (charged once)."""
|
|
184
|
+
deadline = time.monotonic() + self.ask_timeout
|
|
185
|
+
with httpx.Client(transport=self._transport, timeout=httpx.Timeout(self.ask_timeout)) as client:
|
|
186
|
+
status, body = self._request(
|
|
187
|
+
client,
|
|
188
|
+
"POST",
|
|
189
|
+
"/v1/answers",
|
|
190
|
+
json=self._ask_body(question, mode, as_of, max_cost_cents),
|
|
191
|
+
headers={"Idempotency-Key": str(uuid.uuid4()), "Prefer": f"respond-async; wait={POLL_WAIT_SECONDS + 5}"},
|
|
192
|
+
)
|
|
193
|
+
while status == 202 or body.get("status") == "pending":
|
|
194
|
+
call_id = body.get("id")
|
|
195
|
+
if not call_id:
|
|
196
|
+
raise HyperresearchError(status, "The API answered pending without a call id.", body=body)
|
|
197
|
+
if time.monotonic() > deadline:
|
|
198
|
+
raise HyperresearchError(
|
|
199
|
+
408, f"Ask {call_id} did not finish within {self.ask_timeout:.0f}s; fetch it later with get_call().", code="client_timeout", body=body
|
|
200
|
+
)
|
|
201
|
+
status, body = self._request(client, "GET", f"/v1/answers/{call_id}?wait={POLL_WAIT_SECONDS}")
|
|
202
|
+
return body
|
|
203
|
+
|
|
204
|
+
def search(self, query: str, max_results: int = 10, mode: SearchMode = "standard") -> dict[str, Any]:
|
|
205
|
+
"""Ranked raw sources with verbatim passages."""
|
|
206
|
+
with httpx.Client(transport=self._transport, timeout=httpx.Timeout(self.search_timeout)) as client:
|
|
207
|
+
_, body = self._request(
|
|
208
|
+
client,
|
|
209
|
+
"POST",
|
|
210
|
+
"/v1/answers/search",
|
|
211
|
+
json={"query": query, "max_results": max_results, "mode": mode},
|
|
212
|
+
headers={"Idempotency-Key": str(uuid.uuid4())},
|
|
213
|
+
)
|
|
214
|
+
return body
|
|
215
|
+
|
|
216
|
+
def get_call(self, call_id: str, wait: Optional[int] = None) -> dict[str, Any]:
|
|
217
|
+
"""A stored call. Free."""
|
|
218
|
+
path = f"/v1/answers/{call_id}" + (f"?wait={wait}" if wait is not None else "")
|
|
219
|
+
with httpx.Client(transport=self._transport, timeout=httpx.Timeout(60.0)) as client:
|
|
220
|
+
_, body = self._request(client, "GET", path)
|
|
221
|
+
return body
|
|
222
|
+
|
|
223
|
+
# ---- async ----
|
|
224
|
+
|
|
225
|
+
async def _arequest(
|
|
226
|
+
self, client: httpx.AsyncClient, method: str, path: str, *, json: Any = None, headers: Optional[dict[str, str]] = None
|
|
227
|
+
) -> tuple[int, dict[str, Any]]:
|
|
228
|
+
attempt = 0
|
|
229
|
+
while True:
|
|
230
|
+
response = await client.request(method, f"{self.base_url}{path}", json=json, headers=self._headers(headers))
|
|
231
|
+
body = _parse(response)
|
|
232
|
+
if response.is_success:
|
|
233
|
+
return response.status_code, body
|
|
234
|
+
if response.status_code in (429, 503) and attempt < self.max_retries:
|
|
235
|
+
await self._asleep(self._delay(attempt, _retry_after(response, body)))
|
|
236
|
+
attempt += 1
|
|
237
|
+
continue
|
|
238
|
+
raise _error(response, body)
|
|
239
|
+
|
|
240
|
+
async def aask(self, question: str, mode: AskMode = "standard", *, as_of: Optional[str] = None, max_cost_cents: Optional[float] = None) -> dict[str, Any]:
|
|
241
|
+
deadline = time.monotonic() + self.ask_timeout
|
|
242
|
+
async with httpx.AsyncClient(transport=self._async_transport, timeout=httpx.Timeout(self.ask_timeout)) as client:
|
|
243
|
+
status, body = await self._arequest(
|
|
244
|
+
client,
|
|
245
|
+
"POST",
|
|
246
|
+
"/v1/answers",
|
|
247
|
+
json=self._ask_body(question, mode, as_of, max_cost_cents),
|
|
248
|
+
headers={"Idempotency-Key": str(uuid.uuid4()), "Prefer": f"respond-async; wait={POLL_WAIT_SECONDS + 5}"},
|
|
249
|
+
)
|
|
250
|
+
while status == 202 or body.get("status") == "pending":
|
|
251
|
+
call_id = body.get("id")
|
|
252
|
+
if not call_id:
|
|
253
|
+
raise HyperresearchError(status, "The API answered pending without a call id.", body=body)
|
|
254
|
+
if time.monotonic() > deadline:
|
|
255
|
+
raise HyperresearchError(
|
|
256
|
+
408, f"Ask {call_id} did not finish within {self.ask_timeout:.0f}s; fetch it later with get_call().", code="client_timeout", body=body
|
|
257
|
+
)
|
|
258
|
+
status, body = await self._arequest(client, "GET", f"/v1/answers/{call_id}?wait={POLL_WAIT_SECONDS}")
|
|
259
|
+
return body
|
|
260
|
+
|
|
261
|
+
async def asearch(self, query: str, max_results: int = 10, mode: SearchMode = "standard") -> dict[str, Any]:
|
|
262
|
+
async with httpx.AsyncClient(transport=self._async_transport, timeout=httpx.Timeout(self.search_timeout)) as client:
|
|
263
|
+
_, body = await self._arequest(
|
|
264
|
+
client,
|
|
265
|
+
"POST",
|
|
266
|
+
"/v1/answers/search",
|
|
267
|
+
json={"query": query, "max_results": max_results, "mode": mode},
|
|
268
|
+
headers={"Idempotency-Key": str(uuid.uuid4())},
|
|
269
|
+
)
|
|
270
|
+
return body
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def compact_answer(r: dict[str, Any]) -> dict[str, Any]:
|
|
274
|
+
"""The form a model reads: the answer, its quotes, and what it cost."""
|
|
275
|
+
out: dict[str, Any] = {
|
|
276
|
+
"id": r.get("id"),
|
|
277
|
+
"status": r.get("status"),
|
|
278
|
+
"answer": r.get("answer", ""),
|
|
279
|
+
"evidence": [
|
|
280
|
+
{k: e.get(k) for k in ("url", "title", "quote", "check") if e.get(k) is not None} for e in (r.get("evidence") or []) if isinstance(e, dict)
|
|
281
|
+
],
|
|
282
|
+
}
|
|
283
|
+
if r.get("caveat"):
|
|
284
|
+
out["caveat"] = r["caveat"]
|
|
285
|
+
if r.get("escalated"):
|
|
286
|
+
out["escalated"] = True
|
|
287
|
+
if r.get("error"):
|
|
288
|
+
out["error"] = r["error"]
|
|
289
|
+
out["price_cents"] = r.get("price_cents")
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def compact_search(r: dict[str, Any]) -> dict[str, Any]:
|
|
294
|
+
results = []
|
|
295
|
+
for x in r.get("results") or []:
|
|
296
|
+
if not isinstance(x, dict):
|
|
297
|
+
continue
|
|
298
|
+
row = {k: x.get(k) for k in ("rank", "url", "title", "published", "source") if x.get(k) is not None}
|
|
299
|
+
row["passages"] = [{"text": p.get("text"), "verified": p.get("verified")} for p in (x.get("passages") or []) if isinstance(p, dict)]
|
|
300
|
+
results.append(row)
|
|
301
|
+
return {"id": r.get("id"), "status": r.get("status"), "query": r.get("query"), "results": results, "price_cents": r.get("price_cents")}
|
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""A LangChain retriever over Hyperresearch Answers ``search``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from langchain_core.callbacks import AsyncCallbackManagerForRetrieverRun, CallbackManagerForRetrieverRun
|
|
8
|
+
from langchain_core.documents import Document
|
|
9
|
+
from langchain_core.retrievers import BaseRetriever
|
|
10
|
+
from pydantic import ConfigDict, Field, SecretStr, model_validator
|
|
11
|
+
|
|
12
|
+
from langchain_hyperresearch._client import HyperresearchClient, SearchMode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _to_documents(res: dict[str, Any]) -> list[Document]:
|
|
16
|
+
docs = []
|
|
17
|
+
for r in res.get("results") or []:
|
|
18
|
+
if not isinstance(r, dict):
|
|
19
|
+
continue
|
|
20
|
+
passages = [p for p in (r.get("passages") or []) if isinstance(p, dict)]
|
|
21
|
+
texts = [p["text"] for p in passages if p.get("text")]
|
|
22
|
+
docs.append(
|
|
23
|
+
Document(
|
|
24
|
+
page_content="\n\n".join(texts) if texts else (r.get("snippet") or ""),
|
|
25
|
+
metadata={
|
|
26
|
+
"url": r.get("url"),
|
|
27
|
+
"title": r.get("title"),
|
|
28
|
+
"rank": r.get("rank"),
|
|
29
|
+
"source": r.get("source"),
|
|
30
|
+
"published": r.get("published"),
|
|
31
|
+
"verified": all(p.get("verified") is not False for p in passages),
|
|
32
|
+
"call_id": res.get("id"),
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return docs
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HyperresearchRetriever(BaseRetriever):
|
|
40
|
+
"""Ranked sources from Hyperresearch Answers ``search``, one Document per source.
|
|
41
|
+
|
|
42
|
+
``page_content`` is the matched passages, verbatim (or the snippet when there are none).
|
|
43
|
+
``metadata`` carries url, title, rank, source, published, verified and the call id.
|
|
44
|
+
Each retrieval is one billed ``search`` call (standard 5¢, max 15¢).
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
.. code-block:: python
|
|
48
|
+
|
|
49
|
+
from langchain_hyperresearch import HyperresearchRetriever
|
|
50
|
+
|
|
51
|
+
retriever = HyperresearchRetriever(max_results=5)
|
|
52
|
+
docs = retriever.invoke("minimum age to serve as a US senator")
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
56
|
+
|
|
57
|
+
max_results: int = Field(default=10, ge=1, le=25)
|
|
58
|
+
mode: SearchMode = "standard"
|
|
59
|
+
api_key: Optional[SecretStr] = Field(default=None, exclude=True)
|
|
60
|
+
base_url: Optional[str] = None
|
|
61
|
+
client: Optional[HyperresearchClient] = Field(default=None, exclude=True)
|
|
62
|
+
|
|
63
|
+
@model_validator(mode="after")
|
|
64
|
+
def _build_client(self) -> HyperresearchRetriever:
|
|
65
|
+
if self.client is None:
|
|
66
|
+
self.client = HyperresearchClient(
|
|
67
|
+
api_key=self.api_key.get_secret_value() if self.api_key else None,
|
|
68
|
+
base_url=self.base_url,
|
|
69
|
+
)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def _get_relevant_documents(self, query: str, *, run_manager: CallbackManagerForRetrieverRun) -> list[Document]:
|
|
73
|
+
assert self.client is not None
|
|
74
|
+
return _to_documents(self.client.search(query, self.max_results, self.mode))
|
|
75
|
+
|
|
76
|
+
async def _aget_relevant_documents(self, query: str, *, run_manager: AsyncCallbackManagerForRetrieverRun) -> list[Document]:
|
|
77
|
+
assert self.client is not None
|
|
78
|
+
return _to_documents(await self.client.asearch(query, self.max_results, self.mode))
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Hyperresearch Answers tools for LangChain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Literal, Optional
|
|
7
|
+
|
|
8
|
+
from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
|
|
9
|
+
from langchain_core.tools import BaseTool
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
|
|
11
|
+
|
|
12
|
+
from langchain_hyperresearch._client import (
|
|
13
|
+
AskMode,
|
|
14
|
+
HyperresearchClient,
|
|
15
|
+
SearchMode,
|
|
16
|
+
compact_answer,
|
|
17
|
+
compact_search,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
ANSWER_DESCRIPTION = (
|
|
21
|
+
"Answer one factual question with Hyperresearch Answers. Researches the open web and about 45 structured sources "
|
|
22
|
+
"(filings, statistics, registers, scholarly indexes, books) on the server and returns one short answer with the quotes it rests on, "
|
|
23
|
+
"each checked word for word against its page, or status not_found when nothing supports an answer. "
|
|
24
|
+
"Prefer this over a loop of searches for any factual lookup. Modes: fast (5¢), standard (10¢, 20¢ if it digs deeper; default), "
|
|
25
|
+
"max (50¢, numbers confirmed on two sites). Each call is charged to the user's prepaid Hyperresearch Answers balance."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
SEARCH_DESCRIPTION = (
|
|
29
|
+
"Search the web and Hyperresearch's ~45 structured sources (filings, statistics, registers, scholarly, books). "
|
|
30
|
+
"Returns ranked raw sources with verbatim passages. No synthesis. Use when you need to read the sources yourself. "
|
|
31
|
+
"Modes: standard (5¢, default), max (15¢: every provider plus structured data rows and PDF tables, for hard lookups). "
|
|
32
|
+
"Each call is charged to the user's prepaid Hyperresearch Answers balance."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HyperresearchAnswerInput(BaseModel):
|
|
37
|
+
"""Input for HyperresearchAnswerTool."""
|
|
38
|
+
|
|
39
|
+
question: str = Field(min_length=1, max_length=2000, description="One factual question, asked the way you would ask a researcher.")
|
|
40
|
+
mode: Optional[Literal["fast", "standard", "max"]] = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description="How hard to look: fast (5¢, about 45 s), standard (10¢, 20¢ if it digs deeper; default, up to about 3 min) "
|
|
43
|
+
"or max (50¢, up to about 5 min).",
|
|
44
|
+
)
|
|
45
|
+
as_of: Optional[str] = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$", description="Answer as of this date (YYYY-MM-DD). Omit for today.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HyperresearchSearchInput(BaseModel):
|
|
49
|
+
"""Input for HyperresearchSearchTool."""
|
|
50
|
+
|
|
51
|
+
query: str = Field(min_length=1, max_length=1000, description="What to find, as a question or a precise phrase.")
|
|
52
|
+
max_results: Optional[int] = Field(
|
|
53
|
+
default=None, ge=1, le=25, description="How many ranked sources to return (1-25, default 10). The price does not change."
|
|
54
|
+
)
|
|
55
|
+
mode: Optional[Literal["standard", "max"]] = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
description="standard (5¢, default) or max (15¢: every provider, plus structured data rows and PDF tables, for hard lookups).",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _HyperresearchBase(BaseTool):
|
|
62
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
63
|
+
|
|
64
|
+
api_key: Optional[SecretStr] = Field(default=None, exclude=True)
|
|
65
|
+
"""Defaults to the HYPERRESEARCH_API_KEY environment variable."""
|
|
66
|
+
base_url: Optional[str] = None
|
|
67
|
+
"""Defaults to HYPERRESEARCH_BASE_URL, then https://api.hyperresearch.ai."""
|
|
68
|
+
client: Optional[HyperresearchClient] = Field(default=None, exclude=True)
|
|
69
|
+
"""Share one client between tools and retrievers."""
|
|
70
|
+
|
|
71
|
+
response_format: Literal["content_and_artifact"] = "content_and_artifact"
|
|
72
|
+
|
|
73
|
+
@model_validator(mode="after")
|
|
74
|
+
def _build_client(self) -> _HyperresearchBase:
|
|
75
|
+
if self.client is None:
|
|
76
|
+
self.client = HyperresearchClient(
|
|
77
|
+
api_key=self.api_key.get_secret_value() if self.api_key else None,
|
|
78
|
+
base_url=self.base_url,
|
|
79
|
+
)
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class HyperresearchAnswerTool(_HyperresearchBase):
|
|
84
|
+
"""Hyperresearch Answers ``ask``: one checked answer with its quotes, or ``not_found``.
|
|
85
|
+
|
|
86
|
+
Setup:
|
|
87
|
+
.. code-block:: bash
|
|
88
|
+
|
|
89
|
+
pip install -U langchain-hyperresearch
|
|
90
|
+
export HYPERRESEARCH_API_KEY="hr_live_..."
|
|
91
|
+
|
|
92
|
+
Instantiate:
|
|
93
|
+
.. code-block:: python
|
|
94
|
+
|
|
95
|
+
from langchain_hyperresearch import HyperresearchAnswerTool
|
|
96
|
+
|
|
97
|
+
tool = HyperresearchAnswerTool() # the model picks the mode
|
|
98
|
+
tool = HyperresearchAnswerTool(mode="fast", max_cost_cents=5)
|
|
99
|
+
|
|
100
|
+
Invoke directly:
|
|
101
|
+
.. code-block:: python
|
|
102
|
+
|
|
103
|
+
tool.invoke({"question": "How old must a US senator be?", "mode": "fast"})
|
|
104
|
+
|
|
105
|
+
Invoke with a ToolCall (returns a ToolMessage; ``artifact`` is the full API response):
|
|
106
|
+
.. code-block:: python
|
|
107
|
+
|
|
108
|
+
tool.invoke({"args": {"question": "How old must a US senator be?"}, "id": "1", "name": tool.name, "type": "tool_call"})
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
name: str = "hyperresearch_answer"
|
|
112
|
+
description: str = ANSWER_DESCRIPTION
|
|
113
|
+
args_schema: type[BaseModel] = HyperresearchAnswerInput
|
|
114
|
+
|
|
115
|
+
mode: Optional[AskMode] = None
|
|
116
|
+
"""Fix the mode; the model then cannot choose it."""
|
|
117
|
+
max_cost_cents: Optional[float] = None
|
|
118
|
+
"""Cap every call, in cents. A standard ask under 20 never escalates."""
|
|
119
|
+
|
|
120
|
+
def _mode(self, mode: Optional[str]) -> str:
|
|
121
|
+
return self.mode or mode or "standard"
|
|
122
|
+
|
|
123
|
+
def _run(
|
|
124
|
+
self,
|
|
125
|
+
question: str,
|
|
126
|
+
mode: Optional[str] = None,
|
|
127
|
+
as_of: Optional[str] = None,
|
|
128
|
+
run_manager: Optional[CallbackManagerForToolRun] = None,
|
|
129
|
+
) -> tuple[str, dict[str, Any]]:
|
|
130
|
+
assert self.client is not None
|
|
131
|
+
raw = self.client.ask(question, self._mode(mode), as_of=as_of, max_cost_cents=self.max_cost_cents) # type: ignore[arg-type]
|
|
132
|
+
return json.dumps(compact_answer(raw), ensure_ascii=False), raw
|
|
133
|
+
|
|
134
|
+
async def _arun(
|
|
135
|
+
self,
|
|
136
|
+
question: str,
|
|
137
|
+
mode: Optional[str] = None,
|
|
138
|
+
as_of: Optional[str] = None,
|
|
139
|
+
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
|
|
140
|
+
) -> tuple[str, dict[str, Any]]:
|
|
141
|
+
assert self.client is not None
|
|
142
|
+
raw = await self.client.aask(question, self._mode(mode), as_of=as_of, max_cost_cents=self.max_cost_cents) # type: ignore[arg-type]
|
|
143
|
+
return json.dumps(compact_answer(raw), ensure_ascii=False), raw
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class HyperresearchSearchTool(_HyperresearchBase):
|
|
147
|
+
"""Hyperresearch Answers ``search``: ranked raw sources with verbatim passages.
|
|
148
|
+
|
|
149
|
+
Setup:
|
|
150
|
+
.. code-block:: bash
|
|
151
|
+
|
|
152
|
+
pip install -U langchain-hyperresearch
|
|
153
|
+
export HYPERRESEARCH_API_KEY="hr_live_..."
|
|
154
|
+
|
|
155
|
+
Instantiate:
|
|
156
|
+
.. code-block:: python
|
|
157
|
+
|
|
158
|
+
from langchain_hyperresearch import HyperresearchSearchTool
|
|
159
|
+
|
|
160
|
+
tool = HyperresearchSearchTool(max_results=5)
|
|
161
|
+
|
|
162
|
+
Invoke:
|
|
163
|
+
.. code-block:: python
|
|
164
|
+
|
|
165
|
+
tool.invoke({"query": "minimum age to serve as a US senator"})
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
name: str = "hyperresearch_search"
|
|
169
|
+
description: str = SEARCH_DESCRIPTION
|
|
170
|
+
args_schema: type[BaseModel] = HyperresearchSearchInput
|
|
171
|
+
|
|
172
|
+
mode: Optional[SearchMode] = None
|
|
173
|
+
"""Fix the mode; the model then cannot choose it."""
|
|
174
|
+
max_results: Optional[int] = Field(default=None, ge=1, le=25)
|
|
175
|
+
"""Fix the number of results (1-25)."""
|
|
176
|
+
|
|
177
|
+
def _args(self, max_results: Optional[int], mode: Optional[str]) -> tuple[int, str]:
|
|
178
|
+
return self.max_results or max_results or 10, self.mode or mode or "standard"
|
|
179
|
+
|
|
180
|
+
def _run(
|
|
181
|
+
self,
|
|
182
|
+
query: str,
|
|
183
|
+
max_results: Optional[int] = None,
|
|
184
|
+
mode: Optional[str] = None,
|
|
185
|
+
run_manager: Optional[CallbackManagerForToolRun] = None,
|
|
186
|
+
) -> tuple[str, dict[str, Any]]:
|
|
187
|
+
assert self.client is not None
|
|
188
|
+
n, m = self._args(max_results, mode)
|
|
189
|
+
raw = self.client.search(query, n, m) # type: ignore[arg-type]
|
|
190
|
+
return json.dumps(compact_search(raw), ensure_ascii=False), raw
|
|
191
|
+
|
|
192
|
+
async def _arun(
|
|
193
|
+
self,
|
|
194
|
+
query: str,
|
|
195
|
+
max_results: Optional[int] = None,
|
|
196
|
+
mode: Optional[str] = None,
|
|
197
|
+
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
|
|
198
|
+
) -> tuple[str, dict[str, Any]]:
|
|
199
|
+
assert self.client is not None
|
|
200
|
+
n, m = self._args(max_results, mode)
|
|
201
|
+
raw = await self.client.asearch(query, n, m) # type: ignore[arg-type]
|
|
202
|
+
return json.dumps(compact_search(raw), ensure_ascii=False), raw
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-hyperresearch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain integration for Hyperresearch Answers: a cited, quote-checked answer tool, a raw-source search tool and a retriever.
|
|
5
|
+
Project-URL: Homepage, https://hyperresearch.ai/answers
|
|
6
|
+
Project-URL: Documentation, https://hyperresearch.ai/docs/answers/langchain
|
|
7
|
+
Project-URL: Repository, https://github.com/jordan-gibbs/hyperresearch-answers
|
|
8
|
+
Project-URL: Source, https://github.com/jordan-gibbs/hyperresearch-answers/tree/main/packages/langchain-python
|
|
9
|
+
Project-URL: Issues, https://github.com/jordan-gibbs/hyperresearch-answers/issues
|
|
10
|
+
Author: Hyperresearch
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agents,answers,citations,hyperresearch,langchain,retriever,search,tools,web-search
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: <4.0,>=3.10
|
|
19
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
20
|
+
Requires-Dist: langchain-core<2.0.0,>=1.0.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# langchain-hyperresearch
|
|
24
|
+
|
|
25
|
+
This package contains the LangChain integration for [Hyperresearch Answers](https://hyperresearch.ai/answers): cited, quote-checked answers and ranked raw sources for agents.
|
|
26
|
+
|
|
27
|
+
## Overview
|
|
28
|
+
|
|
29
|
+
### Details
|
|
30
|
+
|
|
31
|
+
| Class | Package | Serializable | JS support |
|
|
32
|
+
| :--- | :--- | :---: | :---: |
|
|
33
|
+
| `HyperresearchAnswerTool` | `langchain-hyperresearch` | ❌ | ✅ (`@hyperresearch/langchain`) |
|
|
34
|
+
| `HyperresearchSearchTool` | `langchain-hyperresearch` | ❌ | ✅ |
|
|
35
|
+
| `HyperresearchRetriever` | `langchain-hyperresearch` | ❌ | ✅ |
|
|
36
|
+
|
|
37
|
+
### Features
|
|
38
|
+
|
|
39
|
+
- **`HyperresearchAnswerTool`** (`hyperresearch_answer`): one factual question in, one short answer out, with the quotes it rests on, each checked word for word against its page. When nothing supports an answer it returns `not_found` instead of a guess. The research runs on the Hyperresearch side: one tool call instead of a loop of searches, and no pages in the agent's context.
|
|
40
|
+
- **`HyperresearchSearchTool`** (`hyperresearch_search`): ranked raw sources from the open web and about 45 structured sources (company filings, statistics agencies, scholarly indexes, books, government records), with word-for-word passages. No summary.
|
|
41
|
+
- **`HyperresearchRetriever`**: `search` as a retriever, one `Document` per ranked source.
|
|
42
|
+
- Sync and async (`invoke` / `ainvoke`). Tools use `response_format="content_and_artifact"`: the model reads a compact JSON string, and `ToolMessage.artifact` holds the full API response.
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
### Credentials
|
|
47
|
+
|
|
48
|
+
Create an API key with the `answers:ask` scope at <https://console.hyperresearch.ai/#/keys>. Every account gets $5 of free credit, no card needed.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import getpass
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
if "HYPERRESEARCH_API_KEY" not in os.environ:
|
|
55
|
+
os.environ["HYPERRESEARCH_API_KEY"] = getpass.getpass("Enter your Hyperresearch API key: ")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install -U langchain-hyperresearch
|
|
62
|
+
# or
|
|
63
|
+
uv add langchain-hyperresearch
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Instantiation
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from langchain_hyperresearch import HyperresearchAnswerTool, HyperresearchSearchTool
|
|
70
|
+
|
|
71
|
+
answer = HyperresearchAnswerTool() # the model picks the mode
|
|
72
|
+
cheap = HyperresearchAnswerTool(mode="fast", max_cost_cents=5)
|
|
73
|
+
search = HyperresearchSearchTool(max_results=5)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Parameter | Default | |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `api_key` | `HYPERRESEARCH_API_KEY` | |
|
|
79
|
+
| `mode` | the model's choice, else `standard` | Fix the mode. Ask: `fast` 5¢ · `standard` 10¢ (20¢ when it digs deeper) · `max` 50¢. Search: `standard` 5¢ · `max` 15¢. |
|
|
80
|
+
| `max_cost_cents` | none | Answer tool. Caps every call; a standard ask under 20 never escalates. |
|
|
81
|
+
| `max_results` | the model's choice, else 10 | Search tool and retriever. 1-25. |
|
|
82
|
+
| `client` | new client | Share one `HyperresearchClient` (it also takes `base_url`, timeouts, `max_retries`). |
|
|
83
|
+
|
|
84
|
+
## Invocation
|
|
85
|
+
|
|
86
|
+
### Directly
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
answer.invoke({"question": "How old must a US senator be?", "mode": "fast"})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{"id": "ans_01J...", "status": "answered", "answer": "At least 30 years old (US Constitution, Article I, Section 3).", "evidence": [{"url": "https://www.archives.gov/founding-docs/constitution-transcript", "title": "The Constitution of the United States: A Transcription", "quote": "No Person shall be a Senator who shall not have attained to the Age of thirty Years", "check": "verified"}], "price_cents": 5}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`status` is one of `answered`, `false_premise`, `unconfirmed`, `not_found` or `error`.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
search.invoke({"query": "minimum age to serve as a US senator"})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### As a ToolCall
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
model_generated_tool_call = {
|
|
106
|
+
"args": {"question": "How old must a US senator be?"},
|
|
107
|
+
"id": "1",
|
|
108
|
+
"name": answer.name,
|
|
109
|
+
"type": "tool_call",
|
|
110
|
+
}
|
|
111
|
+
msg = answer.invoke(model_generated_tool_call)
|
|
112
|
+
msg.content # the compact JSON the model reads
|
|
113
|
+
msg.artifact # the full API response, including balance_cents
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Within an agent
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from langchain.agents import create_agent
|
|
120
|
+
|
|
121
|
+
agent = create_agent(
|
|
122
|
+
model="claude-sonnet-4-6",
|
|
123
|
+
tools=[HyperresearchAnswerTool(), HyperresearchSearchTool()],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
agent.invoke(
|
|
127
|
+
{"messages": [{"role": "user", "content": "What was the US federal deficit in fiscal 2025, with a source?"}]}
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Retriever
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from langchain_hyperresearch import HyperresearchRetriever
|
|
135
|
+
|
|
136
|
+
retriever = HyperresearchRetriever(max_results=5)
|
|
137
|
+
docs = retriever.invoke("minimum age to serve as a US senator")
|
|
138
|
+
docs[0].page_content # the matched passages, verbatim
|
|
139
|
+
docs[0].metadata # url, title, rank, source, published, verified, call_id
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Each retrieval is one billed `search` call.
|
|
143
|
+
|
|
144
|
+
## Pricing
|
|
145
|
+
|
|
146
|
+
| Call | Price | If not found |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| ask · fast | 5¢ | 1¢–5¢ |
|
|
149
|
+
| ask · standard (default) | 10¢, 20¢ when it digs deeper | 2¢–20¢ |
|
|
150
|
+
| ask · max | 50¢ | 10¢–50¢ |
|
|
151
|
+
| search · standard (default) | 5¢ | 0¢–5¢ |
|
|
152
|
+
| search · max | 15¢ | 0¢–15¢ |
|
|
153
|
+
|
|
154
|
+
A call that fails on our side costs nothing. Current list: <https://hyperresearch.ai/answers#pricing>.
|
|
155
|
+
|
|
156
|
+
## API contract
|
|
157
|
+
|
|
158
|
+
The package calls the Answers REST API at `https://api.hyperresearch.ai` with `Authorization: Bearer <key>`.
|
|
159
|
+
|
|
160
|
+
- `POST /v1/answers` `{question (1-2000 chars), mode?: fast|standard|max, as_of?: YYYY-MM-DD, max_cost_cents?}` returns `{id, status, answer, evidence[{url, title, quote, check}], caveat?, escalated, mode, price_cents, balance_cents}`.
|
|
161
|
+
- `POST /v1/answers/search` `{query (1-1000 chars), max_results?: 1-25, mode?: standard|max}` returns `{id, query, results[{rank, url, title, published, source, kind, snippet, passages[{text, verified}]}], meta, status: ok|error, price_cents, balance_cents}`.
|
|
162
|
+
- Long asks: the client sends `Prefer: respond-async; wait=50`; a `202 {id, status: "pending"}` is collected from `GET /v1/answers/{id}?wait=45`. Charged once, when it finishes.
|
|
163
|
+
- Every call carries an `Idempotency-Key`. 429 and `503 answers_capacity` are retried after `Retry-After`; capacity refusals are free.
|
|
164
|
+
- A failure on our side answers `200` with `status: "error"` and `error.code: "engine_unavailable"`, and costs nothing.
|
|
165
|
+
- Other errors: `{"error": {"type", "code", "message", "param"?, "retry_after"?, "request_id"}}`, raised as `HyperresearchError` (`402 insufficient_balance`, `402 wallet_frozen`, `409 idempotency_key_reuse`, `409 idempotency_in_flight`, ...). `hr_test_` keys are refused (Answers has no fixture mode).
|
|
166
|
+
|
|
167
|
+
Answers and passages are text from web pages. Treat them as data, not as instructions.
|
|
168
|
+
|
|
169
|
+
The same two tools are also served over MCP at `https://answers.hyperresearch.ai/mcp`.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
uv sync --group test --group lint
|
|
175
|
+
uv run pytest # mocked HTTP, includes langchain-tests ToolsUnitTests
|
|
176
|
+
uv run ruff check . && uv run mypy langchain_hyperresearch
|
|
177
|
+
uv build
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Releasing
|
|
181
|
+
|
|
182
|
+
Releases are published from GitHub Actions by `.github/workflows/publish-langchain-python.yml` in [jordan-gibbs/hyperresearch-answers](https://github.com/jordan-gibbs/hyperresearch-answers), with PyPI trusted publishing (no API token). Nothing is published from a laptop.
|
|
183
|
+
|
|
184
|
+
1. Bump `version` in `packages/langchain-python/pyproject.toml`, run `uv lock`, merge to `main`.
|
|
185
|
+
2. Tag and push: `git tag langchain-python-v<version> && git push origin langchain-python-v<version>`. The workflow fails if the tag and `pyproject.toml` disagree.
|
|
186
|
+
|
|
187
|
+
The workflow does not work until this one-time setup is done:
|
|
188
|
+
|
|
189
|
+
- On PyPI, add a pending trusted publisher (Account → Publishing → "Add a new pending publisher"): project `langchain-hyperresearch`, owner `jordan-gibbs`, repository `hyperresearch-answers`, workflow `publish-langchain-python.yml`, environment `pypi`. The first successful run creates the project.
|
|
190
|
+
- A GitHub environment named `pypi` exists in the repo (Settings → Environments; add required reviewers if you want a manual gate).
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
langchain_hyperresearch/__init__.py,sha256=GvG6fUYVHoPu3Y2tHOzXjLy6xZ4gJrOaxz1viGDYBGU,693
|
|
2
|
+
langchain_hyperresearch/_client.py,sha256=bDO7YYWZMzqlDwJbiajwdE8TKoqzXXCjeHMtYWmedYo,12750
|
|
3
|
+
langchain_hyperresearch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
langchain_hyperresearch/retrievers.py,sha256=KC9kn3_Jkk0EXuzp3pKKBJaWYilscM4pEPV8QeztYj8,3194
|
|
5
|
+
langchain_hyperresearch/tools.py,sha256=rkAjIrmZNOyFOC8qrqmeSaijnCXSSO_yfiBuPpCS1Yg,8192
|
|
6
|
+
langchain_hyperresearch-0.1.0.dist-info/METADATA,sha256=cWvy1rxy_w6wWEuqVFqwTvheTViim_iRgO3dkx-igPk,8920
|
|
7
|
+
langchain_hyperresearch-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
langchain_hyperresearch-0.1.0.dist-info/licenses/LICENSE,sha256=3PGUUvvm_rsvAXFoBqw3b0n2bBXZEdisn3g_1ijvIOQ,1075
|
|
9
|
+
langchain_hyperresearch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thalient Labs Inc.
|
|
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.
|