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