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:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)}
|
||||
@@ -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 ""
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
Reference in New Issue
Block a user