Nebulixlabs commited on
Commit
bf17016
·
verified ·
1 Parent(s): 61f9576

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +62 -67
app.py CHANGED
@@ -23,7 +23,7 @@ import aiohttp
23
  from bs4 import BeautifulSoup
24
  import torch
25
 
26
- # Hugging Face imports for Q4 Quantization
27
  from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline, BitsAndBytesConfig
28
 
29
  # --- CONFIGURATION & MULTIPROCESSING ---
@@ -37,9 +37,9 @@ ai_semaphore = asyncio.Semaphore(MAX_CONCURRENT_AI)
37
  ai_generator = None
38
  HF_TOKEN = os.getenv("HF_TOKEN")
39
 
40
- # Cache Structures
41
  AI_RESPONSE_CACHE = {}
42
- MAX_AI_CACHE_SIZE = 5000
43
 
44
  CONTENT_CACHE = {}
45
  MAX_CONTENT_CONCURRENCY = 10
@@ -113,31 +113,27 @@ def detect_query_intent(query_tokens: List[str]) -> Dict[str, Any]:
113
 
114
  # --- 3. DEDICATED WEB CONTENT EXTRACTION ---
115
  async def extract_page_content(url: str) -> Dict[str, Any]:
116
- """Downloads webpage safely, extracts pure text, and caches it."""
117
  if url in CONTENT_CACHE:
118
  return CONTENT_CACHE[url]
119
 
120
  async with content_semaphore:
121
  try:
122
  async with aiohttp.ClientSession() as session:
123
- async with session.get(url, timeout=4.0, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) NebulixBot"}) as resp:
124
  if resp.status != 200:
125
  return {"url": url, "title": "", "content": "", "success": False, "error": f"HTTP {resp.status}"}
126
 
127
  html = await resp.text()
128
  soup = BeautifulSoup(html, "html.parser")
129
 
130
- # Clean irrelevant elements
131
  for tag in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'noscript', 'form', 'button', 'iframe']):
132
  tag.decompose()
133
 
134
  blocks = []
135
  for tag in soup.find_all(['h1', 'h2', 'h3', 'p', 'article', 'main', 'li']):
136
  text = tag.get_text(separator=' ', strip=True)
137
- if len(text) > 30: # Skip very tiny menu stubs missed by decompose
138
- blocks.append(text)
139
 
140
- # Deduplicate contiguous identical text
141
  clean_blocks = []
142
  seen_blocks = set()
143
  for b in blocks:
@@ -148,22 +144,13 @@ async def extract_page_content(url: str) -> Dict[str, Any]:
148
  content_str = "\n\n".join(clean_blocks)
149
  page_title = soup.title.string.strip() if soup.title and soup.title.string else ""
150
 
151
- result = {
152
- "url": url,
153
- "title": page_title,
154
- "content": content_str,
155
- "success": True,
156
- "timestamp": time.time()
157
- }
158
  CONTENT_CACHE[url] = result
159
  return result
160
- except asyncio.TimeoutError:
161
- return {"url": url, "title": "", "content": "", "success": False, "error": "Timeout"}
162
  except Exception as e:
163
  return {"url": url, "title": "", "content": "", "success": False, "error": str(e)}
164
 
165
  def rank_chunks_for_rag(text: str, query_tokens: List[str]) -> List[str]:
166
- """Deterministically extracts the most relevant chunks from an article for context."""
167
  if not text: return []
168
  chunks = text.split("\n\n")
169
  q_set = set(query_tokens)
@@ -172,14 +159,11 @@ def rank_chunks_for_rag(text: str, query_tokens: List[str]) -> List[str]:
172
  for c in chunks:
173
  c_toks = set(tokenize(c))
174
  score = len(q_set.intersection(c_toks))
175
- if score > 0:
176
- scored_chunks.append((score, c))
177
 
178
- if not scored_chunks and chunks:
179
- scored_chunks = [(0, c) for c in chunks[:3]] # Fallback to top paragraphs
180
-
181
  scored_chunks.sort(reverse=True, key=lambda x: x[0])
182
- return [c for score, c in scored_chunks[:3]] # Max 3 context chunks per source
183
 
184
 
185
  # --- 4. CORE DETERMINISTIC SEARCH INDEX ---
@@ -290,8 +274,7 @@ class HybridSearchIndex:
290
  scores["proximity"] = 1.0 / (1.0 + (max(positions) - min(positions)))
291
 
292
  year_match = re.search(r'\b(202[0-6])\b', url + " " + desc)
293
- if year_match:
294
- scores["freshness"] = math.exp(-(datetime.now().year - int(year_match.group(1))) / 2.0)
295
  else: scores["freshness"] = 0.3
296
 
297
  q_score = 0.0
@@ -367,12 +350,9 @@ class HybridSearchIndex:
367
  diversified_results.append(doc_info)
368
  if len(diversified_results) >= STRICT_RESULT_LIMIT: break # STRICT LIMIT
369
 
370
- # Assign final rank
371
  for idx, item in enumerate(diversified_results): item["rank"] = idx + 1
372
-
373
  return diversified_results, intent_info
374
 
375
- # --- INITIALIZATION ---
376
  search_index = HybridSearchIndex()
377
 
378
  def load_and_index_dataset():
@@ -403,12 +383,12 @@ def load_and_index_dataset():
403
  except: pass
404
  search_index.build(raw_data)
405
 
406
- # --- QWEN AI GENERATION MODULE (FOR RAG ONLY) ---
407
  def load_ai_models():
408
  global ai_generator
409
- print("🤖 Loading Qwen/Qwen2.5-0.5B-Instruct...")
410
  try:
411
- model_id = "Qwen/Qwen2.5-0.5B-Instruct"
412
  tokenizer = AutoTokenizer.from_pretrained(model_id)
413
 
414
  if torch.cuda.is_available():
@@ -418,13 +398,16 @@ def load_ai_models():
418
  model = AutoModelForCausalLM.from_pretrained(model_id, device_map="cpu", torch_dtype=torch.float32)
419
 
420
  ai_generator = pipeline("text-generation", model=model, tokenizer=tokenizer)
421
- print("✅ Qwen AI Ready!")
422
  except Exception as e:
423
  print(f"❌ Model Loading Failed: {e}")
424
 
425
- async def run_ai_task(messages, max_tokens: int, query_key: str, skip_cache: bool = False):
426
  if not ai_generator: return "⚠️ AI Model is starting up."
427
- if not skip_cache and query_key in AI_RESPONSE_CACHE: return AI_RESPONSE_CACHE[query_key]
 
 
 
428
 
429
  async with ai_semaphore:
430
  def _gen():
@@ -436,9 +419,11 @@ async def run_ai_task(messages, max_tokens: int, query_key: str, skip_cache: boo
436
  return result[0]['generated_text'].strip()
437
 
438
  response = await asyncio.to_thread(_gen)
439
- if not skip_cache:
440
- if len(AI_RESPONSE_CACHE) > MAX_AI_CACHE_SIZE: AI_RESPONSE_CACHE.clear()
441
- AI_RESPONSE_CACHE[query_key] = response
 
 
442
  return response
443
 
444
  # --- FASTAPI APP ENDPOINTS ---
@@ -459,7 +444,6 @@ async def get_suggestions(q: str = Query(..., min_length=1)):
459
 
460
  @app.get("/content")
461
  async def get_content_endpoint(url: str = Query(...)):
462
- """Dedicated endpoint to just extract webpage content."""
463
  res = await extract_page_content(url)
464
  return res
465
 
@@ -467,8 +451,6 @@ async def get_content_endpoint(url: str = Query(...)):
467
  async def pure_search(q: str = Query(...), content: bool = Query(False), debug: bool = Query(False)):
468
  req_id = str(uuid.uuid4())
469
  results, intent_info = await asyncio.to_thread(search_index.search, q, debug)
470
-
471
- # Strictly limit to 20 results output
472
  top_20 = results[:STRICT_RESULT_LIMIT]
473
 
474
  if content and top_20:
@@ -479,52 +461,64 @@ async def pure_search(q: str = Query(...), content: bool = Query(False), debug:
479
 
480
  return {"request_id": req_id, "query": q, "results_count": len(top_20), "results": top_20}
481
 
482
- async def process_aimode(q: str, include_content: bool, debug: bool):
483
  req_id = str(uuid.uuid4())
 
484
 
485
- # 1. Deterministic Search (Max 20 internally outputted)
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  results, intent_info = await asyncio.to_thread(search_index.search, q, debug)
487
  top_20 = results[:STRICT_RESULT_LIMIT]
488
  query_tokens = tokenize(q)
489
 
490
- # Bypass AI cache if it's a News/Latest query
491
- is_news = "NEWS" in intent_info["signals"]
492
-
493
  rag_context_parts = []
494
  used_sources = []
495
 
496
  if top_20:
497
- # 2. Extract content ONLY for Top ranked sources
498
- tasks = [extract_page_content(r["url"]) for r in top_20]
499
  extracted_data = await asyncio.gather(*tasks)
500
 
501
- # 3. Deterministic Chunk Selection for Context
502
  valid_sources_count = 0
503
- for i, (r, ext) in enumerate(zip(top_20, extracted_data)):
504
- if include_content:
505
- r["content"] = ext.get("content", "") if ext.get("success") else ""
506
 
507
- if ext.get("success") and ext.get("content") and valid_sources_count < 5: # Build context from top 5 valid pages
508
  best_chunks = rank_chunks_for_rag(ext["content"], query_tokens)
509
  if best_chunks:
510
- rag_context_parts.append(f"[Source {valid_sources_count+1}]\nTitle: {r['title']}\nURL: {r['url']}\nRelevant Content: {' ... '.join(best_chunks)}")
511
  used_sources.append({"id": valid_sources_count+1, "title": r["title"], "url": r["url"]})
512
  valid_sources_count += 1
513
 
514
- rag_context = "\n\n".join(rag_context_parts)
515
- system_prompt = (
516
- "You are an intelligent search assistant. Answer the user's query DIRECTLY based ONLY on the provided Live Context Data. "
517
- "Do not invent facts. If the context contains the answer, summarize it clearly and cite the sources used (e.g., [Source 1]). "
518
- "If the context does not contain the answer, state that you do not have enough information."
519
- )
520
 
 
 
 
 
 
 
 
521
  messages = [
522
  {"role": "system", "content": system_prompt},
523
- {"role": "user", "content": f"Live Context Data:\n{rag_context}\n\nQuestion: {q}" if rag_context else f"Question: {q}"}
524
  ]
525
 
526
- # 4. Generate AI Answer (Only Step utilizing LLM)
527
- ai_reply = await run_ai_task(messages, max_tokens=150, query_key=f"ai_{q}", skip_cache=is_news)
528
 
529
  return {
530
  "request_id": req_id,
@@ -532,16 +526,17 @@ async def process_aimode(q: str, include_content: bool, debug: bool):
532
  "ai_response": ai_reply,
533
  "results_count": len(top_20),
534
  "results": top_20,
535
- "sources": used_sources
 
536
  }
537
 
538
  @app.get("/aimode")
539
  async def ai_mode_get(q: str = Query(...), content: bool = Query(False), debug: bool = Query(False)):
540
- return await process_aimode(q, content, debug)
541
 
542
  @app.post("/aimode")
543
  async def ai_mode_post(q: str = Form(...), content: bool = Form(False), debug: bool = Form(False)):
544
- return await process_aimode(q, content, debug)
545
 
546
  if __name__ == "__main__":
547
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
23
  from bs4 import BeautifulSoup
24
  import torch
25
 
26
+ # Hugging Face imports
27
  from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline, BitsAndBytesConfig
28
 
29
  # --- CONFIGURATION & MULTIPROCESSING ---
 
37
  ai_generator = None
38
  HF_TOKEN = os.getenv("HF_TOKEN")
39
 
40
+ # Strict Cache Structures (Guarantees no re-runs for identical queries)
41
  AI_RESPONSE_CACHE = {}
42
+ MAX_AI_CACHE_SIZE = 10000
43
 
44
  CONTENT_CACHE = {}
45
  MAX_CONTENT_CONCURRENCY = 10
 
113
 
114
  # --- 3. DEDICATED WEB CONTENT EXTRACTION ---
115
  async def extract_page_content(url: str) -> Dict[str, Any]:
 
116
  if url in CONTENT_CACHE:
117
  return CONTENT_CACHE[url]
118
 
119
  async with content_semaphore:
120
  try:
121
  async with aiohttp.ClientSession() as session:
122
+ async with session.get(url, timeout=3.0, headers={"User-Agent": "Mozilla/5.0 NebulixBot"}) as resp:
123
  if resp.status != 200:
124
  return {"url": url, "title": "", "content": "", "success": False, "error": f"HTTP {resp.status}"}
125
 
126
  html = await resp.text()
127
  soup = BeautifulSoup(html, "html.parser")
128
 
 
129
  for tag in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'noscript', 'form', 'button', 'iframe']):
130
  tag.decompose()
131
 
132
  blocks = []
133
  for tag in soup.find_all(['h1', 'h2', 'h3', 'p', 'article', 'main', 'li']):
134
  text = tag.get_text(separator=' ', strip=True)
135
+ if len(text) > 30: blocks.append(text)
 
136
 
 
137
  clean_blocks = []
138
  seen_blocks = set()
139
  for b in blocks:
 
144
  content_str = "\n\n".join(clean_blocks)
145
  page_title = soup.title.string.strip() if soup.title and soup.title.string else ""
146
 
147
+ result = {"url": url, "title": page_title, "content": content_str, "success": True}
 
 
 
 
 
 
148
  CONTENT_CACHE[url] = result
149
  return result
 
 
150
  except Exception as e:
151
  return {"url": url, "title": "", "content": "", "success": False, "error": str(e)}
152
 
153
  def rank_chunks_for_rag(text: str, query_tokens: List[str]) -> List[str]:
 
154
  if not text: return []
155
  chunks = text.split("\n\n")
156
  q_set = set(query_tokens)
 
159
  for c in chunks:
160
  c_toks = set(tokenize(c))
161
  score = len(q_set.intersection(c_toks))
162
+ if score > 0: scored_chunks.append((score, c))
 
163
 
164
+ if not scored_chunks and chunks: scored_chunks = [(0, c) for c in chunks[:3]]
 
 
165
  scored_chunks.sort(reverse=True, key=lambda x: x[0])
166
+ return [c for score, c in scored_chunks[:2]] # Strictly reduced for token limits
167
 
168
 
169
  # --- 4. CORE DETERMINISTIC SEARCH INDEX ---
 
274
  scores["proximity"] = 1.0 / (1.0 + (max(positions) - min(positions)))
275
 
276
  year_match = re.search(r'\b(202[0-6])\b', url + " " + desc)
277
+ if year_match: scores["freshness"] = math.exp(-(datetime.now().year - int(year_match.group(1))) / 2.0)
 
278
  else: scores["freshness"] = 0.3
279
 
280
  q_score = 0.0
 
350
  diversified_results.append(doc_info)
351
  if len(diversified_results) >= STRICT_RESULT_LIMIT: break # STRICT LIMIT
352
 
 
353
  for idx, item in enumerate(diversified_results): item["rank"] = idx + 1
 
354
  return diversified_results, intent_info
355
 
 
356
  search_index = HybridSearchIndex()
357
 
358
  def load_and_index_dataset():
 
383
  except: pass
384
  search_index.build(raw_data)
385
 
386
+ # --- AI GENERATION MODULE (SMOLLM 135M) ---
387
  def load_ai_models():
388
  global ai_generator
389
+ print("🤖 Loading HuggingFaceTB/SmolLM-135M-Instruct (Ultra Fast)...")
390
  try:
391
+ model_id = "HuggingFaceTB/SmolLM-135M-Instruct"
392
  tokenizer = AutoTokenizer.from_pretrained(model_id)
393
 
394
  if torch.cuda.is_available():
 
398
  model = AutoModelForCausalLM.from_pretrained(model_id, device_map="cpu", torch_dtype=torch.float32)
399
 
400
  ai_generator = pipeline("text-generation", model=model, tokenizer=tokenizer)
401
+ print("✅ SmolLM AI Ready!")
402
  except Exception as e:
403
  print(f"❌ Model Loading Failed: {e}")
404
 
405
+ async def run_ai_task(messages, max_tokens: int, query_key: str):
406
  if not ai_generator: return "⚠️ AI Model is starting up."
407
+
408
+ # EXACT CACHE MATCH - Guarantees User 1 and User 2 get identical cached response
409
+ if query_key in AI_RESPONSE_CACHE:
410
+ return AI_RESPONSE_CACHE[query_key]
411
 
412
  async with ai_semaphore:
413
  def _gen():
 
419
  return result[0]['generated_text'].strip()
420
 
421
  response = await asyncio.to_thread(_gen)
422
+
423
+ # Unconditionally cache to save computing power
424
+ if len(AI_RESPONSE_CACHE) > MAX_AI_CACHE_SIZE: AI_RESPONSE_CACHE.clear()
425
+ AI_RESPONSE_CACHE[query_key] = response
426
+
427
  return response
428
 
429
  # --- FASTAPI APP ENDPOINTS ---
 
444
 
445
  @app.get("/content")
446
  async def get_content_endpoint(url: str = Query(...)):
 
447
  res = await extract_page_content(url)
448
  return res
449
 
 
451
  async def pure_search(q: str = Query(...), content: bool = Query(False), debug: bool = Query(False)):
452
  req_id = str(uuid.uuid4())
453
  results, intent_info = await asyncio.to_thread(search_index.search, q, debug)
 
 
454
  top_20 = results[:STRICT_RESULT_LIMIT]
455
 
456
  if content and top_20:
 
461
 
462
  return {"request_id": req_id, "query": q, "results_count": len(top_20), "results": top_20}
463
 
464
+ async def process_aimode(q: str, include_content: bool, debug: bool, max_ai_tokens: int):
465
  req_id = str(uuid.uuid4())
466
+ cache_key = f"ai_{normalize_text(q)}_{max_ai_tokens}"
467
 
468
+ # 1. IMMEDIATE CACHE CHECK (0s latency if previously asked)
469
+ if cache_key in AI_RESPONSE_CACHE:
470
+ results, _ = await asyncio.to_thread(search_index.search, q, debug)
471
+ return {
472
+ "request_id": req_id,
473
+ "query": q,
474
+ "ai_response": AI_RESPONSE_CACHE[cache_key],
475
+ "results_count": len(results[:STRICT_RESULT_LIMIT]),
476
+ "results": results[:STRICT_RESULT_LIMIT],
477
+ "sources": [],
478
+ "cached": True
479
+ }
480
+
481
+ # 2. Deterministic Search
482
  results, intent_info = await asyncio.to_thread(search_index.search, q, debug)
483
  top_20 = results[:STRICT_RESULT_LIMIT]
484
  query_tokens = tokenize(q)
485
 
 
 
 
486
  rag_context_parts = []
487
  used_sources = []
488
 
489
  if top_20:
490
+ tasks = [extract_page_content(r["url"]) for r in top_20[:3]] # Only fetch top 3 to keep it fast
 
491
  extracted_data = await asyncio.gather(*tasks)
492
 
 
493
  valid_sources_count = 0
494
+ for i, (r, ext) in enumerate(zip(top_20[:3], extracted_data)):
495
+ if include_content: r["content"] = ext.get("content", "") if ext.get("success") else ""
 
496
 
497
+ if ext.get("success") and ext.get("content") and valid_sources_count < 3:
498
  best_chunks = rank_chunks_for_rag(ext["content"], query_tokens)
499
  if best_chunks:
500
+ rag_context_parts.append(f"[{valid_sources_count+1}]: {' ... '.join(best_chunks)}")
501
  used_sources.append({"id": valid_sources_count+1, "title": r["title"], "url": r["url"]})
502
  valid_sources_count += 1
503
 
504
+ # 3. STRICT 100 TOKEN INPUT LIMIT (User Query + RAG)
505
+ system_prompt = "Answer directly based on context. Cite sources."
506
+ combined_input = f"Context:\n{chr(10).join(rag_context_parts)}\n\nQuery: {q}"
 
 
 
507
 
508
+ if ai_generator:
509
+ tokenizer = ai_generator.tokenizer
510
+ encoded_input = tokenizer.encode(combined_input, add_special_tokens=False)
511
+ if len(encoded_input) > 90: # 90 + 10 for system prompt = 100 max tokens input
512
+ encoded_input = encoded_input[:90]
513
+ combined_input = tokenizer.decode(encoded_input, skip_special_tokens=True)
514
+
515
  messages = [
516
  {"role": "system", "content": system_prompt},
517
+ {"role": "user", "content": combined_input}
518
  ]
519
 
520
+ # 4. Generate Answer (Restricted to 20 or 50 tokens based on GET/POST)
521
+ ai_reply = await run_ai_task(messages, max_tokens=max_ai_tokens, query_key=cache_key)
522
 
523
  return {
524
  "request_id": req_id,
 
526
  "ai_response": ai_reply,
527
  "results_count": len(top_20),
528
  "results": top_20,
529
+ "sources": used_sources,
530
+ "cached": False
531
  }
532
 
533
  @app.get("/aimode")
534
  async def ai_mode_get(q: str = Query(...), content: bool = Query(False), debug: bool = Query(False)):
535
+ return await process_aimode(q, content, debug, max_ai_tokens=20) # Strictly 20 Tokens
536
 
537
  @app.post("/aimode")
538
  async def ai_mode_post(q: str = Form(...), content: bool = Form(False), debug: bool = Form(False)):
539
+ return await process_aimode(q, content, debug, max_ai_tokens=50) # Strictly 50 Tokens
540
 
541
  if __name__ == "__main__":
542
  uvicorn.run(app, host="0.0.0.0", port=7860)