Platform bundle: marketing publish, IT ops, packaging, agents mesh.

Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
Aissa
2026-06-09 00:41:27 +00:00
commit 5d60d33db1
212 changed files with 30044 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+72
View File
@@ -0,0 +1,72 @@
"""Agent soul profiles and Herman permissions."""
from __future__ import annotations
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
def list_souls() -> list[dict[str, Any]]:
rows = fetch_all(
"""SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count,
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at
FROM agent_souls s ORDER BY s.display_name"""
)
return [dict(r) for r in rows]
def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
row = fetch_one(
"""SELECT s.*,
(SELECT COUNT(*) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS event_count
FROM agent_souls s WHERE agent_key = %s""",
(agent_key.lower(),),
)
if not row:
return None
events = fetch_all(
"""SELECT id, event_type, title, status, created_at FROM agent_events
WHERE LOWER(agent_name) = %s ORDER BY created_at DESC LIMIT 15""",
(agent_key.lower(),),
)
out = dict(row)
out["recent_events"] = [dict(e) for e in events]
return out
def update_soul(agent_key: str, **fields: Any) -> dict[str, Any]:
allowed = ("display_name", "role_title", "soul_md", "responsibilities", "permissions", "is_active")
sets, params = [], []
for k, v in fields.items():
if k in allowed and v is not None:
sets.append(f"{k} = %s")
params.append(v)
if not sets:
soul = get_soul(agent_key)
if not soul:
raise ValueError("Agent not found")
return soul
params.append(agent_key.lower())
execute(f"UPDATE agent_souls SET {', '.join(sets)}, updated_at = NOW() WHERE agent_key = %s", tuple(params))
return get_soul(agent_key) or {}
def list_permissions() -> list[dict[str, Any]]:
return [dict(r) for r in fetch_all("SELECT * FROM herman_permissions ORDER BY category, module_label")]
def update_permission(module_key: str, granted: bool) -> dict[str, Any]:
execute(
"""UPDATE herman_permissions SET granted = %s, granted_at = CASE WHEN %s THEN NOW() ELSE NULL END, updated_at = NOW()
WHERE module_key = %s""",
(granted, granted, module_key),
)
row = fetch_one("SELECT * FROM herman_permissions WHERE module_key = %s", (module_key,))
return dict(row or {})
def grant_all_permissions() -> int:
execute("UPDATE herman_permissions SET granted = TRUE, granted_at = NOW(), updated_at = NOW()")
row = fetch_one("SELECT COUNT(*) AS n FROM herman_permissions WHERE granted = TRUE")
return int((row or {}).get("n") or 0)
+233
View File
@@ -0,0 +1,233 @@
"""Comprehensive analytics data from all DB tables with optional filters."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all, fetch_one
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 _iso_rows(rows: list) -> list:
for row in rows:
for key, val in list(row.items()):
if hasattr(val, "isoformat"):
row[key] = val.isoformat()
elif val is not None and type(val).__name__ == "Decimal":
row[key] = float(val)
return rows
def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]:
f = filters or {}
chain = f.get("chain") or None
province = f.get("province") or None
stage = f.get("stage") or None
agent = f.get("agent") or None
days = int(f.get("days") or 90)
data: dict[str, Any] = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"filters": f,
}
data["kpis"] = {
"clients_total": _safe_count("clients"),
"clients_active": _safe_count("clients", "stage = 'active'"),
"deals_total": _safe_count("deals"),
"pipeline_eur": float(
(fetch_one("SELECT COALESCE(SUM(value),0) AS t FROM deals WHERE stage NOT IN ('won','lost')") or {}).get("t", 0)
),
"supermarkets": _safe_count("supermarkets"),
"wholesalers": _safe_count("wholesalers"),
"crm_partnerships": _safe_count("supermarkets", "partnership_status = 'active'"),
"rss_items": _safe_count("rss_items"),
"rss_bookmarks": _safe_count("rss_bookmarks"),
"agent_events": _safe_count("agent_events"),
"pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
"promo_campaigns": _safe_count("promo_campaigns", "status = 'active'"),
"contacts_supermarket": _safe_count("supermarket_contacts"),
"contacts_wholesaler": _safe_count("wholesaler_contacts"),
"nas_docs": _safe_count("document_analytics"),
}
try:
data["clients_by_stage"] = fetch_all(
"SELECT stage, COUNT(*) AS cnt FROM clients GROUP BY stage ORDER BY cnt DESC"
)
except Exception:
data["clients_by_stage"] = []
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["events_by_agent"] = fetch_all(
"""SELECT agent_name, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""" % days
)
except Exception:
data["events_by_agent"] = []
store_where, store_params = [], []
if chain:
store_where.append("chain = %s")
store_params.append(chain)
if province:
store_where.append("province = %s")
store_params.append(province)
sw = (" WHERE " + " AND ".join(store_where)) if store_where else ""
try:
data["supermarkets_by_chain"] = fetch_all(
f"SELECT chain, COUNT(*) AS cnt FROM supermarkets{sw} GROUP BY chain ORDER BY cnt DESC LIMIT 15",
tuple(store_params) if store_params else None,
)
except Exception:
data["supermarkets_by_chain"] = []
try:
data["supermarkets_by_province"] = fetch_all(
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
if store_where
else "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
except Exception:
data["supermarkets_by_province"] = []
try:
data["partnership_breakdown"] = fetch_all(
"SELECT COALESCE(partnership_status,'none') AS status, COUNT(*) AS cnt FROM supermarkets GROUP BY partnership_status ORDER BY cnt DESC"
)
except Exception:
data["partnership_breakdown"] = []
try:
data["wholesalers_by_province"] = fetch_all(
"SELECT province, COUNT(*) AS cnt FROM wholesalers WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
except Exception:
data["wholesalers_by_province"] = []
try:
data["rss_by_category"] = fetch_all(
"""SELECT f.category, COUNT(i.id) AS cnt FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id GROUP BY f.category ORDER BY cnt DESC"""
)
except Exception:
data["rss_by_category"] = []
try:
data["events_timeline"] = fetch_all(
"""SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days'
GROUP BY DATE(created_at) ORDER BY day ASC""" % days
)
except Exception:
data["events_timeline"] = []
try:
data["milestones_by_status"] = fetch_all(
"SELECT status, COUNT(*) AS cnt FROM sales_milestones GROUP BY status ORDER BY cnt DESC"
)
except Exception:
data["milestones_by_status"] = []
try:
data["top_opportunities"] = fetch_all(
"""SELECT 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 10"""
)
except Exception:
data["top_opportunities"] = []
try:
data["sentiment_distribution"] = fetch_all(
"SELECT sentiment_label, COUNT(*) AS cnt FROM document_analytics GROUP BY sentiment_label"
)
except Exception:
data["sentiment_distribution"] = []
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 15"""
)
except Exception:
data["top_words"] = []
try:
data["promo_by_chain"] = fetch_all(
"SELECT chain, COUNT(*) AS cnt FROM promo_campaigns WHERE status = 'active' GROUP BY chain ORDER BY cnt DESC"
)
except Exception:
data["promo_by_chain"] = []
deal_where = ""
deal_params: tuple = ()
if stage:
deal_where = " WHERE stage = %s"
deal_params = (stage,)
try:
data["recent_deals"] = fetch_all(
f"SELECT title, stage, value, updated_at FROM deals{deal_where} ORDER BY updated_at DESC LIMIT 10",
deal_params or None,
)
except Exception:
data["recent_deals"] = []
agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'"
if agent:
agent_where += " AND agent_name = %s"
try:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25""",
(agent,),
)
except Exception:
data["recent_events"] = []
else:
try:
data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25"""
)
except Exception:
data["recent_events"] = []
try:
data["filter_meta"] = {
"chains": fetch_all("SELECT DISTINCT chain FROM supermarkets WHERE chain IS NOT NULL ORDER BY chain"),
"provinces": fetch_all("SELECT DISTINCT province FROM supermarkets WHERE province IS NOT NULL ORDER BY province"),
"client_stages": fetch_all("SELECT DISTINCT stage FROM clients ORDER BY stage"),
"deal_stages": fetch_all("SELECT DISTINCT stage FROM deals ORDER BY stage"),
"agents": fetch_all("SELECT DISTINCT agent_name FROM agent_events ORDER BY agent_name"),
}
except Exception:
data["filter_meta"] = {}
for key in (
"clients_by_stage", "deals_by_stage", "events_by_agent", "supermarkets_by_chain",
"supermarkets_by_province", "partnership_breakdown", "wholesalers_by_province",
"rss_by_category", "events_timeline", "milestones_by_status", "top_opportunities",
"sentiment_distribution", "top_words", "promo_by_chain", "recent_deals", "recent_events",
):
if isinstance(data.get(key), list):
data[key] = _iso_rows(data[key])
return data
+385
View File
@@ -0,0 +1,385 @@
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, ollama
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 collect_briefing_data() -> dict[str, Any]:
data: dict[str, Any] = {
"date": date.today().isoformat(),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
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'")
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["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"] = []
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"] = []
# Retail intelligence
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"] = []
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["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 ('markt', 'supermarkt', 'kant-en-klaar', 'retail')
OR i.title ILIKE ANY (ARRAY['%supermarkt%','%retail%','%jumbo%','%ahold%','%halal%','%maaltijd%'])
ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
)
except Exception:
data["food_market_highlights"] = []
return data
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("pending_items"):
lines.extend(["", "## ⚠️ Wacht op jouw goedkeuring"])
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"
prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
"## Samenvatting\n(5-7 zinnen: wat is vandaag belangrijk, pipeline, retail kansen, milestones)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 concrete bullets met CRM/retail acties)\n\n"
"## Lange termijn focus\n(3-5 bullets: groei supermarkt partnerships, halal markt, milestones komende weken)\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\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. Schrijf warm, professioneel en actionable. "
"Focus op halal kant-en-klaar retail groei in Nederland. Geen vage tekst — concrete namen en acties."
)
try:
return await ollama.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
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)."
)
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
actions = [
"Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
f"Behandel {data['pending_approvals']} openstaande agent-goedkeuringen",
"Check Marketing Live Feed voor kant-en-klaar trends",
]
if ms:
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
for a in actions[:6]:
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",
])
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 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}),
),
)
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)
return content, serialize_stats(data)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import json
from typing import Any
import httpx
from app.config import settings
from app.db import execute, fetch_one
from app.services import ollama
AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
"bizdev": {"name": "BizDev", "persona": "Pipeline, retail partnerships, deal structuring."},
"finance": {"name": "Finance", "persona": "Margins, cashflow, pricing for food brands."},
"sourcing": {"name": "Sourcing", "persona": "Suppliers, MOQ, lead times, procurement."},
"product": {"name": "Product", "persona": "SKU development, launch timelines, shelf readiness."},
"halal": {"name": "Halal", "persona": "Halal compliance, certification, ingredient vetting."},
"design": {"name": "Design", "persona": "Packaging, visual identity, retail presentation."},
"knowledge": {"name": "Knowledge", "persona": "Internal docs, RAG, policy answers."},
}
IMAGE_KEYWORDS = (
"maak foto", "maak een foto", "genereer foto", "genereer afbeelding",
"maak afbeelding", "productfoto", "genereer image", "generate image",
"make image", "maak plaatje", "/genfoto",
)
def _wants_image(raw: str) -> bool:
t = (raw or "").strip().lower()
return any(k in t for k in IMAGE_KEYWORDS)
def _extract_image_prompt(raw: str) -> str:
t = raw.strip()
lower = t.lower()
for k in IMAGE_KEYWORDS:
if lower.startswith(k):
rest = t[len(k):].strip(" :,-")
if rest:
return rest
for k in IMAGE_KEYWORDS:
if k in lower:
idx = lower.index(k) + len(k)
rest = t[idx:].strip(" :,-")
if rest:
return rest
return t
async def _log_event(agent_name: str, event_type: str, title: str, body: str, metadata: dict | None = None) -> None:
payload = {
"agent_name": agent_name,
"agent_type": "herman_delegate",
"event_type": event_type,
"title": title[:255],
"body": body,
"metadata": metadata or {},
"status": "completed",
"channel": "herman",
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
await client.post(f"{settings.TOOLS_API_URL.rstrip('/')}/events", json=payload)
except Exception:
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)""",
(agent_name, "herman_delegate", event_type, title[:255], body, "completed", "herman", json.dumps(metadata or {})),
)
except Exception:
pass
def _pick_agent(raw: str) -> str:
text = (raw or "").strip().lower()
first = text.split()[0].replace(",", "").replace(".", "") if text else "knowledge"
if first in AGENTS:
return first
for k in AGENTS:
if k in text:
return k
return "knowledge"
async def chat(message: str) -> dict[str, Any]:
if _wants_image(message):
prompt = _extract_image_prompt(message)
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate",
json={"prompt": prompt, "width": 512, "height": 512, "steps": 15},
)
r.raise_for_status()
data = r.json()
filename = data.get("filename", "")
subfolder = data.get("subfolder", "")
img_type = data.get("type", "output")
proxy = f"/api/ai/generated-image?filename={filename}&subfolder={subfolder}&type={img_type}"
reply = f"Afbeelding gegenereerd voor: {prompt}"
await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename})
return {
"agent": "design",
"agent_label": "Design",
"reply": reply,
"image_url": proxy,
"prompt": prompt,
}
except Exception as exc:
return {
"agent": "design",
"agent_label": "Design",
"reply": f"Kon geen afbeelding genereren: {exc}",
}
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.HERMAN_ORCHESTRATOR_URL.rstrip('/')}/chat",
json={"message": message, "agent": "default", "use_crm": True, "channel": "cockpit"},
)
r.raise_for_status()
data = r.json()
delegated = data.get("delegated_agents") or [data.get("agent", "herman")]
await _log_event(
"herman",
"openswarm_delegation",
f"Herman → {', '.join(delegated)}",
message[:2000],
{"delegated": delegated, "reason": data.get("routing_reason", "")},
)
return {
"agent": data.get("agent", "herman"),
"agent_label": data.get("agent_label", "Herman"),
"reply": data.get("reply", ""),
"delegated_agents": delegated,
"routing_reason": data.get("routing_reason", ""),
}
except Exception as exc:
return {
"agent": "herman",
"agent_label": "Herman",
"reply": f"Herman orchestrator niet bereikbaar: {exc}",
}
async def generate_briefing() -> str:
from app.services.briefing import generate_daily_briefing
content, stats = await generate_daily_briefing()
await _log_event("herman", "briefing", "CEO briefing generated", content[:1500], {"stats": stats})
return content
+89
View File
@@ -0,0 +1,89 @@
"""Fetch retail stock quotes — delegates to tools-api when available."""
from __future__ import annotations
import json
import os
from typing import Any
from urllib.request import Request, urlopen
USER_AGENT = "Foodlinkk-MarketIntel/1.0"
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
RETAIL_STOCKS = [
{"symbol": "AD.AS", "name": "Ahold Delhaize", "chain": "Albert Heijn / Gall", "market": "Euronext"},
{"symbol": "TSCO.L", "name": "Tesco", "chain": "Tesco UK", "market": "LSE"},
{"symbol": "CAR.PA", "name": "Carrefour", "chain": "Carrefour EU", "market": "Euronext Paris"},
{"symbol": "SBRY.L", "name": "Sainsbury's", "chain": "Sainsbury's", "market": "LSE"},
{"symbol": "MKS.L", "name": "Marks & Spencer", "chain": "M&S Food", "market": "LSE"},
{"symbol": "WMT", "name": "Walmart", "chain": "Global benchmark", "market": "NYSE"},
{"symbol": "ULVR.L", "name": "Unilever", "chain": "FMCG / food", "market": "LSE"},
]
def _fetch_chart(symbol: str) -> dict[str, Any]:
url = (
f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
f"?interval=1d&range=1mo&includePrePost=false"
)
req = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(req, timeout=12) as resp:
payload = json.loads(resp.read().decode())
result = (payload.get("chart") or {}).get("result") or []
if not result:
return {}
meta = result[0].get("meta") or {}
closes = (result[0].get("indicators") or {}).get("quote") or [{}]
close_series = closes[0].get("close") or []
valid = [c for c in close_series if c is not None]
sparkline = valid[-14:] if len(valid) >= 14 else valid
prev = valid[-2] if len(valid) >= 2 else None
last = valid[-1] if valid else meta.get("regularMarketPrice")
change_pct = meta.get("regularMarketChangePercent")
if change_pct is None and prev and last and prev:
change_pct = ((last - prev) / prev) * 100
return {
"price": meta.get("regularMarketPrice") or last,
"currency": meta.get("currency") or "EUR",
"change_pct": round(float(change_pct or 0), 2),
"sparkline": [round(float(v), 2) for v in sparkline],
"market_state": meta.get("marketState") or "CLOSED",
}
def fetch_retail_quotes() -> list[dict[str, Any]]:
try:
req = Request(f"{TOOLS}/retail/market/stocks", headers={"User-Agent": USER_AGENT})
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
if data.get("items"):
return data["items"]
except Exception:
pass
items: list[dict[str, Any]] = []
for stock in RETAIL_STOCKS:
row = dict(stock)
try:
chart = _fetch_chart(stock["symbol"])
row.update(chart)
row["trend"] = "up" if (row.get("change_pct") or 0) >= 0 else "down"
except Exception:
row["price"] = None
row["change_pct"] = 0
row["sparkline"] = []
row["trend"] = "flat"
items.append(row)
return items
def market_summary(quotes: list[dict[str, Any]] | None = None) -> dict[str, Any]:
quotes = quotes or fetch_retail_quotes()
valid = [q for q in quotes if q.get("price") is not None]
avg_change = sum(float(q.get("change_pct") or 0) for q in valid) / len(valid) if valid else 0
best = max(valid, key=lambda q: float(q.get("change_pct") or 0), default=None)
worst = min(valid, key=lambda q: float(q.get("change_pct") or 0), default=None)
return {
"avg_change_pct": round(avg_change, 2),
"best_performer": best,
"worst_performer": worst,
"quote_count": len(valid),
}
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Optional
from psycopg2.extras import RealDictCursor
from app.db import get_connection
def sentiment_score(text: str) -> float:
try:
from textblob import TextBlob
blob = TextBlob(text)
score = (blob.sentiment.polarity + 1) * 2 + 1
except Exception:
t = text.lower()
neg = sum(1 for w in ("bad", "teleurgest", "klacht", "lang", "duur", "fout") if w in t)
pos = sum(1 for w in ("geweldig", "aanrader", "fantast", "mooi", "lekker", "top") if w in t)
raw = 3.0 + (pos - neg) * 0.5
score = max(1.0, min(5.0, raw))
return max(1.0, min(5.0, round(float(score), 2)))
def evaluate_agent_rules(mention_id: Optional[int] = None) -> None:
with get_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM agent_rules WHERE is_active = TRUE")
rules = cur.fetchall()
for rule in rules:
if rule["condition_type"] == "sentiment_below":
threshold = rule["threshold"] or 2.0
if mention_id:
cur.execute(
"SELECT id, text, sentiment_score FROM social_mentions WHERE id = %s AND sentiment_score < %s",
(mention_id, threshold),
)
else:
cur.execute(
"SELECT id, text, sentiment_score FROM social_mentions WHERE sentiment_score < %s ORDER BY created_at DESC LIMIT 5",
(threshold,),
)
matches = cur.fetchall()
for m in matches:
cur.execute(
"SELECT 1 FROM agent_logs WHERE rule_id = %s AND message LIKE %s",
(rule["id"], f"%mention #{m['id']}%"),
)
if cur.fetchone():
continue
msg = (
f"ALERT [{rule['name']}]: Negatief sentiment ({m['sentiment_score']}/5) "
f"op mention #{m['id']}: {(m['text'] or '')[:120]}"
)
cur.execute(
"INSERT INTO agent_logs (rule_id, message) VALUES (%s, %s)",
(rule["id"], msg),
)
elif rule["condition_type"] == "mention_spike":
threshold = int(rule["threshold"] or 5)
since = datetime.now() - timedelta(hours=24)
cur.execute(
"SELECT COUNT(*) AS cnt FROM social_mentions WHERE created_at > %s",
(since,),
)
count = cur.fetchone()["cnt"]
if count >= threshold:
msg = f"ALERT [{rule['name']}]: {count} mentions in 24u (drempel: {threshold})"
cur.execute(
"SELECT 1 FROM agent_logs WHERE rule_id = %s AND message = %s AND created_at > %s",
(rule["id"], msg, since),
)
if not cur.fetchone():
cur.execute(
"INSERT INTO agent_logs (rule_id, message) VALUES (%s, %s)",
(rule["id"], msg),
)
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import hashlib
import re
import subprocess
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup
from app.db import execute, fetch_one, get_connection
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 FoodlinkkBot/2.0"
def _validate_url(url: str) -> str:
url = (url or "").strip()
if not url.startswith(("http://", "https://")):
url = "https://" + url.lstrip("/")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError("URL must start with http:// or https://")
return url
def _fetch_page(url: str) -> tuple[str, str, str, str] | None:
"""Returns final_url, title, normalized_text, raw_html."""
try:
resp = httpx.get(
url,
timeout=25.0,
follow_redirects=True,
headers={"User-Agent": USER_AGENT, "Accept-Language": "nl-NL,nl;q=0.9"},
)
if resp.status_code >= 400:
return None
html = resp.text
soup = BeautifulSoup(html, "html.parser")
title = (soup.title.string or "").strip() if soup.title else ""
for tag in soup(["script", "style", "noscript", "svg", "iframe"]):
tag.decompose()
text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
return str(resp.url), title, text, html
except Exception:
return None
def get_page_hash(url: str) -> str | None:
fetched = _fetch_page(url)
if not fetched:
return None
_, _, text, _ = fetched
return hashlib.md5(text.encode("utf-8")).hexdigest()
def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str, html: str) -> int | None:
import json
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, crawled_at)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
ON CONFLICT (url) DO UPDATE SET
final_url=EXCLUDED.final_url, title=EXCLUDED.title,
content=EXCLUDED.content, content_html=EXCLUDED.content_html,
site_id=EXCLUDED.site_id, crawled_at=NOW()
RETURNING id
""",
(
url,
final_url,
title,
text[:50000],
html[:100000],
site_id,
json.dumps({"source": "monitor"}),
),
)
page_id = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO browser_sessions (url, final_url, title, status, content_text, site_id, completed_at)
VALUES (%s,%s,%s,'completed',%s,%s,NOW()) RETURNING id
""",
(url, final_url, title, text[:80000], site_id),
)
session_id = cur.fetchone()[0]
cur.execute(
"UPDATE monitored_sites SET last_title=%s, last_snapshot_id=%s WHERE id=%s",
(title, session_id, site_id),
)
return session_id
except Exception:
return None
def add_site(url: str, name: str) -> dict:
url = _validate_url(url)
name = (name or url).strip()
fetched = _fetch_page(url)
if fetched:
final_url, title, text, html = fetched
h = hashlib.md5(text.encode("utf-8")).hexdigest()
else:
final_url, title, text, html = url, name, "", ""
h = hashlib.md5(url.encode("utf-8")).hexdigest()
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO monitored_sites (url, name, last_hash, last_crawled, last_title, is_active)
VALUES (%s, %s, %s, NOW(), %s, TRUE)
ON CONFLICT (url) DO UPDATE SET
name = EXCLUDED.name,
last_hash = EXCLUDED.last_hash,
last_crawled = NOW(),
last_title = EXCLUDED.last_title,
is_active = TRUE
RETURNING id
""",
(url, name, h, title),
)
site_id = cur.fetchone()[0]
if fetched:
_save_snapshot(site_id, url, final_url, title, text, html)
row = fetch_one(
"SELECT id, url, name, last_hash, last_crawled, last_title, is_active, last_snapshot_id FROM monitored_sites WHERE id = %s",
(site_id,),
)
return dict(row) if row else {"id": site_id, "url": url, "name": name}
def remove_site(site_id: int, soft: bool = True) -> None:
if soft:
execute("UPDATE monitored_sites SET is_active = FALSE WHERE id = %s", (site_id,))
else:
execute("DELETE FROM crawl_logs WHERE site_id = %s", (site_id,))
execute("DELETE FROM page_changes WHERE site_id = %s", (site_id,))
execute("DELETE FROM monitored_sites WHERE id = %s", (site_id,))
def trigger_crawl(site_id: int | None = None) -> dict:
try:
cmd = ["docker", "exec", "foodlinkk_worker", "python", "-c", "import trigger"]
subprocess.run(cmd, capture_output=True, timeout=120, check=False)
return {"ok": True, "method": "worker"}
except Exception:
pass
from app.db import fetch_all
if site_id:
row = fetch_one(
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
(site_id,),
)
sites = [row] if row else []
else:
sites = fetch_all(
"SELECT id, url, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE"
)
changed = 0
with get_connection() as conn:
with conn.cursor() as cur:
for site in sites:
fetched = _fetch_page(site["url"])
if not fetched:
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "ERROR", f"Cannot reach {site['url']}"),
)
continue
final_url, title, text, html = fetched
new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
old_hash = site.get("last_hash")
if old_hash and old_hash != new_hash:
cur.execute(
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
(site["id"], old_hash, new_hash),
)
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "CHANGE", f"Change detected on {site['url']}{title}"),
)
changed += 1
else:
cur.execute(
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
(site["id"], "OK", f"Crawl OK — {title}"),
)
cur.execute(
"""
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
""",
(new_hash, title, site["id"]),
)
_save_snapshot(site["id"], site["url"], final_url, title, text, html)
return {"ok": True, "method": "inline", "changes": changed, "sites": len(sites)}
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import httpx
from app.config import settings
async def generate(prompt: str, system: str | None = None, timeout: float = 300.0) -> str:
messages: list[dict[str, str]] = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
return await chat_messages(messages, timeout=timeout)
async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0) -> str:
url = f"{settings.OLLAMA_URL.rstrip('/')}/api/chat"
payload = {
"model": settings.OLLAMA_MODEL,
"messages": messages,
"think": False,
"stream": False,
"keep_alive": "30m",
"options": {"num_predict": 280, "temperature": 0.4},
}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
msg = resp.json().get("message") or {}
content = (msg.get("content") or "").strip()
if content:
return content
thinking = (msg.get("thinking") or "").strip()
return thinking[:2000] if thinking else ""
+113
View File
@@ -0,0 +1,113 @@
"""Unified live platform feed — events with traceable sources."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from app.db import fetch_all
CHANNEL_ROUTES = {
"dashboard": "/",
"retail": "/retail",
"marketing": "/marketing",
"beurs": "/beurs",
"agents": "/agents",
"hermes": "/hermes",
"browser": "/browser",
"documents": "/documents",
"settings": "/settings",
}
def _resolve_source(row: dict[str, Any]) -> dict[str, Any]:
meta = row.get("metadata") or {}
if isinstance(meta, str):
import json
try:
meta = json.loads(meta)
except Exception:
meta = {}
source_url = meta.get("source_url") or meta.get("url") or meta.get("link")
source_label = meta.get("source") or meta.get("feed_name")
if not source_url:
channel = row.get("channel") or "dashboard"
source_url = CHANNEL_ROUTES.get(channel, "/")
source_label = source_label or f"Foodlinkk · {channel}"
if row.get("related_table") == "rss_items" and row.get("related_id"):
source_url = meta.get("link") or source_url
event_type = (row.get("event_type") or "").lower()
agent = (row.get("agent_name") or "").lower()
if event_type in ("briefing", "report"):
source_url = "/"
elif event_type in ("sync", "score", "import") and "retail" in agent:
source_url = "/retail"
elif event_type == "refresh" and "rss" in agent:
source_url = "/marketing"
elif event_type in ("sync",) and "halal" in agent:
source_url = "/retail"
elif agent == "herman":
source_url = "/"
elif agent in ("marketing", "rss_feeds"):
source_url = "/marketing"
elif agent in ("wholesale_scraper", "retail_intel"):
source_url = "/retail"
elif agent == "hermes":
source_url = "/hermes"
internal_url = source_url if source_url.startswith("/") else None
external_url = source_url if source_url and source_url.startswith("http") else None
return {
"source_url": source_url,
"source_label": source_label or "Foodlinkk platform",
"internal_url": internal_url,
"external_url": external_url,
}
def fetch_platform_events(limit: int = 80, agent: Optional[str] = None) -> list[dict[str, Any]]:
clauses, params = [], []
if agent:
clauses.append("LOWER(agent_name) = %s")
params.append(agent.lower())
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"""SELECT id, agent_name, agent_type, event_type, title, body, status,
channel, metadata, related_table, related_id, created_at
FROM agent_events{where}
ORDER BY created_at DESC LIMIT %s""",
tuple(params + [limit]),
)
events = []
for r in rows:
item = dict(r)
if item.get("created_at"):
item["created_at"] = item["created_at"].isoformat()
src = _resolve_source(item)
item.update(src)
item["click_url"] = src.get("external_url") or src.get("internal_url") or "/agents"
item["is_external"] = bool(src.get("external_url"))
events.append(item)
return events
def platform_stats() -> dict[str, Any]:
try:
total = fetch_all("SELECT COUNT(*) AS n FROM agent_events")[0]["n"]
pending = fetch_all("SELECT COUNT(*) AS n FROM agent_events WHERE status = 'needs_approval'")[0]["n"]
last_hour = fetch_all(
"SELECT COUNT(*) AS n FROM agent_events WHERE created_at >= NOW() - INTERVAL '1 hour'"
)[0]["n"]
except Exception:
total = pending = last_hour = 0
return {
"total_events": int(total or 0),
"pending_approvals": int(pending or 0),
"events_last_hour": int(last_hour or 0),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
+86
View File
@@ -0,0 +1,86 @@
"""Full-system data export for Reports hub."""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Any
from app.db import fetch_all
EXPORT_DATASETS: dict[str, dict[str, str]] = {
"clients": {"label": "CRM Klanten", "table": "clients", "order": "updated_at DESC"},
"deals": {"label": "CRM Deals", "table": "deals", "order": "updated_at DESC"},
"supermarkets": {"label": "Supermarkten", "table": "supermarkets", "order": "name ASC"},
"wholesalers": {"label": "Groothandels", "table": "wholesalers", "order": "name ASC"},
"supermarket_contacts": {"label": "Supermarkt contacten", "table": "supermarket_contacts", "order": "id ASC"},
"wholesaler_contacts": {"label": "Groothandel contacten", "table": "wholesaler_contacts", "order": "id ASC"},
"rss_items": {"label": "RSS items", "table": "rss_items", "order": "published_at DESC NULLS LAST"},
"rss_bookmarks": {"label": "RSS bookmarks", "table": "rss_bookmarks", "order": "created_at DESC"},
"agent_events": {"label": "Agent events", "table": "agent_events", "order": "created_at DESC"},
"sales_milestones": {"label": "Sales milestones", "table": "sales_milestones", "order": "created_at DESC"},
"promo_campaigns": {"label": "Promo / reclame", "table": "promo_campaigns", "order": "created_at DESC"},
"daily_briefings": {"label": "Dagrapporten", "table": "daily_briefings", "order": "created_at DESC"},
"document_analytics": {"label": "NAS documenten", "table": "document_analytics", "order": "analyzed_at DESC NULLS LAST"},
"products": {"label": "Producten", "table": "products", "order": "name ASC"},
"suppliers": {"label": "Leveranciers", "table": "suppliers", "order": "name ASC"},
}
def _serialize(val: Any) -> Any:
if hasattr(val, "isoformat"):
return val.isoformat()
if isinstance(val, (dict, list)):
return json.dumps(val, default=str)
if val is not None and type(val).__name__ == "Decimal":
return float(val)
return val
def list_datasets() -> list[dict[str, Any]]:
out = []
for key, meta in EXPORT_DATASETS.items():
count = 0
try:
from app.db import fetch_one
row = fetch_one(f"SELECT COUNT(*) AS c FROM {meta['table']}")
count = int(row["c"]) if row else 0
except Exception:
pass
out.append({"id": key, "label": meta["label"], "count": count})
return out
def fetch_dataset(name: str, limit: int = 10000) -> list[dict[str, Any]]:
meta = EXPORT_DATASETS.get(name)
if not meta:
raise ValueError(f"Unknown dataset: {name}")
rows = fetch_all(f"SELECT * FROM {meta['table']} ORDER BY {meta['order']} LIMIT %s", (limit,))
for row in rows:
for k, v in list(row.items()):
row[k] = _serialize(v)
return rows
def to_csv(rows: list[dict[str, Any]]) -> str:
if not rows:
return ""
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue()
def export_all_json(limit: int = 5000) -> dict[str, Any]:
bundle: dict[str, Any] = {
"exported_at": datetime.now(timezone.utc).isoformat(),
"datasets": {},
}
for key in EXPORT_DATASETS:
try:
bundle["datasets"][key] = fetch_dataset(key, limit=min(limit, 5000))
except Exception as exc:
bundle["datasets"][key] = {"error": str(exc)}
return bundle
+314
View File
@@ -0,0 +1,314 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from app.db import execute, fetch_all, fetch_one
PLATFORMS = ("twitter", "linkedin", "instagram", "facebook", "tiktok", "pinterest")
_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
"twitter": ("api_key", "api_secret", "access_token", "access_secret"),
"linkedin": ("access_token", "person_urn"),
"instagram": ("access_token", "page_id"),
"facebook": ("access_token", "page_id"),
"tiktok": ("access_token", "open_id"),
"pinterest": ("access_token", "board_id"),
}
def _normalize_platform(platform: str) -> str:
value = (platform or "").strip().lower()
if value not in PLATFORMS:
raise ValueError(f"Unsupported platform: {platform}")
return value
def _serialize(value: Any) -> Any:
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def _normalize_config(row: dict[str, Any] | None) -> dict[str, Any]:
if not row:
return {}
config = row.get("config") or {}
if isinstance(config, str):
try:
config = json.loads(config)
except Exception:
config = {}
if not isinstance(config, dict):
config = {}
# Keep compatibility with schemas that store fields as columns.
for key in ("api_key", "api_secret", "access_token", "access_secret", "person_urn", "page_id", "open_id", "board_id"):
if row.get(key) and not config.get(key):
config[key] = row.get(key)
return config
def _has_credentials(platform: str, config: dict[str, Any]) -> bool:
required = _REQUIRED_FIELDS.get(platform, ())
if not required:
return False
return all(bool(config.get(name)) for name in required)
def _log_event(title: str, body: str, status: str, metadata: dict[str, Any]) -> None:
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)
""",
(
"marketing_automation",
"social_publish",
"social_publish",
title,
body[:2000],
status,
"marketing",
json.dumps(metadata),
),
)
except Exception:
pass
def get_integration(platform: str) -> dict[str, Any] | None:
platform = _normalize_platform(platform)
row = fetch_one(
"SELECT * FROM social_integrations WHERE platform = %s AND COALESCE(is_active, TRUE) = TRUE",
(platform,),
)
if not row:
return None
out = {k: _serialize(v) for k, v in row.items()}
out["platform"] = platform
out["config"] = _normalize_config(row)
return out
def get_configured_channels() -> list[dict[str, Any]]:
rows = fetch_all(
"SELECT * FROM social_integrations WHERE platform = ANY(%s) ORDER BY platform",
(list(PLATFORMS),),
)
by_platform = {(row.get("platform") or "").lower(): row for row in rows}
items: list[dict[str, Any]] = []
for platform in PLATFORMS:
row = by_platform.get(platform)
config = _normalize_config(row)
items.append(
{
"platform": platform,
"configured": _has_credentials(platform, config),
"is_active": bool(row.get("is_active")) if row else False,
"updated_at": _serialize(row.get("updated_at")) if row else None,
}
)
return items
def publish_to_channel(platform: str, text: str, image_path: str | None = None, image_url: str | None = None) -> dict[str, Any]:
try:
platform = _normalize_platform(platform)
except ValueError as exc:
return {"status": "failed", "error": str(exc), "platform": platform}
integration = get_integration(platform)
if not integration:
return {
"status": "skipped_not_configured",
"error": f"{platform} integration is not configured",
"platform": platform,
}
config = integration.get("config") or {}
if not _has_credentials(platform, config):
return {
"status": "skipped_not_configured",
"error": f"Missing credentials for {platform}",
"platform": platform,
}
try:
if platform == "twitter":
try:
import tweepy # type: ignore
except Exception as exc:
return {"status": "failed_dependency", "platform": platform, "error": f"tweepy unavailable: {exc}"}
client = tweepy.Client(
consumer_key=config["api_key"],
consumer_secret=config["api_secret"],
access_token=config["access_token"],
access_token_secret=config["access_secret"],
)
resp = client.create_tweet(text=text[:280])
return {"status": "published", "platform": platform, "external_id": str(getattr(resp, "data", {}) or {})}
if platform == "linkedin":
import requests
payload = {
"author": config.get("person_urn"),
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": text},
"shareMediaCategory": "IMAGE" if image_url else "NONE",
}
},
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
}
if image_url:
payload["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [{"status": "READY", "originalUrl": image_url}]
r = requests.post(
"https://api.linkedin.com/v2/ugcPosts",
headers={"Authorization": f"Bearer {config['access_token']}", "X-Restli-Protocol-Version": "2.0.0"},
json=payload,
timeout=20,
)
return {"status": "published" if r.ok else "failed", "platform": platform, "response_code": r.status_code, "error": None if r.ok else r.text[:300]}
if platform in ("instagram", "facebook"):
import requests
endpoint = f"https://graph.facebook.com/v20.0/{config['page_id']}/feed"
payload = {"message": text, "access_token": config["access_token"]}
if image_url:
endpoint = f"https://graph.facebook.com/v20.0/{config['page_id']}/photos"
payload = {"url": image_url, "caption": text, "access_token": config["access_token"]}
r = requests.post(endpoint, data=payload, timeout=20)
data = {}
try:
data = r.json()
except Exception:
data = {}
return {
"status": "published" if r.ok else "failed",
"platform": platform,
"external_id": data.get("id"),
"response_code": r.status_code,
"error": None if r.ok else (data.get("error", {}).get("message") or r.text[:300]),
}
if platform == "pinterest":
import requests
payload = {"board_id": config.get("board_id"), "title": text[:100], "description": text, "media_source": {"source_type": "image_url", "url": image_url}}
r = requests.post(
"https://api.pinterest.com/v5/pins",
headers={"Authorization": f"Bearer {config['access_token']}", "Content-Type": "application/json"},
json=payload,
timeout=20,
)
return {"status": "published" if r.ok else "failed", "platform": platform, "response_code": r.status_code, "error": None if r.ok else r.text[:300]}
if platform == "tiktok":
return {
"status": "failed",
"platform": platform,
"error": "TikTok publish placeholder not implemented yet (requires creator upload flow)",
}
except Exception as exc:
return {"status": "failed", "platform": platform, "error": str(exc)}
return {"status": "failed", "platform": platform, "error": "Unsupported platform"}
def test_connection(platform: str, integration: dict[str, Any] | None = None) -> dict[str, Any]:
platform = _normalize_platform(platform)
integration = integration or get_integration(platform)
if not integration:
return {"ok": False, "status": "skipped_not_configured", "error": f"{platform} integration is not configured"}
config = integration.get("config") or {}
if not _has_credentials(platform, config):
return {"ok": False, "status": "skipped_not_configured", "error": f"Missing credentials for {platform}"}
# Keep tests lightweight: perform a dry publish without side effects where possible.
if platform == "twitter":
try:
import tweepy # type: ignore
client = tweepy.Client(
consumer_key=config["api_key"],
consumer_secret=config["api_secret"],
access_token=config["access_token"],
access_token_secret=config["access_secret"],
)
_ = client.get_me()
return {"ok": True, "status": "ok", "message": "Twitter credentials look valid"}
except Exception as exc:
return {"ok": False, "status": "failed", "error": str(exc)}
return {"ok": True, "status": "ok", "message": f"{platform} configuration is present"}
def run_publish_job(job_id: int, text: str, channels: list[str], image_url: str | None, media_ids: list[int]) -> None:
started_at = datetime.utcnow()
execute(
"UPDATE social_publish_jobs SET status=%s, started_at=NOW(), updated_at=NOW() WHERE id=%s",
("running", job_id),
)
_log_event(
title=f"Social publish job #{job_id} gestart",
body=f"Kanalen: {', '.join(channels) if channels else '-'}",
status="running",
metadata={"job_id": job_id, "channels": channels},
)
chosen_image_url = image_url
if not chosen_image_url and media_ids:
media_rows = fetch_all(
"SELECT id, media_url, url, file_path FROM marketing_media WHERE id = ANY(%s) ORDER BY id",
(media_ids,),
)
if media_rows:
first = media_rows[0]
chosen_image_url = first.get("media_url") or first.get("url")
results: list[dict[str, Any]] = []
for channel in channels:
result = publish_to_channel(channel, text=text, image_url=chosen_image_url)
results.append(result)
published = sum(1 for item in results if item.get("status") == "published")
skipped = sum(1 for item in results if item.get("status") == "skipped_not_configured")
failed = len(results) - published - skipped
final_status = "completed"
if published == 0 and failed > 0:
final_status = "failed"
elif failed > 0:
final_status = "completed_with_errors"
execute(
"""
UPDATE social_publish_jobs
SET status=%s,
finished_at=NOW(),
updated_at=NOW(),
result=%s::jsonb
WHERE id=%s
""",
(
final_status,
json.dumps(
{
"published": published,
"skipped": skipped,
"failed": failed,
"channels": channels,
"results": results,
"started_at": started_at.isoformat(),
}
),
job_id,
),
)
_log_event(
title=f"Social publish job #{job_id} afgerond",
body=f"Published={published}, skipped={skipped}, failed={failed}",
status=final_status,
metadata={"job_id": job_id, "results": results},
)