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:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user