sandeepmudhiraj commited on
Commit
68f428f
Β·
verified Β·
1 Parent(s): 488ee44

Upload folder using huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +403 -403
app.py CHANGED
@@ -1,403 +1,403 @@
1
- """
2
- Swift Scraper API β€” Hugging Face Spaces Edition
3
- =================================================
4
- FastAPI: Private SearxNG meta-search β†’ concurrent scraping (trafilatura +
5
- semaphore + asyncio.to_thread) β†’ Cerebras LLM cascade (gpt-oss-120b β†’ llama3.1-8b).
6
-
7
- Deployed on: HF Spaces (cpu-basic, 16GB RAM)
8
- Port: 7860 (HF requirement)
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import asyncio
14
- import gc
15
- import logging
16
- import os
17
- import sys
18
- import time
19
- from urllib.parse import urlparse
20
-
21
- import httpx
22
- import trafilatura
23
- from fastapi import FastAPI, Header, HTTPException, Request
24
- from fastapi.middleware.cors import CORSMiddleware
25
- from fastapi.responses import JSONResponse
26
- from pydantic import BaseModel, Field
27
-
28
- # ─────────────────────────── Logging ────────────────────────────
29
- logging.basicConfig(
30
- level=logging.INFO,
31
- format="%(asctime)s | %(levelname)-7s | %(message)s",
32
- datefmt="%H:%M:%S",
33
- stream=sys.stdout,
34
- )
35
- log = logging.getLogger("swift-scraper")
36
-
37
- # ─────────────────────────── App ────────────────────────────────
38
- app = FastAPI(
39
- title="Swift Scraper API",
40
- version="2.0.0",
41
- docs_url="/docs",
42
- redoc_url=None,
43
- )
44
-
45
- app.add_middleware(
46
- CORSMiddleware,
47
- allow_origins=["*"],
48
- allow_methods=["*"],
49
- allow_headers=["*"],
50
- )
51
-
52
- # ═══════════════════════════════════════════════════════════════
53
- # CONSTANTS & TUNABLES
54
- # ═══════════════════════════════════════════════════════════════
55
-
56
- # Our private SearxNG instance on HF Spaces (no rate limits!)
57
- SEARXNG_URL: str = os.environ.get(
58
- "SEARXNG_URL",
59
- "https://sandeepmudhiraj-private-searxng.hf.space",
60
- )
61
-
62
- MAX_URLS: int = 50 # hard cap β€” protects Cerebras context window
63
- SCRAPE_SEMAPHORE_LIMIT: int = 12 # max concurrent outbound scrape connections
64
- SCRAPE_TIMEOUT_SEC: float = 6.0 # per-URL hard timeout
65
- MAX_CONTEXT_CHARS: int = 80_000 # hard-slice before LLM call
66
- CEREBRAS_API_URL: str = "https://api.cerebras.ai/v1/chat/completions"
67
-
68
- # LLM fallback cascade
69
- CEREBRAS_MODEL_CASCADE: list[str] = [
70
- "gpt-oss-120b", # Priority 1 β€” reasoning model (120B)
71
- "llama3.1-8b", # Priority 2 β€” lightweight fallback (8B)
72
- ]
73
-
74
- _UA = "Mozilla/5.0 (compatible; SwiftScraperBot/2.0)"
75
- _HEADERS = {"User-Agent": _UA}
76
-
77
-
78
- # ─────────────────── Pydantic Models ───────────────────────────
79
- class SearchRequest(BaseModel):
80
- query: str = Field(..., min_length=1, max_length=1000)
81
-
82
-
83
- class SearchResponse(BaseModel):
84
- query: str
85
- sources_found: int
86
- sources_scraped: int
87
- answer: str
88
- model_used: str
89
- citations: list[str]
90
- elapsed_seconds: float
91
-
92
-
93
- # ═══════════════════════════════════════════════════════════════
94
- # PHASE 1 β€” META-SEARCH (Private SearxNG)
95
- # ═══════════════════════════════════════════════════════════════
96
-
97
- async def meta_search(query: str) -> list[str]:
98
- """Query our private SearxNG instance. Returns up to MAX_URLS unique URLs."""
99
- seen: set[str] = set()
100
- unique_urls: list[str] = []
101
-
102
- params = {
103
- "q": query,
104
- "format": "json",
105
- "categories": "general",
106
- "language": "en",
107
- "pageno": 1,
108
- }
109
-
110
- async with httpx.AsyncClient(follow_redirects=True) as client:
111
- try:
112
- resp = await client.get(
113
- f"{SEARXNG_URL.rstrip('/')}/search",
114
- params=params,
115
- headers=_HEADERS,
116
- timeout=15.0,
117
- )
118
- resp.raise_for_status()
119
- data = resp.json()
120
- for result in data.get("results", []):
121
- url = result.get("url", "").strip()
122
- if url and url.startswith("http"):
123
- parsed = urlparse(url)
124
- key = f"{parsed.netloc}{parsed.path}".lower().rstrip("/")
125
- if key not in seen:
126
- seen.add(key)
127
- unique_urls.append(url)
128
- if len(unique_urls) >= MAX_URLS:
129
- break
130
- except Exception as exc:
131
- log.error("SearxNG query failed: %s", exc)
132
-
133
- log.info("Meta-search returned %d unique URLs for: %s", len(unique_urls), query[:80])
134
- return unique_urls
135
-
136
-
137
- # ═══════════════════════════════════════════════════════════════
138
- # PHASE 2 β€” CONCURRENT SCRAPING (OOM-Safe)
139
- # ═══════════════════════════════════════════════════════════════
140
-
141
- _scrape_semaphore: asyncio.Semaphore | None = None
142
-
143
-
144
- def _get_semaphore() -> asyncio.Semaphore:
145
- global _scrape_semaphore
146
- if _scrape_semaphore is None:
147
- _scrape_semaphore = asyncio.Semaphore(SCRAPE_SEMAPHORE_LIMIT)
148
- return _scrape_semaphore
149
-
150
-
151
- def _extract_text_sync(html: str, url: str) -> str:
152
- try:
153
- text = trafilatura.extract(
154
- html,
155
- include_comments=False,
156
- include_tables=False,
157
- no_fallback=True,
158
- url=url,
159
- )
160
- return text or ""
161
- except Exception:
162
- return ""
163
-
164
-
165
- async def _scrape_single_url(client: httpx.AsyncClient, url: str) -> tuple[str, str]:
166
- sem = _get_semaphore()
167
- async with sem:
168
- try:
169
- resp = await client.get(
170
- url,
171
- headers=_HEADERS,
172
- timeout=SCRAPE_TIMEOUT_SEC,
173
- follow_redirects=True,
174
- )
175
- if resp.status_code != 200:
176
- return url, ""
177
- content_type = resp.headers.get("content-type", "")
178
- if "text/html" not in content_type and "text/plain" not in content_type:
179
- return url, ""
180
- html = resp.text
181
- text = await asyncio.to_thread(_extract_text_sync, html, url)
182
- return url, text
183
- except Exception:
184
- return url, ""
185
-
186
-
187
- async def scrape_urls(urls: list[str]) -> list[tuple[str, str]]:
188
- results: list[tuple[str, str]] = []
189
- async with httpx.AsyncClient(
190
- follow_redirects=True,
191
- limits=httpx.Limits(max_connections=SCRAPE_SEMAPHORE_LIMIT, max_keepalive_connections=5),
192
- ) as client:
193
- tasks = [_scrape_single_url(client, url) for url in urls]
194
- raw = await asyncio.gather(*tasks, return_exceptions=True)
195
- for item in raw:
196
- if isinstance(item, BaseException):
197
- results.append(("", ""))
198
- else:
199
- results.append(item)
200
-
201
- # ── MANDATORY MEMORY CLEANUP ──
202
- del tasks, raw
203
- gc.collect()
204
-
205
- return results
206
-
207
-
208
- # ═══════════════════════════════════════════════════════════════
209
- # PHASE 3 β€” LLM SYNTHESIS (Cerebras Cascade)
210
- # ═══════════════════════════════════════════════════════════════
211
-
212
- def _build_context_block(scraped: list[tuple[str, str]]) -> tuple[str, list[str]]:
213
- parts: list[str] = []
214
- citations: list[str] = []
215
- char_count = 0
216
-
217
- for idx, (url, text) in enumerate(scraped, 1):
218
- if not text or len(text.strip()) < 50:
219
- continue
220
- snippet = text.strip()
221
- marker = f"\n\n--- Source [{idx}]: {url} ---\n{snippet}"
222
- if char_count + len(marker) > MAX_CONTEXT_CHARS:
223
- remaining = MAX_CONTEXT_CHARS - char_count
224
- if remaining > 200:
225
- parts.append(marker[:remaining])
226
- citations.append(url)
227
- break
228
- parts.append(marker)
229
- citations.append(url)
230
- char_count += len(marker)
231
-
232
- context = "".join(parts)
233
- del parts
234
- gc.collect()
235
- return context, citations
236
-
237
-
238
- def _build_system_prompt() -> str:
239
- return (
240
- "You are an advanced research assistant. "
241
- "Using ONLY the provided source context below, write a comprehensive, "
242
- "highly detailed, and well-structured answer to the user's query. "
243
- "Include inline citations in the format [Source N](url) where possible. "
244
- "If the context is insufficient, state what is known and what could not be verified. "
245
- "Do NOT fabricate information beyond what the sources provide."
246
- )
247
-
248
-
249
- async def _try_cerebras_model(model: str, query: str, context: str, api_key: str) -> str:
250
- payload = {
251
- "model": model,
252
- "messages": [
253
- {"role": "system", "content": _build_system_prompt()},
254
- {
255
- "role": "user",
256
- "content": (
257
- f"## Query\n{query}\n\n"
258
- f"## Source Context\n{context}\n\n"
259
- "Now write your comprehensive answer with inline citations."
260
- ),
261
- },
262
- ],
263
- "temperature": 0.3,
264
- "max_tokens": 4096,
265
- "stream": False,
266
- }
267
- headers = {
268
- "Content-Type": "application/json",
269
- "Authorization": f"Bearer {api_key}",
270
- }
271
-
272
- async with httpx.AsyncClient() as client:
273
- try:
274
- resp = await client.post(CEREBRAS_API_URL, json=payload, headers=headers, timeout=30.0)
275
- if resp.status_code == 401:
276
- raise HTTPException(status_code=401, detail="Invalid Cerebras API key.")
277
- if resp.status_code == 429:
278
- raise HTTPException(status_code=429, detail="Cerebras rate limit hit.")
279
- resp.raise_for_status()
280
- data = resp.json()
281
- msg = data.get("choices", [{}])[0].get("message", {})
282
- answer = (msg.get("content", "") or msg.get("reasoning", "") or "").strip()
283
- if not answer:
284
- raise ValueError(f"Model {model} returned empty response")
285
- return answer
286
- except HTTPException:
287
- raise
288
- except Exception as exc:
289
- log.warning("Model '%s' failed: %s", model, exc)
290
- raise
291
- finally:
292
- del payload
293
- gc.collect()
294
-
295
-
296
- async def synthesize_with_cerebras(
297
- query: str, context: str, citations: list[str], api_key: str
298
- ) -> tuple[str, str]:
299
- if not context.strip():
300
- return (
301
- "I was unable to extract meaningful content from the search results. "
302
- "Please try rephrasing your query or try again later.",
303
- "none",
304
- )
305
-
306
- last_error: Exception | None = None
307
- for model in CEREBRAS_MODEL_CASCADE:
308
- try:
309
- log.info("Trying model: %s", model)
310
- answer = await _try_cerebras_model(model, query, context, api_key)
311
- log.info("Model '%s' succeeded", model)
312
- return answer, model
313
- except HTTPException:
314
- raise
315
- except Exception as exc:
316
- last_error = exc
317
- log.warning("Model '%s' failed, trying next...", model)
318
- continue
319
-
320
- raise HTTPException(status_code=502, detail=f"All models failed. Last: {last_error}")
321
-
322
-
323
- # ═══════════════════════════════════════════════════════════════
324
- # ENDPOINTS
325
- # ═══════════════════════════════════════════════════════════════
326
-
327
- @app.get("/")
328
- async def root():
329
- """Root endpoint for UptimeRobot pings."""
330
- return {"status": "ok", "service": "Swift Scraper API"}
331
-
332
-
333
- @app.get("/health")
334
- async def health():
335
- return {"status": "ok"}
336
-
337
-
338
- @app.post("/search", response_model=SearchResponse)
339
- async def search(body: SearchRequest, x_api_key: str = Header(..., alias="x-api-key")):
340
- t0 = time.perf_counter()
341
- query = body.query.strip()
342
- log.info("━━━ NEW SEARCH ━━━ query=%s", query[:100])
343
-
344
- # Phase 1: Meta-Search
345
- urls = await meta_search(query)
346
- if not urls:
347
- raise HTTPException(status_code=404, detail="No search results found.")
348
- sources_found = len(urls)
349
-
350
- # Phase 2: Scrape
351
- scraped = await scrape_urls(urls)
352
- sources_scraped = sum(1 for _, text in scraped if text and len(text.strip()) >= 50)
353
- log.info("Scraped %d / %d URLs", sources_scraped, sources_found)
354
- del urls
355
- gc.collect()
356
-
357
- # Phase 3: Synthesize
358
- context, citations = _build_context_block(scraped)
359
- del scraped
360
- gc.collect()
361
-
362
- answer, model_used = await synthesize_with_cerebras(query, context, citations, x_api_key)
363
- del context
364
- gc.collect()
365
-
366
- elapsed = round(time.perf_counter() - t0, 2)
367
- log.info("━━━ DONE ━━━ model=%s elapsed=%.2fs sources=%d/%d",
368
- model_used, elapsed, sources_scraped, sources_found)
369
-
370
- return SearchResponse(
371
- query=query,
372
- sources_found=sources_found,
373
- sources_scraped=sources_scraped,
374
- answer=answer,
375
- model_used=model_used,
376
- citations=citations,
377
- elapsed_seconds=elapsed,
378
- )
379
-
380
-
381
- @app.exception_handler(Exception)
382
- async def _global_exc_handler(request: Request, exc: Exception):
383
- log.exception("Unhandled error: %s", exc)
384
- gc.collect()
385
- return JSONResponse(status_code=500, content={"detail": "Internal server error."})
386
-
387
-
388
- # ─────────────────── Entrypoint ─────────────────────────────────
389
- if __name__ == "__main__":
390
- import uvicorn
391
-
392
- port = int(os.environ.get("PORT", 7860))
393
- log.info("Starting Swift Scraper API on port %d", port)
394
- log.info("SearxNG: %s", SEARXNG_URL)
395
- log.info("LLM cascade: %s", " β†’ ".join(CEREBRAS_MODEL_CASCADE))
396
- uvicorn.run(
397
- "app:app",
398
- host="0.0.0.0",
399
- port=port,
400
- workers=1,
401
- log_level="info",
402
- access_log=False,
403
- )
 
1
+ """
2
+ Swift Scraper API β€” Hugging Face Spaces Edition
3
+ =================================================
4
+ FastAPI: Private SearxNG meta-search β†’ concurrent scraping (trafilatura +
5
+ semaphore + asyncio.to_thread) β†’ Cerebras LLM cascade (gpt-oss-120b β†’ llama3.1-8b).
6
+
7
+ Deployed on: HF Spaces (cpu-basic, 16GB RAM)
8
+ Port: 7860 (HF requirement)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import gc
15
+ import logging
16
+ import os
17
+ import sys
18
+ import time
19
+ from urllib.parse import urlparse
20
+
21
+ import httpx
22
+ import trafilatura
23
+ from fastapi import FastAPI, Header, HTTPException, Request
24
+ from fastapi.middleware.cors import CORSMiddleware
25
+ from fastapi.responses import JSONResponse
26
+ from pydantic import BaseModel, Field
27
+
28
+ # ─────────────────────────── Logging ────────────────────────────
29
+ logging.basicConfig(
30
+ level=logging.INFO,
31
+ format="%(asctime)s | %(levelname)-7s | %(message)s",
32
+ datefmt="%H:%M:%S",
33
+ stream=sys.stdout,
34
+ )
35
+ log = logging.getLogger("swift-scraper")
36
+
37
+ # ─────────────────────────── App ────────────────────────────────
38
+ app = FastAPI(
39
+ title="Swift Scraper API",
40
+ version="2.0.0",
41
+ docs_url="/docs",
42
+ redoc_url=None,
43
+ )
44
+
45
+ app.add_middleware(
46
+ CORSMiddleware,
47
+ allow_origins=["*"],
48
+ allow_methods=["*"],
49
+ allow_headers=["*"],
50
+ )
51
+
52
+ # ═══════════════════════════════════════════════════════════════
53
+ # CONSTANTS & TUNABLES
54
+ # ═══════════════════════════════════════════════════════════════
55
+
56
+ # Our private SearxNG instance on HF Spaces (no rate limits!)
57
+ SEARXNG_URL: str = os.environ.get(
58
+ "SEARXNG_URL",
59
+ "https://sandeepmudhiraj-private-searxng.hf.space",
60
+ )
61
+
62
+ MAX_URLS: int = 50 # hard cap β€” protects Cerebras context window
63
+ SCRAPE_SEMAPHORE_LIMIT: int = 12 # max concurrent outbound scrape connections
64
+ SCRAPE_TIMEOUT_SEC: float = 6.0 # per-URL hard timeout
65
+ MAX_CONTEXT_CHARS: int = 80_000 # hard-slice before LLM call
66
+ CEREBRAS_API_URL: str = "https://api.cerebras.ai/v1/chat/completions"
67
+
68
+ # LLM fallback cascade
69
+ CEREBRAS_MODEL_CASCADE: list[str] = [
70
+ "gpt-oss-120b", # Priority 1 β€” reasoning model (120B)
71
+ "llama3.1-8b", # Priority 2 β€” lightweight fallback (8B)
72
+ ]
73
+
74
+ _UA = "Mozilla/5.0 (compatible; SwiftScraperBot/2.0)"
75
+ _HEADERS = {"User-Agent": _UA}
76
+
77
+
78
+ # ─────────────────── Pydantic Models ───────────────────────────
79
+ class SearchRequest(BaseModel):
80
+ query: str = Field(..., min_length=1, max_length=1000)
81
+
82
+
83
+ class SearchResponse(BaseModel):
84
+ query: str
85
+ sources_found: int
86
+ sources_scraped: int
87
+ answer: str
88
+ model_used: str
89
+ citations: list[str]
90
+ elapsed_seconds: float
91
+
92
+
93
+ # ═══════════════════════════════════════════════════════════════
94
+ # PHASE 1 β€” META-SEARCH (Private SearxNG)
95
+ # ═══════════════════════════════════════════════════════════════
96
+
97
+ async def meta_search(query: str) -> list[str]:
98
+ """Query our private SearxNG instance. Returns up to MAX_URLS unique URLs."""
99
+ seen: set[str] = set()
100
+ unique_urls: list[str] = []
101
+
102
+ params = {
103
+ "q": query,
104
+ "format": "json",
105
+ "categories": "general",
106
+ "language": "en",
107
+ "pageno": 1,
108
+ }
109
+
110
+ async with httpx.AsyncClient(follow_redirects=True) as client:
111
+ try:
112
+ resp = await client.get(
113
+ f"{SEARXNG_URL.rstrip('/')}/search",
114
+ params=params,
115
+ headers=_HEADERS,
116
+ timeout=15.0,
117
+ )
118
+ resp.raise_for_status()
119
+ data = resp.json()
120
+ for result in data.get("results", []):
121
+ url = result.get("url", "").strip()
122
+ if url and url.startswith("http"):
123
+ parsed = urlparse(url)
124
+ key = f"{parsed.netloc}{parsed.path}".lower().rstrip("/")
125
+ if key not in seen:
126
+ seen.add(key)
127
+ unique_urls.append(url)
128
+ if len(unique_urls) >= MAX_URLS:
129
+ break
130
+ except Exception as exc:
131
+ log.error("SearxNG query failed: %s", exc)
132
+
133
+ log.info("Meta-search returned %d unique URLs for: %s", len(unique_urls), query[:80])
134
+ return unique_urls
135
+
136
+
137
+ # ═══════════════════════════════════════════════════════════════
138
+ # PHASE 2 β€” CONCURRENT SCRAPING (OOM-Safe)
139
+ # ═══════════════════════════════════════════════════════════════
140
+
141
+ _scrape_semaphore: asyncio.Semaphore | None = None
142
+
143
+
144
+ def _get_semaphore() -> asyncio.Semaphore:
145
+ global _scrape_semaphore
146
+ if _scrape_semaphore is None:
147
+ _scrape_semaphore = asyncio.Semaphore(SCRAPE_SEMAPHORE_LIMIT)
148
+ return _scrape_semaphore
149
+
150
+
151
+ def _extract_text_sync(html: str, url: str) -> str:
152
+ try:
153
+ text = trafilatura.extract(
154
+ html,
155
+ include_comments=False,
156
+ include_tables=False,
157
+ no_fallback=True,
158
+ url=url,
159
+ )
160
+ return text or ""
161
+ except Exception:
162
+ return ""
163
+
164
+
165
+ async def _scrape_single_url(client: httpx.AsyncClient, url: str) -> tuple[str, str]:
166
+ sem = _get_semaphore()
167
+ async with sem:
168
+ try:
169
+ resp = await client.get(
170
+ url,
171
+ headers=_HEADERS,
172
+ timeout=SCRAPE_TIMEOUT_SEC,
173
+ follow_redirects=True,
174
+ )
175
+ if resp.status_code != 200:
176
+ return url, ""
177
+ content_type = resp.headers.get("content-type", "")
178
+ if "text/html" not in content_type and "text/plain" not in content_type:
179
+ return url, ""
180
+ html = resp.text
181
+ text = await asyncio.to_thread(_extract_text_sync, html, url)
182
+ return url, text
183
+ except Exception:
184
+ return url, ""
185
+
186
+
187
+ async def scrape_urls(urls: list[str]) -> list[tuple[str, str]]:
188
+ results: list[tuple[str, str]] = []
189
+ async with httpx.AsyncClient(
190
+ follow_redirects=True,
191
+ limits=httpx.Limits(max_connections=SCRAPE_SEMAPHORE_LIMIT, max_keepalive_connections=5),
192
+ ) as client:
193
+ tasks = [_scrape_single_url(client, url) for url in urls]
194
+ raw = await asyncio.gather(*tasks, return_exceptions=True)
195
+ for item in raw:
196
+ if isinstance(item, BaseException):
197
+ results.append(("", ""))
198
+ else:
199
+ results.append(item)
200
+
201
+ # ── MANDATORY MEMORY CLEANUP ──
202
+ del tasks, raw
203
+ gc.collect()
204
+
205
+ return results
206
+
207
+
208
+ # ═══════════════════════════════════════════════════════════════
209
+ # PHASE 3 β€” LLM SYNTHESIS (Cerebras Cascade)
210
+ # ═══════════════════════════════════════════════════════════════
211
+
212
+ def _build_context_block(scraped: list[tuple[str, str]]) -> tuple[str, list[str]]:
213
+ parts: list[str] = []
214
+ citations: list[str] = []
215
+ char_count = 0
216
+
217
+ for idx, (url, text) in enumerate(scraped, 1):
218
+ if not text or len(text.strip()) < 50:
219
+ continue
220
+ snippet = text.strip()
221
+ marker = f"\n\n--- Source [{idx}]: {url} ---\n{snippet}"
222
+ if char_count + len(marker) > MAX_CONTEXT_CHARS:
223
+ remaining = MAX_CONTEXT_CHARS - char_count
224
+ if remaining > 200:
225
+ parts.append(marker[:remaining])
226
+ citations.append(url)
227
+ break
228
+ parts.append(marker)
229
+ citations.append(url)
230
+ char_count += len(marker)
231
+
232
+ context = "".join(parts)
233
+ del parts
234
+ gc.collect()
235
+ return context, citations
236
+
237
+
238
+ def _build_system_prompt() -> str:
239
+ return (
240
+ "You are an advanced research assistant. "
241
+ "Using ONLY the provided source context below, write a comprehensive, "
242
+ "highly detailed, and well-structured answer to the user's query. "
243
+ "Include inline citations in the format [Source N](url) where possible. "
244
+ "If the context is insufficient, state what is known and what could not be verified. "
245
+ "Do NOT fabricate information beyond what the sources provide."
246
+ )
247
+
248
+
249
+ async def _try_cerebras_model(model: str, query: str, context: str, api_key: str) -> str:
250
+ payload = {
251
+ "model": model,
252
+ "messages": [
253
+ {"role": "system", "content": _build_system_prompt()},
254
+ {
255
+ "role": "user",
256
+ "content": (
257
+ f"## Query\n{query}\n\n"
258
+ f"## Source Context\n{context}\n\n"
259
+ "Now write your comprehensive answer with inline citations."
260
+ ),
261
+ },
262
+ ],
263
+ "temperature": 0.3,
264
+ "max_tokens": 4096,
265
+ "stream": False,
266
+ }
267
+ headers = {
268
+ "Content-Type": "application/json",
269
+ "Authorization": f"Bearer {api_key}",
270
+ }
271
+
272
+ async with httpx.AsyncClient() as client:
273
+ try:
274
+ resp = await client.post(CEREBRAS_API_URL, json=payload, headers=headers, timeout=30.0)
275
+ if resp.status_code == 401:
276
+ raise HTTPException(status_code=401, detail="Invalid Cerebras API key.")
277
+ if resp.status_code == 429:
278
+ raise HTTPException(status_code=429, detail="Cerebras rate limit hit.")
279
+ resp.raise_for_status()
280
+ data = resp.json()
281
+ msg = data.get("choices", [{}])[0].get("message", {})
282
+ answer = (msg.get("content", "") or msg.get("reasoning", "") or "").strip()
283
+ if not answer:
284
+ raise ValueError(f"Model {model} returned empty response")
285
+ return answer
286
+ except HTTPException:
287
+ raise
288
+ except Exception as exc:
289
+ log.warning("Model '%s' failed: %s", model, exc)
290
+ raise
291
+ finally:
292
+ del payload
293
+ gc.collect()
294
+
295
+
296
+ async def synthesize_with_cerebras(
297
+ query: str, context: str, citations: list[str], api_key: str
298
+ ) -> tuple[str, str]:
299
+ if not context.strip():
300
+ return (
301
+ "I was unable to extract meaningful content from the search results. "
302
+ "Please try rephrasing your query or try again later.",
303
+ "none",
304
+ )
305
+
306
+ last_error: Exception | None = None
307
+ for model in CEREBRAS_MODEL_CASCADE:
308
+ try:
309
+ log.info("Trying model: %s", model)
310
+ answer = await _try_cerebras_model(model, query, context, api_key)
311
+ log.info("Model '%s' succeeded", model)
312
+ return answer, model
313
+ except HTTPException:
314
+ raise
315
+ except Exception as exc:
316
+ last_error = exc
317
+ log.warning("Model '%s' failed, trying next...", model)
318
+ continue
319
+
320
+ raise HTTPException(status_code=502, detail=f"All models failed. Last: {last_error}")
321
+
322
+
323
+ # ═══════════════════════════════════════════════════════════════
324
+ # ENDPOINTS
325
+ # ═══════════════════════════════════════════════════════════════
326
+
327
+ @app.api_route("/", methods=["GET", "HEAD"])
328
+ async def root():
329
+ """Root endpoint for UptimeRobot pings."""
330
+ return {"status": "ok", "service": "Swift Scraper API"}
331
+
332
+
333
+ @app.api_route("/health", methods=["GET", "HEAD"])
334
+ async def health():
335
+ return {"status": "ok"}
336
+
337
+
338
+ @app.post("/search", response_model=SearchResponse)
339
+ async def search(body: SearchRequest, x_api_key: str = Header(..., alias="x-api-key")):
340
+ t0 = time.perf_counter()
341
+ query = body.query.strip()
342
+ log.info("━━━ NEW SEARCH ━━━ query=%s", query[:100])
343
+
344
+ # Phase 1: Meta-Search
345
+ urls = await meta_search(query)
346
+ if not urls:
347
+ raise HTTPException(status_code=404, detail="No search results found.")
348
+ sources_found = len(urls)
349
+
350
+ # Phase 2: Scrape
351
+ scraped = await scrape_urls(urls)
352
+ sources_scraped = sum(1 for _, text in scraped if text and len(text.strip()) >= 50)
353
+ log.info("Scraped %d / %d URLs", sources_scraped, sources_found)
354
+ del urls
355
+ gc.collect()
356
+
357
+ # Phase 3: Synthesize
358
+ context, citations = _build_context_block(scraped)
359
+ del scraped
360
+ gc.collect()
361
+
362
+ answer, model_used = await synthesize_with_cerebras(query, context, citations, x_api_key)
363
+ del context
364
+ gc.collect()
365
+
366
+ elapsed = round(time.perf_counter() - t0, 2)
367
+ log.info("━━━ DONE ━━━ model=%s elapsed=%.2fs sources=%d/%d",
368
+ model_used, elapsed, sources_scraped, sources_found)
369
+
370
+ return SearchResponse(
371
+ query=query,
372
+ sources_found=sources_found,
373
+ sources_scraped=sources_scraped,
374
+ answer=answer,
375
+ model_used=model_used,
376
+ citations=citations,
377
+ elapsed_seconds=elapsed,
378
+ )
379
+
380
+
381
+ @app.exception_handler(Exception)
382
+ async def _global_exc_handler(request: Request, exc: Exception):
383
+ log.exception("Unhandled error: %s", exc)
384
+ gc.collect()
385
+ return JSONResponse(status_code=500, content={"detail": "Internal server error."})
386
+
387
+
388
+ # ─────────────────── Entrypoint ─────────────────────────────────
389
+ if __name__ == "__main__":
390
+ import uvicorn
391
+
392
+ port = int(os.environ.get("PORT", 7860))
393
+ log.info("Starting Swift Scraper API on port %d", port)
394
+ log.info("SearxNG: %s", SEARXNG_URL)
395
+ log.info("LLM cascade: %s", " β†’ ".join(CEREBRAS_MODEL_CASCADE))
396
+ uvicorn.run(
397
+ "app:app",
398
+ host="0.0.0.0",
399
+ port=port,
400
+ workers=1,
401
+ log_level="info",
402
+ access_log=False,
403
+ )