AI Research Engine — self-hosted knowledge acquisition system
- 10 MCP tools via thin proxy on MacBook - Backend REST API + Dashboard on CT 145 Docker - Services: YaCy crawler, OpenSearch index, Qdrant vectors, Ollama LLM - All 4 services healthy and verified
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.git/
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir fastapi uvicorn httpx python-dotenv
|
||||
|
||||
COPY backend.py .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "backend.py"]
|
||||
105
README.md
Normal file
105
README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 🧠 AI Research Engine
|
||||
|
||||
**Self-hosted agentic AI search infrastructure** — your private research cloud.
|
||||
|
||||
AI agents connect via MCP to discover, crawl, index, and synthesize knowledge from the web — all running on your own Proxmox hardware.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ MacBook (thin MCP proxy) │
|
||||
│ server.py → forwards to CT 145 backend │
|
||||
└──────────────┬───────────────────────────────────┘
|
||||
│ HTTP
|
||||
┌──────────────▼───────────────────────────────────┐
|
||||
│ CT 145 (10.30.20.249) — Docker Host │
|
||||
│ ┌─────────┐ ┌──────────┐ ┌───────┐ ┌─────────┐ │
|
||||
│ │ YaCy │ │OpenSearch│ │ Redis │ │ Backend │ │
|
||||
│ │ :8090 │ │ :9200 │ │ :6379 │ │ :8000 │ │
|
||||
│ │ crawl │ │ index │ │ cache │ │ API+UI │ │
|
||||
│ └─────────┘ └──────────┘ └───────┘ └─────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
│ │
|
||||
┌──────────────▼─────┐ ┌──────────▼──────────────┐
|
||||
│ CT 509 (.68) │ │ GamingPC (.186) │
|
||||
│ Qdrant :6333 │ │ Ollama :11434 │
|
||||
│ semantic search │ │ ornith:latest (9B) │
|
||||
└────────────────────┘ └─────────────────────────┘
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Service | URL | Purpose |
|
||||
|---------|-----|---------|
|
||||
| Dashboard | http://10.30.20.249:8000 | Web UI |
|
||||
| Backend API | http://10.30.20.249:8000/api/* | REST API |
|
||||
| OpenSearch | http://10.30.20.249:9200 | Full-text index |
|
||||
| YaCy | http://10.30.20.249:8090 | Web crawler |
|
||||
| Qdrant | http://10.30.20.68:6333 | Vector DB |
|
||||
| Ollama | http://10.30.20.186:11434 | LLM inference |
|
||||
|
||||
## MCP Tools (10)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_web(query)` | Full-text search across indexed documents |
|
||||
| `semantic_search(query)` | Vector search by meaning (Qdrant) |
|
||||
| `crawl_url(url)` | Crawl a URL into the index |
|
||||
| `crawl_topic(topic)` | Discover + crawl sources for a topic |
|
||||
| `research_topic(topic)` | Full pipeline: search → crawl → summarize |
|
||||
| `retrieve_document(url)` | Get full indexed document content |
|
||||
| `summarize_sources(urls)` | AI summary of multiple sources |
|
||||
| `extract_information(url, schema)` | Structured data extraction |
|
||||
| `create_report(topic)` | Comprehensive research report |
|
||||
| `index_status()` | System health check |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
curl http://10.30.20.249:8000/api/status
|
||||
|
||||
# Search
|
||||
curl "http://10.30.20.249:8000/api/search?q=knowledge+graphs"
|
||||
|
||||
# Crawl a URL
|
||||
curl "http://10.30.20.249:8000/api/crawl?url=https://example.com&depth=1"
|
||||
|
||||
# Deep research
|
||||
curl "http://10.30.20.249:8000/api/research?topic=LED+grow+lights"
|
||||
|
||||
# Generate report
|
||||
curl "http://10.30.20.249:8000/api/report?topic=AI+agents"
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
On CT 145 (10.30.20.249):
|
||||
|
||||
```bash
|
||||
# Services
|
||||
docker run -d --name redis --restart unless-stopped -p 6379:6379 redis:7-alpine
|
||||
docker run -d --name yacy --restart unless-stopped -p 8090:8090 yacy/yacy_search_server:latest
|
||||
docker run -d --name opensearch --restart unless-stopped -p 9200:9200 \
|
||||
-e "discovery.type=single-node" -e "DISABLE_SECURITY_PLUGIN=true" \
|
||||
-e "OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx2g" opensearchproject/opensearch:2.17.0
|
||||
|
||||
# Backend
|
||||
docker build -t research-backend .
|
||||
docker run -d --name research-backend --restart unless-stopped -p 8000:8000 \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e YACY_URL=http://host.docker.internal:8090 \
|
||||
-e OPENSEARCH_URL=http://host.docker.internal:9200 \
|
||||
-e QDRANT_URL=http://10.30.20.68:6333 \
|
||||
-e OLLAMA_URL=http://10.30.20.186:11434 \
|
||||
research-backend
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `server.py` — Thin MCP proxy (runs on MacBook)
|
||||
- `backend.py` — REST API + Dashboard (runs on CT 145)
|
||||
- `docker-compose.yml` — Reference compose file
|
||||
- `Dockerfile` — Backend container build
|
||||
- `.env` — Service endpoints config
|
||||
468
backend.py
Normal file
468
backend.py
Normal file
@@ -0,0 +1,468 @@
|
||||
#!/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("""
|
||||
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>AI Research Engine</title><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#09090b;--surface:#18181b;--border:#27272a;--accent:#7c3aed;--accent2:#a855f7;--text:#f4f4f5;--muted:#a1a1aa;--green:#22c55e;--red:#ef4444;--amber:#f59e0b}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:20px 32px;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.header h1{font-size:1.4rem;font-weight:800;background:linear-gradient(135deg,var(--accent),var(--accent2),#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-0.02em}
|
||||
.header .badge{font-size:0.75rem;color:var(--muted);background:var(--border);padding:6px 12px;border-radius:999px}
|
||||
.container{max-width:1400px;margin:0 auto;padding:32px 24px}
|
||||
.hero{text-align:center;padding:48px 0 32px}
|
||||
.hero h2{font-size:2.2rem;font-weight:800;letter-spacing:-0.03em;margin-bottom:12px}
|
||||
.hero p{color:var(--muted);font-size:1.1rem;max-width:600px;margin:0 auto}
|
||||
.search-box{display:flex;gap:12px;max-width:800px;margin:0 auto 40px}
|
||||
.search-box input{flex:1;padding:16px 24px;border-radius:16px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:1rem;outline:none;transition:border-color .2s}
|
||||
.search-box input:focus{border-color:var(--accent)}
|
||||
.search-box button{padding:16px 32px;border-radius:16px;border:none;font-weight:600;font-size:1rem;cursor:pointer;transition:all .2s}
|
||||
.btn-primary{background:var(--accent);color:white}
|
||||
.btn-primary:hover{background:var(--accent2)}
|
||||
.btn-secondary{background:var(--surface);color:var(--text);border:1px solid var(--border)}
|
||||
.btn-secondary:hover{border-color:var(--accent)}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px;margin-bottom:32px}
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:24px;transition:border-color .2s}
|
||||
.card:hover{border-color:var(--accent)}
|
||||
.card .label{color:var(--muted);font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px}
|
||||
.card .value{font-size:1.8rem;font-weight:700}
|
||||
.card .sub{color:var(--muted);font-size:0.85rem;margin-top:4px}
|
||||
.ok{color:var(--green)}.down{color:var(--red)}.warn{color:var(--amber)}
|
||||
.action-bar{display:flex;gap:10px;margin-bottom:28px;flex-wrap:wrap}
|
||||
.action-bar button{padding:10px 20px;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--text);cursor:pointer;font-size:0.9rem;transition:all .15s}
|
||||
.action-bar button:hover{border-color:var(--accent);background:#1f1f23}
|
||||
.results{display:flex;flex-direction:column;gap:12px}
|
||||
.result-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;transition:border-color .15s}
|
||||
.result-card:hover{border-color:var(--accent)}
|
||||
.result-card h3{margin-bottom:6px;font-size:1.1rem}
|
||||
.result-card h3 a{color:var(--accent2);text-decoration:none}
|
||||
.result-card h3 a:hover{text-decoration:underline}
|
||||
.result-card .excerpt{color:var(--muted);font-size:0.9rem;line-height:1.5}
|
||||
.result-card .meta{color:#71717a;font-size:0.78rem;margin-top:10px;display:flex;gap:16px}
|
||||
.loading{text-align:center;padding:48px;color:var(--muted)}
|
||||
.hidden{display:none}
|
||||
pre{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;overflow-x:auto;font-size:0.85rem;line-height:1.6;white-space:pre-wrap}
|
||||
.report{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:24px;line-height:1.7}
|
||||
.report h1,.report h2,.report h3{color:var(--accent2);margin:16px 0 8px}
|
||||
.report ul,.report ol{padding-left:20px;margin:8px 0}
|
||||
.report li{margin:4px 0}
|
||||
</style></head><body>
|
||||
<div class="header"><h1>🧠 AI Research Engine</h1><div class="badge">Self-hosted · Private</div></div>
|
||||
<div class="container">
|
||||
<div class="hero"><h2>Your Private Research Cloud</h2><p>Discover, index, and synthesize knowledge — all on your own infrastructure.</p></div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="query" placeholder="Research anything..." onkeydown="if(event.key==='Enter')search()">
|
||||
<button class="btn-primary" onclick="search()">🔍 Search</button>
|
||||
<button class="btn-secondary" onclick="research()" style="background:var(--accent);color:white;">🧪 Deep Research</button>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<button onclick="loadStatus()">📡 System Status</button>
|
||||
<button onclick="toggleCrawl()">🕷️ Crawl URL</button>
|
||||
<button onclick="loadReport()">📄 Generate Report</button>
|
||||
</div>
|
||||
<div id="crawl-box" class="hidden" style="margin-bottom:20px;display:flex;gap:10px;">
|
||||
<input type="text" id="crawl-input" placeholder="https://example.com" style="flex:1;padding:12px 16px;border-radius:12px;border:1px solid var(--border);background:var(--surface);color:var(--text);">
|
||||
<button class="btn-primary" onclick="crawl()">Crawl</button>
|
||||
</div>
|
||||
<div class="grid" id="stats"></div>
|
||||
<div id="output"></div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(p){const r=await fetch(p);return r.json()}
|
||||
async function loadStatus(){
|
||||
try{const d=await api('/api/status');let h='';for(const[n,i]of Object.entries(d.services||{})){
|
||||
const c=i.status==='ok'||i.status==='green'?'ok':'down';
|
||||
let x='';if(i.documents!==undefined)x=i.documents+' docs';if(i.points!==undefined)x=i.points+' vectors';if(i.models!==undefined)x=i.models+' models';
|
||||
h+=`<div class="card"><div class="label">${n}</div><div class="value ${c}">${i.status||'down'}</div><div class="sub">${x||'—'}</div></div>`}
|
||||
document.getElementById('stats').innerHTML=h}catch(e){}
|
||||
}
|
||||
async function search(){
|
||||
const q=document.getElementById('query').value;if(!q)return;
|
||||
document.getElementById('output').innerHTML='<div class="loading">Searching...</div>';
|
||||
const d=await api('/api/search?q='+encodeURIComponent(q));
|
||||
let h=`<p style="color:var(--muted);margin-bottom:16px">${d.total||0} results for "${d.query||q}"</p><div class="results">`;
|
||||
for(const r of(d.hits||[]))h+=`<div class="result-card"><h3><a href="${r.url||'#'}" target="_blank">${r.title||'Untitled'}</a></h3><div class="excerpt">${r.excerpt||''}</div><div class="meta"><span>⭐ ${(r.score||0).toFixed(1)}</span>${r.category?`<span>📁 ${r.category}</span>`:''}<span>${r.crawled_at||''}</span></div></div>`;
|
||||
h+='</div>';document.getElementById('output').innerHTML=h}
|
||||
async function research(){
|
||||
const q=document.getElementById('query').value;if(!q)return;
|
||||
document.getElementById('output').innerHTML='<div class="loading">Deep researching... this may take 30-60 seconds</div>';
|
||||
const d=await api('/api/research?topic='+encodeURIComponent(q));
|
||||
let h=`<div class="card" style="margin-bottom:16px"><div class="label">Research Complete</div><div class="value" style="font-size:1.2rem">${d.topic}</div><div class="sub">Steps: ${(d.steps||[]).join(' → ')}</div></div>`;
|
||||
if(d.ai_summary)h+=`<div class="report"><h2>AI Summary</h2>${d.ai_summary.replace(/\\n/g,'<br>')}</div>`;
|
||||
h+=`<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">`;
|
||||
h+=`<div class="card"><div class="label">Keyword Results</div><div class="sub">${d.keyword_results?.total||0} hits</div></div>`;
|
||||
h+=`<div class="card"><div class="label">Semantic Results</div><div class="sub">${d.semantic_results?.total||0} hits</div></div></div>`;
|
||||
document.getElementById('output').innerHTML=h}
|
||||
function toggleCrawl(){document.getElementById('crawl-box').classList.toggle('hidden')}
|
||||
async function crawl(){
|
||||
const u=document.getElementById('crawl-input').value;if(!u)return;
|
||||
document.getElementById('output').innerHTML='<div class="loading">Dispatching crawl...</div>';
|
||||
const d=await api('/api/crawl?url='+encodeURIComponent(u));
|
||||
document.getElementById('output').innerHTML='<pre>'+JSON.stringify(d,null,2)+'</pre>'}
|
||||
async function loadReport(){
|
||||
const q=document.getElementById('query').value||'AI research';
|
||||
document.getElementById('output').innerHTML='<div class="loading">Generating report...</div>';
|
||||
const d=await api('/api/report?topic='+encodeURIComponent(q));
|
||||
document.getElementById('output').innerHTML=`<div class="report"><h2>📊 Research Report: ${d.topic}</h2>${(d.report||'').replace(/\\n/g,'<br>')}<p style="color:var(--muted);margin-top:16px">Sources: ${d.sources_used||0}</p></div>`}
|
||||
loadStatus()
|
||||
</script></body></html>""")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
226
dashboard.py
Normal file
226
dashboard.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Research Engine Dashboard — FastAPI web UI
|
||||
Runs on CT 145 port 8000
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.expanduser("~/ai-research-engine/.env"))
|
||||
|
||||
app = FastAPI(title="AI Research Engine")
|
||||
|
||||
OPENSEARCH_URL = os.getenv("OPENSEARCH_URL", "http://localhost:9200")
|
||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://10.30.20.68:6333")
|
||||
YACY_URL = os.getenv("YACY_URL", "http://localhost:8090")
|
||||
INDEX_NAME = "research_docs"
|
||||
|
||||
client = httpx.Client(timeout=10.0)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard():
|
||||
return HTMLResponse("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Research Engine</title>
|
||||
<style>
|
||||
:root { --bg: #0f0f0f; --card: #1a1a1a; --accent: #6366f1; --text: #e2e8f0; --muted: #94a3b8; --green: #22c55e; --red: #ef4444; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
.header { background: var(--card); border-bottom: 1px solid #262626; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; font-weight: 700; background: linear-gradient(135deg, var(--accent), #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 30px 20px; }
|
||||
.search-box { display: flex; gap: 10px; margin-bottom: 30px; }
|
||||
.search-box input { flex: 1; padding: 14px 20px; border-radius: 12px; border: 1px solid #333; background: var(--card); color: var(--text); font-size: 1rem; }
|
||||
.search-box button { padding: 14px 28px; border-radius: 12px; border: none; background: var(--accent); color: white; font-weight: 600; cursor: pointer; font-size: 1rem; }
|
||||
.search-box button:hover { opacity: 0.9; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 30px; }
|
||||
.stat-card { background: var(--card); border: 1px solid #262626; border-radius: 12px; padding: 20px; }
|
||||
.stat-card .label { color: var(--muted); font-size: 0.85rem; margin-bottom: 4px; }
|
||||
.stat-card .value { font-size: 1.8rem; font-weight: 700; }
|
||||
.stat-card .ok { color: var(--green); } .stat-card .down { color: var(--red); }
|
||||
.results { display: flex; flex-direction: column; gap: 12px; }
|
||||
.result-card { background: var(--card); border: 1px solid #262626; border-radius: 12px; padding: 20px; }
|
||||
.result-card h3 { margin-bottom: 6px; }
|
||||
.result-card h3 a { color: var(--accent); text-decoration: none; }
|
||||
.result-card .excerpt { color: var(--muted); font-size: 0.9rem; line-height: 1.5; }
|
||||
.result-card .meta { color: #64748b; font-size: 0.8rem; margin-top: 8px; }
|
||||
.action-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.action-bar button { padding: 10px 20px; border-radius: 8px; border: 1px solid #333; background: var(--card); color: var(--text); cursor: pointer; font-size: 0.9rem; }
|
||||
.action-bar button:hover { border-color: var(--accent); }
|
||||
.loading { text-align: center; padding: 40px; color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🧠 AI Research Engine</h1>
|
||||
<span style="color:var(--muted);font-size:0.85rem">Self-hosted knowledge acquisition</span>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="search-box">
|
||||
<input type="text" id="query" placeholder="Research anything..." onkeydown="if(event.key==='Enter')search()">
|
||||
<button onclick="search()">Search</button>
|
||||
<button onclick="research()" style="background:#a855f7;">Deep Research</button>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<button onclick="loadStatus()">📊 System Status</button>
|
||||
<button onclick="document.getElementById('crawl-url').style.display='block'">🕷️ Crawl URL</button>
|
||||
</div>
|
||||
<div id="crawl-url" style="display:none;margin-bottom:20px;display:none;">
|
||||
<input type="text" id="crawl-input" placeholder="https://example.com" style="padding:10px;width:400px;border-radius:8px;border:1px solid #333;background:var(--card);color:var(--text);">
|
||||
<button onclick="crawl()" style="padding:10px 20px;border-radius:8px;border:none;background:var(--accent);color:white;cursor:pointer;margin-left:8px;">Crawl</button>
|
||||
</div>
|
||||
<div class="grid" id="stats"></div>
|
||||
<div id="output"></div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(path) { const r = await fetch(path); return r.json(); }
|
||||
async function loadStatus() {
|
||||
try { const d = await api('/api/status'); renderStatus(d); } catch(e) { document.getElementById('output').innerHTML='<div class="loading">Backend unreachable — is docker-compose running?</div>'; }
|
||||
}
|
||||
async function search() {
|
||||
const q = document.getElementById('query').value;
|
||||
if(!q) return;
|
||||
document.getElementById('output').innerHTML='<div class="loading">Searching...</div>';
|
||||
try { const d = await api('/api/search?q='+encodeURIComponent(q)); renderResults(d); } catch(e) { document.getElementById('output').innerHTML='<div class="loading">Error: '+e.message+'</div>'; }
|
||||
}
|
||||
async function research() {
|
||||
const q = document.getElementById('query').value;
|
||||
if(!q) return;
|
||||
document.getElementById('output').innerHTML='<div class="loading">Researching (this may take a minute)...</div>';
|
||||
try { const d = await api('/api/research?topic='+encodeURIComponent(q)); renderResearch(d); } catch(e) { document.getElementById('output').innerHTML='<div class="loading">Error: '+e.message+'</div>'; }
|
||||
}
|
||||
async function crawl() {
|
||||
const url = document.getElementById('crawl-input').value;
|
||||
if(!url) return;
|
||||
document.getElementById('output').innerHTML='<div class="loading">Crawling...</div>';
|
||||
try { const d = await api('/api/crawl?url='+encodeURIComponent(url)); document.getElementById('output').innerHTML='<pre style="background:var(--card);padding:16px;border-radius:8px;overflow-x:auto;">'+JSON.stringify(d,null,2)+'</pre>'; } catch(e) {}
|
||||
}
|
||||
function renderStatus(d) {
|
||||
let html = '';
|
||||
for(const [name, info] of Object.entries(d.services||{})) {
|
||||
const cls = info.status==='ok'||info.status==='green'?'ok':'down';
|
||||
html += `<div class="stat-card"><div class="label">${name}</div><div class="value ${cls}">${info.status||'down'}</div><div style="color:var(--muted);font-size:0.8rem;margin-top:4px;">${info.documents!==undefined?'📄 '+info.documents+' docs':''}${info.points!==undefined?'🔢 '+info.points+' vectors':''}</div></div>`;
|
||||
}
|
||||
document.getElementById('stats').innerHTML = html;
|
||||
}
|
||||
function renderResults(d) {
|
||||
let html = `<p style="color:var(--muted);margin-bottom:16px;">Found ${d.total||0} results for "${d.query||''}"</p><div class="results">`;
|
||||
for(const h of (d.hits||[])) {
|
||||
html += `<div class="result-card"><h3><a href="${h.url||'#'}" target="_blank">${h.title||'Untitled'}</a></h3><div class="excerpt">${h.excerpt||''}</div><div class="meta">Score: ${(h.score||0).toFixed(2)} · ${h.category||''} · ${h.crawled_at||''}</div></div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
document.getElementById('output').innerHTML = html;
|
||||
}
|
||||
function renderResearch(d) {
|
||||
let html = `<div class="result-card"><h3>Research: ${d.topic||''}</h3>`;
|
||||
if(d.ai_summary) html += `<div class="excerpt" style="white-space:pre-wrap;">${d.ai_summary}</div>`;
|
||||
html += `<div class="meta">Steps: ${(d.steps||[]).join(', ')}</div></div>`;
|
||||
document.getElementById('output').innerHTML = html;
|
||||
}
|
||||
loadStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
@app.get("/api/status")
|
||||
async def status():
|
||||
svc = {}
|
||||
# OpenSearch
|
||||
try:
|
||||
r = client.get(f"{OPENSEARCH_URL}/_cluster/health")
|
||||
h = r.json()
|
||||
cnt = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_count").json()
|
||||
svc["opensearch"] = {"status": h.get("status"), "documents": cnt.get("count", 0)}
|
||||
except Exception as e:
|
||||
svc["opensearch"] = {"status": "down", "error": str(e)}
|
||||
# Qdrant
|
||||
try:
|
||||
r = client.get(f"{QDRANT_URL}/health")
|
||||
if r.status_code == 200:
|
||||
col = client.get(f"{QDRANT_URL}/collections/{INDEX_NAME}").json()
|
||||
svc["qdrant"] = {"status": "ok", "points": col.get("result", {}).get("points_count", 0)}
|
||||
except Exception as e:
|
||||
svc["qdrant"] = {"status": "down", "error": str(e)}
|
||||
# YaCy
|
||||
try:
|
||||
r = client.get(f"{YACY_URL}/api/status.json")
|
||||
svc["yacy"] = {"status": r.json().get("status", "unknown")}
|
||||
except Exception as e:
|
||||
svc["yacy"] = {"status": "down", "error": str(e)}
|
||||
# Ollama
|
||||
try:
|
||||
r = httpx.get("http://10.30.20.186:11434/api/tags", timeout=5.0)
|
||||
svc["ollama"] = {"status": "ok", "models": len(r.json().get("models", []))}
|
||||
except Exception as e:
|
||||
svc["ollama"] = {"status": "down", "error": str(e)}
|
||||
|
||||
return {"services": svc}
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search(q: str = Query(...), limit: int = 10):
|
||||
try:
|
||||
r = client.post(f"{OPENSEARCH_URL}/{INDEX_NAME}/_search", json={
|
||||
"size": limit,
|
||||
"query": {"multi_match": {"query": q, "fields": ["title^3", "content", "excerpt"]}},
|
||||
"highlight": {"fields": {"content": {"fragment_size": 200, "number_of_fragments": 2}}},
|
||||
})
|
||||
hits = []
|
||||
for h in r.json().get("hits", {}).get("hits", []):
|
||||
src = h["_source"]
|
||||
hits.append({
|
||||
"url": src.get("url"), "title": src.get("title"),
|
||||
"excerpt": h.get("highlight", {}).get("content", [src.get("excerpt", "")])[0],
|
||||
"category": src.get("category"), "crawled_at": src.get("crawled_at"),
|
||||
"score": h["_score"],
|
||||
})
|
||||
return {"query": q, "total": r.json().get("hits", {}).get("total", {}).get("value", 0), "hits": hits}
|
||||
except Exception as e:
|
||||
return {"query": q, "total": 0, "hits": [], "error": str(e)}
|
||||
|
||||
@app.get("/api/crawl")
|
||||
async def crawl(url: str = Query(...)):
|
||||
try:
|
||||
r = client.get(f"{YACY_URL}/Crawler_p.json", params={
|
||||
"crawlingDomMaxPages": 50, "crawlingDepth": 1,
|
||||
"crawlingStart": url, "crawlingQ": "on",
|
||||
"bookmarkTitle": "research", "bookmarkFolder": "/research",
|
||||
"indexText": "on", "indexMedia": "on",
|
||||
"crawlingMode": "url", "cachePolicy": "iffresh",
|
||||
})
|
||||
return {"status": "crawl_started", "url": url, "yacy": r.json()}
|
||||
except Exception as e:
|
||||
return {"status": "error", "url": url, "error": str(e)}
|
||||
|
||||
@app.get("/api/research")
|
||||
async def research(topic: str = Query(...)):
|
||||
# Proxy to MCP server's research_topic via command
|
||||
import subprocess, sys
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", f"""
|
||||
import json, sys
|
||||
sys.path.insert(0, '/Users/drjones/ai-research-engine')
|
||||
from server import research_topic
|
||||
print(research_topic("{topic}"))
|
||||
"""],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
env={**os.environ, "PYTHONPATH": "/Users/drjones/ai-research-engine"}
|
||||
)
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except:
|
||||
return {"error": result.stderr, "topic": topic}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ── Web Crawler ────────────────────────────────────────────
|
||||
yacy:
|
||||
image: yacy/yacy_search_server:latest
|
||||
container_name: yacy
|
||||
ports:
|
||||
- "8090:8090"
|
||||
environment:
|
||||
- YACY_ADMIN_PASSWORD=research2026
|
||||
volumes:
|
||||
- yacy_data:/opt/yacy_search_server/DATA
|
||||
restart: unless-stopped
|
||||
mem_limit: 2g
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8090/api/status.json"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ── Document Index ─────────────────────────────────────────
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2.17.0
|
||||
container_name: opensearch
|
||||
environment:
|
||||
- discovery.type=single-node
|
||||
- DISABLE_SECURITY_PLUGIN=true
|
||||
- "OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx2g"
|
||||
- DISABLE_INSTALL_DEMO_CONFIG=true
|
||||
ports:
|
||||
- "9200:9200"
|
||||
- "9600:9600"
|
||||
volumes:
|
||||
- opensearch_data:/usr/share/opensearch/data
|
||||
restart: unless-stopped
|
||||
mem_limit: 3g
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9200/_cluster/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# ── Cache / Job Queue ──────────────────────────────────────
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
yacy_data:
|
||||
opensearch_data:
|
||||
redis_data:
|
||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[project]
|
||||
name = "ai-research-engine"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp>=1.0.0,<2.0.0",
|
||||
"httpx>=0.27.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
110
server.py
Normal file
110
server.py
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from mcp.server import FastMCP
|
||||
|
||||
BACKEND_URL = "http://10.30.20.249:8000"
|
||||
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}")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def search_web(query: str, category: str = "", limit: int = 10) -> str:
|
||||
"""Full-text search across indexed documents. Find by keywords, titles, content."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
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."""
|
||||
r = _get("/api/status")
|
||||
return json.dumps(r, indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
Reference in New Issue
Block a user