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
+1
View File
@@ -23,6 +23,7 @@ class Settings:
WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755")
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
+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,
},
)
+382 -27
View File
@@ -1,10 +1,10 @@
"""Comprehensive analytics data from all DB tables with optional filters."""
"""Intelligence analytics: wins, kansen, pulse score + Hermes findings hooks."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all, fetch_one
from app.db import execute, fetch_all, fetch_one
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
@@ -26,6 +26,343 @@ def _iso_rows(rows: list) -> list:
return rows
def ensure_findings_table() -> None:
try:
execute(
"""
CREATE TABLE IF NOT EXISTS analytics_findings (
id SERIAL PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'insight',
title TEXT NOT NULL,
body TEXT,
score DOUBLE PRECISION,
source TEXT DEFAULT 'hermes',
href TEXT,
meta JSONB DEFAULT '{}'::jsonb,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
"""
)
except Exception:
pass
def list_hermes_findings(limit: int = 20) -> list[dict[str, Any]]:
ensure_findings_table()
try:
rows = fetch_all(
"""
SELECT id, kind, title, body, score, source, href, meta, created_at
FROM analytics_findings
WHERE active = TRUE
ORDER BY created_at DESC
LIMIT %s
""",
(limit,),
)
return _iso_rows(rows or [])
except Exception:
return []
def add_finding(
*,
kind: str,
title: str,
body: str = "",
score: float | None = None,
source: str = "hermes",
href: str | None = None,
meta: dict | None = None,
) -> dict[str, Any]:
ensure_findings_table()
import json
row = fetch_one(
"""
INSERT INTO analytics_findings (kind, title, body, score, source, href, meta)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, kind, title, body, score, source, href, meta, created_at
""",
(
kind or "insight",
title,
body or "",
score,
source or "hermes",
href,
json.dumps(meta or {}),
),
)
return _iso_rows([row])[0] if row else {}
def _revenue_kpis() -> dict[str, Any]:
out = {
"active_projects": 0,
"margin_month": 0.0,
"margin_year": 0.0,
"vision_text": "",
"horizon_text": "",
"tagline": "",
}
try:
row = fetch_one(
"""
SELECT COUNT(*)::int AS c,
COALESCE(SUM(margin_month),0) AS mm,
COALESCE(SUM(margin_year),0) AS my
FROM revenue_projects
WHERE COALESCE(status,'') NOT IN ('lost','cancelled','done')
"""
)
if row:
out["active_projects"] = int(row.get("c") or 0)
out["margin_month"] = float(row.get("mm") or 0)
out["margin_year"] = float(row.get("my") or 0)
except Exception:
pass
try:
g = fetch_one(
"""
SELECT vision_text, horizon_text, tagline, mid_text
FROM revenue_cockpit_goals
WHERE COALESCE(is_active, TRUE) = TRUE
ORDER BY updated_at DESC NULLS LAST
LIMIT 1
"""
)
if g:
out["vision_text"] = g.get("vision_text") or ""
out["horizon_text"] = g.get("horizon_text") or ""
out["tagline"] = g.get("tagline") or ""
out["mid_text"] = g.get("mid_text") or ""
except Exception:
pass
return out
def _build_pulse(data: dict[str, Any]) -> dict[str, Any]:
k = data.get("kpis") or {}
partners = {r.get("status"): int(r.get("cnt") or 0) for r in (data.get("partnership_breakdown") or [])}
active_p = int(partners.get("active") or k.get("crm_partnerships") or 0)
none_p = int(partners.get("none") or 0)
proposal_p = int(partners.get("proposal") or 0)
stores = int(k.get("supermarkets") or 0)
cover_pct = round((active_p / stores) * 100, 2) if stores else 0.0
gap = max(stores - active_p - proposal_p, 0)
wins: list[dict[str, Any]] = []
opportunities: list[dict[str, Any]] = []
attention: list[dict[str, Any]] = []
if stores >= 1000:
wins.append({
"id": "win-coverage",
"title": "Retail footprint is massief",
"body": f"{stores:,} supermarkten in de database — sterke basis voor targeting.".replace(",", "."),
"score": 92,
"metric": f"{stores} stores",
"href": "/retail",
})
if active_p >= 10:
wins.append({
"id": "win-crm",
"title": "Actieve partnerships draaien",
"body": f"{active_p} winkels met actieve CRM-status. Voorstellen: {proposal_p}.",
"score": 78,
"metric": f"{active_p} actief",
"href": "/retail",
})
if int(k.get("rss_items") or 0) >= 200:
wins.append({
"id": "win-rss",
"title": "Markt-signalen stromen binnen",
"body": f"{int(k['rss_items'])} RSS-items gevoed — concurrentie & trends blijven zichtbaar.",
"score": 74,
"metric": f"{int(k['rss_items'])} items",
"href": "/marketing",
})
if int(k.get("promo_campaigns") or 0) >= 10:
wins.append({
"id": "win-promo",
"title": "Promo-intelligence actief",
"body": f"{int(k['promo_campaigns'])} actieve folders/campagnes gemonitord.",
"score": 70,
"metric": f"{int(k['promo_campaigns'])} campagnes",
"href": "/marketing",
})
if float(k.get("margin_month") or 0) > 0:
wins.append({
"id": "win-margin",
"title": "Revenue engine levert marge",
"body": f"Cockpit-marge ≈ €{float(k['margin_month']):,.0f}/maand over {int(k.get('active_projects') or 0)} deals.".replace(",", "."),
"score": 88,
"metric": f"€{float(k['margin_month']):,.0f}".replace(",", "."),
"href": "/revenue-cockpit",
})
if int(k.get("wholesalers") or 0) >= 100:
wins.append({
"id": "win-wholesale",
"title": "Groothandel-netwerk in kaart",
"body": f"{int(k['wholesalers'])} groothandels beschikbaar voor distributie-routes.",
"score": 68,
"metric": f"{int(k['wholesalers'])} GH",
"href": "/retail",
})
if int(k.get("agent_events") or 0) >= 500:
wins.append({
"id": "win-agents",
"title": "Agent-mesh is productief",
"body": f"{int(k['agent_events'])} agent-events gelogd — Herman & crew blijven draaien.",
"score": 66,
"metric": f"{int(k['agent_events'])} events",
"href": "/agents",
})
if gap >= 100:
opportunities.append({
"id": "opp-white-space",
"title": "Enorm witruimte in retail CRM",
"body": f"{gap:,} winkels zonder partnership. Focus eerst op top-halal scores & grote ketens.".replace(",", "."),
"score": min(98, 40 + int(gap / 100)),
"metric": f"{gap} open",
"href": "/retail",
"tag": "distributie",
})
if cover_pct < 5:
opportunities.append({
"id": "opp-cover",
"title": "Partnership-dekking is nog dun",
"body": f"Slechts {cover_pct}% van de winkels is ‘active’. Doelbeeld Cucina vraagt schaal.",
"score": 90,
"metric": f"{cover_pct}% dekking",
"href": "/retail",
"tag": "groei",
})
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
opportunities.append({
"id": "opp-clients",
"title": "Klanten in intake → nog niet actief",
"body": f"{int(k['clients_total'])} klanten in CRM, 0 gemarkeerd als active. Conversie = snelle win.",
"score": 82,
"metric": "0 active",
"href": "/clients",
"tag": "crm",
})
if float(k.get("pipeline_eur") or 0) <= 0 and int(k.get("deals_total") or 0) > 0:
opportunities.append({
"id": "opp-pipeline",
"title": "Deals zonder pipeline-waarde",
"body": "Er staan deals, maar €0 open pipeline. Waardes & stages aanscherpen geeft sturing.",
"score": 76,
"metric": "€0 pipeline",
"href": "/deals",
"tag": "sales",
})
top_opp = data.get("top_opportunities") or []
if top_opp:
best = top_opp[0]
opportunities.append({
"id": "opp-halal-top",
"title": f"Halal-kans: {best.get('chain') or '?'} · {best.get('city') or '?'}",
"body": f"Score {round(float(best.get('halal_opportunity_score') or 0), 1)}/100 — sterkste retail-signaal nu.",
"score": round(float(best.get("halal_opportunity_score") or 0)),
"metric": f"{round(float(best.get('halal_opportunity_score') or 0), 1)}",
"href": "/retail",
"tag": "halal",
})
# cluster cities
cities: dict[str, int] = {}
for o in top_opp:
cities[o.get("city") or "?"] = cities.get(o.get("city") or "?", 0) + 1
top_city = sorted(cities.items(), key=lambda x: -x[1])[0]
if top_city[1] >= 2:
opportunities.append({
"id": "opp-city-cluster",
"title": f"Cluster-kans in {top_city[0]}",
"body": f"{top_city[1]} top-scores in dezelfde stad — bundel outreach / bezorgroute.",
"score": 72,
"metric": top_city[0],
"href": "/retail",
"tag": "geo",
})
if int(k.get("contacts_wholesaler") or 0) == 0 and int(k.get("wholesalers") or 0) > 0:
opportunities.append({
"id": "opp-gh-contacts",
"title": "Groothandel-contacten ontbreken",
"body": "550+ groothandels, 0 contactpersonen. Relaties = hefboom voor listingen.",
"score": 69,
"metric": "0 GH-contacten",
"href": "/retail",
"tag": "netwerk",
})
if int(k.get("pending_approvals") or 0) > 0:
attention.append({
"id": "att-approvals",
"title": "Goedkeuringen wachten",
"body": f"{int(k['pending_approvals'])} agent-items op needs_approval.",
"score": 55,
"metric": str(int(k["pending_approvals"])),
"href": "/agents",
})
if int(k.get("rss_bookmarks") or 0) == 0 and int(k.get("rss_items") or 0) > 100:
attention.append({
"id": "att-bookmarks",
"title": "Geen RSS-bookmarks",
"body": "Veel signalen, weinig vastgepind. Markeer wat telt voor de weekbriefing.",
"score": 48,
"metric": "0 bookmarks",
"href": "/marketing",
})
# Pulse score 0-100
score = 35
score += min(18, active_p) # up to +18
score += 12 if stores >= 2000 else (6 if stores >= 500 else 0)
score += 8 if int(k.get("rss_items") or 0) >= 500 else 0
score += 8 if float(k.get("margin_month") or 0) > 100000 else (4 if float(k.get("margin_month") or 0) > 0 else 0)
score += 6 if int(k.get("promo_campaigns") or 0) >= 20 else 0
score += 5 if int(k.get("nas_docs") or 0) >= 40 else 0
if cover_pct < 1:
score -= 12
elif cover_pct < 3:
score -= 6
if int(k.get("pending_approvals") or 0) >= 5:
score -= 5
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
score -= 4
score = max(0, min(100, score))
if score >= 75:
label, tone = "Sterk momentum", "good"
elif score >= 55:
label, tone = "Stabiel · kansen open", "mid"
else:
label, tone = "Opbouw-fase · focus nodig", "low"
wins.sort(key=lambda x: -int(x.get("score") or 0))
opportunities.sort(key=lambda x: -int(x.get("score") or 0))
return {
"score": score,
"label": label,
"tone": tone,
"cover_pct": cover_pct,
"white_space": gap,
"wins": wins[:8],
"opportunities": opportunities[:8],
"attention": attention[:6],
"headline": "Wat goed gaat vs waar de kansen liggen",
"sub": "Live uit CRM, retail, revenue & agent-mesh — Hermes vult diepere bevindingen aan.",
}
def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]:
f = filters or {}
chain = f.get("chain") or None
@@ -39,12 +376,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
"filters": f,
}
rev = _revenue_kpis()
data["kpis"] = {
"clients_total": _safe_count("clients"),
"clients_active": _safe_count("clients", "stage = 'active'"),
"deals_total": _safe_count("deals"),
"pipeline_eur": float(
(fetch_one("SELECT COALESCE(SUM(value),0) AS t FROM deals WHERE stage NOT IN ('won','lost')") or {}).get("t", 0)
or 0
),
"supermarkets": _safe_count("supermarkets"),
"wholesalers": _safe_count("wholesalers"),
@@ -57,6 +396,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
"contacts_supermarket": _safe_count("supermarket_contacts"),
"contacts_wholesaler": _safe_count("wholesaler_contacts"),
"nas_docs": _safe_count("document_analytics"),
"active_projects": rev.get("active_projects") or 0,
"margin_month": rev.get("margin_month") or 0,
"margin_year": rev.get("margin_year") or 0,
}
data["goals"] = {
"vision_text": rev.get("vision_text") or "",
"horizon_text": rev.get("horizon_text") or "",
"tagline": rev.get("tagline") or "",
"mid_text": rev.get("mid_text") or "",
}
try:
@@ -76,8 +424,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
try:
data["events_by_agent"] = fetch_all(
"""SELECT agent_name, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""" % days
WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""",
(days,),
)
except Exception:
data["events_by_agent"] = []
@@ -100,11 +449,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
data["supermarkets_by_chain"] = []
try:
data["supermarkets_by_province"] = fetch_all(
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
if store_where
else "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
if store_where:
data["supermarkets_by_province"] = fetch_all(
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12",
tuple(store_params),
)
else:
data["supermarkets_by_province"] = fetch_all(
"SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
except Exception:
data["supermarkets_by_province"] = []
@@ -133,8 +486,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
try:
data["events_timeline"] = fetch_all(
"""SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY DATE(created_at) ORDER BY day ASC""" % days
WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY DATE(created_at) ORDER BY day ASC""",
(days,),
)
except Exception:
data["events_timeline"] = []
@@ -150,7 +504,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
data["top_opportunities"] = fetch_all(
"""SELECT s.chain, s.city, ros.halal_opportunity_score
FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 10"""
ORDER BY ros.halal_opportunity_score DESC LIMIT 12"""
)
except Exception:
data["top_opportunities"] = []
@@ -191,25 +545,23 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
except Exception:
data["recent_deals"] = []
agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'"
if agent:
agent_where += " AND agent_name = %s"
try:
try:
if agent:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25""",
(agent,),
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
WHERE created_at >= NOW() - make_interval(days => %s) AND agent_name = %s
ORDER BY created_at DESC LIMIT 25""",
(days, agent),
)
except Exception:
data["recent_events"] = []
else:
try:
else:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25"""
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
WHERE created_at >= NOW() - make_interval(days => %s)
ORDER BY created_at DESC LIMIT 25""",
(days,),
)
except Exception:
data["recent_events"] = []
except Exception:
data["recent_events"] = []
try:
data["filter_meta"] = {
@@ -230,4 +582,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
):
if isinstance(data.get(key), list):
data[key] = _iso_rows(data[key])
data["pulse"] = _build_pulse(data)
data["hermes_findings"] = list_hermes_findings(20)
return data
+11 -4
View File
@@ -379,16 +379,23 @@ async def chat(
}
backend = (settings.HERMAN_LLM_BACKEND or "auto").strip().lower()
if backend in ("router", "openrouter", "llm"):
# Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator.
if backend in ("hermes", "router", "openrouter", "llm"):
try:
return await _chat_via_llm_router(message, channel)
except Exception as exc:
hint = (
"Controleer HERMES_API_KEY / Hermes Agent (:8642)."
if backend == "hermes"
else "Vul OpenRouter key in via Instellingen → AI / LLM."
)
return {
"agent": "herman",
"agent_label": "Herman",
"reply": f"LLM provider niet beschikbaar: {exc}. Vul OpenRouter key in via Instellingen → AI / LLM.",
"reply": f"LLM provider niet beschikbaar: {exc}. {hint}",
}
# orchestrator | auto — try Herman orchestrator first
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
@@ -427,8 +434,8 @@ async def chat(
"agent_label": "Herman",
"reply": (
f"Orchestrator offline ({exc}). "
f"Cloud LLM ook niet beschikbaar ({llm_exc}). "
"Voeg OpenRouter API key toe: Instellingen → AI / LLM."
f"Cloud/Hermes LLM ook niet beschikbaar ({llm_exc}). "
"Check Hermes (:8642) of Instellingen → AI / LLM."
),
}
return {