From bb3217bfeafe5f95f2d4a911219fc780ea26be13 Mon Sep 17 00:00:00 2001 From: Marc Shade Date: Sun, 8 Mar 2026 10:34:31 -0400 Subject: [PATCH] feat: add Vector Intelligence panel to dashboard - SSE endpoint /sse/vector-analytics: pushes stats, domain summary, trend detection every 30s via asyncio.gather() - POST /api/vector-search: semantic search with query, hours, category filters - Glassmorphic Vector Intelligence panel (bottom-left): - Total vector points + collection status - Per-category data point counts (top 12) - Trend tags: SURGE (red), ELEVATED (orange), NORMAL (green), DROP (blue) - Inline semantic search with score-ranked results - Graceful degradation when Qdrant unavailable (panel stays hidden) - All innerHTML uses safe() wrapper (DOMPurify) --- src/world_intel_mcp/dashboard/app.py | 119 +++++++++++++ src/world_intel_mcp/dashboard/index.html | 207 +++++++++++++++++++++++ 2 files changed, 326 insertions(+) diff --git a/src/world_intel_mcp/dashboard/app.py b/src/world_intel_mcp/dashboard/app.py index d1bcaed..0e5822c 100644 --- a/src/world_intel_mcp/dashboard/app.py +++ b/src/world_intel_mcp/dashboard/app.py @@ -383,6 +383,122 @@ async def api_report_pdf(request): ) +async def sse_vector_analytics(request): + """SSE endpoint — pushes vector store analytics every 30 seconds.""" + + async def event_generator(): + while True: + try: + payload = await _fetch_vector_analytics() + yield f"data: {json.dumps(payload, default=str)}\n\n" + except asyncio.CancelledError: + return + except Exception as exc: + logger.exception("Vector analytics SSE tick failed") + yield f"data: {json.dumps({'error': str(exc)})}\n\n" + await asyncio.sleep(30) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Access-Control-Allow-Origin": "*", + }, + ) + + +async def _fetch_vector_analytics() -> dict: + """Gather vector store stats, domain summary, and trend detection.""" + _ensure_fetcher() + if _vector_store is None: + return { + "available": False, + "error": "Vector store not available (Qdrant not running)", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + stats_coro = _vector_store.collection_stats() + domain_coro = _vector_store.domain_summary(hours=24.0) + trend_coro = _vector_store.trend_detection(recent_hours=6.0, baseline_hours=48.0) + + gathered = await asyncio.gather( + stats_coro, domain_coro, trend_coro, return_exceptions=True + ) + + result: dict = {"available": True} + names = ("stats", "domain_summary", "trends") + for name, data in zip(names, gathered): + if isinstance(data, Exception): + logger.warning("Vector analytics %s failed: %s", name, data) + result[name] = {"error": str(data)} + else: + result[name] = data + result["timestamp"] = datetime.now(timezone.utc).isoformat() + return result + + +async def api_vector_search(request): + """POST endpoint for semantic search against the vector store.""" + _ensure_fetcher() + if _vector_store is None: + return JSONResponse( + {"error": "Vector store not available (Qdrant not running)"}, + status_code=503, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + try: + body = await request.json() + except Exception: + return JSONResponse( + {"error": "Invalid JSON body"}, + status_code=400, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + query = body.get("query", "").strip() + if not query: + return JSONResponse( + {"error": "Missing 'query' parameter"}, + status_code=400, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + hours = body.get("hours") + category = body.get("category") + limit = min(int(body.get("limit", 20)), 50) + + try: + results = await _vector_store.semantic_search( + query=query, + limit=limit, + category=category, + hours=float(hours) if hours else None, + ) + return JSONResponse(results, headers={"Access-Control-Allow-Origin": "*"}) + except Exception as exc: + logger.exception("Vector search failed") + return JSONResponse( + {"error": str(exc)}, + status_code=500, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + +async def api_vector_search_options(request): + """CORS preflight for vector search.""" + return JSONResponse( + {}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + ) + + # --------------------------------------------------------------------------- # App # --------------------------------------------------------------------------- @@ -404,6 +520,9 @@ app = Starlette( Route("/api/static", api_static), Route("/api/health", api_health), Route("/api/report/pdf", api_report_pdf), + Route("/sse/vector-analytics", sse_vector_analytics), + Route("/api/vector-search", api_vector_search, methods=["POST"]), + Route("/api/vector-search", api_vector_search_options, methods=["OPTIONS"]), ], on_startup=[on_startup], ) diff --git a/src/world_intel_mcp/dashboard/index.html b/src/world_intel_mcp/dashboard/index.html index 5028849..a5d411d 100644 --- a/src/world_intel_mcp/dashboard/index.html +++ b/src/world_intel_mcp/dashboard/index.html @@ -325,6 +325,66 @@ a { color: var(--accent); text-decoration: none; } .risk-fill.med { background: linear-gradient(90deg, var(--blue), #80c4ff); } .risk-fill.low { background: linear-gradient(90deg, var(--green), #66e6b3); } +/* ═══════════════ VECTOR INTELLIGENCE PANEL ═══════════════ */ +#vectorPanel { + position: fixed; bottom: 44px; left: 10px; z-index: 40; + width: 310px; max-height: 420px; + overflow-y: auto; overflow-x: hidden; + padding: 12px 14px; + transition: transform 0.4s var(--ease), opacity 0.3s; +} +#vectorPanel.hidden { transform: translateY(20px); opacity: 0; pointer-events: none; } +.vec-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.vec-title { font-size: 0.62rem; font-weight: 600; letter-spacing: 2.5px; color: var(--accent); text-transform: uppercase; } +.vec-stat { font-family: var(--mono); font-size: 0.6rem; color: var(--dim); } +.vec-stat .v { color: var(--bright); font-weight: 600; } +.vec-cats { margin: 6px 0 8px; } +.vec-cat-row { + display: flex; align-items: center; gap: 6px; padding: 3px 0; + font-size: 0.66rem; border-bottom: 1px solid rgba(255,255,255,0.02); +} +.vec-cat-row .cat-name { flex: 1; color: var(--text); } +.vec-cat-row .cat-count { font-family: var(--mono); color: var(--bright); min-width: 28px; text-align: right; font-weight: 600; } +.vec-trend-tag { + display: inline-block; padding: 1px 6px; border-radius: 3px; + font-family: var(--mono); font-size: 0.56rem; font-weight: 600; + letter-spacing: 0.5px; +} +.vec-trend-tag.surge { background: rgba(255,59,59,0.12); color: var(--red); } +.vec-trend-tag.elevated { background: rgba(255,159,28,0.1); color: var(--amber); } +.vec-trend-tag.normal { background: rgba(52,211,153,0.08); color: var(--green); } +.vec-trend-tag.drop { background: rgba(77,168,255,0.1); color: var(--blue); } +.vec-trend-tag.declining { background: rgba(77,168,255,0.08); color: var(--blue); opacity: 0.7; } +.vec-search-wrap { + display: flex; gap: 4px; margin: 8px 0 4px; +} +.vec-search-input { + flex: 1; padding: 5px 8px; border-radius: 5px; + background: rgba(0,0,0,0.35); border: 1px solid var(--border); + color: var(--bright); font-family: var(--mono); font-size: 0.65rem; + outline: none; transition: border-color 0.2s; +} +.vec-search-input:focus { border-color: rgba(0,229,255,0.3); } +.vec-search-input::placeholder { color: var(--dim); } +.vec-search-btn { + padding: 5px 10px; border-radius: 5px; + background: rgba(0,229,255,0.08); border: 1px solid rgba(0,229,255,0.15); + color: var(--accent); font-family: var(--mono); font-size: 0.6rem; + cursor: pointer; transition: background 0.2s; +} +.vec-search-btn:hover { background: rgba(0,229,255,0.15); } +.vec-search-btn:disabled { opacity: 0.4; cursor: default; } +.vec-results { margin-top: 6px; } +.vec-result-item { + padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.02); + font-size: 0.64rem; +} +.vec-result-item .r-score { font-family: var(--mono); color: var(--accent); font-weight: 600; } +.vec-result-item .r-domain { font-family: var(--mono); color: var(--dim); font-size: 0.56rem; text-transform: uppercase; letter-spacing: 0.5px; } +.vec-result-item .r-text { color: var(--text); margin-top: 2px; line-height: 1.3; } +.vec-result-item .r-time { font-family: var(--mono); color: var(--dim); font-size: 0.56rem; } +.vec-unavail { font-size: 0.65rem; color: var(--dim); padding: 8px 0; text-align: center; } + /* ═══════════════ GAUGE ═══════════════ */ .gauge-row { display: flex; align-items: center; gap: 8px; margin: 6px 0 10px; } .gauge-track { flex: 1; height: 8px; background: rgba(255,255,255,0.04); border-radius: 4px; overflow: hidden; } @@ -721,6 +781,20 @@ a { color: var(--accent); text-decoration: none; } + + +