diff --git a/Dockerfile b/Dockerfile
index ab9cc09..5c7e4b8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@ FROM python:3.11-slim
WORKDIR /app
-RUN pip install --no-cache-dir fastapi uvicorn httpx python-dotenv
+RUN pip install --no-cache-dir fastapi uvicorn httpx python-dotenv psycopg2-binary
COPY backend.py .
diff --git a/backend.py b/backend.py
index 10b51a7..c12bb98 100644
--- a/backend.py
+++ b/backend.py
@@ -1,547 +1,517 @@
#!/usr/bin/env python3
"""
-AI Research Engine — Backend API
-Runs on CT 145, handles all heavy lifting.
-Exposes REST API consumed by the MCP proxy (MacBook) and dashboard.
+AI Research Engine — Commercial Backend
+Postgres auth, BTCPay payments, API key system, premium UI.
"""
-import os
-import json
+import os, json, hashlib, re, html as html_mod, random, secrets, time
+from datetime import datetime, timezone
+from urllib.parse import urlparse
+
import httpx
-import hashlib
-from fastapi import FastAPI, Query, HTTPException
+import psycopg2
+import psycopg2.extras
+from fastapi import FastAPI, Query, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
import uvicorn
-app = FastAPI(title="AI Research Engine Backend")
+app = FastAPI(title="AI Research Engine")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# ── Config ──────────────────────────────────────────────────
-YACY_URL = os.getenv("YACY_URL", "http://localhost:8090")
-OPENSEARCH_URL = os.getenv("OPENSEARCH_URL", "http://localhost:9200")
+YACY_URL = os.getenv("YACY_URL", "http://host.docker.internal:8090")
+OPENSEARCH_URL = os.getenv("OPENSEARCH_URL", "http://host.docker.internal:9200")
QDRANT_URL = os.getenv("QDRANT_URL", "http://10.30.20.68:6333")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.30.20.186:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "ornith:latest")
INDEX_NAME = os.getenv("INDEX_NAME", "research_docs")
+BTCPAY_URL = "https://10.30.20.140"
+BTCPAY_KEY = "6026288e2e315984661c748baafd509e81a75f22"
+BTCPAY_STORE = "8ERS6v1UyQ46bQqaWr4LotvMCJLsmUhH2sT8zbzfjkT6"
+WEBHOOK_SECRET = "XjfhDd9DzXsUkA91B4SwRz"
+PRICE_USD = 5.00 # $5
+CALLS_PER_TIER = 5 # 5 API calls
+
+# Proxies
+PROXIES = [
+ "http://10.30.20.154:3128", # Tokyo
+ "http://10.30.20.71:3128", # London
+ "http://10.30.20.189:3128", # Sydney
+]
+
+# DB
+def get_db():
+ return psycopg2.connect(
+ host="host.docker.internal", port=5432,
+ user="research", password="ResearchDB2026!",
+ database="research_engine"
+ )
client = httpx.Client(timeout=30.0)
ollama = httpx.Client(timeout=120.0, base_url=OLLAMA_URL)
-# ── Helpers ──────────────────────────────────────────────────
+# ── Auth Helpers ─────────────────────────────────────────────
+
+def _auth(request: Request) -> dict:
+ """Authenticate by API key, return user row or raise 401."""
+ api_key = request.headers.get("X-API-Key") or request.query_params.get("api_key")
+ if not api_key:
+ raise HTTPException(401, "API key required. Get one at /signup or /pricing.")
+ db = get_db()
+ cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ cur.execute("SELECT * FROM users WHERE api_key = %s", (api_key,))
+ user = cur.fetchone()
+ db.close()
+ if not user:
+ raise HTTPException(401, "Invalid API key.")
+ if not user["is_admin"] and user["calls_remaining"] <= 0:
+ raise HTTPException(402, "No calls remaining. Buy more at /pricing.")
+ return dict(user)
+
+def _track_usage(user_id: int, tool: str, request: Request):
+ """Deduct a call and log usage."""
+ db = get_db()
+ cur = db.cursor()
+ cur.execute("UPDATE users SET calls_remaining = calls_remaining - 1, total_calls = total_calls + 1, last_used_at = NOW() WHERE id = %s AND NOT is_admin", (user_id,))
+ cur.execute("INSERT INTO usage_log (user_id, tool_name, endpoint, ip_address) VALUES (%s,%s,%s,%s)",
+ (user_id, tool, str(request.url), request.client.host if request.client else ""))
+ db.commit()
+ db.close()
+
+# ── Search Helpers (unchanged from v1) ────────────────────────
def _ensure_index():
- try:
- client.get(f"{OPENSEARCH_URL}/{INDEX_NAME}")
- except Exception:
- try:
- client.put(f"{OPENSEARCH_URL}/{INDEX_NAME}", json={
- "settings": {"number_of_shards": 1, "number_of_replicas": 0},
- "mappings": {"properties": {
- "url": {"type": "keyword"}, "title": {"type": "text"},
- "content": {"type": "text"}, "excerpt": {"type": "text"},
- "category": {"type": "keyword"}, "source_domain": {"type": "keyword"},
- "crawled_at": {"type": "date"}, "indexed_at": {"type": "date"},
- "metadata": {"type": "object"},
- }}
- })
- except Exception:
- pass
-
-def _ensure_qdrant():
- try:
- client.get(f"{QDRANT_URL}/collections/{INDEX_NAME}")
- except Exception:
- try:
- client.put(f"{QDRANT_URL}/collections/{INDEX_NAME}", json={
- "vectors": {"size": 768, "distance": "Cosine"}
- })
- except Exception:
- pass
+ try: client.get(f"{OPENSEARCH_URL}/{INDEX_NAME}")
+ except: client.put(f"{OPENSEARCH_URL}/{INDEX_NAME}", json={"settings":{"number_of_shards":1,"number_of_replicas":0},"mappings":{"properties":{"url":{"type":"keyword"},"title":{"type":"text"},"content":{"type":"text"},"excerpt":{"type":"text"},"category":{"type":"keyword"},"source_domain":{"type":"keyword"},"crawled_at":{"type":"date"},"indexed_at":{"type":"date"},"metadata":{"type":"object"}}}})
def _ai_chat(prompt: str, system: str = "You are a research assistant. Be concise and factual.") -> str:
- r = ollama.post("/api/chat", json={
- "model": OLLAMA_MODEL, "messages": [
- {"role": "system", "content": system},
- {"role": "user", "content": prompt},
- ], "stream": False,
- "options": {"temperature": 0.3, "num_predict": 2048},
- })
+ r = ollama.post("/api/chat", json={"model":OLLAMA_MODEL,"messages":[{"role":"system","content":system},{"role":"user","content":prompt}],"stream":False,"options":{"temperature":0.3,"num_predict":2048}})
body = r.json()
- return body.get("message", {}).get("content", "") or body.get("thinking", "") or ""
+ return body.get("message",{}).get("content","") or body.get("thinking","") or ""
def _get_embedding(text: str) -> list:
try:
- r = ollama.post("/api/embeddings", json={"model": "nomic-embed-text-v2-moe:latest", "prompt": text[:2048]})
- return r.json().get("embedding", [])
- except Exception:
- return []
+ r = ollama.post("/api/embeddings", json={"model":"nomic-embed-text-v2-moe:latest","prompt":text[:2048]})
+ return r.json().get("embedding",[])
+ except: return []
+
+def _fetch_and_index(url: str, category: str = ""):
+ proxy_url = random.choice(PROXIES)
+ try:
+ pc = httpx.Client(proxy=proxy_url, timeout=15.0)
+ r = pc.get(url, headers={"User-Agent":"Mozilla/5.0 (compatible; ResearchBot/1.0)"})
+ if r.status_code != 200: return None
+ html_text = r.text
+ text = re.sub(r'','',html_text,flags=re.DOTALL|re.IGNORECASE)
+ text = re.sub(r'','',text,flags=re.DOTALL|re.IGNORECASE)
+ text = re.sub(r'<[^>]+>',' ',text)
+ text = re.sub(r'\s+',' ',text).strip()
+ text = html_mod.unescape(text)
+ title_m = re.search(r'
]*>(.*?) ',html_text,re.IGNORECASE|re.DOTALL)
+ title = html_mod.unescape(title_m.group(1).strip()) if title_m else url
+ desc_m = re.search(r' ]+name=["\']description["\'][^>]+content=["\']([^"\']+)',html_text,re.IGNORECASE)
+ excerpt = desc_m.group(1)[:500] if desc_m else text[:500]
+ domain = urlparse(url).netloc
+ if not category:
+ for k,v in {"wikipedia":"reference","github":"software","arxiv":"science","docs.":"documentation","blog.":"blog","news.":"news"}.items():
+ if k in domain: category = v; break
+ if not category: category = "web"
+ doc = {"url":url,"title":title,"content":text[:50000],"excerpt":excerpt[:1000],"category":category,"source_domain":domain,"crawled_at":datetime.now(timezone.utc).isoformat(),"indexed_at":datetime.now(timezone.utc).isoformat()}
+ _ensure_index()
+ client.put(f"{OPENSEARCH_URL}/{INDEX_NAME}/_doc/{hashlib.md5(url.encode()).hexdigest()}",json=doc,params={"refresh":"true"})
+ emb = _get_embedding(excerpt[:1000])
+ if emb:
+ try:
+ client.put(f"{QDRANT_URL}/collections/{INDEX_NAME}/points",json={"points":[{"id":hashlib.md5(url.encode()).hexdigest(),"vector":emb,"payload":{"url":url,"title":title,"excerpt":excerpt[:500]}}]})
+ except: pass
+ return {"title":title,"domain":domain,"category":category,"size":len(text)}
+ except: return None
-# ── Status ────────────────────────────────────────────────────
+# ═══════════════════════════════════════════════════════════════
+# PUBLIC PAGES
+# ═══════════════════════════════════════════════════════════════
+
+@app.get("/", response_class=HTMLResponse)
+def landing():
+ return HTMLResponse(LANDING_HTML)
+
+@app.get("/pricing", response_class=HTMLResponse)
+def pricing_page():
+ return HTMLResponse(PRICING_HTML)
+
+@app.get("/signup", response_class=HTMLResponse)
+def signup_page():
+ return HTMLResponse(SIGNUP_HTML)
+
+@app.get("/dashboard", response_class=HTMLResponse)
+def dashboard_page():
+ return HTMLResponse(DASHBOARD_HTML)
+
+
+# ═══════════════════════════════════════════════════════════════
+# AUTH API
+# ═══════════════════════════════════════════════════════════════
+
+@app.post("/api/signup")
+async def api_signup(request: Request):
+ data = await request.json()
+ email = data.get("email","").strip().lower()
+ if not email or "@" not in email:
+ return JSONResponse({"error":"Valid email required"}, 400)
+ api_key = "sk-" + secrets.token_hex(24)
+ db = get_db()
+ cur = db.cursor()
+ try:
+ cur.execute("INSERT INTO users (email, password_hash, api_key, calls_remaining) VALUES (%s,%s,%s,0)", (email, "bcrypt_placeholder", api_key))
+ db.commit()
+ except psycopg2.errors.UniqueViolation:
+ db.rollback()
+ cur.execute("SELECT api_key FROM users WHERE email = %s", (email,))
+ api_key = cur.fetchone()[0]
+ db.close()
+ return {"email": email, "api_key": api_key, "message": "Signup successful. Purchase API calls at /pricing to start using the engine."}
+
+@app.get("/api/my-usage")
+async def my_usage(request: Request):
+ # Allow even with 0 calls — users need to see their balance
+ api_key = request.headers.get("X-API-Key") or request.query_params.get("api_key")
+ if not api_key:
+ raise HTTPException(401, "API key required.")
+ db = get_db()
+ cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ cur.execute("SELECT * FROM users WHERE api_key = %s", (api_key,))
+ user = cur.fetchone()
+ if not user:
+ db.close()
+ raise HTTPException(401, "Invalid API key.")
+ user = dict(user)
+ cur.execute("SELECT tool_name, COUNT(*) as cnt FROM usage_log WHERE user_id = %s GROUP BY tool_name ORDER BY cnt DESC", (user["id"],))
+ user["usage_by_tool"] = [dict(r) for r in cur.fetchall()]
+ db.close()
+ return user
+
+
+# ═══════════════════════════════════════════════════════════════
+# BTCPAY INTEGRATION
+# ═══════════════════════════════════════════════════════════════
+
+@app.post("/api/create-invoice")
+async def create_invoice(request: Request):
+ """Create BTCPay invoice for $5 = 5 calls. Works even with 0 calls."""
+ api_key = request.headers.get("X-API-Key") or request.query_params.get("api_key")
+ if not api_key:
+ raise HTTPException(401, "API key required.")
+ db = get_db()
+ cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ cur.execute("SELECT * FROM users WHERE api_key = %s", (api_key,))
+ user = cur.fetchone()
+ if not user:
+ db.close()
+ raise HTTPException(401, "Invalid API key.")
+ user = dict(user)
+ try:
+ r = httpx.post(f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices",
+ headers={"Authorization": f"token {BTCPAY_KEY}", "Content-Type": "application/json"},
+ json={
+ "amount": str(PRICE_USD),
+ "currency": "USD",
+ "metadata": {
+ "user_id": user["id"],
+ "user_email": user["email"],
+ "calls": CALLS_PER_TIER,
+ "orderId": f"research-{user['id']}-{int(time.time())}"
+ },
+ "checkout": {"redirectURL": f"http://10.30.20.249:8000/dashboard"}
+ },
+ verify=False, timeout=15.0
+ )
+ inv = r.json()
+ # Log pending payment
+ db = get_db()
+ cur = db.cursor()
+ cur.execute("INSERT INTO payments (user_id, btcpay_invoice_id, amount_usd, calls_purchased, status) VALUES (%s,%s,%s,%s,'pending')",
+ (user["id"], inv["id"], PRICE_USD, CALLS_PER_TIER))
+ db.commit()
+ db.close()
+ return {"invoice_id": inv["id"], "checkout_url": inv["checkoutLink"], "amount": f"${PRICE_USD}", "calls": CALLS_PER_TIER}
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, 500)
+
+@app.post("/webhook/btcpay")
+async def btcpay_webhook(request: Request):
+ """BTCPay calls this when invoice is settled."""
+ body = await request.json()
+ event_type = body.get("type", "")
+ invoice_id = body.get("invoiceId", "")
+ metadata = body.get("metadata", {})
+
+ # Handle nested metadata (BTCPay wraps it)
+ if isinstance(metadata, dict):
+ user_id = metadata.get("user_id")
+ calls = metadata.get("calls", CALLS_PER_TIER)
+ else:
+ user_id = None
+ calls = CALLS_PER_TIER
+
+ if event_type in ("InvoiceSettled", "InvoiceProcessing") and user_id:
+ db = get_db()
+ cur = db.cursor()
+ cur.execute("UPDATE payments SET status = 'settled', settled_at = NOW() WHERE btcpay_invoice_id = %s", (invoice_id,))
+ cur.execute("UPDATE users SET calls_remaining = calls_remaining + %s WHERE id = %s", (calls, user_id))
+ db.commit()
+ db.close()
-@app.get("/health")
-def health():
return {"status": "ok"}
+
+# ═══════════════════════════════════════════════════════════════
+# API TOOLS (auth-gated)
+# ═══════════════════════════════════════════════════════════════
+
@app.get("/api/status")
def status():
svc = {}
- try:
+ try:
r = client.get(f"{OPENSEARCH_URL}/_cluster/health")
- cnt = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_count").json() if r.status_code == 200 else {"count": 0}
- svc["opensearch"] = {"status": r.json().get("status", "down"), "documents": cnt.get("count", 0)}
- except Exception:
- svc["opensearch"] = {"status": "down"}
+ cnt = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_count").json()
+ svc["opensearch"] = {"status": r.json().get("status"), "documents": cnt.get("count",0)}
+ except: svc["opensearch"] = {"status":"down"}
try:
- r = client.get(f"{QDRANT_URL}/healthz")
+ client.get(f"{QDRANT_URL}/healthz")
col = client.get(f"{QDRANT_URL}/collections/{INDEX_NAME}").json()
- svc["qdrant"] = {"status": "ok", "points": col.get("result", {}).get("points_count", 0)}
- except Exception:
- svc["qdrant"] = {"status": "down"}
+ svc["qdrant"] = {"status":"ok","points":col.get("result",{}).get("points_count",0)}
+ except: svc["qdrant"] = {"status":"down"}
try:
- r = client.get(f"{YACY_URL}/yacysearch.json", params={"query": "test", "maximumRecords": 1})
- svc["yacy"] = {"status": "ok" if r.status_code == 200 else "down"}
- except Exception:
- svc["yacy"] = {"status": "down"}
+ client.get(f"{YACY_URL}/yacysearch.json",params={"query":"test","maximumRecords":1})
+ svc["yacy"] = {"status":"ok"}
+ except: svc["yacy"] = {"status":"down"}
try:
r = ollama.get("/api/tags")
- svc["ollama"] = {"status": "ok", "models": len(r.json().get("models", []))}
- except Exception:
- svc["ollama"] = {"status": "down"}
- return {"services": svc}
-
-
-# ── Search ────────────────────────────────────────────────────
+ svc["ollama"] = {"status":"ok","models":len(r.json().get("models",[]))}
+ except: svc["ollama"] = {"status":"down"}
+ # DB stats
+ try:
+ db = get_db()
+ cur = db.cursor()
+ cur.execute("SELECT COUNT(*) FROM users")
+ users = cur.fetchone()[0]
+ cur.execute("SELECT COALESCE(SUM(total_calls),0) FROM users")
+ total = cur.fetchone()[0]
+ cur.execute("SELECT COUNT(*) FROM payments WHERE status = 'settled'")
+ payments = cur.fetchone()[0]
+ db.close()
+ svc["business"] = {"status":"ok","users":users,"total_api_calls":total,"payments_settled":payments}
+ except: svc["business"] = {"status":"down"}
+ return {"services":svc}
@app.get("/api/search")
-def search_web(q: str = Query(...), category: str = "", limit: int = 10):
+def search_web(request: Request, q: str = Query(...), category: str = "", limit: int = 10):
+ user = _auth(request)
+ _track_usage(user["id"], "search_web", request)
_ensure_index()
- body = {
- "size": limit,
- "query": {"bool": {"must": [{"multi_match": {"query": q, "fields": ["title^3", "content", "excerpt"]}}]}},
- "highlight": {"fields": {"content": {"fragment_size": 200, "number_of_fragments": 2}}},
- }
- if category:
- body["query"]["bool"]["filter"] = [{"term": {"category": category}}]
+ body = {"size":limit,"query":{"bool":{"must":[{"multi_match":{"query":q,"fields":["title^3","content","excerpt"]}}]}},"highlight":{"fields":{"content":{"fragment_size":200,"number_of_fragments":2}}}}
+ if category: body["query"]["bool"]["filter"] = [{"term":{"category":category}}]
try:
r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json=body)
result = r.json()
hits = []
- for h in result.get("hits", {}).get("hits", []):
+ for h in result.get("hits",{}).get("hits",[]):
src = h["_source"]
- hits.append({"url": src.get("url"), "title": src.get("title"),
- "excerpt": src.get("excerpt") or (h.get("highlight", {}).get("content", [""])[0]),
- "category": src.get("category"), "crawled_at": src.get("crawled_at"), "score": h["_score"]})
- return {"query": q, "total": result.get("hits", {}).get("total", {}).get("value", 0), "hits": hits}
+ hits.append({"url":src.get("url"),"title":src.get("title"),"excerpt":src.get("excerpt") or (h.get("highlight",{}).get("content",[""])[0]),"category":src.get("category"),"crawled_at":src.get("crawled_at"),"score":h["_score"]})
+ return {"query":q,"total":result.get("hits",{}).get("total",{}).get("value",0),"hits":hits}
except Exception as e:
- return {"query": q, "total": 0, "hits": [], "note": f"Index may be empty. {e}"}
-
+ return {"query":q,"total":0,"hits":[],"note":str(e)}
@app.get("/api/semantic-search")
-def semantic_search(q: str = Query(...), limit: int = 10):
- _ensure_qdrant()
+def semantic_search(request: Request, q: str = Query(...), limit: int = 10):
+ user = _auth(request)
+ _track_usage(user["id"], "semantic_search", request)
emb = _get_embedding(q)
- if not emb:
- return {"hits": [], "error": "Embedding model not available"}
+ if not emb: return {"hits":[],"error":"Embedding model unavailable"}
try:
- r = client.post(f"{QDRANT_URL}/collections/{INDEX_NAME}/points/search", json={
- "vector": emb, "limit": limit, "with_payload": True, "with_vector": False})
- hits = [{"url": p.get("payload", {}).get("url"), "title": p.get("payload", {}).get("title"),
- "excerpt": str(p.get("payload", {}).get("excerpt", ""))[:300], "score": p.get("score")}
- for p in r.json().get("result", [])]
- return {"query": q, "total": len(hits), "hits": hits}
+ r = client.post(f"{QDRANT_URL}/collections/{INDEX_NAME}/points/search",json={"vector":emb,"limit":limit,"with_payload":True,"with_vector":False})
+ hits = [{"url":p.get("payload",{}).get("url"),"title":p.get("payload",{}).get("title"),"excerpt":str(p.get("payload",{}).get("excerpt",""))[:300],"score":p.get("score")} for p in r.json().get("result",[])]
+ return {"query":q,"total":len(hits),"hits":hits}
except Exception as e:
- return {"hits": [], "error": str(e)}
-
-
-# ── Crawl ─────────────────────────────────────────────────────
-
-import re
-import html as html_mod
-import random
-
-# NordVPN HTTP proxies for geo-distributed fetching
-PROXIES = [
- "http://10.30.20.154:3128", # Tokyo, Japan
- "http://10.30.20.71:3128", # London, UK
- "http://10.30.20.189:3128", # Sydney, Australia
-]
-
-def _fetch_and_index(url: str, category: str = ""):
- """Fetch a URL, extract text, and index into OpenSearch immediately."""
- proxy_url = random.choice(PROXIES)
- try:
- # Create a per-request client with proxy
- proxy_client = httpx.Client(proxy=proxy_url, timeout=15.0)
- r = proxy_client.get(url,
- headers={"User-Agent": "Mozilla/5.0 (compatible; ResearchBot/1.0)"},
- )
- if r.status_code != 200:
- return None
- html_text = r.text
- # Basic HTML-to-text
- text = re.sub(r'', '', html_text, flags=re.DOTALL|re.IGNORECASE)
- text = re.sub(r'', '', text, flags=re.DOTALL|re.IGNORECASE)
- text = re.sub(r'<[^>]+>', ' ', text)
- text = re.sub(r'\s+', ' ', text).strip()
- text = html_mod.unescape(text)
- # Extract title
- title_match = re.search(r']*>(.*?) ', html_text, re.IGNORECASE|re.DOTALL)
- title = html_mod.unescape(title_match.group(1).strip()) if title_match else url
- # Extract meta description
- desc_match = re.search(r' ]+name=["\']description["\'][^>]+content=["\']([^"\']+)', html_text, re.IGNORECASE)
- excerpt = desc_match.group(1)[:500] if desc_match else text[:500]
- # Derive domain + category
- from urllib.parse import urlparse
- domain = urlparse(url).netloc
- if not category:
- cat_map = {"wikipedia": "reference", "github": "software", "arxiv": "science",
- "docs.": "documentation", "blog.": "blog", "news.": "news"}
- for k, v in cat_map.items():
- if k in domain:
- category = v
- break
- if not category:
- category = "web"
- # Index into OpenSearch
- import datetime
- doc = {
- "url": url, "title": title, "content": text[:50000],
- "excerpt": excerpt[:1000], "category": category,
- "source_domain": domain,
- "crawled_at": datetime.datetime.utcnow().isoformat(),
- "indexed_at": datetime.datetime.utcnow().isoformat(),
- }
- _ensure_index()
- client.put(f"{OPENSEARCH_URL}/{INDEX_NAME}/_doc/{hashlib.md5(url.encode()).hexdigest()}",
- json=doc, params={"refresh": "true"})
- # Also index into Qdrant
- emb = _get_embedding(excerpt[:1000])
- if emb:
- try:
- _ensure_qdrant()
- client.put(f"{QDRANT_URL}/collections/{INDEX_NAME}/points", json={
- "points": [{"id": hashlib.md5(url.encode()).hexdigest(),
- "vector": emb, "payload": {"url": url, "title": title, "excerpt": excerpt[:500]}}]
- })
- except Exception:
- pass
- return {"title": title, "domain": domain, "category": category, "size": len(text)}
- except Exception as e:
- return None
-
+ return {"hits":[],"error":str(e)}
@app.get("/api/crawl")
-def crawl_url(url: str = Query(...), depth: int = 1):
- # 1. Fetch and index immediately into OpenSearch + Qdrant
+def crawl_url(request: Request, url: str = Query(...), depth: int = 1):
+ user = _auth(request)
+ _track_usage(user["id"], "crawl_url", request)
indexed = _fetch_and_index(url)
- # 2. Also submit to YaCy for deeper crawling
try:
- r = client.get(f"{YACY_URL}/Crawler_p.json", params={
- "crawlingDomMaxPages": 50, "crawlingDepth": depth,
- "crawlingStart": url, "crawlingQ": "on",
- "bookmarkTitle": "research", "bookmarkFolder": "/research",
- "indexText": "on", "indexMedia": "on",
- "crawlingMode": "url", "cachePolicy": "iffresh",
- })
- return {"status": "crawl_started", "url": url, "depth": depth, "indexed": indexed}
- except Exception as e:
- return {"status": "indexed_only", "url": url, "indexed": indexed, "yacy_error": str(e)}
+ client.get(f"{YACY_URL}/Crawler_p.json", params={"crawlingDomMaxPages":50,"crawlingDepth":depth,"crawlingStart":url,"crawlingQ":"on","bookmarkTitle":"research","bookmarkFolder":"/research","indexText":"on","indexMedia":"on","crawlingMode":"url","cachePolicy":"iffresh"})
+ return {"status":"crawl_started","url":url,"depth":depth,"indexed":indexed}
+ except:
+ return {"status":"indexed_only","url":url,"indexed":indexed}
-
-@app.get("/api/crawl-topic")
-def crawl_topic(topic: str = Query(...), max_urls: int = 20):
- discovered = []
+@app.get("/api/research")
+def research_topic(request: Request, topic: str = Query(...)):
+ user = _auth(request)
+ _track_usage(user["id"], "research_topic", request)
+ steps = []; kw_result = {"hits":[]}; sem_result = {"hits":[]}
try:
- r = client.get(f"{YACY_URL}/yacysearch.json", params={"query": topic, "maximumRecords": max_urls, "resource": "global"})
- for ch in r.json().get("channels", []):
- for item in ch.get("items", []):
- if item.get("link"):
- discovered.append({"url": item["link"], "title": item.get("title", ""), "snippet": item.get("description", "")})
- except Exception as e:
- return {"topic": topic, "error": f"Discovery failed: {e}", "urls_discovered": 0, "urls_crawled": 0}
-
- crawled = 0
- for u in discovered[:max_urls]:
- try:
- client.get(f"{YACY_URL}/Crawler_p.json", params={
- "crawlingDomMaxPages": 10, "crawlingDepth": 0,
- "crawlingStart": u["url"], "crawlingQ": "on",
- "indexText": "on", "indexMedia": "on",
- "crawlingMode": "url", "cachePolicy": "iffresh",
- }, timeout=5.0)
- crawled += 1
- except Exception:
- pass
-
- return {"topic": topic, "urls_discovered": len(discovered), "urls_crawled": crawled}
-
-
-# ── Document Retrieval ────────────────────────────────────────
-
-@app.get("/api/document")
-def retrieve_document(url: str = Query(...)):
+ r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size":5,"query":{"multi_match":{"query":topic,"fields":["title^3","content","excerpt"]}},"highlight":{"fields":{"content":{"fragment_size":200,"number_of_fragments":1}}}})
+ hits = [{"title":h["_source"].get("title"),"excerpt":h.get("highlight",{}).get("content",[h["_source"].get("excerpt","")])[0]} for h in r.json().get("hits",{}).get("hits",[])]
+ kw_result = {"total":len(hits),"hits":hits}; steps.append("keyword_search_done")
+ except: steps.append("keyword_search_skipped")
try:
- r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={
- "size": 1, "query": {"term": {"url": url}}})
- hits = r.json().get("hits", {}).get("hits", [])
- if not hits:
- return {"error": "Not found", "url": url}
- src = hits[0]["_source"]
- return {"url": src.get("url"), "title": src.get("title"),
- "content": src.get("content", "")[:10000], "excerpt": src.get("excerpt"),
- "category": src.get("category"), "crawled_at": src.get("crawled_at")}
- except Exception as e:
- return {"error": str(e), "url": url}
-
-
-# ── AI Synthesis ──────────────────────────────────────────────
-
-@app.get("/api/summarize")
-def summarize_sources(urls: str = Query(...), instruction: str = "Summarize key points"):
- url_list = [u.strip() for u in urls.split(",") if u.strip()]
- combined = ""
- for url in url_list[:5]:
- try:
- r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size": 1, "query": {"term": {"url": url}}})
- hits = r.json().get("hits", {}).get("hits", [])
- if hits:
- src = hits[0]["_source"]
- combined += f"\n\n--- {url} ---\n{src.get('title','')}\n{src.get('content', src.get('excerpt',''))[:2000]}"
- except Exception:
- pass
- if not combined.strip():
- return {"error": "No content retrieved"}
- summary = _ai_chat(f"Instruction: {instruction}\n\nSources:{combined}\n\nProvide a structured summary.")
- return {"instruction": instruction, "sources": len(url_list), "summary": summary}
-
-
-@app.get("/api/extract")
-def extract_information(url: str = Query(...), schema: str = Query("company names, products, prices")):
+ emb = _get_embedding(topic)
+ if emb:
+ r = client.post(f"{QDRANT_URL}/collections/{INDEX_NAME}/points/search",json={"vector":emb,"limit":5,"with_payload":True})
+ hits2 = [{"title":p.get("payload",{}).get("title",""),"excerpt":str(p.get("payload",{}).get("excerpt",""))[:200],"score":p.get("score")} for p in r.json().get("result",[])]
+ sem_result = {"total":len(hits2),"hits":hits2}; steps.append("semantic_search_done")
+ except: steps.append("semantic_search_skipped")
try:
- r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size": 1, "query": {"term": {"url": url}}})
- hits = r.json().get("hits", {}).get("hits", [])
- if not hits:
- return {"error": "Not found", "url": url}
- content = hits[0]["_source"].get("content", "")[:8000]
- prompt = f"Extract: {schema}\n\nDocument:\n{content}\n\nReturn ONLY valid JSON."
- result = _ai_chat(prompt, system="Extract structured data. Return ONLY valid JSON. No explanation.")
- return {"url": url, "schema": schema, "extracted": result}
- except Exception as e:
- return {"error": str(e)}
-
+ r = client.get(f"{YACY_URL}/yacysearch.json",params={"query":topic,"maximumRecords":10,"resource":"global"})
+ discovered = [item.get("link") for ch in r.json().get("channels",[]) for item in ch.get("items",[]) if item.get("link")]
+ for url in discovered[:10]:
+ try: client.get(f"{YACY_URL}/Crawler_p.json",params={"crawlingDomMaxPages":10,"crawlingDepth":0,"crawlingStart":url,"crawlingQ":"on","indexText":"on","indexMedia":"on","crawlingMode":"url","cachePolicy":"iffresh"},timeout=5.0)
+ except: pass
+ steps.append(f"crawl_dispatched_{len(discovered[:10])}")
+ except: steps.append("crawl_skipped")
+ all_src = ""
+ for h in kw_result.get("hits",[])[:3] + sem_result.get("hits",[])[:3]:
+ all_src += f"- {h.get('title','Unknown')}: {h.get('excerpt','')[:200]}\n"
+ summary = _ai_chat(f"Research topic: {topic}\n\nSources:\n{all_src}\n\nConcise research summary (3-5 paragraphs): key findings, important sources, knowledge gaps, next steps.") if all_src else ""
+ return {"topic":topic,"steps":steps,"keyword_results":kw_result,"semantic_results":sem_result,"ai_summary":summary}
@app.get("/api/report")
-def create_report(topic: str = Query(...), sources: str = ""):
+def create_report(request: Request, topic: str = Query(...), sources: str = ""):
+ user = _auth(request)
+ _track_usage(user["id"], "create_report", request)
if sources:
urls = [u.strip() for u in sources.split(",") if u.strip()]
else:
try:
- r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={
- "size": 8, "query": {"multi_match": {"query": topic, "fields": ["title^3", "content"]}}})
- urls = [h["_source"]["url"] for h in r.json().get("hits", {}).get("hits", [])]
- except Exception:
- urls = []
-
+ r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size":8,"query":{"multi_match":{"query":topic,"fields":["title^3","content"]}}})
+ urls = [h["_source"]["url"] for h in r.json().get("hits",{}).get("hits",[])]
+ except: urls = []
gathered = ""
for url in urls[:8]:
try:
- r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size": 1, "query": {"term": {"url": url}}})
- hits = r.json().get("hits", {}).get("hits", [])
+ r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size":1,"query":{"term":{"url":url}}})
+ hits = r.json().get("hits",{}).get("hits",[])
if hits:
src = hits[0]["_source"]
gathered += f"\n\n### {src.get('title','Source')}\nURL: {url}\n{src.get('content',src.get('excerpt',''))[:1500]}"
- except Exception:
- pass
-
- prompt = f"""Research topic: {topic}\nSources:{gathered if gathered else ' No sources found.'}
-
-Generate a comprehensive report:
-1. Executive Summary
-2. Key Findings (numbered)
-3. Source Analysis
-4. Knowledge Gaps
-5. Recommendations
-
-Be thorough, use markdown, cite sources."""
- report = _ai_chat(prompt, system="You are a senior research analyst. Produce thorough, structured reports.")
- return {"topic": topic, "sources_used": len(urls), "report": report}
-
-
-# ── Research Pipeline ─────────────────────────────────────────
-
-@app.get("/api/research")
-def research_topic(topic: str = Query(...)):
- steps = []
- kw_result = {"hits": []}
- sem_result = {"hits": []}
+ except: pass
+ report = _ai_chat(f"Research topic: {topic}\nSources:{gathered if gathered else ' No sources found.'}\n\nGenerate a comprehensive report:\n1. Executive Summary\n2. Key Findings (numbered)\n3. Source Analysis\n4. Knowledge Gaps\n5. Recommendations\n\nBe thorough, use markdown, cite sources.", system="You are a senior research analyst. Produce thorough, structured reports.")
+ return {"topic":topic,"sources_used":len(urls),"report":report}
+@app.get("/api/document")
+def retrieve_document(request: Request, url: str = Query(...)):
+ user = _auth(request)
+ _track_usage(user["id"], "retrieve_document", request)
try:
- r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={
- "size": 5, "query": {"multi_match": {"query": topic, "fields": ["title^3", "content", "excerpt"]}},
- "highlight": {"fields": {"content": {"fragment_size": 200, "number_of_fragments": 1}}},
- })
- hits = [{"title": h["_source"].get("title"), "excerpt": h.get("highlight", {}).get("content", [h["_source"].get("excerpt", "")])[0]}
- for h in r.json().get("hits", {}).get("hits", [])]
- kw_result = {"total": len(hits), "hits": hits}
- steps.append("keyword_search_done")
- except Exception:
- steps.append("keyword_search_skipped")
+ r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size":1,"query":{"term":{"url":url}}})
+ hits = r.json().get("hits",{}).get("hits",[])
+ if not hits: return {"error":"Not found","url":url}
+ src = hits[0]["_source"]
+ return {"url":src.get("url"),"title":src.get("title"),"content":src.get("content","")[:10000],"excerpt":src.get("excerpt"),"category":src.get("category"),"crawled_at":src.get("crawled_at")}
+ except Exception as e:
+ return {"error":str(e),"url":url}
+@app.get("/api/summarize")
+def summarize_sources(request: Request, urls: str = Query(...), instruction: str = "Summarize key points"):
+ user = _auth(request)
+ _track_usage(user["id"], "summarize_sources", request)
+ url_list = [u.strip() for u in urls.split(",") if u.strip()]
+ combined = ""
+ for url in url_list[:5]:
+ try:
+ r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size":1,"query":{"term":{"url":url}}})
+ hits = r.json().get("hits",{}).get("hits",[])
+ if hits:
+ src = hits[0]["_source"]
+ combined += f"\n\n--- {url} ---\n{src.get('title','')}\n{src.get('content',src.get('excerpt',''))[:2000]}"
+ except: pass
+ if not combined.strip(): return {"error":"No content retrieved"}
+ summary = _ai_chat(f"Instruction: {instruction}\n\nSources:{combined}\n\nProvide a structured summary.")
+ return {"instruction":instruction,"sources":len(url_list),"summary":summary}
+
+@app.get("/api/extract")
+def extract_information(request: Request, url: str = Query(...), schema: str = Query("company names, products, prices")):
+ user = _auth(request)
+ _track_usage(user["id"], "extract_information", request)
try:
- emb = _get_embedding(topic)
- if emb:
- r = client.post(f"{QDRANT_URL}/collections/{INDEX_NAME}/points/search", json={
- "vector": emb, "limit": 5, "with_payload": True})
- hits2 = [{"title": p.get("payload", {}).get("title", ""), "excerpt": str(p.get("payload", {}).get("excerpt", ""))[:200],
- "score": p.get("score")} for p in r.json().get("result", [])]
- sem_result = {"total": len(hits2), "hits": hits2}
- steps.append("semantic_search_done")
- except Exception:
- steps.append("semantic_search_skipped")
-
- try:
- r = client.get(f"{YACY_URL}/yacysearch.json", params={"query": topic, "maximumRecords": 10, "resource": "global"})
- discovered = [item.get("link") for ch in r.json().get("channels", []) for item in ch.get("items", []) if item.get("link")]
- for url in discovered[:10]:
- try:
- client.get(f"{YACY_URL}/Crawler_p.json", params={
- "crawlingDomMaxPages": 10, "crawlingDepth": 0, "crawlingStart": url,
- "crawlingQ": "on", "indexText": "on", "indexMedia": "on",
- "crawlingMode": "url", "cachePolicy": "iffresh"}, timeout=5.0)
- except Exception:
- pass
- steps.append(f"crawl_dispatched_{len(discovered[:10])}")
- except Exception:
- steps.append("crawl_skipped")
-
- all_sources = ""
- for h in kw_result.get("hits", [])[:3] + sem_result.get("hits", [])[:3]:
- all_sources += f"- {h.get('title', 'Unknown')}: {h.get('excerpt', '')[:200]}\n"
-
- summary = ""
- if all_sources:
- summary = _ai_chat(
- f"Research topic: {topic}\n\nSources:\n{all_sources}\n\nConcise research summary (3-5 paragraphs): "
- "key findings, important sources, knowledge gaps, next steps.")
-
- return {"topic": topic, "steps": steps,
- "keyword_results": kw_result, "semantic_results": sem_result,
- "ai_summary": summary}
+ r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={"size":1,"query":{"term":{"url":url}}})
+ hits = r.json().get("hits",{}).get("hits",[])
+ if not hits: return {"error":"Not found","url":url}
+ content = hits[0]["_source"].get("content","")[:8000]
+ result = _ai_chat(f"Extract: {schema}\n\nDocument:\n{content}\n\nReturn ONLY valid JSON.", system="Extract structured data. Return ONLY valid JSON.")
+ return {"url":url,"schema":schema,"extracted":result}
+ except Exception as e:
+ return {"error":str(e)}
-# ── Dashboard ─────────────────────────────────────────────────
+# ═══════════════════════════════════════════════════════════════
+# UI TEMPLATES
+# ═══════════════════════════════════════════════════════════════
-@app.get("/", response_class=HTMLResponse)
-def dashboard():
- return HTMLResponse("""
-
-AI Research Engine
-
-
-
-
Your Private Research Cloud Discover, index, and synthesize knowledge — all on your own infrastructure.
-
-
-🔍 Search
-🧪 Deep Research
-
-
-📡 System Status
-🕷️ Crawl URL
-📄 Generate Report
-
-
-
-Crawl
-
-
-
-
-""")
+.btn-primary:hover{background:var(--accent2);transform:translateY(-1px);box-shadow:0 8px 25px rgba(124,58,237,0.3)}
+.btn-gold{background:linear-gradient(135deg,var(--gold),#fbbf24);color:#1a1a1a}
+.btn-gold:hover{transform:translateY(-1px);box-shadow:0 8px 25px rgba(245,158,11,0.3)}
+.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
+.container{max-width:1200px;margin:0 auto;padding:40px 24px}
+.hero{text-align:center;padding:80px 0 60px}
+.hero h2{font-size:3rem;font-weight:900;letter-spacing:-0.04em;line-height:1.1;margin-bottom:20px;background:linear-gradient(135deg,var(--text),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
+.hero p{color:var(--muted);font-size:1.2rem;max-width:650px;margin:0 auto 32px;line-height:1.6}
+.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px;margin:40px 0}
+.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:28px;transition:all .2s}
+.card:hover{border-color:var(--accent);transform:translateY(-2px)}
+.card h3{font-size:1.2rem;margin-bottom:10px}
+.card p{color:var(--muted);font-size:0.9rem;line-height:1.5}
+.price{font-size:3rem;font-weight:900;text-align:center;margin:20px 0}
+.price span{font-size:1rem;color:var(--muted);font-weight:400}
+.feature-list{list-style:none;margin:20px 0}
+.feature-list li{padding:8px 0;color:var(--muted);font-size:0.9rem}
+.feature-list li::before{content:'✓ ';color:var(--green);font-weight:700}
+pre{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px;overflow-x:auto;font-size:0.85rem;margin:10px 0}
+.footer{text-align:center;padding:40px;color:var(--muted);font-size:0.85rem;border-top:1px solid var(--border);margin-top:60px}
+.footer a{color:var(--accent2);text-decoration:none}
+input,textarea{width:100%;padding:14px 18px;border-radius:12px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:1rem;outline:none;transition:border-color .2s;margin-bottom:12px}
+input:focus,textarea:focus{border-color:var(--accent)}
+"""
+
+LANDING_HTML = f"""AI Research Engine — Private Knowledge Cloud
The Ultimate Source of Uncensored Internet Knowledge A self-hosted AI workforce that continuously gathers, organizes, and acts on information. Your private research cloud — no filters, no tracking, no limits.
🔍 Deep Search Full-text search across millions of indexed documents. Find anything — no Google bubble, no censorship.
🧬 Semantic Understanding Search by meaning, not keywords. Our vector engine finds conceptually related content even when the words differ.
🕷️ Autonomous Crawling Point it at a topic and it discovers, crawls, and indexes every relevant source. Never miss a thing.
🤖 AI Synthesis Local LLMs summarize, extract, and report. Your research, analyzed by AI running on our hardware.
🌍 Geo-Distributed Crawls route through proxies in Tokyo, London, and Sydney. Bypass regional blocks automatically.
🔐 Private & Self-Hosted Everything runs on our metal. No third-party APIs, no data harvesting, no surveillance.
Trusted by AI Agents Worldwide 10 MCP tools. One API key. Infinite knowledge. Agents connect and research autonomously — no browser needed.
"""
+
+PRICING_HTML = f"""Pricing — AI Research Engine Simple Bitcoin Pricing No subscriptions. No KYC. Pay with Bitcoin, get API calls.
🚀 Researcher Tier $5 USD
in Bitcoin · Lightning ⚡
5 API calls Full access to all 10 tools Search, crawl, research, report No expiration No KYC required ⚡ Lightning Fast Payments settle in seconds. Your API calls are credited instantly.
🔑 Bring Your Own Key Use your API key in any MCP client, script, or AI agent.
📊 Track Usage Real-time dashboard shows your remaining calls and history.
"""
+
+SIGNUP_HTML = f"""Sign Up — AI Research Engine Get Your API Key No KYC. Just an email. Your key is generated instantly.
"""
+
+DASHBOARD_HTML = f"""Dashboard — AI Research Engine Dashboard Your account, usage, and API access.
⚡ Buy API Calls $5 Bitcoin = 5 API calls. Pay with Lightning.
Pay $5 with Bitcoin ⚡
📋 Quick Reference # Use your key in any HTTP request:
+curl -H "X-API-Key: YOUR_KEY" \\
+ "http://10.30.20.249:8000/api/search?q=your+query"
+
+# Or as a query parameter:
+curl "http://10.30.20.249:8000/api/search?q=test&api_key=YOUR_KEY" """
+
+
+# ── Startup ──────────────────────────────────────────────────
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/server.py b/server.py
index 519e873..06c5b21 100644
--- a/server.py
+++ b/server.py
@@ -1,110 +1,81 @@
#!/usr/bin/env python3
-"""
-AI Research Engine — Thin MCP Proxy
-Runs on MacBook. Forwards all tool calls to the CT 145 backend.
-Minimal resource usage — all heavy lifting on Proxmox.
-"""
+"""AI Research Engine — Thin MCP Proxy. Admin key for backend auth."""
import json
import httpx
from mcp.server import FastMCP
BACKEND_URL = "http://10.30.20.249:8000"
+ADMIN_KEY = "sk-admin-unlimited-2026"
client = httpx.Client(timeout=120.0)
-mcp = FastMCP(
- "ai-research-engine",
- instructions="""
-AI Research Engine — private knowledge acquisition system.
-
-search_web(query) — Full-text search across indexed documents
-semantic_search(query) — Find documents by meaning (vector search)
-crawl_url(url) — Crawl a URL into the index
-crawl_topic(topic) — Discover and crawl sources for a topic
-research_topic(topic) — Full pipeline: discover → crawl → summarize
-retrieve_document(url) — Get full content of an indexed document
-summarize_sources(urls, instruction) — AI summary of multiple sources
-extract_information(url, schema) — Structured data extraction
-create_report(topic, sources) — Generate comprehensive research report
-index_status() — System health and stats
-""",
-)
-
def _get(path: str) -> dict:
- r = client.get(f"{BACKEND_URL}{path}")
+ sep = "&" if "?" in path else "?"
+ r = client.get(f"{BACKEND_URL}{path}{sep}api_key={ADMIN_KEY}")
r.raise_for_status()
return r.json()
+mcp = FastMCP("ai-research-engine", instructions="AI Research Engine — private knowledge acquisition. 10 tools.")
@mcp.tool()
def search_web(query: str, category: str = "", limit: int = 10) -> str:
- """Full-text search across indexed documents. Find by keywords, titles, content."""
+ """Full-text search across indexed documents."""
r = _get(f"/api/search?q={query}&category={category}&limit={limit}")
return json.dumps(r, indent=2)
-
@mcp.tool()
def semantic_search(query: str, limit: int = 10) -> str:
- """Search by meaning using vector embeddings. Finds conceptually related docs."""
+ """Search by meaning using vector embeddings."""
r = _get(f"/api/semantic-search?q={query}&limit={limit}")
return json.dumps(r, indent=2)
-
@mcp.tool()
def crawl_url(url: str, depth: int = 1) -> str:
- """Crawl a URL. depth: 0=just this page, 1=+linked pages."""
+ """Crawl a URL and index it."""
r = _get(f"/api/crawl?url={url}&depth={depth}")
return json.dumps(r, indent=2)
-
@mcp.tool()
def crawl_topic(topic: str, max_urls: int = 20) -> str:
- """Discover and crawl sources for a topic using YaCy."""
+ """Discover and crawl sources for a topic."""
r = _get(f"/api/crawl-topic?topic={topic}&max_urls={max_urls}")
return json.dumps(r, indent=2)
-
@mcp.tool()
def research_topic(topic: str) -> str:
- """Full research pipeline: keyword search → semantic search → crawl new sources → AI summary."""
+ """Full pipeline: search → crawl → AI summary."""
r = _get(f"/api/research?topic={topic}")
return json.dumps(r, indent=2)
-
@mcp.tool()
def retrieve_document(url: str) -> str:
- """Get full indexed content of a document by URL."""
+ """Get full indexed content of a document."""
r = _get(f"/api/document?url={url}")
return json.dumps(r, indent=2)
-
@mcp.tool()
def summarize_sources(urls: str, instruction: str = "Summarize key points") -> str:
- """Summarize multiple URLs using local LLM. urls: comma-separated."""
+ """AI summary of multiple URLs. urls: comma-separated."""
r = _get(f"/api/summarize?urls={urls}&instruction={instruction}")
return json.dumps(r, indent=2)
-
@mcp.tool()
-def extract_information(url: str, schema: str = "company names, products, prices, specifications") -> str:
- """Extract structured information from a document using LLM."""
+def extract_information(url: str, schema: str = "company names, products, prices") -> str:
+ """Extract structured data from a document using LLM."""
r = _get(f"/api/extract?url={url}&schema={schema}")
return json.dumps(r, indent=2)
-
@mcp.tool()
def create_report(topic: str, sources: str = "") -> str:
- """Generate a comprehensive research report. sources: optional comma-separated URLs."""
+ """Generate comprehensive research report."""
r = _get(f"/api/report?topic={topic}&sources={sources}")
return json.dumps(r, indent=2)
-
@mcp.tool()
def index_status() -> str:
- """Check health of all backend services: OpenSearch, Qdrant, YaCy, Ollama."""
+ """System health + business stats."""
r = _get("/api/status")
return json.dumps(r, indent=2)
-
if __name__ == "__main__":
mcp.run(transport="stdio")