Files
foodlinkk-command-center/cockpit/app/services/briefing.py
T
Aissa 4dd1b8265f 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.
2026-07-19 18:25:21 +00:00

1083 lines
44 KiB
Python

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
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 _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(),
}
_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)
_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] = {
"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 _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']}",
"",
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