diff --git a/.gitignore b/.gitignore
index e364dd7..9a0f431 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@ cockpit/static/uploads/
*.swp
.idea/
.vscode/
+.cursor/
+tmp/
+cockpit/tmp-upload/
diff --git a/cockpit/api.py b/cockpit/api.py
new file mode 100644
index 0000000..68fd3b7
--- /dev/null
+++ b/cockpit/api.py
@@ -0,0 +1,161 @@
+import json
+from datetime import date, datetime
+from typing import Any, Optional
+
+import httpx
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
+
+from app.config import settings
+from app.db import execute, fetch_all, fetch_one
+from app.services.briefing import (
+ build_live_digest,
+ collect_briefing_data,
+ generate_daily_briefing,
+ serialize_stats,
+ stream_daily_briefing,
+)
+
+router = APIRouter(prefix="/api", tags=["api"])
+
+
+def _stats_payload() -> dict[str, Any]:
+ stats: dict[str, Any] = {
+ "deals": 0,
+ "clients": 0,
+ "pending_approvals": 0,
+ "pipeline_value": 0,
+ }
+ try:
+ row = fetch_one("SELECT COUNT(*) AS c FROM deals")
+ stats["deals"] = int(row["c"]) if row else 0
+ except Exception:
+ pass
+ try:
+ row = fetch_one("SELECT COUNT(*) AS c FROM clients")
+ stats["clients"] = int(row["c"]) if row else 0
+ except Exception:
+ pass
+ try:
+ row = fetch_one("SELECT COUNT(*) AS c FROM agent_events WHERE status = 'needs_approval'")
+ stats["pending_approvals"] = int(row["c"]) if row else 0
+ except Exception:
+ pass
+ try:
+ row = fetch_one(
+ "SELECT COALESCE(SUM(value), 0) AS total FROM deals WHERE stage NOT IN ('won', 'lost')"
+ )
+ stats["pipeline_value"] = float(row["total"]) if row else 0
+ except Exception:
+ pass
+ return stats
+
+
+@router.get("/server-time")
+async def server_time():
+ now = datetime.utcnow()
+ return {"utc": now.isoformat() + "Z", "timezone": "Europe/Amsterdam"}
+
+
+@router.get("/herman/briefing/stats")
+async def herman_briefing_stats():
+ """Live stats from DB — always fresh for dashboard panels."""
+ stats = serialize_stats(collect_briefing_data())
+ bookmarks = []
+ bookmark_map = {}
+ try:
+ bookmarks = fetch_all(
+ """SELECT b.rss_item_id, b.title, b.link, b.feed_name, b.created_at
+ FROM rss_bookmarks b ORDER BY b.created_at DESC LIMIT 30"""
+ )
+ for b in bookmarks:
+ if b.get("created_at") and hasattr(b["created_at"], "isoformat"):
+ b["created_at"] = b["created_at"].isoformat()
+ bookmark_map[b["rss_item_id"]] = True
+ except Exception:
+ bookmarks = []
+ stats["rss_bookmarks"] = bookmarks
+ stats["rss_bookmark_ids"] = list(bookmark_map.keys())
+ if not stats.get("rss_live") and not stats.get("trending_food"):
+ try:
+ stats["rss_live"] = fetch_all(
+ """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name,
+ f.category, i.published_at
+ FROM rss_items i
+ JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
+ ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 25"""
+ )
+ except Exception:
+ stats["rss_live"] = []
+ return {"ok": True, "stats": stats, "bookmarks": bookmarks, "live_digest": build_live_digest(stats), "at": datetime.utcnow().isoformat()}
+
+
+@router.post("/herman/briefing")
+async def herman_briefing():
+ try:
+ content, stats = await generate_daily_briefing()
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ return {"ok": True, "content": content, "stats": stats, "generated_at": datetime.utcnow().isoformat()}
+
+
+@router.post("/herman/briefing/stream")
+async def herman_briefing_stream():
+ """SSE stream — live stappen tijdens dagrapport generatie."""
+
+ async def event_gen():
+ try:
+ async for event in stream_daily_briefing():
+ yield f"data: {json.dumps(event, default=str)}\n\n"
+ except Exception as exc:
+ err = {"type": "error", "message": str(exc)}
+ yield f"data: {json.dumps(err)}\n\n"
+
+ return StreamingResponse(
+ event_gen(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+@router.get("/herman/briefing/latest")
+async def herman_briefing_latest():
+ try:
+ row = fetch_one(
+ "SELECT id, content, generated_by, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ live_stats = serialize_stats(collect_briefing_data())
+ if not row:
+ return {"ok": True, "content": None, "stats": live_stats}
+ if row.get("created_at") and hasattr(row["created_at"], "isoformat"):
+ row["created_at"] = row["created_at"].isoformat()
+ return {"ok": True, **row, "stats": live_stats}
+
+
+@router.get("/live/platform")
+async def live_platform(limit: int = 100, agent: Optional[str] = None):
+ from app.services.platform_live import fetch_platform_events, platform_stats
+
+ events = fetch_platform_events(limit=min(limit, 200), agent=agent)
+ return {"ok": True, "stats": platform_stats(), "events": events}
+
+
+@router.get("/events")
+async def list_events(limit: int = 50):
+ limit = max(1, min(limit, 200))
+ try:
+ rows = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, created_at
+ FROM agent_events ORDER BY created_at DESC LIMIT %s
+ """,
+ (limit,),
+ )
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+ for row in rows:
+ if row.get("created_at"):
+ row["created_at"] = row["created_at"].isoformat()
+ return {"events": rows}
diff --git a/cockpit/app/routes/api.py b/cockpit/app/routes/api.py
index 02c2858..bc7aac1 100644
--- a/cockpit/app/routes/api.py
+++ b/cockpit/app/routes/api.py
@@ -9,7 +9,9 @@ from fastapi.responses import StreamingResponse
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services.briefing import (
+ build_live_digest,
collect_briefing_data,
+ format_live_digest_text,
generate_daily_briefing,
serialize_stats,
stream_daily_briefing,
@@ -57,9 +59,9 @@ async def server_time():
@router.get("/herman/briefing/stats")
-async def herman_briefing_stats():
- """Live stats from DB — always fresh for dashboard panels."""
- stats = serialize_stats(collect_briefing_data())
+async def herman_briefing_stats(light: bool = False):
+ """Live stats from DB — light=1 skips halal engine for snelle poll-refresh."""
+ stats = serialize_stats(collect_briefing_data(skip_halal=light))
bookmarks = []
bookmark_map = {}
try:
@@ -86,7 +88,20 @@ async def herman_briefing_stats():
)
except Exception:
stats["rss_live"] = []
- return {"ok": True, "stats": stats, "bookmarks": bookmarks, "at": datetime.utcnow().isoformat()}
+ return {"ok": True, "stats": stats, "bookmarks": bookmarks, "live_digest": build_live_digest(stats), "at": datetime.utcnow().isoformat()}
+
+
+@router.get("/herman/briefing/live-text")
+async def herman_briefing_live_text():
+ """Compacte live samenvatting voor Telegram / bots."""
+ stats = serialize_stats(collect_briefing_data())
+ digest = build_live_digest(stats)
+ return {
+ "ok": True,
+ "text": format_live_digest_text(digest),
+ "live_digest": digest,
+ "at": datetime.utcnow().isoformat(),
+ }
@router.post("/herman/briefing")
diff --git a/cockpit/app/routes/dashboard.py b/cockpit/app/routes/dashboard.py
index 34282bb..e3d4d1b 100644
--- a/cockpit/app/routes/dashboard.py
+++ b/cockpit/app/routes/dashboard.py
@@ -5,7 +5,7 @@ from pathlib import Path
import json
from app.db import fetch_all, fetch_one
-from app.services.briefing import collect_briefing_data, serialize_stats
+from app.services.briefing import build_live_digest, collect_briefing_data, serialize_stats
router = APIRouter(tags=["dashboard"])
@@ -32,8 +32,14 @@ def _safe_sum(table: str, column: str, where: str = "") -> float:
def _briefing_payload(briefing: dict | None) -> dict:
- """Always use live DB stats; briefing text may be cached."""
- payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None}
+ """Live stats + live digest; opgeslagen briefing-tekst alleen als AI-rapport."""
+ live_stats = serialize_stats(collect_briefing_data())
+ payload: dict = {
+ "content": None,
+ "stats": live_stats,
+ "live_digest": build_live_digest(live_stats),
+ "created_at": None,
+ }
if not briefing:
return payload
diff --git a/cockpit/app/routes/export_intel.py b/cockpit/app/routes/export_intel.py
index 7ab5d70..7e5a782 100644
--- a/cockpit/app/routes/export_intel.py
+++ b/cockpit/app/routes/export_intel.py
@@ -85,8 +85,10 @@ async def api_entity(entity_id: int):
@router.get("/api/export-intel/contacts")
async def api_contacts(
country: Optional[str] = None,
+ region: Optional[str] = None,
entity_type: Optional[str] = None,
has_email: Optional[bool] = None,
+ crm_linked: Optional[bool] = None,
q: Optional[str] = None,
limit: int = 200,
offset: int = 0,
@@ -94,18 +96,40 @@ async def api_contacts(
params: dict[str, Any] = {"limit": limit, "offset": offset}
if country:
params["country"] = country
+ if region:
+ params["region"] = region
if entity_type:
params["entity_type"] = entity_type
if has_email is not None:
params["has_email"] = has_email
+ if crm_linked is not None:
+ params["crm_linked"] = crm_linked
if q:
params["q"] = q
return await _proxy("GET", "/contacts", params=params)
@router.get("/api/export-intel/contacts/export.csv")
-async def api_contacts_export(country: Optional[str] = None, entity_type: Optional[str] = None):
- params = {k: v for k, v in {"country": country, "entity_type": entity_type}.items() if v}
+async def api_contacts_export(
+ country: Optional[str] = None,
+ region: Optional[str] = None,
+ entity_type: Optional[str] = None,
+ has_email: Optional[bool] = None,
+ crm_linked: Optional[bool] = None,
+ q: Optional[str] = None,
+):
+ params = {
+ k: v
+ for k, v in {
+ "country": country,
+ "region": region,
+ "entity_type": entity_type,
+ "has_email": has_email,
+ "crm_linked": crm_linked,
+ "q": q,
+ }.items()
+ if v is not None and v != ""
+ }
url = f"{TOOLS}/export-intel/contacts/export.csv"
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(url, params=params)
diff --git a/cockpit/app/routes/reco_proxy.py b/cockpit/app/routes/reco_proxy.py
index 4d52ddf..662d285 100644
--- a/cockpit/app/routes/reco_proxy.py
+++ b/cockpit/app/routes/reco_proxy.py
@@ -34,6 +34,17 @@ def register_recommendation_routes(router: APIRouter) -> None:
async def reco_dismiss(rec_id: int):
return await _proxy("POST", f"/recommendations/{rec_id}/dismiss")
+ @router.get("/recommendations/halal/live")
+ async def halal_reco_live(limit: int = 5, refresh: bool = False):
+ q = f"/recommendations/halal/live?limit={limit}"
+ if refresh:
+ q += "&refresh=true"
+ return await _proxy("GET", q)
+
+ @router.post("/recommendations/halal/refresh")
+ async def halal_reco_refresh(limit: int = 5):
+ return await _proxy("POST", f"/recommendations/halal/refresh?limit={limit}")
+
@router.post("/research/run")
async def research_run():
return await _proxy("POST", "/research/run")
diff --git a/cockpit/app/services/briefing.py b/cockpit/app/services/briefing.py
index 8c019d4..038b85f 100644
--- a/cockpit/app/services/briefing.py
+++ b/cockpit/app/services/briefing.py
@@ -2,9 +2,12 @@ from __future__ import annotations
import asyncio
import json
+import re
from datetime import date, datetime, timezone
from typing import Any
+import httpx
+
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import market_stocks, llm_router
@@ -362,7 +365,57 @@ def _collect_rss_market(data: dict[str, Any]) -> None:
data["rss_items"] = _safe_count("rss_items")
-def collect_briefing_data() -> dict[str, Any]:
+def _classify_revenue_brand(name: str) -> str:
+ n = (name or "").lower()
+ if any(k in n for k in ("foodlinkk", "linknbit", "linknbi", "wereldexport")):
+ return "foodlinkk"
+ if "cucina" in n and "foodlinkk" not in n:
+ return "cucina"
+ chains = ("ah", "jumbo", "picnic", "plus", "vomar", "dirk", "hoogvliet", "deka", "spar", "doner")
+ if any(n == c or n.startswith(c + " ") for c in chains):
+ return "cucina"
+ if any(k in n for k in ("foodservice", "snack", "diepvries", "halal", "gas stunning", "marketing budget adds cucina")):
+ return "cucina"
+ if any(k in n for k in ("fresh supplier", "boerschappen", "subsidies cucina")):
+ return "foodlinkk"
+ if any(k in n for k in ("total earnings", "loonkosten", "total expenses", "verkopen cucina")):
+ return "shared"
+ return "other"
+
+
+def _collect_revenue_cockpit(data: dict[str, Any]) -> None:
+ try:
+ from app.services import revenue_cockpit as rc
+
+ projects = rc.list_projects()
+ stats = rc.dashboard_stats()
+ by_brand: dict[str, list] = {"cucina": [], "foodlinkk": [], "shared": [], "other": []}
+ for p in projects:
+ if (p.get("status") or "") != "active":
+ continue
+ brand = _classify_revenue_brand(p.get("name") or "")
+ by_brand[brand].append(p)
+ data["revenue"] = {
+ "stats": stats,
+ "by_brand": {
+ k: sorted(v, key=lambda x: float(x.get("margin_month") or 0), reverse=True)[:6]
+ for k, v in by_brand.items()
+ },
+ }
+ except Exception:
+ data["revenue"] = {}
+
+
+def _collect_export_intel(data: dict[str, Any]) -> None:
+ try:
+ url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/stats"
+ r = httpx.get(url, timeout=4.0)
+ data["export_intel"] = r.json() if r.status_code == 200 else {}
+ except Exception:
+ data["export_intel"] = {}
+
+
+def collect_briefing_data(*, skip_halal: bool = False) -> dict[str, Any]:
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
@@ -374,10 +427,37 @@ def collect_briefing_data() -> dict[str, Any]:
_collect_nas_analytics(data)
_collect_retail_intel(data)
_collect_rss_market(data)
+ _collect_revenue_cockpit(data)
+ _collect_export_intel(data)
+ if not skip_halal:
+ _collect_halal_recommendations(data)
data["activity_log"] = _build_activity_log(data)
return data
+def _collect_halal_recommendations(data: dict[str, Any]) -> None:
+ try:
+ url = f"{settings.TOOLS_API_URL.rstrip('/')}/recommendations/halal/live"
+ r = httpx.get(url, params={"limit": 5, "brand": "cucina"}, timeout=20.0)
+ data["halal_recommendations"] = r.json() if r.status_code == 200 else {"items": []}
+ except Exception:
+ data["halal_recommendations"] = {"items": []}
+ # Legacy veld voor backwards compat — eerste 3 engine results
+ items = (data.get("halal_recommendations") or {}).get("items") or []
+ data["top_opportunities"] = [
+ {
+ "id": i.get("store_id"),
+ "name": (i.get("title") or "").split(" · ", 1)[-1],
+ "chain": i.get("chain"),
+ "city": i.get("city"),
+ "halal_opportunity_score": i.get("halal_opportunity_score") or i.get("score"),
+ "composite_score": i.get("score"),
+ "reasons": i.get("reasons"),
+ }
+ for i in items[:5]
+ ]
+
+
async def stream_daily_briefing():
"""Yield SSE step events while building the CEO daily report."""
data: dict[str, Any] = {
@@ -585,6 +665,222 @@ def _build_activity_log(data: dict[str, Any]) -> list[str]:
return lines[:25]
+def _strip_activity_prefix(line: str) -> str:
+ for prefix in ("⏳ ", "✓ ", "📁 ", "🔧 ", "💾 ", "🖥️ ", "⚡ ", "🔗 "):
+ if line.startswith(prefix):
+ return line[len(prefix) :].strip()
+ return line.strip()
+
+
+def _short_activity(line: str, max_len: int = 88) -> str:
+ s = _strip_activity_prefix(line)
+ s = re.sub(r" — \d{4}-\d{2}-\d{2}$", "", s)
+ s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?", "", s).strip()
+ s = re.sub(r"\s+", " ", s)
+ if len(s) > max_len:
+ return s[: max_len - 1].rstrip() + "…"
+ return s
+
+
+def _fmt_eur(val: float | int | None) -> str:
+ try:
+ return f"€{float(val or 0):,.0f}"
+ except (TypeError, ValueError):
+ return "€—"
+
+
+def _revenue_line(p: dict[str, Any]) -> str:
+ mm = p.get("margin_month")
+ margin = f" · marge/maand {_fmt_eur(mm)}" if mm else ""
+ obj = p.get("open_objectives") or 0
+ extra = f" · {obj} open stappen" if obj else ""
+ return f"{p.get('name')}{margin}{extra}"
+
+
+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)
+ 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 {}
+
+ sections: list[dict[str, Any]] = []
+
+ # —— Foodlinkk B2B ——
+ fl_lines: list[str] = [
+ f"CRM pipeline {_fmt_eur(data.get('pipeline_eur'))} · {data.get('deals', 0)} deals · {data.get('clients', 0)} klanten",
+ ]
+ if export:
+ 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', '—')}"
+ )
+ 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),
+ ),
+ )
+ 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:
+ continue
+ seen_news.add(title)
+ news_lines.append(title)
+ if len(seen_news) >= 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]})
+
+ 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}")
+ 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')}")
+ break
+ for rec in halal_items[:2]:
+ actions.append(f"Cucina outreach: {rec.get('title')} — {rec.get('reasons', [''])[0]}")
+ 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",
+ ])
+
+ 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",
+ ]
+ 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"
+
+ return {
+ "summary": summary,
+ "sections": sections,
+ "actions": actions[:6],
+ "long_term": long_term[:5],
+ "updated_at": data.get("generated_at"),
+ "activity_count": len(activity),
+ "source": "live",
+ }
+
+
+def format_live_digest_text(digest: dict[str, Any]) -> str:
+ """Platte tekst voor Telegram / Herman chat — halal zit in Cucina-sectie."""
+ lines = ["📋 Herman briefing · live", "", digest.get("summary") or ""]
+ for section in digest.get("sections") or []:
+ icon = section.get("icon") or ""
+ title = section.get("title") or ""
+ lines.append("")
+ lines.append(f"{icon} {title}".strip())
+ for line in section.get("lines") or []:
+ lines.append(f" · {line}")
+ actions = digest.get("actions") or []
+ if actions:
+ lines.extend(["", "⚡ Korte termijn"])
+ for action in actions[:6]:
+ lines.append(f" · {action}")
+ long_term = digest.get("long_term") or []
+ if long_term:
+ lines.extend(["", "🎯 Lange termijn"])
+ for item in long_term[:5]:
+ lines.append(f" · {item}")
+ updated = digest.get("updated_at")
+ if updated:
+ ts = str(updated)[:19].replace("T", " ")
+ n = digest.get("activity_count")
+ extra = f" · {n} activiteiten" if n is not None else ""
+ lines.extend(["", f"Bijgewerkt {ts} UTC{extra}"])
+ return "\n".join(lines)
+
+
+def build_live_digest_text(data: dict[str, Any] | None = None) -> str:
+ payload = data if data is not None else collect_briefing_data()
+ return format_live_digest_text(build_live_digest(payload))
+
+
def build_template_report(data: dict[str, Any]) -> str:
lines = [
f"# Foodlinkk Dagrapport — {data['date']}",
@@ -715,39 +1011,19 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
def _fallback_summary(data: dict[str, Any]) -> str:
- opp = data.get("top_opportunities") or []
- ms = data.get("milestones_pending") or []
- lines = [
- "## Samenvatting",
- f"Vandaag ({data['date']}) heb je **€{data['pipeline_eur']:,.0f}** in je pipeline en **{data.get('crm_partnerships',0)} actieve supermarkt-partnerships**. "
- f"In Retail 360 staan **{data.get('supermarkets',0)} filialen** met live CBS-data.",
- ]
- if opp:
- top = opp[0]
- lines.append(
- f"De grootste halal-kans is **{top.get('chain')} · {top.get('name')}** in {top.get('city')} "
- f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
- )
+ digest = build_live_digest(data)
+ lines = ["## Samenvatting", digest["summary"]]
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
- actions = [
- f"Keur {data['pending_approvals']} open agent-verzoeken goed (dashboard → Goedkeuringen)",
- "Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
- "Check Marketing Live Feed voor kant-en-klaar trends",
- ]
- for row in data.get("pending_approval_requests") or []:
- if row.get("action_type") == "maintenance_scan":
- actions.insert(0, f"**SysOps update-scan:** {row.get('title')} — keur goed op dashboard")
- break
- if ms:
- actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
- for a in actions[:6]:
+ for a in digest["actions"]:
lines.append(f"- {a}")
lines.extend(["", "## Lange termijn focus"])
- lines.extend([
- "- Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
- "- Halal kant-en-klaar listing bij Jumbo/AH regio's met hoogste demografische vraag",
- "- Wekelijks milestones review in Retail 360 sales tab",
- ])
+ 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)
@@ -801,4 +1077,6 @@ async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
- return content, serialize_stats(data)
+ stats = serialize_stats(data)
+ stats["live_digest"] = build_live_digest(data)
+ return content, stats
diff --git a/cockpit/app/services/herman.py b/cockpit/app/services/herman.py
index a6a8c17..a2f16a6 100644
--- a/cockpit/app/services/herman.py
+++ b/cockpit/app/services/herman.py
@@ -28,6 +28,22 @@ IMAGE_KEYWORDS = (
"make image", "maak plaatje", "/genfoto",
)
+BRIEFING_KEYWORDS = (
+ "/briefing", "briefing", "samenvatting", "dagrapport",
+ "ochtendbriefing", "avondbriefing", "ceo briefing", "live briefing",
+)
+
+
+def _wants_briefing(raw: str) -> bool:
+ t = (raw or "").strip().lower()
+ if not t:
+ return False
+ if t.startswith("/briefing"):
+ return True
+ if len(t) > 120:
+ return False
+ return any(k in t for k in BRIEFING_KEYWORDS)
+
def _wants_image(raw: str) -> bool:
t = (raw or "").strip().lower()
@@ -137,6 +153,28 @@ async def chat(
)
return routed
+ if channel == "telegram" and _wants_briefing(message):
+ from app.services.briefing import build_live_digest_text, collect_briefing_data
+
+ data = collect_briefing_data()
+ reply = build_live_digest_text(data)
+ await _log_event(
+ "herman",
+ "briefing",
+ "Live briefing (Telegram)",
+ reply[:1500],
+ {"channel": channel, "source": "live_digest"},
+ channel=channel,
+ )
+ return {
+ "agent": "herman",
+ "agent_label": "Herman · Briefing",
+ "reply": reply,
+ "delegated_agents": ["herman"],
+ "routing_reason": "Live cockpit-samenvatting",
+ "agent_steps": _agent_steps([], "Live digest uit briefing engine"),
+ }
+
if webbuilder_agent.wants_website(message):
try:
outcome = await webbuilder_agent.generate_from_message(message, channel=channel, wait=False)
diff --git a/cockpit/briefing-charts.js b/cockpit/briefing-charts.js
new file mode 100644
index 0000000..bba01e6
--- /dev/null
+++ b/cockpit/briefing-charts.js
@@ -0,0 +1,475 @@
+window.BriefingCharts = (function () {
+ var charts = {};
+ var typewriterTimer = null;
+
+ function destroyAll() {
+ Object.keys(charts).forEach(function (k) {
+ if (charts[k]) { charts[k].destroy(); charts[k] = null; }
+ });
+ }
+
+ function parseContent(content) {
+ var parts = (content || '').split(/\n---\n/);
+ var ai = parts[0] || '';
+ var summary = '', actions = [], longTerm = [];
+ var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|##\s*Lange termijn|$)/i);
+ if (sm) summary = sm[1].trim().replace(/\*\*/g, '');
+ var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*?)(?=##\s*Lange termijn|$)/i);
+ if (am) actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
+ var lm = ai.match(/##\s*Lange termijn[^\n]*\n([\s\S]*)/i);
+ if (lm) longTerm = lm[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
+ if (!summary && ai.trim()) summary = ai.trim().slice(0, 800).replace(/\*\*/g, '');
+ return { summary: summary, actions: actions, longTerm: longTerm };
+ }
+
+ function typewriter(el, text, speed) {
+ if (!el) return;
+ if (typewriterTimer) clearInterval(typewriterTimer);
+ el.textContent = '';
+ if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; }
+ var i = 0;
+ typewriterTimer = setInterval(function () {
+ if (i < text.length) { el.textContent += text.charAt(i); i++; }
+ else clearInterval(typewriterTimer);
+ }, speed || 8);
+ }
+
+ function countUp(el, end, prefix, suffix) {
+ if (!el) return;
+ prefix = prefix || ''; suffix = suffix || '';
+ var start = 0, dur = 600, t0 = performance.now();
+ function step(t) {
+ var p = Math.min(1, (t - t0) / dur);
+ var v = Math.round(start + (end - start) * p);
+ el.textContent = prefix + v.toLocaleString('nl-NL') + suffix;
+ if (p < 1) requestAnimationFrame(step);
+ }
+ requestAnimationFrame(step);
+ }
+
+ function chartColors() {
+ return {
+ gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)',
+ green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)',
+ gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0',
+ };
+ }
+
+ function renderPipeline(canvas, stats) {
+ if (!canvas || typeof Chart === 'undefined') return;
+ var rows = stats.deals_by_stage || [];
+ var c = chartColors();
+ if (charts.pipeline) charts.pipeline.destroy();
+ charts.pipeline = new Chart(canvas, {
+ type: 'bar',
+ data: { labels: rows.map(function (r) { return r.stage || '?'; }), datasets: [{ label: 'EUR', data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.gold, borderRadius: 6 }] },
+ options: { responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } },
+ scales: { y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } }, x: { ticks: { color: c.text }, grid: { display: false } } } },
+ });
+ }
+
+ function renderSentiment(canvas, stats) {
+ if (!canvas || typeof Chart === 'undefined') return;
+ var files = stats.nas_files || [], counts = { positive: 0, neutral: 0, negative: 0 };
+ files.forEach(function (f) { var s = (f.sentiment_label || 'neutral').toLowerCase(); if (counts[s] !== undefined) counts[s]++; });
+ if (!files.length) counts.neutral = 1;
+ var c = chartColors();
+ if (charts.sentiment) charts.sentiment.destroy();
+ charts.sentiment = new Chart(canvas, {
+ type: 'doughnut',
+ data: { labels: ['Positief', 'Neutraal', 'Negatief'], datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }] },
+ options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } } },
+ });
+ }
+
+ function renderWords(canvas, stats) {
+ if (!canvas || typeof Chart === 'undefined') return;
+ var rows = (stats.top_words || []).slice(0, 8), c = chartColors();
+ if (charts.words) charts.words.destroy();
+ charts.words = new Chart(canvas, {
+ type: 'bar',
+ data: { labels: rows.map(function (r) { return r.lemma; }), datasets: [{ data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.cyan, borderRadius: 6 }] },
+ options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } },
+ scales: { x: { ticks: { color: c.text }, grid: { color: c.grid } }, y: { ticks: { color: c.text }, grid: { display: false } } } },
+ });
+ }
+
+ function renderAgents(canvas, stats) {
+ if (!canvas || typeof Chart === 'undefined') return;
+ var map = {};
+ (stats.recent_events || []).forEach(function (e) { var a = e.agent_name || 'other'; map[a] = (map[a] || 0) + 1; });
+ var labels = Object.keys(map), c = chartColors();
+ if (charts.agents) charts.agents.destroy();
+ charts.agents = new Chart(canvas, {
+ type: 'polarArea',
+ data: { labels: labels, datasets: [{ data: labels.map(function (k) { return map[k]; }), backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray] }] },
+ options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } },
+ scales: { r: { ticks: { display: false }, grid: { color: c.grid } } } },
+ });
+ }
+
+ function eqBars() {
+ return '
';
+ }
+
+ function sparklineSvg(values, trend) {
+ if (!values || !values.length) return '';
+ var min = Math.min.apply(null, values), max = Math.max.apply(null, values);
+ var range = max - min || 1;
+ var pts = values.map(function (v, i) {
+ var x = (i / (values.length - 1 || 1)) * 100;
+ var y = 100 - ((v - min) / range) * 80 - 10;
+ return x.toFixed(1) + ',' + y.toFixed(1);
+ }).join(' ');
+ var color = trend === 'down' ? '#fb7185' : '#4ade80';
+ return '';
+ }
+
+ function stockEqBars(trend) {
+ var cls = trend === 'down' ? ' hm-eq-down' : '';
+ return '
';
+ }
+
+ function renderStocksMini(container, stats) {
+ if (!container) return;
+ var stocks = (stats.market_stocks || []).slice(0, 4);
+ var summary = stats.market_summary || {};
+ if (!stocks.length) {
+ container.innerHTML = 'Beurs openen →';
+ return;
+ }
+ container.innerHTML = stocks.map(function (s) {
+ var pct = Number(s.change_pct || 0);
+ return '' +
+ '' + (s.symbol || s.name) + ' ' +
+ (pct >= 0 ? '+' : '') + pct.toFixed(2) + '%';
+ }).join('') + 'Gem. ' +
+ (summary.avg_change_pct || 0) + '% · alle koersen →';
+ }
+
+ function renderStocks(container, stats) {
+ if (!container) return;
+ var stocks = stats.market_stocks || [];
+ var summary = stats.market_summary || {};
+ var meta = document.getElementById('market-updated-at');
+ if (meta) {
+ var avg = summary.avg_change_pct;
+ meta.textContent = stocks.length ? ('Gem. ' + (avg >= 0 ? '+' : '') + Number(avg || 0).toFixed(2) + '% · ' + (summary.quote_count || stocks.length) + ' quotes') : 'Beurs data laden…';
+ }
+ if (!stocks.length) {
+ container.innerHTML = 'Beursdata tijdelijk niet beschikbaar — probeer Live data opnieuw.
';
+ return;
+ }
+ container.innerHTML = stocks.map(function (s) {
+ var pct = Number(s.change_pct || 0);
+ var up = pct >= 0;
+ var price = s.price != null ? Number(s.price).toFixed(2) : '—';
+ var cur = s.currency || 'EUR';
+ return '' +
+ '
' + (s.symbol || '') + '' + (s.name || '') + '
' +
+ '
' + (up ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(2) + '%' +
+ '
' + price + ' ' + cur + '
' +
+ '
' + (s.chain || s.market || '') + '
' +
+ sparklineSvg(s.sparkline || [], s.trend) + stockEqBars(s.trend) + '
';
+ }).join('');
+ }
+
+ function rssTeaserHtml(stats) {
+ var n = stats.rss_items || (stats.rss_live || []).length || 0;
+ return '';
+ }
+
+ function renderFoodHighlights(container, stats) {
+ if (!container) return;
+ container.innerHTML = rssTeaserHtml(stats);
+ }
+
+ function renderRegulations(container, stats) {
+ if (!container) return;
+ var items = stats.regulation_highlights || [];
+ if (!items.length) {
+ container.innerHTML = 'Regelgeving feeds — klik RSS refresh in Retail 360
';
+ return;
+ }
+ container.innerHTML = items.map(function (r) {
+ var cat = r.category === 'cbs' ? 'CBS' : 'REG';
+ return '';
+ }).join('');
+ }
+
+ function renderTrends(canvas, stats) {
+ if (!canvas || typeof Chart === 'undefined') return;
+ var rows = stats.market_trends || [];
+ var c = chartColors();
+ if (charts.trends) charts.trends.destroy();
+ if (!rows.length) return;
+ charts.trends = new Chart(canvas, {
+ type: 'bar',
+ data: {
+ labels: rows.map(function (r) { return (r.trend_name || '?').slice(0, 18); }),
+ datasets: [{
+ label: 'Kans %',
+ data: rows.map(function (r) { return Math.round(Number(r.opportunity_score || 0) * 100); }),
+ backgroundColor: [c.green, c.cyan, c.gold, c.purple || '#a855f7'],
+ borderRadius: 6,
+ }],
+ },
+ options: {
+ responsive: true,
+ plugins: { legend: { display: false }, title: { display: true, text: 'Markt trend scores', color: c.text } },
+ scales: {
+ y: { max: 100, ticks: { color: c.text, callback: function (v) { return v + '%'; } }, grid: { color: c.grid } },
+ x: { ticks: { color: c.text, maxRotation: 45 }, grid: { display: false } },
+ },
+ },
+ });
+ }
+
+ function kpiValue(id, stats) {
+ var summary = stats.market_summary || {};
+ var best = summary.best_performer || {};
+ var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
+ switch (id) {
+ case 'pipeline': return '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL');
+ case 'clients_active': return (stats.clients_active || 0) + ' / ' + (stats.clients_total || stats.clients || 0);
+ case 'clients_total': return String(stats.clients_total || stats.clients || 0);
+ case 'crm_partnerships': return String(stats.crm_partnerships || 0);
+ case 'supermarkets': return (stats.supermarkets || 0).toLocaleString('nl-NL');
+ case 'wholesalers': return String(stats.wholesalers || 0);
+ case 'trending_food': return String(trendCount || 0);
+ case 'rss_items': return String(stats.rss_items || (stats.rss_live || []).length || 0);
+ case 'rss_bookmarks': return String((stats.rss_bookmarks || []).length || stats.rss_bookmarks_count || 0);
+ case 'pending_approvals': return String(stats.pending_approvals || 0);
+ case 'deals': return String(stats.deals || 0);
+ case 'products': return String(stats.products || 0);
+ case 'suppliers': return String(stats.suppliers || 0);
+ case 'nas_docs': return String(stats.nas_docs || 0);
+ case 'promo_campaigns': return String(stats.promo_campaigns || 0);
+ case 'market_best': return best.symbol ? (best.symbol + ' ' + Number(best.change_pct || 0).toFixed(1) + '%') : '—';
+ default: return '—';
+ }
+ }
+
+ function kpiPct(id, stats) {
+ var summary = stats.market_summary || {};
+ var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
+ switch (id) {
+ case 'pipeline': return '72%';
+ case 'clients_active':
+ return Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%';
+ case 'crm_partnerships': return '45%';
+ case 'supermarkets': return '88%';
+ case 'trending_food': return Math.min(95, trendCount * 10) + '%';
+ case 'rss_items': return Math.min(95, ((stats.rss_items || 0) / 10)) + '%';
+ case 'pending_approvals': return '30%';
+ case 'market_best': return Math.min(95, Math.abs(Number(summary.avg_change_pct || 0)) * 10) + '%';
+ default: return '50%';
+ }
+ }
+
+ function renderKpis(container, stats, selectedKpis) {
+ if (!container) return;
+ var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {};
+ var order = selectedKpis || window._dashboardKpis ||
+ (window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) ||
+ ['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
+ var items = order.map(function (id) {
+ var m = meta[id] || { label: id, sub: '', icon: '▪', color: '#94a3b8' };
+ return {
+ label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8',
+ pct: kpiPct(id, stats), val: kpiValue(id, stats), link: m.link,
+ };
+ });
+ if (!items.length) {
+ container.innerHTML = 'Geen KPI\'s geselecteerd — klik Dashboard instellen.
';
+ return;
+ }
+ container.className = 'hm-neo-kpi-row';
+ container.innerHTML = items.map(function (it) {
+ var inner = '' +
+ '
' +
+ '
' + it.label + '
' + it.sub + '
' +
+ '
' + it.val + '
' + eqBars() + '
';
+ return it.link ? '' + inner + '' : inner;
+ }).join('');
+ }
+
+ function renderRssFeed(container, stats) {
+ if (!container) return;
+ var items = stats.rss_live || stats.trending_food || stats.food_market_highlights || [];
+ var count = stats.rss_items || items.length;
+ var head = '';
+ if (!items.length) {
+ container.innerHTML = head + 'Geen RSS items — klik RSS ophalen of ga naar Marketing Hub.
';
+ var btn = container.querySelector('#btn-rss-refresh-dash');
+ if (btn) btn.addEventListener('click', function () {
+ if (window._refreshRssDash) window._refreshRssDash();
+ });
+ return;
+ }
+ container.innerHTML = head + '';
+ var refreshBtn = container.querySelector('#btn-rss-refresh-dash');
+ if (refreshBtn) refreshBtn.addEventListener('click', function () {
+ if (window._refreshRssDash) window._refreshRssDash();
+ });
+ }
+
+ function renderRetail(container, stats) {
+ if (!container) return;
+ container.innerHTML = rssTeaserHtml(stats);
+ }
+
+ function renderMilestones(container, stats) {
+ if (!container) return;
+ var ms = stats.milestones_pending || [];
+ if (!ms.length) {
+ container.innerHTML = 'Nog geen milestones — voeg toe via Retail 360 → Sales tab
';
+ return;
+ }
+ container.innerHTML = ms.map(function (m) {
+ return '' + (m.title || '') + '
' + (m.chain || '') + ' ' + (m.store_name || '') + '
';
+ }).join('');
+ }
+
+ function renderExecutiveSummary(container, stats) {
+ if (!container) return;
+ var items = [
+ { icon: '🤝', label: 'Actieve klanten', val: (stats.clients_active || 0) + ' van ' + (stats.clients_total || stats.clients || 0), link: '/clients' },
+ { icon: '💰', label: 'Pipeline', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), link: '/deals' },
+ { icon: '🏪', label: 'CRM partnerships', val: stats.crm_partnerships || 0, link: '/retail' },
+ { icon: '🛒', label: 'Supermarkten DB', val: (stats.supermarkets || 0).toLocaleString('nl-NL'), link: '/retail' },
+ { icon: '📦', label: 'Groothandels', val: stats.wholesalers || 0, link: '/retail' },
+ { icon: '✓', label: 'Goedkeuringen open', val: stats.pending_approvals || 0, link: '/' },
+ { icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
+ { icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
+ ];
+ var trending = (stats.trending_food || stats.food_market_highlights || []).length;
+ var rssN = stats.rss_items || (stats.rss_live || []).length || trending;
+ var ms = (stats.milestones_pending || []).slice(0, 3);
+ var html = '';
+ if (rssN) {
+ html += '';
+ }
+ if (ms.length) {
+ html += 'Open milestones
';
+ ms.forEach(function (m) {
+ html += '- ' + (m.title || '') + ' · ' + (m.chain || '') + ' ' + (m.store_name || '') + '
';
+ });
+ html += '
';
+ }
+ var pending = stats.pending_approval_requests || [];
+ if (pending.length) {
+ html += '⏳ Open goedkeuringen
';
+ pending.forEach(function (p) {
+ html += '- @' + (p.agent_key || '') + ' · ' + (p.title || '') + '
';
+ });
+ html += '
';
+ }
+ html += '';
+ container.innerHTML = html;
+ }
+
+ function renderText(root, content, createdAt) {
+ if (!root) return;
+ var parsed = parseContent(content);
+ var meta = document.getElementById('briefing-meta');
+ if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ');
+ typewriter(document.getElementById('briefing-summary-text'), parsed.summary, 8);
+ var actEl = document.getElementById('briefing-actions-list');
+ if (actEl) actEl.innerHTML = parsed.actions.length ? parsed.actions.map(function (a) { return '' + a + ''; }).join('') : 'Genereer dagrapport voor actiepunten';
+ var longEl = document.getElementById('briefing-longterm-list');
+ if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '' + a + ''; }).join('') : 'Halal kant-en-klaar partnerships schalen';
+ }
+
+ function renderLiveSummary(digest, instant) {
+ if (!digest) return;
+ var sumEl = document.getElementById('briefing-summary-text');
+ var actEl = document.getElementById('briefing-actions-list');
+ var longEl = document.getElementById('briefing-longterm-list');
+ var meta = document.getElementById('briefing-meta');
+ var summary = digest.summary || '';
+ if (sumEl) {
+ if (instant) {
+ if (typewriterTimer) clearInterval(typewriterTimer);
+ sumEl.textContent = summary || 'Live samenvatting laden…';
+ } else {
+ typewriter(sumEl, summary, 4);
+ }
+ }
+ if (actEl) {
+ var actions = digest.actions || [];
+ actEl.innerHTML = actions.length
+ ? actions.map(function (a) { return '' + a + ''; }).join('')
+ : 'Geen urgente acties — check agents & retail';
+ }
+ if (longEl) {
+ var lt = digest.long_term || [];
+ longEl.innerHTML = lt.length
+ ? lt.map(function (a) { return '' + a + ''; }).join('')
+ : 'Halal kant-en-klaar partnerships schalen';
+ }
+ if (meta && digest.updated_at) {
+ var ts = String(digest.updated_at).substring(0, 19).replace('T', ' ');
+ var n = digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
+ meta.textContent = 'Live samenvatting: ' + ts + ' UTC' + n;
+ }
+ }
+
+ function renderLive(root, stats, vizMode, kpiSelection, liveDigest) {
+ if (!root || !stats) return;
+ var mode = vizMode || window._dashboardVizMode || 'neo-bars';
+ var kpis = kpiSelection || window._dashboardKpis;
+ if (liveDigest) renderLiveSummary(liveDigest, true);
+ else if (stats.live_digest) renderLiveSummary(stats.live_digest, true);
+ renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
+ renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
+ renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
+ renderRetail(document.getElementById('briefing-retail'), stats);
+ renderMilestones(document.getElementById('briefing-milestones'), stats);
+ var actEl = document.getElementById('briefing-activity-log');
+ if (actEl) {
+ var log = stats.activity_log || [];
+ actEl.innerHTML = log.length ? log.slice(0, 12).map(function (e) { return '' + e + ''; }).join('')
+ : 'Nog geen agent-acties vandaag.';
+ }
+ renderPipeline(document.getElementById('chart-pipeline'), stats);
+ renderWords(document.getElementById('chart-words'), stats);
+ if (window.VizEngine) {
+ VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
+ VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
+ } else {
+ renderSentiment(document.getElementById('chart-sentiment'), stats);
+ renderAgents(document.getElementById('chart-agents'), stats);
+ }
+ }
+
+ function render(root, stats, content, createdAt, vizMode) {
+ renderLive(root, stats, vizMode);
+ renderText(root, content, createdAt);
+ }
+
+ return {
+ render: render, renderLive: renderLive, renderText: renderText, renderLiveSummary: renderLiveSummary,
+ renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
+ renderKpis: renderKpis, destroyAll: destroyAll,
+ };
+})();
diff --git a/cockpit/briefing.py b/cockpit/briefing.py
new file mode 100644
index 0000000..a818a02
--- /dev/null
+++ b/cockpit/briefing.py
@@ -0,0 +1,872 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from datetime import date, datetime, timezone
+from typing import Any
+
+from app.config import settings
+from app.db import execute, fetch_all, fetch_one
+from app.services import market_stocks, llm_router
+
+
+def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
+ try:
+ clause = f" WHERE {where}" if where else ""
+ row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}", params or None)
+ return int(row["c"]) if row else 0
+ except Exception:
+ return 0
+
+
+def _safe_sum(table: str, column: str, where: str = "", params: tuple = ()) -> float:
+ try:
+ clause = f" WHERE {where}" if where else ""
+ row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}", params or None)
+ return float(row["total"]) if row else 0.0
+ except Exception:
+ return 0.0
+
+
+def serialize_stats(data: dict[str, Any]) -> dict[str, Any]:
+ def _default(o: Any) -> Any:
+ if hasattr(o, "isoformat"):
+ return o.isoformat()
+ if hasattr(o, "__float__"):
+ try:
+ return float(o)
+ except (TypeError, ValueError):
+ pass
+ return str(o)
+
+ return json.loads(json.dumps(data, default=_default))
+
+
+def _briefing_step(
+ phase: str,
+ source: str,
+ agent: str,
+ status: str,
+ message: str,
+ detail: str | None = None,
+) -> dict[str, Any]:
+ return {
+ "type": "step",
+ "phase": phase,
+ "source": source,
+ "agent": agent,
+ "status": status,
+ "message": message,
+ "detail": detail,
+ "at": datetime.now(timezone.utc).isoformat(),
+ }
+
+
+def _collect_crm_core(data: dict[str, Any]) -> None:
+ data["clients"] = _safe_count("clients")
+ data["deals"] = _safe_count("deals")
+ data["products"] = _safe_count("products")
+ data["suppliers"] = _safe_count("suppliers")
+ data["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
+ data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'")
+
+
+def _collect_agent_queue(data: dict[str, Any]) -> None:
+ try:
+ data["pending_approval_requests"] = fetch_all(
+ """SELECT id, agent_key, action_type, title, query_payload, created_at
+ FROM agent_action_requests WHERE status = 'pending'
+ ORDER BY created_at ASC LIMIT 15"""
+ )
+ data["pending_approvals"] = len(data["pending_approval_requests"])
+ except Exception:
+ data["pending_approval_requests"] = []
+
+ try:
+ data["recent_executed_actions"] = fetch_all(
+ """SELECT id, agent_key, action_type, title, result, executed_at, approved_by
+ FROM agent_action_requests
+ WHERE status = 'executed' AND executed_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY executed_at DESC LIMIT 12"""
+ )
+ except Exception:
+ data["recent_executed_actions"] = []
+
+
+def _collect_projects_ops(data: dict[str, Any]) -> None:
+ try:
+ data["project_assets_recent"] = fetch_all(
+ """SELECT pa.title, pa.asset_type, pa.source_agent, pa.created_at, cp.name AS project_name
+ FROM project_assets pa
+ JOIN cockpit_projects cp ON cp.id = pa.project_id
+ WHERE pa.created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY pa.created_at DESC LIMIT 15"""
+ )
+ except Exception:
+ data["project_assets_recent"] = []
+
+ try:
+ data["ops_maintenance_open"] = fetch_all(
+ """SELECT severity, title, body, created_at FROM ops_maintenance_notes
+ WHERE resolved = false ORDER BY created_at DESC LIMIT 8"""
+ )
+ except Exception:
+ data["ops_maintenance_open"] = []
+
+ try:
+ data["config_backups_recent"] = fetch_all(
+ """SELECT status, message, commit_ref, created_at FROM config_backups
+ ORDER BY created_at DESC LIMIT 5"""
+ )
+ except Exception:
+ data["config_backups_recent"] = []
+
+ try:
+ data["sysops_activity_24h"] = fetch_all(
+ """SELECT action_type, title, body, commit_ref, files_changed, status, created_at
+ FROM sysops_activity
+ WHERE created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY created_at DESC LIMIT 20"""
+ )
+ except Exception:
+ data["sysops_activity_24h"] = []
+
+ try:
+ data["sysops_events_24h"] = fetch_all(
+ """SELECT event_type, title, body, status, created_at, metadata
+ FROM agent_events
+ WHERE LOWER(agent_name) = 'sysops'
+ AND created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY created_at DESC LIMIT 15"""
+ )
+ except Exception:
+ data["sysops_events_24h"] = []
+
+
+def _collect_pipeline_events(data: dict[str, Any]) -> None:
+ try:
+ data["deals_by_stage"] = fetch_all(
+ "SELECT stage, COUNT(*) AS cnt, COALESCE(SUM(value), 0) AS total FROM deals GROUP BY stage ORDER BY cnt DESC"
+ )
+ except Exception:
+ data["deals_by_stage"] = []
+
+ try:
+ data["recent_clients"] = fetch_all(
+ "SELECT name, stage, email, created_at FROM clients ORDER BY created_at DESC LIMIT 5"
+ )
+ except Exception:
+ data["recent_clients"] = []
+
+ try:
+ data["recent_events"] = fetch_all(
+ """SELECT agent_name, event_type, title, status, created_at
+ FROM agent_events ORDER BY created_at DESC LIMIT 12"""
+ )
+ except Exception:
+ data["recent_events"] = []
+
+ try:
+ data["recent_handoffs"] = fetch_all(
+ """SELECT from_agent, to_agent, handoff_type, status, created_at, correlation_id::text
+ FROM agent_handoffs
+ WHERE created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY created_at DESC LIMIT 20"""
+ )
+ except Exception:
+ data["recent_handoffs"] = []
+
+ try:
+ data["pending_items"] = fetch_all(
+ """SELECT agent_name, title, event_type, created_at
+ FROM agent_events WHERE status = 'needs_approval'
+ ORDER BY created_at DESC LIMIT 8"""
+ )
+ except Exception:
+ data["pending_items"] = []
+
+
+def _collect_nas_analytics(data: dict[str, Any]) -> None:
+ try:
+ row = fetch_one(
+ """SELECT COUNT(*) AS docs, COALESCE(SUM(word_count), 0) AS words,
+ COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment
+ FROM document_analytics"""
+ )
+ data["nas_docs"] = int(row["docs"] or 0) if row else 0
+ data["nas_words"] = int(row["words"] or 0) if row else 0
+ data["nas_sentiment"] = round(float(row["avg_sentiment"] or 0), 3) if row else 0.0
+ except Exception:
+ data["nas_docs"] = data["nas_words"] = 0
+ data["nas_sentiment"] = 0.0
+
+ try:
+ data["nas_files"] = fetch_all(
+ """SELECT filename, doc_type, sentiment_label, word_count
+ FROM document_analytics ORDER BY analyzed_at DESC LIMIT 8"""
+ )
+ except Exception:
+ data["nas_files"] = []
+
+ try:
+ data["top_words"] = fetch_all(
+ """SELECT lemma, SUM(count) AS total FROM document_word_counts
+ WHERE NOT is_stopword GROUP BY lemma ORDER BY total DESC LIMIT 10"""
+ )
+ except Exception:
+ data["top_words"] = []
+
+ try:
+ data["calendar_events"] = fetch_all(
+ """SELECT ce.title, ce.starts_at, ce.ends_at, c.name AS client_name
+ FROM calendar_events ce
+ LEFT JOIN clients c ON c.id = ce.client_id
+ WHERE ce.starts_at >= NOW() - INTERVAL '1 day'
+ AND ce.starts_at <= NOW() + INTERVAL '7 days'
+ ORDER BY ce.starts_at ASC LIMIT 10"""
+ )
+ except Exception:
+ data["calendar_events"] = []
+
+
+def _collect_retail_intel(data: dict[str, Any]) -> None:
+ data["supermarkets"] = _safe_count("supermarkets")
+ data["clients_active"] = _safe_count("clients", "stage = 'active'")
+ data["clients_total"] = _safe_count("clients")
+ data["crm_partnerships"] = _safe_count("supermarkets", "partnership_status = 'active'")
+ data["wholesalers"] = _safe_count("wholesalers")
+ data["rss_bookmarks"] = _safe_count("rss_bookmarks")
+ data["promo_campaigns"] = _safe_count("promo_campaigns", "status = 'active'")
+
+ try:
+ data["top_opportunities"] = fetch_all(
+ """SELECT s.name, 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 5"""
+ )
+ except Exception:
+ data["top_opportunities"] = []
+
+ try:
+ data["milestones_pending"] = fetch_all(
+ """SELECT sm.title, sm.milestone_type, sm.status, sm.target_date, sm.value_eur,
+ s.name AS store_name, s.chain, c.name AS client_name
+ FROM sales_milestones sm
+ LEFT JOIN supermarkets s ON s.id = sm.supermarket_id
+ LEFT JOIN clients c ON c.id = sm.client_id
+ WHERE sm.status IN ('pending', 'in_progress')
+ ORDER BY sm.target_date ASC NULLS LAST, sm.created_at DESC LIMIT 8"""
+ )
+ except Exception:
+ data["milestones_pending"] = []
+
+ try:
+ data["milestones_recent"] = fetch_all(
+ """SELECT sm.title, sm.milestone_type, sm.status, sm.completed_at, sm.value_eur,
+ s.name AS store_name, s.chain
+ FROM sales_milestones sm
+ LEFT JOIN supermarkets s ON s.id = sm.supermarket_id
+ ORDER BY sm.created_at DESC LIMIT 5"""
+ )
+ except Exception:
+ data["milestones_recent"] = []
+
+
+def _collect_rss_market(data: dict[str, Any]) -> None:
+ try:
+ data["rss_highlights"] = fetch_all(
+ """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url
+ FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
+ WHERE i.title ILIKE ANY (ARRAY['%kant%','%maaltijd%','%supermarkt%','%retail%','%halal%','%jumbo%','%meal%'])
+ ORDER BY i.published_at DESC NULLS LAST LIMIT 8"""
+ )
+ except Exception:
+ data["rss_highlights"] = []
+
+ try:
+ data["market_trends"] = fetch_all(
+ "SELECT trend_name, description, opportunity_score FROM market_trends ORDER BY updated_at DESC LIMIT 4"
+ )
+ except Exception:
+ data["market_trends"] = []
+
+ try:
+ quotes = market_stocks.fetch_retail_quotes()
+ data["market_stocks"] = quotes
+ data["market_summary"] = market_stocks.market_summary(quotes)
+ except Exception:
+ data["market_stocks"] = []
+ data["market_summary"] = {}
+
+ try:
+ data["regulation_highlights"] = fetch_all(
+ """SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
+ FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
+ WHERE f.category IN ('regelgeving', 'cbs')
+ ORDER BY i.published_at DESC NULLS LAST LIMIT 8"""
+ )
+ except Exception:
+ data["regulation_highlights"] = []
+
+ try:
+ data["trending_food"] = fetch_all(
+ """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
+ f.category, i.published_at
+ FROM rss_items i
+ JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
+ WHERE f.category IN ('food', 'markt', 'supermarkt', 'retail', 'kant-en-klaar')
+ OR f.name ILIKE '%retaildetail%'
+ ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 10"""
+ )
+ except Exception:
+ data["trending_food"] = []
+
+ if not data.get("trending_food"):
+ try:
+ data["trending_food"] = fetch_all(
+ """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
+ f.category, i.published_at
+ FROM rss_items i
+ JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
+ ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 12"""
+ )
+ except Exception:
+ data["trending_food"] = []
+
+ try:
+ data["food_market_highlights"] = fetch_all(
+ """SELECT i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url, f.category
+ FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
+ WHERE f.category IN ('food', 'markt', 'supermarkt', 'kant-en-klaar', 'retail')
+ OR f.name ILIKE '%retaildetail%'
+ ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
+ )
+ except Exception:
+ data["food_market_highlights"] = []
+
+ if not data.get("food_market_highlights"):
+ data["food_market_highlights"] = list(data.get("trending_food") or [])[:10]
+
+ try:
+ data["rss_live"] = fetch_all(
+ """SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
+ f.category, i.published_at
+ FROM rss_items i
+ JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
+ ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 25"""
+ )
+ except Exception:
+ data["rss_live"] = list(data.get("trending_food") or [])
+
+ data["rss_items"] = _safe_count("rss_items")
+
+
+def collect_briefing_data() -> dict[str, Any]:
+ data: dict[str, Any] = {
+ "date": date.today().isoformat(),
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ }
+ _collect_crm_core(data)
+ _collect_agent_queue(data)
+ _collect_projects_ops(data)
+ _collect_pipeline_events(data)
+ _collect_nas_analytics(data)
+ _collect_retail_intel(data)
+ _collect_rss_market(data)
+ data["activity_log"] = _build_activity_log(data)
+ return data
+
+
+async def stream_daily_briefing():
+ """Yield SSE step events while building the CEO daily report."""
+ data: dict[str, Any] = {
+ "date": date.today().isoformat(),
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ }
+
+ 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…")
+ _collect_crm_core(data)
+ yield _briefing_step(
+ "crm", "PostgreSQL", "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…")
+ _collect_agent_queue(data)
+ 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",
+ )
+ 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)…")
+ _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",
+ )
+
+ yield _briefing_step("pipeline", "PostgreSQL", "finance", "running", "Pipeline stages & agent feed ophalen…")
+ _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",
+ )
+
+ yield _briefing_step("nas", "NAS analytics", "knowledge", "running", "Document sentiment & top woorden analyseren…")
+ _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…")
+ _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",
+ )
+ 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…")
+ _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",
+ )
+
+ 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",
+ )
+ 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…",
+ )
+ try:
+ ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
+ except (asyncio.TimeoutError, Exception) as exc:
+ ai_part = ""
+ yield _briefing_step(
+ "ai", "Ollama", "herman", "warn",
+ "AI timeout — gebruik 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)",
+ )
+
+ yield _briefing_step("compose", "Herman", "herman", "running", "Rapport samenstellen & opslaan…")
+ template = build_template_report(data)
+ if ai_part and len(ai_part.strip()) > 80:
+ content = ai_part.strip() + "\n\n---\n\n" + template
+ else:
+ content = _fallback_summary(data) + "\n\n---\n\n" + template
+ _save_briefing(content, data)
+ stats = serialize_stats(data)
+ yield _briefing_step("compose", "PostgreSQL", "herman", "ok", "Dagrapport opgeslagen in daily_briefings")
+
+ yield {
+ "type": "done",
+ "content": content,
+ "stats": stats,
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ }
+
+
+def _build_activity_log(data: dict[str, Any]) -> list[str]:
+ lines: list[str] = []
+ for row in data.get("pending_approval_requests") or []:
+ agent = row.get("agent_key") or "agent"
+ action = row.get("action_type") or "actie"
+ title = row.get("title") or ""
+ if action == "maintenance_scan":
+ lines.append(f"⏳ SysOps vraagt toestemming voor update-scan: {title}")
+ elif action == "config_backup":
+ lines.append(f"⏳ SysOps vraagt goedkeuring backup: {title}")
+ else:
+ lines.append(f"⏳ {agent} wacht op goedkeuring ({action}): {title}")
+
+ for row in data.get("recent_executed_actions") or []:
+ lines.append(f"✓ Uitgevoerd door {row.get('agent_key')}: {row.get('title')}")
+
+ for row in data.get("project_assets_recent") or []:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts or "")[:16]
+ lines.append(
+ f"📁 Project asset ({row.get('project_name')}): {row.get('title')} "
+ f"[{row.get('asset_type')} · {row.get('source_agent')}] {ts_s}"
+ )
+
+ for row in data.get("ops_maintenance_open") or []:
+ lines.append(f"🔧 IT Ops [{row.get('severity')}]: {row.get('title')}")
+
+ for row in data.get("config_backups_recent") or []:
+ lines.append(f"💾 Backup {row.get('status')}: {row.get('message') or row.get('commit_ref')}")
+
+ for row in data.get("sysops_activity_24h") or []:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ cref = f" [{row.get('commit_ref')}]" if row.get("commit_ref") else ""
+ lines.append(f"🖥️ SysOps {row.get('action_type')}: {row.get('title')}{cref} ({ts_s})")
+
+ for row in data.get("sysops_events_24h") or []:
+ if (row.get("event_type") or "") == "gitea_sync":
+ continue
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ lines.append(f"🔧 SysOps: {row.get('title')} ({ts_s})")
+
+ for row in (data.get("recent_events") or [])[:8]:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ lines.append(f"⚡ {row.get('agent_name')}: {row.get('title')} ({ts_s})")
+
+ for row in (data.get("recent_handoffs") or [])[:8]:
+ 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})"
+ )
+
+ return lines[:25]
+
+
+def _strip_activity_prefix(line: str) -> str:
+ for prefix in ("⏳ ", "✓ ", "📁 ", "🔧 ", "💾 ", "🖥️ ", "⚡ ", "🔗 "):
+ if line.startswith(prefix):
+ return line[len(prefix) :].strip()
+ return line.strip()
+
+
+def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
+ """Live samenvatting zonder LLM — volgt cockpit-activiteit (polling / websocket)."""
+ activity = data.get("activity_log") or []
+ opp = data.get("top_opportunities") or []
+ ms = data.get("milestones_pending") or []
+ pending_n = int(data.get("pending_approvals") or 0)
+ executed = data.get("recent_executed_actions") or []
+ sysops = data.get("sysops_activity_24h") or []
+
+ summary_parts = [
+ (
+ f"Vandaag ({data['date']}) staat er €{data['pipeline_eur']:,.0f} in je pipeline, "
+ f"{data.get('crm_partnerships', 0)} actieve supermarkt-partnerships en "
+ f"{pending_n} open goedkeuring{'en' if pending_n != 1 else ''}."
+ )
+ ]
+
+ if activity:
+ recent = [_strip_activity_prefix(a) for a in activity[:4]]
+ summary_parts.append("Recent in het Cockpit: " + "; ".join(recent[:3]) + ".")
+ elif executed:
+ row = executed[0]
+ summary_parts.append(
+ f"Laatste uitgevoerde actie: {row.get('agent_key')} — {row.get('title')}."
+ )
+ elif sysops:
+ row = sysops[0]
+ summary_parts.append(f"Laatste SysOps: {row.get('title')}.")
+ elif data.get("recent_events"):
+ ev = data["recent_events"][0]
+ summary_parts.append(
+ f"Laatste agent-event: {ev.get('agent_name')} — {ev.get('title')}."
+ )
+ else:
+ summary_parts.append(
+ "Nog geen nieuwe activiteit vandaag — gebruik Voice, Agents of Export Intel om Herman te voeden."
+ )
+
+ if opp:
+ top = opp[0]
+ summary_parts.append(
+ f"Top retail-kans: {top.get('chain')} · {top.get('name')} in {top.get('city')} "
+ f"(score {round(float(top.get('halal_opportunity_score') or 0))}/100)."
+ )
+
+ actions: list[str] = []
+ for row in data.get("pending_approval_requests") or []:
+ label = row.get("title") or row.get("action_type") or "goedkeuring"
+ actions.append(f"Keur goed: @{row.get('agent_key')} — {label}")
+ for row in executed[:2]:
+ actions.append(f"Follow-up na {row.get('agent_key')}: {row.get('title')}")
+ for row in ms[:2]:
+ actions.append(f"Milestone: {row.get('title')} ({row.get('chain') or 'CRM'})")
+ if pending_n and not actions:
+ actions.append(f"Behandel {pending_n} open agent-goedkeuring(en) op het dashboard")
+ if not actions:
+ actions.extend([
+ "Open Retail 360 voor top halal-gap filialen",
+ "Check Marketing Live Feed voor kant-en-klaar trends",
+ ])
+
+ long_term = [
+ "Schaal CRM partnerships van proposal naar actief in top-10 kans-filialen",
+ "Halal kant-en-klaar listing bij regio's met hoogste demografische vraag",
+ "Wekelijks milestones review in Retail 360",
+ ]
+ if sysops:
+ long_term.insert(0, f"IT Ops: {len(sysops)} SysOps-acties in de laatste 24 uur vastleggen en reviewen")
+
+ return {
+ "summary": " ".join(summary_parts),
+ "actions": actions[:6],
+ "long_term": long_term[:5],
+ "updated_at": data.get("generated_at"),
+ "activity_count": len(activity),
+ "source": "live",
+ }
+
+
+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}*",
+ "",
+ "## KPI's",
+ f"- **Klanten:** {data['clients']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
+ f"- **Supermarkten in DB:** {data.get('supermarkets', 0)} · **CRM partnerships:** {data.get('crm_partnerships', 0)}",
+ f"- **Groothandels:** {data.get('wholesalers', 0)} · **Goedkeuringen open:** {data['pending_approvals']}",
+ "",
+ ]
+
+ if data.get("top_opportunities"):
+ lines.extend(["## Top halal-markt kansen (Retail 360)"])
+ for row in data["top_opportunities"]:
+ score = round(float(row.get("halal_opportunity_score") or 0))
+ lines.append(f"- **{row.get('chain')} · {row.get('name')}** ({row.get('city')}) — score {score}/100")
+ lines.append("")
+
+ if data.get("milestones_pending"):
+ lines.extend(["## Sales milestones — open"])
+ for row in data["milestones_pending"]:
+ td = row.get("target_date")
+ td_s = td.isoformat()[:10] if hasattr(td, "isoformat") else str(td or "—")[:10]
+ lines.append(f"- [{td_s}] **{row.get('title')}** · {row.get('chain') or ''} {row.get('store_name') or ''} · €{row.get('value_eur') or '—'}")
+ lines.append("")
+
+ if data.get("rss_highlights"):
+ lines.extend(["## Kant-en-klaar & supermarkt nieuws"])
+ for row in data["rss_highlights"]:
+ lines.append(f"- [{row.get('feed_name')}] {row.get('title')}")
+ lines.append("")
+
+ if data.get("market_trends"):
+ lines.extend(["## Markt trends"])
+ for row in data["market_trends"]:
+ pct = round(float(row.get("opportunity_score") or 0) * 100)
+ lines.append(f"- **{row.get('trend_name')}** ({pct}% kans) — {row.get('description') or ''}")
+ lines.append("")
+
+ lines.extend(["## Pipeline per stage"])
+ for row in data.get("deals_by_stage") or []:
+ lines.append(f"- **{row.get('stage')}:** {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}")
+ if not data.get("deals_by_stage"):
+ lines.append("- Geen deals in database.")
+
+ if data.get("calendar_events"):
+ lines.extend(["", "## Agenda (7 dagen)"])
+ for row in data["calendar_events"]:
+ ts = row.get("starts_at")
+ 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')}")
+
+ return "\n".join(lines)
+
+
+async def _ai_executive_summary(data: dict[str, Any]) -> str:
+ opp_lines = ""
+ for row in data.get("top_opportunities") or []:
+ opp_lines += f"- {row.get('chain')} {row.get('name')} ({row.get('city')}): score {round(float(row.get('halal_opportunity_score') or 0))}\n"
+
+ ms_lines = ""
+ 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"
+
+ 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"
+ 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"Top kansen:\n{opp_lines or '- geen data'}\n"
+ f"Milestones open:\n{ms_lines or '- geen milestones'}\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."
+ )
+ try:
+ return await llm_router.generate(prompt, system=system, timeout=120.0)
+ except Exception:
+ return ""
+
+
+def _fallback_summary(data: dict[str, Any]) -> str:
+ digest = build_live_digest(data)
+ lines = ["## Samenvatting", digest["summary"]]
+ lines.extend(["", "## Actiepunten vandaag — korte termijn"])
+ for a in digest["actions"]:
+ lines.append(f"- {a}")
+ 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"}
+ try:
+ execute(
+ "INSERT INTO daily_briefings (content, generated_by, metadata) VALUES (%s, %s, %s::jsonb)",
+ (content, "herman", json.dumps(metadata)),
+ )
+ except Exception:
+ pass
+ try:
+ execute(
+ """INSERT INTO llm_memory (category, subject, content, source, metadata, updated_at)
+ VALUES ('herman_daily', %s, %s, 'herman', %s::jsonb, NOW())""",
+ (
+ f"Briefing {data['date']}",
+ content[:8000],
+ json.dumps({"date": data["date"], "activity_count": len(data.get("activity_log") or [])}),
+ ),
+ )
+ except Exception:
+ pass
+ try:
+ execute(
+ """INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
+ (
+ "herman", "herman_delegate", "briefing",
+ f"CEO dagrapport {data['date']}", content[:2000],
+ "completed", "dashboard", json.dumps({"stats": safe, "activity_log": data.get("activity_log", [])}),
+ ),
+ )
+ except Exception:
+ pass
+
+
+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)
+ except (asyncio.TimeoutError, Exception):
+ ai_part = ""
+
+ if ai_part and len(ai_part.strip()) > 80:
+ content = ai_part.strip() + "\n\n---\n\n" + template
+ else:
+ content = _fallback_summary(data) + "\n\n---\n\n" + template
+
+ _save_briefing(content, data)
+ stats = serialize_stats(data)
+ stats["live_digest"] = build_live_digest(data)
+ return content, stats
diff --git a/cockpit/dashboard.html b/cockpit/dashboard.html
index 383222c..58f7daf 100644
--- a/cockpit/dashboard.html
+++ b/cockpit/dashboard.html
@@ -252,7 +252,7 @@
{% block scripts %}
-
+
-
+
-
+
{% endblock %}
{% block content %}
@@ -93,8 +93,8 @@
Herman briefing
-
Samenvatting
-
+
Samenvatting · live
+
📋 Herman activiteitenlog
@@ -252,7 +252,7 @@
{% block scripts %}
-
+
@@ -31,10 +31,10 @@
-
+
- Selecteer rijen met ☑ voor bulk-acties
+
@@ -137,8 +137,8 @@
-
-