Improve CEO cockpit clarity and Revenue visuals, with Hermes briefing.

Tighten overview KPIs/pipeline height, add selectable Revenue views and a resizable sidebar, and keep supporting Hermes/Wereldexport updates.
This commit is contained in:
Aissa
2026-07-30 11:53:43 +00:00
parent 5dd69a1138
commit 62239f0a4e
14 changed files with 1876 additions and 647 deletions
+4
View File
@@ -16,6 +16,10 @@ class Settings:
OPENROUTER_BASE_URL: str = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
# orchestrator | router | auto (auto = orchestrator, fallback naar standaard LLM provider)
HERMAN_LLM_BACKEND: str = os.getenv("HERMAN_LLM_BACKEND", "auto")
# Hermes Agent (OpenAI-compatible) — Herman briefing backend
HERMES_API_BASE: str = os.getenv("HERMES_API_BASE", "http://10.4.7.10:8642/v1")
HERMES_API_KEY: str = os.getenv("HERMES_API_KEY", "")
HERMES_API_MODEL: str = os.getenv("HERMES_API_MODEL", "hermes-agent")
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")
+233 -250
View File
@@ -467,126 +467,77 @@ async def stream_daily_briefing():
yield _briefing_step("init", "dashboard", "herman", "running", "Herman start CEO dagrapport")
yield _briefing_step("crm", "PostgreSQL", "crm", "running", "Ophalen klanten, deals & pipeline uit CRM database…")
yield _briefing_step("crm", "CRM", "crm", "running", "Pipeline & klanten ophalen…")
_collect_crm_core(data)
yield _briefing_step(
"crm", "PostgreSQL", "crm", "ok",
"crm", "CRM", "crm", "ok",
f"{data['clients']} klanten · {data['deals']} deals · pipeline €{data['pipeline_eur']:,.0f}",
"tables: clients, deals, products, suppliers",
)
yield _briefing_step("agents", "PostgreSQL", "herman", "running", "Agent goedkeuringsqueue & uitgevoerde acties…")
yield _briefing_step("agents", "Herman", "herman", "running", "Open beslissingen filteren…")
_collect_agent_queue(data)
data["pending_approval_requests"] = _fresh_approvals(data.get("pending_approval_requests") or [])
data["pending_approvals"] = len(data["pending_approval_requests"])
pending = data.get("pending_approval_requests") or []
yield _briefing_step(
"agents", "PostgreSQL", "herman", "ok",
f"{len(pending)} open goedkeuringen · {len(data.get('recent_executed_actions') or [])} uitgevoerd (24u)",
"table: agent_action_requests",
"agents", "Herman", "herman", "ok",
f"{len(pending)} relevante beslissingen",
)
for row in pending[:4]:
agent = row.get("agent_key") or "agent"
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent} wacht op goedkeuring: {row.get('title') or row.get('action_type')}",
)
yield _briefing_step("projects", "PostgreSQL", "product", "running", "Project assets & IT Ops log (24u)…")
yield _briefing_step("projects", "Operations", "product", "running", "Milestones & projectstatus…")
_collect_projects_ops(data)
yield _briefing_step(
"projects", "PostgreSQL", "sysops", "ok",
f"{len(data.get('project_assets_recent') or [])} project assets · "
f"{len(data.get('sysops_activity_24h') or [])} SysOps acties",
"tables: project_assets, sysops_activity, config_backups",
"projects", "Operations", "product", "ok",
f"{len(data.get('milestones_pending') or [])} open milestones",
)
yield _briefing_step("pipeline", "PostgreSQL", "finance", "running", "Pipeline stages & agent feed ophalen…")
yield _briefing_step("pipeline", "Finance", "finance", "running", "Pipeline stages laden…")
_collect_pipeline_events(data)
stages = len(data.get("deals_by_stage") or [])
events = data.get("recent_events") or []
yield _briefing_step(
"pipeline", "PostgreSQL", "finance", "ok",
f"{stages} pipeline stages · {len(events)} recente agent-events",
"tables: deals, agent_events",
"pipeline", "Finance", "finance", "ok",
f"{stages} pipeline stages",
)
yield _briefing_step("nas", "NAS analytics", "knowledge", "running", "Document sentiment & top woorden analyseren…")
# NAS collected for internal stats but not streamed as CEO noise
_collect_nas_analytics(data)
yield _briefing_step(
"nas", "NAS analytics", "knowledge", "ok",
f"{data.get('nas_docs', 0)} documenten · sentiment {data.get('nas_sentiment', 0):.2f}",
"tables: document_analytics, document_word_counts",
)
yield _briefing_step("retail", "Retail 360", "retail", "running", "Supermarkten, partnerships & milestones…")
yield _briefing_step("retail", "Retail", "retail", "running", "Supermarkten & partnerships…")
_collect_retail_intel(data)
opp = data.get("top_opportunities") or []
yield _briefing_step(
"retail", "Retail 360", "retail", "ok",
f"{data.get('supermarkets', 0)} supermarkten · {data.get('crm_partnerships', 0)} partnerships · "
f"{len(data.get('milestones_pending') or [])} open milestones",
"tables: supermarkets, sales_milestones, retail_opportunity_scores",
"retail", "Retail", "retail", "ok",
f"{data.get('supermarkets', 0)} filialen · {data.get('crm_partnerships', 0)} partnerships · "
f"{len(data.get('milestones_pending') or [])} milestones",
)
if opp:
top = opp[0]
yield _briefing_step(
"agent_msg", "agent_mesh", "retail", "agent",
f"retail → Herman: top kans {top.get('chain')} {top.get('name')} ({top.get('city')})",
)
yield _briefing_step("rss", "RSS feeds", "marketing", "running", "Marketing Hub RSS & markt highlights ophalen…")
yield _briefing_step("rss", "Markt", "marketing", "running", "Food & retail nieuws selecteren…")
_collect_rss_market(data)
trend_n = len(data.get("trending_food") or [])
yield _briefing_step(
"rss", "RSS feeds", "marketing", "ok",
f"{data.get('rss_items', 0)} RSS items · {trend_n} food trends · {data.get('promo_campaigns', 0)} promo's",
"tables: rss_items, rss_feeds, promo_campaigns",
)
yield _briefing_step(
"agent_msg", "agent_mesh", "marketing", "agent",
f"marketing → Herman: {trend_n} trending retail headlines geleverd",
"rss", "Markt", "marketing", "ok",
f"{trend_n} relevante food-headlines",
)
data["activity_log"] = _build_activity_log(data)
yield _briefing_step("agent_mesh", "Agent mesh", "herman", "running", "Synchroniseert met actieve agents…")
seen: set[str] = set()
mesh_events = data.get("recent_events") or []
for ev in mesh_events[:10]:
agent = (ev.get("agent_name") or "agent").lower()
if agent in seen:
continue
seen.add(agent)
yield _briefing_step(
"agent_msg", "agent_mesh", agent, "agent",
f"@{agent}: {ev.get('title') or ev.get('event_type')}",
)
for agent_key in ("bizdev", "finance", "sourcing", "halal", "packaging", "hr"):
yield _briefing_step(
"agent_msg", "agent_mesh", "herman", "agent",
f"Herman → {agent_key}: briefing context gedeeld",
)
model_label = getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent"
yield _briefing_step(
"agent_mesh", "Agent mesh", "herman", "ok",
f"{len(seen)} agents met live activiteit · activity log {len(data.get('activity_log') or [])} regels",
)
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "running",
"Herman schrijft executive samenvatting met AI…",
"ai", f"Herman ({model_label})", "herman", "running",
"Herman schrijft CEO-briefing…",
)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
except (asyncio.TimeoutError, Exception) as exc:
ai_part = ""
yield _briefing_step(
"ai", "Ollama", "herman", "warn",
"AI timeout — gebruik template samenvatting",
"ai", "Herman", "herman", "warn",
"AI timeout — template samenvatting",
str(exc)[:120],
)
else:
yield _briefing_step(
"ai", f"Ollama ({settings.OLLAMA_MODEL})", "herman", "ok",
f"Samenvatting klaar ({len(ai_part or '')} tekens)",
"ai", f"Herman ({model_label})", "herman", "ok",
f"Briefing klaar ({len(ai_part or '')} tekens)",
)
yield _briefing_step("compose", "Herman", "herman", "running", "Rapport samenstellen & opslaan…")
@@ -607,6 +558,34 @@ async def stream_daily_briefing():
}
def _fresh_approvals(rows: list | None, *, max_age_days: int = 14) -> list:
"""Drop stale agent approvals so SysOps noise does not dominate the CEO digest."""
from datetime import datetime, timezone, timedelta
out = []
cutoff = datetime.now(timezone.utc) - timedelta(days=max_age_days)
for row in rows or []:
ts = row.get("created_at") or row.get("requested_at") or row.get("updated_at")
if ts is None:
continue
if hasattr(ts, "tzinfo") and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
try:
if ts < cutoff:
continue
except TypeError:
continue
agent = (row.get("agent_key") or "").lower()
action = (row.get("action_type") or "").lower()
# Keep business agents; skip routine SysOps unless critical keyword
if agent in ("sysops", "ops", "it") or action in ("maintenance_scan", "config_backup", "gitea_sync"):
title = (row.get("title") or "").lower()
if not any(k in title for k in ("critical", "kritiek", "down", "security", "breach")):
continue
out.append(row)
return out
def _build_activity_log(data: dict[str, Any]) -> list[str]:
lines: list[str] = []
for row in data.get("pending_approval_requests") or []:
@@ -698,151 +677,161 @@ def _revenue_line(p: dict[str, Any]) -> str:
def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
"""Gestructureerde live samenvatting — Cucina B2C + Foodlinkk B2B + cockpit."""
activity = data.get("activity_log") or []
opp = data.get("top_opportunities") or []
pending_n = int(data.get("pending_approvals") or 0)
"""CEO digest: B2B + B2C + focus + trends + nieuws — geen SysOps."""
revenue = data.get("revenue") or {}
rev_stats = revenue.get("stats") or {}
rev_goals = rev_stats.get("goals") or {}
export = data.get("export_intel") or {}
market = data.get("market_summary") or {}
trends = data.get("market_trends") or []
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
sections: list[dict[str, Any]] = []
# —— Foodlinkk B2B ——
# —— Foodlinkk B2B (compact) ——
fl_lines: list[str] = [
f"CRM pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
f"Pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
]
if export:
if export.get("tenders_open") or export.get("distributors"):
fl_lines.append(
f"Wereldexport: {export.get('distributors', 0):,} distributeurs · "
f"{export.get('entities', 0):,} entiteiten · {export.get('tenders_open', 0)} open tenders · "
f"fase {export.get('phase', '—')}"
f"Export: {export.get('distributors', 0):,} distributeurs · {export.get('tenders_open', 0)} open tenders"
)
if export.get("crm_linked"):
fl_lines.append(f"Export → CRM gekoppeld: {export.get('crm_linked')} records")
fl_projects = (revenue.get("by_brand") or {}).get("foodlinkk") or []
fl_projects = sorted(
fl_projects,
key=lambda p: (
1 if "verkopen" in (p.get("name") or "").lower() else 0,
-float(p.get("margin_month") or 0),
),
[p for p in fl_projects if p.get("margin_month")],
key=lambda p: -float(p.get("margin_month") or 0),
)
if fl_projects:
fl_lines.append("Revenue projecten: " + "; ".join(_revenue_line(p) for p in fl_projects[:3]))
if rev_goals.get("vision_text"):
vision = str(rev_goals["vision_text"]).strip().split("\n")[0][:90]
fl_lines.append(f"Visie: {vision}")
sections.append({"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines})
# —— Cucina B2C ——
cu_lines: list[str] = [
f"Retail 360: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} actieve partnerships",
f"Marketing: {data.get('promo_campaigns', 0)} actieve promo's · {data.get('rss_items', 0):,} RSS-items",
]
if rev_stats:
cu_lines.insert(
0,
f"Revenue Cockpit: {rev_stats.get('active_projects', 0)} projecten · "
f"marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
f"{rev_stats.get('open_objectives', 0)} open stappen",
)
cu_projects = (revenue.get("by_brand") or {}).get("cucina") or []
if cu_projects:
cu_lines.append("Supermarkt-deals: " + "; ".join(_revenue_line(p) for p in cu_projects[:4]))
halal_items = (data.get("halal_recommendations") or {}).get("items") or []
if halal_items:
cu_lines.append("🎯 Halal kansen (live engine):")
for rec in halal_items[:4]:
reason = (rec.get("reasons") or [""])[0]
cu_lines.append(
f"#{rec.get('rank')} {rec.get('chain')} · {rec.get('city')} — "
f"score {rec.get('score')} · {reason}"
)
elif opp:
top = opp[0]
cu_lines.append(
f"Halal-kans: {top.get('chain')} · {top.get('name')} ({top.get('city')}) — "
f"score {round(float(top.get('halal_opportunity_score') or 0))}/100"
)
ms = data.get("milestones_pending") or []
if ms:
cu_lines.append(f"Milestone open: {ms[0].get('title')} ({ms[0].get('chain') or 'CRM'})")
sections.append({"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines})
# —— Vandaag in cockpit ——
cockpit_lines: list[str] = []
if pending_n:
cockpit_lines.append(f"{pending_n} agent-goedkeuring{'en' if pending_n != 1 else ''} wachten op jou")
for row in (data.get("pending_approval_requests") or [])[:3]:
cockpit_lines.append(f"⏳ @{row.get('agent_key')}: {(row.get('title') or row.get('action_type') or '')[:70]}")
for line in activity[:5]:
cockpit_lines.append(_short_activity(line))
if not cockpit_lines:
cockpit_lines.append("Nog geen activiteit vandaag — Voice, Agents of Export Intel voeden Herman.")
sections.append({"brand": "platform", "title": "Cockpit vandaag", "icon": "⚡", "lines": cockpit_lines[:6]})
# —— Markt & kennis ——
news_lines: list[str] = []
if market.get("best_performer"):
bp = market["best_performer"]
news_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
seen_news: set[str] = set()
for row in (data.get("trending_food") or []):
title = str(row.get("title") or "").strip()[:95]
if not title or title in seen_news:
n_rev = 0
for p in fl_projects:
name_l = (p.get("name") or "").lower()
if any(x in name_l for x in ("verkopen", "2030", "total earnings", "total expense", "loonkosten", "boekhouder", "linknbit")):
continue
seen_news.add(title)
news_lines.append(title)
if len(seen_news) >= 2:
fl_lines.append(_revenue_line(p))
n_rev += 1
if n_rev >= 2:
break
if data.get("nas_docs"):
news_lines.append(
f"NAS: {data.get('nas_docs')} documenten · sentiment {data.get('nas_sentiment', 0):.2f}"
)
if news_lines:
sections.append({"brand": "news", "title": "Markt & kennis", "icon": "📰", "lines": news_lines[:4]})
if export.get("crm_linked"):
fl_lines.append(f"Export↔CRM: {export.get('crm_linked')} gekoppeld")
actions: list[str] = []
for row in data.get("pending_approval_requests") or []:
label = (row.get("title") or row.get("action_type") or "goedkeuring")[:60]
actions.append(f"@{row.get('agent_key')}: {label}")
# —— Cucina B2C (compact) ——
cu_lines: list[str] = []
if rev_stats:
cu_lines.append(
f"Marge/maand {_fmt_eur(rev_stats.get('total_margin_month'))} · "
f"{rev_stats.get('open_objectives', 0)} open stappen"
)
cu_lines.append(
f"Retail: {data.get('supermarkets', 0):,} filialen · {data.get('crm_partnerships', 0)} partnerships"
)
cu_projects = (revenue.get("by_brand") or {}).get("cucina") or []
deal_bits = []
for p in cu_projects[:4]:
name = (p.get("name") or "").strip()
if not name or "marketing" in name.lower() or "boekhouder" in name.lower():
continue
mm = p.get("margin_month")
bit = name
if mm:
bit += f" {_fmt_eur(mm)}/m"
if p.get("open_objectives"):
bit += f" · {p.get('open_objectives')} stappen"
deal_bits.append(bit)
if len(deal_bits) >= 3:
break
if deal_bits:
cu_lines.append("Deals: " + " · ".join(deal_bits))
if data.get("promo_campaigns"):
cu_lines.append(f"Marketing: {data.get('promo_campaigns')} actieve promo's")
if halal_items:
rec = halal_items[0]
cu_lines.append(
f"Halal-kans: {rec.get('chain')} · {rec.get('city')} (score {int(float(rec.get('halal_opportunity_score') or rec.get('score') or 0))})"
)
sections = [
{"brand": "foodlinkk", "title": "Foodlinkk B2B", "icon": "🌍", "lines": fl_lines},
{"brand": "cucina", "title": "Cucina B2C", "icon": "🍽️", "lines": cu_lines},
]
# —— Focus vandaag (business only, no sysops) ——
focus_lines: list[str] = []
for row in (data.get("milestones_pending") or [])[:2]:
actions.append(f"Milestone: {row.get('title')}")
for row in (revenue.get("by_brand") or {}).get("cucina") or []:
if row.get("open_objectives"):
actions.append(f"Cucina follow-up: {row.get('name')}")
title = (row.get("title") or "").strip()
if title:
focus_lines.append(title[:90])
for p in cu_projects:
if p.get("open_objectives") and (p.get("name") or "").lower() in ("ah", "jumbo", "picnic", "plus"):
focus_lines.append(f"Cucina follow-up: {p.get('name')} ({p.get('open_objectives')} open stappen)")
break
for rec in halal_items[:2]:
actions.append(f"Cucina outreach: {rec.get('title')} — {rec.get('reasons', [''])[0]}")
reason = ((rec.get("reasons") or [""])[0] or "")[:60]
focus_lines.append(f"Outreach {rec.get('chain')} · {rec.get('city')}" + (f" — {reason}" if reason else ""))
if export.get("tenders_open"):
actions.append(f"Wereldexport: {export.get('tenders_open')} open tenders bekijken")
if not actions:
actions.extend([
"Retail 360: top-3 halal-gap filialen benaderen",
"Marketing Hub: kant-en-klaar trends checken",
])
focus_lines.append(f"Bekijk {export.get('tenders_open')} open export-tenders")
# Skip sysops / infra approvals entirely
if not focus_lines:
focus_lines.append("Pipeline en Cucina retail-deals nalopen")
sections.append({"brand": "platform", "title": "Focus vandaag", "icon": "🎯", "lines": focus_lines[:4]})
# —— Trends ——
trend_lines: list[str] = []
for tr in trends[:4]:
name = (tr.get("trend_name") or tr.get("name") or "").strip()
score = tr.get("opportunity_score")
if not name:
continue
if score is not None:
pct = int(float(score) * 100) if float(score) <= 1 else int(float(score))
trend_lines.append(f"{name} ↑{pct}%")
else:
trend_lines.append(name)
if market.get("best_performer"):
bp = market["best_performer"]
trend_lines.append(f"Aandeel {bp.get('name')}: {bp.get('change_pct', 0):+.1f}%")
if not trend_lines:
trend_lines.append("Geen trenddata — check Retail 360")
# —— Nieuws (max 5, food only) ——
news: list[dict[str, Any]] = []
seen: set[str] = set()
skip = ("mediamarkt", "ceconomy", "elektro", "airco", "gaming", "smartphone", "sysops", "gitea", "backup")
for row in list(data.get("trending_food") or []) + list(data.get("rss_highlights") or []) + list(data.get("rss_live") or []):
title = str(row.get("title") or "").strip()
low = title.lower()
if not title or title in seen:
continue
if any(x in low for x in skip):
continue
seen.add(title)
news.append({
"title": title[:110],
"link": row.get("link") or "#",
"source": row.get("feed_name") or "RSS",
})
if len(news) >= 5:
break
actions = focus_lines[:4]
long_term = [
"Cucina: schaal supermarkt-listings (AH, Jumbo, PLUS) naar actieve partnerships",
"Foodlinkk: distributeurs in Wereldexport koppelen aan CRM-deals",
"Revenue Cockpit: open stappen en marges wekelijks reviewen",
"Cucina: schaal AH/Jumbo/PLUS naar actieve partnerships",
"Foodlinkk: export-distributeurs koppelen aan CRM-deals",
"Revenue: open stappen wekelijks reviewen",
]
if rev_goals.get("horizon_text"):
long_term.insert(0, str(rev_goals["horizon_text"]).strip().split("\n")[0][:100])
# Platte summary voor backwards compat (notifications)
summary = f"{data['date']} · Foodlinkk B2B + Cucina B2C · {len(activity)} activiteiten vandaag"
summary = f"{data.get('date')} · Foodlinkk B2B + Cucina B2C"
return {
"summary": summary,
"sections": sections,
"actions": actions[:6],
"long_term": long_term[:5],
"b2b": fl_lines,
"b2c": cu_lines,
"focus": focus_lines[:4],
"trends": trend_lines[:5],
"news": news,
"actions": actions,
"long_term": long_term[:4],
"updated_at": data.get("generated_at"),
"activity_count": len(activity),
"activity_count": len(data.get("activity_log") or []),
"source": "live",
}
@@ -885,7 +874,7 @@ def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
"",
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Model: {settings.OLLAMA_MODEL}*",
f"*Gegenereerd: {data['generated_at'][:19]} UTC · Herman via OpenCode*",
"",
"## KPI's",
f"- **Klanten:** {data['clients']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
@@ -935,41 +924,11 @@ def build_template_report(data: dict[str, Any]) -> str:
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
if data.get("sysops_activity_24h"):
lines.extend(["", "## SysOps IT — laatste 24 uur"])
for row in data["sysops_activity_24h"][:12]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
if row.get("body"):
lines.append(f" {str(row.get('body'))[:200]}")
if data.get("pending_approval_requests"):
lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
for row in data["pending_approval_requests"]:
action = row.get("action_type") or ""
label = "Update-scan VM106" if action == "maintenance_scan" else action
lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
if data.get("activity_log"):
lines.extend(["", "## Herman activiteitenlog (24u)"])
for entry in data["activity_log"][:20]:
lines.append(f"- {entry}")
if data.get("recent_handoffs"):
lines.extend(["", "## Agent samenwerking (handoffs 24u)"])
for row in data["recent_handoffs"][:15]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"- {row.get('from_agent')} → {row.get('to_agent')} ({row.get('handoff_type')}) {ts_s}"
)
if data.get("pending_items"):
lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
fresh_appr = _fresh_approvals(data.get("pending_approval_requests") or [])
if fresh_appr:
lines.extend(["", "## Open beslissingen"])
for row in fresh_appr[:8]:
lines.append(f"- **{row.get('agent_key')}**: {row.get('title')}")
return "\n".join(lines)
@@ -983,31 +942,60 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
for row in data.get("milestones_pending") or []:
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
news_lines = ""
for row in (data.get("trending_food") or [])[:5]:
title = str(row.get("title") or "").strip()
if title:
news_lines += f"- {title}\n"
fresh = _fresh_approvals(data.get("pending_approval_requests") or [])
decision_lines = ""
for row in fresh[:5]:
decision_lines += f"- @{row.get('agent_key')}: {row.get('title') or row.get('action_type')}\n"
prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
"## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
"## Lange termijn focus\n(3-5 bullets)\n\n"
"## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
f"Data vandaag ({data['date']}):\n"
"Schrijf een strak CEO-memo in het Nederlands (markdown) voor Aïssa (Foodlinkk / Cucina, "
"halal kant-en-klaar). Geen IT, geen SysOps, geen NAS, geen agent-mesh.\n\n"
"## Stand van zaken\n(4-6 zinnen: business context vandaag)\n\n"
"## Pipeline & revenue\n(bullets met cijfers en wat dat betekent)\n\n"
"## Retail & partnerships\n(supermarkten, milestones, halal-kansen)\n\n"
"## Markt\n(max 3 relevante food/retail signalen)\n\n"
"## Beslissingen vandaag\n(3-5 concrete CEO-acties)\n\n"
f"Data ({data['date']}):\n"
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
f"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
f"Activiteitenlog:\n{activity}\n"
f"- {data.get('supermarkets',0)} filialen, {data.get('crm_partnerships',0)} partnerships\n"
f"Top kansen:\n{opp_lines or '- geen data'}\n"
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
f"Milestones:\n{ms_lines or '- geen'}\n"
f"Markt:\n{news_lines or '- geen'}\n"
f"Open beslissingen:\n{decision_lines or '- geen'}\n"
)
system = (
"Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
"Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
"Schrijf warm, professioneel, actionable."
"Je bent Herman, AI co-CEO van Foodlinkk. Schrijf als een executive briefing: "
"helder, zakelijk, actionable. Focus op omzet, retail, partnerships en keuzes. "
"Noem geen servers, backups, Gitea, NAS of SysOps. "
"Antwoord UITSLUITEND met de markdown-memo. Geen voorwoord, geen bestandsnamen, "
"geen vragen aan de gebruiker, geen tools of Telegram-aanbod."
)
try:
return await llm_router.generate(prompt, system=system, timeout=120.0)
raw = await llm_router.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
text = (raw or "").strip()
for marker in ("## Stand van zaken", "## Samenvatting", "# "):
idx = text.find(marker)
if idx > 0:
text = text[idx:]
break
cut_at = len(text)
for m in ["Wil je dat ik", "Wil je deze", "Shall I", "Memo geschreven"]:
idx = text.find(m)
if 0 < idx < cut_at:
cut_at = idx
text = text[:cut_at].rstrip()
return text.strip()
return text.strip()
def _fallback_summary(data: dict[str, Any]) -> str:
@@ -1019,17 +1007,12 @@ def _fallback_summary(data: dict[str, Any]) -> str:
lines.extend(["", "## Lange termijn focus"])
for a in digest["long_term"]:
lines.append(f"- {a}")
activity = data.get("activity_log") or []
if activity:
lines.extend(["", "## Herman documentatie — wat er gebeurde"])
for entry in activity[:8]:
lines.append(f"- {_strip_activity_prefix(entry)}")
return "\n".join(lines)
def _save_briefing(content: str, data: dict[str, Any]) -> None:
safe = serialize_stats(data)
metadata = {"stats": safe, "model": settings.OLLAMA_MODEL, "type": "daily_ceo_report"}
metadata = {"stats": safe, "model": getattr(settings, "HERMES_API_MODEL", None) or "hermes-agent", "type": "daily_ceo_report"}
try:
execute(
"INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
@@ -1067,7 +1050,7 @@ async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
data = collect_briefing_data()
template = build_template_report(data)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=120.0)
except (asyncio.TimeoutError, Exception):
ai_part = ""
+47 -3
View File
@@ -68,6 +68,14 @@ LLM_PRESETS: dict[str, dict[str, Any]] = {
"signup_url": "https://console.mistral.ai/",
"hint": "EU-hosted · gratis proef tier.",
},
"hermes": {
"label": "Hermes Agent (Herman)",
"api_base_url": "http://10.4.7.10:8642/v1",
"models": ["hermes-agent"],
"default_model": "hermes-agent",
"needs_key": True,
"hint": "OpenAI-compatible API van Hermes Agent LXC — OpenCode eronder.",
},
"custom_openai": {
"label": "Custom OpenAI-compatible",
"api_base_url": "",
@@ -124,6 +132,26 @@ def _env_openrouter_provider() -> dict[str, Any] | None:
}
def _env_hermes_provider() -> dict[str, Any] | None:
from app.config import settings as app_settings
key = (app_settings.HERMES_API_KEY or "").strip()
base = (app_settings.HERMES_API_BASE or "").strip()
if not key or not base:
return None
return {
"id": -2,
"label": "Hermes Agent (Herman)",
"provider_type": "hermes",
"api_base_url": base.rstrip("/"),
"api_key": key,
"model": app_settings.HERMES_API_MODEL or "hermes-agent",
"extra_config": {"temperature": 0.35, "max_tokens": 4096},
}
def list_presets() -> list[dict[str, Any]]:
out = []
for key, meta in LLM_PRESETS.items():
@@ -175,8 +203,24 @@ def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
from app.config import settings as app_settings
backend = (app_settings.HERMAN_LLM_BACKEND or "auto").lower()
hermes = _env_hermes_provider()
if hermes and backend in ("hermes", "auto"):
# Prefer Hermes when configured (Ollama is often offline)
if backend == "hermes" or provider_id is None:
row = get_provider(provider_id) if provider_id else None
# Explicit provider_id still wins when set
if provider_id and row:
pass
else:
return hermes
row = get_provider(provider_id)
if not row:
if hermes:
return hermes
env_or = _env_openrouter_provider()
if env_or:
return env_or
@@ -191,6 +235,8 @@ def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
}
env_or = _env_openrouter_provider()
ptype = (row.get("provider_type") or "").lower()
if hermes and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
return hermes
if env_or and ptype == "openrouter" and not (row.get("api_key") or "").strip():
merged = dict(row)
merged["api_key"] = env_or["api_key"]
@@ -198,9 +244,7 @@ def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
merged["model"] = env_or["model"]
return merged
if env_or and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
# Optional: prefer env OpenRouter over default Ollama when key is set
from app.config import settings as app_settings
if (app_settings.HERMAN_LLM_BACKEND or "").lower() in ("router", "openrouter"):
if backend in ("router", "openrouter"):
return env_or
return row