From 4dd1b8265feaaa15c0a0d4f5e1ca8791a0be5d1e Mon Sep 17 00:00:00 2001 From: Aissa Date: Sun, 19 Jul 2026 18:25:01 +0000 Subject: [PATCH] Add live CEO briefing, Wereldexport CRM, halal engine, and realtime cockpit. Ship export intel with contact filters and CRM push, Herman live digest with Telegram briefing, halal recommendation engine, revenue cockpit, and dashboard polling/WebSocket fixes. --- .gitignore | 3 + cockpit/api.py | 161 +++++ cockpit/app/routes/api.py | 23 +- cockpit/app/routes/dashboard.py | 12 +- cockpit/app/routes/export_intel.py | 28 +- cockpit/app/routes/reco_proxy.py | 11 + cockpit/app/services/briefing.py | 342 +++++++++- cockpit/app/services/herman.py | 38 ++ cockpit/briefing-charts.js | 475 +++++++++++++ cockpit/briefing.py | 872 ++++++++++++++++++++++++ cockpit/dashboard.html | 16 +- cockpit/dashboard.py | 19 +- cockpit/static/css/export-intel.css | 5 + cockpit/static/css/herman-dashboard.css | 76 ++- cockpit/static/js/briefing-charts.js | 202 +++++- cockpit/static/js/export-intel.js | 78 ++- cockpit/static/js/live-pulse.js | 24 +- cockpit/static/js/revenue-cockpit.js | 7 + cockpit/templates/base.html | 2 +- cockpit/templates/dashboard.html | 51 +- cockpit/templates/export_intel.html | 66 +- cockpit/templates/revenue_cockpit.html | 2 +- tools-api/app/export_intel.py | 60 +- tools-api/app/halal_reco_engine.py | 276 ++++++++ tools-api/app/recommendations.py | 16 + 25 files changed, 2757 insertions(+), 108 deletions(-) create mode 100644 cockpit/api.py create mode 100644 cockpit/briefing-charts.js create mode 100644 cockpit/briefing.py create mode 100644 tools-api/app/halal_reco_engine.py 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 '

' + n + ' RSS artikelen beschikbaar. ' + + 'Open RSS tab →

'; + } + + 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 '
' + cat + '' + + '' + (r.title || '') + '' + + '' + (r.feed_name || '') + '
'; + }).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.icon + '
' + + '
' + 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 = '
' + count + ' items in database' + + ' · ' + + ' · Marketing Hub →
'; + 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 + '
' + items.slice(0, 20).map(function (r) { + var when = (r.published_at || '').substring(0, 16).replace('T', ' '); + var cat = (r.category || 'feed').toUpperCase(); + return '' + + '' + cat + '' + + '' + (r.title || '') + '' + + '' + (r.feed_name || 'RSS') + (when ? ' · ' + when : '') + ''; + }).join('') + '
'; + 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 = '
' + items.map(function (it) { + return '' + it.icon + '' + + '
' + it.label + '
' + it.val + '
'; + }).join('') + '
'; + if (rssN) { + html += '

' + rssN + ' RSS feeds — bekijk in RSS tab →

'; + } + if (ms.length) { + html += '

Open milestones

'; + } + var pending = stats.pending_approval_requests || []; + if (pending.length) { + html += '

⏳ Open goedkeuringen

'; + } + 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 @@
    -
    -