#!/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. """ import os import json import httpx from fastapi import FastAPI, Query, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse import uvicorn app = FastAPI(title="AI Research Engine Backend") 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") 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") client = httpx.Client(timeout=30.0) ollama = httpx.Client(timeout=120.0, base_url=OLLAMA_URL) # ── Helpers ────────────────────────────────────────────────── 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 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}, }) body = r.json() 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 [] # ── Status ──────────────────────────────────────────────────── @app.get("/health") def health(): return {"status": "ok"} @app.get("/api/status") def status(): svc = {} 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"} try: r = 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"} 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"} 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 ──────────────────────────────────────────────────── @app.get("/api/search") def search_web(q: str = Query(...), category: str = "", limit: int = 10): _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}}] try: r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json=body, params={"refresh": "true"}) result = r.json() 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} except Exception as e: return {"query": q, "total": 0, "hits": [], "note": f"Index may be empty. {e}"} @app.get("/api/semantic-search") def semantic_search(q: str = Query(...), limit: int = 10): _ensure_qdrant() emb = _get_embedding(q) if not emb: return {"hits": [], "error": "Embedding model not available"} 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} except Exception as e: return {"hits": [], "error": str(e)} # ── Crawl ───────────────────────────────────────────────────── @app.get("/api/crawl") def crawl_url(url: str = Query(...), depth: int = 1): 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} except Exception as e: return {"status": "error", "url": url, "error": str(e)} @app.get("/api/crawl-topic") def crawl_topic(topic: str = Query(...), max_urls: int = 20): discovered = [] 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(...)): 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")): 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)} @app.get("/api/report") def create_report(topic: str = Query(...), sources: str = ""): 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 = [] 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", []) 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": []} 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") 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} # ── Dashboard ───────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) def dashboard(): return HTMLResponse("""
Discover, index, and synthesize knowledge — all on your own infrastructure.