Ship Herman chat, Hermes KPI explorer, and intelligence analytics.

Adds floating Herman assistant with voice, Hermes technical KPI dashboard with file explorer, and a wins/kansen analytics pulse with Hermes findings hooks. Includes optional local HTTPS via Caddy for mic permissions (public certs only).
This commit is contained in:
Aissa
2026-08-01 17:50:45 +00:00
parent 62239f0a4e
commit 5e43b6b246
28 changed files with 2686 additions and 1149 deletions
+126 -1
View File
@@ -2369,18 +2369,128 @@ class HermesSearchBody(BaseModel):
limit: int = Field(default=15, ge=1, le=30)
def _hermes_feed_base() -> str:
return (getattr(settings, "HERMES_FEED_URL", None) or "http://10.4.7.10:8755").rstrip("/")
@admin_router.get("/hermes/stats")
async def hermes_stats_proxy() -> dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{_hermes_feed_base()}/stats")
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/kpis")
async def hermes_kpis_proxy() -> dict[str, Any]:
"""Technical KPIs from Hermes Agent + Herman cockpit event counts."""
import httpx
from app.db import fetch_all
payload: dict[str, Any] = {
"online": False,
"synced_at": None,
"source": "hermes-agent",
"overview": {},
"buckets": [],
"tools_top": [],
"sources": [],
"herman_cockpit": [],
}
try:
async with httpx.AsyncClient(timeout=12.0) as client:
r = await client.get(f"{_hermes_feed_base()}/kpis")
if r.status_code == 200:
payload.update(r.json())
except Exception as exc:
payload["error"] = str(exc)[:160]
try:
rows = fetch_all(
"""
SELECT event_type, COUNT(*)::int AS count
FROM agent_events
GROUP BY event_type
ORDER BY COUNT(*) DESC
LIMIT 12
"""
)
payload["herman_cockpit"] = [
{"event_type": r.get("event_type") or "unknown", "count": int(r.get("count") or 0)}
for r in (rows or [])
]
except Exception:
payload["herman_cockpit"] = []
return payload
@admin_router.get("/hermes/bucket/{key}")
async def hermes_bucket_detail_proxy(key: str, limit: int = 60, tool: str | None = None) -> dict[str, Any]:
"""Bucket detail: recent tool events + touched files for explorer popup."""
import httpx
params: dict[str, Any] = {"limit": limit}
if tool:
params["tool"] = tool
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/bucket/{key}", params=params)
if r.status_code == 404:
return {"error": "unknown bucket", "key": key, "events": [], "files": []}
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/explorer")
async def hermes_explorer_proxy(path: str | None = None) -> dict[str, Any]:
"""Browse Hermes Agent filesystem (read-only, restricted roots)."""
import httpx
params = {}
if path:
params["path"] = path
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{_hermes_feed_base()}/explorer", params=params)
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/explorer/file")
async def hermes_explorer_file_proxy(path: str) -> dict[str, Any]:
"""Preview a file from Hermes Agent filesystem."""
import httpx
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/explorer/file", params={"path": path})
if r.status_code in (403, 404, 400):
try:
err = (r.json() or {}).get("error") or r.text
except Exception:
err = r.text
return {"error": err, "path": path, "content": None}
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/conversations")
async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]:
"""Live conversations from Hermes Agent (not June Postgres archive)."""
import httpx
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{_hermes_feed_base()}/conversations", params={"limit": limit})
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit})
r.raise_for_status()
@@ -2393,10 +2503,18 @@ async def hermes_feed_proxy(
limit: int = 80,
offset: int = 0,
) -> dict[str, Any]:
"""Live Telegram feed from Hermes Agent state.db."""
import httpx
params = {"limit": limit, "offset": offset}
params: dict[str, Any] = {"limit": limit, "offset": offset}
if chat_id is not None:
params["chat_id"] = chat_id
try:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/feed", params=params)
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params)
r.raise_for_status()
@@ -2469,6 +2587,13 @@ async def hermes_pa_slot_screenshot(label: str):
@admin_router.get("/hermes/users")
async def hermes_users_proxy() -> dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{_hermes_feed_base()}/users")
if r.status_code == 200 and (r.json().get("users") is not None):
return r.json()
except Exception:
pass
users: list[dict[str, Any]] = []
try:
async with httpx.AsyncClient(timeout=8.0) as client:
+34 -4
View File
@@ -1,17 +1,28 @@
from pathlib import Path
from typing import Optional
from typing import Any, Optional
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Body, Query, Request
from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.services.analytics_data import collect_analytics
from app.services.analytics_data import add_finding, collect_analytics, list_hermes_findings
router = APIRouter(prefix="/analytics", tags=["analytics"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class FindingIn(BaseModel):
kind: str = Field(default="insight", description="win|opportunity|attention|insight")
title: str
body: str = ""
score: float | None = None
source: str = "hermes"
href: str | None = None
meta: dict[str, Any] | None = None
@router.get("")
async def analytics_page(request: Request):
initial = collect_analytics({})
@@ -30,5 +41,24 @@ async def analytics_api(
days: int = Query(90, ge=7, le=365),
):
filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days}
filters = {k: v for k, v in filters.items() if v is not None and v != ""}
return JSONResponse(collect_analytics(filters))
@router.get("/api/findings")
async def analytics_findings(limit: int = Query(20, ge=1, le=100)):
return {"items": list_hermes_findings(limit)}
@router.post("/api/findings")
async def analytics_findings_create(payload: FindingIn = Body(...)):
"""Hermes (of admin) kan bevindingen pushen naar het intelligence dashboard."""
row = add_finding(
kind=payload.kind,
title=payload.title,
body=payload.body,
score=payload.score,
source=payload.source,
href=payload.href,
meta=payload.meta,
)
return {"ok": True, "finding": row}
+5
View File
@@ -225,3 +225,8 @@ async def cto_dashboard_legacy(request: Request):
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
@router.get("/trust")
async def trust_page(request: Request):
return templates.TemplateResponse("trust.html", {"request": request, "page_title": "Certificaat"})
+5 -2
View File
@@ -10,12 +10,15 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social", "ai")
allowed = ("email", "general", "permissions", "social")
active = tab if tab in allowed else "email"
if tab == "ai":
active = "email"
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"page_title": "Settings",
"active_tab": tab if tab in allowed else "email",
"active_tab": active,
},
)