search-api-webui 0.1.7__py3-none-any.whl → 0.1.9__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.
@@ -18,55 +18,81 @@
18
18
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
19
  # DEALINGS IN THE SOFTWARE.
20
20
 
21
+ import html
22
+ import logging
21
23
  import time
22
- import requests
24
+
23
25
  import jmespath
24
- from .base import BaseProvider
26
+ import requests
27
+
28
+ from .base import BaseProvider, extract_domain_from_url, parse_server_latency
29
+
30
+ logger = logging.getLogger(__name__)
31
+
25
32
 
26
33
  class GenericProvider(BaseProvider):
27
- """
34
+ '''
28
35
  A generic search provider driven by YAML configuration.
29
36
  It constructs HTTP requests dynamically and maps responses using JMESPath.
30
- """
37
+ '''
31
38
 
32
39
  def __init__(self, config):
33
- """
40
+ '''
34
41
  Initialize the provider with a configuration dictionary.
35
-
42
+
36
43
  Args:
37
44
  config (dict): Configuration containing url, headers, params, and mapping rules.
38
- """
45
+ '''
39
46
  self.config = config
40
47
  self.session = requests.Session() # Persistent connection session
41
- self._connection_ready = False # Connection ready status
42
- self._last_url = None # Last used URL tracker
48
+ self._connection_ready = False # Connection ready status
49
+ self._last_url = None # Last used URL tracker
43
50
 
44
51
  def _fill_template(self, template_obj, **kwargs):
45
- """
46
- Recursively replaces placeholders (e.g., {query}) in dictionaries or strings
52
+ '''
53
+ Recursively replaces placeholders (e.g., {query}) in dictionaries or strings
47
54
  with values provided in kwargs.
48
-
55
+
49
56
  Args:
50
57
  template_obj (dict | str): The structure containing placeholders.
51
58
  **kwargs: Key-value pairs to inject into the template.
52
-
59
+
53
60
  Returns:
54
61
  The structure with placeholders replaced by actual values.
55
- """
62
+ Dict entries with empty values are removed.
63
+ Pure numeric placeholders (e.g., "{limit}") are converted to int/float.
64
+ '''
56
65
  if isinstance(template_obj, str):
57
66
  # Treat None values as empty strings to prevent "None" appearing in URLs
58
67
  safe_kwargs = {k: (v if v is not None else '') for k, v in kwargs.items()}
59
68
  try:
60
- return template_obj.format(**safe_kwargs)
69
+ result = template_obj.format(**safe_kwargs)
70
+ # Convert to number if the result is a numeric string
71
+ # and the original template was a pure placeholder like "{limit}"
72
+ if template_obj.strip().startswith('{') and template_obj.strip().endswith('}'):
73
+ try:
74
+ # Try int first, then float
75
+ if '.' in result:
76
+ return float(result)
77
+ return int(result)
78
+ except (ValueError, AttributeError):
79
+ pass
80
+ return result
61
81
  except KeyError:
62
82
  # Return original string if a placeholder key is missing in kwargs
63
83
  return template_obj
64
84
  elif isinstance(template_obj, dict):
65
- return {k: self._fill_template(v, **kwargs) for k, v in template_obj.items()}
85
+ result = {}
86
+ for k, v in template_obj.items():
87
+ filled = self._fill_template(v, **kwargs)
88
+ # Skip entries with empty values (e.g., empty strings after template fill)
89
+ if filled != '':
90
+ result[k] = filled
91
+ return result
66
92
  return template_obj
67
93
 
68
94
  def _ensure_connection(self, url, headers):
69
- """
95
+ '''
70
96
  Pre-warm HTTPS connection and verify availability.
71
97
  Uses lightweight HEAD request to verify connection without fetching response body.
72
98
 
@@ -76,7 +102,7 @@ class GenericProvider(BaseProvider):
76
102
 
77
103
  Returns:
78
104
  bool: Whether connection is ready
79
- """
105
+ '''
80
106
  # Re-warm if URL changed or connection not ready
81
107
  if url != self._last_url or not self._connection_ready:
82
108
  try:
@@ -84,53 +110,64 @@ class GenericProvider(BaseProvider):
84
110
  self.session.head(url, headers=headers, timeout=5)
85
111
  self._connection_ready = True
86
112
  self._last_url = url
87
- print(f' [Connection Pool] Connected to: {url}')
113
+ logger.debug('[Connection Pool] Connected to: %s', url)
88
114
  except Exception as e:
89
115
  self._connection_ready = False
90
- print(f' [Connection Pool] Connection warm-up failed: {e}')
116
+ logger.warning('[Connection Pool] Connection warm-up failed: %s', e)
91
117
  raise
92
118
 
93
119
  def search(self, query, api_key, **kwargs):
120
+ '''
121
+ Perform search using the configured API.
122
+
123
+ Args:
124
+ query: Search query string
125
+ api_key: API key for authentication
126
+ **kwargs: Additional parameters (limit, language, api_url)
127
+
128
+ Returns:
129
+ dict: Search results with 'results' and 'metrics' keys
130
+ '''
94
131
  # 1. Extract parameters with defaults
95
132
  limit = kwargs.get('limit', '10')
96
- language = kwargs.get('language', 'en-US')
97
- custom_url = kwargs.get('api_url', '').strip()
133
+ language = kwargs.get('language')
134
+ custom_url = kwargs.get('api_url')
98
135
 
99
136
  # 2. Determine configuration
100
- url = custom_url if custom_url else self.config.get('url')
137
+ # Use custom api_url if provided, otherwise fallback to config url
138
+ url = custom_url.strip() if custom_url else self.config.get('url')
101
139
  method = self.config.get('method', 'GET')
102
-
140
+
103
141
  # 3. Prepare context for template injection
104
142
  context = {
105
143
  'query': query,
106
144
  'api_key': api_key,
107
145
  'limit': limit,
108
- 'language': language
109
146
  }
147
+ # Only add language to context if provided
148
+ if language:
149
+ context['language'] = language
110
150
 
111
151
  # 4. construct request components
112
152
  headers = self._fill_template(self.config.get('headers', {}), **context)
113
153
  params = self._fill_template(self.config.get('params', {}), **context)
114
154
  json_body = self._fill_template(self.config.get('payload', {}), **context)
115
155
 
116
- # Logging (Masking sensitive API keys)
117
- print(f'[{self.config.get("name", "Unknown")}] Search:')
118
- print(f' URL: {url} | Method: {method}')
119
-
156
+ logger.info('[%s] Search: URL=%s | Method=%s',
157
+ self.config.get('name', 'Unknown'), url, method)
158
+
120
159
  # Ensure connection is pre-warmed (use HEAD request to verify availability)
121
160
  # Pre-warming is not counted in request latency, only verifies connection
122
161
  try:
123
162
  self._ensure_connection(url, headers)
124
163
  except Exception as e:
125
- print(f"Connection Warm-up Error: {e}")
164
+ logger.error('Connection Warm-up Error: %s', e)
126
165
  return {
127
- "error": f"Connection failed: {str(e)}",
128
- "results": [],
129
- "metrics": {"latency_ms": 0, "size_bytes": 0}
166
+ 'error': f'Connection failed: {str(e)}',
167
+ 'results': [],
168
+ 'metrics': {'latency_ms': 0, 'server_latency_ms': None, 'size_bytes': 0},
130
169
  }
131
170
 
132
- start_time = time.time()
133
-
134
171
  try:
135
172
  req_args = {'headers': headers, 'timeout': 30}
136
173
  if params:
@@ -139,48 +176,91 @@ class GenericProvider(BaseProvider):
139
176
  req_args['json'] = json_body
140
177
 
141
178
  # Use Session to send request (connection is reused)
179
+ start_time = time.time()
142
180
  if method.upper() == 'GET':
143
181
  response = self.session.get(url, **req_args)
144
182
  else:
145
183
  response = self.session.post(url, **req_args)
184
+ end_time = time.time()
146
185
 
147
186
  response.raise_for_status()
148
187
  except Exception as e:
149
- print(f"Request Error: {e}")
188
+ logger.error('Request Error: %s', e, f"args: {req_args}")
150
189
  return {
151
- "error": str(e),
152
- "results": [],
153
- "metrics": {"latency_ms": 0, "size_bytes": 0}
190
+ 'error': str(e),
191
+ 'results': [],
192
+ 'metrics': {'latency_ms': 0, 'server_latency_ms': None, 'size_bytes': 0},
154
193
  }
155
194
 
156
- end_time = time.time()
157
-
158
195
  # 5. Parse and Normalize Response
159
196
  try:
160
197
  raw_data = response.json()
161
198
  except Exception as e:
162
- print(f"JSON Parse Error: {e}")
199
+ logger.error('JSON Parse Error: %s', e)
163
200
  raw_data = {}
164
201
 
202
+ logger.debug('Full response: %s', raw_data)
203
+
165
204
  mapping = self.config.get('response_mapping', {})
166
205
  # Use JMESPath to find the list of results
167
206
  root_list = jmespath.search(mapping.get('root_path', '@'), raw_data) or []
168
207
 
169
208
  normalized_results = []
170
209
  field_map = mapping.get('fields', {})
210
+ # Define common fields that should be extracted as-is
211
+ common_fields = {'title', 'url', 'site_name', 'site_icon', 'page_age'}
212
+
213
+ # Collect all JMESPath source paths that are already mapped
214
+ mapped_paths = set(field_map.values())
171
215
 
172
216
  for item in root_list:
173
217
  entry = {}
218
+ snippet_fields = {} # Collect unmapped fields from raw API response
219
+
174
220
  # Map specific fields (title, url, etc.) based on config
175
221
  for std_key, source_path in field_map.items():
176
222
  val = jmespath.search(source_path, item)
177
- entry[std_key] = val if val else ""
223
+ # Decode HTML entities for site_name
224
+ if std_key == 'site_name' and val:
225
+ val = html.unescape(val)
226
+
227
+ # Only store common fields in entry
228
+ if std_key in common_fields:
229
+ entry[std_key] = val if val else ''
230
+
231
+ # Find all unmapped fields in the raw item
232
+ # These are fields that exist in the API response but are not in field_map
233
+ if isinstance(item, dict):
234
+ for key, value in item.items():
235
+ # Check if this key is already mapped in config
236
+ # Skip common nested objects and arrays for cleaner output
237
+ if key not in mapped_paths and value and not isinstance(value, (dict, list)):
238
+ snippet_fields[key] = value
239
+
240
+ # Store snippet fields as JSON structure in snippet
241
+ if snippet_fields:
242
+ entry['snippet'] = snippet_fields
243
+ else:
244
+ entry['snippet'] = ''
245
+
178
246
  normalized_results.append(entry)
179
247
 
248
+ # Post-process: extract domain from URL if site_name is empty
249
+ for entry in normalized_results:
250
+ if not entry.get('site_name') and entry.get('url'):
251
+ entry['site_name'] = extract_domain_from_url(entry['url']) or ''
252
+
253
+ # Extract server latency from response if configured
254
+ server_latency_path = mapping.get('server_latency_path')
255
+ server_latency_ms = parse_server_latency(
256
+ jmespath.search(server_latency_path, raw_data),
257
+ ) if server_latency_path else None
258
+
180
259
  return {
181
- "results": normalized_results,
182
- "metrics": {
183
- "latency_ms": round((end_time - start_time) * 1000, 2),
184
- "size_bytes": len(response.content)
185
- }
260
+ 'results': normalized_results,
261
+ 'metrics': {
262
+ 'latency_ms': round((end_time - start_time) * 1000, 2),
263
+ 'server_latency_ms': server_latency_ms,
264
+ 'size_bytes': len(response.content),
265
+ },
186
266
  }
@@ -18,32 +18,35 @@
18
18
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
19
  # DEALINGS IN THE SOFTWARE.
20
20
 
21
- import time
22
21
  import json
22
+ import logging
23
+ import time
24
+
23
25
  from querit import QueritClient
24
- from querit.models.request import SearchRequest
25
26
  from querit.errors import QueritError
26
- from .base import BaseProvider
27
+ from querit.models.request import SearchRequest
28
+
29
+ from .base import BaseProvider, parse_server_latency
30
+
31
+ logger = logging.getLogger(__name__)
32
+
27
33
 
28
34
  class QueritSdkProvider(BaseProvider):
29
- """
35
+ '''
30
36
  Specialized provider implementation using the official Querit Python SDK.
31
- """
37
+ '''
32
38
 
33
39
  def __init__(self, config):
34
40
  self.config = config
35
41
 
36
42
  def search(self, query, api_key, **kwargs):
37
- """
43
+ '''
38
44
  Executes a search using the Querit SDK.
39
45
  Handles the 'Bearer' prefix logic internally within the SDK.
40
- """
46
+ '''
41
47
  try:
42
48
  # Initialize client with the raw API key
43
- client = QueritClient(
44
- api_key=api_key.strip(),
45
- timeout=30
46
- )
49
+ client = QueritClient(api_key=api_key.strip(), timeout=30)
47
50
 
48
51
  limit = int(kwargs.get('limit', 10))
49
52
 
@@ -52,13 +55,13 @@ class QueritSdkProvider(BaseProvider):
52
55
  count=limit,
53
56
  )
54
57
 
55
- print(f'[Querit SDK] Searching: {query} (Limit: {limit})')
58
+ logger.debug(f'[Querit SDK] Searching: {query} (Limit: {limit})')
56
59
 
57
60
  start_time = time.time()
58
-
61
+
59
62
  # Execute search via SDK
60
63
  response = client.search(request_model)
61
-
64
+
62
65
  end_time = time.time()
63
66
 
64
67
  # Normalize results to standard format
@@ -66,35 +69,44 @@ class QueritSdkProvider(BaseProvider):
66
69
  if response.results:
67
70
  for item in response.results:
68
71
  # Use getattr to safely access SDK object attributes
69
- normalized_results.append({
70
- "title": getattr(item, 'title', ''),
71
- "url": getattr(item, 'url', ''),
72
- # Fallback to description if snippet is missing
73
- "snippet": getattr(item, 'snippet', '') or getattr(item, 'description', '')
74
- })
72
+ normalized_results.append(
73
+ {
74
+ 'title': getattr(item, 'title', ''),
75
+ 'url': getattr(item, 'url', ''),
76
+ # Fallback to description if snippet is missing
77
+ 'snippet': getattr(item, 'snippet', '') or getattr(item, 'description', ''),
78
+ },
79
+ )
75
80
 
76
81
  # Calculate estimated size for metrics (approximate JSON size)
77
- estimated_size = len(json.dumps([r for r in normalized_results]))
82
+ estimated_size = len(json.dumps(list(normalized_results)))
83
+
84
+ # Extract server latency from SDK response if available
85
+ server_latency_ms = None
86
+ # The SDK may provide the server's 'took' field from the response
87
+ if hasattr(response, 'took') and response.took is not None:
88
+ server_latency_ms = parse_server_latency(response.took)
78
89
 
79
90
  return {
80
- "results": normalized_results,
81
- "metrics": {
82
- "latency_ms": round((end_time - start_time) * 1000, 2),
83
- "size_bytes": estimated_size
84
- }
91
+ 'results': normalized_results,
92
+ 'metrics': {
93
+ 'latency_ms': round((end_time - start_time) * 1000, 2),
94
+ 'server_latency_ms': server_latency_ms,
95
+ 'size_bytes': estimated_size,
96
+ },
85
97
  }
86
98
 
87
99
  except QueritError as e:
88
- print(f"Querit SDK Error: {e}")
100
+ logger.error(f'Querit SDK Error: {e}')
89
101
  return {
90
- "error": f"Querit SDK Error: {str(e)}",
91
- "results": [],
92
- "metrics": {"latency_ms": 0, "size_bytes": 0}
102
+ 'error': f'Querit SDK Error: {str(e)}',
103
+ 'results': [],
104
+ 'metrics': {'latency_ms': 0, 'server_latency_ms': None, 'size_bytes': 0},
93
105
  }
94
106
  except Exception as e:
95
- print(f"Unexpected Error: {e}")
107
+ logger.exception(f'Unexpected Error: {e}')
96
108
  return {
97
- "error": f"Error: {str(e)}",
98
- "results": [],
99
- "metrics": {"latency_ms": 0, "size_bytes": 0}
109
+ 'error': f'Error: {str(e)}',
110
+ 'results': [],
111
+ 'metrics': {'latency_ms': 0, 'server_latency_ms': None, 'size_bytes': 0},
100
112
  }
@@ -1,7 +1,22 @@
1
1
  querit:
2
- type: "querit_sdk"
3
- description: "Official Querit Search via Python SDK"
4
- default_limit: 10
2
+ url: "https://api.querit.ai/v1/search"
3
+ method: "POST"
4
+ headers:
5
+ "Accept": "application/json"
6
+ "Authorization": "Bearer {api_key}"
7
+ "Content-Type": "application/json"
8
+ payload:
9
+ query: "{query}"
10
+ count: "{limit}"
11
+ response_mapping:
12
+ root_path: "results.result"
13
+ server_latency_path: "took"
14
+ fields:
15
+ title: "title"
16
+ url: "url"
17
+ site_name: "site_name"
18
+ site_icon: "site_icon"
19
+ page_age: "page_age"
5
20
 
6
21
  you:
7
22
  url: "https://ydc-index.io/v1/search"
@@ -10,10 +25,53 @@ you:
10
25
  X-API-Key: "{api_key}"
11
26
  params:
12
27
  query: "{query}"
28
+ count: "{limit}"
13
29
  payload: {}
14
30
  response_mapping:
15
- root_path: "results.web"
31
+ root_path: "results.web"
32
+ server_latency_path: "metadata.latency"
16
33
  fields:
17
34
  title: "title"
18
35
  url: "url"
19
- snippet: "snippets[0] || description"
36
+ site_icon: "favicon_url"
37
+ page_age: "page_age"
38
+
39
+ brave:
40
+ url: "https://api.search.brave.com/res/v1/web/search"
41
+ method: "GET"
42
+ headers:
43
+ X-Subscription-Token: "{api_key}"
44
+ Accept: "application/json"
45
+ params:
46
+ q: "{query}"
47
+ count: "{limit}"
48
+ payload: {}
49
+ response_mapping:
50
+ root_path: "web.results"
51
+ fields:
52
+ title: "title"
53
+ url: "url"
54
+ page_age: "page_age"
55
+
56
+ exa:
57
+ url: "https://api.exa.ai/search"
58
+ method: "POST"
59
+ headers:
60
+ "Content-Type": "application/json"
61
+ "x-api-key": "{api_key}"
62
+ params: {}
63
+ payload:
64
+ query: "{query}"
65
+ numResults: "{limit}"
66
+ response_mapping:
67
+ root_path: "results"
68
+ fields:
69
+ title: "title"
70
+ url: "url"
71
+ page_age: "publishedDate"
72
+ site_icon: "favicon"
73
+
74
+ querit_sdk:
75
+ type: "querit_sdk"
76
+ description: "Official Querit Search via Python SDK"
77
+ default_limit: 10
@@ -0,0 +1,27 @@
1
+ # Ruff configuration
2
+ # Ruff replaces Black, flake8, and isort
3
+
4
+ line-length = 120
5
+ target-version = "py37"
6
+
7
+ [format]
8
+ quote-style = "single"
9
+ indent-style = "space"
10
+ docstring-code-format = false
11
+
12
+ [lint]
13
+ select = [
14
+ "E", # pycodestyle errors
15
+ "W", # pycodestyle warnings
16
+ "F", # pyflakes
17
+ "I", # isort
18
+ "N", # pep8-naming
19
+ "UP", # pyupgrade
20
+ "B", # flake8-bugbear
21
+ "C4", # flake8-comprehensions
22
+ "SIM", # flake8-simplify
23
+ "COM", # flake8-commas (trailing commas)
24
+ ]
25
+ ignore = [
26
+ "E203", # whitespace before ':' - conflicts with Black
27
+ ]
Binary file
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.right-3{right:.75rem}.top-0{top:0}.top-3\.5{top:.875rem}.z-10{z-index:10}.col-span-2{grid-column:span 2 / span 2}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-2{margin-left:.5rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-full{height:100%}.min-h-\[500px\]{min-height:500px}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-12{width:3rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-0{border-width:0px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-t-4{border-top-width:4px}.border-dashed{border-style:dashed}.border-gray-100{--tw-border-opacity: 1;border-color:rgb(243 244 246 / var(--tw-border-opacity, 1))}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1))}.border-gray-300{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1))}.border-green-200{--tw-border-opacity: 1;border-color:rgb(187 247 208 / var(--tw-border-opacity, 1))}.border-purple-200{--tw-border-opacity: 1;border-color:rgb(233 213 255 / var(--tw-border-opacity, 1))}.border-red-200{--tw-border-opacity: 1;border-color:rgb(254 202 202 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.border-t-transparent{border-top-color:transparent}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.bg-gray-100\/50{background-color:#f3f4f680}.bg-gray-400{--tw-bg-opacity: 1;background-color:rgb(156 163 175 / var(--tw-bg-opacity, 1))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-green-200{--tw-bg-opacity: 1;background-color:rgb(187 247 208 / var(--tw-bg-opacity, 1))}.bg-green-50{--tw-bg-opacity: 1;background-color:rgb(240 253 244 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-purple-600{--tw-bg-opacity: 1;background-color:rgb(147 51 234 / var(--tw-bg-opacity, 1))}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-yellow-100{--tw-bg-opacity: 1;background-color:rgb(254 249 195 / var(--tw-bg-opacity, 1))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-blue-600{--tw-gradient-from: #2563eb var(--tw-gradient-from-position);--tw-gradient-to: rgb(37 99 235 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-gray-50{--tw-gradient-from: #f9fafb var(--tw-gradient-from-position);--tw-gradient-to: rgb(249 250 251 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-purple-600{--tw-gradient-from: #9333ea var(--tw-gradient-from-position);--tw-gradient-to: rgb(147 51 234 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.to-blue-50{--tw-gradient-to: #eff6ff var(--tw-gradient-to-position)}.to-blue-600{--tw-gradient-to: #2563eb var(--tw-gradient-to-position)}.to-indigo-600{--tw-gradient-to: #4f46e5 var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.p-2{padding:.5rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-2{padding-bottom:.5rem}.pl-0{padding-left:0}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-relaxed{line-height:1.625}.tracking-widest{letter-spacing:.1em}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.text-gray-950{--tw-text-opacity: 1;color:rgb(3 7 18 / var(--tw-text-opacity, 1))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-green-700{--tw-text-opacity: 1;color:rgb(21 128 61 / var(--tw-text-opacity, 1))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-purple-600{--tw-text-opacity: 1;color:rgb(147 51 234 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-red-800{--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity, 1))}.text-transparent{color:transparent}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-800{--tw-text-opacity: 1;color:rgb(133 77 14 / var(--tw-text-opacity, 1))}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.shadow-inner{--tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / .05);--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-gray-200{--tw-ring-opacity: 1;--tw-ring-color: rgb(229 231 235 / var(--tw-ring-opacity, 1))}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-500{transition-duration:.5s}body{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.file\:border-0::file-selector-button{border-width:0px}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-gray-500::-moz-placeholder{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.placeholder\:text-gray-500::placeholder{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.hover\:bg-blue-700:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-200:hover{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.hover\:bg-green-300:hover{--tw-bg-opacity: 1;background-color:rgb(134 239 172 / var(--tw-bg-opacity, 1))}.hover\:bg-purple-50:hover{--tw-bg-opacity: 1;background-color:rgb(250 245 255 / var(--tw-bg-opacity, 1))}.hover\:bg-purple-700:hover{--tw-bg-opacity: 1;background-color:rgb(126 34 206 / var(--tw-bg-opacity, 1))}.hover\:bg-red-50:hover{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.hover\:text-blue-800:hover{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity, 1))}.hover\:text-gray-900:hover{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.hover\:text-purple-700:hover{--tw-text-opacity: 1;color:rgb(126 34 206 / var(--tw-text-opacity, 1))}.hover\:text-red-700:hover{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-600:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(37 99 235 / var(--tw-ring-opacity, 1))}.focus\:ring-purple-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(168 85 247 / var(--tw-ring-opacity, 1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-blue-600:focus-visible{--tw-ring-opacity: 1;--tw-ring-color: rgb(37 99 235 / var(--tw-ring-opacity, 1))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:underline{text-decoration-line:underline}.group:hover .group-hover\:opacity-50{opacity:.5}.data-\[side\=Left\]\:border-t-blue-500[data-side=Left]{--tw-border-opacity: 1;border-top-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.data-\[side\=Right\]\:border-t-orange-500[data-side=Right]{--tw-border-opacity: 1;border-top-color:rgb(249 115 22 / var(--tw-border-opacity, 1))}@media(min-width:768px){.md\:w-64{width:16rem}.md\:w-auto{width:auto}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}}