exa-py 1.13.1__tar.gz → 1.13.2__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.
Potentially problematic release.
This version of exa-py might be problematic. Click here for more details.
- {exa_py-1.13.1 → exa_py-1.13.2}/PKG-INFO +38 -5
- {exa_py-1.13.1 → exa_py-1.13.2}/README.md +37 -4
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/api.py +6 -95
- exa_py-1.13.2/exa_py/research/__init__.py +9 -0
- exa_py-1.13.2/exa_py/research/client.py +232 -0
- exa_py-1.13.2/exa_py/research/models.py +98 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/client.py +2 -1
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/core/base.py +6 -2
- exa_py-1.13.2/exa_py/websets/streams/__init__.py +4 -0
- exa_py-1.13.2/exa_py/websets/streams/client.py +96 -0
- exa_py-1.13.2/exa_py/websets/streams/runs/__init__.py +3 -0
- exa_py-1.13.2/exa_py/websets/streams/runs/client.py +38 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/types.py +302 -49
- {exa_py-1.13.1 → exa_py-1.13.2}/pyproject.toml +4 -6
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/__init__.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/py.typed +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/utils.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/__init__.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/_generator/pydantic/BaseModel.jinja2 +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/core/__init__.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/enrichments/__init__.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/enrichments/client.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/items/__init__.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/items/client.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/searches/__init__.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/searches/client.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/webhooks/__init__.py +0 -0
- {exa_py-1.13.1 → exa_py-1.13.2}/exa_py/websets/webhooks/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: exa-py
|
|
3
|
-
Version: 1.13.
|
|
3
|
+
Version: 1.13.2
|
|
4
4
|
Summary: Python SDK for Exa API.
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Exa AI
|
|
@@ -45,6 +45,7 @@ exa = Exa(api_key="your-api-key")
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
## Common requests
|
|
48
|
+
|
|
48
49
|
```python
|
|
49
50
|
|
|
50
51
|
# basic search
|
|
@@ -63,9 +64,9 @@ exa = Exa(api_key="your-api-key")
|
|
|
63
64
|
results = exa.search_and_contents("This is a Exa query:")
|
|
64
65
|
|
|
65
66
|
# search and get contents with contents options
|
|
66
|
-
results = exa.search_and_contents("This is a Exa query:",
|
|
67
|
+
results = exa.search_and_contents("This is a Exa query:",
|
|
67
68
|
text={"include_html_tags": True, "max_characters": 1000})
|
|
68
|
-
|
|
69
|
+
|
|
69
70
|
# find similar documents
|
|
70
71
|
results = exa.find_similar("https://example.com")
|
|
71
72
|
|
|
@@ -79,7 +80,7 @@ exa = Exa(api_key="your-api-key")
|
|
|
79
80
|
results = exa.get_contents(["tesla.com"])
|
|
80
81
|
|
|
81
82
|
# get contents with contents options
|
|
82
|
-
results = exa.get_contents(["urls"],
|
|
83
|
+
results = exa.get_contents(["urls"],
|
|
83
84
|
text={"include_html_tags": True, "max_characters": 1000})
|
|
84
85
|
|
|
85
86
|
# basic answer
|
|
@@ -95,6 +96,38 @@ exa = Exa(api_key="your-api-key")
|
|
|
95
96
|
for chunk in response:
|
|
96
97
|
print(chunk, end='', flush=True)
|
|
97
98
|
|
|
99
|
+
# research task example – answer a question with citations
|
|
100
|
+
# Example prompt & schema inspired by the TypeScript example.
|
|
101
|
+
QUESTION = (
|
|
102
|
+
"Summarize the history of San Francisco highlighting one or two major events "
|
|
103
|
+
"for each decade from 1850 to 1950"
|
|
104
|
+
)
|
|
105
|
+
OUTPUT_SCHEMA: Dict[str, Any] = {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"required": ["timeline"],
|
|
108
|
+
"properties": {
|
|
109
|
+
"timeline": {
|
|
110
|
+
"type": "array",
|
|
111
|
+
"items": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"required": ["decade", "notableEvents"],
|
|
114
|
+
"properties": {
|
|
115
|
+
"decade": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"description": 'Decade label e.g. "1850s"',
|
|
118
|
+
},
|
|
119
|
+
"notableEvents": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"description": "A summary of notable events.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
resp = exa.research.create_task(
|
|
129
|
+
input_instructions=QUESTION,
|
|
130
|
+
output_schema=OUTPUT_SCHEMA,
|
|
131
|
+
)
|
|
98
132
|
```
|
|
99
133
|
|
|
100
|
-
|
|
@@ -22,6 +22,7 @@ exa = Exa(api_key="your-api-key")
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## Common requests
|
|
25
|
+
|
|
25
26
|
```python
|
|
26
27
|
|
|
27
28
|
# basic search
|
|
@@ -40,9 +41,9 @@ exa = Exa(api_key="your-api-key")
|
|
|
40
41
|
results = exa.search_and_contents("This is a Exa query:")
|
|
41
42
|
|
|
42
43
|
# search and get contents with contents options
|
|
43
|
-
results = exa.search_and_contents("This is a Exa query:",
|
|
44
|
+
results = exa.search_and_contents("This is a Exa query:",
|
|
44
45
|
text={"include_html_tags": True, "max_characters": 1000})
|
|
45
|
-
|
|
46
|
+
|
|
46
47
|
# find similar documents
|
|
47
48
|
results = exa.find_similar("https://example.com")
|
|
48
49
|
|
|
@@ -56,7 +57,7 @@ exa = Exa(api_key="your-api-key")
|
|
|
56
57
|
results = exa.get_contents(["tesla.com"])
|
|
57
58
|
|
|
58
59
|
# get contents with contents options
|
|
59
|
-
results = exa.get_contents(["urls"],
|
|
60
|
+
results = exa.get_contents(["urls"],
|
|
60
61
|
text={"include_html_tags": True, "max_characters": 1000})
|
|
61
62
|
|
|
62
63
|
# basic answer
|
|
@@ -72,5 +73,37 @@ exa = Exa(api_key="your-api-key")
|
|
|
72
73
|
for chunk in response:
|
|
73
74
|
print(chunk, end='', flush=True)
|
|
74
75
|
|
|
76
|
+
# research task example – answer a question with citations
|
|
77
|
+
# Example prompt & schema inspired by the TypeScript example.
|
|
78
|
+
QUESTION = (
|
|
79
|
+
"Summarize the history of San Francisco highlighting one or two major events "
|
|
80
|
+
"for each decade from 1850 to 1950"
|
|
81
|
+
)
|
|
82
|
+
OUTPUT_SCHEMA: Dict[str, Any] = {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": ["timeline"],
|
|
85
|
+
"properties": {
|
|
86
|
+
"timeline": {
|
|
87
|
+
"type": "array",
|
|
88
|
+
"items": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"required": ["decade", "notableEvents"],
|
|
91
|
+
"properties": {
|
|
92
|
+
"decade": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": 'Decade label e.g. "1850s"',
|
|
95
|
+
},
|
|
96
|
+
"notableEvents": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"description": "A summary of notable events.",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
resp = exa.research.create_task(
|
|
106
|
+
input_instructions=QUESTION,
|
|
107
|
+
output_schema=OUTPUT_SCHEMA,
|
|
108
|
+
)
|
|
75
109
|
```
|
|
76
|
-
|
|
@@ -38,6 +38,7 @@ from exa_py.utils import (
|
|
|
38
38
|
)
|
|
39
39
|
from .websets import WebsetsClient
|
|
40
40
|
from .websets.core.base import ExaJSONEncoder
|
|
41
|
+
from .research.client import ResearchClient, AsyncResearchClient
|
|
41
42
|
|
|
42
43
|
is_beta = os.getenv("IS_BETA") == "True"
|
|
43
44
|
|
|
@@ -837,37 +838,6 @@ def nest_fields(original_dict: Dict, fields_to_nest: List[str], new_key: str):
|
|
|
837
838
|
return original_dict
|
|
838
839
|
|
|
839
840
|
|
|
840
|
-
@dataclass
|
|
841
|
-
class ResearchTaskResponse:
|
|
842
|
-
"""A class representing the response for a research task.
|
|
843
|
-
|
|
844
|
-
Attributes:
|
|
845
|
-
id (str): The unique identifier for the research request.
|
|
846
|
-
status (str): Status of the research request.
|
|
847
|
-
output (Optional[Dict[str, Any]]): The answer structured as JSON, if available.
|
|
848
|
-
citations (Optional[Dict[str, List[_Result]]]): List of citations used to generate the answer, grouped by root field in the output schema.
|
|
849
|
-
"""
|
|
850
|
-
|
|
851
|
-
id: str
|
|
852
|
-
status: str
|
|
853
|
-
output: Optional[Dict[str, Any]]
|
|
854
|
-
citations: Dict[str, List[_Result]]
|
|
855
|
-
|
|
856
|
-
def __str__(self):
|
|
857
|
-
output_repr = (
|
|
858
|
-
json.dumps(self.output, indent=2, ensure_ascii=False)
|
|
859
|
-
if self.output is not None
|
|
860
|
-
else "None"
|
|
861
|
-
)
|
|
862
|
-
citations_str = "\n\n".join(str(src) for src in self.citations)
|
|
863
|
-
return (
|
|
864
|
-
f"ID: {self.id}\n"
|
|
865
|
-
f"Status: {self.status}\n"
|
|
866
|
-
f"Output: {output_repr}\n\n"
|
|
867
|
-
f"Citations:\n{citations_str}"
|
|
868
|
-
)
|
|
869
|
-
|
|
870
|
-
|
|
871
841
|
class Exa:
|
|
872
842
|
"""A client for interacting with Exa API."""
|
|
873
843
|
|
|
@@ -875,7 +845,7 @@ class Exa:
|
|
|
875
845
|
self,
|
|
876
846
|
api_key: Optional[str],
|
|
877
847
|
base_url: str = "https://api.exa.ai",
|
|
878
|
-
user_agent: str = "exa-py 1.12.
|
|
848
|
+
user_agent: str = "exa-py 1.12.4",
|
|
879
849
|
):
|
|
880
850
|
"""Initialize the Exa client with the provided API key and optional base URL and user agent.
|
|
881
851
|
|
|
@@ -898,6 +868,8 @@ class Exa:
|
|
|
898
868
|
"Content-Type": "application/json",
|
|
899
869
|
}
|
|
900
870
|
self.websets = WebsetsClient(self)
|
|
871
|
+
# Research tasks client (new, mirrors Websets design)
|
|
872
|
+
self.research = ResearchClient(self)
|
|
901
873
|
|
|
902
874
|
def request(
|
|
903
875
|
self,
|
|
@@ -1952,40 +1924,12 @@ class Exa:
|
|
|
1952
1924
|
raw_response = self.request("/answer", options)
|
|
1953
1925
|
return StreamAnswerResponse(raw_response)
|
|
1954
1926
|
|
|
1955
|
-
def researchTask(
|
|
1956
|
-
self,
|
|
1957
|
-
*,
|
|
1958
|
-
input_instructions: str,
|
|
1959
|
-
output_schema: Dict[str, Any],
|
|
1960
|
-
) -> ResearchTaskResponse:
|
|
1961
|
-
"""Submit a research request to Exa.
|
|
1962
|
-
|
|
1963
|
-
Args:
|
|
1964
|
-
input_instructions (str): The instructions for the research task.
|
|
1965
|
-
output_schema (Dict[str, Any]): JSON schema describing the desired answer structure.
|
|
1966
|
-
"""
|
|
1967
|
-
# Build the request payload expected by the Exa API
|
|
1968
|
-
options = {
|
|
1969
|
-
"input": {"instructions": input_instructions},
|
|
1970
|
-
"output": {"schema": output_schema},
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
response = self.request("/research/tasks", options)
|
|
1974
|
-
|
|
1975
|
-
return ResearchTaskResponse(
|
|
1976
|
-
id=response["id"],
|
|
1977
|
-
status=response["status"],
|
|
1978
|
-
output=response.get("output"),
|
|
1979
|
-
citations={
|
|
1980
|
-
key: [_Result(**to_snake_case(citation)) for citation in citations_list]
|
|
1981
|
-
for key, citations_list in response.get("citations", {}).items()
|
|
1982
|
-
},
|
|
1983
|
-
)
|
|
1984
|
-
|
|
1985
1927
|
|
|
1986
1928
|
class AsyncExa(Exa):
|
|
1987
1929
|
def __init__(self, api_key: str, api_base: str = "https://api.exa.ai"):
|
|
1988
1930
|
super().__init__(api_key, api_base)
|
|
1931
|
+
# Override the synchronous ResearchClient with its async counterpart.
|
|
1932
|
+
self.research = AsyncResearchClient(self)
|
|
1989
1933
|
self._client = None
|
|
1990
1934
|
|
|
1991
1935
|
@property
|
|
@@ -2316,36 +2260,3 @@ class AsyncExa(Exa):
|
|
|
2316
2260
|
options["stream"] = True
|
|
2317
2261
|
raw_response = await self.async_request("/answer", options)
|
|
2318
2262
|
return AsyncStreamAnswerResponse(raw_response)
|
|
2319
|
-
|
|
2320
|
-
async def researchTask(
|
|
2321
|
-
self,
|
|
2322
|
-
*,
|
|
2323
|
-
input_instructions: str,
|
|
2324
|
-
output_schema: Dict[str, Any],
|
|
2325
|
-
) -> ResearchTaskResponse:
|
|
2326
|
-
"""Asynchronously submit a research request to Exa.
|
|
2327
|
-
|
|
2328
|
-
Args:
|
|
2329
|
-
input_instructions (str): The instructions for the research task.
|
|
2330
|
-
output_schema (Dict[str, Any]): JSON schema describing the desired answer structure.
|
|
2331
|
-
|
|
2332
|
-
Returns:
|
|
2333
|
-
ResearchTaskResponse: The parsed response from the Exa API.
|
|
2334
|
-
"""
|
|
2335
|
-
# Build the request payload expected by the Exa API
|
|
2336
|
-
options = {
|
|
2337
|
-
"input": {"instructions": input_instructions},
|
|
2338
|
-
"output": {"schema": output_schema},
|
|
2339
|
-
}
|
|
2340
|
-
|
|
2341
|
-
response = await self.async_request("/research/tasks", options)
|
|
2342
|
-
|
|
2343
|
-
return ResearchTaskResponse(
|
|
2344
|
-
id=response["id"],
|
|
2345
|
-
status=response["status"],
|
|
2346
|
-
output=response.get("output"),
|
|
2347
|
-
citations={
|
|
2348
|
-
key: [_Result(**to_snake_case(citation)) for citation in citations_list]
|
|
2349
|
-
for key, citations_list in response.get("citations", {}).items()
|
|
2350
|
-
},
|
|
2351
|
-
)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Lightweight research client wrappers for the Exa REST API.
|
|
2
|
+
|
|
3
|
+
This module purposefully keeps its import surface minimal to avoid circular
|
|
4
|
+
import problems with :pymod:`exa_py.api`. Any heavy dependencies (including
|
|
5
|
+
`exa_py.api` itself) are imported lazily **inside** functions. This means
|
|
6
|
+
that type-checkers still see the full, precise types via the ``TYPE_CHECKING``
|
|
7
|
+
block, but at runtime we only pay the cost if/when a helper is actually used.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Dict
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover – only for static analysers
|
|
15
|
+
# Import with full type info when static type-checking. `_Result` still
|
|
16
|
+
# lives in ``exa_py.api`` but the response model moved to
|
|
17
|
+
# ``exa_py.research.models``.
|
|
18
|
+
from ..api import _Result # noqa: F401
|
|
19
|
+
from .models import ResearchTask, ResearchTaskId # noqa: F401
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Public, user-facing clients
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResearchClient:
|
|
27
|
+
"""Synchronous helper namespace accessed via :pyattr:`Exa.research`."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, parent_client):
|
|
30
|
+
# A reference to the *already-constructed* ``Exa`` instance so that we
|
|
31
|
+
# can piggy-back on its HTTP plumbing (headers, base URL, retries, …).
|
|
32
|
+
self._client = parent_client
|
|
33
|
+
|
|
34
|
+
def create_task(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
input_instructions: str,
|
|
38
|
+
output_schema: Dict[str, Any],
|
|
39
|
+
) -> "ResearchTaskId":
|
|
40
|
+
"""Submit a research request and return the *task identifier*."""
|
|
41
|
+
payload = {
|
|
42
|
+
"input": {"instructions": input_instructions},
|
|
43
|
+
"output": {"schema": output_schema},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
raw_response: Dict[str, Any] = self._client.request("/research/tasks", payload)
|
|
47
|
+
|
|
48
|
+
# Defensive checks so that we fail loudly if the contract changes.
|
|
49
|
+
if not isinstance(raw_response, dict) or "id" not in raw_response:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f"Unexpected response while creating research task: {raw_response}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Lazily import to avoid circular deps at runtime.
|
|
55
|
+
from .models import ResearchTaskId # noqa: WPS433 – runtime import
|
|
56
|
+
|
|
57
|
+
return ResearchTaskId(id=raw_response["id"])
|
|
58
|
+
|
|
59
|
+
def get_task(
|
|
60
|
+
self, id: str
|
|
61
|
+
) -> "ResearchTask": # noqa: D401 – imperative mood is fine
|
|
62
|
+
"""Fetch the current status / result for a research task."""
|
|
63
|
+
endpoint = f"/research/tasks/{id}"
|
|
64
|
+
|
|
65
|
+
# The new endpoint is a simple GET.
|
|
66
|
+
raw_response: Dict[str, Any] = self._client.request(endpoint, method="GET")
|
|
67
|
+
|
|
68
|
+
return _build_research_task(raw_response)
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Convenience helpers
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def poll_task(
|
|
75
|
+
self,
|
|
76
|
+
id: str,
|
|
77
|
+
*,
|
|
78
|
+
poll_interval: float = 1.0,
|
|
79
|
+
timeout_seconds: int = 15 * 60,
|
|
80
|
+
) -> "ResearchTask":
|
|
81
|
+
"""Blocking helper that polls until task completes or fails.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
id:
|
|
86
|
+
The ID of the research task to poll.
|
|
87
|
+
poll_interval:
|
|
88
|
+
Seconds to wait between successive polls (default 1s).
|
|
89
|
+
timeout_seconds:
|
|
90
|
+
Maximum time to wait before raising :class:`TimeoutError` (default 15 min).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
import time
|
|
94
|
+
|
|
95
|
+
deadline = time.monotonic() + timeout_seconds
|
|
96
|
+
|
|
97
|
+
while True:
|
|
98
|
+
task = self.get_task(id)
|
|
99
|
+
status = task.status.lower() if isinstance(task.status, str) else ""
|
|
100
|
+
|
|
101
|
+
if status in {"completed", "failed", "complete", "finished", "done"}:
|
|
102
|
+
return task
|
|
103
|
+
|
|
104
|
+
if time.monotonic() > deadline:
|
|
105
|
+
raise TimeoutError(
|
|
106
|
+
f"Research task {id} did not finish within {timeout_seconds} seconds"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
time.sleep(poll_interval)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AsyncResearchClient:
|
|
113
|
+
"""Async counterpart used via :pyattr:`AsyncExa.research`."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, parent_client):
|
|
116
|
+
self._client = parent_client
|
|
117
|
+
|
|
118
|
+
async def create_task(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
input_instructions: str,
|
|
122
|
+
output_schema: Dict[str, Any],
|
|
123
|
+
) -> "ResearchTaskId":
|
|
124
|
+
"""Submit a research request and return the *task identifier* (async)."""
|
|
125
|
+
|
|
126
|
+
payload = {
|
|
127
|
+
"input": {"instructions": input_instructions},
|
|
128
|
+
"output": {"schema": output_schema},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
raw_response: Dict[str, Any] = await self._client.async_request(
|
|
132
|
+
"/research/tasks", payload
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Defensive checks so that we fail loudly if the contract changes.
|
|
136
|
+
if not isinstance(raw_response, dict) or "id" not in raw_response:
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
f"Unexpected response while creating research task: {raw_response}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Lazily import to avoid circular deps at runtime.
|
|
142
|
+
from .models import ResearchTaskId # noqa: WPS433 – runtime import
|
|
143
|
+
|
|
144
|
+
return ResearchTaskId(id=raw_response["id"])
|
|
145
|
+
|
|
146
|
+
async def get_task(self, id: str) -> "ResearchTask": # noqa: D401
|
|
147
|
+
"""Fetch the current status / result for a research task (async)."""
|
|
148
|
+
|
|
149
|
+
endpoint = f"/research/tasks/{id}"
|
|
150
|
+
|
|
151
|
+
# Perform GET using the underlying HTTP client because `async_request`
|
|
152
|
+
# only supports POST semantics.
|
|
153
|
+
resp = await self._client.client.get(
|
|
154
|
+
self._client.base_url + endpoint, headers=self._client.headers
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if resp.status_code >= 400:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
f"Request failed with status code {resp.status_code}: {resp.text}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
raw_response: Dict[str, Any] = resp.json()
|
|
163
|
+
|
|
164
|
+
return _build_research_task(raw_response)
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Convenience helpers
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
async def poll_task(
|
|
171
|
+
self,
|
|
172
|
+
id: str,
|
|
173
|
+
*,
|
|
174
|
+
poll_interval: float = 1.0,
|
|
175
|
+
timeout_seconds: int = 15 * 60,
|
|
176
|
+
) -> "ResearchTask":
|
|
177
|
+
"""Async helper that polls until task completes or fails.
|
|
178
|
+
|
|
179
|
+
Mirrors :py:meth:`ResearchClient.poll_task` but uses ``await`` and
|
|
180
|
+
:pyfunc:`asyncio.sleep`. Raises :class:`TimeoutError` on timeout.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
import asyncio
|
|
184
|
+
import time
|
|
185
|
+
|
|
186
|
+
deadline = time.monotonic() + timeout_seconds
|
|
187
|
+
|
|
188
|
+
while True:
|
|
189
|
+
task = await self.get_task(id)
|
|
190
|
+
status = task.status.lower() if isinstance(task.status, str) else ""
|
|
191
|
+
|
|
192
|
+
if status in {"completed", "failed", "complete", "finished", "done"}:
|
|
193
|
+
return task
|
|
194
|
+
|
|
195
|
+
if time.monotonic() > deadline:
|
|
196
|
+
raise TimeoutError(
|
|
197
|
+
f"Research task {id} did not finish within {timeout_seconds} seconds"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
await asyncio.sleep(poll_interval)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Internal helpers (lazy imports to avoid cycles)
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _build_research_task(raw: Dict[str, Any]):
|
|
209
|
+
"""Convert raw API response into a :class:`ResearchTask` instance."""
|
|
210
|
+
|
|
211
|
+
# Defensive check – fail loudly if the API contract changes.
|
|
212
|
+
if not isinstance(raw, dict) or "id" not in raw:
|
|
213
|
+
raise RuntimeError(f"Unexpected response while fetching research task: {raw}")
|
|
214
|
+
|
|
215
|
+
# Lazily import heavy deps to avoid cycles and unnecessary startup cost.
|
|
216
|
+
from .models import ResearchTask # noqa: WPS433 – runtime import
|
|
217
|
+
from ..api import _Result, to_snake_case # noqa: WPS433 – runtime import
|
|
218
|
+
|
|
219
|
+
citations_raw = raw.get("citations", {}) or {}
|
|
220
|
+
citations_parsed = {
|
|
221
|
+
key: [_Result(**to_snake_case(c)) for c in cites]
|
|
222
|
+
for key, cites in citations_raw.items()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return ResearchTask(
|
|
226
|
+
id=raw["id"],
|
|
227
|
+
status=raw["status"],
|
|
228
|
+
instructions=raw.get("instructions", ""),
|
|
229
|
+
schema=raw.get("schema", {}),
|
|
230
|
+
data=raw.get("data"),
|
|
231
|
+
citations=citations_parsed,
|
|
232
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
# Local import placed inside TYPE_CHECKING block to avoid runtime cycles.
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING: # pragma: no cover – for static analysers only
|
|
11
|
+
from ..api import _Result # noqa: F401
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ResearchTaskId:
|
|
16
|
+
"""Structured research task ID.
|
|
17
|
+
|
|
18
|
+
Attributes
|
|
19
|
+
----------
|
|
20
|
+
id:
|
|
21
|
+
Unique identifier for the research task.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------
|
|
27
|
+
# Pretty representation helpers
|
|
28
|
+
# ---------------------------------------------------------------------
|
|
29
|
+
def __str__(self) -> str: # pragma: no cover – convenience only
|
|
30
|
+
return f"ID: {self.id}\n"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ResearchTask:
|
|
35
|
+
"""Structured research task.
|
|
36
|
+
|
|
37
|
+
Attributes
|
|
38
|
+
----------
|
|
39
|
+
id:
|
|
40
|
+
Unique identifier for the research task.
|
|
41
|
+
status:
|
|
42
|
+
Current task status
|
|
43
|
+
instructions:
|
|
44
|
+
Instructions for the task
|
|
45
|
+
schema:
|
|
46
|
+
Output schema defining the task
|
|
47
|
+
data:
|
|
48
|
+
JSON-serialisable answer generated by Exa (may be ``None`` until the task
|
|
49
|
+
completes).
|
|
50
|
+
citations:
|
|
51
|
+
Mapping from *root field* in the output schema to the list of search
|
|
52
|
+
results that were used to generate that part of the answer.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
id: str
|
|
56
|
+
status: str
|
|
57
|
+
instructions: str
|
|
58
|
+
schema: Dict[str, Any]
|
|
59
|
+
data: Optional[Dict[str, Any]]
|
|
60
|
+
citations: Dict[str, List["_Result"]]
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------
|
|
63
|
+
# Pretty representation helpers
|
|
64
|
+
# ---------------------------------------------------------------------
|
|
65
|
+
def __str__(self) -> str: # pragma: no cover – convenience only
|
|
66
|
+
"""Human-readable representation including *all* relevant fields."""
|
|
67
|
+
schema_repr = json.dumps(self.schema, indent=2, ensure_ascii=False)
|
|
68
|
+
data_repr = (
|
|
69
|
+
json.dumps(self.data, indent=2, ensure_ascii=False)
|
|
70
|
+
if self.data is not None
|
|
71
|
+
else "None"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Render citations grouped by the root field they belong to.
|
|
75
|
+
if self.citations:
|
|
76
|
+
# Each key is a root field, each value is a list of _Result objects.
|
|
77
|
+
citations_lines = []
|
|
78
|
+
for field, sources in self.citations.items():
|
|
79
|
+
rendered_sources = "\n ".join(str(src) for src in sources)
|
|
80
|
+
citations_lines.append(f"{field}:\n {rendered_sources}")
|
|
81
|
+
citations_str = "\n\n".join(citations_lines)
|
|
82
|
+
else:
|
|
83
|
+
citations_str = "None"
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
f"ID: {self.id}\n"
|
|
87
|
+
f"Status: {self.status}\n"
|
|
88
|
+
f"Instructions: {self.instructions}\n"
|
|
89
|
+
f"Schema:\n{schema_repr}\n"
|
|
90
|
+
f"Data:\n{data_repr}\n\n"
|
|
91
|
+
f"Citations:\n{citations_str}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"ResearchTaskId",
|
|
97
|
+
"ResearchTask",
|
|
98
|
+
]
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
-
from datetime import datetime
|
|
5
4
|
from typing import List, Optional, Literal, Dict, Any, Union
|
|
6
5
|
|
|
7
6
|
from .types import (
|
|
@@ -17,6 +16,7 @@ from .items import WebsetItemsClient
|
|
|
17
16
|
from .searches import WebsetSearchesClient
|
|
18
17
|
from .enrichments import WebsetEnrichmentsClient
|
|
19
18
|
from .webhooks import WebsetWebhooksClient
|
|
19
|
+
from .streams import StreamsClient
|
|
20
20
|
|
|
21
21
|
class WebsetsClient(WebsetsBaseClient):
|
|
22
22
|
"""Client for managing Websets."""
|
|
@@ -27,6 +27,7 @@ class WebsetsClient(WebsetsBaseClient):
|
|
|
27
27
|
self.searches = WebsetSearchesClient(client)
|
|
28
28
|
self.enrichments = WebsetEnrichmentsClient(client)
|
|
29
29
|
self.webhooks = WebsetWebhooksClient(client)
|
|
30
|
+
self.streams = StreamsClient(client)
|
|
30
31
|
|
|
31
32
|
def create(self, params: Union[Dict[str, Any], CreateWebsetParameters]) -> Webset:
|
|
32
33
|
"""Create a new Webset.
|
|
@@ -29,7 +29,7 @@ class ExaBaseModel(BaseModel):
|
|
|
29
29
|
str_to_upper=False, # Don't convert strings to uppercase
|
|
30
30
|
from_attributes=True, # Allow initialization from attributes
|
|
31
31
|
validate_assignment=True, # Validate on assignment
|
|
32
|
-
extra='
|
|
32
|
+
extra='allow',
|
|
33
33
|
json_encoders={AnyUrl: str} # Convert AnyUrl to string when serializing to JSON
|
|
34
34
|
)
|
|
35
35
|
|
|
@@ -92,5 +92,9 @@ class WebsetsBaseClient:
|
|
|
92
92
|
# If data is a model instance, convert it to a dict
|
|
93
93
|
data = data.model_dump(by_alias=True, exclude_none=True)
|
|
94
94
|
|
|
95
|
+
# Ensure proper URL construction by removing leading slash from endpoint if present
|
|
96
|
+
if endpoint.startswith("/"):
|
|
97
|
+
endpoint = endpoint[1:]
|
|
98
|
+
|
|
95
99
|
return self._client.request("/websets/" + endpoint, data=data, method=method, params=params)
|
|
96
|
-
|
|
100
|
+
|