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