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.
Files changed (95) hide show
  1. second_read-0.1.1.dist-info/METADATA +243 -0
  2. second_read-0.1.1.dist-info/RECORD +95 -0
  3. second_read-0.1.1.dist-info/WHEEL +4 -0
  4. second_read-0.1.1.dist-info/entry_points.txt +4 -0
  5. second_read-0.1.1.dist-info/licenses/LICENSE +21 -0
  6. secondread/__init__.py +0 -0
  7. secondread/agents/__init__.py +0 -0
  8. secondread/agents/copyedit/__init__.py +10 -0
  9. secondread/agents/copyedit/agent.py +261 -0
  10. secondread/agents/copyedit/prompts.py +123 -0
  11. secondread/agents/copyedit/text.py +274 -0
  12. secondread/agents/devils_advocate/__init__.py +18 -0
  13. secondread/agents/devils_advocate/agent.py +681 -0
  14. secondread/agents/devils_advocate/prompts.py +263 -0
  15. secondread/agents/devils_advocate/schemas.py +86 -0
  16. secondread/agents/devils_advocate/text.py +123 -0
  17. secondread/agents/extractor/__init__.py +20 -0
  18. secondread/agents/extractor/agent.py +330 -0
  19. secondread/agents/extractor/chunks.py +53 -0
  20. secondread/agents/extractor/prompts.py +121 -0
  21. secondread/agents/extractor/spans.py +177 -0
  22. secondread/agents/factcheck/__init__.py +12 -0
  23. secondread/agents/factcheck/agent.py +267 -0
  24. secondread/agents/factcheck/prompts.py +264 -0
  25. secondread/agents/factcheck/research.py +737 -0
  26. secondread/agents/factcheck/schemas.py +124 -0
  27. secondread/agents/factcheck/sources.py +295 -0
  28. secondread/agents/originality/__init__.py +29 -0
  29. secondread/agents/originality/agent.py +277 -0
  30. secondread/agents/originality/sentences.py +311 -0
  31. secondread/agents/originality/similarity.py +114 -0
  32. secondread/agents/structure/__init__.py +25 -0
  33. secondread/agents/structure/agent.py +301 -0
  34. secondread/agents/structure/prompts.py +179 -0
  35. secondread/cli/__init__.py +5 -0
  36. secondread/cli/__main__.py +6 -0
  37. secondread/cli/envfile.py +80 -0
  38. secondread/cli/errors.py +114 -0
  39. secondread/cli/main.py +327 -0
  40. secondread/contracts/__init__.py +134 -0
  41. secondread/contracts/errors.py +60 -0
  42. secondread/contracts/interfaces.py +314 -0
  43. secondread/contracts/models.py +689 -0
  44. secondread/mcp_local/README.md +96 -0
  45. secondread/mcp_local/__init__.py +35 -0
  46. secondread/mcp_local/__main__.py +6 -0
  47. secondread/mcp_local/demo.py +111 -0
  48. secondread/mcp_local/errors.py +107 -0
  49. secondread/mcp_local/main.py +80 -0
  50. secondread/mcp_local/progress.py +86 -0
  51. secondread/mcp_local/server.py +187 -0
  52. secondread/mcp_local/wiring.py +251 -0
  53. secondread/orchestrator/__init__.py +54 -0
  54. secondread/orchestrator/assemble.py +148 -0
  55. secondread/orchestrator/authwatch.py +83 -0
  56. secondread/orchestrator/classify.py +75 -0
  57. secondread/orchestrator/conflicts.py +406 -0
  58. secondread/orchestrator/labels.py +39 -0
  59. secondread/orchestrator/orchestrator.py +601 -0
  60. secondread/orchestrator/planning.py +221 -0
  61. secondread/orchestrator/progress.py +114 -0
  62. secondread/orchestrator/prompts.py +132 -0
  63. secondread/orchestrator/reactions.py +153 -0
  64. secondread/orchestrator/registry.py +56 -0
  65. secondread/orchestrator/schemas.py +45 -0
  66. secondread/pipeline.py +73 -0
  67. secondread/providers/__init__.py +0 -0
  68. secondread/providers/fetch/__init__.py +29 -0
  69. secondread/providers/fetch/extract.py +153 -0
  70. secondread/providers/fetch/fetcher.py +241 -0
  71. secondread/providers/fetch/guarded.py +110 -0
  72. secondread/providers/fetch/ssrf.py +207 -0
  73. secondread/providers/llm/__init__.py +49 -0
  74. secondread/providers/llm/_common.py +259 -0
  75. secondread/providers/llm/anthropic_client.py +332 -0
  76. secondread/providers/llm/config.py +93 -0
  77. secondread/providers/llm/openai_client.py +229 -0
  78. secondread/providers/search/__init__.py +45 -0
  79. secondread/providers/search/base.py +218 -0
  80. secondread/providers/search/brave.py +55 -0
  81. secondread/providers/search/config.py +63 -0
  82. secondread/providers/search/exa.py +62 -0
  83. secondread/providers/search/retry.py +60 -0
  84. secondread/providers/search/tavily.py +60 -0
  85. secondread/registry.py +270 -0
  86. secondread/report/__init__.py +30 -0
  87. secondread/report/email.py +43 -0
  88. secondread/report/html.py +49 -0
  89. secondread/report/markdown.py +195 -0
  90. secondread/report/templates/email.html.j2 +139 -0
  91. secondread/report/text.py +158 -0
  92. secondread/report/view.py +408 -0
  93. secondread/testing/__init__.py +1 -0
  94. secondread/testing/fakes.py +458 -0
  95. 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