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