foundry-mcp 0.8.22__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.
Potentially problematic release.
This version of foundry-mcp might be problematic. Click here for more details.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Perplexity Search API provider for web search.
|
|
2
|
+
|
|
3
|
+
This module implements PerplexitySearchProvider, which wraps the Perplexity Search API
|
|
4
|
+
to provide web search capabilities for the deep research workflow.
|
|
5
|
+
|
|
6
|
+
Perplexity Search API documentation: https://docs.perplexity.ai/api-reference/search-post
|
|
7
|
+
|
|
8
|
+
Example usage:
|
|
9
|
+
provider = PerplexitySearchProvider(api_key="pplx-...")
|
|
10
|
+
sources = await provider.search("machine learning trends", max_results=5)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from foundry_mcp.core.research.models import ResearchSource, SourceType
|
|
22
|
+
from foundry_mcp.core.research.providers.base import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
SearchProvider,
|
|
26
|
+
SearchProviderError,
|
|
27
|
+
SearchResult,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Perplexity API constants
|
|
33
|
+
PERPLEXITY_API_BASE_URL = "https://api.perplexity.ai"
|
|
34
|
+
PERPLEXITY_SEARCH_ENDPOINT = "/search"
|
|
35
|
+
DEFAULT_TIMEOUT = 30.0
|
|
36
|
+
DEFAULT_MAX_RETRIES = 3
|
|
37
|
+
DEFAULT_RATE_LIMIT = 1.0 # requests per second
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PerplexitySearchProvider(SearchProvider):
|
|
41
|
+
"""Perplexity Search API provider for web search.
|
|
42
|
+
|
|
43
|
+
Wraps the Perplexity Search API to provide web search capabilities.
|
|
44
|
+
Supports domain filtering, recency filtering, and geographic targeting.
|
|
45
|
+
|
|
46
|
+
Pricing: $5 per 1,000 requests
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
api_key: Perplexity API key (required)
|
|
50
|
+
base_url: API base URL (default: https://api.perplexity.ai)
|
|
51
|
+
timeout: Request timeout in seconds (default: 30.0)
|
|
52
|
+
max_retries: Maximum retry attempts for rate limits (default: 3)
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
provider = PerplexitySearchProvider(api_key="pplx-...")
|
|
56
|
+
sources = await provider.search(
|
|
57
|
+
"AI trends 2024",
|
|
58
|
+
max_results=10,
|
|
59
|
+
recency_filter="week",
|
|
60
|
+
)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
api_key: Optional[str] = None,
|
|
66
|
+
base_url: str = PERPLEXITY_API_BASE_URL,
|
|
67
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
68
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
69
|
+
):
|
|
70
|
+
"""Initialize Perplexity search provider.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
api_key: Perplexity API key. If not provided, reads from PERPLEXITY_API_KEY env var.
|
|
74
|
+
base_url: API base URL (default: https://api.perplexity.ai)
|
|
75
|
+
timeout: Request timeout in seconds (default: 30.0)
|
|
76
|
+
max_retries: Maximum retry attempts for rate limits (default: 3)
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If no API key is provided or found in environment
|
|
80
|
+
"""
|
|
81
|
+
self._api_key = api_key or os.environ.get("PERPLEXITY_API_KEY")
|
|
82
|
+
if not self._api_key:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"Perplexity API key required. Provide via api_key parameter "
|
|
85
|
+
"or PERPLEXITY_API_KEY environment variable."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self._base_url = base_url.rstrip("/")
|
|
89
|
+
self._timeout = timeout
|
|
90
|
+
self._max_retries = max_retries
|
|
91
|
+
self._rate_limit_value = DEFAULT_RATE_LIMIT
|
|
92
|
+
|
|
93
|
+
def get_provider_name(self) -> str:
|
|
94
|
+
"""Return the provider identifier.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
"perplexity"
|
|
98
|
+
"""
|
|
99
|
+
return "perplexity"
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def rate_limit(self) -> Optional[float]:
|
|
103
|
+
"""Return the rate limit in requests per second.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
1.0 (one request per second)
|
|
107
|
+
"""
|
|
108
|
+
return self._rate_limit_value
|
|
109
|
+
|
|
110
|
+
async def search(
|
|
111
|
+
self,
|
|
112
|
+
query: str,
|
|
113
|
+
max_results: int = 10,
|
|
114
|
+
**kwargs: Any,
|
|
115
|
+
) -> list[ResearchSource]:
|
|
116
|
+
"""Execute a web search via Perplexity Search API.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
query: The search query string
|
|
120
|
+
max_results: Maximum number of results to return (default: 10, max: 20)
|
|
121
|
+
**kwargs: Additional Perplexity options:
|
|
122
|
+
- recency_filter: Time filter ('day', 'week', 'month', 'year')
|
|
123
|
+
- domain_filter: List of domains to include (max 20)
|
|
124
|
+
- country: Geographic filter ('US', 'GB', etc.)
|
|
125
|
+
- sub_query_id: SubQuery ID for source tracking
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of ResearchSource objects
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
AuthenticationError: If API key is invalid
|
|
132
|
+
RateLimitError: If rate limit exceeded after all retries
|
|
133
|
+
SearchProviderError: For other API errors
|
|
134
|
+
"""
|
|
135
|
+
# Extract Perplexity-specific options
|
|
136
|
+
recency_filter = kwargs.get("recency_filter")
|
|
137
|
+
domain_filter = kwargs.get("domain_filter", [])
|
|
138
|
+
country = kwargs.get("country")
|
|
139
|
+
sub_query_id = kwargs.get("sub_query_id")
|
|
140
|
+
|
|
141
|
+
# Clamp max_results to Perplexity's limit (1-20)
|
|
142
|
+
max_results = max(1, min(max_results, 20))
|
|
143
|
+
|
|
144
|
+
# Build request payload
|
|
145
|
+
payload: dict[str, Any] = {
|
|
146
|
+
"query": query,
|
|
147
|
+
"max_results": max_results,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if recency_filter and recency_filter in ("day", "week", "month", "year"):
|
|
151
|
+
payload["search_recency_filter"] = recency_filter
|
|
152
|
+
if domain_filter:
|
|
153
|
+
# Perplexity allows max 20 domains
|
|
154
|
+
payload["search_domain_filter"] = domain_filter[:20]
|
|
155
|
+
if country:
|
|
156
|
+
payload["country"] = country
|
|
157
|
+
|
|
158
|
+
# Execute with retry logic
|
|
159
|
+
response_data = await self._execute_with_retry(payload)
|
|
160
|
+
|
|
161
|
+
# Parse results
|
|
162
|
+
return self._parse_response(response_data, sub_query_id)
|
|
163
|
+
|
|
164
|
+
async def _execute_with_retry(
|
|
165
|
+
self,
|
|
166
|
+
payload: dict[str, Any],
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Execute API request with exponential backoff retry.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
payload: Request payload
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Parsed JSON response
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
AuthenticationError: If API key is invalid
|
|
178
|
+
RateLimitError: If rate limit exceeded after all retries
|
|
179
|
+
SearchProviderError: For other API errors
|
|
180
|
+
"""
|
|
181
|
+
url = f"{self._base_url}{PERPLEXITY_SEARCH_ENDPOINT}"
|
|
182
|
+
headers = {
|
|
183
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
}
|
|
186
|
+
last_error: Optional[Exception] = None
|
|
187
|
+
|
|
188
|
+
for attempt in range(self._max_retries):
|
|
189
|
+
try:
|
|
190
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
191
|
+
response = await client.post(url, json=payload, headers=headers)
|
|
192
|
+
|
|
193
|
+
# Handle authentication errors (not retryable)
|
|
194
|
+
if response.status_code == 401:
|
|
195
|
+
raise AuthenticationError(
|
|
196
|
+
provider="perplexity",
|
|
197
|
+
message="Invalid API key",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Handle rate limiting
|
|
201
|
+
if response.status_code == 429:
|
|
202
|
+
retry_after = self._parse_retry_after(response)
|
|
203
|
+
if attempt < self._max_retries - 1:
|
|
204
|
+
wait_time = retry_after or (2**attempt)
|
|
205
|
+
logger.warning(
|
|
206
|
+
f"Perplexity rate limit hit, waiting {wait_time}s "
|
|
207
|
+
f"(attempt {attempt + 1}/{self._max_retries})"
|
|
208
|
+
)
|
|
209
|
+
await asyncio.sleep(wait_time)
|
|
210
|
+
continue
|
|
211
|
+
raise RateLimitError(
|
|
212
|
+
provider="perplexity",
|
|
213
|
+
retry_after=retry_after,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Handle server errors (retryable)
|
|
217
|
+
if response.status_code >= 500:
|
|
218
|
+
if attempt < self._max_retries - 1:
|
|
219
|
+
wait_time = 2**attempt
|
|
220
|
+
logger.warning(
|
|
221
|
+
f"Perplexity server error {response.status_code}, "
|
|
222
|
+
f"retrying in {wait_time}s "
|
|
223
|
+
f"(attempt {attempt + 1}/{self._max_retries})"
|
|
224
|
+
)
|
|
225
|
+
await asyncio.sleep(wait_time)
|
|
226
|
+
continue
|
|
227
|
+
error_msg = self._extract_error_message(response)
|
|
228
|
+
raise SearchProviderError(
|
|
229
|
+
provider="perplexity",
|
|
230
|
+
message=f"API error {response.status_code}: {error_msg}",
|
|
231
|
+
retryable=True,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Handle other errors
|
|
235
|
+
if response.status_code >= 400:
|
|
236
|
+
error_msg = self._extract_error_message(response)
|
|
237
|
+
raise SearchProviderError(
|
|
238
|
+
provider="perplexity",
|
|
239
|
+
message=f"API error {response.status_code}: {error_msg}",
|
|
240
|
+
retryable=False,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return response.json()
|
|
244
|
+
|
|
245
|
+
except httpx.TimeoutException as e:
|
|
246
|
+
last_error = e
|
|
247
|
+
if attempt < self._max_retries - 1:
|
|
248
|
+
wait_time = 2**attempt
|
|
249
|
+
logger.warning(
|
|
250
|
+
f"Perplexity request timeout, retrying in {wait_time}s "
|
|
251
|
+
f"(attempt {attempt + 1}/{self._max_retries})"
|
|
252
|
+
)
|
|
253
|
+
await asyncio.sleep(wait_time)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
except httpx.RequestError as e:
|
|
257
|
+
last_error = e
|
|
258
|
+
if attempt < self._max_retries - 1:
|
|
259
|
+
wait_time = 2**attempt
|
|
260
|
+
logger.warning(
|
|
261
|
+
f"Perplexity request error: {e}, retrying in {wait_time}s "
|
|
262
|
+
f"(attempt {attempt + 1}/{self._max_retries})"
|
|
263
|
+
)
|
|
264
|
+
await asyncio.sleep(wait_time)
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
except (AuthenticationError, RateLimitError, SearchProviderError):
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
# All retries exhausted
|
|
271
|
+
raise SearchProviderError(
|
|
272
|
+
provider="perplexity",
|
|
273
|
+
message=f"Request failed after {self._max_retries} attempts",
|
|
274
|
+
retryable=False,
|
|
275
|
+
original_error=last_error,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _parse_retry_after(self, response: httpx.Response) -> Optional[float]:
|
|
279
|
+
"""Parse Retry-After header from response.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
response: HTTP response
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Seconds to wait, or None if not provided
|
|
286
|
+
"""
|
|
287
|
+
retry_after = response.headers.get("Retry-After")
|
|
288
|
+
if retry_after:
|
|
289
|
+
try:
|
|
290
|
+
return float(retry_after)
|
|
291
|
+
except ValueError:
|
|
292
|
+
pass
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
def _extract_error_message(self, response: httpx.Response) -> str:
|
|
296
|
+
"""Extract error message from response.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
response: HTTP response
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Error message string
|
|
303
|
+
"""
|
|
304
|
+
try:
|
|
305
|
+
data = response.json()
|
|
306
|
+
return data.get("error", data.get("message", response.text[:200]))
|
|
307
|
+
except Exception:
|
|
308
|
+
return response.text[:200] if response.text else "Unknown error"
|
|
309
|
+
|
|
310
|
+
def _parse_response(
|
|
311
|
+
self,
|
|
312
|
+
data: dict[str, Any],
|
|
313
|
+
sub_query_id: Optional[str] = None,
|
|
314
|
+
) -> list[ResearchSource]:
|
|
315
|
+
"""Parse Perplexity API response into ResearchSource objects.
|
|
316
|
+
|
|
317
|
+
Perplexity Search API response structure:
|
|
318
|
+
{
|
|
319
|
+
"results": [
|
|
320
|
+
{
|
|
321
|
+
"title": "...",
|
|
322
|
+
"url": "...",
|
|
323
|
+
"snippet": "...",
|
|
324
|
+
"date": "...",
|
|
325
|
+
"last_updated": "..."
|
|
326
|
+
}
|
|
327
|
+
]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
data: Perplexity API response JSON
|
|
332
|
+
sub_query_id: SubQuery ID for source tracking
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
List of ResearchSource objects
|
|
336
|
+
"""
|
|
337
|
+
sources: list[ResearchSource] = []
|
|
338
|
+
results = data.get("results", [])
|
|
339
|
+
|
|
340
|
+
for result in results:
|
|
341
|
+
# Parse date - try both 'date' and 'last_updated' fields
|
|
342
|
+
published_date = self._parse_date(
|
|
343
|
+
result.get("date") or result.get("last_updated")
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Create SearchResult from Perplexity response
|
|
347
|
+
search_result = SearchResult(
|
|
348
|
+
url=result.get("url", ""),
|
|
349
|
+
title=result.get("title", "Untitled"),
|
|
350
|
+
snippet=result.get("snippet"),
|
|
351
|
+
content=None, # Perplexity doesn't provide full content in search
|
|
352
|
+
score=None, # Perplexity doesn't provide relevance scores
|
|
353
|
+
published_date=published_date,
|
|
354
|
+
source=self._extract_domain(result.get("url", "")),
|
|
355
|
+
metadata={
|
|
356
|
+
"perplexity_date": result.get("date"),
|
|
357
|
+
"perplexity_last_updated": result.get("last_updated"),
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Convert to ResearchSource
|
|
362
|
+
research_source = search_result.to_research_source(
|
|
363
|
+
source_type=SourceType.WEB,
|
|
364
|
+
sub_query_id=sub_query_id,
|
|
365
|
+
)
|
|
366
|
+
sources.append(research_source)
|
|
367
|
+
|
|
368
|
+
return sources
|
|
369
|
+
|
|
370
|
+
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
|
371
|
+
"""Parse date string from Perplexity response.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
date_str: ISO format date string or other common formats
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Parsed datetime or None
|
|
378
|
+
"""
|
|
379
|
+
if not date_str:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
# Try ISO format first
|
|
383
|
+
try:
|
|
384
|
+
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
385
|
+
except ValueError:
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
# Try common date formats
|
|
389
|
+
formats = [
|
|
390
|
+
"%Y-%m-%d",
|
|
391
|
+
"%Y/%m/%d",
|
|
392
|
+
"%d-%m-%Y",
|
|
393
|
+
"%d/%m/%Y",
|
|
394
|
+
"%B %d, %Y",
|
|
395
|
+
"%b %d, %Y",
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
for fmt in formats:
|
|
399
|
+
try:
|
|
400
|
+
return datetime.strptime(date_str, fmt)
|
|
401
|
+
except ValueError:
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
def _extract_domain(self, url: str) -> Optional[str]:
|
|
407
|
+
"""Extract domain from URL.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
url: Full URL
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Domain name or None
|
|
414
|
+
"""
|
|
415
|
+
if not url:
|
|
416
|
+
return None
|
|
417
|
+
try:
|
|
418
|
+
from urllib.parse import urlparse
|
|
419
|
+
|
|
420
|
+
parsed = urlparse(url)
|
|
421
|
+
return parsed.netloc or None
|
|
422
|
+
except Exception:
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
async def health_check(self) -> bool:
|
|
426
|
+
"""Check if Perplexity API is accessible.
|
|
427
|
+
|
|
428
|
+
Performs a lightweight search to verify API key and connectivity.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
True if provider is healthy, False otherwise
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
# Perform minimal search to verify connectivity
|
|
435
|
+
await self.search("test", max_results=1)
|
|
436
|
+
return True
|
|
437
|
+
except AuthenticationError:
|
|
438
|
+
logger.error("Perplexity health check failed: invalid API key")
|
|
439
|
+
return False
|
|
440
|
+
except Exception as e:
|
|
441
|
+
logger.warning(f"Perplexity health check failed: {e}")
|
|
442
|
+
return False
|