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:
@@ -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