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.
This commit is contained in:
@@ -13,3 +13,6 @@ cockpit/static/uploads/
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
tmp/
|
||||
cockpit/tmp-upload/
|
||||
|
||||
+161
@@ -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}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 '<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
|
||||
}
|
||||
|
||||
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 '<svg class="hm-spark" viewBox="0 0 100 100" preserveAspectRatio="none"><polyline fill="none" stroke="' + color + '" stroke-width="3" points="' + pts + '"/></svg>';
|
||||
}
|
||||
|
||||
function stockEqBars(trend) {
|
||||
var cls = trend === 'down' ? ' hm-eq-down' : '';
|
||||
return '<div class="hm-eq' + cls + '"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
|
||||
}
|
||||
|
||||
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 = '<a href="/beurs" class="btn btn-sm">Beurs openen →</a>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = stocks.map(function (s) {
|
||||
var pct = Number(s.change_pct || 0);
|
||||
return '<a href="/beurs" class="beurs-mini-chip ' + (pct >= 0 ? 'up' : 'down') + '">' +
|
||||
'<strong>' + (s.symbol || s.name) + '</strong> ' +
|
||||
(pct >= 0 ? '+' : '') + pct.toFixed(2) + '%</a>';
|
||||
}).join('') + '<small style="display:block;margin-top:0.35rem;color:#64748b">Gem. ' +
|
||||
(summary.avg_change_pct || 0) + '% · <a href="/beurs">alle koersen →</a></small>';
|
||||
}
|
||||
|
||||
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 = '<p class="empty-state">Beursdata tijdelijk niet beschikbaar — probeer Live data opnieuw.</p>';
|
||||
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 '<div class="hm-stock-card hm-stock-' + (s.trend || (up ? 'up' : 'down')) + '">' +
|
||||
'<div class="hm-stock-head"><div><strong>' + (s.symbol || '') + '</strong><small>' + (s.name || '') + '</small></div>' +
|
||||
'<span class="hm-stock-pct ' + (up ? 'up' : 'down') + '">' + (up ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(2) + '%</span></div>' +
|
||||
'<div class="hm-stock-price">' + price + ' <small>' + cur + '</small></div>' +
|
||||
'<div class="hm-stock-chain">' + (s.chain || s.market || '') + '</div>' +
|
||||
sparklineSvg(s.sparkline || [], s.trend) + stockEqBars(s.trend) + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function rssTeaserHtml(stats) {
|
||||
var n = stats.rss_items || (stats.rss_live || []).length || 0;
|
||||
return '<p class="hm-rss-teaser">' + n + ' RSS artikelen beschikbaar. ' +
|
||||
'<a href="/?tab=rss">Open RSS tab →</a></p>';
|
||||
}
|
||||
|
||||
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 = '<p class="empty-state">Regelgeving feeds — klik RSS refresh in Retail 360</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = items.map(function (r) {
|
||||
var cat = r.category === 'cbs' ? 'CBS' : 'REG';
|
||||
return '<div class="hm-highlight-item"><span class="hm-cat-badge hm-cat-' + (r.category || 'reg') + '">' + cat + '</span>' +
|
||||
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
|
||||
'<small>' + (r.feed_name || '') + '</small></div>';
|
||||
}).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 = '<p class="empty-state">Geen KPI\'s geselecteerd — klik <strong>Dashboard instellen</strong>.</p>';
|
||||
return;
|
||||
}
|
||||
container.className = 'hm-neo-kpi-row';
|
||||
container.innerHTML = items.map(function (it) {
|
||||
var inner = '<div class="hm-neo-kpi"><div class="hm-neo-kpi-top">' +
|
||||
'<div class="hm-neo-ring" style="--ring-color:' + it.color + ';--ring-pct:' + it.pct + '"><div class="hm-neo-ring-inner">' + it.icon + '</div></div>' +
|
||||
'<div><div class="hm-neo-kpi-label">' + it.label + '</div><div class="hm-neo-kpi-sub">' + it.sub + '</div></div></div>' +
|
||||
'<div class="hm-neo-kpi-value">' + it.val + '</div>' + eqBars() + '</div>';
|
||||
return it.link ? '<a href="' + it.link + '" class="hm-neo-kpi-link">' + inner + '</a>' : 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 = '<div class="hm-rss-head"><span class="hm-live-dot"></span> ' + count + ' items in database' +
|
||||
' · <button type="button" class="btn btn-sm" id="btn-rss-refresh-dash">↻ RSS ophalen</button>' +
|
||||
' · <a href="/marketing">Marketing Hub →</a></div>';
|
||||
if (!items.length) {
|
||||
container.innerHTML = head + '<p class="empty-state">Geen RSS items — klik <strong>RSS ophalen</strong> of ga naar <a href="/marketing">Marketing Hub</a>.</p>';
|
||||
var btn = container.querySelector('#btn-rss-refresh-dash');
|
||||
if (btn) btn.addEventListener('click', function () {
|
||||
if (window._refreshRssDash) window._refreshRssDash();
|
||||
});
|
||||
return;
|
||||
}
|
||||
container.innerHTML = head + '<div class="hm-rss-list">' + items.slice(0, 20).map(function (r) {
|
||||
var when = (r.published_at || '').substring(0, 16).replace('T', ' ');
|
||||
var cat = (r.category || 'feed').toUpperCase();
|
||||
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="hm-rss-item">' +
|
||||
'<span class="hm-rss-cat">' + cat + '</span>' +
|
||||
'<span class="hm-rss-body"><strong>' + (r.title || '') + '</strong>' +
|
||||
'<small>' + (r.feed_name || 'RSS') + (when ? ' · ' + when : '') + '</small></span></a>';
|
||||
}).join('') + '</div>';
|
||||
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 = '<p class="empty-state">Nog geen milestones — voeg toe via <a href="/retail">Retail 360 → Sales tab</a></p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = ms.map(function (m) {
|
||||
return '<div class="milestone-item"><span class="milestone-dot"></span><div><strong>' + (m.title || '') + '</strong><br><small>' + (m.chain || '') + ' ' + (m.store_name || '') + '</small></div></div>';
|
||||
}).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 = '<div class="hm-exec-grid">' + items.map(function (it) {
|
||||
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
|
||||
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
|
||||
}).join('') + '</div>';
|
||||
if (rssN) {
|
||||
html += '<p class="hm-rss-teaser" style="margin-top:1rem">' + rssN + ' RSS feeds — <a href="/?tab=rss">bekijk in RSS tab →</a></p>';
|
||||
}
|
||||
if (ms.length) {
|
||||
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
|
||||
ms.forEach(function (m) {
|
||||
html += '<li>' + (m.title || '') + ' · ' + (m.chain || '') + ' ' + (m.store_name || '') + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
var pending = stats.pending_approval_requests || [];
|
||||
if (pending.length) {
|
||||
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#f59e0b">⏳ Open goedkeuringen</h4><ul class="hm-exec-list">';
|
||||
pending.forEach(function (p) {
|
||||
html += '<li><strong>@' + (p.agent_key || '') + '</strong> · ' + (p.title || '') + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">' +
|
||||
'<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener">Gitea</a> · ' +
|
||||
'<a href="/ops">IT Ops</a> · <a href="/packaging">Packaging</a> · ' +
|
||||
'<a href="/marketing?tab=publish">Automatisering</a> · ' +
|
||||
'<a href="/marketing">Marketing Hub</a> · <a href="/analytics">Analytics</a></p>';
|
||||
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 '<li>' + a + '</li>'; }).join('') : '<li>Genereer dagrapport voor actiepunten</li>';
|
||||
var longEl = document.getElementById('briefing-longterm-list');
|
||||
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
|
||||
}
|
||||
|
||||
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 '<li>' + a + '</li>'; }).join('')
|
||||
: '<li>Geen urgente acties — check agents & retail</li>';
|
||||
}
|
||||
if (longEl) {
|
||||
var lt = digest.long_term || [];
|
||||
longEl.innerHTML = lt.length
|
||||
? lt.map(function (a) { return '<li>' + a + '</li>'; }).join('')
|
||||
: '<li>Halal kant-en-klaar partnerships schalen</li>';
|
||||
}
|
||||
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 '<li>' + e + '</li>'; }).join('')
|
||||
: '<li class="muted">Nog geen agent-acties vandaag.</li>';
|
||||
}
|
||||
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,
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
+12
-4
@@ -252,7 +252,7 @@
|
||||
{% block scripts %}
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/dashboard-layout.js?v=4"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=13"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=14"></script>
|
||||
<script>
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
|
||||
@@ -264,6 +264,7 @@ function dashboardBriefing() {
|
||||
rssItems: [], rssTotal: 0, rssCategory: '', rssSearch: '', rssLoading: false,
|
||||
rssRegulations: {}, rssBookmarks: [],
|
||||
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
|
||||
liveDigest: INITIAL_BRIEFING.live_digest || null,
|
||||
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
|
||||
_prefs: null, _setDragMode: null, _saveTimer: null,
|
||||
applyDashboardPrefs(prefs) {
|
||||
@@ -274,7 +275,7 @@ function dashboardBriefing() {
|
||||
DashboardLayout.applyOrder(dash, order);
|
||||
DashboardLayout.applyVisibility(dash, prefs.dashboard_widgets);
|
||||
if (this._setDragMode) this._setDragMode(this.editLayout);
|
||||
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis);
|
||||
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis, this.liveDigest);
|
||||
},
|
||||
scheduleSave(patch) {
|
||||
if (!this._prefs) return;
|
||||
@@ -375,9 +376,14 @@ function dashboardBriefing() {
|
||||
if (cnt < 3) { sessionStorage.setItem('rss_auto_refreshed', '1'); await this.refreshRss(false); }
|
||||
}
|
||||
await this.loadProjectsMini();
|
||||
if (this.content) BriefingCharts.renderText(dash, this.content, this.createdAt);
|
||||
if (this.liveDigest) {
|
||||
BriefingCharts.renderLiveSummary(this.liveDigest, true);
|
||||
} else if (this.content) {
|
||||
BriefingCharts.renderText(dash, this.content, this.createdAt);
|
||||
}
|
||||
window._refreshBriefingLive = (t) => this.refreshLive(t);
|
||||
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 30000);
|
||||
this._wsStop = CockpitLive.connectFeed(() => { this.refreshLive(false); });
|
||||
if (this.hermanTab === 'rss') await this.loadRssTab();
|
||||
},
|
||||
async refreshRss(toast) {
|
||||
@@ -454,6 +460,7 @@ function dashboardBriefing() {
|
||||
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
|
||||
if (r.stats) {
|
||||
this._stats = r.stats;
|
||||
if (r.live_digest) this.liveDigest = r.live_digest;
|
||||
try {
|
||||
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
|
||||
if (live.items && live.items.length) {
|
||||
@@ -462,7 +469,7 @@ function dashboardBriefing() {
|
||||
this.rssTotal = this._stats.rss_items;
|
||||
}
|
||||
} catch (e) {}
|
||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis);
|
||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis, this.liveDigest);
|
||||
this.renderClientsMini(this._stats);
|
||||
this.renderActivityLog(this._stats.activity_log || []);
|
||||
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
|
||||
@@ -523,6 +530,7 @@ function dashboardBriefing() {
|
||||
this.content = result.content || '';
|
||||
this.createdAt = result.generated_at || new Date().toISOString();
|
||||
this._stats = result.stats;
|
||||
if (result.stats && result.stats.live_digest) this.liveDigest = result.stats.live_digest;
|
||||
BriefingCharts.render(document.getElementById('briefing-dashboard'), result.stats, result.content, this.createdAt, this._vizMode);
|
||||
this.renderClientsMini(result.stats || {});
|
||||
this.renderActivityLog((result.stats || {}).activity_log || []);
|
||||
|
||||
+15
-4
@@ -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
|
||||
|
||||
@@ -159,7 +165,12 @@ async def dashboard(request: Request):
|
||||
|
||||
|
||||
@router.get("/cto")
|
||||
async def cto_dashboard(request: Request):
|
||||
async def cto_redirect():
|
||||
return RedirectResponse(url="/ops", status_code=302)
|
||||
|
||||
|
||||
@router.get("/cto/dashboard")
|
||||
async def cto_dashboard_legacy(request: Request):
|
||||
kpis = {
|
||||
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
|
||||
"browser_sessions_24h": 0,
|
||||
|
||||
@@ -366,3 +366,8 @@
|
||||
.ei-contact-role { font-size: 0.72rem; color: #64748b; margin-left: 0.35rem; }
|
||||
.ei-drawer-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
|
||||
.ei-sync-msg { font-size: 0.75rem; color: #7dd3fc; margin-top: 0.5rem; }
|
||||
.ei-tab-panel-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||||
.ei-link-btn { background: none; border: none; padding: 0; color: #38bdf8; cursor: pointer; font: inherit; text-align: left; }
|
||||
.ei-link-btn:hover { text-decoration: underline; }
|
||||
.ei-td-actions { white-space: nowrap; }
|
||||
.ei-load-more { display: flex; justify-content: center; padding: 1rem 0 0.5rem; }
|
||||
|
||||
@@ -34,11 +34,85 @@
|
||||
@media (max-width: 900px) { .hm-dash-two-col { grid-template-columns: 1fr; } }
|
||||
|
||||
.briefing-summary-grid {
|
||||
display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem;
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;
|
||||
}
|
||||
.briefing-card.summary { grid-column: 1 / -1; }
|
||||
.briefing-horizon-col { display: grid; gap: 1rem; }
|
||||
@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.live-digest-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .live-digest-body { grid-template-columns: 1fr; } }
|
||||
|
||||
.live-digest-section {
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.live-digest-section.brand-foodlinkk { border-left: 3px solid #fbbf24; }
|
||||
.live-digest-section.brand-cucina { border-left: 3px solid #fb923c; }
|
||||
.live-digest-section.brand-platform { border-left: 3px solid var(--hm-cyan); }
|
||||
.live-digest-section.brand-news { border-left: 3px solid #a855f7; }
|
||||
|
||||
.live-digest-section h5 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
.live-digest-section.brand-foodlinkk h5 { color: #fbbf24; }
|
||||
.live-digest-section.brand-cucina h5 { color: #fdba74; }
|
||||
.live-digest-section.brand-platform h5 { color: var(--hm-cyan); }
|
||||
.live-digest-section.brand-news h5 { color: #c4b5fd; }
|
||||
|
||||
.live-digest-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.live-digest-list li {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: #e2e8f0;
|
||||
padding-left: 0.65rem;
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.live-digest-list li.is-typing::after {
|
||||
content: '▋';
|
||||
display: inline-block;
|
||||
margin-left: 1px;
|
||||
color: var(--hm-cyan);
|
||||
animation: twCursorBlink 0.85s step-end infinite;
|
||||
font-weight: 400;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.live-digest-body.is-typing .live-digest-section {
|
||||
animation: twSectionIn 0.35s ease-out both;
|
||||
}
|
||||
@keyframes twCursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
@keyframes twSectionIn {
|
||||
from { opacity: 0.4; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.horizon-card ul li.is-typing::after {
|
||||
content: '▋';
|
||||
color: #ff9f43;
|
||||
animation: twCursorBlink 0.85s step-end infinite;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.briefing-card.summary,
|
||||
.horizon-card {
|
||||
background: var(--hm-panel); border-radius: 14px; padding: 1rem;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
window.BriefingCharts = (function () {
|
||||
var charts = {};
|
||||
var typewriterTimer = null;
|
||||
var typewriterAbort = null;
|
||||
var lastDigestSig = '';
|
||||
|
||||
function destroyAll() {
|
||||
Object.keys(charts).forEach(function (k) {
|
||||
@@ -393,17 +395,207 @@ window.BriefingCharts = (function () {
|
||||
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 sumEl = document.getElementById('briefing-summary-text');
|
||||
if (sumEl && parsed.summary) {
|
||||
var lines = parsed.summary.split(/(?<=[.!?])\s+/).filter(Boolean);
|
||||
typewriterLiveDigest(sumEl, {
|
||||
sections: [{ brand: 'platform', title: 'Herman rapport', icon: '📝', lines: lines.length ? lines : [parsed.summary] }],
|
||||
}, { charMs: 10, linePause: 90 });
|
||||
}
|
||||
var actEl = document.getElementById('briefing-actions-list');
|
||||
if (actEl) actEl.innerHTML = parsed.actions.length ? parsed.actions.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Genereer dagrapport voor actiepunten</li>';
|
||||
if (actEl) typewriterListItems(actEl, parsed.actions.length ? parsed.actions : ['Genereer dagrapport voor actiepunten'], { charMs: 8 });
|
||||
var longEl = document.getElementById('briefing-longterm-list');
|
||||
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
|
||||
if (longEl) setTimeout(function () {
|
||||
typewriterListItems(longEl, parsed.longTerm.length ? parsed.longTerm : ['Halal kant-en-klaar partnerships schalen'], { charMs: 8 });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function renderLive(root, stats, vizMode, kpiSelection) {
|
||||
function escHtml(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function digestSignature(digest) {
|
||||
if (!digest) return '';
|
||||
return JSON.stringify({
|
||||
sections: digest.sections || [],
|
||||
actions: digest.actions || [],
|
||||
long_term: digest.long_term || [],
|
||||
});
|
||||
}
|
||||
|
||||
function fillDigestInstant(container, digest) {
|
||||
var sections = (digest && digest.sections) || [];
|
||||
if (!sections.length) {
|
||||
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = sections.map(function (sec) {
|
||||
var lines = (sec.lines || []).filter(Boolean);
|
||||
var lis = lines.length
|
||||
? lines.map(function (l) { return '<li>' + escHtml(l) + '</li>'; }).join('')
|
||||
: '<li class="muted">Geen data</li>';
|
||||
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
|
||||
'<h5>' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
|
||||
'<ul class="live-digest-list">' + lis + '</ul></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function typewriterLiveDigest(container, digest, opts) {
|
||||
if (!container) return;
|
||||
opts = opts || {};
|
||||
var charMs = opts.charMs || 14;
|
||||
var linePause = opts.linePause || 120;
|
||||
var sectionPause = opts.sectionPause || 200;
|
||||
|
||||
if (typewriterAbort) { typewriterAbort(); typewriterAbort = null; }
|
||||
if (typewriterTimer) { clearInterval(typewriterTimer); typewriterTimer = null; }
|
||||
|
||||
var sections = (digest && digest.sections) || [];
|
||||
if (!sections.length) {
|
||||
var summary = (digest && digest.summary) || '';
|
||||
if (summary) {
|
||||
typewriter(container, summary, charMs);
|
||||
} else {
|
||||
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = sections.map(function (sec) {
|
||||
var lines = (sec.lines || []).filter(Boolean);
|
||||
var lis = lines.length
|
||||
? lines.map(function () { return '<li class="tw-line"></li>'; }).join('')
|
||||
: '<li class="muted">Geen data</li>';
|
||||
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
|
||||
'<h5 class="tw-heading">' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
|
||||
'<ul class="live-digest-list">' + lis + '</ul></div>';
|
||||
}).join('');
|
||||
|
||||
var queue = [];
|
||||
container.querySelectorAll('.live-digest-section').forEach(function (secEl, si) {
|
||||
var lines = (sections[si] && sections[si].lines) || [];
|
||||
var lis = secEl.querySelectorAll('li.tw-line');
|
||||
for (var i = 0; i < lis.length; i++) {
|
||||
queue.push({ el: lis[i], text: lines[i] || '', section: si });
|
||||
}
|
||||
});
|
||||
|
||||
var cancelled = false;
|
||||
typewriterAbort = function () { cancelled = true; };
|
||||
container.classList.add('is-typing');
|
||||
|
||||
function tick(qi, ci, prevSection) {
|
||||
if (cancelled) return;
|
||||
if (qi >= queue.length) {
|
||||
container.classList.remove('is-typing');
|
||||
container.querySelectorAll('.is-typing').forEach(function (n) { n.classList.remove('is-typing'); });
|
||||
return;
|
||||
}
|
||||
var item = queue[qi];
|
||||
var el = item.el;
|
||||
var text = item.text;
|
||||
if (prevSection !== item.section && ci === 0) {
|
||||
setTimeout(function () { if (!cancelled) tick(qi, ci, item.section); }, sectionPause);
|
||||
return;
|
||||
}
|
||||
el.classList.add('is-typing');
|
||||
container.querySelectorAll('li.is-typing').forEach(function (n) { if (n !== el) n.classList.remove('is-typing'); });
|
||||
if (ci < text.length) {
|
||||
el.textContent = text.substring(0, ci + 1);
|
||||
setTimeout(function () { tick(qi, ci + 1, item.section); }, charMs);
|
||||
} else {
|
||||
el.classList.remove('is-typing');
|
||||
setTimeout(function () { tick(qi + 1, 0, item.section); }, linePause);
|
||||
}
|
||||
}
|
||||
tick(0, 0, -1);
|
||||
}
|
||||
|
||||
function typewriterListItems(listEl, items, opts) {
|
||||
if (!listEl || !items || !items.length) return;
|
||||
opts = opts || {};
|
||||
var charMs = opts.charMs || 10;
|
||||
listEl.innerHTML = items.map(function () { return '<li class="tw-line"></li>'; }).join('');
|
||||
var lis = listEl.querySelectorAll('li.tw-line');
|
||||
var qi = 0, ci = 0, cancelled = false;
|
||||
function next() {
|
||||
if (cancelled || qi >= items.length) return;
|
||||
var el = lis[qi];
|
||||
var text = items[qi];
|
||||
el.classList.add('is-typing');
|
||||
if (ci < text.length) {
|
||||
el.textContent = text.substring(0, ci + 1);
|
||||
ci++;
|
||||
setTimeout(next, charMs);
|
||||
} else {
|
||||
el.classList.remove('is-typing');
|
||||
qi++; ci = 0;
|
||||
setTimeout(next, 90);
|
||||
}
|
||||
}
|
||||
next();
|
||||
return function () { cancelled = true; };
|
||||
}
|
||||
|
||||
function fillListInstant(listEl, items) {
|
||||
if (!listEl) return;
|
||||
var rows = (items && items.length) ? items : ['Geen urgente acties — check agents & retail'];
|
||||
listEl.innerHTML = rows.map(function (t) { return '<li>' + escHtml(t) + '</li>'; }).join('');
|
||||
}
|
||||
|
||||
function renderLiveSummary(digest, pollRefresh) {
|
||||
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 sig = digestSignature(digest);
|
||||
var unchanged = pollRefresh && sig === lastDigestSig;
|
||||
if (!unchanged) lastDigestSig = sig;
|
||||
|
||||
if (meta) {
|
||||
var ts = String(digest.updated_at || '').substring(0, 19).replace('T', ' ');
|
||||
var n = digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
|
||||
meta.textContent = 'Live · ververst ' + ts + ' UTC' + n;
|
||||
}
|
||||
|
||||
if (unchanged) {
|
||||
if (actEl) fillListInstant(actEl, digest.actions || []);
|
||||
if (longEl) fillListInstant(longEl, digest.long_term || []);
|
||||
return;
|
||||
}
|
||||
|
||||
var isUpdate = !!lastDigestSig && !pollRefresh;
|
||||
var twOpts = { charMs: isUpdate ? 8 : 12, linePause: isUpdate ? 70 : 100, sectionPause: isUpdate ? 120 : 180 };
|
||||
|
||||
if (sumEl) {
|
||||
if (pollRefresh) fillDigestInstant(sumEl, digest);
|
||||
else typewriterLiveDigest(sumEl, digest, twOpts);
|
||||
}
|
||||
|
||||
var actions = digest.actions || [];
|
||||
if (actEl) {
|
||||
if (pollRefresh) fillListInstant(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail']);
|
||||
else typewriterListItems(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail'], { charMs: isUpdate ? 6 : 8 });
|
||||
}
|
||||
|
||||
var lt = digest.long_term || [];
|
||||
if (longEl) {
|
||||
if (pollRefresh) fillListInstant(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen']);
|
||||
else setTimeout(function () {
|
||||
typewriterListItems(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen'], { charMs: isUpdate ? 6 : 8 });
|
||||
}, isUpdate ? 200 : 400);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLive(root, stats, vizMode, kpiSelection, liveDigest, opts) {
|
||||
if (!root || !stats) return;
|
||||
opts = opts || {};
|
||||
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
|
||||
var kpis = kpiSelection || window._dashboardKpis;
|
||||
if (liveDigest) renderLiveSummary(liveDigest, !!opts.pollRefresh);
|
||||
else if (stats.live_digest) renderLiveSummary(stats.live_digest, !!opts.pollRefresh);
|
||||
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
|
||||
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
|
||||
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
|
||||
@@ -432,7 +624,7 @@ window.BriefingCharts = (function () {
|
||||
}
|
||||
|
||||
return {
|
||||
render: render, renderLive: renderLive, renderText: renderText,
|
||||
render: render, renderLive: renderLive, renderText: renderText, renderLiveSummary: renderLiveSummary,
|
||||
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
|
||||
renderKpis: renderKpis, destroyAll: destroyAll,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ function exportIntelApp() {
|
||||
region: '',
|
||||
entities: [],
|
||||
contacts: [],
|
||||
contactsHasMore: false,
|
||||
contactsLoadingMore: false,
|
||||
tenders: [],
|
||||
catererPresence: [],
|
||||
catererBrands: [],
|
||||
@@ -139,7 +141,7 @@ function exportIntelApp() {
|
||||
},
|
||||
|
||||
showEntityTypeFilter() {
|
||||
return ['distributors', 'customers', 'caterers'].indexOf(this.tab) >= 0;
|
||||
return ['distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
|
||||
},
|
||||
|
||||
showMapTypeFilter() {
|
||||
@@ -183,6 +185,14 @@ function exportIntelApp() {
|
||||
return this.tab === 'contacts';
|
||||
},
|
||||
|
||||
showCrmFilter() {
|
||||
return this.tab === 'map' || this.tab === 'contacts';
|
||||
},
|
||||
|
||||
showSelectionBar() {
|
||||
return this.tab === 'map' || this.tab === 'contacts';
|
||||
},
|
||||
|
||||
entityTypeOptions() {
|
||||
if (this.tab === 'distributors') {
|
||||
return [
|
||||
@@ -205,6 +215,18 @@ function exportIntelApp() {
|
||||
if (this.tab === 'caterers') {
|
||||
return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }];
|
||||
}
|
||||
if (this.tab === 'contacts') {
|
||||
return [
|
||||
{ v: '', l: 'Alle types' },
|
||||
{ v: 'distributor', l: 'Distributeur' },
|
||||
{ v: 'wholesaler', l: 'Groothandel' },
|
||||
{ v: 'importer', l: 'Importeur' },
|
||||
{ v: 'logistics', l: 'Logistiek' },
|
||||
{ v: 'contract_caterer', l: 'Cateraar' },
|
||||
{ v: 'restaurant', l: 'Restaurant' },
|
||||
{ v: 'foodservice', l: 'Foodservice' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ v: '', l: 'Alle types' },
|
||||
{ v: 'distributor', l: 'Distributeur' },
|
||||
@@ -214,7 +236,8 @@ function exportIntelApp() {
|
||||
];
|
||||
},
|
||||
|
||||
async loadTabData() {
|
||||
async loadTabData(opts) {
|
||||
opts = opts || {};
|
||||
var base = '/api/export-intel';
|
||||
var fq = this.filterQs();
|
||||
if (this.tab === 'map') {
|
||||
@@ -245,9 +268,17 @@ function exportIntelApp() {
|
||||
this.catererPresence = await fetch(base + '/caterers/presence' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
|
||||
this.catererBrands = await fetch(base + '/caterers/brands').then((x) => x.json());
|
||||
} else if (this.tab === 'contacts') {
|
||||
var r4 = await fetch(base + '/contacts?limit=500' + fq).then((x) => x.json());
|
||||
this.contacts = r4.items || [];
|
||||
if (!opts.append) {
|
||||
this.contacts = [];
|
||||
this.contactsHasMore = false;
|
||||
}
|
||||
var offset = opts.append ? (this.contacts || []).length : 0;
|
||||
var r4 = await fetch(base + '/contacts?limit=200&offset=' + offset + fq).then((x) => x.json());
|
||||
var items = r4.items || [];
|
||||
if (opts.append) this.contacts = (this.contacts || []).concat(items);
|
||||
else this.contacts = items;
|
||||
this.resultCount = r4.total != null ? r4.total : this.contacts.length;
|
||||
this.contactsHasMore = this.contacts.length < this.resultCount;
|
||||
} else if (this.tab === 'tenders') {
|
||||
var tenders = await fetch(base + '/tenders' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
|
||||
var items = tenders.items || [];
|
||||
@@ -468,10 +499,26 @@ function exportIntelApp() {
|
||||
var u = '/api/export-intel/contacts/export.csv?';
|
||||
var parts = [];
|
||||
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
|
||||
if (this.region) parts.push('region=' + encodeURIComponent(this.region));
|
||||
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
|
||||
if (this.filterHasEmail === 'yes') parts.push('has_email=true');
|
||||
if (this.filterHasEmail === 'no') parts.push('has_email=false');
|
||||
if (this.filterCrmStatus === 'linked') parts.push('crm_linked=true');
|
||||
if (this.filterCrmStatus === 'not_linked') parts.push('crm_linked=false');
|
||||
if (this.q && this.q.trim()) parts.push('q=' + encodeURIComponent(this.q.trim()));
|
||||
return u + parts.join('&');
|
||||
},
|
||||
|
||||
async loadMoreContacts() {
|
||||
if (!this.contactsHasMore || this.contactsLoadingMore) return;
|
||||
this.contactsLoadingMore = true;
|
||||
try {
|
||||
await this.loadTabData({ append: true });
|
||||
} finally {
|
||||
this.contactsLoadingMore = false;
|
||||
}
|
||||
},
|
||||
|
||||
typeLabel(t) {
|
||||
return (t || '').replace(/_/g, ' ');
|
||||
},
|
||||
@@ -494,6 +541,25 @@ function exportIntelApp() {
|
||||
},
|
||||
|
||||
toggleSelectAllVisible() {
|
||||
if (this.tab === 'contacts') {
|
||||
var entityIds = [];
|
||||
(this.contacts || []).forEach(function (c) {
|
||||
if (c.entity_id && entityIds.indexOf(c.entity_id) < 0) entityIds.push(c.entity_id);
|
||||
});
|
||||
if (!entityIds.length) return;
|
||||
var allSelected = entityIds.every((id) => this.isSelected(id));
|
||||
if (allSelected) {
|
||||
entityIds.forEach((id) => {
|
||||
var i = this.selectedIds.indexOf(id);
|
||||
if (i >= 0) this.selectedIds.splice(i, 1);
|
||||
});
|
||||
} else {
|
||||
entityIds.forEach((id) => {
|
||||
if (!this.isSelected(id)) this.selectedIds.push(id);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities;
|
||||
if (!list.length) return;
|
||||
var allSelected = list.every((e) => this.isSelected(e.id));
|
||||
@@ -590,6 +656,10 @@ function exportIntelApp() {
|
||||
},
|
||||
|
||||
onFilterCollectionChange() {
|
||||
if (this.tab === 'contacts') {
|
||||
this.loadTabData();
|
||||
return;
|
||||
}
|
||||
this.applyMapFilters();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,19 +9,41 @@ window.CockpitLive = (function () {
|
||||
return function () { clearInterval(id); };
|
||||
}
|
||||
|
||||
function eventFingerprint(data) {
|
||||
var ev = (data && data.events) || [];
|
||||
if (!ev.length) return '';
|
||||
return ev.slice(0, 5).map(function (e) {
|
||||
return String(e.created_at || '') + '#' + String(e.agent_name || '') + '#' + String(e.title || e.event_type || '');
|
||||
}).join('|');
|
||||
}
|
||||
|
||||
function connectFeed(onMessage) {
|
||||
if (typeof WebSocket === 'undefined') return function () {};
|
||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var lastFp = '';
|
||||
var booted = false;
|
||||
function connect() {
|
||||
try {
|
||||
ws = new WebSocket(proto + '//' + location.host + '/ws/feed');
|
||||
ws.onmessage = function (ev) {
|
||||
try {
|
||||
var data = JSON.parse(ev.data);
|
||||
if (onMessage) onMessage(data);
|
||||
var fp = eventFingerprint(data);
|
||||
if (!booted) {
|
||||
booted = true;
|
||||
lastFp = fp;
|
||||
if (onMessage) onMessage(data, { reason: 'connect' });
|
||||
return;
|
||||
}
|
||||
if (fp && fp !== lastFp) {
|
||||
lastFp = fp;
|
||||
if (onMessage) onMessage(data, { reason: 'events' });
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
ws.onclose = function () {
|
||||
booted = false;
|
||||
lastFp = '';
|
||||
reconnectTimer = setTimeout(connect, 5000);
|
||||
};
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,6 +3,7 @@ function revenueCockpit() {
|
||||
loading: false,
|
||||
saving: false,
|
||||
savedAt: '',
|
||||
_pollStop: null,
|
||||
viewMode: 'cockpit',
|
||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||
projects: [],
|
||||
@@ -46,6 +47,12 @@ function revenueCockpit() {
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (window.CockpitLive) {
|
||||
this._pollStop = CockpitLive.startPolling(async () => {
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
}, 45000);
|
||||
}
|
||||
},
|
||||
|
||||
projectKey(p) {
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
<script src="/static/js/cockpit.js?v=2"></script>
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/global-viz.js?v=1"></script>
|
||||
<script src="/static/js/live-pulse.js?v=2"></script>
|
||||
<script src="/static/js/live-pulse.js?v=3"></script>
|
||||
<script>
|
||||
window.FoodlinkkBrand = (function () {
|
||||
var FOODLINKK_PREFIXES = ['/export-intel', '/foodlinkk', '/clients', '/deals', '/products', '/suppliers'];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=17" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=21" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="herman-shell hm-neo">
|
||||
@@ -93,8 +93,8 @@
|
||||
<h3 class="hm-section-title">Herman briefing</h3>
|
||||
<div class="briefing-summary-grid">
|
||||
<div class="briefing-card summary">
|
||||
<h4>Samenvatting</h4>
|
||||
<p id="briefing-summary-text" class="briefing-typewriter"></p>
|
||||
<h4>Samenvatting · live</h4>
|
||||
<div id="briefing-summary-text" class="live-digest-body"></div>
|
||||
</div>
|
||||
<div class="briefing-card" style="max-height:220px;overflow-y:auto">
|
||||
<h4>📋 Herman activiteitenlog</h4>
|
||||
@@ -252,7 +252,7 @@
|
||||
{% block scripts %}
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/dashboard-layout.js?v=4"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=13"></script>
|
||||
<script src="/static/js/briefing-charts.js?v=19"></script>
|
||||
<script>
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
|
||||
@@ -264,7 +264,9 @@ function dashboardBriefing() {
|
||||
rssItems: [], rssTotal: 0, rssCategory: '', rssSearch: '', rssLoading: false,
|
||||
rssRegulations: {}, rssBookmarks: [],
|
||||
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
|
||||
liveDigest: INITIAL_BRIEFING.live_digest || null,
|
||||
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
|
||||
_liveRefreshing: false, _pollTick: 0,
|
||||
_prefs: null, _setDragMode: null, _saveTimer: null,
|
||||
applyDashboardPrefs(prefs) {
|
||||
this._prefs = prefs;
|
||||
@@ -274,7 +276,7 @@ function dashboardBriefing() {
|
||||
DashboardLayout.applyOrder(dash, order);
|
||||
DashboardLayout.applyVisibility(dash, prefs.dashboard_widgets);
|
||||
if (this._setDragMode) this._setDragMode(this.editLayout);
|
||||
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis);
|
||||
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis, this.liveDigest);
|
||||
},
|
||||
scheduleSave(patch) {
|
||||
if (!this._prefs) return;
|
||||
@@ -375,9 +377,14 @@ function dashboardBriefing() {
|
||||
if (cnt < 3) { sessionStorage.setItem('rss_auto_refreshed', '1'); await this.refreshRss(false); }
|
||||
}
|
||||
await this.loadProjectsMini();
|
||||
if (this.content) BriefingCharts.renderText(dash, this.content, this.createdAt);
|
||||
if (this.liveDigest) {
|
||||
BriefingCharts.renderLiveSummary(this.liveDigest, false);
|
||||
} else if (this.content) {
|
||||
BriefingCharts.renderText(dash, this.content, this.createdAt);
|
||||
}
|
||||
window._refreshBriefingLive = (t) => this.refreshLive(t);
|
||||
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 30000);
|
||||
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 20000);
|
||||
this._wsStop = CockpitLive.connectFeed(() => { this.refreshLive(false); });
|
||||
if (this.hermanTab === 'rss') await this.loadRssTab();
|
||||
},
|
||||
async refreshRss(toast) {
|
||||
@@ -448,12 +455,34 @@ function dashboardBriefing() {
|
||||
'<a href="/retail" class="beurs-mini-chip"><strong>' + (stats.crm_partnerships||0) + '</strong> partnerships</a>' +
|
||||
'<a href="/marketing?tab=reclame" class="beurs-mini-chip"><strong>' + (stats.promo_campaigns||0) + '</strong> promo\'s</a>';
|
||||
},
|
||||
updateLiveMeta(at, digest) {
|
||||
const tsRaw = at || (digest && digest.updated_at) || '';
|
||||
const ts = String(tsRaw).substring(0, 19).replace('T', ' ');
|
||||
const meta = document.getElementById('briefing-meta');
|
||||
if (meta && ts) {
|
||||
const n = digest && digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
|
||||
meta.textContent = 'Live · ververst ' + ts + ' UTC' + n;
|
||||
}
|
||||
const badge = document.querySelector('.herman-hero .live-badge');
|
||||
if (badge && tsRaw) {
|
||||
try {
|
||||
badge.textContent = 'Live · ' + new Date(tsRaw).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (e) {}
|
||||
}
|
||||
},
|
||||
async refreshLive(toast) {
|
||||
if (this._liveRefreshing) return;
|
||||
this._liveRefreshing = true;
|
||||
const isPoll = toast === false;
|
||||
if (isPoll) this._pollTick = (this._pollTick || 0) + 1;
|
||||
if (toast !== false) Cockpit.toast('Live data ophalen…', 'info');
|
||||
try {
|
||||
const r = await fetch('/api/herman/briefing/stats').then(x => x.json());
|
||||
const light = isPoll && (this._pollTick % 6 !== 0);
|
||||
const url = light ? '/api/herman/briefing/stats?light=1' : '/api/herman/briefing/stats';
|
||||
const r = await fetch(url).then(x => x.json());
|
||||
if (r.stats) {
|
||||
this._stats = r.stats;
|
||||
if (r.live_digest) this.liveDigest = r.live_digest;
|
||||
try {
|
||||
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
|
||||
if (live.items && live.items.length) {
|
||||
@@ -462,13 +491,14 @@ function dashboardBriefing() {
|
||||
this.rssTotal = this._stats.rss_items;
|
||||
}
|
||||
} catch (e) {}
|
||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis);
|
||||
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis, this.liveDigest, { pollRefresh: isPoll });
|
||||
this.renderClientsMini(this._stats);
|
||||
this.renderActivityLog(this._stats.activity_log || []);
|
||||
document.getElementById('briefing-meta').textContent = 'Live sync: ' + (r.at || '').substring(0, 19).replace('T', ' ') + ' UTC';
|
||||
this.updateLiveMeta(r.at, this.liveDigest);
|
||||
}
|
||||
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
|
||||
} catch (e) { if (toast !== false) Cockpit.toast(e.message, 'error'); }
|
||||
finally { this._liveRefreshing = false; }
|
||||
},
|
||||
pushLiveEntry(entry) {
|
||||
this.liveLog.push(entry);
|
||||
@@ -523,6 +553,7 @@ function dashboardBriefing() {
|
||||
this.content = result.content || '';
|
||||
this.createdAt = result.generated_at || new Date().toISOString();
|
||||
this._stats = result.stats;
|
||||
if (result.stats && result.stats.live_digest) this.liveDigest = result.stats.live_digest;
|
||||
BriefingCharts.render(document.getElementById('briefing-dashboard'), result.stats, result.content, this.createdAt, this._vizMode);
|
||||
this.renderClientsMini(result.stats || {});
|
||||
this.renderActivityLog((result.stats || {}).activity_log || []);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
||||
<link rel="stylesheet" href="/static/css/export-intel.css?v=11" />
|
||||
<link rel="stylesheet" href="/static/css/export-intel.css?v=12" />
|
||||
<link rel="stylesheet" href="/static/css/export-intel-tabs.css?v=10" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||
@@ -31,10 +31,10 @@
|
||||
|
||||
<p class="ei-sync-msg" x-show="syncMsg" x-text="syncMsg"></p>
|
||||
|
||||
<div class="ei-selection-bar" x-show="tab === 'map'" x-cloak>
|
||||
<div class="ei-selection-bar" x-show="showSelectionBar()" x-cloak>
|
||||
<div class="ei-selection-left">
|
||||
<span class="ei-selection-count" x-show="selectedIds.length" x-text="selectedIds.length + ' geselecteerd'"></span>
|
||||
<span class="ei-selection-hint" x-show="!selectedIds.length">Selecteer rijen met ☑ voor bulk-acties</span>
|
||||
<span class="ei-selection-hint" x-show="!selectedIds.length" x-text="tab === 'contacts' ? 'Selecteer contacten met ☑ — organisatie gaat naar CRM' : 'Selecteer rijen met ☑ voor bulk-acties'"></span>
|
||||
</div>
|
||||
<div class="ei-selection-actions">
|
||||
<button type="button" class="btn btn-sm" @click="toggleSelectAllVisible()" title="Alles op lijst">☑ Alles</button>
|
||||
@@ -137,8 +137,8 @@
|
||||
<strong x-text="mapHalalMin"></strong>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ei-filter-group ei-filter-group-collection" x-show="tab === 'map'" x-cloak>
|
||||
<label class="ei-filter-check">
|
||||
<div class="ei-filter-group ei-filter-group-collection" x-show="showCrmFilter()" x-cloak>
|
||||
<label class="ei-filter-check" x-show="tab === 'map'">
|
||||
<input type="checkbox" x-model="filterFavoritesOnly" @change="onFilterCollectionChange()" />
|
||||
<span>★ Alleen favorieten</span>
|
||||
</label>
|
||||
@@ -478,31 +478,67 @@
|
||||
<section x-show="tab==='contacts'" x-cloak>
|
||||
<div class="ei-tab-panel">
|
||||
<div class="ei-tab-panel-head">
|
||||
<div><h2>Contactregistry</h2><p>E-mail · telefoon · outreach-ready</p></div>
|
||||
<a class="btn btn-sm" :href="contactsExportUrl()" target="_blank">CSV export</a>
|
||||
<div><h2>Contactregistry</h2><p>E-mail · telefoon · outreach-ready · direct naar CRM</p></div>
|
||||
<div class="ei-tab-panel-actions">
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" :disabled="!selectedIds.length || crmPushBusy" @click="openCrmModal()">➕ Geselecteerde naar CRM</button>
|
||||
<a class="btn btn-sm" :href="contactsExportUrl()" target="_blank">CSV export</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ei-tab-panel-body">
|
||||
<div class="ei-panel" style="border:none;background:transparent;padding:0">
|
||||
<div class="ei-table-wrap">
|
||||
<table class="ei-table">
|
||||
<thead><tr><th>Organisatie</th><th>Type</th><th>Land</th><th>Rol</th><th>Naam</th><th>E-mail</th><th>Telefoon</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ei-th-check"><input type="checkbox" @change="toggleSelectAllVisible()" title="Alles selecteren" /></th>
|
||||
<th>Organisatie</th>
|
||||
<th>Stad</th>
|
||||
<th>Type</th>
|
||||
<th>Land</th>
|
||||
<th>Rol</th>
|
||||
<th>Naam</th>
|
||||
<th>E-mail</th>
|
||||
<th>Telefoon</th>
|
||||
<th>CRM</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="c in contacts" :key="c.id">
|
||||
<tr>
|
||||
<td x-text="c.entity_name"></td>
|
||||
<td x-text="typeLabel(c.entity_type)"></td>
|
||||
<tr :class="{ 'ei-row-selected': isSelected(c.entity_id), 'ei-row-crm': c.client_id }">
|
||||
<td class="ei-td-check" @click.stop>
|
||||
<input type="checkbox" :checked="isSelected(c.entity_id)" @change="toggleSelect(c.entity_id, $event)" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="ei-link-btn" @click="openEntity(c.entity_id)" x-text="c.entity_name"></button>
|
||||
</td>
|
||||
<td x-text="c.entity_city || '—'"></td>
|
||||
<td><span class="ei-type-pill ei-type-pill-sm" :class="'ei-type-'+c.entity_type" x-text="typeLabel(c.entity_type)"></span></td>
|
||||
<td x-text="c.country_iso2"></td>
|
||||
<td x-text="c.role"></td>
|
||||
<td x-text="c.role || '—'"></td>
|
||||
<td x-text="c.name||'—'"></td>
|
||||
<td x-text="c.email||'—'"></td>
|
||||
<td x-text="c.phone||c.mobile||'—'"></td>
|
||||
<td>
|
||||
<span class="ei-crm-badge" x-show="c.client_id" :title="c.crm_client_name || 'In CRM'">CRM</span>
|
||||
<span class="muted" x-show="!c.client_id">—</span>
|
||||
</td>
|
||||
<td class="ei-td-actions" @click.stop>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" x-show="!c.client_id" @click="pushEntityToCrm(c.entity_id)" :disabled="crmPushBusy">➕ CRM</button>
|
||||
<a class="btn btn-sm" x-show="c.client_id" :href="'/clients'" title="Bekijk in CRM">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ei-empty" x-show="!contacts.length">
|
||||
<p>Contacten worden aangevuld in fase B (Places, websites, tenders).</p>
|
||||
<div class="ei-load-more" x-show="contactsHasMore">
|
||||
<button type="button" class="btn btn-sm" @click="loadMoreContacts()" :disabled="contactsLoadingMore || busy">
|
||||
<span x-text="contactsLoadingMore ? 'Laden…' : ('Meer laden (' + contacts.length + ' / ' + resultCount + ')')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ei-empty" x-show="!contacts.length && !busy">
|
||||
<p>Geen contacten voor deze filters — pas land, type of zoekterm aan.</p>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="syncKind('contacts')">Sync contacten</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -760,5 +796,5 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/export-intel.js?v=10"></script>
|
||||
<script src="/static/js/export-intel.js?v=11"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -258,5 +258,5 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/revenue-cockpit.js?v=5"></script>
|
||||
<script src="/static/js/revenue-cockpit.js?v=6"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -285,37 +285,41 @@ def get_entity(entity_id: int) -> dict[str, Any]:
|
||||
@router.get("/contacts")
|
||||
def list_contacts(
|
||||
country: Optional[str] = Query(None),
|
||||
region: Optional[str] = Query(None),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
has_email: Optional[bool] = Query(None),
|
||||
crm_linked: Optional[bool] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
clauses = ["1=1"]
|
||||
params: list[Any] = []
|
||||
if country:
|
||||
clauses.append("e.country_iso2 = %s")
|
||||
params.append(country.upper())
|
||||
if entity_type:
|
||||
clauses.append("e.entity_type = %s")
|
||||
params.append(entity_type)
|
||||
if has_email:
|
||||
clauses.append("c.email IS NOT NULL AND c.email <> ''")
|
||||
join = ""
|
||||
if region:
|
||||
join = "JOIN export_territories t ON e.territory_code = t.code"
|
||||
where_sql, params = _entity_filters(country, region, entity_type, None, False, crm_linked)
|
||||
if has_email is True:
|
||||
where_sql += " AND c.email IS NOT NULL AND c.email <> ''"
|
||||
elif has_email is False:
|
||||
where_sql += " AND (c.email IS NULL OR c.email = '')"
|
||||
if q:
|
||||
clauses.append(
|
||||
"(e.name ILIKE %s OR c.email ILIKE %s OR c.name ILIKE %s OR e.city ILIKE %s)"
|
||||
)
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like, like, like])
|
||||
where_sql += (
|
||||
" AND (e.name ILIKE %s OR e.city ILIKE %s OR c.email ILIKE %s"
|
||||
" OR c.name ILIKE %s OR c.phone ILIKE %s OR c.mobile ILIKE %s)"
|
||||
)
|
||||
params.extend([like, like, like, like, like, like])
|
||||
|
||||
where = " AND ".join(clauses)
|
||||
rows = fetch_all(
|
||||
f"""
|
||||
SELECT c.*, e.name AS entity_name, e.entity_type, e.country_iso2, e.city AS entity_city
|
||||
SELECT c.*, e.name AS entity_name, e.entity_type, e.country_iso2,
|
||||
e.city AS entity_city, e.client_id, e.deal_id,
|
||||
cl.name AS crm_client_name
|
||||
FROM export_entity_contacts c
|
||||
JOIN export_market_entities e ON e.id = c.entity_id
|
||||
WHERE {where}
|
||||
ORDER BY e.name, c.is_primary DESC
|
||||
LEFT JOIN clients cl ON cl.id = e.client_id
|
||||
{join}
|
||||
WHERE {where_sql}
|
||||
ORDER BY e.name, e.city NULLS LAST, c.is_primary DESC, c.email NULLS LAST
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params) + (limit, offset),
|
||||
@@ -324,19 +328,33 @@ def list_contacts(
|
||||
f"""
|
||||
SELECT COUNT(*) AS n FROM export_entity_contacts c
|
||||
JOIN export_market_entities e ON e.id = c.entity_id
|
||||
WHERE {where}
|
||||
{join}
|
||||
WHERE {where_sql}
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
return {"items": rows, "total": int(total["n"] or 0)}
|
||||
return {"items": rows, "total": int(total["n"] or 0), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/contacts/export.csv")
|
||||
def export_contacts_csv(
|
||||
country: Optional[str] = Query(None),
|
||||
region: Optional[str] = Query(None),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
has_email: Optional[bool] = Query(None),
|
||||
crm_linked: Optional[bool] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
) -> StreamingResponse:
|
||||
data = list_contacts(country=country, entity_type=entity_type, limit=5000, offset=0)
|
||||
data = list_contacts(
|
||||
country=country,
|
||||
region=region,
|
||||
entity_type=entity_type,
|
||||
has_email=has_email,
|
||||
crm_linked=crm_linked,
|
||||
q=q,
|
||||
limit=5000,
|
||||
offset=0,
|
||||
)
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Live halal retail recommendation engine — diverse, actionable, data-driven."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from app import retail_opportunities
|
||||
from app.db import execute, execute_returning, fetch_all, fetch_one, json_param
|
||||
from app.middleware import log_agent_event
|
||||
|
||||
MIN_SCORE = 35.0
|
||||
|
||||
CANDIDATE_SQL = """
|
||||
SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode,
|
||||
s.partnership_status, s.halal_certified, s.has_halal_section,
|
||||
s.phone, s.email,
|
||||
ros.halal_opportunity_score, ros.market_potential_score, ros.factors, ros.computed_at,
|
||||
(a.religious_composition->>'muslim_proxy_pct')::float AS muslim_proxy_pct,
|
||||
(a.ethnic_composition->>'niet_westers_pct')::float AS niet_westers_pct,
|
||||
a.population, a.avg_income,
|
||||
sp.manager_email, sp.manager_phone,
|
||||
EXISTS(SELECT 1 FROM client_supermarket_links csl WHERE csl.supermarket_id = s.id) AS crm_linked
|
||||
FROM supermarkets s
|
||||
JOIN retail_opportunity_scores ros ON ros.supermarket_id = s.id
|
||||
LEFT JOIN area_analysis a ON a.postcode = s.postcode
|
||||
LEFT JOIN supermarket_profiles sp ON sp.supermarket_id = s.id
|
||||
WHERE COALESCE(s.partnership_status, 'none') NOT IN ('active', 'contract', 'won')
|
||||
AND COALESCE(s.has_halal_section, false) IS NOT TRUE
|
||||
AND ros.halal_opportunity_score >= %s
|
||||
AND s.postcode <> '0000AA'
|
||||
LIMIT 500
|
||||
"""
|
||||
|
||||
|
||||
def _parse_factors(row: dict[str, Any]) -> dict[str, Any]:
|
||||
factors = row.get("factors") or {}
|
||||
if isinstance(factors, str):
|
||||
try:
|
||||
factors = json.loads(factors)
|
||||
except json.JSONDecodeError:
|
||||
factors = {}
|
||||
return factors if isinstance(factors, dict) else {}
|
||||
|
||||
|
||||
def _composite_score(row: dict[str, Any], day_seed: int) -> float:
|
||||
halal = float(row.get("halal_opportunity_score") or 0)
|
||||
market = float(row.get("market_potential_score") or 0)
|
||||
muslim = float(row.get("muslim_proxy_pct") or 0)
|
||||
factors = _parse_factors(row)
|
||||
halal_gap = float(factors.get("halal_gap") or 0)
|
||||
|
||||
score = halal * 0.32 + halal_gap * 0.28 + muslim * 0.22 + market * 0.12
|
||||
if row.get("phone") or row.get("email") or row.get("manager_email") or row.get("manager_phone"):
|
||||
score += 8
|
||||
if row.get("crm_linked"):
|
||||
score -= 22
|
||||
if row.get("halal_certified"):
|
||||
score -= 18
|
||||
pop = int(row.get("population") or 0)
|
||||
if pop > 80000:
|
||||
score += 5
|
||||
jitter = ((int(row["id"]) * 17 + day_seed) % 11) - 5
|
||||
return round(score + jitter, 2)
|
||||
|
||||
|
||||
def _reasons(row: dict[str, Any]) -> list[str]:
|
||||
factors = _parse_factors(row)
|
||||
muslim = float(row.get("muslim_proxy_pct") or factors.get("muslim_proxy_pct") or 0)
|
||||
reasons: list[str] = []
|
||||
if not row.get("has_halal_section"):
|
||||
reasons.append(f"Geen halal schap — {muslim:.0f}% moslim-demografie")
|
||||
gap = float(factors.get("halal_gap") or 0)
|
||||
if gap >= 25:
|
||||
reasons.append(f"Halal-gap {gap:.0f}/100")
|
||||
pop = int(row.get("population") or 0)
|
||||
if pop > 40000:
|
||||
reasons.append(f"Catchment {pop:,} inwoners")
|
||||
if row.get("phone") or row.get("manager_phone"):
|
||||
reasons.append("Telefoon beschikbaar")
|
||||
chain = row.get("chain") or ""
|
||||
if chain in ("Jumbo", "Albert Heijn", "PLUS", "Dirk"):
|
||||
reasons.append(f"Strategische keten: {chain}")
|
||||
status = row.get("partnership_status") or "none"
|
||||
if status in ("none", "prospect", "lead"):
|
||||
reasons.append("Nog geen actief partnership — eerste mover")
|
||||
return reasons[:4] or ["Halal kant-en-klaar listing kans"]
|
||||
|
||||
|
||||
def _diverse_pick(candidates: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
|
||||
picked: list[dict[str, Any]] = []
|
||||
chains_seen: set[str] = set()
|
||||
cities_seen: set[str] = set()
|
||||
ids_seen: set[int] = set()
|
||||
for row in candidates:
|
||||
sid = int(row["id"])
|
||||
if sid in ids_seen:
|
||||
continue
|
||||
chain = (row.get("chain") or "").lower()
|
||||
city = (row.get("city") or "").lower()
|
||||
name = (row.get("name") or "").lower()
|
||||
chain_city = f"{chain}|{city}|{name[:20]}"
|
||||
if chain_city in chains_seen and len(picked) < max(1, limit - 1):
|
||||
continue
|
||||
if chain in chains_seen and len(picked) < max(1, limit - 1):
|
||||
continue
|
||||
if city in cities_seen and len(cities_seen) >= 2 and len(picked) < max(1, limit - 1):
|
||||
continue
|
||||
picked.append(row)
|
||||
ids_seen.add(sid)
|
||||
chains_seen.add(chain)
|
||||
chains_seen.add(chain_city)
|
||||
cities_seen.add(city)
|
||||
if len(picked) >= limit:
|
||||
return picked
|
||||
for row in candidates:
|
||||
if row in picked:
|
||||
continue
|
||||
picked.append(row)
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
return picked
|
||||
|
||||
|
||||
def get_dismissed_store_ids() -> set[int]:
|
||||
rows = fetch_all(
|
||||
"""SELECT related_entity_id FROM ai_recommendations
|
||||
WHERE recommendation_type LIKE 'halal_%%' AND status = 'dismissed'
|
||||
AND related_entity_id IS NOT NULL
|
||||
AND updated_at >= NOW() - INTERVAL '30 days'"""
|
||||
)
|
||||
return {int(r["related_entity_id"]) for r in rows if r.get("related_entity_id")}
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in row.items():
|
||||
if k.startswith("_"):
|
||||
continue
|
||||
if hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
elif type(v).__name__ == "Decimal":
|
||||
out[k] = float(v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def build_recommendation(row: dict[str, Any], rank: int) -> dict[str, Any]:
|
||||
score = row.get("_composite_score", row.get("halal_opportunity_score"))
|
||||
reasons = _reasons(row)
|
||||
title = f"{row.get('chain')} · {row.get('name')} ({row.get('city')})"
|
||||
priority = "high" if float(score) >= 62 else "medium" if float(score) >= 45 else "normal"
|
||||
return {
|
||||
"rank": rank,
|
||||
"store_id": int(row["id"]),
|
||||
"recommendation_type": "halal_retail_cucina",
|
||||
"brand": "cucina",
|
||||
"title": title,
|
||||
"description": " · ".join(reasons),
|
||||
"score": round(float(score), 1),
|
||||
"halal_opportunity_score": float(row.get("halal_opportunity_score") or 0),
|
||||
"market_potential_score": float(row.get("market_potential_score") or 0),
|
||||
"muslim_proxy_pct": float(row.get("muslim_proxy_pct") or 0),
|
||||
"priority": priority,
|
||||
"reasons": reasons,
|
||||
"chain": row.get("chain"),
|
||||
"city": row.get("city"),
|
||||
"province": row.get("province"),
|
||||
"partnership_status": row.get("partnership_status"),
|
||||
"actions": [
|
||||
{"label": "Retail 360", "href": f"/retail?open={row['id']}"},
|
||||
{"label": "Naar CRM", "href": f"/retail?open={row['id']}&crm=1"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _upsert_recommendation(item: dict[str, Any]) -> None:
|
||||
store_id = item["store_id"]
|
||||
title = item["title"][:255]
|
||||
existing = fetch_one(
|
||||
"""SELECT id FROM ai_recommendations
|
||||
WHERE recommendation_type = %s AND related_entity_id = %s AND status = 'pending'""",
|
||||
(item["recommendation_type"], store_id),
|
||||
)
|
||||
payload = json_param({"score": item["score"], "reasons": item["reasons"]})
|
||||
if existing:
|
||||
execute(
|
||||
"""UPDATE ai_recommendations SET description = %s, impact_score = %s,
|
||||
priority = %s, data_sources = %s::jsonb, updated_at = NOW() WHERE id = %s""",
|
||||
(
|
||||
item["description"][:2000],
|
||||
min(0.99, float(item["score"]) / 100),
|
||||
item["priority"],
|
||||
payload,
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
return
|
||||
execute_returning(
|
||||
"""INSERT INTO ai_recommendations
|
||||
(recommendation_type, title, description, priority, impact_score, confidence_score,
|
||||
data_sources, action_items, generated_by, related_entity_type, related_entity_id, status, expires_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'halal_engine','supermarket',%s,'pending',%s)
|
||||
RETURNING id""",
|
||||
(
|
||||
item["recommendation_type"],
|
||||
title,
|
||||
item["description"][:2000],
|
||||
item["priority"],
|
||||
min(0.99, float(item["score"]) / 100),
|
||||
min(0.95, 0.55 + float(item.get("muslim_proxy_pct") or 0) / 200),
|
||||
payload,
|
||||
[a["label"] for a in item.get("actions", [])],
|
||||
store_id,
|
||||
datetime.now(timezone.utc) + timedelta(days=14),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _maybe_refresh_scores(force: bool = False) -> bool:
|
||||
"""Full recompute is expensive (~60s) — only on explicit refresh or empty DB."""
|
||||
if force:
|
||||
retail_opportunities.compute_all_scores(5000)
|
||||
return True
|
||||
row = fetch_one("SELECT COUNT(*) AS n FROM retail_opportunity_scores")
|
||||
if not row or int(row.get("n") or 0) == 0:
|
||||
retail_opportunities.compute_all_scores(5000)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def live_halal_recommendations(
|
||||
limit: int = 5,
|
||||
refresh_scores: bool = False,
|
||||
brand: str = "cucina",
|
||||
) -> dict[str, Any]:
|
||||
refreshed = _maybe_refresh_scores(refresh_scores)
|
||||
day_seed = int(date.today().strftime("%Y%m%d"))
|
||||
dismissed = get_dismissed_store_ids()
|
||||
|
||||
rows = fetch_all(CANDIDATE_SQL, (MIN_SCORE,))
|
||||
scored: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
sid = int(r["id"])
|
||||
if sid in dismissed:
|
||||
continue
|
||||
r["_composite_score"] = _composite_score(r, day_seed)
|
||||
scored.append(r)
|
||||
scored.sort(key=lambda x: x["_composite_score"], reverse=True)
|
||||
picked = _diverse_pick(scored, limit)
|
||||
items = [build_recommendation(r, i + 1) for i, r in enumerate(picked)]
|
||||
|
||||
for item in items:
|
||||
_upsert_recommendation(item)
|
||||
|
||||
if items:
|
||||
log_agent_event(
|
||||
agent_name="halal_engine",
|
||||
event_type="recommendations",
|
||||
title=f"Halal kansen: {len(items)} live recommendations",
|
||||
metadata={"store_ids": [i["store_id"] for i in items], "top_score": items[0]["score"]},
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"brand": brand,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"items": items,
|
||||
"meta": {
|
||||
"candidates_evaluated": len(rows),
|
||||
"dismissed_skipped": len(dismissed),
|
||||
"scores_refreshed": refreshed or refresh_scores,
|
||||
"engine": "halal_reco_v1",
|
||||
},
|
||||
}
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Optional
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.db import execute, execute_returning, fetch_all, fetch_one, json_param
|
||||
from app.halal_reco_engine import live_halal_recommendations
|
||||
from app.middleware import log_agent_event
|
||||
|
||||
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
|
||||
@@ -154,6 +155,21 @@ def approve_recommendation(rec_id: int) -> dict[str, Any]:
|
||||
return _serialize(row)
|
||||
|
||||
|
||||
@router.get("/halal/live")
|
||||
def halal_recommendations_live(
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
refresh: bool = False,
|
||||
brand: str = Query("cucina"),
|
||||
) -> dict[str, Any]:
|
||||
"""Live halal retail kansen — divers, geen actieve partnerships, composite score."""
|
||||
return live_halal_recommendations(limit=limit, refresh_scores=refresh, brand=brand)
|
||||
|
||||
|
||||
@router.post("/halal/refresh")
|
||||
def halal_recommendations_refresh(limit: int = Query(5, ge=1, le=20)) -> dict[str, Any]:
|
||||
return live_halal_recommendations(limit=limit, refresh_scores=True)
|
||||
|
||||
|
||||
@router.post("/{rec_id}/dismiss")
|
||||
def dismiss_recommendation(rec_id: int) -> dict[str, Any]:
|
||||
row = execute_returning(
|
||||
|
||||
Reference in New Issue
Block a user