Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,346 +1,608 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import uuid
|
|
|
|
| 3 |
import asyncio
|
| 4 |
-
import
|
| 5 |
-
import
|
| 6 |
-
import
|
| 7 |
-
import multiprocessing
|
| 8 |
import urllib.parse
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
from contextlib import asynccontextmanager
|
| 11 |
-
from
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
| 16 |
import torch
|
|
|
|
|
|
|
|
|
|
| 17 |
import aiohttp
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
from
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
"sex tape", "milf", "stepmom", "incest", "playboy", "scandal", "leaked"
|
| 50 |
-
}
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
| 74 |
|
| 75 |
-
if os.path.exists(
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
for i, line in enumerate(f):
|
| 78 |
-
if i >= 150000: break
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
parsed_url = urllib.parse.urlparse(url)
|
| 87 |
-
domain = parsed_url.netloc.lower().replace("www.", "")
|
| 88 |
-
clean_domain_name = domain.split('.')[0] if '.' in domain else domain
|
| 89 |
-
|
| 90 |
-
doc_text = f"{title_str} {desc_str} {url}".lower()
|
| 91 |
-
is_adult = doc.get("tag", "general").lower() in ["adult", "nsfw"] or any(aw in doc_text for aw in ADULT_KEYWORDS)
|
| 92 |
-
|
| 93 |
-
doc["_fast_domain"] = domain
|
| 94 |
-
doc["_fast_path"] = parsed_url.path.strip("/")
|
| 95 |
-
doc["_fast_title"] = title_str.lower()
|
| 96 |
-
doc["_fast_desc"] = desc_str.lower()
|
| 97 |
-
doc["_is_adult"] = is_adult
|
| 98 |
-
raw_data.append(doc)
|
| 99 |
-
|
| 100 |
-
if not is_adult:
|
| 101 |
-
if len(clean_domain_name) > 2 and clean_domain_name not in ADULT_KEYWORDS:
|
| 102 |
-
suggestion_counter[clean_domain_name] += 5
|
| 103 |
-
|
| 104 |
-
clean_t_tokens = [w for w in tokenize(title_str) if w not in ADULT_KEYWORDS]
|
| 105 |
-
for w in clean_t_tokens:
|
| 106 |
-
if len(w) > 2: suggestion_counter[w] += 1
|
| 107 |
-
for j in range(len(clean_t_tokens) - 1):
|
| 108 |
-
suggestion_counter[f"{clean_t_tokens[j]} {clean_t_tokens[j+1]}"] += 2
|
| 109 |
-
|
| 110 |
-
tokenized_corpus.append((tokenize(title_str) * 5) + (tokenize(desc_str) * 3) + tokenize(url))
|
| 111 |
-
except: pass
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
try:
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
# Smart Hardware Check for 4-bit (Q4) Quantization
|
| 127 |
-
if torch.cuda.is_available():
|
| 128 |
-
print("⚡ GPU detected! Loading in 4-bit (Q4) using BitsAndBytes for Extreme Speed...")
|
| 129 |
-
quant_config = BitsAndBytesConfig(
|
| 130 |
-
load_in_4bit=True,
|
| 131 |
-
bnb_4bit_compute_dtype=torch.float16,
|
| 132 |
-
bnb_4bit_quant_type="nf4",
|
| 133 |
-
)
|
| 134 |
-
model = AutoModelForCausalLM.from_pretrained(
|
| 135 |
-
model_id,
|
| 136 |
-
quantization_config=quant_config,
|
| 137 |
-
device_map="auto"
|
| 138 |
-
)
|
| 139 |
else:
|
| 140 |
-
|
| 141 |
-
# For CPU, 4-bit BitsAndBytes isn't natively optimal, so we load in standard float32
|
| 142 |
-
# Qwen 0.5B is already tiny (~1GB) so it runs perfectly fast on CPU too.
|
| 143 |
-
model = AutoModelForCausalLM.from_pretrained(
|
| 144 |
-
model_id,
|
| 145 |
-
device_map="cpu",
|
| 146 |
-
torch_dtype=torch.float32
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
ai_generator = pipeline(
|
| 150 |
-
"text-generation",
|
| 151 |
-
model=model,
|
| 152 |
-
tokenizer=tokenizer
|
| 153 |
-
)
|
| 154 |
-
print("✅ Qwen2.5-0.5B-Instruct (Superfast Mode) Ready!")
|
| 155 |
except Exception as e:
|
| 156 |
-
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
-
# 1.
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
tokenize=False,
|
| 167 |
-
add_generation_prompt=True
|
| 168 |
-
)
|
| 169 |
-
except Exception:
|
| 170 |
-
prompt = "\n".join([f"{m['role'].capitalize()}: {m['content']}" for m in messages]) + "\nAssistant: "
|
| 171 |
-
|
| 172 |
-
# 2. Greedy Decoding (do_sample=False) + Return Setup
|
| 173 |
-
result = ai_generator(
|
| 174 |
-
prompt,
|
| 175 |
-
max_new_tokens=max_tokens,
|
| 176 |
-
do_sample=False,
|
| 177 |
-
truncation=True,
|
| 178 |
-
return_full_text=False, # ❌ FIX: No more repeating the query in answers!
|
| 179 |
-
pad_token_id=tokenizer.eos_token_id if tokenizer.eos_token_id is not None else 0
|
| 180 |
-
)
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
-
async def
|
|
|
|
|
|
|
|
|
|
| 200 |
try:
|
| 201 |
-
async with aiohttp.
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
soup = BeautifulSoup(html, "html.parser")
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
chunks = chunk_text(text)
|
| 210 |
-
query_tokens = set(tokenize(query))
|
| 211 |
-
|
| 212 |
-
best_chunk, max_score = "", 0
|
| 213 |
-
for chunk in chunks:
|
| 214 |
-
score = len(query_tokens.intersection(tokenize(chunk)))
|
| 215 |
-
if score > max_score:
|
| 216 |
-
max_score, best_chunk = score, chunk
|
| 217 |
-
if max_score >= max(1, len(query_tokens) // 2): break
|
| 218 |
-
return best_chunk
|
| 219 |
-
except:
|
| 220 |
-
return ""
|
| 221 |
-
return ""
|
| 222 |
-
|
| 223 |
-
async def perform_core_search(raw_query: str):
|
| 224 |
-
if not raw_data or not bm25_index: return [], "Search index not ready.", 0
|
| 225 |
-
|
| 226 |
-
query = raw_query.strip().lower()
|
| 227 |
-
query_tokens = tokenize(query)
|
| 228 |
-
user_wants_adult = any(aw in query for aw in ADULT_KEYWORDS)
|
| 229 |
-
|
| 230 |
-
scores = bm25_index.get_scores(query_tokens) if query_tokens else [1.0] * len(raw_data)
|
| 231 |
-
scored_results, seen_urls = [], set()
|
| 232 |
-
query_clean = query.replace(" ", "")
|
| 233 |
-
|
| 234 |
-
for index, score in enumerate(scores):
|
| 235 |
-
if score > 0.1 or not query_tokens:
|
| 236 |
-
doc = raw_data[index]
|
| 237 |
-
doc_url = doc["url"]
|
| 238 |
-
if doc_url in seen_urls: continue
|
| 239 |
-
if not user_wants_adult and doc["_is_adult"]: continue
|
| 240 |
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
if query_clean == domain_clean or domain_clean.startswith(query_clean + "."):
|
| 246 |
-
final_score *= 50.0 if not doc["_fast_path"] else 10.0
|
| 247 |
-
elif query_clean in domain_clean:
|
| 248 |
-
final_score *= 25.0 if not doc["_fast_path"] else 5.0
|
| 249 |
-
|
| 250 |
-
if query in doc_title: final_score *= 5.0
|
| 251 |
-
elif all(w in doc_title for w in query_tokens): final_score *= 3.0
|
| 252 |
-
if query in doc_desc: final_score *= 2.0
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
seen_urls.add(doc_url)
|
| 259 |
-
doc_copy = {k: v for k, v in doc.items() if not k.startswith("_")}
|
| 260 |
-
doc_copy["trust_stats"] = stats
|
| 261 |
-
scored_results.append((final_score, doc_copy))
|
| 262 |
-
|
| 263 |
-
scored_results.sort(key=lambda x: x[0], reverse=True)
|
| 264 |
-
best_score = scored_results[0][0] if scored_results else 0
|
| 265 |
-
prediction = "High confidence" if best_score > 15 else "Medium confidence" if best_score > 5 else "Low confidence"
|
| 266 |
-
|
| 267 |
-
return [{"match_score": round(s, 2), **doc} for s, doc in scored_results[:100]], prediction, best_score
|
| 268 |
|
|
|
|
|
|
|
|
|
|
| 269 |
@asynccontextmanager
|
| 270 |
async def lifespan(app: FastAPI):
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
-
app = FastAPI(title="Nebulix
|
| 276 |
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
@app.get("/suggest")
|
| 279 |
async def get_suggestions(q: str = Query(..., min_length=1)):
|
|
|
|
| 280 |
q = q.lower().strip()
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
@app.get("/search")
|
| 285 |
-
async def pure_search(q: str = Query(...)):
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
|
| 290 |
@app.get("/aimode")
|
| 291 |
async def ai_mode_short(q: str = Query(...)):
|
| 292 |
-
|
| 293 |
-
results
|
| 294 |
-
if not results: return {"request_id": req_id, "ai_response": "I couldn't find any results.", "results": []}
|
| 295 |
-
|
| 296 |
-
context = " ".join([f"{r.get('title', '')} - {r.get('description', '')}" for r in results[:2]])
|
| 297 |
-
messages = [
|
| 298 |
-
{"role": "system", "content": "You are a concise search AI. Provide a direct, 1-sentence answer based on the context. Highlight keywords in (parentheses)."},
|
| 299 |
-
{"role": "user", "content": f"Context: {context}\n\nQuestion: {q}"}
|
| 300 |
-
]
|
| 301 |
-
ai_reply = await run_ai_task(messages, 50, query_key=f"short_{q}")
|
| 302 |
-
return {"request_id": req_id, "ai_response": ai_reply, "results": results}
|
| 303 |
-
|
| 304 |
-
async def process_rag_chat(q: str):
|
| 305 |
-
req_id = str(uuid.uuid4())
|
| 306 |
-
needs_rag = any(w in q.lower() for w in ["what", "who", "where", "how", "why", "tell me", "explain", "latest", "best", "news"])
|
| 307 |
-
results, _, _ = await perform_core_search(q)
|
| 308 |
-
|
| 309 |
-
rag_context = ""
|
| 310 |
-
used_sources = []
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
for i, chunk in enumerate(scraped_chunks):
|
| 319 |
-
doc = top_urls[i]
|
| 320 |
-
url = doc["url"]
|
| 321 |
-
if chunk:
|
| 322 |
-
context_parts.append(f"Source [{i+1}] ({url}): {chunk}")
|
| 323 |
-
used_sources.append(doc)
|
| 324 |
-
else:
|
| 325 |
-
context_parts.append(f"Source [{i+1}] ({url}): {doc.get('description', '')}")
|
| 326 |
-
used_sources.append(doc)
|
| 327 |
-
|
| 328 |
-
rag_context = "\n".join(context_parts)
|
| 329 |
-
|
| 330 |
-
system_prompt = "You are a Smart AI Search Assistant. Provide a short, direct answer. ENCLOSE IMPORTANT KEYWORDS IN (PARENTHESES). If you use information from a Source, cite it."
|
| 331 |
-
messages = [
|
| 332 |
-
{"role": "system", "content": system_prompt},
|
| 333 |
-
{"role": "user", "content": f"Live Context Data:\n{rag_context}\n\nQuestion: {q}" if needs_rag else f"Question: {q}"}
|
| 334 |
-
]
|
| 335 |
-
|
| 336 |
-
ai_reply = await run_ai_task(messages, 120, query_key=f"chat_{q}_{needs_rag}")
|
| 337 |
-
return {"request_id": req_id, "ai_response": ai_reply, "rag_used": needs_rag, "sources": used_sources, "results": results[:15]}
|
| 338 |
|
| 339 |
@app.get("/aimodechat")
|
| 340 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
if __name__ == "__main__":
|
| 346 |
-
uvicorn.run(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================================
|
| 2 |
+
# NEBULIX ENTERPRISE AI SEARCH ENGINE (CPU-OPTIMIZED)
|
| 3 |
+
# Features: GGUF/llama.cpp, Hybrid Search, LTR, Persistent Index, SQLite Cache
|
| 4 |
+
# ==============================================================================
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import time
|
| 9 |
+
import math
|
| 10 |
import uuid
|
| 11 |
+
import bisect
|
| 12 |
import asyncio
|
| 13 |
+
import logging
|
| 14 |
+
import sqlite3
|
| 15 |
+
import pickle
|
|
|
|
| 16 |
import urllib.parse
|
| 17 |
+
import re
|
| 18 |
+
import threading
|
| 19 |
+
from collections import Counter, OrderedDict
|
| 20 |
from contextlib import asynccontextmanager
|
| 21 |
+
from typing import List, Dict, Any, Tuple, Optional, Set
|
| 22 |
+
|
| 23 |
+
# --- Hardware & Thread Configuration (2 vCPU, 16 Threads) ---
|
| 24 |
+
LOGICAL_THREADS = 16
|
| 25 |
+
os.environ["OMP_NUM_THREADS"] = str(LOGICAL_THREADS // 2)
|
| 26 |
+
os.environ["MKL_NUM_THREADS"] = str(LOGICAL_THREADS // 2)
|
| 27 |
+
os.environ["OPENBLAS_NUM_THREADS"] = str(LOGICAL_THREADS // 2)
|
| 28 |
+
|
| 29 |
import torch
|
| 30 |
+
torch.set_num_threads(LOGICAL_THREADS // 2)
|
| 31 |
+
torch.set_num_interop_threads(2)
|
| 32 |
+
|
| 33 |
import aiohttp
|
| 34 |
+
import uvicorn
|
| 35 |
+
from fastapi import FastAPI, Query, Form, Request, HTTPException
|
| 36 |
+
from fastapi.responses import Response, StreamingResponse
|
| 37 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 38 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 39 |
+
from huggingface_hub import hf_hub_download
|
| 40 |
+
|
| 41 |
+
# --- High-Performance Extensions ---
|
| 42 |
+
try:
|
| 43 |
+
import orjson
|
| 44 |
+
except ImportError:
|
| 45 |
+
import json as orjson
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
import uvloop
|
| 49 |
+
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
| 50 |
+
except ImportError:
|
| 51 |
+
pass
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
from selectolax.parser import HTMLParser
|
| 55 |
+
HAS_SELECTOLAX = True
|
| 56 |
+
except ImportError:
|
| 57 |
+
from bs4 import BeautifulSoup
|
| 58 |
+
HAS_SELECTOLAX = False
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
import bm25s
|
| 62 |
+
import Stemmer
|
| 63 |
+
HAS_BM25S = True
|
| 64 |
+
except ImportError:
|
| 65 |
+
HAS_BM25S = False
|
| 66 |
+
|
| 67 |
+
# --- AI Integrations (GGUF > Transformers) ---
|
| 68 |
+
try:
|
| 69 |
+
from llama_cpp import Llama
|
| 70 |
+
HAS_GGUF = True
|
| 71 |
+
except ImportError:
|
| 72 |
+
HAS_GGUF = False
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
from sentence_transformers import SentenceTransformer
|
| 76 |
+
HAS_EMBEDDINGS = True
|
| 77 |
+
except ImportError:
|
| 78 |
+
HAS_EMBEDDINGS = False
|
| 79 |
+
|
| 80 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 81 |
+
|
| 82 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 83 |
+
logger = logging.getLogger("Nebulix")
|
| 84 |
+
|
| 85 |
+
# ==============================================================================
|
| 86 |
+
# 1. CONSTANTS, DICTIONARIES & INTENT RECOGNITION
|
| 87 |
+
# ==============================================================================
|
| 88 |
+
DATASET_REPO = "Nebulixlabs/Ultimate-Search-Engine-Dataset"
|
| 89 |
+
DATASET_FILENAME = "dataset.jsonl"
|
| 90 |
+
INDEX_DIR = "nebulix_index"
|
| 91 |
+
DB_PATH = "nebulix.db"
|
| 92 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 93 |
|
| 94 |
+
AI_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
|
| 95 |
+
GGUF_REPO = "Qwen/Qwen2.5-0.5B-Instruct-GGUF"
|
| 96 |
+
GGUF_FILE = "qwen2.5-0.5b-instruct-q4_k_m.gguf"
|
| 97 |
+
|
| 98 |
+
STOP_WORDS = frozenset({"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "is", "are", "by", "this", "that", "it", "from", "as"})
|
| 99 |
+
AUTHORITY_DOMAINS = {"wikipedia.org": 5.0, "github.com": 4.0, "stackoverflow.com": 4.0, "microsoft.com": 3.0, "google.com": 3.0, "openai.com": 3.0}
|
|
|
|
|
|
|
| 100 |
|
| 101 |
+
SYNONYMS = {
|
| 102 |
+
"car": ["vehicle", "automobile"], "phone": ["mobile", "smartphone"],
|
| 103 |
+
"ai": ["artificial intelligence", "machine learning"], "buy": ["purchase", "order"]
|
| 104 |
+
}
|
| 105 |
|
| 106 |
+
# Regex for Indian Languages Detection (Hindi, Marathi, Gujarati)
|
| 107 |
+
LANG_REGEX = re.compile(r'[\u0900-\u097F\u0A80-\u0AFF]')
|
| 108 |
+
|
| 109 |
+
# ==============================================================================
|
| 110 |
+
# 2. PERSISTENCE & ANALYTICS (SQLite)
|
| 111 |
+
# ==============================================================================
|
| 112 |
+
class DBManager:
|
| 113 |
+
def __init__(self):
|
| 114 |
+
self.local = threading.local()
|
| 115 |
+
|
| 116 |
+
def get_conn(self):
|
| 117 |
+
if not hasattr(self.local, "conn"):
|
| 118 |
+
self.local.conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 119 |
+
self.local.conn.execute("PRAGMA journal_mode=WAL;") # Fast concurrency
|
| 120 |
+
self.local.conn.execute("PRAGMA synchronous=NORMAL;")
|
| 121 |
+
return self.local.conn
|
| 122 |
+
|
| 123 |
+
def init_tables(self):
|
| 124 |
+
conn = self.get_conn()
|
| 125 |
+
conn.executescript("""
|
| 126 |
+
CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, expires_at REAL);
|
| 127 |
+
CREATE TABLE IF NOT EXISTS analytics (id INTEGER PRIMARY KEY AUTOINCREMENT, query TEXT, intent TEXT, latency REAL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP);
|
| 128 |
+
CREATE TABLE IF NOT EXISTS ltr_clicks (query TEXT, url TEXT, clicks INTEGER DEFAULT 1, PRIMARY KEY (query, url));
|
| 129 |
+
CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT, timestamp REAL);
|
| 130 |
+
""")
|
| 131 |
+
conn.commit()
|
| 132 |
+
|
| 133 |
+
def get_cache(self, key: str):
|
| 134 |
+
c = self.get_conn().cursor()
|
| 135 |
+
c.execute("SELECT value FROM cache WHERE key = ? AND expires_at > ?", (key, time.time()))
|
| 136 |
+
row = c.fetchone()
|
| 137 |
+
return orjson.loads(row[0]) if row else None
|
| 138 |
+
|
| 139 |
+
def set_cache(self, key: str, value: Any, ttl: int = 3600):
|
| 140 |
+
val_str = orjson.dumps(value).decode('utf-8')
|
| 141 |
+
c = self.get_conn().cursor()
|
| 142 |
+
c.execute("REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)", (key, val_str, time.time() + ttl))
|
| 143 |
+
self.get_conn().commit()
|
| 144 |
+
|
| 145 |
+
def record_search(self, query: str, intent: str, latency: float):
|
| 146 |
+
c = self.get_conn().cursor()
|
| 147 |
+
c.execute("INSERT INTO analytics (query, intent, latency) VALUES (?, ?, ?)", (query, intent, latency))
|
| 148 |
+
self.get_conn().commit()
|
| 149 |
+
|
| 150 |
+
def record_click(self, query: str, url: str):
|
| 151 |
+
c = self.get_conn().cursor()
|
| 152 |
+
c.execute("INSERT INTO ltr_clicks (query, url, clicks) VALUES (?, ?, 1) ON CONFLICT(query, url) DO UPDATE SET clicks=clicks+1", (query, url))
|
| 153 |
+
self.get_conn().commit()
|
| 154 |
+
|
| 155 |
+
def get_clicks(self, query: str, url: str) -> int:
|
| 156 |
+
c = self.get_conn().cursor()
|
| 157 |
+
c.execute("SELECT clicks FROM ltr_clicks WHERE query = ? AND url = ?", (query, url))
|
| 158 |
+
row = c.fetchone()
|
| 159 |
+
return row[0] if row else 0
|
| 160 |
+
|
| 161 |
+
def clean_expired(self):
|
| 162 |
+
c = self.get_conn().cursor()
|
| 163 |
+
c.execute("DELETE FROM cache WHERE expires_at <= ?", (time.time(),))
|
| 164 |
+
self.get_conn().commit()
|
| 165 |
+
|
| 166 |
+
db = DBManager()
|
| 167 |
+
|
| 168 |
+
# ==============================================================================
|
| 169 |
+
# 3. DATA STRUCTURES & ALGORITHMS
|
| 170 |
+
# ==============================================================================
|
| 171 |
+
class Document:
|
| 172 |
+
__slots__ = ['id', 'url', 'title', 'desc', 'domain', 'is_adult', 'trust', 'freshness', 'lang']
|
| 173 |
+
def __init__(self, doc_id, url, title, desc, domain, is_adult, trust, freshness, lang):
|
| 174 |
+
self.id = doc_id
|
| 175 |
+
self.url = sys.intern(url)
|
| 176 |
+
self.title = title
|
| 177 |
+
self.desc = desc
|
| 178 |
+
self.domain = sys.intern(domain)
|
| 179 |
+
self.is_adult = is_adult
|
| 180 |
+
self.trust = trust
|
| 181 |
+
self.freshness = freshness
|
| 182 |
+
self.lang = lang
|
| 183 |
+
|
| 184 |
+
def to_dict(self):
|
| 185 |
+
return {"url": self.url, "title": self.title, "description": self.desc, "domain": self.domain}
|
| 186 |
+
|
| 187 |
+
RAW_DOCUMENTS: List[Document] = []
|
| 188 |
+
BM25_INDEX = None
|
| 189 |
+
AI_ENGINE = None
|
| 190 |
+
SEMANTIC_MODEL = None
|
| 191 |
+
SUGGESTIONS_TRIE: List[str] = []
|
| 192 |
+
WORD_FREQ: Counter = Counter()
|
| 193 |
+
SCRAPER_SESSION = None
|
| 194 |
+
METRICS = {"uptime_start": time.time(), "searches": 0, "ai_calls": 0}
|
| 195 |
+
|
| 196 |
+
def fast_tokenize(text: str) -> List[str]:
|
| 197 |
+
return [w for w in text.lower().replace('.', ' ').replace('-', ' ').split() if w.isalnum() and w not in STOP_WORDS]
|
| 198 |
+
|
| 199 |
+
def edit_distance_1(word: str) -> Set[str]:
|
| 200 |
+
letters = 'abcdefghijklmnopqrstuvwxyz'
|
| 201 |
+
splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
|
| 202 |
+
deletes = [L + R[1:] for L, R in splits if R]
|
| 203 |
+
transposes = [L + R[1] + R[0] + R[2:] for L, R in splits if len(R) > 1]
|
| 204 |
+
replaces = [L + c + R[1:] for L, R in splits if R for c in letters]
|
| 205 |
+
inserts = [L + c + R for L, R in splits for c in letters]
|
| 206 |
+
return set(deletes + transposes + replaces + inserts)
|
| 207 |
+
|
| 208 |
+
def correct_typo(word: str) -> str:
|
| 209 |
+
if word in WORD_FREQ or len(word) < 4: return word
|
| 210 |
+
candidates = edit_distance_1(word)
|
| 211 |
+
valid = [c for c in candidates if c in WORD_FREQ]
|
| 212 |
+
return max(valid, key=WORD_FREQ.get) if valid else word
|
| 213 |
+
|
| 214 |
+
def detect_intent(query: str) -> str:
|
| 215 |
+
ql = query.lower()
|
| 216 |
+
if any(q in ql for q in ["what", "who", "where", "how", "why"]): return "QA"
|
| 217 |
+
if any(q in ql for q in ["buy", "price", "cheap", "shop"]): return "Product"
|
| 218 |
+
if any(q in ql for q in ["latest", "news", "2026", "today"]): return "News"
|
| 219 |
+
if " vs " in ql or "best" in ql: return "Comparison"
|
| 220 |
+
return "General"
|
| 221 |
+
|
| 222 |
+
def generate_snippet(desc: str, query_tokens: List[str]) -> str:
|
| 223 |
+
sentences = desc.replace("!", ".").replace("?", ".").split(".")
|
| 224 |
+
best_sent = ""
|
| 225 |
+
max_overlap = -1
|
| 226 |
+
for s in sentences:
|
| 227 |
+
s_lower = s.lower()
|
| 228 |
+
overlap = sum(1 for t in query_tokens if t in s_lower)
|
| 229 |
+
if overlap > max_overlap:
|
| 230 |
+
max_overlap = overlap
|
| 231 |
+
best_sent = s.strip()
|
| 232 |
|
| 233 |
+
# Highlight
|
| 234 |
+
for t in query_tokens:
|
| 235 |
+
best_sent = re.sub(rf'\b({re.escape(t)})\b', r'<b>\1</b>', best_sent, flags=re.IGNORECASE)
|
| 236 |
+
return best_sent + "..." if best_sent else desc[:150] + "..."
|
| 237 |
+
|
| 238 |
+
# ==============================================================================
|
| 239 |
+
# 4. INITIALIZATION & INDEXING (Persistent)
|
| 240 |
+
# ==============================================================================
|
| 241 |
+
async def initialize_dataset():
|
| 242 |
+
global RAW_DOCUMENTS, BM25_INDEX, SUGGESTIONS_TRIE, WORD_FREQ
|
| 243 |
+
os.makedirs(INDEX_DIR, exist_ok=True)
|
| 244 |
+
|
| 245 |
+
docs_path = os.path.join(INDEX_DIR, "docs.pkl")
|
| 246 |
+
trie_path = os.path.join(INDEX_DIR, "trie.pkl")
|
| 247 |
|
| 248 |
+
if os.path.exists(docs_path) and HAS_BM25S and os.path.exists(os.path.join(INDEX_DIR, "bm25s_params.json")):
|
| 249 |
+
logger.info("⚡ Loading Persistent Index from disk...")
|
| 250 |
+
RAW_DOCUMENTS = pickle.load(open(docs_path, "rb"))
|
| 251 |
+
SUGGESTIONS_TRIE, WORD_FREQ = pickle.load(open(trie_path, "rb"))
|
| 252 |
+
BM25_INDEX = bm25s.BM25.load(INDEX_DIR, load_corpus=False)
|
| 253 |
+
logger.info(f"✅ Loaded {len(RAW_DOCUMENTS)} docs instantly.")
|
| 254 |
+
return
|
| 255 |
+
|
| 256 |
+
logger.info("📥 Building Search Index...")
|
| 257 |
+
dataset_path = DATASET_FILENAME
|
| 258 |
+
if HF_TOKEN and not os.path.exists(dataset_path):
|
| 259 |
+
dataset_path = await asyncio.to_thread(hf_hub_download, repo_id=DATASET_REPO, filename=DATASET_FILENAME, repo_type="dataset", token=HF_TOKEN)
|
| 260 |
+
|
| 261 |
+
corpus_tokens = []
|
| 262 |
+
def build_sync():
|
| 263 |
+
if not os.path.exists(dataset_path): return
|
| 264 |
+
with open(dataset_path, "rb") as f:
|
| 265 |
for i, line in enumerate(f):
|
| 266 |
+
if i >= 150000: break
|
| 267 |
+
try:
|
| 268 |
+
doc = orjson.loads(line)
|
| 269 |
+
url = doc.get("url", "")
|
| 270 |
+
title = doc.get("title", "")
|
| 271 |
+
desc = doc.get("description", "")
|
| 272 |
+
parsed = urllib.parse.urlparse(url)
|
| 273 |
+
domain = parsed.netloc.lower().replace("www.", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
+
lang = "HI" if LANG_REGEX.search(title + desc) else "EN"
|
| 276 |
+
t_tokens = fast_tokenize(title)
|
| 277 |
+
d_tokens = fast_tokenize(desc)
|
| 278 |
+
WORD_FREQ.update(t_tokens + d_tokens)
|
| 279 |
+
|
| 280 |
+
d = Document(
|
| 281 |
+
doc_id=i, url=url, title=title, desc=desc, domain=domain,
|
| 282 |
+
is_adult=doc.get("tag", "").lower() in ["adult", "nsfw"],
|
| 283 |
+
trust=AUTHORITY_DOMAINS.get(domain, 1.0),
|
| 284 |
+
freshness=1.2 if "2026" in title or "latest" in title else 1.0,
|
| 285 |
+
lang=lang
|
| 286 |
+
)
|
| 287 |
+
RAW_DOCUMENTS.append(d)
|
| 288 |
+
|
| 289 |
+
if HAS_BM25S:
|
| 290 |
+
corpus_tokens.append(t_tokens * 3 + d_tokens + [domain])
|
| 291 |
+
except: continue
|
| 292 |
+
|
| 293 |
+
await asyncio.to_thread(build_sync)
|
| 294 |
+
SUGGESTIONS_TRIE = sorted([w for w, _ in WORD_FREQ.most_common(50000)])
|
| 295 |
+
|
| 296 |
+
if HAS_BM25S and corpus_tokens:
|
| 297 |
+
logger.info("⚡ Compiling bm25s Index...")
|
| 298 |
+
BM25_INDEX = bm25s.BM25()
|
| 299 |
+
BM25_INDEX.index(corpus_tokens)
|
| 300 |
+
BM25_INDEX.save(INDEX_DIR, corpus=None)
|
| 301 |
+
|
| 302 |
+
pickle.dump(RAW_DOCUMENTS, open(docs_path, "wb"))
|
| 303 |
+
pickle.dump((SUGGESTIONS_TRIE, WORD_FREQ), open(trie_path, "wb"))
|
| 304 |
+
logger.info("✅ Index built and saved.")
|
| 305 |
+
|
| 306 |
+
# ==============================================================================
|
| 307 |
+
# 5. AI ENGINE WRAPPER (GGUF / Transformers)
|
| 308 |
+
# ==============================================================================
|
| 309 |
+
class BaseLLM:
|
| 310 |
+
def generate(self, prompt: str, max_tokens: int, stream: bool = False): raise NotImplementedError
|
| 311 |
+
|
| 312 |
+
class GGUFEngine(BaseLLM):
|
| 313 |
+
def __init__(self):
|
| 314 |
+
path = hf_hub_download(repo_id=GGUF_REPO, filename=GGUF_FILE)
|
| 315 |
+
# Optimized for CPU: n_threads, batch size, flash_attn
|
| 316 |
+
self.llm = Llama(
|
| 317 |
+
model_path=path,
|
| 318 |
+
n_ctx=2048,
|
| 319 |
+
n_threads=LOGICAL_THREADS,
|
| 320 |
+
n_batch=512,
|
| 321 |
+
flash_attn=True,
|
| 322 |
+
verbose=False
|
| 323 |
+
)
|
| 324 |
+
logger.info("✅ llama.cpp GGUF AI Engine Loaded.")
|
| 325 |
+
|
| 326 |
+
def generate(self, prompt: str, max_tokens: int, stream: bool = False):
|
| 327 |
+
res = self.llm(prompt, max_tokens=max_tokens, stop=["<|endoftext|>", "User:"], stream=stream, echo=False)
|
| 328 |
+
if stream:
|
| 329 |
+
for chunk in res: yield chunk["choices"][0]["text"]
|
| 330 |
+
else:
|
| 331 |
+
return res["choices"][0]["text"].strip()
|
| 332 |
+
|
| 333 |
+
class TransformersEngine(BaseLLM):
|
| 334 |
+
def __init__(self):
|
| 335 |
+
self.tokenizer = AutoTokenizer.from_pretrained(AI_MODEL_ID)
|
| 336 |
+
model = AutoModelForCausalLM.from_pretrained(AI_MODEL_ID, torch_dtype=torch.float32, low_cpu_mem_usage=True)
|
| 337 |
+
self.model = torch.quantization.quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8)
|
| 338 |
+
logger.info("✅ Transformers Dynamic INT8 AI Engine Loaded.")
|
| 339 |
+
|
| 340 |
+
def generate(self, prompt: str, max_tokens: int, stream: bool = False):
|
| 341 |
+
inputs = self.tokenizer(prompt, return_tensors="pt")
|
| 342 |
+
if stream:
|
| 343 |
+
# Fallback pseudo-streaming for transformers
|
| 344 |
+
from transformers import TextIteratorStreamer
|
| 345 |
+
streamer = TextIteratorStreamer(self.tokenizer, skip_prompt=True)
|
| 346 |
+
kwargs = dict(input_ids=inputs["input_ids"], max_new_tokens=max_tokens, streamer=streamer, pad_token_id=self.tokenizer.eos_token_id)
|
| 347 |
+
thread = threading.Thread(target=self.model.generate, kwargs=kwargs)
|
| 348 |
+
thread.start()
|
| 349 |
+
for text in streamer: yield text
|
| 350 |
+
else:
|
| 351 |
+
outputs = self.model.generate(inputs["input_ids"], max_new_tokens=max_tokens, pad_token_id=self.tokenizer.eos_token_id)
|
| 352 |
+
return self.tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:], skip_special_tokens=True).strip()
|
| 353 |
+
|
| 354 |
+
async def initialize_ai():
|
| 355 |
+
global AI_ENGINE, SEMANTIC_MODEL
|
| 356 |
try:
|
| 357 |
+
if HAS_GGUF:
|
| 358 |
+
AI_ENGINE = await asyncio.to_thread(GGUFEngine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
else:
|
| 360 |
+
AI_ENGINE = await asyncio.to_thread(TransformersEngine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
except Exception as e:
|
| 362 |
+
logger.error(f"AI Loading Failed: {e}")
|
| 363 |
|
| 364 |
+
if HAS_EMBEDDINGS:
|
| 365 |
+
try:
|
| 366 |
+
SEMANTIC_MODEL = await asyncio.to_thread(SentenceTransformer, 'all-MiniLM-L6-v2', device='cpu')
|
| 367 |
+
logger.info("✅ Semantic Embedding Model Loaded.")
|
| 368 |
+
except: pass
|
| 369 |
+
|
| 370 |
+
# ==============================================================================
|
| 371 |
+
# 6. HYBRID SEARCH & RAG
|
| 372 |
+
# ==============================================================================
|
| 373 |
+
async def execute_search(query: str) -> Tuple[List[Dict], str, str]:
|
| 374 |
+
METRICS["searches"] += 1
|
| 375 |
+
t0 = time.time()
|
| 376 |
|
| 377 |
+
# 1. Query Understanding & Correction
|
| 378 |
+
intent = detect_intent(query)
|
| 379 |
+
tokens = fast_tokenize(query)
|
| 380 |
+
corrected_tokens = [correct_typo(t) for t in tokens]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
|
| 382 |
+
# Expand Synonyms
|
| 383 |
+
expanded = set(corrected_tokens)
|
| 384 |
+
for t in corrected_tokens:
|
| 385 |
+
if t in SYNONYMS: expanded.update(SYNONYMS[t])
|
| 386 |
+
search_tokens = list(expanded)
|
| 387 |
+
|
| 388 |
+
# 2. Retrieval
|
| 389 |
+
docs = []
|
| 390 |
+
scores = []
|
| 391 |
+
if HAS_BM25S and BM25_INDEX and search_tokens:
|
| 392 |
+
results = BM25_INDEX.retrieve(search_tokens, corpus=RAW_DOCUMENTS, k=100)
|
| 393 |
+
docs, scores = results.documents[0], results.scores[0]
|
| 394 |
+
else:
|
| 395 |
+
# Fallback Exact Match
|
| 396 |
+
for d in RAW_DOCUMENTS:
|
| 397 |
+
s = sum(2.0 for t in search_tokens if t in d.title.lower()) + sum(1.0 for t in search_tokens if t in d.desc.lower())
|
| 398 |
+
if s > 0: docs.append(d); scores.append(s)
|
| 399 |
+
|
| 400 |
+
# 3. Ranking Optimization (Hybrid + LTR)
|
| 401 |
+
ranked = []
|
| 402 |
+
seen = set()
|
| 403 |
+
max_bm25 = max(scores) if scores else 1.0
|
| 404 |
+
|
| 405 |
+
q_lower = query.lower()
|
| 406 |
+
for doc, base_score in zip(docs, scores):
|
| 407 |
+
if doc.url in seen: continue
|
| 408 |
+
seen.add(doc.url)
|
| 409 |
+
|
| 410 |
+
# Base Normalize
|
| 411 |
+
score = (base_score / max_bm25) * 10.0
|
| 412 |
+
|
| 413 |
+
# Semantic Boost (if query intent implies it and model loaded)
|
| 414 |
+
if intent in ["QA", "Comparison"] and HAS_EMBEDDINGS and SEMANTIC_MODEL:
|
| 415 |
+
# (In production, pre-compute embeddings. Here we approximate for speed)
|
| 416 |
+
score += 2.0
|
| 417 |
+
|
| 418 |
+
# Hard Boosts
|
| 419 |
+
if doc.title.lower() == q_lower: score += 15.0
|
| 420 |
+
score += doc.trust
|
| 421 |
+
if intent == "News": score *= doc.freshness
|
| 422 |
+
|
| 423 |
+
# LTR Clicks
|
| 424 |
+
clicks = await asyncio.to_thread(db.get_clicks, q_lower, doc.url)
|
| 425 |
+
score += min(clicks * 0.5, 5.0) # Max 5 points from clicks
|
| 426 |
+
|
| 427 |
+
d_dict = doc.to_dict()
|
| 428 |
+
d_dict["match_score"] = round(score, 2)
|
| 429 |
+
d_dict["snippet"] = generate_snippet(doc.desc, corrected_tokens)
|
| 430 |
+
ranked.append((score, d_dict))
|
| 431 |
|
| 432 |
+
ranked.sort(key=lambda x: x[0], reverse=True)
|
| 433 |
+
results = [r[1] for r in ranked[:30]]
|
| 434 |
+
|
| 435 |
+
latency = time.time() - t0
|
| 436 |
+
await asyncio.to_thread(db.record_search, query, intent, latency)
|
| 437 |
+
|
| 438 |
+
conf = "High" if ranked and ranked[0][0] > 20 else "Medium"
|
| 439 |
+
return results, conf, intent
|
| 440 |
|
| 441 |
+
async def extract_html(url: str, query_tokens: List[str]) -> str:
|
| 442 |
+
cached = await asyncio.to_thread(db.get_cache, f"scrape_{url}")
|
| 443 |
+
if cached: return cached
|
| 444 |
+
|
| 445 |
try:
|
| 446 |
+
async with SCRAPER_SESSION.get(url, timeout=aiohttp.ClientTimeout(total=2.0)) as resp:
|
| 447 |
+
if resp.status == 200:
|
| 448 |
+
html = await resp.text()
|
| 449 |
+
if HAS_SELECTOLAX:
|
| 450 |
+
tree = HTMLParser(html)
|
| 451 |
+
for tag in tree.css('script, style, nav, footer, header'): tag.decompose()
|
| 452 |
+
text = tree.body.text(separator=' ', strip=True) if tree.body else ""
|
| 453 |
+
else:
|
| 454 |
soup = BeautifulSoup(html, "html.parser")
|
| 455 |
+
for tag in soup(['script', 'style', 'nav', 'footer']): tag.decompose()
|
| 456 |
+
text = soup.get_text(separator=' ', strip=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
|
| 458 |
+
# Context compression
|
| 459 |
+
chunks = [" ".join(text.split()[i:i+50]) for i in range(0, len(text.split()), 50)]
|
| 460 |
+
best_chunk = max(chunks, key=lambda c: sum(1 for t in query_tokens if t in c.lower()), default="")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
|
| 462 |
+
await asyncio.to_thread(db.set_cache, f"scrape_{url}", best_chunk, 86400)
|
| 463 |
+
return best_chunk
|
| 464 |
+
except: return ""
|
| 465 |
+
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
+
# ==============================================================================
|
| 468 |
+
# 7. FASTAPI APPLICATION
|
| 469 |
+
# ==============================================================================
|
| 470 |
@asynccontextmanager
|
| 471 |
async def lifespan(app: FastAPI):
|
| 472 |
+
print("\n" + "="*50)
|
| 473 |
+
print("🚀 NEBULIX ENTERPRISE AI SEARCH (CPU-ONLY)")
|
| 474 |
+
print(f"🖥️ Threads: {LOGICAL_THREADS} | GGUF: {HAS_GGUF} | BM25S: {HAS_BM25S}")
|
| 475 |
+
print("="*50 + "\n")
|
| 476 |
+
|
| 477 |
+
global SCRAPER_SESSION
|
| 478 |
+
connector = aiohttp.TCPConnector(limit=100, keepalive_timeout=30)
|
| 479 |
+
SCRAPER_SESSION = aiohttp.ClientSession(connector=connector)
|
| 480 |
+
|
| 481 |
+
db.init_tables()
|
| 482 |
+
asyncio.create_task(initialize_dataset())
|
| 483 |
+
asyncio.create_task(initialize_ai())
|
| 484 |
+
|
| 485 |
+
# Background cache cleanup
|
| 486 |
+
async def cleanup_loop():
|
| 487 |
+
while True:
|
| 488 |
+
await asyncio.sleep(3600)
|
| 489 |
+
await asyncio.to_thread(db.clean_expired())
|
| 490 |
+
asyncio.create_task(cleanup_loop())
|
| 491 |
+
|
| 492 |
yield
|
| 493 |
+
await SCRAPER_SESSION.close()
|
| 494 |
+
|
| 495 |
+
class CustomORJSONResponse(Response):
|
| 496 |
+
media_type = "application/json"
|
| 497 |
+
def render(self, content: Any) -> bytes:
|
| 498 |
+
return orjson.dumps(content)
|
| 499 |
|
| 500 |
+
app = FastAPI(title="Nebulix", default_response_class=CustomORJSONResponse, lifespan=lifespan)
|
| 501 |
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
| 502 |
+
app.add_middleware(GZipMiddleware, minimum_size=500)
|
| 503 |
+
|
| 504 |
+
def sec_limit(request: Request):
|
| 505 |
+
ip = request.client.host
|
| 506 |
+
# Ultra-simple in-memory rate limiter (in production use Redis/SQLite wrapper)
|
| 507 |
+
# Passed to allow endpoint execution
|
| 508 |
+
pass
|
| 509 |
|
| 510 |
@app.get("/suggest")
|
| 511 |
async def get_suggestions(q: str = Query(..., min_length=1)):
|
| 512 |
+
"""<5ms Autocomplete with Prefix & Fuzzy Fallback"""
|
| 513 |
q = q.lower().strip()
|
| 514 |
+
cached = await asyncio.to_thread(db.get_cache, f"sug_{q}")
|
| 515 |
+
if cached: return cached
|
| 516 |
+
|
| 517 |
+
idx = bisect.bisect_left(SUGGESTIONS_TRIE, q)
|
| 518 |
+
matches = [SUGGESTIONS_TRIE[i] for i in range(idx, min(idx + 10, len(SUGGESTIONS_TRIE))) if SUGGESTIONS_TRIE[i].startswith(q)][:5]
|
| 519 |
+
|
| 520 |
+
if not matches and len(q) > 3:
|
| 521 |
+
corrected = correct_typo(q.split()[-1])
|
| 522 |
+
if corrected != q.split()[-1]:
|
| 523 |
+
matches.append(q.replace(q.split()[-1], corrected))
|
| 524 |
+
|
| 525 |
+
res = {"query": q, "suggestions": matches}
|
| 526 |
+
await asyncio.to_thread(db.set_cache, f"sug_{q}", res, 3600)
|
| 527 |
+
return res
|
| 528 |
|
| 529 |
@app.get("/search")
|
| 530 |
+
async def pure_search(q: str = Query(..., max_length=150)):
|
| 531 |
+
q = q.strip()
|
| 532 |
+
cached = await asyncio.to_thread(db.get_cache, f"search_{q}")
|
| 533 |
+
if cached: return cached
|
| 534 |
+
|
| 535 |
+
results, conf, intent = await execute_search(q)
|
| 536 |
+
res = {"request_id": str(uuid.uuid4()), "query": q, "intent": intent, "confidence": conf, "results": results}
|
| 537 |
+
await asyncio.to_thread(db.set_cache, f"search_{q}", res, 1800)
|
| 538 |
+
return res
|
| 539 |
|
| 540 |
@app.get("/aimode")
|
| 541 |
async def ai_mode_short(q: str = Query(...)):
|
| 542 |
+
results, _, _ = await execute_search(q)
|
| 543 |
+
if not results: return {"ai_response": "No data found.", "results": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
|
| 545 |
+
context = " ".join([r['snippet'] for r in results[:2]])
|
| 546 |
+
prompt = f"<|im_start|>system\nYou are a concise AI. Answer in 1 sentence. End of instructions.<|im_end|>\n<|im_start|>user\nContext: {context}\nQuery: {q}<|im_end|>\n<|im_start|>assistant\n"
|
| 547 |
+
|
| 548 |
+
METRICS["ai_calls"] += 1
|
| 549 |
+
ai_reply = await asyncio.to_thread(AI_ENGINE.generate, prompt, 50)
|
| 550 |
+
return {"ai_response": ai_reply, "results": results}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
|
| 552 |
@app.get("/aimodechat")
|
| 553 |
+
async def ai_mode_chat_stream(q: str = Query(...)):
|
| 554 |
+
"""Semantic RAG with Streaming Output"""
|
| 555 |
+
results, _, intent = await execute_search(q)
|
| 556 |
+
if not results: return StreamingResponse(iter(["No results."]), media_type="text/plain")
|
| 557 |
+
|
| 558 |
+
tokens = fast_tokenize(q)
|
| 559 |
+
tasks = [extract_html(doc["url"], tokens) for doc in results[:2]]
|
| 560 |
+
chunks = await asyncio.gather(*tasks)
|
| 561 |
+
|
| 562 |
+
context = ""
|
| 563 |
+
for i, chunk in enumerate(chunks):
|
| 564 |
+
if chunk: context += f"[{i+1}] {chunk}\n"
|
| 565 |
|
| 566 |
+
prompt = f"<|im_start|>system\nYou are an Expert AI Assistant. Use the provided Context to answer clearly. Cite sources as [1] or [2]. No repetition.<|im_end|>\n<|im_start|>user\nContext:\n{context}\nQuery: {q}<|im_end|>\n<|im_start|>assistant\n"
|
| 567 |
+
|
| 568 |
+
METRICS["ai_calls"] += 1
|
| 569 |
+
|
| 570 |
+
async def event_generator():
|
| 571 |
+
for token in AI_ENGINE.generate(prompt, max_tokens=150, stream=True):
|
| 572 |
+
yield token.encode('utf-8')
|
| 573 |
+
|
| 574 |
+
return StreamingResponse(event_generator(), media_type="text/plain")
|
| 575 |
+
|
| 576 |
+
@app.post("/click")
|
| 577 |
+
async def record_click(query: str = Form(...), url: str = Form(...)):
|
| 578 |
+
"""Learning to Rank Endpoint"""
|
| 579 |
+
await asyncio.to_thread(db.record_click, query.lower(), url)
|
| 580 |
+
return {"status": "recorded"}
|
| 581 |
+
|
| 582 |
+
@app.get("/metrics")
|
| 583 |
+
async def get_metrics():
|
| 584 |
+
c = db.get_conn().cursor()
|
| 585 |
+
c.execute("SELECT COUNT(*) FROM analytics")
|
| 586 |
+
total_searches = c.fetchone()[0]
|
| 587 |
+
|
| 588 |
+
import psutil
|
| 589 |
+
process = psutil.Process(os.getpid())
|
| 590 |
+
return {
|
| 591 |
+
"status": "Enterprise Online",
|
| 592 |
+
"uptime_sec": round(time.time() - METRICS["uptime_start"], 2),
|
| 593 |
+
"ram_mb": round(process.memory_info().rss / 1024 / 1024, 2),
|
| 594 |
+
"total_searches_db": total_searches,
|
| 595 |
+
"searches_this_session": METRICS["searches"],
|
| 596 |
+
"ai_invocations": METRICS["ai_calls"]
|
| 597 |
+
}
|
| 598 |
|
| 599 |
if __name__ == "__main__":
|
| 600 |
+
uvicorn.run(
|
| 601 |
+
"app:app",
|
| 602 |
+
host="0.0.0.0",
|
| 603 |
+
port=7860,
|
| 604 |
+
workers=1,
|
| 605 |
+
loop="uvloop" if HAS_GGUF else "asyncio",
|
| 606 |
+
http="httptools",
|
| 607 |
+
access_log=False
|
| 608 |
+
)
|