second-read 0.1.1__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.
- second_read-0.1.1.dist-info/METADATA +243 -0
- second_read-0.1.1.dist-info/RECORD +95 -0
- second_read-0.1.1.dist-info/WHEEL +4 -0
- second_read-0.1.1.dist-info/entry_points.txt +4 -0
- second_read-0.1.1.dist-info/licenses/LICENSE +21 -0
- secondread/__init__.py +0 -0
- secondread/agents/__init__.py +0 -0
- secondread/agents/copyedit/__init__.py +10 -0
- secondread/agents/copyedit/agent.py +261 -0
- secondread/agents/copyedit/prompts.py +123 -0
- secondread/agents/copyedit/text.py +274 -0
- secondread/agents/devils_advocate/__init__.py +18 -0
- secondread/agents/devils_advocate/agent.py +681 -0
- secondread/agents/devils_advocate/prompts.py +263 -0
- secondread/agents/devils_advocate/schemas.py +86 -0
- secondread/agents/devils_advocate/text.py +123 -0
- secondread/agents/extractor/__init__.py +20 -0
- secondread/agents/extractor/agent.py +330 -0
- secondread/agents/extractor/chunks.py +53 -0
- secondread/agents/extractor/prompts.py +121 -0
- secondread/agents/extractor/spans.py +177 -0
- secondread/agents/factcheck/__init__.py +12 -0
- secondread/agents/factcheck/agent.py +267 -0
- secondread/agents/factcheck/prompts.py +264 -0
- secondread/agents/factcheck/research.py +737 -0
- secondread/agents/factcheck/schemas.py +124 -0
- secondread/agents/factcheck/sources.py +295 -0
- secondread/agents/originality/__init__.py +29 -0
- secondread/agents/originality/agent.py +277 -0
- secondread/agents/originality/sentences.py +311 -0
- secondread/agents/originality/similarity.py +114 -0
- secondread/agents/structure/__init__.py +25 -0
- secondread/agents/structure/agent.py +301 -0
- secondread/agents/structure/prompts.py +179 -0
- secondread/cli/__init__.py +5 -0
- secondread/cli/__main__.py +6 -0
- secondread/cli/envfile.py +80 -0
- secondread/cli/errors.py +114 -0
- secondread/cli/main.py +327 -0
- secondread/contracts/__init__.py +134 -0
- secondread/contracts/errors.py +60 -0
- secondread/contracts/interfaces.py +314 -0
- secondread/contracts/models.py +689 -0
- secondread/mcp_local/README.md +96 -0
- secondread/mcp_local/__init__.py +35 -0
- secondread/mcp_local/__main__.py +6 -0
- secondread/mcp_local/demo.py +111 -0
- secondread/mcp_local/errors.py +107 -0
- secondread/mcp_local/main.py +80 -0
- secondread/mcp_local/progress.py +86 -0
- secondread/mcp_local/server.py +187 -0
- secondread/mcp_local/wiring.py +251 -0
- secondread/orchestrator/__init__.py +54 -0
- secondread/orchestrator/assemble.py +148 -0
- secondread/orchestrator/authwatch.py +83 -0
- secondread/orchestrator/classify.py +75 -0
- secondread/orchestrator/conflicts.py +406 -0
- secondread/orchestrator/labels.py +39 -0
- secondread/orchestrator/orchestrator.py +601 -0
- secondread/orchestrator/planning.py +221 -0
- secondread/orchestrator/progress.py +114 -0
- secondread/orchestrator/prompts.py +132 -0
- secondread/orchestrator/reactions.py +153 -0
- secondread/orchestrator/registry.py +56 -0
- secondread/orchestrator/schemas.py +45 -0
- secondread/pipeline.py +73 -0
- secondread/providers/__init__.py +0 -0
- secondread/providers/fetch/__init__.py +29 -0
- secondread/providers/fetch/extract.py +153 -0
- secondread/providers/fetch/fetcher.py +241 -0
- secondread/providers/fetch/guarded.py +110 -0
- secondread/providers/fetch/ssrf.py +207 -0
- secondread/providers/llm/__init__.py +49 -0
- secondread/providers/llm/_common.py +259 -0
- secondread/providers/llm/anthropic_client.py +332 -0
- secondread/providers/llm/config.py +93 -0
- secondread/providers/llm/openai_client.py +229 -0
- secondread/providers/search/__init__.py +45 -0
- secondread/providers/search/base.py +218 -0
- secondread/providers/search/brave.py +55 -0
- secondread/providers/search/config.py +63 -0
- secondread/providers/search/exa.py +62 -0
- secondread/providers/search/retry.py +60 -0
- secondread/providers/search/tavily.py +60 -0
- secondread/registry.py +270 -0
- secondread/report/__init__.py +30 -0
- secondread/report/email.py +43 -0
- secondread/report/html.py +49 -0
- secondread/report/markdown.py +195 -0
- secondread/report/templates/email.html.j2 +139 -0
- secondread/report/text.py +158 -0
- secondread/report/view.py +408 -0
- secondread/testing/__init__.py +1 -0
- secondread/testing/fakes.py +458 -0
- secondread/testing/samples.py +348 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Shared HTTP plumbing for search adapters.
|
|
2
|
+
|
|
3
|
+
Each adapter subclasses ``HttpSearchClient`` and implements ``_build_request``
|
|
4
|
+
(what to send) and ``_parse`` (JSON to ``SearchResult``s). The base class
|
|
5
|
+
handles retries, error mapping, result cleanup and key hygiene: the key is
|
|
6
|
+
held in a ``Secret`` whose ``repr`` is masked and it never appears in error
|
|
7
|
+
messages.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import html
|
|
13
|
+
import re
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Any, Self
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from secondread.contracts import AuthError, ProviderError, RateLimitError, SearchResult
|
|
23
|
+
from secondread.providers.search.retry import RetryPolicy, parse_retry_after
|
|
24
|
+
|
|
25
|
+
MAX_SNIPPET_CHARS = 500
|
|
26
|
+
MAX_TITLE_CHARS = 300
|
|
27
|
+
DEFAULT_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
|
28
|
+
USER_AGENT = "SecondRead/0.1 (document reviewer)"
|
|
29
|
+
|
|
30
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
31
|
+
_WS_RE = re.compile(r"\s+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Secret:
|
|
35
|
+
"""Holds an API key. ``repr`` and ``str`` are masked."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_value",)
|
|
38
|
+
|
|
39
|
+
def __init__(self, value: str) -> None:
|
|
40
|
+
self._value = value
|
|
41
|
+
|
|
42
|
+
def reveal(self) -> str:
|
|
43
|
+
"""Return the raw key (only for building request headers)."""
|
|
44
|
+
return self._value
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return "Secret('***')"
|
|
48
|
+
|
|
49
|
+
__str__ = __repr__
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SearchRequest:
|
|
54
|
+
"""One HTTP request an adapter wants sent."""
|
|
55
|
+
|
|
56
|
+
method: str
|
|
57
|
+
url: str
|
|
58
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
params: dict[str, str | int] | None = None
|
|
60
|
+
json: dict[str, Any] | None = None
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
# Headers carry the API key; never show them.
|
|
64
|
+
return f"SearchRequest(method={self.method!r}, url={self.url!r})"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clean_text(value: object, limit: int) -> str:
|
|
68
|
+
"""Strip tags and entities, collapse whitespace and truncate."""
|
|
69
|
+
if not isinstance(value, str):
|
|
70
|
+
return ""
|
|
71
|
+
text = _WS_RE.sub(" ", html.unescape(_TAG_RE.sub("", value))).strip()
|
|
72
|
+
if len(text) > limit:
|
|
73
|
+
text = text[: limit - 1].rstrip() + "…"
|
|
74
|
+
return text
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_http_url(value: object) -> bool:
|
|
78
|
+
"""True for absolute ``http``/``https`` URLs with a host."""
|
|
79
|
+
if not isinstance(value, str):
|
|
80
|
+
return False
|
|
81
|
+
try:
|
|
82
|
+
parts = urlsplit(value)
|
|
83
|
+
except ValueError:
|
|
84
|
+
return False
|
|
85
|
+
return parts.scheme in ("http", "https") and bool(parts.netloc)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class HttpSearchClient(ABC):
|
|
89
|
+
"""Base ``SearchClient`` over a JSON HTTP API.
|
|
90
|
+
|
|
91
|
+
``transport`` lets tests inject ``httpx.MockTransport``; ``retry`` controls
|
|
92
|
+
backoff (inject a no-op sleep in tests). Use as an async context manager
|
|
93
|
+
or call ``aclose`` to release connections.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
#: Provider name used in error messages and the comparison script.
|
|
97
|
+
name: str = "search"
|
|
98
|
+
#: Largest ``k`` the API accepts in one call.
|
|
99
|
+
max_k: int = 20
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
api_key: str,
|
|
104
|
+
*,
|
|
105
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
106
|
+
retry: RetryPolicy | None = None,
|
|
107
|
+
timeout: httpx.Timeout | float = DEFAULT_TIMEOUT,
|
|
108
|
+
base_url: str | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
if not api_key or not api_key.strip():
|
|
111
|
+
raise AuthError(f"{self.name}: missing API key")
|
|
112
|
+
self._key = Secret(api_key.strip())
|
|
113
|
+
self._retry = retry or RetryPolicy()
|
|
114
|
+
self._base_url = base_url
|
|
115
|
+
self._client = httpx.AsyncClient(
|
|
116
|
+
transport=transport,
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
headers={"User-Agent": USER_AGENT},
|
|
119
|
+
trust_env=False,
|
|
120
|
+
follow_redirects=False,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
return f"{type(self).__name__}(api_key=***)"
|
|
125
|
+
|
|
126
|
+
async def __aenter__(self) -> Self:
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
async def __aexit__(
|
|
130
|
+
self,
|
|
131
|
+
exc_type: type[BaseException] | None,
|
|
132
|
+
exc: BaseException | None,
|
|
133
|
+
tb: TracebackType | None,
|
|
134
|
+
) -> None:
|
|
135
|
+
await self.aclose()
|
|
136
|
+
|
|
137
|
+
async def aclose(self) -> None:
|
|
138
|
+
"""Close the underlying HTTP client."""
|
|
139
|
+
await self._client.aclose()
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def _build_request(self, query: str, k: int) -> SearchRequest:
|
|
143
|
+
"""Return the HTTP request for ``query`` asking for ``k`` hits."""
|
|
144
|
+
|
|
145
|
+
@abstractmethod
|
|
146
|
+
def _parse(self, data: Any) -> list[SearchResult]:
|
|
147
|
+
"""Turn the decoded JSON body into results (ranks are reassigned)."""
|
|
148
|
+
|
|
149
|
+
async def search(self, query: str, *, k: int = 5) -> list[SearchResult]:
|
|
150
|
+
"""Return at most ``k`` results for ``query``, best first."""
|
|
151
|
+
query = query.strip()
|
|
152
|
+
if not query or k < 1:
|
|
153
|
+
return []
|
|
154
|
+
request = self._build_request(query, min(k, self.max_k))
|
|
155
|
+
data = await self._send(request)
|
|
156
|
+
try:
|
|
157
|
+
raw = self._parse(data)
|
|
158
|
+
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
|
159
|
+
raise ProviderError(f"{self.name}: unexpected response shape") from exc
|
|
160
|
+
return _finalize(raw, k)
|
|
161
|
+
|
|
162
|
+
async def _send(self, request: SearchRequest) -> Any:
|
|
163
|
+
last_error: ProviderError | None = None
|
|
164
|
+
for attempt in range(self._retry.attempts):
|
|
165
|
+
retry_after: float | None = None
|
|
166
|
+
try:
|
|
167
|
+
response = await self._client.request(
|
|
168
|
+
request.method,
|
|
169
|
+
request.url,
|
|
170
|
+
headers=request.headers,
|
|
171
|
+
params=request.params,
|
|
172
|
+
json=request.json,
|
|
173
|
+
)
|
|
174
|
+
except httpx.TimeoutException:
|
|
175
|
+
last_error = ProviderError(f"{self.name}: request timed out", retryable=True)
|
|
176
|
+
except httpx.TransportError:
|
|
177
|
+
last_error = ProviderError(f"{self.name}: connection failed", retryable=True)
|
|
178
|
+
else:
|
|
179
|
+
status = response.status_code
|
|
180
|
+
if status in (401, 403):
|
|
181
|
+
raise AuthError(f"{self.name}: API key rejected (HTTP {status})")
|
|
182
|
+
if status == 402:
|
|
183
|
+
raise AuthError(f"{self.name}: out of credit or plan limit (HTTP 402)")
|
|
184
|
+
if status == 429:
|
|
185
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
186
|
+
last_error = RateLimitError(f"{self.name}: rate limited (HTTP 429)")
|
|
187
|
+
elif status >= 500:
|
|
188
|
+
last_error = ProviderError(
|
|
189
|
+
f"{self.name}: server error (HTTP {status})", retryable=True
|
|
190
|
+
)
|
|
191
|
+
elif status >= 400:
|
|
192
|
+
raise ProviderError(f"{self.name}: request rejected (HTTP {status})")
|
|
193
|
+
else:
|
|
194
|
+
try:
|
|
195
|
+
return response.json()
|
|
196
|
+
except ValueError as exc:
|
|
197
|
+
raise ProviderError(f"{self.name}: response was not JSON") from exc
|
|
198
|
+
if attempt + 1 < self._retry.attempts:
|
|
199
|
+
await self._retry.wait(attempt, retry_after)
|
|
200
|
+
assert last_error is not None
|
|
201
|
+
raise last_error
|
|
202
|
+
|
|
203
|
+
def _url(self, default: str) -> str:
|
|
204
|
+
return self._base_url or default
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _finalize(raw: list[SearchResult], k: int) -> list[SearchResult]:
|
|
208
|
+
"""Drop non-HTTP and duplicate URLs, cap at ``k``, and renumber ranks."""
|
|
209
|
+
seen: set[str] = set()
|
|
210
|
+
out: list[SearchResult] = []
|
|
211
|
+
for hit in raw:
|
|
212
|
+
if not is_http_url(hit.url) or hit.url in seen:
|
|
213
|
+
continue
|
|
214
|
+
seen.add(hit.url)
|
|
215
|
+
out.append(hit.model_copy(update={"rank": len(out) + 1}))
|
|
216
|
+
if len(out) >= k:
|
|
217
|
+
break
|
|
218
|
+
return out
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Brave Search API adapter (``GET /res/v1/web/search``).
|
|
2
|
+
|
|
3
|
+
Auth: ``X-Subscription-Token`` header. Snippets arrive with ``<strong>``
|
|
4
|
+
highlighting, which is stripped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from secondread.contracts import SearchResult
|
|
12
|
+
from secondread.providers.search.base import (
|
|
13
|
+
MAX_SNIPPET_CHARS,
|
|
14
|
+
MAX_TITLE_CHARS,
|
|
15
|
+
HttpSearchClient,
|
|
16
|
+
SearchRequest,
|
|
17
|
+
clean_text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BraveSearch(HttpSearchClient):
|
|
24
|
+
"""``SearchClient`` backed by the Brave Search web endpoint."""
|
|
25
|
+
|
|
26
|
+
name = "brave"
|
|
27
|
+
max_k = 20
|
|
28
|
+
|
|
29
|
+
def _build_request(self, query: str, k: int) -> SearchRequest:
|
|
30
|
+
return SearchRequest(
|
|
31
|
+
method="GET",
|
|
32
|
+
url=self._url(BRAVE_URL),
|
|
33
|
+
headers={
|
|
34
|
+
"Accept": "application/json",
|
|
35
|
+
"X-Subscription-Token": self._key.reveal(),
|
|
36
|
+
},
|
|
37
|
+
params={"q": query, "count": k},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _parse(self, data: Any) -> list[SearchResult]:
|
|
41
|
+
web = data.get("web") or {}
|
|
42
|
+
results: list[SearchResult] = []
|
|
43
|
+
for item in web.get("results") or []:
|
|
44
|
+
if not isinstance(item, dict):
|
|
45
|
+
continue
|
|
46
|
+
published = item.get("page_age") or item.get("age")
|
|
47
|
+
results.append(
|
|
48
|
+
SearchResult(
|
|
49
|
+
url=str(item.get("url", "")),
|
|
50
|
+
title=clean_text(item.get("title"), MAX_TITLE_CHARS),
|
|
51
|
+
snippet=clean_text(item.get("description"), MAX_SNIPPET_CHARS),
|
|
52
|
+
published=published if isinstance(published, str) else None,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
return results
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Build search clients from environment variables.
|
|
2
|
+
|
|
3
|
+
| Provider | Env var | Class |
|
|
4
|
+
| --- | --- | --- |
|
|
5
|
+
| ``brave`` | ``BRAVE_SEARCH_API_KEY`` | ``BraveSearch`` |
|
|
6
|
+
| ``tavily`` | ``TAVILY_API_KEY`` | ``TavilySearch`` |
|
|
7
|
+
| ``exa`` | ``EXA_API_KEY`` | ``ExaSearch`` |
|
|
8
|
+
|
|
9
|
+
Providers whose variable is unset or blank are skipped.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from secondread.contracts import MissingSetup
|
|
19
|
+
from secondread.providers.search.base import HttpSearchClient
|
|
20
|
+
from secondread.providers.search.brave import BraveSearch
|
|
21
|
+
from secondread.providers.search.exa import ExaSearch
|
|
22
|
+
from secondread.providers.search.tavily import TavilySearch
|
|
23
|
+
|
|
24
|
+
PROVIDERS: dict[str, tuple[str, type[HttpSearchClient]]] = {
|
|
25
|
+
"brave": ("BRAVE_SEARCH_API_KEY", BraveSearch),
|
|
26
|
+
"tavily": ("TAVILY_API_KEY", TavilySearch),
|
|
27
|
+
"exa": ("EXA_API_KEY", ExaSearch),
|
|
28
|
+
}
|
|
29
|
+
"""Provider name -> (env var holding its key, adapter class)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_search_client(name: str, api_key: str, **kwargs: Any) -> HttpSearchClient:
|
|
33
|
+
"""Build the adapter called ``name`` with ``api_key``."""
|
|
34
|
+
if name not in PROVIDERS:
|
|
35
|
+
known = ", ".join(PROVIDERS)
|
|
36
|
+
raise MissingSetup(f"unknown search provider {name!r} (known: {known})")
|
|
37
|
+
_env_var, cls = PROVIDERS[name]
|
|
38
|
+
return cls(api_key, **kwargs)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def search_clients_from_env(
|
|
42
|
+
env: Mapping[str, str] | None = None,
|
|
43
|
+
*,
|
|
44
|
+
only: list[str] | None = None,
|
|
45
|
+
**kwargs: Any,
|
|
46
|
+
) -> dict[str, HttpSearchClient]:
|
|
47
|
+
"""Build every provider whose key is set in ``env`` (default ``os.environ``).
|
|
48
|
+
|
|
49
|
+
``only`` restricts to the named providers; ``kwargs`` go to each adapter
|
|
50
|
+
(e.g. ``transport`` or ``retry``).
|
|
51
|
+
"""
|
|
52
|
+
source: Mapping[str, str] = os.environ if env is None else env
|
|
53
|
+
names = only if only is not None else list(PROVIDERS)
|
|
54
|
+
clients: dict[str, HttpSearchClient] = {}
|
|
55
|
+
for name in names:
|
|
56
|
+
if name not in PROVIDERS:
|
|
57
|
+
known = ", ".join(PROVIDERS)
|
|
58
|
+
raise MissingSetup(f"unknown search provider {name!r} (known: {known})")
|
|
59
|
+
env_var, cls = PROVIDERS[name]
|
|
60
|
+
key = source.get(env_var, "").strip()
|
|
61
|
+
if key:
|
|
62
|
+
clients[name] = cls(key, **kwargs)
|
|
63
|
+
return clients
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Exa search API adapter (``POST /search``).
|
|
2
|
+
|
|
3
|
+
Auth: ``x-api-key`` header. Requests ``type=auto`` with highlights, which
|
|
4
|
+
become the snippet (falling back to the first part of ``text``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from secondread.contracts import SearchResult
|
|
12
|
+
from secondread.providers.search.base import (
|
|
13
|
+
MAX_SNIPPET_CHARS,
|
|
14
|
+
MAX_TITLE_CHARS,
|
|
15
|
+
HttpSearchClient,
|
|
16
|
+
SearchRequest,
|
|
17
|
+
clean_text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
EXA_URL = "https://api.exa.ai/search"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExaSearch(HttpSearchClient):
|
|
24
|
+
"""``SearchClient`` backed by Exa's search endpoint."""
|
|
25
|
+
|
|
26
|
+
name = "exa"
|
|
27
|
+
max_k = 25
|
|
28
|
+
|
|
29
|
+
def _build_request(self, query: str, k: int) -> SearchRequest:
|
|
30
|
+
return SearchRequest(
|
|
31
|
+
method="POST",
|
|
32
|
+
url=self._url(EXA_URL),
|
|
33
|
+
headers={"Accept": "application/json", "x-api-key": self._key.reveal()},
|
|
34
|
+
json={
|
|
35
|
+
"query": query,
|
|
36
|
+
"numResults": k,
|
|
37
|
+
"type": "auto",
|
|
38
|
+
"contents": {"highlights": {"numSentences": 2, "highlightsPerUrl": 2}},
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _parse(self, data: Any) -> list[SearchResult]:
|
|
43
|
+
results: list[SearchResult] = []
|
|
44
|
+
for item in data.get("results") or []:
|
|
45
|
+
if not isinstance(item, dict):
|
|
46
|
+
continue
|
|
47
|
+
highlights = item.get("highlights")
|
|
48
|
+
snippet_src: object
|
|
49
|
+
if isinstance(highlights, list) and highlights:
|
|
50
|
+
snippet_src = " … ".join(h for h in highlights if isinstance(h, str))
|
|
51
|
+
else:
|
|
52
|
+
snippet_src = item.get("text")
|
|
53
|
+
published = item.get("publishedDate")
|
|
54
|
+
results.append(
|
|
55
|
+
SearchResult(
|
|
56
|
+
url=str(item.get("url", "")),
|
|
57
|
+
title=clean_text(item.get("title"), MAX_TITLE_CHARS),
|
|
58
|
+
snippet=clean_text(snippet_src, MAX_SNIPPET_CHARS),
|
|
59
|
+
published=published if isinstance(published, str) else None,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
return results
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Retry policy shared by the search adapters and the fetcher.
|
|
2
|
+
|
|
3
|
+
The sleep function is injectable so tests run instantly: pass
|
|
4
|
+
``RetryPolicy(sleep=no_sleep)`` or any coroutine that records delays.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
Sleep = Callable[[float], Awaitable[None]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def no_sleep(_seconds: float) -> None:
|
|
17
|
+
"""A sleep that returns immediately (for tests)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RetryPolicy:
|
|
22
|
+
"""Exponential backoff for transient failures.
|
|
23
|
+
|
|
24
|
+
``attempts`` is the total number of tries (1 means no retry). The delay
|
|
25
|
+
before retry ``n`` (0-based) is ``base_delay * 2**n``, capped at
|
|
26
|
+
``max_delay``. A server-sent ``Retry-After`` replaces the computed delay
|
|
27
|
+
but is still capped, so a hostile or confused server cannot stall a job.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
attempts: int = 3
|
|
31
|
+
base_delay: float = 0.5
|
|
32
|
+
max_delay: float = 8.0
|
|
33
|
+
sleep: Sleep = field(default=asyncio.sleep, compare=False)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if self.attempts < 1:
|
|
37
|
+
raise ValueError("attempts must be at least 1")
|
|
38
|
+
if self.base_delay < 0 or self.max_delay < 0:
|
|
39
|
+
raise ValueError("delays must be non-negative")
|
|
40
|
+
|
|
41
|
+
def delay(self, retry_index: int, retry_after: float | None = None) -> float:
|
|
42
|
+
"""Seconds to wait before retry number ``retry_index`` (0-based)."""
|
|
43
|
+
if retry_after is not None and retry_after >= 0:
|
|
44
|
+
return min(self.max_delay, retry_after)
|
|
45
|
+
return min(self.max_delay, self.base_delay * float(2**retry_index))
|
|
46
|
+
|
|
47
|
+
async def wait(self, retry_index: int, retry_after: float | None = None) -> None:
|
|
48
|
+
"""Sleep for ``delay(retry_index, retry_after)`` using the injected sleep."""
|
|
49
|
+
await self.sleep(self.delay(retry_index, retry_after))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
53
|
+
"""Parse a ``Retry-After`` header given in seconds; dates are ignored."""
|
|
54
|
+
if not value:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
seconds = float(value.strip())
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
return seconds if seconds >= 0 else None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tavily Search API adapter (``POST /search``).
|
|
2
|
+
|
|
3
|
+
Auth: ``Authorization: Bearer <key>``. Uses ``search_depth=basic`` (the
|
|
4
|
+
cheaper tier); ``content`` becomes the snippet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from secondread.contracts import SearchResult
|
|
12
|
+
from secondread.providers.search.base import (
|
|
13
|
+
MAX_SNIPPET_CHARS,
|
|
14
|
+
MAX_TITLE_CHARS,
|
|
15
|
+
HttpSearchClient,
|
|
16
|
+
SearchRequest,
|
|
17
|
+
clean_text,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
TAVILY_URL = "https://api.tavily.com/search"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TavilySearch(HttpSearchClient):
|
|
24
|
+
"""``SearchClient`` backed by Tavily's search endpoint."""
|
|
25
|
+
|
|
26
|
+
name = "tavily"
|
|
27
|
+
max_k = 20
|
|
28
|
+
|
|
29
|
+
def _build_request(self, query: str, k: int) -> SearchRequest:
|
|
30
|
+
return SearchRequest(
|
|
31
|
+
method="POST",
|
|
32
|
+
url=self._url(TAVILY_URL),
|
|
33
|
+
headers={
|
|
34
|
+
"Accept": "application/json",
|
|
35
|
+
"Authorization": f"Bearer {self._key.reveal()}",
|
|
36
|
+
},
|
|
37
|
+
json={
|
|
38
|
+
"query": query,
|
|
39
|
+
"max_results": k,
|
|
40
|
+
"search_depth": "basic",
|
|
41
|
+
"include_answer": False,
|
|
42
|
+
"include_raw_content": False,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _parse(self, data: Any) -> list[SearchResult]:
|
|
47
|
+
results: list[SearchResult] = []
|
|
48
|
+
for item in data.get("results") or []:
|
|
49
|
+
if not isinstance(item, dict):
|
|
50
|
+
continue
|
|
51
|
+
published = item.get("published_date")
|
|
52
|
+
results.append(
|
|
53
|
+
SearchResult(
|
|
54
|
+
url=str(item.get("url", "")),
|
|
55
|
+
title=clean_text(item.get("title"), MAX_TITLE_CHARS),
|
|
56
|
+
snippet=clean_text(item.get("content"), MAX_SNIPPET_CHARS),
|
|
57
|
+
published=published if isinstance(published, str) else None,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
return results
|