From 5d60d33db1ea1ef32baed44699d9ad73c0d5a8b6 Mon Sep 17 00:00:00 2001 From: Aissa Date: Tue, 9 Jun 2026 00:41:27 +0000 Subject: [PATCH] Platform bundle: marketing publish, IT ops, packaging, agents mesh. Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie. --- .env.example | 23 + .gitignore | 15 + README.md | 42 + browser-agent/Dockerfile | 13 + browser-agent/app/__init__.py | 0 browser-agent/app/db.py | 49 + browser-agent/app/extract.py | 264 ++++ browser-agent/app/main.py | 660 +++++++++ browser-agent/app/pa_live.py | 157 +++ browser-agent/requirements.txt | 8 + cockpit/Dockerfile | 9 + cockpit/agents.html | 40 + cockpit/agents.py | 80 ++ cockpit/app/__init__.py | 0 cockpit/app/config.py | 24 + cockpit/app/db.py | 65 + cockpit/app/main.py | 134 ++ cockpit/app/routes/__init__.py | 1 + cockpit/app/routes/admin_api.py | 1251 +++++++++++++++++ cockpit/app/routes/agents.py | 84 ++ cockpit/app/routes/agents_api.py | 95 ++ cockpit/app/routes/analytics.html | 18 + cockpit/app/routes/analytics.py | 34 + cockpit/app/routes/api.py | 123 ++ cockpit/app/routes/beurs.py | 17 + cockpit/app/routes/browser.py | 71 + cockpit/app/routes/clients.html | 55 + cockpit/app/routes/clients.py | 27 + cockpit/app/routes/dashboard.py | 163 +++ cockpit/app/routes/deals.py | 33 + cockpit/app/routes/documents.py | 95 ++ cockpit/app/routes/herman.py | 79 ++ cockpit/app/routes/hermes.py | 52 + cockpit/app/routes/marketing.py | 72 + cockpit/app/routes/marketing_api.py | 119 ++ cockpit/app/routes/monitor.py | 9 + cockpit/app/routes/ops.py | 20 + cockpit/app/routes/ops_api.py | 56 + cockpit/app/routes/packaging.py | 87 ++ cockpit/app/routes/platform_live.py | 113 ++ cockpit/app/routes/products.py | 29 + cockpit/app/routes/reco_proxy.py | 39 + cockpit/app/routes/reports.py | 78 + cockpit/app/routes/retail.py | 333 +++++ cockpit/app/routes/settings.py | 21 + cockpit/app/routes/settings_api.py | 383 +++++ cockpit/app/routes/studio.py | 16 + cockpit/app/routes/suppliers.py | 27 + cockpit/app/routes/voice.py | 27 + cockpit/app/services/__init__.py | 1 + cockpit/app/services/agent_souls.py | 72 + cockpit/app/services/analytics_data.py | 233 +++ cockpit/app/services/briefing.py | 385 +++++ cockpit/app/services/herman.py | 149 ++ cockpit/app/services/market_stocks.py | 89 ++ cockpit/app/services/marketing.py | 80 ++ cockpit/app/services/monitor.py | 202 +++ cockpit/app/services/ollama.py | 34 + cockpit/app/services/platform_live.py | 113 ++ cockpit/app/services/reports_export.py | 86 ++ cockpit/app/services/social_publish.py | 314 +++++ cockpit/herman.py | 149 ++ cockpit/hermes.css | 218 +++ cockpit/hermes.html | 244 ++++ cockpit/patch_admin.py | 11 + cockpit/patch_cockpit.py | 57 + cockpit/requirements.txt | 10 + cockpit/settings.html | 310 ++++ cockpit/static/css/agents-mesh.css | 82 ++ cockpit/static/css/analytics.css | 50 + cockpit/static/css/base.html | 69 + cockpit/static/css/beurs.css | 171 +++ cockpit/static/css/herman-dashboard.css | 378 +++++ cockpit/static/css/hermes.css | 218 +++ cockpit/static/css/mobile.css | 146 ++ cockpit/static/css/ops-topology.css | 130 ++ cockpit/static/css/palantir-theme.css | 867 ++++++++++++ cockpit/static/css/pulse-theme.css | 335 +++++ cockpit/static/css/retail.css | 116 ++ cockpit/static/css/tokens.css | 29 + cockpit/static/css/topnav-neo.css | 196 +++ cockpit/static/css/vertical-tabs.css | 117 ++ cockpit/static/icons/app-icon.svg | 6 + cockpit/static/js/agents-mesh.js | 109 ++ cockpit/static/js/analytics.js | 181 +++ cockpit/static/js/beurs.js | 100 ++ cockpit/static/js/briefing-charts.js | 347 +++++ cockpit/static/js/cockpit.js | 75 + cockpit/static/js/hermes-ui.js | 346 +++++ cockpit/static/js/live-pulse.js | 54 + cockpit/static/manifest.json | 26 + cockpit/static/retail.css | 180 +++ cockpit/static/retail.html | 247 ++++ cockpit/static/retail.py | 69 + cockpit/static/sw.js | 46 + cockpit/templates/agents.html | 192 +++ cockpit/templates/analytics.html | 75 + cockpit/templates/api.py | 93 ++ cockpit/templates/base.html | 158 +++ cockpit/templates/beurs.html | 221 +++ cockpit/templates/briefing-charts.js | 212 +++ cockpit/templates/briefing.py | 268 ++++ cockpit/templates/browser.html | 561 ++++++++ cockpit/templates/clients.html | 55 + cockpit/templates/cockpit.js | 75 + cockpit/templates/dashboard.html | 194 +++ cockpit/templates/dashboard.py | 162 +++ cockpit/templates/deals.html | 33 + cockpit/templates/documents.html | 383 +++++ cockpit/templates/herman-dashboard.css | 122 ++ cockpit/templates/herman_chat.html | 92 ++ cockpit/templates/hermes.html | 244 ++++ cockpit/templates/marketing.html | 612 ++++++++ cockpit/templates/monitor.html | 77 + cockpit/templates/ops.html | 132 ++ cockpit/templates/packaging.html | 153 ++ cockpit/templates/palantir-theme.css | 867 ++++++++++++ cockpit/templates/products.html | 41 + cockpit/templates/pulse-theme.css | 258 ++++ cockpit/templates/reports.html | 92 ++ cockpit/templates/retail.html | 750 ++++++++++ cockpit/templates/settings.html | 432 ++++++ cockpit/templates/studio.html | 170 +++ cockpit/templates/suppliers.html | 31 + cockpit/templates/tokens.css | 28 + cockpit/templates/topnav-neo.css | 195 +++ cockpit/templates/voice.html | 63 + deploy-360.sh | 96 ++ deploy-all.sh | 56 + deploy-beurs.sh | 75 + deploy-ceo-market.sh | 79 ++ deploy-layout-fix.sh | 42 + deploy-neo-agents.sh | 65 + deploy-platform-upgrade.sh | 49 + deploy-ui-upgrade.sh | 15 + deploy-ui-v2.sh | 59 + deploy.sh | 58 + docker-compose.yml | 137 ++ docs/AGENTS.md | 24 + docs/API.md | 50 + docs/APPS.md | 34 + docs/ARCHITECTURE.md | 52 + docs/CSS-THEME.md | 32 + docs/DEPLOY.md | 53 + email-agent/Dockerfile | 7 + email-agent/app/__init__.py | 0 email-agent/app/config.py | 21 + email-agent/app/db.py | 72 + email-agent/app/imap_sync.py | 229 +++ email-agent/app/main.py | 81 ++ email-agent/requirements.txt | 4 + migrations/001_fase1.sql | 112 ++ migrations/002_fase234.sql | 61 + migrations/003_word_analytics.sql | 50 + migrations/004_emails_calendar_brain.sql | 74 + migrations/005_email_accounts.sql | 27 + migrations/006_browser_sessions.sql | 30 + migrations/007_photo_imports.sql | 17 + migrations/008_pgvector_telegram_brain.sql | 84 ++ migrations/009_full_upgrade.sql | 303 ++++ migrations/010_retail_crm_intel.sql | 83 ++ migrations/011_retail_360.sql | 113 ++ migrations/012_rss_focus.sql | 34 + migrations/013_agent_souls_permissions.sql | 206 +++ migrations/014_market_regulation_avatars.sql | 29 + migrations/015_platform_upgrade.sql | 66 + migrations/016_platform_ops_packaging.sql | 223 +++ monitoring/prometheus.yml | 15 + scripts/research_refresh.py | 15 + tools-api/Dockerfile | 7 + tools-api/__init__.py | 0 tools-api/app/__init__.py | 0 tools-api/app/brain.py | 425 ++++++ tools-api/app/briefing.py | 352 +++++ tools-api/app/comfyui.py | 396 ++++++ tools-api/app/config.py | 20 + tools-api/app/connectors/__init__.py | 0 tools-api/app/connectors/cbs.py | 142 ++ tools-api/app/connectors/food_trends.py | 167 +++ tools-api/app/connectors/halal_registry.py | 155 ++ tools-api/app/connectors/market_stocks.py | 273 ++++ tools-api/app/connectors/pdok.py | 56 + tools-api/app/connectors/proxmox.py | 381 +++++ tools-api/app/connectors/reclamefolder.py | 211 +++ tools-api/app/connectors/retail_360_routes.py | 239 ++++ tools-api/app/connectors/rss_feeds.py | 152 ++ tools-api/app/connectors/trends_feed.py | 104 ++ tools-api/app/db.py | 76 + tools-api/app/email_config.py | 46 + tools-api/app/logging_middleware.py | 26 + tools-api/app/main.py | 750 ++++++++++ tools-api/app/middleware.py | 45 + tools-api/app/ops_routes.py | 31 + tools-api/app/packaging/__init__.py | 1 + tools-api/app/packaging/export.py | 52 + tools-api/app/packaging/generator.py | 233 +++ tools-api/app/packaging_routes.py | 80 ++ tools-api/app/recommendations.py | 165 +++ tools-api/app/research.py | 183 +++ tools-api/app/retail.html | 411 ++++++ tools-api/app/retail.py | 557 ++++++++ tools-api/app/retail_360.py | 182 +++ tools-api/app/retail_360_routes.py | 511 +++++++ tools-api/app/retail_crm.py | 96 ++ tools-api/app/retail_enrichment.py | 158 +++ tools-api/app/retail_opportunities.py | 108 ++ tools-api/app/retail_scrapers.py | 253 ++++ tools-api/app/rss_feeds.py | 152 ++ tools-api/app/wholesaler_scrapers.py | 111 ++ tools-api/db.py | 76 + tools-api/patch_main.py | 21 + tools-api/requirements.txt | 10 + 212 files changed, 30044 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 browser-agent/Dockerfile create mode 100644 browser-agent/app/__init__.py create mode 100644 browser-agent/app/db.py create mode 100644 browser-agent/app/extract.py create mode 100644 browser-agent/app/main.py create mode 100644 browser-agent/app/pa_live.py create mode 100644 browser-agent/requirements.txt create mode 100644 cockpit/Dockerfile create mode 100644 cockpit/agents.html create mode 100644 cockpit/agents.py create mode 100644 cockpit/app/__init__.py create mode 100644 cockpit/app/config.py create mode 100644 cockpit/app/db.py create mode 100644 cockpit/app/main.py create mode 100644 cockpit/app/routes/__init__.py create mode 100644 cockpit/app/routes/admin_api.py create mode 100644 cockpit/app/routes/agents.py create mode 100644 cockpit/app/routes/agents_api.py create mode 100644 cockpit/app/routes/analytics.html create mode 100644 cockpit/app/routes/analytics.py create mode 100644 cockpit/app/routes/api.py create mode 100644 cockpit/app/routes/beurs.py create mode 100644 cockpit/app/routes/browser.py create mode 100644 cockpit/app/routes/clients.html create mode 100644 cockpit/app/routes/clients.py create mode 100644 cockpit/app/routes/dashboard.py create mode 100644 cockpit/app/routes/deals.py create mode 100644 cockpit/app/routes/documents.py create mode 100644 cockpit/app/routes/herman.py create mode 100644 cockpit/app/routes/hermes.py create mode 100644 cockpit/app/routes/marketing.py create mode 100644 cockpit/app/routes/marketing_api.py create mode 100644 cockpit/app/routes/monitor.py create mode 100644 cockpit/app/routes/ops.py create mode 100644 cockpit/app/routes/ops_api.py create mode 100644 cockpit/app/routes/packaging.py create mode 100644 cockpit/app/routes/platform_live.py create mode 100644 cockpit/app/routes/products.py create mode 100644 cockpit/app/routes/reco_proxy.py create mode 100644 cockpit/app/routes/reports.py create mode 100644 cockpit/app/routes/retail.py create mode 100644 cockpit/app/routes/settings.py create mode 100644 cockpit/app/routes/settings_api.py create mode 100644 cockpit/app/routes/studio.py create mode 100644 cockpit/app/routes/suppliers.py create mode 100644 cockpit/app/routes/voice.py create mode 100644 cockpit/app/services/__init__.py create mode 100644 cockpit/app/services/agent_souls.py create mode 100644 cockpit/app/services/analytics_data.py create mode 100644 cockpit/app/services/briefing.py create mode 100644 cockpit/app/services/herman.py create mode 100644 cockpit/app/services/market_stocks.py create mode 100644 cockpit/app/services/marketing.py create mode 100644 cockpit/app/services/monitor.py create mode 100644 cockpit/app/services/ollama.py create mode 100644 cockpit/app/services/platform_live.py create mode 100644 cockpit/app/services/reports_export.py create mode 100644 cockpit/app/services/social_publish.py create mode 100644 cockpit/herman.py create mode 100644 cockpit/hermes.css create mode 100644 cockpit/hermes.html create mode 100644 cockpit/patch_admin.py create mode 100644 cockpit/patch_cockpit.py create mode 100644 cockpit/requirements.txt create mode 100644 cockpit/settings.html create mode 100644 cockpit/static/css/agents-mesh.css create mode 100644 cockpit/static/css/analytics.css create mode 100644 cockpit/static/css/base.html create mode 100644 cockpit/static/css/beurs.css create mode 100644 cockpit/static/css/herman-dashboard.css create mode 100644 cockpit/static/css/hermes.css create mode 100644 cockpit/static/css/mobile.css create mode 100644 cockpit/static/css/ops-topology.css create mode 100644 cockpit/static/css/palantir-theme.css create mode 100644 cockpit/static/css/pulse-theme.css create mode 100644 cockpit/static/css/retail.css create mode 100644 cockpit/static/css/tokens.css create mode 100644 cockpit/static/css/topnav-neo.css create mode 100644 cockpit/static/css/vertical-tabs.css create mode 100644 cockpit/static/icons/app-icon.svg create mode 100644 cockpit/static/js/agents-mesh.js create mode 100644 cockpit/static/js/analytics.js create mode 100644 cockpit/static/js/beurs.js create mode 100644 cockpit/static/js/briefing-charts.js create mode 100644 cockpit/static/js/cockpit.js create mode 100644 cockpit/static/js/hermes-ui.js create mode 100644 cockpit/static/js/live-pulse.js create mode 100644 cockpit/static/manifest.json create mode 100644 cockpit/static/retail.css create mode 100644 cockpit/static/retail.html create mode 100644 cockpit/static/retail.py create mode 100644 cockpit/static/sw.js create mode 100644 cockpit/templates/agents.html create mode 100644 cockpit/templates/analytics.html create mode 100644 cockpit/templates/api.py create mode 100644 cockpit/templates/base.html create mode 100644 cockpit/templates/beurs.html create mode 100644 cockpit/templates/briefing-charts.js create mode 100644 cockpit/templates/briefing.py create mode 100644 cockpit/templates/browser.html create mode 100644 cockpit/templates/clients.html create mode 100644 cockpit/templates/cockpit.js create mode 100644 cockpit/templates/dashboard.html create mode 100644 cockpit/templates/dashboard.py create mode 100644 cockpit/templates/deals.html create mode 100644 cockpit/templates/documents.html create mode 100644 cockpit/templates/herman-dashboard.css create mode 100644 cockpit/templates/herman_chat.html create mode 100644 cockpit/templates/hermes.html create mode 100644 cockpit/templates/marketing.html create mode 100644 cockpit/templates/monitor.html create mode 100644 cockpit/templates/ops.html create mode 100644 cockpit/templates/packaging.html create mode 100644 cockpit/templates/palantir-theme.css create mode 100644 cockpit/templates/products.html create mode 100644 cockpit/templates/pulse-theme.css create mode 100644 cockpit/templates/reports.html create mode 100644 cockpit/templates/retail.html create mode 100644 cockpit/templates/settings.html create mode 100644 cockpit/templates/studio.html create mode 100644 cockpit/templates/suppliers.html create mode 100644 cockpit/templates/tokens.css create mode 100644 cockpit/templates/topnav-neo.css create mode 100644 cockpit/templates/voice.html create mode 100755 deploy-360.sh create mode 100755 deploy-all.sh create mode 100755 deploy-beurs.sh create mode 100755 deploy-ceo-market.sh create mode 100755 deploy-layout-fix.sh create mode 100755 deploy-neo-agents.sh create mode 100755 deploy-platform-upgrade.sh create mode 100755 deploy-ui-upgrade.sh create mode 100755 deploy-ui-v2.sh create mode 100755 deploy.sh create mode 100644 docker-compose.yml create mode 100644 docs/AGENTS.md create mode 100644 docs/API.md create mode 100644 docs/APPS.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CSS-THEME.md create mode 100644 docs/DEPLOY.md create mode 100644 email-agent/Dockerfile create mode 100644 email-agent/app/__init__.py create mode 100644 email-agent/app/config.py create mode 100644 email-agent/app/db.py create mode 100644 email-agent/app/imap_sync.py create mode 100644 email-agent/app/main.py create mode 100644 email-agent/requirements.txt create mode 100644 migrations/001_fase1.sql create mode 100644 migrations/002_fase234.sql create mode 100644 migrations/003_word_analytics.sql create mode 100644 migrations/004_emails_calendar_brain.sql create mode 100644 migrations/005_email_accounts.sql create mode 100644 migrations/006_browser_sessions.sql create mode 100644 migrations/007_photo_imports.sql create mode 100644 migrations/008_pgvector_telegram_brain.sql create mode 100644 migrations/009_full_upgrade.sql create mode 100644 migrations/010_retail_crm_intel.sql create mode 100644 migrations/011_retail_360.sql create mode 100644 migrations/012_rss_focus.sql create mode 100644 migrations/013_agent_souls_permissions.sql create mode 100644 migrations/014_market_regulation_avatars.sql create mode 100644 migrations/015_platform_upgrade.sql create mode 100644 migrations/016_platform_ops_packaging.sql create mode 100644 monitoring/prometheus.yml create mode 100644 scripts/research_refresh.py create mode 100644 tools-api/Dockerfile create mode 100644 tools-api/__init__.py create mode 100644 tools-api/app/__init__.py create mode 100644 tools-api/app/brain.py create mode 100644 tools-api/app/briefing.py create mode 100644 tools-api/app/comfyui.py create mode 100644 tools-api/app/config.py create mode 100644 tools-api/app/connectors/__init__.py create mode 100644 tools-api/app/connectors/cbs.py create mode 100644 tools-api/app/connectors/food_trends.py create mode 100644 tools-api/app/connectors/halal_registry.py create mode 100644 tools-api/app/connectors/market_stocks.py create mode 100644 tools-api/app/connectors/pdok.py create mode 100644 tools-api/app/connectors/proxmox.py create mode 100644 tools-api/app/connectors/reclamefolder.py create mode 100644 tools-api/app/connectors/retail_360_routes.py create mode 100644 tools-api/app/connectors/rss_feeds.py create mode 100644 tools-api/app/connectors/trends_feed.py create mode 100644 tools-api/app/db.py create mode 100644 tools-api/app/email_config.py create mode 100644 tools-api/app/logging_middleware.py create mode 100644 tools-api/app/main.py create mode 100644 tools-api/app/middleware.py create mode 100644 tools-api/app/ops_routes.py create mode 100644 tools-api/app/packaging/__init__.py create mode 100644 tools-api/app/packaging/export.py create mode 100644 tools-api/app/packaging/generator.py create mode 100644 tools-api/app/packaging_routes.py create mode 100644 tools-api/app/recommendations.py create mode 100644 tools-api/app/research.py create mode 100644 tools-api/app/retail.html create mode 100644 tools-api/app/retail.py create mode 100644 tools-api/app/retail_360.py create mode 100644 tools-api/app/retail_360_routes.py create mode 100644 tools-api/app/retail_crm.py create mode 100644 tools-api/app/retail_enrichment.py create mode 100644 tools-api/app/retail_opportunities.py create mode 100644 tools-api/app/retail_scrapers.py create mode 100644 tools-api/app/rss_feeds.py create mode 100644 tools-api/app/wholesaler_scrapers.py create mode 100644 tools-api/db.py create mode 100644 tools-api/patch_main.py create mode 100644 tools-api/requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dee0245 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# PostgreSQL (extern op VM106 host) +DB_HOST=10.4.7.18 +DB_USER=aissa +DB_PASSWORD=changeme +DB_NAME=foodlinkk + +# Cockpit +TOOLS_API_URL=http://tools-api:8700 +OLLAMA_URL=http://10.4.7.19:11434 +OLLAMA_MODEL=qwen3:8b + +# SMTP (optioneel — ook via Settings UI) +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM= + +# Proxmox (IT Ops — optioneel) +PROXMOX_HOST=10.4.7.14 +PROXMOX_USER=root@pam +PROXMOX_TOKEN_NAME= +PROXMOX_TOKEN_VALUE= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e364dd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +*.pyc +__pycache__/ +*.pyo +.pytest_cache/ +.mypy_cache/ +.venv/ +venv/ +node_modules/ +*.log +.DS_Store +cockpit/static/uploads/ +*.swp +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..43ec7e7 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Foodlinkk Command Center + +AI-gedreven platform voor retail intelligence, marketing automatisering, agent orchestratie en IT operations. + +## Stack + +| Service | Poort | Beschrijving | +|---------|-------|--------------| +| Cockpit | 8600 | Web UI (FastAPI + Jinja) | +| Tools API | 8700 | Connectors, retail 360, packaging, ops | +| Gitea | 3001 | Git repository | +| Email agent | 8801 | IMAP/SMTP sync | +| Hermes/Ollama | 10.4.7.19 | LLM + vector store | + +## Snel starten + +```bash +cp .env.example .env +# Pas wachtwoorden aan + +docker compose up -d +./deploy-all.sh # vanaf dev machine naar VM106 +``` + +## Documentatie + +- [Architectuur](docs/ARCHITECTURE.md) +- [Deploy](docs/DEPLOY.md) +- [Apps & pagina's](docs/APPS.md) +- [API](docs/API.md) +- [Agents](docs/AGENTS.md) +- [CSS thema](docs/CSS-THEME.md) + +## Belangrijkste routes + +- `/` — Herman dashboard +- `/marketing?tab=reclame` — Reclame folders (alle ketens) +- `/marketing?tab=publish` — Social automatisering +- `/settings?tab=social` — Social API keys +- `/ops` — IT infrastructure topology +- `/packaging` — Verpakkingsontwerp generator +- `/agents?tab=mesh` — Agent netwerk diff --git a/browser-agent/Dockerfile b/browser-agent/Dockerfile new file mode 100644 index 0000000..fa82fff --- /dev/null +++ b/browser-agent/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/playwright/python:v1.49.1-noble + +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr tesseract-ocr-nld \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt && playwright install chromium +COPY app ./app + +ENV PYTHONUNBUFFERED=1 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7790"] diff --git a/browser-agent/app/__init__.py b/browser-agent/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/browser-agent/app/db.py b/browser-agent/app/db.py new file mode 100644 index 0000000..074a3a6 --- /dev/null +++ b/browser-agent/app/db.py @@ -0,0 +1,49 @@ +import os + +import psycopg2 +from psycopg2.extras import RealDictCursor + +_pool = None + + +def init_pool() -> None: + global _pool + if _pool is None: + _pool = { + "host": os.getenv("DB_HOST", "10.4.7.18"), + "user": os.getenv("DB_USER", "aissa"), + "password": os.getenv("DB_PASSWORD", "Foodlinkk#2026"), + "dbname": os.getenv("DB_NAME", "foodlinkk"), + } + + +def _conn(): + init_pool() + return psycopg2.connect(**_pool, cursor_factory=RealDictCursor) + + +def fetch_one(sql: str, params: tuple = ()) -> dict | None: + with _conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, params) + row = cur.fetchone() + conn.commit() + return dict(row) if row else None + + +def fetch_all(sql: str, params: tuple = ()) -> list: + with _conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, params) + rows = cur.fetchall() + conn.commit() + return [dict(r) for r in rows] + + +def execute_returning(sql: str, params: tuple = ()) -> dict: + with _conn() as conn: + with conn.cursor() as cur: + cur.execute(sql, params) + row = cur.fetchone() + conn.commit() + return dict(row) if row else {} diff --git a/browser-agent/app/extract.py b/browser-agent/app/extract.py new file mode 100644 index 0000000..44f5c56 --- /dev/null +++ b/browser-agent/app/extract.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import base64 +import json +import os +import re +from typing import Any + +from playwright.sync_api import sync_playwright + +VNC_CDP_URL = os.getenv("VNC_CDP_URL", "http://10.4.7.18:9222") + +WEIGHT_RE = re.compile( + r"(\d+[.,]?\d*\s*(?:g|gr|gram|kg|ml|cl|l|liter|st|stuks?|x\d+|pack|zak|doos))", + re.I, +) +PRICE_RE = re.compile(r"(€\s?\d+[.,]\d{2}|EUR\s?\d+[.,]\d{2})", re.I) + + +def extract_items_from_text(text: str, source: str = "ocr") -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + seen: set[str] = set() + for line in re.split(r"[\n\r]+", text): + line = re.sub(r"\s+", " ", line).strip() + if len(line) < 8: + continue + weight = WEIGHT_RE.search(line) + price = PRICE_RE.search(line) + if not weight and not price: + continue + key = line[:80].lower() + if key in seen: + continue + seen.add(key) + items.append({ + "raw": line[:300], + "weight": weight.group(1) if weight else None, + "price": price.group(1) if price else None, + "source": source, + }) + return items[:200] + + +def ocr_with_boxes(image_bytes: bytes) -> tuple[str, list[dict[str, Any]]]: + try: + import io + import pytesseract + from PIL import Image + + img = Image.open(io.BytesIO(image_bytes)) + text = pytesseract.image_to_string(img, lang="nld+eng").strip() + data = pytesseract.image_to_data(img, lang="nld+eng", output_type=pytesseract.Output.DICT) + boxes: list[dict[str, Any]] = [] + for i, word in enumerate(data.get("text", [])): + word = (word or "").strip() + conf_raw = data["conf"][i] + conf = int(conf_raw) if str(conf_raw).isdigit() else -1 + if not word or conf < 40: + continue + boxes.append({ + "text": word, + "confidence": conf, + "x": data["left"][i], + "y": data["top"][i], + "w": data["width"][i], + "h": data["height"][i], + }) + return text, boxes[:500] + except Exception as exc: + return f"OCR niet beschikbaar: {exc}", [] + + + + +def vnc_screenshot_sync() -> bytes: + with sync_playwright() as p: + browser = p.chromium.connect_over_cdp(VNC_CDP_URL) + context = browser.contexts[0] if browser.contexts else browser.new_context() + page = context.pages[0] if context.pages else context.new_page() + return page.screenshot(type="jpeg", quality=72, full_page=False) + +def vnc_navigate_sync(url: str, instruction: str | None, wait_seconds: float, helpers: dict) -> dict[str, Any]: + accept_cookies = helpers["accept_cookies"] + run_instructions = helpers["run_instructions"] + log: list[str] = [] + with sync_playwright() as p: + browser = p.chromium.connect_over_cdp(VNC_CDP_URL) + context = browser.contexts[0] if browser.contexts else browser.new_context() + page = context.pages[0] if context.pages else context.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(int(wait_seconds * 1000)) + cr = accept_cookies(page) + if cr: + log.append(cr) + if instruction: + log.extend(run_instructions(page, instruction)) + shot = page.screenshot(type="jpeg", quality=72, full_page=False) + title = page.title() + final_url = page.url + return { + "ok": True, + "url": url, + "final_url": final_url, + "title": title, + "steps": log, + "screenshot_b64": base64.b64encode(shot).decode("ascii"), + } + + +def extract_full_sync( + url: str, + instruction: str | None, + wait_seconds: float, + scroll_pages: int, + site_id: int | None, + helpers: dict, +) -> dict: + from bs4 import BeautifulSoup + + execute_returning = helpers["execute_returning"] + serialize = helpers["serialize"] + accept_cookies = helpers["accept_cookies"] + run_instructions = helpers["run_instructions"] + extract_links = helpers["extract_links"] + + session_row = execute_returning( + "INSERT INTO browser_sessions (url, task, status, site_id) VALUES (%s, %s, 'running', %s) RETURNING id", + (url, instruction or "full-extract", site_id), + ) + session_id = session_row["id"] + all_items: list[dict] = [] + all_ocr: list[str] = [] + steps_log: list[str] = [] + + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context( + viewport={"width": 1440, "height": 900}, + user_agent="Mozilla/5.0 FoodlinkkBot/2.0 FullExtract", + locale="nl-NL", + ) + page = context.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(int(wait_seconds * 1000)) + cr = accept_cookies(page) + if cr: + steps_log.append(cr) + if instruction: + steps_log.extend(run_instructions(page, instruction)) + + page.evaluate("window.scrollTo(0, 0)") + page.wait_for_timeout(400) + for i in range(max(1, min(scroll_pages, 12))): + shot = page.screenshot(type="jpeg", quality=70, full_page=False) + text, _ = ocr_with_boxes(shot) + all_ocr.append(text) + all_items.extend(extract_items_from_text(text, f"ocr-scroll-{i + 1}")) + page.evaluate("window.scrollBy(0, Math.min(window.innerHeight * 0.85, 800))") + page.wait_for_timeout(500) + steps_log.append(f"Scroll+OCR {i + 1}") + + full_shot = page.screenshot(type="jpeg", quality=65, full_page=True) + full_b64 = base64.b64encode(full_shot).decode("ascii") + full_text, full_boxes = ocr_with_boxes(full_shot) + all_items.extend(extract_items_from_text(full_text, "ocr-fullpage")) + + title = page.title() + final_url = page.url + html = page.content() + soup = BeautifulSoup(html, "html.parser") + for tag in soup(["script", "style", "noscript"]): + tag.decompose() + text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True)) + all_items.extend(extract_items_from_text(text, "html-text")) + links = extract_links(html, final_url) + + seen: set[str] = set() + deduped: list[dict] = [] + for it in all_items: + k = (it.get("raw") or "")[:100] + if k in seen: + continue + seen.add(k) + deduped.append(it) + + meta = { + "task": "full-extract", + "steps": steps_log, + "scroll_pages": scroll_pages, + "extracted_items": deduped[:300], + "ocr_combined": "\n\n".join(all_ocr)[:50000], + "detection_boxes": full_boxes[:200], + } + + row = execute_returning( + """ + UPDATE browser_sessions SET + final_url=%s, title=%s, status='completed', + content_text=%s, content_html=%s, screenshot_b64=%s, + links=%s::jsonb, completed_at=NOW(), metadata=%s::jsonb + WHERE id=%s RETURNING * + """, + ( + final_url, + title, + text[:120000], + html[:250000], + full_b64, + json.dumps(links), + json.dumps(meta), + session_id, + ), + ) + browser.close() + + out = serialize(row) + out["extracted_items"] = deduped[:300] + out["detection_boxes"] = meta["detection_boxes"] + out["content_preview"] = text[:2000] + return out + except Exception as exc: + execute_returning( + "UPDATE browser_sessions SET status='failed', error_message=%s, completed_at=NOW() WHERE id=%s RETURNING id", + (str(exc)[:500], session_id), + ) + raise + + +def analyze_photo_sync( + image_b64: str, + source: str, + filename: str | None, + storage_path: str | None, + session_id: int | None, + execute_returning, +) -> dict: + image_bytes = base64.b64decode(image_b64) + text, boxes = ocr_with_boxes(image_bytes) + items = extract_items_from_text(text, "photo-ocr") + row = execute_returning( + """ + INSERT INTO photo_imports (source, filename, storage_path, image_b64, ocr_text, detections, extracted_items, session_id, metadata) + VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s::jsonb) + RETURNING * + """, + ( + source, + filename, + storage_path, + image_b64, + text, + json.dumps(boxes), + json.dumps(items), + session_id, + json.dumps({"box_count": len(boxes), "item_count": len(items)}), + ), + ) + out = dict(row) + for k, v in list(out.items()): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + out.pop("image_b64", None) + return out diff --git a/browser-agent/app/main.py b/browser-agent/app/main.py new file mode 100644 index 0000000..b11cefe --- /dev/null +++ b/browser-agent/app/main.py @@ -0,0 +1,660 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import re +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from typing import Any, Optional + +from bs4 import BeautifulSoup +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import Response +from pydantic import BaseModel, Field +from playwright.sync_api import sync_playwright + +from app.db import execute_returning, fetch_all, fetch_one, init_pool +from app import pa_live + +app = FastAPI(title="Foodlinkk Browser Agent", version="1.0.0") +_executor = ThreadPoolExecutor(max_workers=4) + +_active_session: dict[str, Any] = {"id": None, "status": "idle", "screenshot_b64": None, "url": None} + + +class BrowseRequest(BaseModel): + url: str + task: Optional[str] = Field(default=None, description="Optional task description for logging") + site_id: Optional[int] = None + wait_seconds: float = Field(default=3.0, ge=0, le=30) + instruction: Optional[str] = Field(default=None, description="Klik/scroll instructies in NL") + pa_label: Optional[str] = None + pa_job_id: Optional[str] = None + pa_index: Optional[int] = None + pa_total: Optional[int] = None + pa_query: Optional[str] = None + pa_chat_id: Optional[int] = None + pa_user_name: Optional[str] = None + + +def _serialize(row: dict | None) -> dict | None: + if not row: + return None + out = dict(row) + for k, v in list(out.items()): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + return out + + +def _accept_cookies(page) -> str | None: + for sel in ( + 'button:has-text("Accepteren")', + 'button:has-text("Alles accepteren")', + 'button:has-text("Akkoord")', + 'button:has-text("Accept")', + "#onetrust-accept-btn-handler", + '[data-testid="accept-all"]', + ): + try: + btn = page.locator(sel).first + if btn.is_visible(timeout=1200): + btn.click(timeout=3000) + page.wait_for_timeout(500) + return f"Clicked: {sel}" + except Exception: + pass + return None + + +def _run_instructions(page, instruction: str) -> list[str]: + """Parse Dutch/English instructions into Playwright actions.""" + log: list[str] = [] + if not instruction or not instruction.strip(): + return log + + lower = instruction.lower() + if any(w in lower for w in ("cookie", "accepteren", "privacy", "akkoord", "consent")): + r = _accept_cookies(page) + if r: + log.append(r) + + for m in re.finditer(r"klik(?:ken)?(?:\s+op)?\s+['\"]?([^'\".\n]+)", instruction, re.I): + label = m.group(1).strip() + if len(label) < 2: + continue + try: + page.get_by_role("button", name=re.compile(re.escape(label[:40]), re.I)).first.click(timeout=4000) + log.append(f"Klik: button '{label[:40]}'") + page.wait_for_timeout(800) + continue + except Exception: + pass + try: + page.get_by_text(label[:60], exact=False).first.click(timeout=4000) + log.append(f"Klik: tekst '{label[:40]}'") + page.wait_for_timeout(800) + except Exception as exc: + log.append(f"Mislukt klik '{label[:30]}': {exc}") + + scroll_n = lower.count("scroll") + lower.count("naar beneden") + lower.count("verder") + scroll_n = max(scroll_n, 1 if ("meer" in lower and "product" in lower) else 0) + for i in range(min(scroll_n, 5)): + page.evaluate("window.scrollBy(0, Math.min(window.innerHeight, 700))") + page.wait_for_timeout(400) + log.append(f"Scroll {i + 1}") + + if "filter" in lower or "categorie" in lower or "subcategorie" in lower: + for word in re.findall(r"[a-zA-Z]{4,}", instruction): + if word.lower() in ("filter", "categorie", "subcategorie", "klik", "scroll"): + continue + try: + page.get_by_text(word, exact=False).first.click(timeout=2000) + log.append(f"Filter/klik: {word}") + page.wait_for_timeout(600) + break + except Exception: + pass + + page.wait_for_timeout(1000) + return log + + +def _ocr_bytes(image_bytes: bytes) -> str: + try: + import io + import pytesseract + from PIL import Image + img = Image.open(io.BytesIO(image_bytes)) + return pytesseract.image_to_string(img, lang="nld+eng").strip() + except Exception as exc: + return f"OCR niet beschikbaar: {exc}" + + +def _extract_links(html: str, base_url: str) -> list[dict[str, str]]: + soup = BeautifulSoup(html, "html.parser") + links: list[dict[str, str]] = [] + seen: set[str] = set() + for a in soup.find_all("a", href=True): + href = a["href"].strip() + text = (a.get_text() or "").strip()[:120] + if not href or href.startswith("#") or href in seen: + continue + seen.add(href) + links.append({"href": href, "text": text}) + if len(links) >= 40: + break + return links + + +def _browse_sync(url: str, task: Optional[str], site_id: Optional[int], wait_seconds: float, instruction: Optional[str] = None) -> dict: + global _active_session + session_row = execute_returning( + """ + INSERT INTO browser_sessions (url, task, status, site_id) + VALUES (%s, %s, 'running', %s) + RETURNING id + """, + (url, task or instruction, site_id), + ) + session_id = session_row["id"] + _active_session = {"id": session_id, "status": "running", "screenshot_b64": None, "url": url} + + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context( + viewport={"width": 1440, "height": 900}, + 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", + locale="nl-NL", + ) + page = context.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(int(wait_seconds * 1000)) + try: + page.wait_for_load_state("networkidle", timeout=15000) + except Exception: + pass + + steps_log: list[str] = [] + cr = _accept_cookies(page) + if cr: + steps_log.append(cr) + if instruction: + steps_log.extend(_run_instructions(page, instruction)) + elif task: + steps_log.extend(_run_instructions(page, task)) + + shot = page.screenshot(type="jpeg", quality=72, full_page=False) + b64 = base64.b64encode(shot).decode("ascii") + _active_session["screenshot_b64"] = b64 + + title = page.title() + final_url = page.url + html = page.content() + soup = BeautifulSoup(html, "html.parser") + for tag in soup(["script", "style", "noscript"]): + tag.decompose() + text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))[:80000] + links = _extract_links(html, final_url) + + row = execute_returning( + """ + UPDATE browser_sessions SET + final_url=%s, title=%s, status='completed', + content_text=%s, content_html=%s, screenshot_b64=%s, + links=%s::jsonb, completed_at=NOW(), + metadata=%s::jsonb + WHERE id=%s + RETURNING * + """, + ( + final_url, + title, + text, + html[:200000], + b64, + json.dumps(links), + json.dumps({"task": task or instruction or "browse", "link_count": len(links), "steps": steps_log}), + session_id, + ), + ) + + execute_returning( + """ + INSERT INTO crawled_pages (url, final_url, title, content, content_html, screenshot_b64, links, site_id, metadata, crawled_at) + VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%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, + screenshot_b64=EXCLUDED.screenshot_b64, + links=EXCLUDED.links, + site_id=EXCLUDED.site_id, + metadata=EXCLUDED.metadata, + crawled_at=NOW() + RETURNING id + """, + ( + url, + final_url, + title, + text[:50000], + html[:100000], + b64, + json.dumps(links), + site_id, + json.dumps({"session_id": session_id, "source": "browser-agent"}), + ), + ) + + browser.close() + + _active_session["status"] = "completed" + out = _serialize(row) + out["content_preview"] = text[:2000] + out["screenshot_b64"] = b64 + return out + except Exception as exc: + execute_returning( + """ + UPDATE browser_sessions SET status='failed', error_message=%s, completed_at=NOW() + WHERE id=%s RETURNING id + """, + (str(exc)[:500], session_id), + ) + _active_session["status"] = "failed" + raise + + +@app.on_event("startup") +def startup() -> None: + init_pool() + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok", "service": "browser-agent"} + + +@app.get("/live") +def live_view() -> dict[str, Any]: + return { + "active": _active_session, + "novnc_url": "http://10.4.7.18:6080/vnc.html?autoconnect=true&resize=scale&password=Foodlinkk2026&path=websockify", + "gradio_url": "http://10.4.7.18:7788", + } + + +@app.get("/sessions") +def list_sessions(limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]: + rows = fetch_all( + """ + SELECT id, url, final_url, title, task, status, created_at, completed_at, + LEFT(content_text, 400) AS content_preview, + (screenshot_b64 IS NOT NULL) AS has_screenshot, + site_id + FROM browser_sessions ORDER BY created_at DESC LIMIT %s + """, + (limit,), + ) + return {"sessions": [_serialize(r) for r in rows]} + + +@app.get("/sessions/{session_id}") +def get_session(session_id: int, include_screenshot: bool = Query(default=True)) -> dict[str, Any]: + row = fetch_one("SELECT * FROM browser_sessions WHERE id = %s", (session_id,)) + if not row: + raise HTTPException(status_code=404, detail="Session not found") + out = _serialize(row) + if not include_screenshot: + out.pop("screenshot_b64", None) + out.pop("content_html", None) + else: + out["content_preview"] = (out.get("content_text") or "")[:3000] + return {"session": out} + + +@app.get("/sessions/{session_id}/screenshot.jpg") +def session_screenshot_jpg(session_id: int) -> Response: + row = fetch_one("SELECT screenshot_b64 FROM browser_sessions WHERE id = %s", (session_id,)) + if not row or not row.get("screenshot_b64"): + raise HTTPException(status_code=404, detail="No screenshot") + data = base64.b64decode(row["screenshot_b64"]) + return Response(content=data, media_type="image/jpeg") + + +@app.get("/live/screenshot.jpg") +def live_screenshot_jpg() -> Response: + b64 = _active_session.get("screenshot_b64") + if not b64: + row = fetch_one( + "SELECT screenshot_b64 FROM browser_sessions WHERE screenshot_b64 IS NOT NULL ORDER BY id DESC LIMIT 1" + ) + b64 = row.get("screenshot_b64") if row else None + if not b64: + raise HTTPException(status_code=404, detail="No active screenshot") + return Response(content=base64.b64decode(b64), media_type="image/jpeg") + + +@app.post("/browse") +async def browse(req: BrowseRequest) -> dict[str, Any]: + url = req.url.strip() + if not url.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="URL must start with http:// or https://") + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + _executor, + _browse_sync, + url, + req.task, + req.site_id, + req.wait_seconds, + req.instruction, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return {"ok": True, "session": result} + + +class TaskRequest(BaseModel): + url: str + task: str = "open and summarize visible content" + metadata: dict[str, Any] = Field(default_factory=dict) + + +@app.post("/task") +async def browser_task(req: TaskRequest) -> dict[str, Any]: + """Compatibility endpoint for tools-api.""" + browse_req = BrowseRequest(url=req.url, task=req.task, wait_seconds=4.0) + return await browse(browse_req) + +class InstructRequest(BaseModel): + url: str + instruction: str + wait_seconds: float = 5.0 + + +@app.post("/browse/instruct") +async def browse_instruct(req: InstructRequest) -> dict[str, Any]: + browse_req = BrowseRequest(url=req.url, task=req.instruction, instruction=req.instruction, wait_seconds=req.wait_seconds) + return await browse(browse_req) + + +@app.get("/sessions/{session_id}/ocr") +def session_ocr(session_id: int) -> dict[str, Any]: + row = fetch_one("SELECT screenshot_b64, title, url FROM browser_sessions WHERE id = %s", (session_id,)) + if not row or not row.get("screenshot_b64"): + raise HTTPException(status_code=404, detail="Geen screenshot voor OCR") + text = _ocr_bytes(base64.b64decode(row["screenshot_b64"])) + return {"ok": True, "session_id": session_id, "ocr_text": text, "title": row.get("title"), "url": row.get("url")} + + +@app.post("/ocr") +def ocr_upload(body: dict[str, Any]) -> dict[str, Any]: + b64 = body.get("image_b64") or "" + if not b64: + raise HTTPException(status_code=400, detail="image_b64 required") + text = _ocr_bytes(base64.b64decode(b64)) + return {"ok": True, "ocr_text": text} + +# --- VNC + full extract + photo analysis (added by patch) --- +from app import extract as extract_mod + +def _browser_helpers() -> dict: + return { + "execute_returning": execute_returning, + "serialize": _serialize, + "accept_cookies": _accept_cookies, + "run_instructions": _run_instructions, + "extract_links": _extract_links, + } + + +class VncNavigateBody(BaseModel): + url: str + instruction: Optional[str] = None + wait_seconds: float = 4.0 + + +class ExtractFullBody(BaseModel): + url: str + instruction: Optional[str] = None + wait_seconds: float = 4.0 + scroll_pages: int = Field(default=6, ge=1, le=12) + site_id: Optional[int] = None + also_vnc: bool = False + pa_label: Optional[str] = None + pa_job_id: Optional[str] = None + pa_index: Optional[int] = None + pa_total: Optional[int] = None + pa_query: Optional[str] = None + pa_chat_id: Optional[int] = None + pa_user_name: Optional[str] = None + + +class PhotoAnalyzeBody(BaseModel): + image_b64: str + source: str = "upload" + filename: Optional[str] = None + storage_path: Optional[str] = None + session_id: Optional[int] = None + + +@app.post("/vnc/navigate") +async def vnc_navigate(req: VncNavigateBody) -> dict[str, Any]: + url = req.url.strip() + if not url.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="URL must start with http:// or https://") + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + _executor, + extract_mod.vnc_navigate_sync, + url, + req.instruction, + req.wait_seconds, + _browser_helpers(), + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"VNC navigate failed: {exc}") from exc + return result + + +@app.post("/browse/extract-full") +async def browse_extract_full(req: ExtractFullBody) -> dict[str, Any]: + url = req.url.strip() + if not url.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="URL must start with http:// or https://") + if req.pa_label and req.pa_job_id: + if req.pa_index == 1 or not pa_live.get_live().get("job_id"): + pa_live.start_job( + req.pa_job_id, + req.pa_query or "", + chat_id=req.pa_chat_id, + user_name=req.pa_user_name or "", + ) + pa_live.update_slot( + req.pa_label, + status="loading", + url=url, + index=req.pa_index, + total=req.pa_total, + ) + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor( + _executor, + extract_mod.extract_full_sync, + url, + req.instruction, + req.wait_seconds, + req.scroll_pages, + req.site_id, + _browser_helpers(), + ) + if req.pa_label: + pa_live.update_slot( + req.pa_label, + status="completed", + url=result.get("final_url") or url, + title=result.get("title"), + screenshot_b64=result.get("screenshot_b64"), + index=req.pa_index, + total=req.pa_total, + ) + if req.also_vnc: + try: + await loop.run_in_executor( + _executor, + extract_mod.vnc_navigate_sync, + url, + req.instruction, + req.wait_seconds, + _browser_helpers(), + ) + except Exception: + pass + except Exception as exc: + if req.pa_label: + pa_live.update_slot(req.pa_label, status="failed", url=url, error=str(exc)[:300]) + raise HTTPException(status_code=502, detail=str(exc)) from exc + global _active_session + _active_session = { + "id": result.get("id"), + "status": "completed", + "screenshot_b64": result.get("screenshot_b64"), + "url": url, + } + return {"ok": True, "session": result} + + +@app.post("/photos/analyze") +async def photos_analyze(req: PhotoAnalyzeBody) -> dict[str, Any]: + if not req.image_b64.strip(): + raise HTTPException(status_code=400, detail="image_b64 required") + loop = asyncio.get_event_loop() + try: + row = await loop.run_in_executor( + _executor, + extract_mod.analyze_photo_sync, + req.image_b64, + req.source, + req.filename, + req.storage_path, + req.session_id, + execute_returning, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return {"ok": True, "photo": row} + + +@app.get("/photos") +def photos_list(limit: int = Query(default=30, ge=1, le=100)) -> dict[str, Any]: + rows = fetch_all( + """ + SELECT id, source, filename, storage_path, LEFT(ocr_text, 400) AS ocr_preview, + jsonb_array_length(COALESCE(detections, '[]'::jsonb)) AS box_count, + jsonb_array_length(COALESCE(extracted_items, '[]'::jsonb)) AS item_count, + session_id, created_at + FROM photo_imports ORDER BY created_at DESC LIMIT %s + """, + (limit,), + ) + return {"photos": [_serialize(r) for r in rows]} + + +@app.get("/photos/{photo_id}") +def photos_get(photo_id: int) -> dict[str, Any]: + row = fetch_one("SELECT * FROM photo_imports WHERE id = %s", (photo_id,)) + if not row: + raise HTTPException(status_code=404, detail="Photo not found") + out = _serialize(row) + out.pop("image_b64", None) + return {"photo": out} + + +@app.get("/photos/{photo_id}/image.jpg") +def photos_image(photo_id: int) -> Response: + row = fetch_one("SELECT image_b64 FROM photo_imports WHERE id = %s", (photo_id,)) + if not row or not row.get("image_b64"): + raise HTTPException(status_code=404, detail="No image") + return Response(content=base64.b64decode(row["image_b64"]), media_type="image/jpeg") + + +@app.get("/photos/{photo_id}/detections") +def photos_detections(photo_id: int) -> dict[str, Any]: + row = fetch_one( + "SELECT id, detections, extracted_items, ocr_text FROM photo_imports WHERE id = %s", + (photo_id,), + ) + if not row: + raise HTTPException(status_code=404, detail="Photo not found") + return { + "ok": True, + "photo_id": photo_id, + "detections": row.get("detections") or [], + "extracted_items": row.get("extracted_items") or [], + "ocr_text": row.get("ocr_text") or "", + } + + + + +class PaJobStartBody(BaseModel): + job_id: str + query: str + chat_id: Optional[int] = None + user_name: Optional[str] = None + sites: Optional[list[str]] = None + + +@app.post("/pa/job/start") +def pa_job_start(body: PaJobStartBody) -> dict[str, Any]: + pa_live.start_job( + body.job_id, + body.query, + chat_id=body.chat_id, + user_name=body.user_name or "", + sites=body.sites, + ) + return {"ok": True, "live": pa_live.get_live()} + + +@app.post("/pa/job/comparing") +def pa_job_comparing() -> dict[str, Any]: + pa_live.set_comparing() + return {"ok": True, "live": pa_live.get_live()} + + +@app.post("/pa/job/done") +def pa_job_done() -> dict[str, Any]: + pa_live.finish_job() + return {"ok": True, "live": pa_live.get_live()} + + +@app.get("/pa/live") +def pa_live_view() -> dict[str, Any]: + return pa_live.get_live() + + +@app.get("/pa/live/{label}/screenshot.jpg") +def pa_slot_screenshot(label: str) -> Response: + data = pa_live.get_slot_screenshot(label) + if not data: + raise HTTPException(status_code=404, detail="No screenshot for slot") + return Response(content=data, media_type="image/jpeg") + + +@app.get("/vnc/screenshot.jpg") +async def vnc_screenshot_jpg() -> Response: + loop = asyncio.get_event_loop() + try: + data = await loop.run_in_executor(_executor, extract_mod.vnc_screenshot_sync) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return Response(content=data, media_type="image/jpeg") diff --git a/browser-agent/app/pa_live.py b/browser-agent/app/pa_live.py new file mode 100644 index 0000000..7498b5c --- /dev/null +++ b/browser-agent/app/pa_live.py @@ -0,0 +1,157 @@ +"""PA multi-site live status — 4 browser slots for Cockpit.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +PA_SITES = ("Airbnb", "Booking.com", "DuckDuckGo", "HolidayCheck") + +_state: dict[str, Any] = { + "job_id": None, + "query": "", + "chat_id": None, + "user_name": "", + "status": "idle", + "updated_at": None, + "slots": { + site: { + "label": site, + "status": "idle", + "url": None, + "title": None, + "error": None, + "index": i + 1, + "total": len(PA_SITES), + "screenshot_b64": None, + "updated_at": None, + } + for i, site in enumerate(PA_SITES) + }, +} + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _slot(label: str) -> dict[str, Any]: + slots = _state["slots"] + if label not in slots: + slots[label] = { + "label": label, + "status": "idle", + "url": None, + "title": None, + "error": None, + "index": 0, + "total": len(PA_SITES), + "screenshot_b64": None, + "updated_at": _now(), + } + return slots[label] + + +def start_job( + job_id: str, + query: str, + *, + chat_id: int | None = None, + user_name: str = "", + sites: list[str] | None = None, +) -> None: + site_list = sites or list(PA_SITES) + _state["job_id"] = job_id + _state["query"] = query[:200] + _state["chat_id"] = chat_id + _state["user_name"] = user_name + _state["status"] = "running" + _state["updated_at"] = _now() + _state["slots"] = { + site: { + "label": site, + "status": "waiting", + "url": None, + "title": None, + "error": None, + "index": i + 1, + "total": len(site_list), + "screenshot_b64": None, + "updated_at": _now(), + } + for i, site in enumerate(site_list) + } + + +def set_comparing() -> None: + _state["status"] = "comparing" + _state["updated_at"] = _now() + + +def finish_job() -> None: + _state["status"] = "done" + _state["updated_at"] = _now() + + +def fail_job(error: str) -> None: + _state["status"] = "failed" + _state["updated_at"] = _now() + _state["error"] = error[:200] + + +def update_slot( + label: str, + *, + status: str, + url: str | None = None, + title: str | None = None, + error: str | None = None, + index: int | None = None, + total: int | None = None, + screenshot_b64: str | None = None, +) -> None: + slot = _slot(label) + slot["status"] = status + slot["updated_at"] = _now() + if url is not None: + slot["url"] = url + if title is not None: + slot["title"] = title + if error is not None: + slot["error"] = error[:300] + if index is not None: + slot["index"] = index + if total is not None: + slot["total"] = total + if screenshot_b64 is not None: + slot["screenshot_b64"] = screenshot_b64 + _state["updated_at"] = _now() + + +def get_live() -> dict[str, Any]: + out = { + "job_id": _state.get("job_id"), + "query": _state.get("query"), + "chat_id": _state.get("chat_id"), + "user_name": _state.get("user_name"), + "status": _state.get("status", "idle"), + "updated_at": _state.get("updated_at"), + "sites": list(PA_SITES), + "slots": [], + } + for site in PA_SITES: + slot = dict(_state["slots"].get(site) or _slot(site)) + slot.pop("screenshot_b64", None) + slot["has_screenshot"] = bool( + (_state["slots"].get(site) or {}).get("screenshot_b64") + ) + out["slots"].append(slot) + return out + + +def get_slot_screenshot(label: str) -> bytes | None: + slot = _state["slots"].get(label) + if not slot or not slot.get("screenshot_b64"): + return None + import base64 + + return base64.b64decode(slot["screenshot_b64"]) diff --git a/browser-agent/requirements.txt b/browser-agent/requirements.txt new file mode 100644 index 0000000..69c9d79 --- /dev/null +++ b/browser-agent/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +psycopg2-binary==2.9.10 +httpx==0.28.1 +beautifulsoup4==4.12.3 +playwright==1.49.1 +pytesseract==0.3.13 +Pillow==11.0.0 diff --git a/cockpit/Dockerfile b/cockpit/Dockerfile new file mode 100644 index 0000000..2eb0b04 --- /dev/null +++ b/cockpit/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +COPY static ./static +COPY templates ./templates +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8600"] diff --git a/cockpit/agents.html b/cockpit/agents.html new file mode 100644 index 0000000..10a0f69 --- /dev/null +++ b/cockpit/agents.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block content %} +
+ +

Delegate to Herman

+
+ + + +
+
+

Agent rules

+ +
+

Event log

+ +
    {% for ev in events %} +
  • + {{ ev.agent_name }} {{ ev.title or ev.event_type }} {{ ev.created_at }} +
  • {% endfor %}
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/agents.py b/cockpit/agents.py new file mode 100644 index 0000000..6b74e16 --- /dev/null +++ b/cockpit/agents.py @@ -0,0 +1,80 @@ +from pathlib import Path + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.db import execute, fetch_all + +router = APIRouter(prefix="/agents", tags=["agents"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +AGENT_STATUSES = [ + {"name": "Herman", "role": "CEO Briefing", "color": "cyan"}, + {"name": "Sales", "role": "Pipeline", "color": "purple"}, + {"name": "Marketing", "role": "Social & Content", "color": "amber"}, + {"name": "Ops", "role": "Operations", "color": "green"}, +] + + +@router.get("") +async def agents_page(request: Request): + events: list = [] + try: + events = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events + ORDER BY created_at DESC + LIMIT 100 + """ + ) + for ev in events: + if ev.get("created_at"): + ev["created_at"] = ev["created_at"].isoformat() + except Exception: + events = [] + + return templates.TemplateResponse( + "agents.html", + { + "request": request, + "page_title": "Agents", + "agents": AGENT_STATUSES, + "events": events, + }, + ) + + +@router.post("/approve/{event_id}") +async def approve_event(event_id: int, next_url: str = Form("/")): + try: + execute( + """ + UPDATE agent_events + SET status = 'approved' + WHERE id = %s + """, + (event_id,), + ) + except Exception: + pass + return RedirectResponse(url=next_url, status_code=303) + + +@router.post("/reject/{event_id}") +async def reject_event(event_id: int, next_url: str = Form("/")): + try: + execute( + """ + UPDATE agent_events + SET status = 'rejected' + WHERE id = %s + """, + (event_id,), + ) + except Exception: + pass + return RedirectResponse(url=next_url, status_code=303) diff --git a/cockpit/app/__init__.py b/cockpit/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cockpit/app/config.py b/cockpit/app/config.py new file mode 100644 index 0000000..4642a43 --- /dev/null +++ b/cockpit/app/config.py @@ -0,0 +1,24 @@ +import os + +class Settings: + DB_HOST: str = os.getenv("DB_HOST", "postgres") + DB_PORT: int = int(os.getenv("DB_PORT", "5432")) + DB_USER: str = os.getenv("DB_USER", "aissa") + DB_PASSWORD: str = os.getenv("DB_PASSWORD", "Foodlinkk#2026") + DB_NAME: str = os.getenv("DB_NAME", "foodlinkk") + OLLAMA_URL: str = os.getenv("OLLAMA_URL", "http://10.4.7.19:11434") + OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b") + TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700") + HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090") + CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma") + CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000")) + MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000") + + @property + def database_dsn(self) -> str: + return ( + f"host={self.DB_HOST} port={self.DB_PORT} dbname={self.DB_NAME} " + f"user={self.DB_USER} password={self.DB_PASSWORD}" + ) + +settings = Settings() diff --git a/cockpit/app/db.py b/cockpit/app/db.py new file mode 100644 index 0000000..15c08ce --- /dev/null +++ b/cockpit/app/db.py @@ -0,0 +1,65 @@ +from contextlib import contextmanager +from typing import Any, Optional + +import psycopg2 +from psycopg2 import pool +from psycopg2.extras import RealDictCursor + +from app.config import settings + +_connection_pool: Optional[pool.SimpleConnectionPool] = None + + +def init_pool(minconn: int = 1, maxconn: int = 10) -> None: + global _connection_pool + if _connection_pool is None: + _connection_pool = pool.SimpleConnectionPool( + minconn, + maxconn, + dsn=settings.database_dsn, + ) + + +def close_pool() -> None: + global _connection_pool + if _connection_pool is not None: + _connection_pool.closeall() + _connection_pool = None + + +@contextmanager +def get_connection(): + if _connection_pool is None: + init_pool() + conn = _connection_pool.getconn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + _connection_pool.putconn(conn) + + +def fetch_all(query: str, params: Optional[tuple] = None) -> list[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + rows = cur.fetchall() + return [dict(row) for row in rows] + + +def fetch_one(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + row = cur.fetchone() + return dict(row) if row else None + + +def execute(query: str, params: Optional[tuple] = None) -> int: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute(query, params) + return cur.rowcount diff --git a/cockpit/app/main.py b/cockpit/app/main.py new file mode 100644 index 0000000..691ee93 --- /dev/null +++ b/cockpit/app/main.py @@ -0,0 +1,134 @@ +import asyncio +import json +from pathlib import Path + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from app.db import close_pool, fetch_all, init_pool +from app.routes import ( + retail, + agents, + analytics, + documents, + api, + clients, + dashboard, + deals, + herman, + studio, + marketing, + monitor, + products, + reports, + suppliers, + voice, + settings, + browser, + hermes, + beurs, + packaging, + ops, + ops_api, +) +from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api +from app.routes.settings_api import settings_router +from app.routes.agents_api import router as agents_api_router +from app.routes.marketing_api import router as marketing_api_router + +BASE_DIR = Path(__file__).resolve().parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +app = FastAPI(title="Foodlinkk Command Center", version="2.5.0") +app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") + +for r in ( + dashboard.router, + beurs.router, + agents.router, + marketing.router, + retail.router, + clients.router, + deals.router, + products.router, + suppliers.router, + monitor.router, + analytics.router, + documents.router, + reports.router, + voice.router, + settings.router, + browser.router, + herman.router, + studio.router, + hermes.router, + packaging.router, + ops.router, + api.router, + ops_api.router, + admin_router, + ai_router, + herman_api, + voice_api, + settings_router, + agents_api_router, + marketing_api_router, +): + app.include_router(r) + + +@app.on_event("startup") +def on_startup() -> None: + init_pool() + + +@app.on_event("shutdown") +def on_shutdown() -> None: + close_pool() + + +@app.websocket("/ws/agents") +async def ws_agents(websocket: WebSocket) -> None: + await websocket.accept() + last_payload: str | None = None + try: + while True: + try: + rows = fetch_all( + """SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events ORDER BY created_at DESC LIMIT 50""" + ) + for row in rows: + if row.get("created_at") is not None: + row["created_at"] = row["created_at"].isoformat() + payload = json.dumps({"events": rows}) + except Exception as exc: + payload = json.dumps({"error": str(exc), "events": []}) + if payload != last_payload: + await websocket.send_text(payload) + last_payload = payload + await asyncio.sleep(3) + except WebSocketDisconnect: + return + + +@app.websocket("/ws/feed") +async def ws_feed(websocket: WebSocket) -> None: + await websocket.accept() + try: + while True: + snapshot = {"type": "heartbeat", "events": []} + try: + snapshot["events"] = fetch_all( + "SELECT agent_name, event_type, title, status, created_at FROM agent_events ORDER BY created_at DESC LIMIT 15" + ) + for row in snapshot["events"]: + if row.get("created_at"): + row["created_at"] = row["created_at"].isoformat() + except Exception as exc: + snapshot["error"] = str(exc) + await websocket.send_text(json.dumps(snapshot)) + await asyncio.sleep(5) + except WebSocketDisconnect: + return diff --git a/cockpit/app/routes/__init__.py b/cockpit/app/routes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cockpit/app/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/cockpit/app/routes/admin_api.py b/cockpit/app/routes/admin_api.py new file mode 100644 index 0000000..59bc07e --- /dev/null +++ b/cockpit/app/routes/admin_api.py @@ -0,0 +1,1251 @@ +from __future__ import annotations + +from app.routes.reco_proxy import register_recommendation_routes + +import json +import os +from datetime import datetime +from typing import Any, Optional + +import httpx +from fastapi import APIRouter, File, HTTPException, UploadFile +from pydantic import BaseModel, Field + +from app.config import settings +from app.db import execute, fetch_all, fetch_one +from app.services import herman as herman_service +from app.services.marketing import evaluate_agent_rules, sentiment_score +from app.services.monitor import add_site, remove_site, trigger_crawl +from app.services import ollama + +admin_router = APIRouter(prefix="/api/admin", tags=["admin-api"]) +register_recommendation_routes(admin_router) +ai_router = APIRouter(prefix="/api/ai", tags=["ai"]) +herman_api = APIRouter(prefix="/api/herman", tags=["herman-api"]) +voice_api = APIRouter(prefix="/api/voice", tags=["voice-api"]) + + +def _serialize(row: dict | None) -> dict | None: + if not row: + return None + out = dict(row) + for k, v in list(out.items()): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + elif isinstance(v, (datetime,)): + out[k] = v.isoformat() + return out + + +def _serialize_rows(rows: list) -> list: + return [_serialize(r) for r in rows] + + +# --- Pydantic models --- + +class ClientBody(BaseModel): + name: str + contact: Optional[str] = None + email: Optional[str] = None + stage: str = "intake" + sector: Optional[str] = None + mrr_estimate: Optional[float] = None + notes: Optional[str] = None + + +class DealBody(BaseModel): + client_id: Optional[int] = None + title: str + value: float = 0 + stage: str = "lead" + agent_owner: str = "herman" + next_action: Optional[str] = None + deadline: Optional[str] = None + + +class ProductBody(BaseModel): + client_id: Optional[int] = None + name: str + status: str = "concept" + margin_pct: Optional[float] = None + moq: Optional[int] = None + shelf_target: Optional[str] = None + launch_date: Optional[str] = None + + +class SupplierBody(BaseModel): + name: str + country: Optional[str] = None + category: Optional[str] = None + moq: Optional[int] = None + lead_time_days: Optional[int] = None + rating: Optional[float] = None + contact: Optional[str] = None + + +class MentionBody(BaseModel): + platform: str + text: str + + +class AccountBody(BaseModel): + platform: str + username: str + is_active: bool = True + + +class ScheduledPostBody(BaseModel): + account_id: int + content: str + scheduled_time: str + status: str = "pending" + + +class PostStatusBody(BaseModel): + status: str + + +class AgentRuleBody(BaseModel): + name: str + condition_type: str + threshold: float + action: str = "notify" + is_active: bool = True + + +class SiteBody(BaseModel): + url: str + name: str + + +class MarginBody(BaseModel): + cost: float + sell: float + + +class AIGenerateBody(BaseModel): + platform: str = "Instagram" + topic: str + tone: str = "vriendelijk en professioneel" + + +class AIPostDraftBody(BaseModel): + account_id: Optional[int] = None + platform: str = "Instagram" + topic: str + tone: str = "vriendelijk en professioneel" + + +class HermanChatBody(BaseModel): + message: str + + +class ImageGenerateBody(BaseModel): + prompt: str = Field(..., min_length=3, max_length=2000) + width: int = Field(default=1024, ge=256, le=1024) + height: int = Field(default=1024, ge=256, le=1024) + steps: int = Field(default=28, ge=5, le=40) + quality: str = Field(default="hd") + + +class ImageStartBody(BaseModel): + prompt: str = Field(..., min_length=3, max_length=2000) + negative_prompt: str = Field(default="blurry, low quality, watermark, text, ugly, deformed", max_length=2000) + quality: str = Field(default="hd") + + +class CrawlBody(BaseModel): + site_id: Optional[int] = None + + +class DelegateBody(BaseModel): + agent: str + task: str + + +# --- Clients CRUD --- + +@admin_router.get("/clients") +def list_clients(): + return {"items": _serialize_rows(fetch_all( + "SELECT * FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 500" + ))} + + +@admin_router.post("/clients") +def create_client(body: ClientBody): + row = fetch_one( + """INSERT INTO clients (name, contact, email, stage, sector, mrr_estimate, notes, updated_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,NOW()) RETURNING id""", + (body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes), + ) + cid = row["id"] + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (cid,)))} + + +@admin_router.put("/clients/{client_id}") +def update_client(client_id: int, body: ClientBody): + execute( + """UPDATE clients SET name=%s, contact=%s, email=%s, stage=%s, sector=%s, + mrr_estimate=%s, notes=%s, updated_at=NOW() WHERE id=%s""", + (body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes, client_id), + ) + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (client_id,)))} + + +@admin_router.delete("/clients/{client_id}") +def delete_client(client_id: int): + execute("DELETE FROM clients WHERE id=%s", (client_id,)) + return {"ok": True} + + +# --- Deals CRUD --- + +@admin_router.get("/deals") +def list_deals(): + return {"items": _serialize_rows(fetch_all( + """SELECT d.*, c.name AS client_name FROM deals d + LEFT JOIN clients c ON c.id = d.client_id + ORDER BY d.updated_at DESC NULLS LAST LIMIT 500""" + ))} + + +@admin_router.post("/deals") +def create_deal(body: DealBody): + row = fetch_one( + """INSERT INTO deals (client_id, title, value, stage, agent_owner, next_action, deadline, updated_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,NOW()) RETURNING id""", + (body.client_id, body.title, body.value, body.stage, body.agent_owner, body.next_action, body.deadline or None), + ) + did = row["id"] + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM deals WHERE id=%s", (did,)))} + + +@admin_router.put("/deals/{deal_id}") +def update_deal(deal_id: int, body: DealBody): + execute( + """UPDATE deals SET client_id=%s, title=%s, value=%s, stage=%s, agent_owner=%s, + next_action=%s, deadline=%s, updated_at=NOW() WHERE id=%s""", + (body.client_id, body.title, body.value, body.stage, body.agent_owner, body.next_action, body.deadline or None, deal_id), + ) + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM deals WHERE id=%s", (deal_id,)))} + + +@admin_router.delete("/deals/{deal_id}") +def delete_deal(deal_id: int): + execute("DELETE FROM deals WHERE id=%s", (deal_id,)) + return {"ok": True} + + +# --- Products CRUD --- + +@admin_router.get("/products") +def list_products(): + return {"items": _serialize_rows(fetch_all( + """SELECT p.*, c.name AS client_name FROM products p + LEFT JOIN clients c ON c.id = p.client_id ORDER BY p.created_at DESC LIMIT 500""" + ))} + + +@admin_router.post("/products") +def create_product(body: ProductBody): + row = fetch_one( + """INSERT INTO products (client_id, name, status, margin_pct, moq, shelf_target, launch_date) + VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""", + (body.client_id, body.name, body.status, body.margin_pct, body.moq, body.shelf_target, body.launch_date or None), + ) + pid = row["id"] + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM products WHERE id=%s", (pid,)))} + + +@admin_router.put("/products/{product_id}") +def update_product(product_id: int, body: ProductBody): + execute( + """UPDATE products SET client_id=%s, name=%s, status=%s, margin_pct=%s, moq=%s, + shelf_target=%s, launch_date=%s WHERE id=%s""", + (body.client_id, body.name, body.status, body.margin_pct, body.moq, body.shelf_target, body.launch_date or None, product_id), + ) + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM products WHERE id=%s", (product_id,)))} + + +@admin_router.delete("/products/{product_id}") +def delete_product(product_id: int): + execute("DELETE FROM products WHERE id=%s", (product_id,)) + return {"ok": True} + + +@admin_router.post("/products/margin-calc") +def margin_calc(body: MarginBody): + if body.sell <= 0: + raise HTTPException(400, "sell must be > 0") + margin = ((body.sell - body.cost) / body.sell) * 100 + return {"cost": body.cost, "sell": body.sell, "margin_pct": round(margin, 2), "profit": round(body.sell - body.cost, 2)} + + +# --- Suppliers CRUD --- + +@admin_router.get("/suppliers") +def list_suppliers(): + return {"items": _serialize_rows(fetch_all("SELECT * FROM suppliers ORDER BY name LIMIT 500"))} + + +@admin_router.post("/suppliers") +def create_supplier(body: SupplierBody): + row = fetch_one( + """INSERT INTO suppliers (name, country, category, moq, lead_time_days, rating, contact) + VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""", + (body.name, body.country, body.category, body.moq, body.lead_time_days, body.rating, body.contact), + ) + sid = row["id"] + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM suppliers WHERE id=%s", (sid,)))} + + +@admin_router.put("/suppliers/{supplier_id}") +def update_supplier(supplier_id: int, body: SupplierBody): + execute( + """UPDATE suppliers SET name=%s, country=%s, category=%s, moq=%s, + lead_time_days=%s, rating=%s, contact=%s WHERE id=%s""", + (body.name, body.country, body.category, body.moq, body.lead_time_days, body.rating, body.contact, supplier_id), + ) + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM suppliers WHERE id=%s", (supplier_id,)))} + + +@admin_router.delete("/suppliers/{supplier_id}") +def delete_supplier(supplier_id: int): + execute("DELETE FROM suppliers WHERE id=%s", (supplier_id,)) + return {"ok": True} + + +# --- Marketing --- + +@admin_router.get("/mentions") +def list_mentions(platform: str = "all"): + if platform == "all": + rows = fetch_all("SELECT * FROM social_mentions ORDER BY created_at DESC LIMIT 200") + else: + rows = fetch_all( + "SELECT * FROM social_mentions WHERE platform=%s ORDER BY created_at DESC LIMIT 200", + (platform,), + ) + evaluate_agent_rules() + return {"items": _serialize_rows(rows)} + + +@admin_router.post("/mentions") +def create_mention(body: MentionBody): + text = body.text.strip() + if not text: + raise HTTPException(400, "text required") + score = sentiment_score(text) + row = fetch_one( + "INSERT INTO social_mentions (platform, text, sentiment_score) VALUES (%s,%s,%s) RETURNING id", + (body.platform, text, score), + ) + evaluate_agent_rules(row["id"]) + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM social_mentions WHERE id=%s", (row["id"],)))} + + +@admin_router.delete("/mentions/{mention_id}") +def delete_mention(mention_id: int): + execute("DELETE FROM social_mentions WHERE id=%s", (mention_id,)) + return {"ok": True} + + +@admin_router.get("/accounts") +def list_accounts(): + return {"items": _serialize_rows(fetch_all("SELECT * FROM social_accounts ORDER BY platform, username"))} + + +@admin_router.post("/accounts") +def create_account(body: AccountBody): + row = fetch_one( + "INSERT INTO social_accounts (platform, username, is_active) VALUES (%s,%s,%s) RETURNING id", + (body.platform, body.username.strip(), body.is_active), + ) + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM social_accounts WHERE id=%s", (row["id"],)))} + + +@admin_router.delete("/accounts/{account_id}") +def delete_account(account_id: int): + execute("DELETE FROM social_accounts WHERE id=%s", (account_id,)) + return {"ok": True} + + +@admin_router.get("/scheduled-posts") +def list_scheduled_posts(): + rows = fetch_all( + """SELECT sp.*, sa.platform, sa.username FROM scheduled_posts sp + JOIN social_accounts sa ON sp.account_id = sa.id + ORDER BY sp.scheduled_time ASC""" + ) + return {"items": _serialize_rows(rows)} + + +@admin_router.post("/scheduled-posts") +def create_scheduled_post(body: ScheduledPostBody): + row = fetch_one( + "INSERT INTO scheduled_posts (account_id, content, scheduled_time, status) VALUES (%s,%s,%s,%s) RETURNING id", + (body.account_id, body.content.strip(), body.scheduled_time, body.status), + ) + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM scheduled_posts WHERE id=%s", (row["id"],)))} + + +@admin_router.patch("/scheduled-posts/{post_id}/status") +def update_scheduled_status(post_id: int, body: PostStatusBody): + allowed = {"pending", "approved", "posted", "rejected", "published", "cancelled"} + if body.status not in allowed: + raise HTTPException(400, "invalid status") + if body.status in ("posted", "published"): + execute( + "UPDATE scheduled_posts SET status=%s, posted_at=NOW() WHERE id=%s", + (body.status, post_id), + ) + else: + execute("UPDATE scheduled_posts SET status=%s WHERE id=%s", (body.status, post_id)) + return {"ok": True} + + +@admin_router.put("/scheduled-posts/{post_id}") +def update_scheduled_post(post_id: int, body: ScheduledPostBody): + execute( + "UPDATE scheduled_posts SET account_id=%s, content=%s, scheduled_time=%s, status=%s WHERE id=%s", + (body.account_id, body.content, body.scheduled_time, body.status, post_id), + ) + return {"ok": True} + + +@admin_router.delete("/scheduled-posts/{post_id}") +def delete_scheduled_post(post_id: int): + execute("DELETE FROM scheduled_posts WHERE id=%s", (post_id,)) + return {"ok": True} + + +@admin_router.get("/agent-rules") +def list_agent_rules(): + evaluate_agent_rules() + return {"items": _serialize_rows(fetch_all("SELECT * FROM agent_rules ORDER BY id"))} + + +@admin_router.post("/agent-rules") +def create_agent_rule(body: AgentRuleBody): + row = fetch_one( + """INSERT INTO agent_rules (name, condition_type, threshold, action, is_active) + VALUES (%s,%s,%s,%s,%s) RETURNING id""", + (body.name, body.condition_type, body.threshold, body.action, body.is_active), + ) + return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM agent_rules WHERE id=%s", (row["id"],)))} + + +@admin_router.patch("/agent-rules/{rule_id}/toggle") +def toggle_agent_rule(rule_id: int): + execute("UPDATE agent_rules SET is_active = NOT is_active WHERE id=%s", (rule_id,)) + return {"ok": True} + + +@admin_router.delete("/agent-rules/{rule_id}") +def delete_agent_rule(rule_id: int): + execute("DELETE FROM agent_rules WHERE id=%s", (rule_id,)) + return {"ok": True} + + +@admin_router.get("/agent-logs") +def list_agent_logs(limit: int = 100): + limit = max(1, min(limit, 500)) + rows = fetch_all( + """SELECT al.*, ar.name AS rule_name FROM agent_logs al + LEFT JOIN agent_rules ar ON al.rule_id = ar.id + ORDER BY al.created_at DESC LIMIT %s""", + (limit,), + ) + return {"items": _serialize_rows(rows)} + + +@admin_router.get("/analytics/social") +def social_analytics(): + account_stats = fetch_all( + """SELECT sa.platform, sa.username, SUM(sa2.impressions) AS impressions, + SUM(sa2.engagements) AS engagements, SUM(sa2.reach) AS reach + FROM social_analytics sa2 + JOIN social_accounts sa ON sa2.account_id = sa.id + WHERE sa2.date > CURRENT_DATE - interval '7 days' + GROUP BY sa.platform, sa.username ORDER BY impressions DESC""" + ) + daily = fetch_all( + """SELECT date, SUM(impressions) AS impressions, SUM(engagements) AS engagements + FROM social_analytics WHERE date > CURRENT_DATE - interval '7 days' + GROUP BY date ORDER BY date""" + ) + mention_stats = fetch_one( + "SELECT COUNT(*) AS cnt, COALESCE(AVG(sentiment_score),0) AS avg_sentiment FROM social_mentions" + ) + return { + "account_stats": _serialize_rows(account_stats), + "daily_stats": _serialize_rows(daily), + "mentions": _serialize(mention_stats), + } + + +# --- Monitor --- + +@admin_router.get("/monitor/sites") +def list_monitor_sites(): + rows = fetch_all( + """SELECT id, url, name, last_hash, last_crawled, is_active FROM monitored_sites + ORDER BY last_crawled DESC NULLS LAST""" + ) + active = fetch_one("SELECT COUNT(*) AS c FROM monitored_sites WHERE is_active = TRUE") + changes_24h = fetch_one( + "SELECT COUNT(*) AS c FROM page_changes WHERE changed_at > NOW() - interval '24 hours'" + ) + return { + "items": _serialize_rows(rows), + "stats": { + "active_sites": int(active["c"]) if active else 0, + "changes_24h": int(changes_24h["c"]) if changes_24h else 0, + }, + } + + +@admin_router.post("/monitor/sites") +def create_monitor_site(body: SiteBody): + try: + item = add_site(body.url, body.name) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + for k, v in list(item.items()): + if hasattr(v, "isoformat"): + item[k] = v.isoformat() + return {"ok": True, "item": item} + + +@admin_router.delete("/monitor/sites/{site_id}") +def delete_monitor_site(site_id: int, hard: bool = False): + remove_site(site_id, soft=not hard) + return {"ok": True} + + +@admin_router.patch("/monitor/sites/{site_id}/toggle") +def toggle_monitor_site(site_id: int): + execute("UPDATE monitored_sites SET is_active = NOT is_active WHERE id=%s", (site_id,)) + return {"ok": True} + + +@admin_router.post("/monitor/trigger-crawl") +def monitor_trigger_crawl(body: CrawlBody = CrawlBody()): + return trigger_crawl(body.site_id) + + +@admin_router.get("/monitor/page-changes") +def monitor_page_changes(limit: int = 50): + rows = fetch_all( + """SELECT pc.*, ms.url, ms.name FROM page_changes pc + JOIN monitored_sites ms ON pc.site_id = ms.id + ORDER BY pc.changed_at DESC LIMIT %s""", + (max(1, min(limit, 200)),), + ) + return {"items": _serialize_rows(rows)} + + +@admin_router.get("/monitor/crawl-logs") +def monitor_crawl_logs(limit: int = 50): + rows = fetch_all( + """SELECT cl.*, ms.url, ms.name FROM crawl_logs cl + LEFT JOIN monitored_sites ms ON cl.site_id = ms.id + ORDER BY cl.logged_at DESC LIMIT %s""", + (max(1, min(limit, 200)),), + ) + return {"items": _serialize_rows(rows)} + + +# --- Agent events (approvals) --- + +@admin_router.patch("/agent-events/{event_id}/status") +def patch_agent_event_status(event_id: int, body: PostStatusBody): + if body.status not in ("approved", "rejected", "needs_approval", "completed"): + raise HTTPException(400, "invalid status") + execute("UPDATE agent_events SET status=%s WHERE id=%s", (body.status, event_id)) + return {"ok": True} + + +@admin_router.post("/delegate") +async def delegate_task(body: DelegateBody): + msg = f"[{body.agent}] {body.task}" + result = await herman_service.chat(msg) + return {"ok": True, "result": result} + + +# --- AI --- + +async def _generate_social(prompt: str) -> str: + try: + return await ollama.generate(prompt) + except Exception as exc: + raise HTTPException(502, f"Ollama error: {exc}") from exc + + +@ai_router.post("/generate-content") +async def ai_generate_content(body: AIGenerateBody): + topic = body.topic.strip() + if not topic: + raise HTTPException(400, "topic required") + prompt = ( + f"Schrijf een korte social media post voor {body.platform} over: {topic}. " + f"Toon: {body.tone}. Maximaal 280 tekens. Alleen de posttekst, geen uitleg." + ) + content = await _generate_social(prompt) + return {"ok": True, "content": content} + + + + +@ai_router.post("/generate-image") +async def ai_generate_image(body: ImageGenerateBody): + import httpx + try: + async with httpx.AsyncClient(timeout=620.0) as client: + r = await client.post( + f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate", + json=body.model_dump(), + ) + r.raise_for_status() + data = r.json() + fn = data.get("filename", "") + sub = data.get("subfolder", "") + typ = data.get("type", "output") + data["proxy_url"] = f"/api/ai/generated-image?filename={fn}&subfolder={sub}&type={typ}" + return data + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:300] if exc.response else str(exc) + raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@ai_router.get("/generated-image") +async def ai_generated_image( + filename: str, + subfolder: str = "", + type: str = "output", +): + import httpx + from fastapi.responses import Response + try: + async with httpx.AsyncClient(timeout=60.0) as client: + r = await client.get( + f"{settings.TOOLS_API_URL.rstrip('/')}/images/view", + params={"filename": filename, "subfolder": subfolder, "type": type}, + ) + r.raise_for_status() + media = r.headers.get("content-type", "image/png") + return Response(content=r.content, media_type=media) + except Exception as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@ai_router.post("/generate-image/start") +async def ai_generate_image_start(body: ImageStartBody): + import httpx + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.post( + f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate/start", + json=body.model_dump(), + ) + r.raise_for_status() + return r.json() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:300] if exc.response else str(exc) + raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@ai_router.get("/generate-image/progress/{prompt_id}") +async def ai_generate_image_progress(prompt_id: str): + import httpx + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/images/progress/{prompt_id}") + r.raise_for_status() + data = r.json() + result = data.get("result") or {} + if data.get("status") == "done" and result.get("filename"): + fn = result["filename"] + sub = result.get("subfolder", "") + typ = result.get("type", "output") + data["proxy_url"] = f"/api/ai/generated-image?filename={fn}&subfolder={sub}&type={typ}" + return data + except httpx.HTTPStatusError as exc: + raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=exc.response.text[:300]) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + +@ai_router.post("/generate-post-draft") +async def ai_generate_post_draft(body: AIPostDraftBody): + platform = body.platform + if body.account_id: + acc = fetch_one("SELECT platform, username FROM social_accounts WHERE id=%s", (body.account_id,)) + if acc: + platform = f"{acc['platform']} (@{acc['username']})" + prompt = ( + f"Schrijf een geplande social post voor {platform} over: {body.topic.strip()}. " + f"Toon: {body.tone}. Geef titel + body." + ) + content = await _generate_social(prompt) + return {"ok": True, "content": content, "platform": platform} + + +@herman_api.post("/chat") +async def herman_chat_api(body: HermanChatBody): + if not body.message.strip(): + raise HTTPException(400, "message required") + result = await herman_service.chat(body.message.strip()) + return {"ok": True, **result} + + +@voice_api.post("/transcribe") +async def voice_transcribe(file: UploadFile = File(...)): + url = "http://10.4.7.19:8877/v1/audio/transcriptions" + data = await file.read() + if not data: + raise HTTPException(400, "empty file") + try: + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post( + url, + files={"file": (file.filename or "audio.webm", data, file.content_type or "audio/webm")}, + data={"model": "Systran/faster-whisper-base", "language": "nl"}, + ) + resp.raise_for_status() + data = resp.json() + return {"text": data.get("text", ""), "raw": data} + except httpx.HTTPError as exc: + raise HTTPException(502, f"transcribe proxy failed: {exc}") from exc + +# --- Document word analytics & sentiment --- + +@admin_router.get("/documents/summary") +def documents_summary(): + try: + row = fetch_one( + """ + SELECT COUNT(*) AS documents, + COALESCE(SUM(word_count), 0) AS total_words, + COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment + FROM document_analytics + """ + ) + unique = fetch_one( + "SELECT COUNT(DISTINCT lemma) AS unique_words FROM document_word_counts WHERE NOT is_stopword" + ) + sentiment = fetch_all( + """ + SELECT sentiment_label, COUNT(*) AS cnt + FROM document_analytics + GROUP BY sentiment_label + """ + ) + except Exception as exc: + raise HTTPException(500, str(exc)) from exc + return { + "ok": True, + "summary": { + "documents": int(row["documents"] or 0) if row else 0, + "total_words": int(row["total_words"] or 0) if row else 0, + "avg_sentiment": round(float(row["avg_sentiment"] or 0), 4) if row else 0, + "unique_words": int(unique["unique_words"] or 0) if unique else 0, + "sentiment": {r["sentiment_label"]: int(r["cnt"]) for r in sentiment}, + }, + } + + +@admin_router.get("/documents/words") +def documents_words( + q: str | None = None, + limit: int = 40, + stopwords: bool = False, +): + limit = max(1, min(limit, 200)) + clauses = [] + params: list = [] + if not stopwords: + clauses.append("NOT is_stopword") + if q and q.strip(): + clauses.append("lemma ILIKE %s") + params.append(f"%{q.strip()}%") + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.append(limit) + try: + rows = fetch_all( + f""" + SELECT lemma, MAX(token) AS token, SUM(count) AS total_count, + COUNT(DISTINCT storage_path) AS document_count + FROM document_word_counts + {where} + GROUP BY lemma + ORDER BY total_count DESC + LIMIT %s + """, + tuple(params), + ) + except Exception as exc: + raise HTTPException(500, str(exc)) from exc + return {"ok": True, "items": [_serialize(r) for r in rows]} + + +@admin_router.get("/documents/list") +def documents_list(limit: int = 50): + limit = max(1, min(limit, 200)) + try: + rows = fetch_all( + """ + SELECT filename, storage_path, doc_type, language, word_count, unique_lemmas, + sentiment_label, sentiment_compound, sentiment_positive, + sentiment_negative, sentiment_neutral, sentiment_subjectivity, + extraction_method, analyzed_at + FROM document_analytics + ORDER BY analyzed_at DESC + LIMIT %s + """, + (limit,), + ) + except Exception as exc: + raise HTTPException(500, str(exc)) from exc + return {"ok": True, "items": [_serialize(r) for r in rows]} + + +@admin_router.get("/documents/{storage_path:path}/words") +def document_words(storage_path: str, limit: int = 100, stopwords: bool = False): + limit = max(1, min(limit, 500)) + clauses = ["storage_path = %s"] + params: list = [storage_path] + if not stopwords: + clauses.append("NOT is_stopword") + try: + rows = fetch_all( + f""" + SELECT lemma, token, count, pos_tag, is_stopword, language + FROM document_word_counts + WHERE {' AND '.join(clauses)} + ORDER BY count DESC + LIMIT %s + """, + tuple(params + [limit]), + ) + except Exception as exc: + raise HTTPException(500, str(exc)) from exc + return {"ok": True, "items": [_serialize(r) for r in rows]} + +BROWSER_AGENT_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790") + + +class BrowserBrowseBody(BaseModel): + url: str + task: Optional[str] = None + site_id: Optional[int] = None + wait_seconds: float = 4.0 + + +@admin_router.post("/browser/browse") +async def browser_browse(body: BrowserBrowseBody) -> dict[str, Any]: + import httpx + try: + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse", json=body.model_dump()) + r.raise_for_status() + return r.json() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:500] + raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@admin_router.get("/browser/sessions") +async def browser_sessions(limit: int = 20) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions", params={"limit": limit}) + r.raise_for_status() + return r.json() + + +@admin_router.get("/browser/sessions/{session_id}") +async def browser_session_detail(session_id: int, include_screenshot: bool = True) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get( + f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}", + params={"include_screenshot": include_screenshot}, + ) + r.raise_for_status() + return r.json() + + +@admin_router.get("/browser/live/screenshot.jpg") +async def browser_live_screenshot(): + import httpx + from fastapi.responses import Response + try: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/live/screenshot.jpg") + if r.status_code == 404: + return Response(status_code=404) + return Response(content=r.content, media_type="image/jpeg") + except Exception: + return Response(status_code=404) + + +@admin_router.get("/browser/sessions/{session_id}/screenshot.jpg") +async def browser_session_screenshot(session_id: int): + import httpx + from fastapi.responses import Response + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}/screenshot.jpg") + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail="Screenshot not found") + return Response(content=r.content, media_type="image/jpeg") + + + +class BrowserInstructBody(BaseModel): + url: str + instruction: str + wait_seconds: float = 5.0 + + +@admin_router.post("/browser/instruct") +async def browser_instruct(body: BrowserInstructBody) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=180.0) as client: + r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/instruct", json=body.model_dump()) + r.raise_for_status() + return r.json() + + +@admin_router.get("/browser/sessions/{session_id}/ocr") +async def browser_session_ocr(session_id: int) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=60.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}/ocr") + r.raise_for_status() + return r.json() + +class BrowserVncBody(BaseModel): + url: str + instruction: Optional[str] = None + wait_seconds: float = 4.0 + + +class BrowserExtractFullBody(BaseModel): + url: str + instruction: Optional[str] = None + wait_seconds: float = 4.0 + scroll_pages: int = 6 + site_id: Optional[int] = None + also_vnc: bool = True + + +class PhotoUploadBody(BaseModel): + image_b64: str + source: str = "cockpit" + filename: Optional[str] = None + storage_path: Optional[str] = None + session_id: Optional[int] = None + + +@admin_router.post("/browser/vnc-navigate") +async def browser_vnc_navigate(body: BrowserVncBody) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/navigate", json=body.model_dump()) + if r.status_code >= 400: + raise HTTPException(status_code=r.status_code, detail=r.text[:500]) + return r.json() + + +@admin_router.post("/browser/extract-full") +async def browser_extract_full(body: BrowserExtractFullBody) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=300.0) as client: + r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/extract-full", json=body.model_dump()) + if r.status_code >= 400: + raise HTTPException(status_code=r.status_code, detail=r.text[:500]) + return r.json() + + +@admin_router.get("/photos") +async def photos_list(limit: int = 30) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos", params={"limit": limit}) + r.raise_for_status() + return r.json() + + +@admin_router.get("/photos/{photo_id}") +async def photos_detail(photo_id: int) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}") + r.raise_for_status() + return r.json() + + +@admin_router.get("/photos/{photo_id}/image.jpg") +async def photos_image(photo_id: int): + import httpx + from fastapi.responses import Response + async with httpx.AsyncClient(timeout=60.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}/image.jpg") + if r.status_code != 200: + raise HTTPException(status_code=r.status_code) + return Response(content=r.content, media_type="image/jpeg") + + +@admin_router.get("/photos/{photo_id}/detections") +async def photos_detections(photo_id: int) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}/detections") + r.raise_for_status() + return r.json() + + +@admin_router.post("/photos/analyze") +async def photos_analyze(body: PhotoUploadBody) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/analyze", json=body.model_dump()) + if r.status_code >= 400: + raise HTTPException(status_code=r.status_code, detail=r.text[:500]) + return r.json() + + + + +@admin_router.get("/documents/share-files") +async def documents_share_files( + limit: int = 200, + ext: Optional[str] = None, +) -> dict[str, Any]: + """Alle bestanden op NAS share — incl. pptx, pdf, etc.""" + import httpx + doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750") + limit = max(1, min(limit, 500)) + params: dict[str, Any] = {"limit": limit} + if ext: + params["ext"] = ext + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{doc_url.rstrip('/')}/nas/files", params=params) + r.raise_for_status() + data = r.json() + return {"ok": True, **data} + except Exception as exc: + return {"ok": False, "items": [], "error": str(exc)} + + +@admin_router.post("/documents/trigger-scan") +async def documents_trigger_scan(force: bool = False) -> dict[str, Any]: + """Start doc-ingest scan zodat nieuwe share-bestanden worden herkend.""" + import httpx + doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750") + try: + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post( + f"{doc_url.rstrip('/')}/ingest/scan", + params={"force": "true" if force else "false"}, + ) + r.raise_for_status() + return {"ok": True, **r.json()} + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@admin_router.get("/documents/nas-images") +async def documents_nas_images(limit: int = 40) -> dict[str, Any]: + import httpx + doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750") + exts = (".jpg", ".jpeg", ".png", ".webp", ".gif") + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{doc_url.rstrip('/')}/nas/list", params={"limit": 500}) + r.raise_for_status() + data = r.json() + files = data.get("files") or [] + images = [f for f in files if (f.get("path") or "").lower().endswith(exts)] + return {"ok": True, "items": images[:limit], "total": len(images)} + except Exception as exc: + return {"ok": False, "items": [], "error": str(exc)} + + +@admin_router.get("/browser/vnc/screenshot.jpg") +async def browser_vnc_screenshot(): + import httpx + from fastapi.responses import Response + try: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/screenshot.jpg") + if r.status_code != 200: + return Response(status_code=404) + return Response(content=r.content, media_type="image/jpeg") + except Exception: + return Response(status_code=404) + +# --- Hermes / Telegram second brain proxies --- +class HermesSearchBody(BaseModel): + query: str = Field(..., min_length=2) + chat_id: Optional[int] = None + limit: int = Field(default=15, ge=1, le=30) + + +@admin_router.get("/hermes/stats") +async def hermes_stats_proxy() -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats") + r.raise_for_status() + return r.json() + + +@admin_router.get("/hermes/conversations") +async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit}) + r.raise_for_status() + return r.json() + + +@admin_router.get("/hermes/feed") +async def hermes_feed_proxy( + chat_id: Optional[int] = None, + limit: int = 80, + offset: int = 0, +) -> dict[str, Any]: + import httpx + params = {"limit": limit, "offset": offset} + if chat_id is not None: + params["chat_id"] = chat_id + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params) + r.raise_for_status() + return r.json() + + +@admin_router.get("/hermes/graph") +async def hermes_graph_global(limit: int = 100) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/graph", params={"limit": limit}) + r.raise_for_status() + return r.json() + + +@admin_router.get("/hermes/graph/{chat_id}") +async def hermes_graph_chat(chat_id: int, limit: int = 80) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/graph/{chat_id}", params={"limit": limit}) + r.raise_for_status() + return r.json() + + +@admin_router.post("/hermes/search") +async def hermes_search_proxy(body: HermesSearchBody) -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post( + f"{settings.TOOLS_API_URL.rstrip('/')}/brain/search", + json=body.model_dump(), + ) + r.raise_for_status() + return r.json() + +# --- Hermes PA live + users + control (added by deploy) --- +HERMES_CONTROL_URL = os.getenv("HERMES_CONTROL_URL", "http://10.4.7.19:8799").rstrip("/") + + +@admin_router.get("/hermes/pa/live") +async def hermes_pa_live_proxy() -> dict[str, Any]: + import httpx + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/pa/live") + r.raise_for_status() + live = r.json() + try: + async with httpx.AsyncClient(timeout=8.0) as client: + ctrl = await client.get(f"{HERMES_CONTROL_URL}/status") + if ctrl.status_code == 200: + live["hermes"] = ctrl.json() + except Exception: + live["hermes"] = {"online": False} + return live + + +@admin_router.get("/hermes/pa/live/{label}/screenshot.jpg") +async def hermes_pa_slot_screenshot(label: str): + import httpx + from fastapi.responses import Response + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + f"{BROWSER_AGENT_URL.rstrip('/')}/pa/live/{label}/screenshot.jpg" + ) + if r.status_code != 200: + raise HTTPException(status_code=r.status_code, detail="No screenshot") + return Response(content=r.content, media_type="image/jpeg") + + +@admin_router.get("/hermes/users") +async def hermes_users_proxy() -> dict[str, Any]: + import httpx + users: list[dict[str, Any]] = [] + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.get(f"{HERMES_CONTROL_URL}/users") + if r.status_code == 200: + return r.json() + except Exception: + pass + async with httpx.AsyncClient(timeout=15.0) as client: + conv = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": 20}) + items = conv.json().get("items") or [] if conv.status_code == 200 else [] + default = [ + {"chat_id": 8859782446, "name": "Aïssa", "role": "CEO", "allowed": True, "pa_mode": False, "online": True}, + {"chat_id": 789036463, "name": "Mo", "role": "CTO", "allowed": True, "pa_mode": False, "online": False}, + ] + by_id = {u["chat_id"]: u for u in default} + for c in items: + cid = c.get("chat_id") + if cid in by_id: + by_id[cid]["message_count"] = c.get("message_count") + by_id[cid]["last_message_at"] = c.get("last_message_at") + by_id[cid]["online"] = True + else: + by_id[cid] = { + "chat_id": cid, + "name": c.get("user_name") or str(cid), + "role": c.get("user_role") or "user", + "allowed": True, + "pa_mode": False, + "online": True, + "message_count": c.get("message_count"), + "last_message_at": c.get("last_message_at"), + } + return {"users": list(by_id.values())} + + +@admin_router.post("/hermes/actions/{action}") +async def hermes_action_proxy(action: str) -> dict[str, Any]: + import httpx + allowed = {"evening-briefing", "morning-briefing", "resume-jobs", "test-briefing"} + if action not in allowed: + raise HTTPException(status_code=400, detail=f"Unknown action: {action}") + async with httpx.AsyncClient(timeout=180.0) as client: + r = await client.post(f"{HERMES_CONTROL_URL}/actions/{action}") + if r.status_code >= 400: + raise HTTPException(status_code=r.status_code, detail=r.text[:300]) + return r.json() + + diff --git a/cockpit/app/routes/agents.py b/cockpit/app/routes/agents.py new file mode 100644 index 0000000..08310ce --- /dev/null +++ b/cockpit/app/routes/agents.py @@ -0,0 +1,84 @@ +from pathlib import Path + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates + +from app.db import execute, fetch_all + +router = APIRouter(prefix="/agents", tags=["agents"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +AGENT_STATUSES = [ + {"name": "Herman", "role": "CEO Briefing", "color": "cyan"}, + {"name": "Sales", "role": "Pipeline", "color": "purple"}, + {"name": "Marketing", "role": "Social & Content", "color": "amber"}, + {"name": "Ops", "role": "Operations", "color": "green"}, +] + + +@router.get("") +async def agents_page(request: Request): + active_tab = request.query_params.get("tab", "souls") + if active_tab not in {"souls", "mesh"}: + active_tab = "souls" + events: list = [] + try: + events = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events + ORDER BY created_at DESC + LIMIT 100 + """ + ) + for ev in events: + if ev.get("created_at"): + ev["created_at"] = ev["created_at"].isoformat() + except Exception: + events = [] + + return templates.TemplateResponse( + "agents.html", + { + "request": request, + "page_title": "Agents", + "active_tab": active_tab, + "agents": AGENT_STATUSES, + "events": events, + }, + ) + + +@router.post("/approve/{event_id}") +async def approve_event(event_id: int, next_url: str = Form("/")): + try: + execute( + """ + UPDATE agent_events + SET status = 'approved' + WHERE id = %s + """, + (event_id,), + ) + except Exception: + pass + return RedirectResponse(url=next_url, status_code=303) + + +@router.post("/reject/{event_id}") +async def reject_event(event_id: int, next_url: str = Form("/")): + try: + execute( + """ + UPDATE agent_events + SET status = 'rejected' + WHERE id = %s + """, + (event_id,), + ) + except Exception: + pass + return RedirectResponse(url=next_url, status_code=303) diff --git a/cockpit/app/routes/agents_api.py b/cockpit/app/routes/agents_api.py new file mode 100644 index 0000000..433a41b --- /dev/null +++ b/cockpit/app/routes/agents_api.py @@ -0,0 +1,95 @@ +"""Agents API — souls & activity.""" +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.db import fetch_all +from app.services import agent_souls + +router = APIRouter(prefix="/api/agents", tags=["agents-api"]) + + +class SoulUpdate(BaseModel): + display_name: Optional[str] = None + role_title: Optional[str] = None + soul_md: Optional[str] = None + responsibilities: Optional[str] = None + is_active: Optional[bool] = None + + +@router.get("/souls") +def api_list_souls() -> dict[str, Any]: + return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())} + + +@router.get("/souls/{agent_key}") +def api_get_soul(agent_key: str) -> dict[str, Any]: + soul = agent_souls.get_soul(agent_key) + if not soul: + raise HTTPException(404, "Agent not found") + return {"soul": soul} + + +@router.put("/souls/{agent_key}") +def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]: + try: + soul = agent_souls.update_soul(agent_key, **body.model_dump(exclude_none=True)) + except ValueError as exc: + raise HTTPException(404, str(exc)) from exc + return {"soul": soul} + + +@router.get("/mesh") +def api_agents_mesh() -> dict[str, Any]: + souls = agent_souls.list_souls() + stats_rows = fetch_all( + """ + SELECT LOWER(agent_name) AS agent_key, + MAX(created_at) AS last_event_at, + COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h, + COUNT(*) FILTER ( + WHERE created_at >= NOW() - INTERVAL '24 hours' + AND status IN ('error', 'rejected') + ) AS errors_24h + FROM agent_events + GROUP BY LOWER(agent_name) + """ + ) + by_key = {str(r["agent_key"]): dict(r) for r in stats_rows} + + nodes: list[dict[str, Any]] = [] + for soul in souls: + key = str(soul.get("agent_key") or "").lower() + row = by_key.get(key, {}) + events_6h = int(row.get("events_6h") or 0) + errors_24h = int(row.get("errors_24h") or 0) + health = "offline" + if events_6h > 0 and errors_24h == 0: + health = "healthy" + elif events_6h > 0: + health = "warn" + elif int(soul.get("event_count") or 0) > 0: + health = "idle" + node = dict(soul) + node["health"] = health + node["events_6h"] = events_6h + node["errors_24h"] = errors_24h + if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"): + node["last_event_at"] = row["last_event_at"].isoformat() + nodes.append(node) + + edge_rows = fetch_all( + """ + SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight + FROM agent_events + WHERE created_at >= NOW() - INTERVAL '24 hours' + AND LOWER(agent_name) <> 'herman' + GROUP BY LOWER(agent_name) + ORDER BY weight DESC + """ + ) + edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows] + return {"nodes": nodes, "edges": edges} diff --git a/cockpit/app/routes/analytics.html b/cockpit/app/routes/analytics.html new file mode 100644 index 0000000..5963b63 --- /dev/null +++ b/cockpit/app/routes/analytics.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block content %} + +
+
Pipeline EUR
€{{ "%.0f"|format(metrics.pipeline) }}
+
Clients
{{ metrics.clients }}
+
+
+

Deals by stage

+ +{% for row in metrics.deals_by_stage %}{% endfor %}
StageCountTotal
{{ row.stage }}{{ row.cnt }}€{{ row.total }}
+
+

Events by agent

+ +{% for row in metrics.events_by_agent %}{% endfor %}
AgentEvents
{{ row.agent_name }}{{ row.cnt }}
+
+
+{% endblock %} diff --git a/cockpit/app/routes/analytics.py b/cockpit/app/routes/analytics.py new file mode 100644 index 0000000..042541b --- /dev/null +++ b/cockpit/app/routes/analytics.py @@ -0,0 +1,34 @@ +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Query, Request +from fastapi.responses import JSONResponse +from fastapi.templating import Jinja2Templates + +from app.services.analytics_data import collect_analytics + +router = APIRouter(prefix="/analytics", tags=["analytics"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +@router.get("") +async def analytics_page(request: Request): + initial = collect_analytics({}) + return templates.TemplateResponse( + "analytics.html", + {"request": request, "page_title": "Analytics", "initial_data": initial}, + ) + + +@router.get("/api/data") +async def analytics_api( + chain: Optional[str] = None, + province: Optional[str] = None, + stage: Optional[str] = None, + agent: Optional[str] = None, + days: int = Query(90, ge=7, le=365), +): + filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days} + filters = {k: v for k, v in filters.items() if v is not None and v != ""} + return JSONResponse(collect_analytics(filters)) diff --git a/cockpit/app/routes/api.py b/cockpit/app/routes/api.py new file mode 100644 index 0000000..97f3050 --- /dev/null +++ b/cockpit/app/routes/api.py @@ -0,0 +1,123 @@ +from datetime import date, datetime +from typing import Any, Optional + +import httpx +from fastapi import APIRouter, HTTPException + +from app.config import settings +from app.db import execute, fetch_all, fetch_one +from app.services.briefing import collect_briefing_data, generate_daily_briefing, serialize_stats + +router = APIRouter(prefix="/api", tags=["api"]) + + +def _stats_payload() -> dict[str, Any]: + stats: dict[str, Any] = { + "deals": 0, + "clients": 0, + "pending_approvals": 0, + "pipeline_value": 0, + } + try: + row = fetch_one("SELECT COUNT(*) AS c FROM deals") + stats["deals"] = int(row["c"]) if row else 0 + except Exception: + pass + try: + row = fetch_one("SELECT COUNT(*) AS c FROM clients") + stats["clients"] = int(row["c"]) if row else 0 + except Exception: + pass + try: + row = fetch_one("SELECT COUNT(*) AS c FROM agent_events WHERE status = 'needs_approval'") + stats["pending_approvals"] = int(row["c"]) if row else 0 + except Exception: + pass + try: + row = fetch_one( + "SELECT COALESCE(SUM(value), 0) AS total FROM deals WHERE stage NOT IN ('won', 'lost')" + ) + stats["pipeline_value"] = float(row["total"]) if row else 0 + except Exception: + pass + return stats + + +@router.get("/server-time") +async def server_time(): + now = datetime.utcnow() + return {"utc": now.isoformat() + "Z", "timezone": "Europe/Amsterdam"} + + +@router.get("/herman/briefing/stats") +async def herman_briefing_stats(): + """Live stats from DB — always fresh for dashboard panels.""" + stats = serialize_stats(collect_briefing_data()) + bookmarks = [] + bookmark_map = {} + try: + bookmarks = fetch_all( + """SELECT b.rss_item_id, b.title, b.link, b.feed_name, b.created_at + FROM rss_bookmarks b ORDER BY b.created_at DESC LIMIT 30""" + ) + for b in bookmarks: + if b.get("created_at") and hasattr(b["created_at"], "isoformat"): + b["created_at"] = b["created_at"].isoformat() + bookmark_map[b["rss_item_id"]] = True + except Exception: + bookmarks = [] + stats["rss_bookmarks"] = bookmarks + stats["rss_bookmark_ids"] = list(bookmark_map.keys()) + return {"ok": True, "stats": stats, "bookmarks": bookmarks, "at": datetime.utcnow().isoformat()} + + +@router.post("/herman/briefing") +async def herman_briefing(): + try: + content, stats = await generate_daily_briefing() + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return {"ok": True, "content": content, "stats": stats, "generated_at": datetime.utcnow().isoformat()} + + +@router.get("/herman/briefing/latest") +async def herman_briefing_latest(): + try: + row = fetch_one( + "SELECT id, content, generated_by, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1" + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + live_stats = serialize_stats(collect_briefing_data()) + if not row: + return {"ok": True, "content": None, "stats": live_stats} + if row.get("created_at") and hasattr(row["created_at"], "isoformat"): + row["created_at"] = row["created_at"].isoformat() + return {"ok": True, **row, "stats": live_stats} + + +@router.get("/live/platform") +async def live_platform(limit: int = 100, agent: Optional[str] = None): + from app.services.platform_live import fetch_platform_events, platform_stats + + events = fetch_platform_events(limit=min(limit, 200), agent=agent) + return {"ok": True, "stats": platform_stats(), "events": events} + + +@router.get("/events") +async def list_events(limit: int = 50): + limit = max(1, min(limit, 200)) + try: + rows = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events ORDER BY created_at DESC LIMIT %s + """, + (limit,), + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + for row in rows: + if row.get("created_at"): + row["created_at"] = row["created_at"].isoformat() + return {"events": rows} diff --git a/cockpit/app/routes/beurs.py b/cockpit/app/routes/beurs.py new file mode 100644 index 0000000..9dcd7d3 --- /dev/null +++ b/cockpit/app/routes/beurs.py @@ -0,0 +1,17 @@ +"""Beurs & live market intelligence page.""" +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from pathlib import Path + +router = APIRouter(tags=["beurs"]) +BASE = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE / "templates")) + + +@router.get("/beurs") +async def beurs_page(request: Request): + tab = request.query_params.get("tab", "beurs") + return templates.TemplateResponse( + "beurs.html", + {"request": request, "page_title": "Beurs & Live Intel", "initial_tab": tab}, + ) diff --git a/cockpit/app/routes/browser.py b/cockpit/app/routes/browser.py new file mode 100644 index 0000000..e614ead --- /dev/null +++ b/cockpit/app/routes/browser.py @@ -0,0 +1,71 @@ +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates + +from app.db import fetch_all, fetch_one + +router = APIRouter(tags=["browser"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +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() + return rows + + +def _monitor_context() -> dict: + sites, changes, logs = [], [], [] + stats = {"active_sites": 0, "changes_24h": 0} + try: + sites = _iso_rows(fetch_all( + """SELECT id, url, name, last_hash, last_crawled, is_active + FROM monitored_sites ORDER BY last_crawled DESC NULLS LAST LIMIT 100""" + )) + row = fetch_one("SELECT COUNT(*) AS c FROM monitored_sites WHERE is_active = TRUE") + stats["active_sites"] = int(row["c"]) if row else 0 + row = fetch_one( + "SELECT COUNT(*) AS c FROM page_changes WHERE changed_at > NOW() - interval '24 hours'" + ) + stats["changes_24h"] = int(row["c"]) if row else 0 + except Exception: + sites = [] + try: + changes = _iso_rows(fetch_all( + """SELECT pc.id, pc.site_id, pc.old_hash, pc.new_hash, pc.changed_at, ms.url, ms.name + FROM page_changes pc JOIN monitored_sites ms ON pc.site_id = ms.id + ORDER BY pc.changed_at DESC LIMIT 50""" + )) + except Exception: + changes = [] + try: + logs = _iso_rows(fetch_all( + """SELECT cl.id, cl.site_id, cl.status, cl.message, cl.logged_at, ms.url, ms.name + FROM crawl_logs cl LEFT JOIN monitored_sites ms ON cl.site_id = ms.id + ORDER BY cl.logged_at DESC LIMIT 50""" + )) + except Exception: + logs = [] + return {"sites": sites, "page_changes": changes, "crawl_logs": logs, "stats": stats} + + +@router.get("/browser") +async def browser_page(request: Request): + ctx = _monitor_context() + return templates.TemplateResponse( + "browser.html", + { + "request": request, + "page_title": "Browser & Monitor", + "default_url": "https://www.bidfood.nl/webshop/assortiment/mekkafood/_/N-1z10pje/", + "browser_agent_url": "http://10.4.7.18:7790", + "novnc_url": "http://10.4.7.18:6080/vnc.html?autoconnect=true&resize=scale&path=websockify&password=Foodlinkk2026", + "gradio_url": "http://10.4.7.18:7788", + **ctx, + }, + ) diff --git a/cockpit/app/routes/clients.html b/cockpit/app/routes/clients.html new file mode 100644 index 0000000..5fec861 --- /dev/null +++ b/cockpit/app/routes/clients.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block content %} +
+ + +
+{% set stages = ['intake','discovery','proposal','active','churned'] %} +{% for st in stages %} +

{{ st }}

+{% for c in clients if c.stage == st %} +
+ {{ c.name }}
{{ c.sector or '' }} + +
+{% endfor %} +
+{% endfor %} +
+ + +
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/app/routes/clients.py b/cockpit/app/routes/clients.py new file mode 100644 index 0000000..17c6d1b --- /dev/null +++ b/cockpit/app/routes/clients.py @@ -0,0 +1,27 @@ +from pathlib import Path +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from app.db import fetch_all + +router = APIRouter(prefix="/clients", tags=["clients"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +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() + return rows + +@router.get("") +async def clients_page(request: Request): + rows = [] + try: + rows = _iso_rows(fetch_all( + """SELECT id, name, contact, email, stage, sector, mrr_estimate, notes, created_at, updated_at + FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 200""" + )) + except Exception: + rows = [] + return templates.TemplateResponse("clients.html", {"request": request, "page_title": "Clients", "clients": rows}) diff --git a/cockpit/app/routes/dashboard.py b/cockpit/app/routes/dashboard.py new file mode 100644 index 0000000..efa3c4b --- /dev/null +++ b/cockpit/app/routes/dashboard.py @@ -0,0 +1,163 @@ +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path +import json + +from app.db import fetch_all, fetch_one +from app.services.briefing import collect_briefing_data, serialize_stats + +router = APIRouter(tags=["dashboard"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +def _safe_count(table: str, where: str = "") -> int: + try: + clause = f" WHERE {where}" if where else "" + row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}") + return int(row["c"]) if row else 0 + except Exception: + return 0 + + +def _safe_sum(table: str, column: str, where: str = "") -> float: + try: + clause = f" WHERE {where}" if where else "" + row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}") + return float(row["total"]) if row else 0.0 + except Exception: + return 0.0 + + +def _briefing_payload(briefing: dict | None) -> dict: + """Always use live DB stats; briefing text may be cached.""" + payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None} + if not briefing: + return payload + + payload["content"] = briefing.get("content") + payload["created_at"] = briefing.get("created_at") + return payload + + +@router.get("/") +async def dashboard(request: Request): + kpis = { + "deals_count": _safe_count("deals"), + "clients_count": _safe_count("clients"), + "pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"), + "pipeline_value": _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')"), + "browser_sessions_24h": 0, + "monitor_sites": _safe_count("monitored_sites", "is_active = TRUE"), + "supermarkets_count": _safe_count("supermarkets"), + "clients_active": _safe_count("clients", "stage = 'active'"), + "crm_partnerships": _safe_count("supermarkets", "partnership_status = 'active'"), + } + + briefing = None + try: + briefing = fetch_one( + "SELECT id, content, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1" + ) + if briefing and briefing.get("created_at"): + briefing["created_at"] = briefing["created_at"].isoformat() + except Exception: + briefing = None + + agent_feed: list = [] + try: + agent_feed = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events ORDER BY created_at DESC LIMIT 25 + """ + ) + for ev in agent_feed: + if ev.get("created_at"): + ev["created_at"] = ev["created_at"].isoformat() + except Exception: + agent_feed = [] + + approvals: list = [] + try: + approvals = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events WHERE status = 'needs_approval' + ORDER BY created_at ASC LIMIT 25 + """ + ) + for ev in approvals: + if ev.get("created_at"): + ev["created_at"] = ev["created_at"].isoformat() + except Exception: + approvals = [] + + browser_sessions: list = [] + try: + browser_sessions = fetch_all( + """ + SELECT id, url, final_url, title, task, status, created_at, + LEFT(content_text, 300) AS preview + FROM browser_sessions + ORDER BY created_at DESC LIMIT 8 + """ + ) + kpis["browser_sessions_24h"] = _safe_count( + "browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'" + ) + for s in browser_sessions: + if s.get("created_at"): + s["created_at"] = s["created_at"].isoformat() + except Exception: + browser_sessions = [] + + monitor_sites: list = [] + monitor_changes: list = [] + try: + monitor_sites = fetch_all( + """ + SELECT id, name, url, last_title, last_crawled, is_active + FROM monitored_sites WHERE is_active = TRUE ORDER BY last_crawled DESC NULLS LAST LIMIT 10 + """ + ) + for s in monitor_sites: + if s.get("last_crawled"): + s["last_crawled"] = s["last_crawled"].isoformat() + monitor_changes = fetch_all( + """ + SELECT pc.id, pc.changed_at, ms.name, ms.url + FROM page_changes pc + JOIN monitored_sites ms ON ms.id = pc.site_id + WHERE pc.changed_at >= NOW() - INTERVAL '7 days' + ORDER BY pc.changed_at DESC LIMIT 10 + """ + ) + for c in monitor_changes: + if c.get("changed_at"): + c["changed_at"] = c["changed_at"].isoformat() + except Exception: + pass + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "page_title": "Herman · Command Center", + "kpis": kpis, + "briefing": briefing, + "briefing_payload": _briefing_payload(briefing), + "agent_feed": agent_feed, + "approvals": approvals, + "browser_sessions": browser_sessions, + "monitor_sites": monitor_sites, + "monitor_changes": monitor_changes, + }, + ) + + +@router.get("/marketing-redirect") +async def marketing_redirect(): + return RedirectResponse(url="/marketing", status_code=302) diff --git a/cockpit/app/routes/deals.py b/cockpit/app/routes/deals.py new file mode 100644 index 0000000..85c76d0 --- /dev/null +++ b/cockpit/app/routes/deals.py @@ -0,0 +1,33 @@ +from pathlib import Path +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from app.db import fetch_all + +router = APIRouter(prefix="/deals", tags=["deals"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +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() + return rows + +@router.get("") +async def deals_page(request: Request): + rows = [] + try: + rows = _iso_rows(fetch_all( + """SELECT d.id, d.title, d.value, d.stage, d.agent_owner, d.next_action, d.deadline, + d.created_at, c.name AS client_name + FROM deals d LEFT JOIN clients c ON c.id = d.client_id + ORDER BY d.updated_at DESC NULLS LAST LIMIT 200""" + )) + except Exception: + rows = [] + stages = {} + for r in rows: + st = r.get("stage") or "unknown" + stages.setdefault(st, []).append(r) + return templates.TemplateResponse("deals.html", {"request": request, "page_title": "Deals", "deals": rows, "kanban": stages}) diff --git a/cockpit/app/routes/documents.py b/cockpit/app/routes/documents.py new file mode 100644 index 0000000..479fdc9 --- /dev/null +++ b/cockpit/app/routes/documents.py @@ -0,0 +1,95 @@ +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates + +from app.db import fetch_all, fetch_one + +router = APIRouter(prefix="/documents", tags=["documents"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +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() + return rows + + +@router.get("") +async def documents_page(request: Request): + summary = { + "documents": 0, + "total_words": 0, + "unique_words": 0, + "avg_sentiment": 0.0, + "positive": 0, + "neutral": 0, + "negative": 0, + } + try: + row = fetch_one( + """ + SELECT COUNT(*) AS docs, + COALESCE(SUM(word_count), 0) AS words, + COALESCE(AVG(sentiment_compound), 0) AS avg_sent + FROM document_analytics + """ + ) + if row: + summary["documents"] = int(row["docs"] or 0) + summary["total_words"] = int(row["words"] or 0) + summary["avg_sentiment"] = round(float(row["avg_sent"] or 0), 3) + row = fetch_one("SELECT COUNT(DISTINCT lemma) AS c FROM document_word_counts WHERE NOT is_stopword") + summary["unique_words"] = int(row["c"] or 0) if row else 0 + for label in ("positive", "neutral", "negative"): + row = fetch_one( + "SELECT COUNT(*) AS c FROM document_analytics WHERE sentiment_label = %s", + (label,), + ) + summary[label] = int(row["c"] or 0) if row else 0 + except Exception: + pass + + top_words: list = [] + documents: list = [] + try: + top_words = fetch_all( + """ + SELECT lemma, MAX(token) AS token, SUM(count) AS total_count, + COUNT(DISTINCT storage_path) AS doc_count + FROM document_word_counts + WHERE NOT is_stopword + GROUP BY lemma + ORDER BY total_count DESC + LIMIT 30 + """ + ) + documents = _iso_rows( + fetch_all( + """ + SELECT filename, storage_path, doc_type, language, word_count, + unique_lemmas, sentiment_label, sentiment_compound, + sentiment_positive, sentiment_negative, sentiment_neutral, + extraction_method, analyzed_at + FROM document_analytics + ORDER BY analyzed_at DESC + LIMIT 50 + """ + ) + ) + except Exception: + pass + + return templates.TemplateResponse( + "documents.html", + { + "request": request, + "page_title": "Documents & Sentiment", + "summary": summary, + "top_words": top_words, + "documents": documents, + }, + ) diff --git a/cockpit/app/routes/herman.py b/cockpit/app/routes/herman.py new file mode 100644 index 0000000..a785964 --- /dev/null +++ b/cockpit/app/routes/herman.py @@ -0,0 +1,79 @@ +from pathlib import Path +from fastapi import APIRouter, Form, Request +from fastapi.templating import Jinja2Templates + +from app.db import fetch_all +from app.services.herman import AGENTS, chat, generate_briefing + +router = APIRouter(prefix="/herman", tags=["herman"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +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() + return rows + +@router.get("") +async def herman_page(request: Request): + history = [] + try: + history = _iso_rows(fetch_all( + """SELECT id, agent_name, title, body, metadata, created_at FROM agent_events + WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40""" + )) + except Exception: + history = [] + return templates.TemplateResponse( + "herman_chat.html", + {"request": request, "page_title": "Herman", "agents": AGENTS, "history": history, "last_reply": None, "last_agent": None}, + ) + +@router.post("/chat") +async def herman_chat(request: Request, message: str = Form(...)): + result = await chat(message) + history = [] + try: + history = _iso_rows(fetch_all( + """SELECT id, agent_name, title, body, metadata, created_at FROM agent_events + WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40""" + )) + except Exception: + history = [] + return templates.TemplateResponse( + "herman_chat.html", + { + "request": request, + "page_title": "Herman", + "agents": AGENTS, + "history": history, + "last_reply": result.get("reply"), + "last_agent": result.get("agent_label"), + "user_message": message, + }, + ) + +@router.post("/briefing") +async def herman_briefing_page(request: Request): + content = await generate_briefing() + history = [] + try: + history = _iso_rows(fetch_all( + """SELECT id, agent_name, title, body, metadata, created_at FROM agent_events + WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40""" + )) + except Exception: + history = [] + return templates.TemplateResponse( + "herman_chat.html", + { + "request": request, + "page_title": "Herman", + "agents": AGENTS, + "history": history, + "last_reply": content, + "last_agent": "Herman · Briefing", + }, + ) diff --git a/cockpit/app/routes/hermes.py b/cockpit/app/routes/hermes.py new file mode 100644 index 0000000..a513ddd --- /dev/null +++ b/cockpit/app/routes/hermes.py @@ -0,0 +1,52 @@ +"""Hermes Telegram Command Center UI.""" +from __future__ import annotations + +import httpx +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path + +from app.config import settings + +router = APIRouter(prefix="/hermes", tags=["hermes"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +CEO_CHAT_ID = 8859782446 +CTO_CHAT_ID = 789036463 +CEO_NAME = "Aïssa" +CTO_NAME = "Mo" + + +@router.get("", response_class=HTMLResponse) +async def hermes_dashboard(request: Request) -> HTMLResponse: + stats = {"conversations": 0, "messages": 0, "edges": 0, "embeddings": 0} + conversations = [] + try: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats") + if r.status_code == 200: + data = r.json() + stats = data.get("stats") or stats + conversations_resp = await client.get( + f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": 20} + ) + if conversations_resp.status_code == 200: + conversations = conversations_resp.json().get("items") or [] + except Exception: + pass + + return templates.TemplateResponse( + "hermes.html", + { + "request": request, + "page_title": "Hermes", + "stats": stats, + "conversations": conversations, + "ceo_chat_id": CEO_CHAT_ID, + "cto_chat_id": CTO_CHAT_ID, + "allowed_sites": ["Airbnb", "Booking.com", "DuckDuckGo", "HolidayCheck"], + "blocked_sites": ["Google", "Vrbo", "Expedia", "Hotels.com", "TUI", "TripAdvisor"], + }, + ) diff --git a/cockpit/app/routes/marketing.py b/cockpit/app/routes/marketing.py new file mode 100644 index 0000000..5cdb313 --- /dev/null +++ b/cockpit/app/routes/marketing.py @@ -0,0 +1,72 @@ +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates + +from app.db import fetch_all, fetch_one +from app.services.marketing import evaluate_agent_rules + +router = APIRouter(prefix="/marketing", tags=["marketing"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +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() + return rows + + +@router.get("") +async def marketing_page(request: Request): + evaluate_agent_rules() + posts, mentions, accounts, rules, logs = [], [], [], [], [] + analytics = {"mention_count": 0, "avg_sentiment": 0} + try: + posts = _iso_rows(fetch_all( + """SELECT sp.*, sa.platform, sa.username FROM scheduled_posts sp + JOIN social_accounts sa ON sp.account_id = sa.id + ORDER BY sp.scheduled_time DESC LIMIT 50""" + )) + except Exception: + posts = [] + try: + mentions = _iso_rows(fetch_all( + "SELECT * FROM social_mentions ORDER BY created_at DESC LIMIT 50" + )) + row = fetch_one( + "SELECT COUNT(*) AS cnt, COALESCE(AVG(sentiment_score),0) AS avg FROM social_mentions" + ) + if row: + analytics["mention_count"] = int(row["cnt"]) + analytics["avg_sentiment"] = float(row["avg"]) + except Exception: + mentions = [] + try: + accounts = _iso_rows(fetch_all("SELECT * FROM social_accounts ORDER BY platform")) + except Exception: + accounts = [] + try: + rules = _iso_rows(fetch_all("SELECT * FROM agent_rules ORDER BY id")) + logs = _iso_rows(fetch_all( + """SELECT al.*, ar.name AS rule_name FROM agent_logs al + LEFT JOIN agent_rules ar ON al.rule_id = ar.id ORDER BY al.created_at DESC LIMIT 30""" + )) + except Exception: + pass + return templates.TemplateResponse( + "marketing.html", + { + "request": request, + "page_title": "Marketing", + "scheduled_posts": posts, + "social_mentions": mentions, + "accounts": accounts, + "agent_rules": rules, + "agent_logs": logs, + "analytics": analytics, + }, + ) diff --git a/cockpit/app/routes/marketing_api.py b/cockpit/app/routes/marketing_api.py new file mode 100644 index 0000000..7141ccb --- /dev/null +++ b/cockpit/app/routes/marketing_api.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +from pathlib import Path +from uuid import uuid4 + +from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile +from pydantic import BaseModel, Field + +from app.db import fetch_all, fetch_one +from app.services.social_publish import PLATFORMS, get_configured_channels, run_publish_job + +router = APIRouter(prefix="/api/marketing", tags=["marketing-api"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +UPLOAD_DIR = BASE_DIR / "static" / "uploads" / "marketing" + + +def _serialize_row(row: dict | None) -> dict | None: + if not row: + return None + out = dict(row) + for key, value in list(out.items()): + if hasattr(value, "isoformat"): + out[key] = value.isoformat() + return out + + +def _serialize_rows(rows: list[dict]) -> list[dict]: + return [_serialize_row(row) for row in rows] + + +class PublishRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=5000) + image_url: str | None = None + media_ids: list[int] = Field(default_factory=list) + channels: list[str] = Field(default_factory=list) + + +@router.post("/upload") +async def upload_marketing_media(file: UploadFile = File(...)) -> dict: + data = await file.read() + if not data: + raise HTTPException(status_code=400, detail="empty file") + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + ext = Path(file.filename or "upload.bin").suffix or ".bin" + filename = f"{uuid4().hex}{ext.lower()}" + path = UPLOAD_DIR / filename + path.write_bytes(data) + media_url = f"/static/uploads/marketing/{filename}" + + row = fetch_one( + """ + INSERT INTO marketing_media (filename, original_name, file_path, media_url, mime_type, size_bytes, created_at) + VALUES (%s, %s, %s, %s, %s, %s, NOW()) + RETURNING id + """, + ( + filename, + file.filename or filename, + str(path), + media_url, + file.content_type or "application/octet-stream", + len(data), + ), + ) + return {"media_id": row["id"], "url": media_url} + + +@router.post("/publish", status_code=202) +def create_publish_job(body: PublishRequest, background_tasks: BackgroundTasks) -> dict: + channels = [c.strip().lower() for c in body.channels if c.strip()] + invalid = [c for c in channels if c not in PLATFORMS] + if invalid: + raise HTTPException(status_code=400, detail=f"Unsupported channels: {', '.join(invalid)}") + if not channels: + channels = list(PLATFORMS) + + row = fetch_one( + """ + INSERT INTO social_publish_jobs (text, image_url, media_ids, channels, status, created_at, updated_at) + VALUES (%s, %s, %s::jsonb, %s::jsonb, %s, NOW(), NOW()) + RETURNING id + """, + ( + body.text, + body.image_url, + json.dumps(body.media_ids), + json.dumps(channels), + "queued", + ), + ) + job_id = int(row["id"]) + background_tasks.add_task(run_publish_job, job_id, body.text, channels, body.image_url, body.media_ids) + return {"job_id": job_id, "status": "queued", "channels": channels} + + +@router.get("/publish/{job_id}") +def get_publish_job(job_id: int) -> dict: + row = fetch_one("SELECT * FROM social_publish_jobs WHERE id = %s", (job_id,)) + if not row: + raise HTTPException(status_code=404, detail="Job not found") + return {"job": _serialize_row(row)} + + +@router.get("/publish/history") +def list_publish_history(limit: int = 50) -> dict: + safe_limit = max(1, min(limit, 200)) + rows = fetch_all( + "SELECT * FROM social_publish_jobs ORDER BY created_at DESC LIMIT %s", + (safe_limit,), + ) + return {"items": _serialize_rows(rows), "count": len(rows)} + + +@router.get("/channels") +def list_channels() -> dict: + items = get_configured_channels() + return {"items": items, "platforms": list(PLATFORMS)} diff --git a/cockpit/app/routes/monitor.py b/cockpit/app/routes/monitor.py new file mode 100644 index 0000000..a5cb836 --- /dev/null +++ b/cockpit/app/routes/monitor.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter +from fastapi.responses import RedirectResponse + +router = APIRouter(prefix="/monitor", tags=["monitor"]) + + +@router.get("") +async def monitor_page(): + return RedirectResponse(url="/browser#monitor", status_code=302) diff --git a/cockpit/app/routes/ops.py b/cockpit/app/routes/ops.py new file mode 100644 index 0000000..648720d --- /dev/null +++ b/cockpit/app/routes/ops.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates + +router = APIRouter(tags=["ops"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +@router.get("/ops") +async def ops_page(request: Request): + return templates.TemplateResponse( + "ops.html", + { + "request": request, + "page_title": "IT Ops", + }, + ) diff --git a/cockpit/app/routes/ops_api.py b/cockpit/app/routes/ops_api.py new file mode 100644 index 0000000..341559f --- /dev/null +++ b/cockpit/app/routes/ops_api.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import os + +import httpx +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse + +TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/") + +router = APIRouter(prefix="/api/ops", tags=["ops-api"]) + + +async def _proxy(method: str, path: str, request: Request) -> JSONResponse: + body = await request.body() + upstream = f"{TOOLS_API_URL}/ops{path}" + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.request( + method=method, + url=upstream, + params=dict(request.query_params), + content=body if body else None, + headers={"content-type": request.headers.get("content-type", "application/json")}, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail=f"tools-api unavailable: {exc}") from exc + + if resp.status_code >= 500: + raise HTTPException(status_code=502, detail=f"tools-api error {resp.status_code}") + + try: + payload = resp.json() + except Exception: # noqa: BLE001 + payload = {"raw": resp.text} + return JSONResponse(status_code=resp.status_code, content=payload) + + +@router.get("/status") +async def ops_status_proxy(request: Request): + return await _proxy("GET", "/status", request) + + +@router.get("/topology") +async def ops_topology_proxy(request: Request): + return await _proxy("GET", "/topology", request) + + +@router.post("/refresh") +async def ops_refresh_proxy(request: Request): + return await _proxy("POST", "/refresh", request) + + +@router.api_route("/{subpath:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) +async def ops_generic_proxy(subpath: str, request: Request): + return await _proxy(request.method, f"/{subpath}", request) diff --git a/cockpit/app/routes/packaging.py b/cockpit/app/routes/packaging.py new file mode 100644 index 0000000..3c2fd60 --- /dev/null +++ b/cockpit/app/routes/packaging.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from pathlib import Path + +import httpx +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import Response +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field + +from app.config import settings + +router = APIRouter(tags=["packaging"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +class PackagingGenerateBody(BaseModel): + type: str = Field(default="folding_box") + width_mm: float = Field(default=120, gt=0) + height_mm: float = Field(default=80, gt=0) + depth_mm: float = Field(default=40, ge=0) + elements: dict[str, bool] = Field(default_factory=dict) + brand: dict[str, str] = Field(default_factory=dict) + barcode_value: str | None = Field(default=None) + + +@router.get("/packaging") +async def packaging_page(request: Request): + return templates.TemplateResponse( + "packaging.html", + {"request": request, "page_title": "Packaging Studio"}, + ) + + +@router.post("/api/packaging/generate") +async def proxy_packaging_generate(body: PackagingGenerateBody): + try: + async with httpx.AsyncClient(timeout=60.0) as client: + r = await client.post( + f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/generate", + json=body.model_dump(), + ) + r.raise_for_status() + return r.json() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:400] if exc.response else str(exc) + raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.get("/api/packaging/projects") +async def proxy_packaging_projects(limit: int = 30): + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get( + f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/projects", + params={"limit": limit}, + ) + r.raise_for_status() + return r.json() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:400] if exc.response else str(exc) + raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.get("/api/packaging/download/{project_id}") +async def proxy_packaging_download(project_id: str, format: str = "svg"): + try: + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.get( + f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/download/{project_id}", + params={"format": format}, + ) + r.raise_for_status() + media = r.headers.get("content-type", "application/octet-stream") + disposition = r.headers.get("content-disposition") + headers = {"content-disposition": disposition} if disposition else {} + return Response(content=r.content, media_type=media, headers=headers) + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:400] if exc.response else str(exc) + raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc diff --git a/cockpit/app/routes/platform_live.py b/cockpit/app/routes/platform_live.py new file mode 100644 index 0000000..5f094e8 --- /dev/null +++ b/cockpit/app/routes/platform_live.py @@ -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(), + } diff --git a/cockpit/app/routes/products.py b/cockpit/app/routes/products.py new file mode 100644 index 0000000..a738f40 --- /dev/null +++ b/cockpit/app/routes/products.py @@ -0,0 +1,29 @@ +from pathlib import Path +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from app.db import fetch_all + +router = APIRouter(prefix="/products", tags=["products"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +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() + return rows + +@router.get("") +async def products_page(request: Request): + rows = [] + try: + rows = _iso_rows(fetch_all( + """SELECT p.id, p.name, p.status, p.margin_pct, p.moq, p.shelf_target, p.launch_date, p.created_at, + c.name AS client_name + FROM products p LEFT JOIN clients c ON c.id = p.client_id + ORDER BY p.created_at DESC LIMIT 200""" + )) + except Exception: + rows = [] + return templates.TemplateResponse("products.html", {"request": request, "page_title": "Products", "products": rows}) diff --git a/cockpit/app/routes/reco_proxy.py b/cockpit/app/routes/reco_proxy.py new file mode 100644 index 0000000..4d52ddf --- /dev/null +++ b/cockpit/app/routes/reco_proxy.py @@ -0,0 +1,39 @@ +"""Proxy recommendations to Tools API.""" +from __future__ import annotations + +import os + +import httpx +from fastapi import APIRouter + +admin_router = None # patched into admin_api + +TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/") + + +async def _proxy(method: str, path: str): + async with httpx.AsyncClient(timeout=60) as client: + r = await getattr(client, method.lower())(f"{TOOLS}{path}") + return r.json() + + +def register_recommendation_routes(router: APIRouter) -> None: + @router.get("/recommendations/pending") + async def reco_pending(): + return await _proxy("GET", "/recommendations/pending") + + @router.post("/recommendations/generate") + async def reco_generate(): + return await _proxy("POST", "/recommendations/generate") + + @router.post("/recommendations/{rec_id}/approve") + async def reco_approve(rec_id: int): + return await _proxy("POST", f"/recommendations/{rec_id}/approve") + + @router.post("/recommendations/{rec_id}/dismiss") + async def reco_dismiss(rec_id: int): + return await _proxy("POST", f"/recommendations/{rec_id}/dismiss") + + @router.post("/research/run") + async def research_run(): + return await _proxy("POST", "/research/run") diff --git a/cockpit/app/routes/reports.py b/cockpit/app/routes/reports.py new file mode 100644 index 0000000..46105e6 --- /dev/null +++ b/cockpit/app/routes/reports.py @@ -0,0 +1,78 @@ +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import JSONResponse, PlainTextResponse, Response +from fastapi.templating import Jinja2Templates + +from app.db import fetch_all +from app.services.reports_export import EXPORT_DATASETS, export_all_json, fetch_dataset, list_datasets, to_csv + +router = APIRouter(prefix="/reports", tags=["reports"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +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() + return rows + + +@router.get("") +async def reports_page(request: Request): + briefings, events = [], [] + try: + briefings = _iso_rows(fetch_all( + "SELECT id, content, generated_by, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 20" + )) + except Exception: + briefings = [] + try: + events = _iso_rows(fetch_all( + """SELECT id, agent_name, event_type, title, status, created_at FROM agent_events + WHERE event_type IN ('briefing','report','herman_chat') ORDER BY created_at DESC LIMIT 50""" + )) + except Exception: + events = [] + datasets = list_datasets() + return templates.TemplateResponse( + "reports.html", + { + "request": request, + "page_title": "Reports & Export", + "briefings": briefings, + "report_events": events, + "datasets": datasets, + }, + ) + + +@router.get("/api/datasets") +async def reports_datasets(): + return JSONResponse({"items": list_datasets()}) + + +@router.get("/api/export/{name}") +async def reports_export_one(name: str, format: str = Query("csv", pattern="^(csv|json)$"), limit: int = Query(10000, ge=1, le=50000)): + if name not in EXPORT_DATASETS: + raise HTTPException(404, "Dataset not found") + try: + rows = fetch_dataset(name, limit) + except Exception as exc: + raise HTTPException(500, str(exc)) from exc + if format == "json": + return JSONResponse({"dataset": name, "count": len(rows), "items": rows}) + csv_text = to_csv(rows) + return PlainTextResponse( + csv_text, + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="foodlinkk_{name}.csv"'}, + ) + + +@router.get("/api/export-all") +async def reports_export_all(format: str = Query("json", pattern="^(json)$"), limit: int = Query(3000, ge=100, le=10000)): + bundle = export_all_json(limit) + return JSONResponse(bundle, headers={"Content-Disposition": 'attachment; filename="foodlinkk_full_export.json"'}) diff --git a/cockpit/app/routes/retail.py b/cockpit/app/routes/retail.py new file mode 100644 index 0000000..8b5abd0 --- /dev/null +++ b/cockpit/app/routes/retail.py @@ -0,0 +1,333 @@ +"""Retail intelligence map page + API proxy.""" +from __future__ import annotations + +import os +from typing import Any, Optional + +import httpx +from fastapi import APIRouter, Query, Request +from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path +from pydantic import BaseModel + +router = APIRouter() +BASE = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE / "templates")) +TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/") + + +class CrmLinkBody(BaseModel): + client_id: int + deal_id: Optional[int] = None + relationship_type: str = "prospect" + partnership_status: Optional[str] = None + notes: Optional[str] = None + + +class NoteBody(BaseModel): + body: str + title: Optional[str] = None + note_type: str = "general" + + +class MilestoneBody(BaseModel): + title: str + milestone_type: str = "custom" + client_id: Optional[int] = None + deal_id: Optional[int] = None + target_date: Optional[str] = None + value_eur: Optional[float] = None + notes: Optional[str] = None + + +class OwnershipBody(BaseModel): + new_owner: str + previous_owner: Optional[str] = None + change_type: str = "acquisition" + effective_date: Optional[str] = None + source: Optional[str] = None + notes: Optional[str] = None + + +class CalendarBody(BaseModel): + title: str + starts_at: str + description: Optional[str] = None + ends_at: Optional[str] = None + client_id: Optional[int] = None + deal_id: Optional[int] = None + location: Optional[str] = None + + +class MediaBody(BaseModel): + filename: str + storage_path: str + content_type: str = "image/jpeg" + caption: Optional[str] = None + + +async def _tools_get(path: str, params: Optional[dict] = None) -> Any: + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.get(f"{TOOLS}{path}", params=params or {}) + resp.raise_for_status() + return resp.json() + + +async def _tools_post(path: str, params: Optional[dict] = None, json_body: Optional[dict] = None) -> Any: + async with httpx.AsyncClient(timeout=300) as client: + resp = await client.post(f"{TOOLS}{path}", params=params or {}, json=json_body) + resp.raise_for_status() + return resp.json() + + +async def _tools_delete(path: str) -> Any: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.delete(f"{TOOLS}{path}") + resp.raise_for_status() + return resp.json() + + +@router.get("/retail") +async def retail_page(request: Request): + stats = await _tools_get("/retail/stats") + filters = await _tools_get("/retail/filters") + schema = await _tools_get("/retail/schema") + trends = await _tools_get("/retail/trends", {"limit": 8}) + crm = await _tools_get("/retail/crm/options") + return templates.TemplateResponse( + "retail.html", + { + "request": request, + "stats": stats, + "filters": filters, + "schema": schema, + "trends": trends.get("items", []), + "crm_options": crm, + }, + ) + + +@router.get("/api/retail/stats") +async def api_retail_stats(request: Request): + return JSONResponse(await _tools_get("/retail/stats", dict(request.query_params))) + + +@router.get("/api/retail/filters") +async def api_retail_filters(): + return JSONResponse(await _tools_get("/retail/filters")) + + +@router.get("/api/retail/map") +async def api_retail_map(request: Request): + return JSONResponse(await _tools_get("/retail/map", dict(request.query_params))) + + +@router.get("/api/retail/list") +async def api_retail_list(request: Request): + return JSONResponse(await _tools_get("/retail/supermarkets", dict(request.query_params))) + + +@router.get("/api/retail/supermarkets/{store_id}") +async def api_retail_store(store_id: int): + return JSONResponse(await _tools_get(f"/retail/supermarkets/{store_id}")) + + +@router.get("/api/retail/opportunities") +async def api_retail_opportunities(request: Request): + return JSONResponse(await _tools_get("/retail/opportunities", dict(request.query_params))) + + +@router.get("/api/retail/trends") +async def api_retail_trends(request: Request): + return JSONResponse(await _tools_get("/retail/trends", dict(request.query_params))) + + +@router.get("/api/retail/crm/options") +async def api_crm_options(): + return JSONResponse(await _tools_get("/retail/crm/options")) + + +@router.post("/api/retail/supermarkets/{store_id}/link") +async def api_link_store(store_id: int, body: CrmLinkBody): + return JSONResponse(await _tools_post(f"/retail/supermarkets/{store_id}/link", json_body=body.model_dump())) + + +@router.post("/api/retail/enrich") +async def api_retail_enrich(limit: int = Query(100, ge=1, le=200)): + return JSONResponse(await _tools_post("/retail/enrich", params={"limit": limit})) + + +@router.post("/api/retail/sync/{action}") +async def api_retail_sync(action: str, limit: int = Query(100, ge=1, le=300)): + paths = { + "halal": "/retail/sync/halal", + "contacts": f"/retail/sync/contacts?limit={limit}", + "trends": "/retail/sync/trends", + "opportunities": "/retail/compute-opportunities", + } + if action not in paths: + return JSONResponse({"error": "unknown action"}, status_code=400) + return JSONResponse(await _tools_post(paths[action])) + + +@router.get("/api/retail/export") +async def api_retail_export(request: Request): + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.get(f"{TOOLS}/retail/export", params=dict(request.query_params)) + resp.raise_for_status() + return StreamingResponse( + iter([resp.text]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=retail_export.csv"}, + ) + + +# --- 360 workspace proxies --- + +@router.get("/api/retail/360/{store_id}") +async def api_retail_360(store_id: int): + return JSONResponse(await _tools_get(f"/retail/360/{store_id}")) + + +@router.post("/api/retail/360/{store_id}/notes") +async def api_retail_note(store_id: int, body: NoteBody): + return JSONResponse(await _tools_post(f"/retail/360/{store_id}/notes", json_body=body.model_dump())) + + +@router.post("/api/retail/360/{store_id}/milestones") +async def api_retail_milestone(store_id: int, body: MilestoneBody): + return JSONResponse(await _tools_post(f"/retail/360/{store_id}/milestones", json_body=body.model_dump())) + + +@router.post("/api/retail/360/{store_id}/ownership") +async def api_retail_ownership(store_id: int, body: OwnershipBody): + return JSONResponse(await _tools_post(f"/retail/360/{store_id}/ownership", json_body=body.model_dump())) + + +@router.post("/api/retail/360/{store_id}/calendar") +async def api_retail_calendar(store_id: int, body: CalendarBody): + return JSONResponse(await _tools_post(f"/retail/360/{store_id}/calendar", json_body=body.model_dump())) + + +@router.post("/api/retail/360/{store_id}/media") +async def api_retail_media(store_id: int, body: MediaBody): + return JSONResponse(await _tools_post(f"/retail/360/{store_id}/media", json_body=body.model_dump())) + + +@router.get("/api/retail/cities") +async def api_retail_cities(request: Request): + return JSONResponse(await _tools_get("/retail/cities", dict(request.query_params))) + + +@router.post("/api/retail/cities/sync") +async def api_retail_cities_sync(limit: int = Query(50, ge=1, le=200)): + return JSONResponse(await _tools_post("/retail/cities/sync", params={"limit": limit})) + + +@router.get("/api/retail/wholesalers") +async def api_retail_wholesalers(request: Request): + return JSONResponse(await _tools_get("/retail/wholesalers", dict(request.query_params))) + + +@router.post("/api/retail/wholesalers/import") +async def api_retail_wholesalers_import(): + return JSONResponse(await _tools_post("/retail/wholesalers/import")) + + +@router.get("/api/retail/rss/live") +async def api_retail_rss(request: Request): + return JSONResponse(await _tools_get("/retail/rss/live", dict(request.query_params))) + + +@router.post("/api/retail/rss/refresh") +async def api_retail_rss_refresh(): + return JSONResponse(await _tools_post("/retail/rss/refresh")) + + +@router.get("/api/retail/rss/bookmarks") +async def api_rss_bookmarks(request: Request): + return JSONResponse(await _tools_get("/retail/rss/bookmarks", dict(request.query_params))) + + +@router.post("/api/retail/rss/bookmarks") +async def api_rss_bookmark_add(request: Request): + body = await request.json() + return JSONResponse(await _tools_post("/retail/rss/bookmarks", json_body=body)) + + +@router.delete("/api/retail/rss/bookmarks/{rss_item_id}") +async def api_rss_bookmark_delete(rss_item_id: int): + return JSONResponse(await _tools_delete(f"/retail/rss/bookmarks/{rss_item_id}")) + + +@router.get("/api/retail/wholesalers/meta") +async def api_wholesalers_meta(): + return JSONResponse(await _tools_get("/retail/wholesalers/meta")) + + +@router.get("/api/retail/wholesalers/{wh_id}/contacts") +async def api_wholesaler_contacts(wh_id: int): + return JSONResponse(await _tools_get(f"/retail/wholesalers/{wh_id}/contacts")) + + +@router.post("/api/retail/wholesalers/{wh_id}/contacts") +async def api_wholesaler_contact_add(wh_id: int, request: Request): + body = await request.json() + return JSONResponse(await _tools_post(f"/retail/wholesalers/{wh_id}/contacts", json_body=body)) + + +@router.get("/api/retail/promo-campaigns") +async def api_promo_campaigns(request: Request): + return JSONResponse(await _tools_get("/retail/promo-campaigns", dict(request.query_params))) + + +@router.post("/api/retail/promo-campaigns") +async def api_promo_campaign_add(request: Request): + body = await request.json() + return JSONResponse(await _tools_post("/retail/promo-campaigns", json_body=body)) + + +@router.post("/api/retail/reclamefolder/refresh") +async def api_reclamefolder_refresh(): + return JSONResponse(await _tools_post("/retail/reclamefolder/refresh", json_body={})) + + +@router.get("/api/retail/reclamefolder/live") +async def api_reclamefolder_live(request: Request): + return JSONResponse(await _tools_get("/retail/reclamefolder/live", dict(request.query_params))) + + +@router.get("/api/retail/reclamefolder/chains") +async def api_reclamefolder_chains(): + return JSONResponse(await _tools_get("/retail/reclamefolder/chains")) + + +@router.get("/api/retail/market/supermarkets") +async def api_supermarket_market(): + return JSONResponse(await _tools_get("/retail/market/supermarkets")) + + +@router.get("/api/retail/market/food-trends") +async def api_food_trends(): + return JSONResponse(await _tools_get("/retail/market/food-trends")) + + +@router.get("/api/retail/market/concepts") +async def api_market_concepts(request: Request): + return JSONResponse(await _tools_get("/retail/market/concepts", dict(request.query_params))) + + +@router.get("/api/retail/live-dashboard") +async def api_retail_live_dashboard(): + return JSONResponse(await _tools_get("/retail/live-dashboard")) + + +@router.get("/api/retail/market/stocks") +async def api_retail_market_stocks(): + return JSONResponse(await _tools_get("/retail/market/stocks")) + + +@router.get("/api/retail/regulations") +async def api_retail_regulations(request: Request): + return JSONResponse(await _tools_get("/retail/regulations", dict(request.query_params))) diff --git a/cockpit/app/routes/settings.py b/cockpit/app/routes/settings.py new file mode 100644 index 0000000..3db9618 --- /dev/null +++ b/cockpit/app/routes/settings.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from pathlib import Path + +router = APIRouter(tags=["settings"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +@router.get("/settings") +async def settings_page(request: Request, tab: str = "email"): + allowed = ("email", "general", "permissions", "social") + return templates.TemplateResponse( + "settings.html", + { + "request": request, + "page_title": "Settings", + "active_tab": tab if tab in allowed else "email", + }, + ) diff --git a/cockpit/app/routes/settings_api.py b/cockpit/app/routes/settings_api.py new file mode 100644 index 0000000..4faf92e --- /dev/null +++ b/cockpit/app/routes/settings_api.py @@ -0,0 +1,383 @@ +"""Settings API — email accounts stored in PostgreSQL.""" + +from __future__ import annotations + +import smtplib +import json +from datetime import datetime +from email.mime.text import MIMEText +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app.db import execute, fetch_all, fetch_one +from app.services import agent_souls +from app.services.social_publish import PLATFORMS, get_integration, test_connection + +settings_router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +class EmailAccountBody(BaseModel): + label: str = Field(..., max_length=128) + email_address: str = Field(..., max_length=255) + provider: str = Field(default="custom", max_length=32) + is_active: bool = False + smtp_host: Optional[str] = None + smtp_port: int = 587 + smtp_user: Optional[str] = None + smtp_password: Optional[str] = None + imap_host: Optional[str] = None + imap_port: int = 993 + imap_user: Optional[str] = None + imap_password: Optional[str] = None + sync_enabled: bool = False + + +def _mask_account(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + out = dict(row) + for k, v in list(out.items()): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + out["smtp_password_set"] = bool(row.get("smtp_password")) + out["imap_password_set"] = bool(row.get("imap_password")) + out.pop("smtp_password", None) + out.pop("imap_password", None) + return out + + +def _deactivate_all() -> None: + execute("UPDATE email_accounts SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE") + + +def _test_smtp_config( + smtp_host: str, + smtp_port: int, + smtp_user: str, + smtp_pass: str, + from_addr: str, +) -> tuple[bool, str]: + if not smtp_host or not from_addr: + return False, "SMTP host en from-adres zijn verplicht" + try: + msg = MIMEText("Foodlinkk SMTP test — Herman email settings OK.", "plain", "utf-8") + msg["Subject"] = "Foodlinkk test email" + msg["From"] = from_addr + msg["To"] = from_addr + with smtplib.SMTP(smtp_host, smtp_port, timeout=25) as server: + server.ehlo() + if smtp_port == 587: + server.starttls() + if smtp_user and smtp_pass: + server.login(smtp_user, smtp_pass) + server.sendmail(from_addr, [from_addr], msg.as_string()) + return True, f"Testmail verstuurd naar {from_addr}" + except Exception as exc: + return False, str(exc) + + +def _resolve_password(new: Optional[str], existing: Optional[str]) -> Optional[str]: + if new is not None and new != "": + return new + return existing + + +SOCIAL_PLATFORM_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"), +} + +SOCIAL_SECRET_FIELDS = {"api_secret", "access_secret", "access_token", "api_key"} + + +class SocialIntegrationBody(BaseModel): + api_key: Optional[str] = None + api_secret: Optional[str] = None + access_token: Optional[str] = None + access_secret: Optional[str] = None + person_urn: Optional[str] = None + page_id: Optional[str] = None + open_id: Optional[str] = None + board_id: Optional[str] = None + is_active: Optional[bool] = None + + +def _mask_social_row(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + out = dict(row) + for k, v in list(out.items()): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + config = out.get("config") or {} + if isinstance(config, str): + try: + config = json.loads(config) + except Exception: + config = {} + if not isinstance(config, dict): + config = {} + masked = dict(config) + for key in SOCIAL_SECRET_FIELDS: + if key in config: + masked[f"{key}_set"] = bool(config.get(key)) + masked.pop(key, None) + out["config"] = masked + return out + + +def _normalize_social_platform(platform: str) -> str: + value = (platform or "").strip().lower() + if value not in PLATFORMS: + raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}") + return value + + +@settings_router.get("/email") +def list_email_accounts() -> dict[str, Any]: + try: + rows = fetch_all("SELECT * FROM email_accounts ORDER BY is_active DESC, updated_at DESC") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"accounts": [_mask_account(r) for r in rows]} + + +@settings_router.post("/email") +def create_email_account(body: EmailAccountBody) -> dict[str, Any]: + if body.is_active: + _deactivate_all() + try: + row = fetch_one( + """ + INSERT INTO email_accounts ( + label, email_address, provider, is_active, + smtp_host, smtp_port, smtp_user, smtp_password, + imap_host, imap_port, imap_user, imap_password, sync_enabled + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING * + """, + ( + body.label, + body.email_address, + body.provider, + body.is_active, + body.smtp_host, + body.smtp_port, + body.smtp_user or body.email_address, + body.smtp_password or "", + body.imap_host, + body.imap_port, + body.imap_user or body.email_address, + body.imap_password or "", + body.sync_enabled, + ), + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"ok": True, "account": _mask_account(row)} + + +@settings_router.put("/email/{account_id}") +def update_email_account(account_id: int, body: EmailAccountBody) -> dict[str, Any]: + existing = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,)) + if not existing: + raise HTTPException(status_code=404, detail="Account not found") + if body.is_active: + _deactivate_all() + smtp_pass = _resolve_password(body.smtp_password, existing.get("smtp_password")) + imap_pass = _resolve_password(body.imap_password, existing.get("imap_password")) + try: + row = fetch_one( + """ + UPDATE email_accounts SET + label=%s, email_address=%s, provider=%s, is_active=%s, + smtp_host=%s, smtp_port=%s, smtp_user=%s, smtp_password=%s, + imap_host=%s, imap_port=%s, imap_user=%s, imap_password=%s, + sync_enabled=%s, updated_at=NOW() + WHERE id=%s RETURNING * + """, + ( + body.label, + body.email_address, + body.provider, + body.is_active, + body.smtp_host, + body.smtp_port, + body.smtp_user or body.email_address, + smtp_pass, + body.imap_host, + body.imap_port, + body.imap_user or body.email_address, + imap_pass, + body.sync_enabled, + account_id, + ), + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"ok": True, "account": _mask_account(row)} + + +@settings_router.delete("/email/{account_id}") +def delete_email_account(account_id: int) -> dict[str, Any]: + execute("DELETE FROM email_accounts WHERE id = %s", (account_id,)) + return {"ok": True} + + +@settings_router.post("/email/{account_id}/activate") +def activate_email_account(account_id: int) -> dict[str, Any]: + existing = fetch_one("SELECT id FROM email_accounts WHERE id = %s", (account_id,)) + if not existing: + raise HTTPException(status_code=404, detail="Account not found") + _deactivate_all() + row = fetch_one( + "UPDATE email_accounts SET is_active=TRUE, updated_at=NOW() WHERE id=%s RETURNING *", + (account_id,), + ) + return {"ok": True, "account": _mask_account(row)} + + +@settings_router.post("/email/{account_id}/test") +def test_saved_email_account(account_id: int) -> dict[str, Any]: + row = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,)) + if not row: + raise HTTPException(status_code=404, detail="Account not found") + ok, message = _test_smtp_config( + row.get("smtp_host") or "", + int(row.get("smtp_port") or 587), + row.get("smtp_user") or row.get("email_address") or "", + row.get("smtp_password") or "", + row.get("email_address") or row.get("smtp_user") or "", + ) + status = "ok" if ok else "failed" + execute( + """ + UPDATE email_accounts SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW() + WHERE id=%s + """, + (status, message[:500], account_id), + ) + return {"ok": ok, "message": message} + + +@settings_router.post("/email/test") +def test_email_config(body: EmailAccountBody) -> dict[str, Any]: + """Test SMTP without saving (form preview).""" + if not body.smtp_password: + raise HTTPException(status_code=400, detail="SMTP wachtwoord is verplicht voor test zonder opgeslagen account") + ok, message = _test_smtp_config( + body.smtp_host or "", + body.smtp_port, + body.smtp_user or body.email_address, + body.smtp_password, + body.email_address, + ) + return {"ok": ok, "message": message} + + +@settings_router.get("/social") +def list_social_integrations() -> dict[str, Any]: + rows = fetch_all("SELECT * FROM social_integrations ORDER BY platform") + return {"items": [_mask_social_row(r) for r in rows], "platforms": list(PLATFORMS)} + + +@settings_router.put("/social/{platform}") +def save_social_integration(platform: str, body: SocialIntegrationBody) -> dict[str, Any]: + platform = _normalize_social_platform(platform) + allowed_fields = set(SOCIAL_PLATFORM_FIELDS[platform]) + incoming = body.model_dump(exclude_none=True) + existing = fetch_one("SELECT * FROM social_integrations WHERE platform = %s", (platform,)) + + existing_config = {} + if existing: + existing_config = existing.get("config") or {} + if isinstance(existing_config, str): + try: + existing_config = json.loads(existing_config) + except Exception: + existing_config = {} + if not isinstance(existing_config, dict): + existing_config = {} + + config = dict(existing_config) + for field in allowed_fields: + if field not in incoming: + continue + value = incoming.get(field) + if field in SOCIAL_SECRET_FIELDS: + if value is not None and value != "": + config[field] = value + else: + config[field] = value + + is_active = body.is_active + if is_active is None: + is_active = bool(existing.get("is_active")) if existing else True + + row = fetch_one( + """ + INSERT INTO social_integrations (platform, config, is_active, updated_at) + VALUES (%s, %s::jsonb, %s, NOW()) + ON CONFLICT (platform) DO UPDATE SET + config = EXCLUDED.config, + is_active = EXCLUDED.is_active, + updated_at = NOW() + RETURNING * + """, + (platform, json.dumps(config), is_active), + ) + return {"ok": True, "integration": _mask_social_row(row)} + + +@settings_router.post("/social/{platform}/test") +def test_social_integration(platform: str) -> dict[str, Any]: + platform = _normalize_social_platform(platform) + integration = get_integration(platform) + if not integration: + raise HTTPException(status_code=404, detail="Integration not configured") + result = test_connection(platform, integration) + status = "ok" if result.get("ok") else "failed" + message = (result.get("message") or result.get("error") or "")[:500] + try: + execute( + """ + UPDATE social_integrations + SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW() + WHERE platform=%s + """, + (status, message, platform), + ) + except Exception: + pass + return {"platform": platform, **result} + + +class PermissionBody(BaseModel): + granted: bool + + +@settings_router.get("/permissions") +def list_permissions() -> dict[str, Any]: + items = agent_souls.list_permissions() + granted = sum(1 for i in items if i.get("granted")) + return {"items": items, "granted_count": granted, "total": len(items)} + + +@settings_router.put("/permissions/{module_key}") +def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]: + row = agent_souls.update_permission(module_key, body.granted) + if not row: + raise HTTPException(404, "Module not found") + return {"permission": row} + + +@settings_router.post("/permissions/grant-all") +def grant_all_permissions() -> dict[str, Any]: + n = agent_souls.grant_all_permissions() + return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"} diff --git a/cockpit/app/routes/studio.py b/cockpit/app/routes/studio.py new file mode 100644 index 0000000..c31cda9 --- /dev/null +++ b/cockpit/app/routes/studio.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates + +router = APIRouter(prefix="/studio", tags=["studio"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +@router.get("") +async def studio_page(request: Request): + return templates.TemplateResponse( + "studio.html", + {"request": request, "page_title": "AI Studio"}, + ) diff --git a/cockpit/app/routes/suppliers.py b/cockpit/app/routes/suppliers.py new file mode 100644 index 0000000..a6083a9 --- /dev/null +++ b/cockpit/app/routes/suppliers.py @@ -0,0 +1,27 @@ +from pathlib import Path +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from app.db import fetch_all + +router = APIRouter(prefix="/suppliers", tags=["suppliers"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +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() + return rows + +@router.get("") +async def suppliers_page(request: Request): + rows = [] + try: + rows = _iso_rows(fetch_all( + """SELECT id, name, country, category, moq, lead_time_days, rating, contact, created_at + FROM suppliers ORDER BY rating DESC NULLS LAST, name ASC LIMIT 200""" + )) + except Exception: + rows = [] + return templates.TemplateResponse("suppliers.html", {"request": request, "page_title": "Suppliers", "suppliers": rows}) diff --git a/cockpit/app/routes/voice.py b/cockpit/app/routes/voice.py new file mode 100644 index 0000000..2af33d1 --- /dev/null +++ b/cockpit/app/routes/voice.py @@ -0,0 +1,27 @@ +from pathlib import Path +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from app.db import fetch_all + +router = APIRouter(prefix="/voice", tags=["voice"]) +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +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() + return rows + +@router.get("") +async def voice_page(request: Request): + events = [] + try: + events = _iso_rows(fetch_all( + """SELECT id, agent_name, title, body, status, created_at FROM agent_events + WHERE channel = 'voice' OR event_type LIKE 'voice%%' ORDER BY created_at DESC LIMIT 25""" + )) + except Exception: + events = [] + return templates.TemplateResponse("voice.html", {"request": request, "page_title": "Voice", "voice_events": events}) diff --git a/cockpit/app/services/__init__.py b/cockpit/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cockpit/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/cockpit/app/services/agent_souls.py b/cockpit/app/services/agent_souls.py new file mode 100644 index 0000000..26ed20e --- /dev/null +++ b/cockpit/app/services/agent_souls.py @@ -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) diff --git a/cockpit/app/services/analytics_data.py b/cockpit/app/services/analytics_data.py new file mode 100644 index 0000000..d9d99b2 --- /dev/null +++ b/cockpit/app/services/analytics_data.py @@ -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 diff --git a/cockpit/app/services/briefing.py b/cockpit/app/services/briefing.py new file mode 100644 index 0000000..d459859 --- /dev/null +++ b/cockpit/app/services/briefing.py @@ -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) diff --git a/cockpit/app/services/herman.py b/cockpit/app/services/herman.py new file mode 100644 index 0000000..0cafe8b --- /dev/null +++ b/cockpit/app/services/herman.py @@ -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 \ No newline at end of file diff --git a/cockpit/app/services/market_stocks.py b/cockpit/app/services/market_stocks.py new file mode 100644 index 0000000..16a5d20 --- /dev/null +++ b/cockpit/app/services/market_stocks.py @@ -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), + } diff --git a/cockpit/app/services/marketing.py b/cockpit/app/services/marketing.py new file mode 100644 index 0000000..d387e49 --- /dev/null +++ b/cockpit/app/services/marketing.py @@ -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), + ) diff --git a/cockpit/app/services/monitor.py b/cockpit/app/services/monitor.py new file mode 100644 index 0000000..6d5ec09 --- /dev/null +++ b/cockpit/app/services/monitor.py @@ -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)} diff --git a/cockpit/app/services/ollama.py b/cockpit/app/services/ollama.py new file mode 100644 index 0000000..e5bef96 --- /dev/null +++ b/cockpit/app/services/ollama.py @@ -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 "" diff --git a/cockpit/app/services/platform_live.py b/cockpit/app/services/platform_live.py new file mode 100644 index 0000000..5f094e8 --- /dev/null +++ b/cockpit/app/services/platform_live.py @@ -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(), + } diff --git a/cockpit/app/services/reports_export.py b/cockpit/app/services/reports_export.py new file mode 100644 index 0000000..2ea9d71 --- /dev/null +++ b/cockpit/app/services/reports_export.py @@ -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 diff --git a/cockpit/app/services/social_publish.py b/cockpit/app/services/social_publish.py new file mode 100644 index 0000000..0b6ec00 --- /dev/null +++ b/cockpit/app/services/social_publish.py @@ -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}, + ) diff --git a/cockpit/herman.py b/cockpit/herman.py new file mode 100644 index 0000000..0cafe8b --- /dev/null +++ b/cockpit/herman.py @@ -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 \ No newline at end of file diff --git a/cockpit/hermes.css b/cockpit/hermes.css new file mode 100644 index 0000000..a4a98ba --- /dev/null +++ b/cockpit/hermes.css @@ -0,0 +1,218 @@ +/* Hermes Neo Command Center — network monitor aesthetic */ +.hm-neo { --hm-cyan: #00e5ff; --hm-mag: #ff2d95; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); } + +.hm-neo-header h1 { font-size: 1.65rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; } +.hm-neo-header .hm-sub { color: var(--hm-cyan); font-size: 0.85rem; margin: 0.35rem 0 0; opacity: 0.85; } + +/* ── KPI cards (neo style) ── */ +.hm-neo-kpi-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin: 1.25rem 0; +} +@media (max-width: 1100px) { .hm-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 560px) { .hm-neo-kpi-row { grid-template-columns: 1fr; } } + +.hm-neo-kpi { + position: relative; + background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98)); + border: 1px solid var(--hm-border); + border-radius: 14px; + padding: 1rem 1.1rem 0.75rem; + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; +} +.hm-neo-kpi:hover { border-color: rgba(0, 229, 255, 0.35); box-shadow: 0 0 24px rgba(0, 229, 255, 0.08); } +.hm-neo-kpi.highlight { border-color: var(--hm-gold); box-shadow: 0 0 20px rgba(255, 215, 0, 0.12); } + +.hm-neo-kpi-top { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } +.hm-neo-ring { + width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; + background: conic-gradient(var(--ring-color, var(--hm-cyan)) var(--ring-pct, 75%), rgba(255,255,255,0.06) 0); + display: grid; place-items: center; + box-shadow: 0 0 12px color-mix(in srgb, var(--ring-color, var(--hm-cyan)) 40%, transparent); +} +.hm-neo-ring-inner { + width: 32px; height: 32px; border-radius: 50%; + background: var(--hm-panel); display: grid; place-items: center; + font-size: 1rem; +} +.hm-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.14em; text-transform: uppercase; color: #64748b; } +.hm-neo-kpi-value { font-size: 2rem; font-weight: 700; line-height: 1.1; color: #f1f5f9; font-variant-numeric: tabular-nums; } +.hm-neo-kpi-sub { font-size: 0.72rem; color: #64748b; margin-top: 0.15rem; } + +/* Animated equalizer bars */ +.hm-eq { + display: flex; align-items: flex-end; justify-content: center; gap: 3px; + height: 28px; margin-top: 0.65rem; padding-top: 0.35rem; + border-top: 1px solid rgba(255,255,255,0.04); +} +.hm-eq span { + width: 5px; border-radius: 2px; + background: linear-gradient(to top, rgba(0,229,255,0.3), var(--hm-cyan)); + animation: hmEq 0.55s ease-in-out infinite alternate; + box-shadow: 0 0 6px rgba(0, 229, 255, 0.35); +} +.hm-eq span:nth-child(1) { animation-delay: 0s; } +.hm-eq span:nth-child(2) { animation-delay: 0.08s; } +.hm-eq span:nth-child(3) { animation-delay: 0.16s; } +.hm-eq span:nth-child(4) { animation-delay: 0.24s; } +.hm-eq span:nth-child(5) { animation-delay: 0.32s; } +.hm-eq span:nth-child(6) { animation-delay: 0.12s; } +.hm-eq span:nth-child(7) { animation-delay: 0.2s; } +.hm-eq span:nth-child(8) { animation-delay: 0.28s; } +@keyframes hmEq { + from { height: 18%; opacity: 0.45; } + to { height: 92%; opacity: 1; } +} + +/* ── Chart panels ── */ +.hm-neo-charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1.25rem; +} +@media (max-width: 900px) { .hm-neo-charts { grid-template-columns: 1fr; } } + +.hm-chart-panel { + background: var(--hm-panel); + border: 1px solid var(--hm-border); + border-radius: 14px; + padding: 1rem 1.1rem; + min-height: 280px; +} +.hm-chart-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 0.75rem; +} +.hm-chart-head h3 { + margin: 0; font-size: 0.7rem; letter-spacing: 0.12em; + text-transform: uppercase; color: #94a3b8; font-weight: 600; +} +.hm-chart-head .hm-live-dot { + width: 8px; height: 8px; border-radius: 50%; background: #22c55e; + box-shadow: 0 0 8px #22c55e; animation: hmPulse 2s infinite; +} +@keyframes hmPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +.hm-chart-head small { color: #475569; font-size: 0.7rem; } +.hm-chart-wrap { position: relative; height: 220px; } + +/* ── Tabs ── */ +.hermes-tabs { + display: flex; flex-wrap: wrap; gap: 0.55rem; + margin: 0 0 1rem; padding: 0.45rem; + background: rgba(8, 12, 18, 0.8); + border: 1px solid var(--hm-border); + border-radius: 12px; +} +.hermes-tab { + flex: 1 1 auto; min-width: 120px; + padding: 0.8rem 1.1rem; font-size: 0.88rem; font-weight: 600; + border-radius: 10px; cursor: pointer; + border: 1px solid rgba(100, 116, 139, 0.25); + background: rgba(15, 23, 42, 0.7); color: #94a3b8; + transition: all 0.15s; +} +.hermes-tab:hover { border-color: rgba(0, 229, 255, 0.4); color: #e2e8f0; } +.hermes-tab.active { + background: linear-gradient(135deg, rgba(0, 229, 255, 0.12), rgba(255, 215, 0, 0.08)); + border-color: var(--hm-gold); color: #fef9c3; + box-shadow: 0 0 18px rgba(255, 215, 0, 0.15); +} + +/* ── Team roster (no duplicate names) ── */ +.hm-team { margin-bottom: 1rem; } +.hm-team h3 { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #64748b; margin: 0 0 0.6rem; } +.hm-team-card { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.65rem 0.85rem; margin-bottom: 0.45rem; + background: rgba(13, 18, 25, 0.9); + border: 1px solid var(--hm-border); + border-radius: 10px; +} +.hm-team-card.online { border-left: 3px solid #22c55e; } +.hm-team-card .hm-role-badge { + min-width: 52px; text-align: center; + font-size: 0.65rem; font-weight: 800; letter-spacing: 0.08em; + padding: 0.25rem 0.4rem; border-radius: 6px; +} +.hm-team-card .hm-role-ceo { background: rgba(255, 215, 0, 0.15); color: var(--hm-gold); border: 1px solid rgba(255,215,0,0.3); } +.hm-team-card .hm-role-cto { background: rgba(0, 229, 255, 0.12); color: var(--hm-cyan); border: 1px solid rgba(0,229,255,0.25); } +.hm-team-card .hm-person { flex: 1; } +.hm-team-card .hm-person strong { display: block; font-size: 0.95rem; color: #f1f5f9; } +.hm-team-card .hm-person small { color: #64748b; font-size: 0.72rem; } +.hm-team-card .hm-status-dot { width: 10px; height: 10px; border-radius: 50%; } +.hm-team-card .hm-status-dot.on { background: #22c55e; box-shadow: 0 0 8px #22c55e; } +.hm-team-card .hm-status-dot.off { background: #475569; } + +.hermes-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; } +@media (max-width: 900px) { .hermes-grid { grid-template-columns: 1fr; } } +.hermes-sidebar { max-height: 72vh; overflow-y: auto; } +.hermes-conv-btn { + display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; + margin: 0.3rem 0; padding: 0.5rem 0.65rem; + background: rgba(13, 18, 25, 0.8); border: 1px solid var(--hm-border); + border-radius: 8px; color: #e2e8f0; cursor: pointer; +} +.hermes-conv-btn.active { border-color: var(--hm-cyan); box-shadow: 0 0 0 1px var(--hm-cyan); } +.hermes-conv-btn .conv-role { font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.35rem; border-radius: 4px; } + +.hermes-feed { max-height: 72vh; display: flex; flex-direction: column; } +.hermes-messages { overflow-y: auto; flex: 1; padding: 0.5rem; } +.hermes-msg { + margin-bottom: 0.65rem; padding: 0.6rem 0.8rem; border-radius: 8px; + border-left: 3px solid rgba(100,116,139,0.4); + background: rgba(13, 18, 25, 0.6); +} +.hermes-msg.msg-in { border-left-color: var(--hm-cyan); background: rgba(0, 229, 255, 0.04); } +.hermes-msg.msg-out { border-left-color: var(--hm-purple); background: rgba(168, 85, 247, 0.05); } +.hermes-msg-meta { font-size: 0.72rem; color: #64748b; display: flex; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 0.3rem; } +.hermes-msg-body { white-space: pre-wrap; word-break: break-word; font-size: 0.88rem; } + +/* PA live */ +.hermes-pa-header { + display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; + margin-bottom: 1rem; padding: 0.75rem 1rem; + background: rgba(0, 229, 255, 0.05); border: 1px solid var(--hm-border); border-radius: 10px; +} +.hermes-pa-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; } +@media (max-width: 900px) { .hermes-pa-grid { grid-template-columns: 1fr; } } +.hermes-pa-slot { border: 1px solid var(--hm-border); border-radius: 10px; overflow: hidden; background: var(--hm-panel); } +.hermes-pa-slot.status-loading { border-color: var(--hm-cyan); } +.hermes-pa-slot.status-completed { border-color: #22c55e; } +.hermes-pa-slot.status-failed { border-color: #ef4444; } +.hermes-pa-slot-head { display: flex; justify-content: space-between; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.3); } +.hermes-pa-slot-body { height: 200px; background: #060a10; display: grid; place-items: center; } +.hermes-pa-slot-body img { width: 100%; height: 100%; object-fit: cover; object-position: top; } +.hermes-pa-placeholder { color: #64748b; font-size: 0.82rem; text-align: center; padding: 1rem; } +.hermes-pa-slot-foot { padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.status-badge { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; padding: 0.15rem 0.4rem; border-radius: 4px; } +.status-badge.loading { background: rgba(0,229,255,0.15); color: var(--hm-cyan); } +.status-badge.completed { background: rgba(34,197,94,0.15); color: #22c55e; } +.status-badge.failed { background: rgba(239,68,68,0.12); color: #ef4444; } +.status-badge.waiting, .status-badge.idle { background: rgba(100,116,139,0.15); color: #94a3b8; } +.status-badge.running, .status-badge.comparing { background: rgba(255,159,67,0.15); color: var(--hm-orange); } + +.hermes-graph-canvas { height: 480px; border: 1px solid var(--hm-border); border-radius: 10px; background: #060a10; } +.hermes-search-bar { display: flex; gap: 0.5rem; margin: 1rem 0; } +.hermes-search-bar .form-input { flex: 1; } +.hermes-search-hit { padding: 0.65rem; border-bottom: 1px solid var(--hm-border); } +.hermes-control-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; } +.hermes-info-list { list-style: none; padding: 0; } +.hermes-tags { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; } +.tag { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.78rem; } +.tag-ok { background: rgba(34,197,94,0.15); color: #22c55e; } +.tag-block { background: rgba(239,68,68,0.12); color: #ef4444; } +.tag-pa { background: rgba(168,85,247,0.15); color: var(--hm-purple); } +.hermes-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; } +.hermes-events { max-height: 360px; overflow-y: auto; } +.hermes-event { padding: 0.45rem 0; border-bottom: 1px solid var(--hm-border); } +.panel-header { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; margin-bottom: 0.65rem; } +.hermes-select { max-width: 200px; } +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; } +.dot-in { background: var(--hm-cyan); } +.dot-out { background: var(--hm-purple); } +.dot-edge { background: var(--hm-orange); } diff --git a/cockpit/hermes.html b/cockpit/hermes.html new file mode 100644 index 0000000..6753a1e --- /dev/null +++ b/cockpit/hermes.html @@ -0,0 +1,244 @@ +{% extends "base.html" %} +{% block title %}Hermes · Telegram Command Center{% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
💬
+
Conversaties
Telegram chats
+
+
0
+
+
+
+
+
📨
+
Berichten
in + out
+
+
0
+
+
+
+
+
🕸
+
Relaties
graph edges
+
+
0
+
+
+
+
+
🧠
+
Vectors
pgvector
+
+
0
+
+
+
+ + +
+
+
+

Berichten per richting

+ klik voor detail +
+
+
+
+
+

Agent activiteit

+ laatste events +
+
+
+
+ +
+ + + + + +
+ + +
+ + +
+
+

Telegram live feed

+ + +
+
+ +

Nog geen berichten — stuur iets via @klaploper_bot

+
+
+
+ + +
+
+
+ Personal PA — 4 site browsers +

+
+ + + +
+
+ +
+
+ + +
+
+

Second brain — message graph

+ + +
+
+
+ + +
+

Vector + full-text memory search

+ +
+ +
+
+ + +
+
+

Hermes status

+
    +
  • Bot: @klaploper_bot
  • +
  • CEO: Aïssa · CTO: Mo
  • +
  • Control API:
  • +
  • Inbound: · Outbound:
  • +
+
+ + + +
+

+
+
+

Team

+ +
+
+

Agent events

+ +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/cockpit/patch_admin.py b/cockpit/patch_admin.py new file mode 100644 index 0000000..ffcabee --- /dev/null +++ b/cockpit/patch_admin.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from pathlib import Path + +p = Path("/home/aissa/foodlinkk-command-center/cockpit/app/routes/admin_api.py") +text = p.read_text() +if "reco_proxy" not in text: + text = "from app.routes.reco_proxy import register_recommendation_routes\n" + text +if "register_recommendation_routes(admin_router)" not in text: + text += "\nregister_recommendation_routes(admin_router)\n" +p.write_text(text) +print("admin_api patched") diff --git a/cockpit/patch_cockpit.py b/cockpit/patch_cockpit.py new file mode 100644 index 0000000..3420f19 --- /dev/null +++ b/cockpit/patch_cockpit.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +from pathlib import Path + +# cockpit main.py - add retail router +main = Path("/home/aissa/foodlinkk-command-center/cockpit/app/main.py") +text = main.read_text() +if "retail" not in text: + text = text.replace( + " browser,\n hermes,\n)", + " browser,\n hermes,\n retail,\n)", + ) + text = text.replace( + "from app.routes import (", + "from app.routes import (\n retail,", + ) +if "retail.router," not in text: + text = text.replace( + " marketing.router,\n", + " marketing.router,\n retail.router,\n", + ) +main.write_text(text) + +# base.html - add Retail link under Intel +base = Path("/home/aissa/foodlinkk-command-center/cockpit/templates/base.html") +b = base.read_text() +if "/retail" not in b: + b = b.replace( + 'Retail\n Analytics', + '\n ', + ) + mt += ''' +
+

Herman Recommendations

+
Loading…
+ +
+''' + m.write_text(mt) + +print("cockpit patched") diff --git a/cockpit/requirements.txt b/cockpit/requirements.txt new file mode 100644 index 0000000..83fc029 --- /dev/null +++ b/cockpit/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +jinja2==3.1.4 +python-multipart==0.0.12 +psycopg2-binary==2.9.9 +httpx==0.27.2 +websockets==13.1 +beautifulsoup4==4.12.3 +textblob==0.18.0.post0 +tweepy==4.15.0 diff --git a/cockpit/settings.html b/cockpit/settings.html new file mode 100644 index 0000000..2697ef0 --- /dev/null +++ b/cockpit/settings.html @@ -0,0 +1,310 @@ +{% extends "base.html" %} +{% block content %} +
+ + + + + {% if active_tab == 'email' %} +
+
+
+

Email accounts

+

Kies welk mailbox Herman gebruikt om te versturen. IMAP velden zijn voor latere inbox-sync.

+
+ +
+ +
+ Actief + +
+ + +
+ + + {% elif active_tab == 'permissions' %} +
+
+
+

Herman — Module rechten

+

Bepaal welke bedrijfsprocessen Herman mag benaderen. Grant all = volledige toegang.

+
+ +
+

Live

+
+ +
+
+ {% elif active_tab == 'general' %} +
+

General

+

Dashboard: http://10.4.7.18:8600 · Telegram: @klaploper_bot · Ochtend briefing: 07:00

+

Meer instellingen (Telegram tijd, timezone) komen hier later.

+
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% if active_tab == 'email' %} + +{% endif %} +{% endblock %} diff --git a/cockpit/static/css/agents-mesh.css b/cockpit/static/css/agents-mesh.css new file mode 100644 index 0000000..6846d6e --- /dev/null +++ b/cockpit/static/css/agents-mesh.css @@ -0,0 +1,82 @@ +.mesh-wrap { + position: relative; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: radial-gradient(circle at 50% 40%, rgba(255, 215, 0, 0.08), rgba(0, 0, 0, 0.35)); + overflow: hidden; +} + +.mesh-svg { + width: 100%; + min-height: 520px; + display: block; +} + +.mesh-edge { + stroke: rgba(0, 229, 255, 0.42); + stroke-width: 1.6; + fill: none; + stroke-linecap: round; +} + +.mesh-pulse { + stroke: rgba(0, 229, 255, 0.9); + stroke-width: 3; + stroke-linecap: round; + stroke-dasharray: 3 140; + animation: mesh-pulse 2.4s linear infinite; +} + +@keyframes mesh-pulse { + from { stroke-dashoffset: 0; } + to { stroke-dashoffset: -286; } +} + +.mesh-node { cursor: pointer; } +.mesh-node text { + font-size: 12px; + fill: #e2e8f0; + font-weight: 600; + text-anchor: middle; +} + +.mesh-node circle { + fill: #0f172a; + stroke: rgba(148, 163, 184, 0.65); + stroke-width: 2; +} + +.mesh-node.healthy circle { stroke: #22c55e; } +.mesh-node.warn circle { stroke: #f59e0b; } +.mesh-node.idle circle { stroke: #38bdf8; } +.mesh-node.offline circle { stroke: #64748b; } + +.mesh-herman circle.main { + fill: #111827; + stroke: #ffd700; + stroke-width: 4; + filter: drop-shadow(0 0 12px rgba(255, 215, 0, 0.6)); +} + +.mesh-herman circle.ring { + fill: none; + stroke: rgba(255, 215, 0, 0.35); + stroke-width: 2; + stroke-dasharray: 8 4; + animation: mesh-herman-ring 7s linear infinite; + transform-origin: center; +} + +@keyframes mesh-herman-ring { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.mesh-legend { + display: flex; + gap: 0.8rem; + flex-wrap: wrap; + margin-top: 0.75rem; + color: #94a3b8; + font-size: 0.82rem; +} diff --git a/cockpit/static/css/analytics.css b/cockpit/static/css/analytics.css new file mode 100644 index 0000000..87c7fd2 --- /dev/null +++ b/cockpit/static/css/analytics.css @@ -0,0 +1,50 @@ +.analytics-shell { + display: grid; + grid-template-columns: 240px 1fr; + gap: 1rem; + align-items: start; +} +.analytics-filters label { + display: block; + margin-bottom: 0.75rem; + font-size: 0.85rem; + color: #94a3b8; +} +.analytics-filters select, +.analytics-filters input { + width: 100%; + margin-top: 0.25rem; +} +.analytics-main { min-width: 0; } +.analytics-kpis { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} +.analytics-kpis .kpi-card { + padding: 0.75rem 1rem; + border-radius: 10px; + background: rgba(15, 23, 42, 0.8); + border: 1px solid rgba(148, 163, 184, 0.15); +} +.analytics-kpis .kpi-card strong { + display: block; + font-size: 1.35rem; + color: #e2e8f0; +} +.analytics-kpis .kpi-card span { + font-size: 0.75rem; + color: #64748b; +} +.analytics-charts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} +.chart-panel canvas { max-height: 220px; } +.chart-panel h4 { margin: 0 0 0.75rem; font-size: 0.9rem; color: #cbd5e1; } +@media (max-width: 900px) { + .analytics-shell { grid-template-columns: 1fr; } +} diff --git a/cockpit/static/css/base.html b/cockpit/static/css/base.html new file mode 100644 index 0000000..9244036 --- /dev/null +++ b/cockpit/static/css/base.html @@ -0,0 +1,69 @@ + + + + + + {% block title %}{{ page_title }} · Foodlinkk{% endblock %} + + + + + + {% block extra_head %}{% endblock %} + + +
+
+

Foodlinkk

+

Command Center

+
+ + +
+ +
{% block content %}{% endblock %}
+ + + {% block scripts %}{% endblock %} + + diff --git a/cockpit/static/css/beurs.css b/cockpit/static/css/beurs.css new file mode 100644 index 0000000..80140fc --- /dev/null +++ b/cockpit/static/css/beurs.css @@ -0,0 +1,171 @@ +/* Beurs & Live Intel page */ + +.beurs-shell { max-width: 1400px; margin: 0 auto; } + +.beurs-hero { + display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem; + background: linear-gradient(135deg, rgba(56,189,248,0.14), rgba(74,222,128,0.08), rgba(251,146,60,0.06)); + border: 1px solid rgba(56,189,248,0.35); + border-radius: 16px; padding: 1.25rem 1.5rem; margin-bottom: 1rem; + box-shadow: 0 0 30px rgba(56,189,248,0.08); +} +.beurs-hero h1 { margin: 0 0 0.35rem; font-size: 1.65rem; color: #f8fafc; } +.beurs-hero .hero-sub { color: #94a3b8; margin: 0 0 0.35rem; } +.beurs-hero small { color: #64748b; } +.beurs-hero-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } + +/* Neo tab bar — interactive */ +.neo-tab-bar { + position: relative; + display: flex; + gap: 0; + background: rgba(10, 14, 22, 0.9); + border: 1px solid rgba(56,189,248,0.2); + border-radius: 14px; + padding: 0.35rem; + margin-bottom: 1.25rem; + overflow: hidden; +} +.neo-tab { + flex: 1; + display: flex; align-items: center; justify-content: center; gap: 0.45rem; + padding: 0.75rem 0.5rem; + border: none; background: transparent; + color: #94a3b8; font-size: 0.82rem; font-weight: 600; + cursor: pointer; position: relative; z-index: 1; + transition: color 0.25s, transform 0.2s; + letter-spacing: 0.02em; +} +.neo-tab:hover { color: #e2e8f0; transform: translateY(-1px); } +.neo-tab.active { color: #f8fafc; text-shadow: 0 0 20px rgba(56,189,248,0.5); } +.neo-tab-icon { font-size: 1.1rem; } +.neo-tab-badge { + background: #fb7185; color: #fff; font-size: 0.6rem; + padding: 0.1rem 0.35rem; border-radius: 999px; min-width: 1rem; +} +.neo-tab-indicator { + position: absolute; bottom: 0.35rem; top: 0.35rem; + background: linear-gradient(135deg, rgba(56,189,248,0.25), rgba(74,222,128,0.12)); + border: 1px solid rgba(56,189,248,0.45); + border-radius: 10px; + box-shadow: 0 0 24px rgba(56,189,248,0.2); + transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1), width 0.35s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; z-index: 0; +} + +.beurs-section { animation: fade-up 0.35s ease; } +.section-label { + font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; + color: #94a3b8; display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; +} + +.beurs-summary-row { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; margin-bottom: 1.25rem; +} +@media (max-width: 800px) { .beurs-summary-row { grid-template-columns: 1fr 1fr; } } +.beurs-stat-card { + background: rgba(15, 23, 42, 0.8); border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; padding: 0.75rem 1rem; +} +.beurs-stat-card span { display: block; font-size: 0.7rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; } +.beurs-stat-card strong { font-size: 1.35rem; color: #f8fafc; } +.beurs-stat-card strong.up { color: #4ade80; } +.beurs-stat-card strong.down { color: #fb7185; } +.beurs-stat-card.warn strong { color: #fb923c; } +.beurs-stat-card.source-card a { color: #38bdf8; text-decoration: none; font-size: 0.95rem; } +.beurs-stat-card.source-card a:hover { text-decoration: underline; } + +.beurs-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.85rem; +} +.beurs-card { + background: linear-gradient(160deg, rgba(13,18,25,0.95), rgba(8,12,18,0.98)); + border: 1px solid rgba(56,189,248,0.15); border-radius: 14px; + padding: 0.85rem 1rem; transition: border-color 0.2s, box-shadow 0.2s; +} +.beurs-card:hover { border-color: rgba(56,189,248,0.4); box-shadow: 0 0 20px rgba(56,189,248,0.1); } +.beurs-card.trend-up { border-color: rgba(74,222,128,0.25); } +.beurs-card.trend-down { border-color: rgba(251,113,133,0.25); } +.beurs-card.unlisted-card { border-color: rgba(148,163,184,0.2); opacity: 0.92; } + +.beurs-card-head { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.35rem; } +.beurs-chain-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 0.35rem; flex-shrink: 0; box-shadow: 0 0 8px currentColor; } +.beurs-card-head strong { display: block; color: #f1f5f9; font-size: 0.95rem; } +.beurs-card-head small { color: #64748b; font-size: 0.7rem; } +.beurs-pct { margin-left: auto; font-size: 0.75rem; font-weight: 700; padding: 0.15rem 0.4rem; border-radius: 6px; } +.beurs-pct.up { color: #4ade80; background: rgba(74,222,128,0.12); } +.beurs-pct.down { color: #fb7185; background: rgba(251,113,133,0.12); } +.beurs-tag { margin-left: auto; font-size: 0.65rem; padding: 0.15rem 0.4rem; border-radius: 6px; background: rgba(148,163,184,0.15); color: #94a3b8; } + +.beurs-chains { font-size: 0.72rem; color: #64748b; margin-bottom: 0.4rem; } +.beurs-price { font-size: 1.2rem; font-weight: 700; color: #f8fafc; margin-bottom: 0.35rem; } +.beurs-price small { font-size: 0.65rem; color: #94a3b8; margin-left: 0.25rem; } +.market-state { font-size: 0.6rem; color: #4ade80; margin-left: 0.5rem; text-transform: uppercase; } +.beurs-spark { width: 100%; height: 30px; display: block; margin: 0.25rem 0; } +.beurs-eq { + display: flex; align-items: flex-end; justify-content: center; gap: 3px; + height: 22px; margin: 0.35rem 0; +} +.beurs-eq span { + width: 4px; border-radius: 2px; + background: linear-gradient(to top, rgba(56,189,248,0.3), #38bdf8); + animation: hmEq 0.55s ease-in-out infinite alternate; +} +.beurs-eq span:nth-child(odd) { animation-delay: 0.1s; } +.beurs-sources { display: flex; justify-content: space-between; align-items: center; margin-top: 0.35rem; } +.beurs-sources small { font-size: 0.65rem; color: #64748b; } +.unlisted-note { font-size: 0.78rem; color: #94a3b8; margin: 0.35rem 0; } + +.beurs-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; } +@media (max-width: 900px) { .beurs-two-col { grid-template-columns: 1fr; } } +.panel-inner { + background: rgba(15,23,42,0.6); border: 1px solid rgba(255,255,255,0.06); + border-radius: 14px; padding: 1rem 1.15rem; +} +.panel-inner h2 { margin: 0 0 0.75rem; font-size: 1rem; color: #f1f5f9; } + +.tag { display: inline-block; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.08em; padding: 0.12rem 0.4rem; border-radius: 4px; margin-bottom: 0.35rem; } +.tag-halal { background: rgba(74,222,128,0.15); color: #4ade80; } +.tag-dish { background: rgba(56,189,248,0.15); color: #38bdf8; } +.tag-trend { background: rgba(251,146,60,0.15); color: #fb923c; } + +.concept-header { margin-bottom: 1rem; } +.concept-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; } +.concept-card { + background: linear-gradient(135deg, rgba(192,132,252,0.08), rgba(56,189,248,0.06)); + border: 1px solid rgba(192,132,252,0.25); border-radius: 14px; padding: 1rem 1.15rem; +} +.concept-num { font-size: 0.65rem; color: #c084fc; font-weight: 700; } +.concept-card p { color: #e2e8f0; line-height: 1.55; margin: 0.5rem 0; font-size: 0.92rem; } +.concept-based small { color: #64748b; font-size: 0.72rem; } + +.events-toolbar { display: flex; gap: 1rem; align-items: center; margin-bottom: 0.75rem; } +.events-toolbar select { max-width: 220px; } +.events-stream { display: flex; flex-direction: column; gap: 0.35rem; } +.event-row { + display: grid; grid-template-columns: 52px 100px 1fr 90px 80px; gap: 0.5rem; align-items: center; + padding: 0.65rem 0.85rem; border-radius: 10px; + background: rgba(15,23,42,0.7); border: 1px solid rgba(255,255,255,0.06); + text-decoration: none; color: inherit; transition: all 0.2s; +} +.event-row:hover { + border-color: rgba(56,189,248,0.4); background: rgba(56,189,248,0.08); + transform: translateX(4px); +} +.event-time { font-size: 0.75rem; color: #64748b; font-variant-numeric: tabular-nums; } +.event-agent { font-size: 0.7rem; } +.event-title { font-size: 0.85rem; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.event-status { font-size: 0.65rem; text-transform: uppercase; color: #94a3b8; } +.event-status.st-needs_approval { color: #fb923c; } +.event-status.st-completed { color: #4ade80; } +.event-source { font-size: 0.65rem; color: #38bdf8; text-align: right; } +@media (max-width: 700px) { + .event-row { grid-template-columns: 1fr; gap: 0.25rem; } +} + +.topnav-beurs.active { color: #4ade80 !important; text-shadow: 0 0 12px rgba(74,222,128,0.4); } + +@keyframes hmEq { + from { height: 20%; opacity: 0.5; } + to { height: 90%; opacity: 1; } +} diff --git a/cockpit/static/css/herman-dashboard.css b/cockpit/static/css/herman-dashboard.css new file mode 100644 index 0000000..da1e999 --- /dev/null +++ b/cockpit/static/css/herman-dashboard.css @@ -0,0 +1,378 @@ +/* Herman dashboard — Hermes neo style (KPI cards animate, grote panelen vast) */ + +.hm-neo { --hm-cyan: #00e5ff; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); } + +.herman-shell.hm-neo .herman-hero { + background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98)); + border: 1px solid var(--hm-border); + border-radius: 14px; + padding: 1.25rem 1.5rem; + margin-bottom: 1rem; + /* GEEN animatie op hero — vast */ +} +.herman-shell.hm-neo .herman-hero h1 { font-size: 1.65rem; margin: 0; } +.herman-shell.hm-neo .hero-sub { color: var(--hm-cyan); opacity: 0.9; } + +/* Dashboard layout sections */ +.herman-briefing-header { + display: flex; justify-content: space-between; flex-wrap: wrap; gap: 1rem; + margin-bottom: 1.25rem; padding-bottom: 1rem; + border-bottom: 1px solid var(--hm-border); +} +.herman-briefing-header h2 { margin: 0; } +.herman-briefing-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } + +.hm-dash-layout { display: flex; flex-direction: column; gap: 1.5rem; } +.hm-dash-section { display: flex; flex-direction: column; gap: 0.75rem; } +.hm-dash-full { width: 100%; } +.hm-section-title { + margin: 0; font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; + color: #94a3b8; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; + padding-bottom: 0.35rem; border-bottom: 1px solid rgba(255,255,255,0.05); +} +.hm-dash-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 900px) { .hm-dash-two-col { grid-template-columns: 1fr; } } + +.briefing-summary-grid { + display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; +} +.briefing-horizon-col { display: grid; gap: 1rem; } +@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } } + +.briefing-card.summary, +.horizon-card { + background: var(--hm-panel); border-radius: 14px; padding: 1rem; +} +.briefing-card.summary { border: 1px solid var(--hm-border); } +.briefing-card.summary h4, +.horizon-card h4 { + margin: 0 0 0.65rem; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; +} +.horizon-card.short { border: 1px solid rgba(255,159,67,0.3); } +.horizon-card.short h4 { color: #ff9f43; } +.horizon-card.long { border: 1px solid rgba(168,85,247,0.3); } +.horizon-card.long h4 { color: #a855f7; } + +.hm-dash-trends-only { margin-top: 0; max-width: 100%; } +.hm-dash-trends-only .hm-chart-wrap { height: 180px; } +.beurs-cta-panel { + display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; + background: var(--hm-panel); border: 1px solid var(--hm-border); border-radius: 14px; padding: 1rem 1.25rem; +} +.beurs-mini-row { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: flex-start; } +.beurs-mini-chip { + display: inline-block; padding: 0.35rem 0.65rem; border-radius: 8px; text-decoration: none; + font-size: 0.8rem; border: 1px solid rgba(255,255,255,0.1); color: #e2e8f0; +} +.beurs-mini-chip.up { border-color: rgba(74,222,128,0.3); background: rgba(74,222,128,0.08); } +.beurs-mini-chip.down { border-color: rgba(251,113,133,0.3); background: rgba(251,113,133,0.08); } +.beurs-mini-chip strong { color: #f8fafc; } +.hm-dash-feed { margin-top: 1rem; } + +.hm-chart-head h4 { + margin: 0; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; + color: #cbd5e1; font-weight: 600; +} + +/* Grote briefing panel — VAST, geen beweging */ +.herman-shell .herman-briefing { + position: relative; + border-left: 3px solid var(--hm-cyan); + animation: none !important; + transform: none !important; +} +.herman-shell .briefing-card, +.herman-shell .briefing-chart-panel, +.herman-shell .horizon-card, +.herman-shell .hub-grid .panel { + animation: none !important; +} + +/* Neo KPI row — per kaart animatie (Hermes stijl) */ +.hm-neo-kpi-row { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 1rem; + margin: 1rem 0 1.25rem; +} +@media (max-width: 1200px) { .hm-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 700px) { .hm-neo-kpi-row { grid-template-columns: 1fr 1fr; } } + +.hm-neo-kpi { + position: relative; + background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98)); + border: 1px solid var(--hm-border); + border-radius: 14px; + padding: 1rem 1.1rem 0.75rem; + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; +} +.hm-neo-kpi:hover { + border-color: rgba(0, 229, 255, 0.35); + box-shadow: 0 0 24px rgba(0, 229, 255, 0.1); +} +.hm-neo-kpi-top { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.35rem; } +.hm-neo-ring { + width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; + background: conic-gradient(var(--ring-color, var(--hm-cyan)) var(--ring-pct, 75%), rgba(255,255,255,0.06) 0); + display: grid; place-items: center; + box-shadow: 0 0 14px color-mix(in srgb, var(--ring-color, var(--hm-cyan)) 45%, transparent); + animation: hmRingSpin 8s linear infinite; +} +@keyframes hmRingSpin { + 0% { filter: brightness(1); } + 50% { filter: brightness(1.2); } + 100% { filter: brightness(1); } +} +.hm-neo-ring-inner { + width: 32px; height: 32px; border-radius: 50%; + background: var(--hm-panel); display: grid; place-items: center; font-size: 1rem; +} +.hm-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.14em; text-transform: uppercase; color: #94a3b8; } +.hm-neo-kpi-sub { font-size: 0.72rem; color: #64748b; } +.hm-neo-kpi-value { font-size: 1.75rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; } + +.hm-eq { + display: flex; align-items: flex-end; justify-content: center; gap: 3px; + height: 26px; margin-top: 0.55rem; padding-top: 0.35rem; + border-top: 1px solid rgba(255,255,255,0.04); +} +.hm-eq span { + width: 5px; border-radius: 2px; + background: linear-gradient(to top, rgba(0,229,255,0.3), var(--hm-cyan)); + animation: hmEq 0.55s ease-in-out infinite alternate; + box-shadow: 0 0 6px rgba(0, 229, 255, 0.35); +} +.hm-eq span:nth-child(1) { animation-delay: 0s; } +.hm-eq span:nth-child(2) { animation-delay: 0.08s; } +.hm-eq span:nth-child(3) { animation-delay: 0.16s; } +.hm-eq span:nth-child(4) { animation-delay: 0.24s; } +.hm-eq span:nth-child(5) { animation-delay: 0.32s; } +.hm-eq span:nth-child(6) { animation-delay: 0.12s; } +.hm-eq span:nth-child(7) { animation-delay: 0.2s; } +.hm-eq span:nth-child(8) { animation-delay: 0.28s; } +@keyframes hmEq { + from { height: 18%; opacity: 0.45; } + to { height: 92%; opacity: 1; } +} + +.hm-neo-charts { + display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; +} +@media (max-width: 900px) { .hm-neo-charts { grid-template-columns: 1fr; } } + +.hm-chart-panel { + background: var(--hm-panel); border: 1px solid var(--hm-border); + border-radius: 14px; padding: 1rem 1.1rem; min-height: 260px; +} +.hm-chart-head { + display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; +} +.hm-chart-head h3 { + margin: 0; font-size: 0.7rem; letter-spacing: 0.12em; + text-transform: uppercase; color: #94a3b8; font-weight: 600; + display: flex; align-items: center; gap: 0.5rem; +} +.hm-live-dot { + width: 8px; height: 8px; border-radius: 50%; background: #22c55e; + box-shadow: 0 0 8px #22c55e; animation: hmPulse 2s infinite; +} +@keyframes hmPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +.hm-chart-head small { color: #64748b; font-size: 0.7rem; } +.hm-chart-wrap { position: relative; height: 200px; } + +.briefing-loading-overlay { + position: absolute; inset: 0; background: rgba(10, 14, 20, 0.8); + backdrop-filter: blur(4px); display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 1rem; z-index: 20; border-radius: 12px; +} +.briefing-loading-overlay .loading-dots { display: flex; gap: 0.5rem; } +.briefing-loading-overlay .loading-dots span { + width: 12px; height: 12px; border-radius: 50%; background: var(--hm-cyan); + animation: hmEq 0.8s ease-in-out infinite alternate; +} +.briefing-loading-overlay .loading-dots span:nth-child(2) { animation-delay: 0.15s; } +.briefing-loading-overlay .loading-dots span:nth-child(3) { animation-delay: 0.3s; } + +.briefing-typewriter { color: #f1f5f9; line-height: 1.75; font-size: 1rem; min-height: 3rem; } +.horizon-card ul { color: #dce6f0; line-height: 1.65; } +.retail-highlight { text-decoration: none; color: inherit; display: flex; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid var(--hm-border); } +.retail-highlight:hover { background: rgba(0,229,255,0.06); } + +.hub-kpi-row .kpi-card { animation: none !important; } + +/* Agents page */ +.agents-neo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; } +.agent-soul-card { + background: var(--hm-panel); border: 1px solid var(--hm-border); border-radius: 14px; + padding: 1rem; cursor: pointer; transition: all 0.2s; +} +.agent-soul-card:hover { border-color: rgba(0,229,255,0.4); box-shadow: 0 0 20px rgba(0,229,255,0.08); } +.agent-soul-card.selected { border-color: var(--hm-gold); } +.agent-soul-editor { + background: var(--hm-panel); border: 1px solid var(--hm-border); border-radius: 14px; + padding: 1.25rem; margin-top: 1rem; +} +.agent-soul-editor textarea { + width: 100%; min-height: 280px; font-family: var(--font-mono); font-size: 0.85rem; + background: #060a10; color: #e2e8f0; border: 1px solid var(--hm-border); border-radius: 8px; padding: 0.75rem; +} + +.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; } +.perm-card { + background: var(--hm-panel); border: 1px solid var(--hm-border); border-radius: 10px; + padding: 0.85rem; display: flex; justify-content: space-between; align-items: center; +} +.perm-card.granted { border-color: rgba(34,197,94,0.4); } + +/* CEO market dashboard */ +.hm-ceo-market-row { + display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; +} +@media (max-width: 1000px) { .hm-ceo-market-row { grid-template-columns: 1fr; } } + +.hm-stock-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.65rem; +} +.hm-stock-card { + background: rgba(0,0,0,0.25); border: 1px solid var(--hm-border); border-radius: 12px; + padding: 0.65rem 0.75rem; transition: border-color 0.2s, box-shadow 0.2s; +} +.hm-stock-card:hover { border-color: rgba(0,229,255,0.35); box-shadow: 0 0 16px rgba(0,229,255,0.08); } +.hm-stock-up { border-color: rgba(34,197,94,0.25); } +.hm-stock-down { border-color: rgba(251,113,133,0.25); } +.hm-stock-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.35rem; } +.hm-stock-head strong { display: block; font-size: 0.85rem; color: #f1f5f9; } +.hm-stock-head small { font-size: 0.65rem; color: #94a3b8; } +.hm-stock-pct { font-size: 0.72rem; font-weight: 700; padding: 0.15rem 0.4rem; border-radius: 6px; } +.hm-stock-pct.up { color: #4ade80; background: rgba(74,222,128,0.12); } +.hm-stock-pct.down { color: #fb7185; background: rgba(251,113,133,0.12); } +.hm-stock-price { font-size: 1.15rem; font-weight: 700; color: #f8fafc; margin: 0.35rem 0 0.15rem; } +.hm-stock-price small { font-size: 0.65rem; color: #94a3b8; font-weight: 400; } +.hm-stock-chain { font-size: 0.65rem; color: #64748b; margin-bottom: 0.35rem; } +.hm-spark { width: 100%; height: 36px; display: block; opacity: 0.85; } +.hm-eq-down span { background: linear-gradient(to top, rgba(251,113,133,0.3), #fb7185); box-shadow: 0 0 6px rgba(251,113,133,0.35); } + +.hm-highlight-scroll { max-height: 220px; overflow-y: auto; } +.hm-highlight-item { + padding: 0.55rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); +} +.hm-highlight-item strong { color: #e2e8f0; font-size: 0.85rem; text-decoration: none; } +.hm-highlight-item a { color: inherit; text-decoration: none; } +.hm-highlight-item a:hover strong { color: var(--hm-cyan); } +.hm-highlight-item small { display: block; color: #64748b; font-size: 0.7rem; margin-top: 0.15rem; } +.hm-cat-badge { + display: inline-block; font-size: 0.55rem; letter-spacing: 0.1em; font-weight: 700; + padding: 0.1rem 0.35rem; border-radius: 4px; margin-bottom: 0.25rem; + background: rgba(255,159,67,0.15); color: #ff9f43; +} +.hm-cat-badge.hm-cat-cbs { background: rgba(56,189,248,0.15); color: #38bdf8; } +.hm-cat-badge.hm-cat-regelgeving { background: rgba(168,85,247,0.15); color: #a855f7; } + +.hm-neo-kpi-row { grid-template-columns: repeat(6, 1fr); } +@media (max-width: 1200px) { .hm-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 640px) { .hm-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } } + +/* Agent animated avatars */ +.agent-avatar-wrap { + position: relative; width: 64px; height: 64px; flex-shrink: 0; +} +.agent-avatar { + width: 64px; height: 64px; border-radius: 50%; position: relative; + display: grid; place-items: center; font-size: 1.5rem; + box-shadow: 0 0 20px color-mix(in srgb, var(--av-color, #00e5ff) 40%, transparent); + animation: agentBob 3s ease-in-out infinite; +} +.agent-avatar::before { + content: ''; position: absolute; inset: -3px; border-radius: 50%; + border: 2px solid var(--av-color, #00e5ff); opacity: 0.5; + animation: agentPulseRing 2s ease-out infinite; +} +.agent-face { + width: 48px; height: 48px; border-radius: 50%; background: var(--av-color, #00e5ff); + position: relative; overflow: hidden; +} +.agent-face .eye { + position: absolute; top: 16px; width: 7px; height: 9px; background: #0a0e14; border-radius: 50%; + animation: agentBlink 4s infinite; +} +.agent-face .eye.left { left: 12px; } +.agent-face .eye.right { right: 12px; } +.agent-face .mouth { + position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%); + width: 14px; height: 7px; border-bottom: 3px solid #0a0e14; border-radius: 0 0 14px 14px; +} +.agent-face.mood-busy .mouth { width: 10px; height: 3px; border: none; background: #0a0e14; border-radius: 2px; bottom: 12px; } +.agent-face.mood-creative .mouth { border-bottom-color: #0a0e14; width: 16px; height: 8px; } + +/* Hair, glasses, gender styles */ +.agent-face .hair { + position: absolute; top: 0; left: 50%; transform: translateX(-50%); + width: 90%; height: 22px; border-radius: 50% 50% 0 0; + background: #1a1410; z-index: 1; +} +.agent-face.style-female-long .hair { + width: 110%; height: 28px; border-radius: 40% 40% 0 0; + background: linear-gradient(180deg, #2d1810, #1a1008); +} +.agent-face.style-female-long::after { + content: ''; position: absolute; top: 18px; left: -4px; width: 12px; height: 22px; + background: #2d1810; border-radius: 0 0 50% 50%; z-index: 0; +} +.agent-face.style-female-bob .hair { + width: 100%; height: 20px; background: #3d2817; border-radius: 30% 30% 0 0; +} +.agent-face.style-male-short .hair { + width: 85%; height: 14px; background: #0f0f0f; border-radius: 40% 40% 0 0; +} +.agent-face.style-male-curl .hair { + width: 95%; height: 18px; background: #2a1810; + border-radius: 50% 50% 0 0; box-shadow: -6px 2px 0 #2a1810, 6px 2px 0 #2a1810; +} +.agent-face .glasses { + position: absolute; top: 14px; left: 50%; transform: translateX(-50%); + width: 34px; height: 10px; border: 2px solid #0a0e14; border-radius: 4px; + z-index: 3; box-shadow: -8px 0 0 -2px #0a0e14, 8px 0 0 -2px #0a0e14; +} +.agent-face.style-female-long .eye { top: 18px; } +.agent-face.style-male-glasses .eye { top: 15px; } + +.hm-exec-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.65rem; +} +.hm-exec-item { + display: flex; gap: 0.65rem; align-items: center; padding: 0.75rem; + border-radius: 10px; background: rgba(0,0,0,0.25); border: 1px solid rgba(148,163,184,0.1); + text-decoration: none; color: inherit; transition: border-color 0.2s, transform 0.2s; +} +.hm-exec-item:hover { border-color: rgba(0,229,255,0.35); transform: translateY(-2px); } +.hm-exec-icon { font-size: 1.4rem; } +.hm-exec-item strong { display: block; font-size: 0.78rem; color: #94a3b8; font-weight: 600; } +.hm-exec-val { font-size: 1.1rem; color: #e2e8f0; font-weight: 700; } +.hm-exec-list { margin: 0; padding-left: 1.2rem; color: #cbd5e1; font-size: 0.85rem; } +.hm-exec-list a { color: #38bdf8; } +.agent-status-dot { + position: absolute; bottom: 2px; right: 2px; width: 12px; height: 12px; + border-radius: 50%; background: #22c55e; border: 2px solid var(--hm-panel); + box-shadow: 0 0 8px #22c55e; animation: hmPulse 2s infinite; +} +@keyframes agentBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } } +@keyframes agentPulseRing { 0% { transform: scale(1); opacity: 0.5; } 100% { transform: scale(1.15); opacity: 0; } } +@keyframes agentBlink { 0%,96%,100% { transform: scaleY(1); } 98% { transform: scaleY(0.1); } } + +.agent-soul-card .agent-role-desc { + font-size: 0.82rem; color: #cbd5e1; line-height: 1.5; margin: 0.65rem 0; + min-height: 2.5rem; +} +.agent-soul-card .agent-meta-row { + display: flex; justify-content: space-between; align-items: center; + font-size: 0.72rem; color: #94a3b8; +} +.agent-soul-card .agent-tag { + display: inline-block; padding: 0.15rem 0.45rem; border-radius: 999px; + background: rgba(0,229,255,0.1); color: var(--hm-cyan); font-size: 0.65rem; +} +.agent-soul-editor .agent-editor-header { + display: flex; gap: 1rem; align-items: center; margin-bottom: 1rem; +} +.agent-soul-editor .agent-editor-header h2 { margin: 0; } diff --git a/cockpit/static/css/hermes.css b/cockpit/static/css/hermes.css new file mode 100644 index 0000000..a4a98ba --- /dev/null +++ b/cockpit/static/css/hermes.css @@ -0,0 +1,218 @@ +/* Hermes Neo Command Center — network monitor aesthetic */ +.hm-neo { --hm-cyan: #00e5ff; --hm-mag: #ff2d95; --hm-lime: #b8ff3c; --hm-orange: #ff9f43; --hm-purple: #a855f7; --hm-gold: #ffd700; --hm-panel: #0d1219; --hm-border: rgba(0, 229, 255, 0.12); } + +.hm-neo-header h1 { font-size: 1.65rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; } +.hm-neo-header .hm-sub { color: var(--hm-cyan); font-size: 0.85rem; margin: 0.35rem 0 0; opacity: 0.85; } + +/* ── KPI cards (neo style) ── */ +.hm-neo-kpi-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin: 1.25rem 0; +} +@media (max-width: 1100px) { .hm-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 560px) { .hm-neo-kpi-row { grid-template-columns: 1fr; } } + +.hm-neo-kpi { + position: relative; + background: linear-gradient(160deg, rgba(13, 18, 25, 0.95), rgba(8, 12, 18, 0.98)); + border: 1px solid var(--hm-border); + border-radius: 14px; + padding: 1rem 1.1rem 0.75rem; + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; +} +.hm-neo-kpi:hover { border-color: rgba(0, 229, 255, 0.35); box-shadow: 0 0 24px rgba(0, 229, 255, 0.08); } +.hm-neo-kpi.highlight { border-color: var(--hm-gold); box-shadow: 0 0 20px rgba(255, 215, 0, 0.12); } + +.hm-neo-kpi-top { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } +.hm-neo-ring { + width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; + background: conic-gradient(var(--ring-color, var(--hm-cyan)) var(--ring-pct, 75%), rgba(255,255,255,0.06) 0); + display: grid; place-items: center; + box-shadow: 0 0 12px color-mix(in srgb, var(--ring-color, var(--hm-cyan)) 40%, transparent); +} +.hm-neo-ring-inner { + width: 32px; height: 32px; border-radius: 50%; + background: var(--hm-panel); display: grid; place-items: center; + font-size: 1rem; +} +.hm-neo-kpi-label { font-size: 0.65rem; letter-spacing: 0.14em; text-transform: uppercase; color: #64748b; } +.hm-neo-kpi-value { font-size: 2rem; font-weight: 700; line-height: 1.1; color: #f1f5f9; font-variant-numeric: tabular-nums; } +.hm-neo-kpi-sub { font-size: 0.72rem; color: #64748b; margin-top: 0.15rem; } + +/* Animated equalizer bars */ +.hm-eq { + display: flex; align-items: flex-end; justify-content: center; gap: 3px; + height: 28px; margin-top: 0.65rem; padding-top: 0.35rem; + border-top: 1px solid rgba(255,255,255,0.04); +} +.hm-eq span { + width: 5px; border-radius: 2px; + background: linear-gradient(to top, rgba(0,229,255,0.3), var(--hm-cyan)); + animation: hmEq 0.55s ease-in-out infinite alternate; + box-shadow: 0 0 6px rgba(0, 229, 255, 0.35); +} +.hm-eq span:nth-child(1) { animation-delay: 0s; } +.hm-eq span:nth-child(2) { animation-delay: 0.08s; } +.hm-eq span:nth-child(3) { animation-delay: 0.16s; } +.hm-eq span:nth-child(4) { animation-delay: 0.24s; } +.hm-eq span:nth-child(5) { animation-delay: 0.32s; } +.hm-eq span:nth-child(6) { animation-delay: 0.12s; } +.hm-eq span:nth-child(7) { animation-delay: 0.2s; } +.hm-eq span:nth-child(8) { animation-delay: 0.28s; } +@keyframes hmEq { + from { height: 18%; opacity: 0.45; } + to { height: 92%; opacity: 1; } +} + +/* ── Chart panels ── */ +.hm-neo-charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1.25rem; +} +@media (max-width: 900px) { .hm-neo-charts { grid-template-columns: 1fr; } } + +.hm-chart-panel { + background: var(--hm-panel); + border: 1px solid var(--hm-border); + border-radius: 14px; + padding: 1rem 1.1rem; + min-height: 280px; +} +.hm-chart-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 0.75rem; +} +.hm-chart-head h3 { + margin: 0; font-size: 0.7rem; letter-spacing: 0.12em; + text-transform: uppercase; color: #94a3b8; font-weight: 600; +} +.hm-chart-head .hm-live-dot { + width: 8px; height: 8px; border-radius: 50%; background: #22c55e; + box-shadow: 0 0 8px #22c55e; animation: hmPulse 2s infinite; +} +@keyframes hmPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +.hm-chart-head small { color: #475569; font-size: 0.7rem; } +.hm-chart-wrap { position: relative; height: 220px; } + +/* ── Tabs ── */ +.hermes-tabs { + display: flex; flex-wrap: wrap; gap: 0.55rem; + margin: 0 0 1rem; padding: 0.45rem; + background: rgba(8, 12, 18, 0.8); + border: 1px solid var(--hm-border); + border-radius: 12px; +} +.hermes-tab { + flex: 1 1 auto; min-width: 120px; + padding: 0.8rem 1.1rem; font-size: 0.88rem; font-weight: 600; + border-radius: 10px; cursor: pointer; + border: 1px solid rgba(100, 116, 139, 0.25); + background: rgba(15, 23, 42, 0.7); color: #94a3b8; + transition: all 0.15s; +} +.hermes-tab:hover { border-color: rgba(0, 229, 255, 0.4); color: #e2e8f0; } +.hermes-tab.active { + background: linear-gradient(135deg, rgba(0, 229, 255, 0.12), rgba(255, 215, 0, 0.08)); + border-color: var(--hm-gold); color: #fef9c3; + box-shadow: 0 0 18px rgba(255, 215, 0, 0.15); +} + +/* ── Team roster (no duplicate names) ── */ +.hm-team { margin-bottom: 1rem; } +.hm-team h3 { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #64748b; margin: 0 0 0.6rem; } +.hm-team-card { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.65rem 0.85rem; margin-bottom: 0.45rem; + background: rgba(13, 18, 25, 0.9); + border: 1px solid var(--hm-border); + border-radius: 10px; +} +.hm-team-card.online { border-left: 3px solid #22c55e; } +.hm-team-card .hm-role-badge { + min-width: 52px; text-align: center; + font-size: 0.65rem; font-weight: 800; letter-spacing: 0.08em; + padding: 0.25rem 0.4rem; border-radius: 6px; +} +.hm-team-card .hm-role-ceo { background: rgba(255, 215, 0, 0.15); color: var(--hm-gold); border: 1px solid rgba(255,215,0,0.3); } +.hm-team-card .hm-role-cto { background: rgba(0, 229, 255, 0.12); color: var(--hm-cyan); border: 1px solid rgba(0,229,255,0.25); } +.hm-team-card .hm-person { flex: 1; } +.hm-team-card .hm-person strong { display: block; font-size: 0.95rem; color: #f1f5f9; } +.hm-team-card .hm-person small { color: #64748b; font-size: 0.72rem; } +.hm-team-card .hm-status-dot { width: 10px; height: 10px; border-radius: 50%; } +.hm-team-card .hm-status-dot.on { background: #22c55e; box-shadow: 0 0 8px #22c55e; } +.hm-team-card .hm-status-dot.off { background: #475569; } + +.hermes-grid { display: grid; grid-template-columns: 260px 1fr; gap: 1rem; } +@media (max-width: 900px) { .hermes-grid { grid-template-columns: 1fr; } } +.hermes-sidebar { max-height: 72vh; overflow-y: auto; } +.hermes-conv-btn { + display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; + margin: 0.3rem 0; padding: 0.5rem 0.65rem; + background: rgba(13, 18, 25, 0.8); border: 1px solid var(--hm-border); + border-radius: 8px; color: #e2e8f0; cursor: pointer; +} +.hermes-conv-btn.active { border-color: var(--hm-cyan); box-shadow: 0 0 0 1px var(--hm-cyan); } +.hermes-conv-btn .conv-role { font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.35rem; border-radius: 4px; } + +.hermes-feed { max-height: 72vh; display: flex; flex-direction: column; } +.hermes-messages { overflow-y: auto; flex: 1; padding: 0.5rem; } +.hermes-msg { + margin-bottom: 0.65rem; padding: 0.6rem 0.8rem; border-radius: 8px; + border-left: 3px solid rgba(100,116,139,0.4); + background: rgba(13, 18, 25, 0.6); +} +.hermes-msg.msg-in { border-left-color: var(--hm-cyan); background: rgba(0, 229, 255, 0.04); } +.hermes-msg.msg-out { border-left-color: var(--hm-purple); background: rgba(168, 85, 247, 0.05); } +.hermes-msg-meta { font-size: 0.72rem; color: #64748b; display: flex; gap: 0.45rem; flex-wrap: wrap; margin-bottom: 0.3rem; } +.hermes-msg-body { white-space: pre-wrap; word-break: break-word; font-size: 0.88rem; } + +/* PA live */ +.hermes-pa-header { + display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; + margin-bottom: 1rem; padding: 0.75rem 1rem; + background: rgba(0, 229, 255, 0.05); border: 1px solid var(--hm-border); border-radius: 10px; +} +.hermes-pa-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; } +@media (max-width: 900px) { .hermes-pa-grid { grid-template-columns: 1fr; } } +.hermes-pa-slot { border: 1px solid var(--hm-border); border-radius: 10px; overflow: hidden; background: var(--hm-panel); } +.hermes-pa-slot.status-loading { border-color: var(--hm-cyan); } +.hermes-pa-slot.status-completed { border-color: #22c55e; } +.hermes-pa-slot.status-failed { border-color: #ef4444; } +.hermes-pa-slot-head { display: flex; justify-content: space-between; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.3); } +.hermes-pa-slot-body { height: 200px; background: #060a10; display: grid; place-items: center; } +.hermes-pa-slot-body img { width: 100%; height: 100%; object-fit: cover; object-position: top; } +.hermes-pa-placeholder { color: #64748b; font-size: 0.82rem; text-align: center; padding: 1rem; } +.hermes-pa-slot-foot { padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #64748b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.status-badge { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; padding: 0.15rem 0.4rem; border-radius: 4px; } +.status-badge.loading { background: rgba(0,229,255,0.15); color: var(--hm-cyan); } +.status-badge.completed { background: rgba(34,197,94,0.15); color: #22c55e; } +.status-badge.failed { background: rgba(239,68,68,0.12); color: #ef4444; } +.status-badge.waiting, .status-badge.idle { background: rgba(100,116,139,0.15); color: #94a3b8; } +.status-badge.running, .status-badge.comparing { background: rgba(255,159,67,0.15); color: var(--hm-orange); } + +.hermes-graph-canvas { height: 480px; border: 1px solid var(--hm-border); border-radius: 10px; background: #060a10; } +.hermes-search-bar { display: flex; gap: 0.5rem; margin: 1rem 0; } +.hermes-search-bar .form-input { flex: 1; } +.hermes-search-hit { padding: 0.65rem; border-bottom: 1px solid var(--hm-border); } +.hermes-control-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; } +.hermes-info-list { list-style: none; padding: 0; } +.hermes-tags { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; } +.tag { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.78rem; } +.tag-ok { background: rgba(34,197,94,0.15); color: #22c55e; } +.tag-block { background: rgba(239,68,68,0.12); color: #ef4444; } +.tag-pa { background: rgba(168,85,247,0.15); color: var(--hm-purple); } +.hermes-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; } +.hermes-events { max-height: 360px; overflow-y: auto; } +.hermes-event { padding: 0.45rem 0; border-bottom: 1px solid var(--hm-border); } +.panel-header { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; margin-bottom: 0.65rem; } +.hermes-select { max-width: 200px; } +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; } +.dot-in { background: var(--hm-cyan); } +.dot-out { background: var(--hm-purple); } +.dot-edge { background: var(--hm-orange); } diff --git a/cockpit/static/css/mobile.css b/cockpit/static/css/mobile.css new file mode 100644 index 0000000..aed9325 --- /dev/null +++ b/cockpit/static/css/mobile.css @@ -0,0 +1,146 @@ +/** + * Foodlinkk Command Center — mobile (Android + iOS) + */ +:root { + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); + --safe-right: env(safe-area-inset-right, 0px); +} + +html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } +body { overflow-x: hidden; padding-bottom: var(--safe-bottom); } + +.mobile-menu-btn { + display: none; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + min-width: 44px; + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 10px; + background: rgba(15, 23, 42, 0.9); + color: #e2e8f0; + font-size: 1.35rem; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.mobile-nav-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 998; + backdrop-filter: blur(2px); +} + +@media (max-width: 1024px) { + .btn, .btn-sm, .nav-pill, .vtab-btn, .side-nav-btn { + min-height: 44px; + } + .form-input, select, textarea, input { + font-size: 16px !important; + min-height: 44px; + } + .main-topnav { + padding: 0.75rem; + padding-left: max(0.75rem, var(--safe-left)); + padding-right: max(0.75rem, var(--safe-right)); + } + .page-header { flex-direction: column; align-items: stretch !important; gap: 0.75rem; } + .page-header h1 { font-size: 1.35rem !important; } + .retail-actions, .herman-briefing-actions { + display: flex; flex-wrap: wrap; gap: 0.5rem; + } + .retail-actions .btn, .herman-briefing-actions .btn { + flex: 1 1 calc(50% - 0.25rem); min-width: calc(50% - 0.25rem); + } + .data-table-wrap, .retail-table-wrap, .panel { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .data-table { min-width: 520px; } + .retail-table { min-width: 480px; } + .kpi-row, .hm-neo-kpi-row, .hub-kpi-row, .analytics-kpis { + grid-template-columns: repeat(2, 1fr) !important; + } + .hm-exec-grid { grid-template-columns: 1fr !important; } + .hm-neo-charts, .analytics-charts, .beurs-two-col { grid-template-columns: 1fr !important; } + .vtabs-layout { grid-template-columns: 1fr !important; } + .vtabs-nav { + flex-direction: row !important; + flex-wrap: nowrap !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; + position: relative !important; + } + .vtabs-nav::before { display: none; } + .vtab-btn { flex: 0 0 auto; white-space: nowrap; } + .retail-workspace, .retail-workspace.with-filters { + grid-template-columns: 1fr !important; + } + .retail-sidebar .side-nav { + display: flex; flex-direction: row; overflow-x: auto; + -webkit-overflow-scrolling: touch; gap: 0.35rem; + } + .retail-sidebar .side-nav-btn { flex: 0 0 auto; white-space: nowrap; } + .retail-map { min-height: 280px !important; height: 45vh !important; } + .analytics-shell { grid-template-columns: 1fr !important; } + .hub-grid, .hm-dash-feed, .grid-2, .briefing-summary-grid, .hm-dash-two-col { + grid-template-columns: 1fr !important; + } + .agents-neo-grid, .reports-grid, .concept-grid { grid-template-columns: 1fr !important; } + [style*="grid-template-columns:1fr 1fr"], + [style*="grid-template-columns: 1fr 1fr"], + .regs-columns[style*="repeat(3"] { grid-template-columns: 1fr !important; } + .regs-columns { flex-direction: column !important; display: flex !important; } + .pwa-install-bar { display: flex; } +} + +@media (max-width: 768px) { + .mobile-menu-btn { display: inline-flex; } + .mobile-nav-overlay { display: block; } + body:not(.nav-open) .mobile-nav-overlay { display: none; } + .app-shell { + grid-template-columns: 1fr !important; + display: block !important; + } + .app-sidebar { + position: fixed !important; + top: 0; left: 0; bottom: 0; + width: min(280px, 88vw); + height: 100dvh !important; + z-index: 999; + transform: translateX(-105%); + transition: transform 0.3s ease; + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4); + padding-top: max(1rem, var(--safe-top)); + flex-direction: column !important; + flex-wrap: nowrap !important; + } + body.nav-open .app-sidebar { transform: translateX(0); } + body.nav-open { overflow: hidden; } + .app-topbar { + position: sticky; top: 0; z-index: 100; + padding-top: max(0.5rem, var(--safe-top)); + } + .app-topbar .page-tip { display: none; } + .app-clock { font-size: 0.72rem; flex: 1; text-align: right; } + .retail-actions .btn, .herman-briefing-actions .btn { + min-width: 100%; flex: 1 1 100%; + } +} + +.pwa-install-bar { + display: none; + position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; + padding: 0.75rem 1rem; + padding-bottom: max(0.75rem, var(--safe-bottom)); + background: rgba(8, 12, 18, 0.95); + border-top: 1px solid rgba(148, 163, 184, 0.15); + align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; +} +.pwa-install-bar p { margin: 0; font-size: 0.85rem; color: #cbd5e1; flex: 1; } +@media (display-mode: standalone) { .pwa-install-bar { display: none !important; } } diff --git a/cockpit/static/css/ops-topology.css b/cockpit/static/css/ops-topology.css new file mode 100644 index 0000000..6f53a14 --- /dev/null +++ b/cockpit/static/css/ops-topology.css @@ -0,0 +1,130 @@ +.ops-shell { + display: grid; + gap: 1rem; +} + +.ops-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; + flex-wrap: wrap; +} + +.ops-header h1 { + margin: 0; +} + +.ops-subtitle { + color: #93a4b8; + margin: 0.25rem 0 0; +} + +.ops-kpis { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.ops-kpi { + border: 1px solid rgba(120, 170, 255, 0.25); + border-radius: 12px; + padding: 0.8rem; + background: linear-gradient(150deg, rgba(15, 23, 42, 0.95), rgba(17, 30, 53, 0.7)); + box-shadow: inset 0 0 20px rgba(36, 99, 235, 0.08), 0 0 20px rgba(30, 64, 175, 0.12); +} + +.ops-kpi .label { + color: #8ba3c4; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.ops-kpi .value { + font-size: 1.3rem; + font-weight: 700; + margin-top: 0.2rem; +} + +.topology-card { + position: relative; + border-radius: 14px; + border: 1px solid rgba(56, 189, 248, 0.3); + background: radial-gradient(circle at top, rgba(21, 43, 77, 0.35), rgba(8, 15, 29, 0.92)); + overflow: hidden; +} + +.topology-canvas { + width: 100%; + min-height: 520px; +} + +.topology-lines line { + stroke: rgba(59, 130, 246, 0.42); + stroke-width: 2; +} + +.pulse-line { + stroke: rgba(56, 189, 248, 0.95); + stroke-width: 2.6; + stroke-dasharray: 10 14; + animation: ops-line-pulse 1.8s linear infinite; + filter: drop-shadow(0 0 6px rgba(56, 189, 248, 0.85)); +} + +.ops-node circle { + fill: #0f172a; + stroke-width: 2.2; +} + +.ops-node text { + fill: #d9e8ff; + font-size: 12px; + font-weight: 600; +} + +.ops-node .sub { + fill: #8ea7ca; + font-size: 11px; + font-weight: 500; +} + +.node-proxmox circle { + stroke: #f59e0b; + filter: drop-shadow(0 0 11px rgba(245, 158, 11, 0.6)); +} + +.node-vm circle { + stroke: #38bdf8; + filter: drop-shadow(0 0 9px rgba(56, 189, 248, 0.5)); +} + +.node-service circle { + stroke: #22c55e; + filter: drop-shadow(0 0 8px rgba(34, 197, 94, 0.45)); +} + +.status-offline circle { + stroke: #ef4444; + filter: drop-shadow(0 0 10px rgba(239, 68, 68, 0.6)); +} + +.status-degraded circle, +.status-unknown circle { + stroke: #f59e0b; +} + +.ops-meta { + color: #8ba3c4; + font-size: 0.85rem; +} + +@keyframes ops-line-pulse { + from { + stroke-dashoffset: 26; + } + to { + stroke-dashoffset: 0; + } +} diff --git a/cockpit/static/css/palantir-theme.css b/cockpit/static/css/palantir-theme.css new file mode 100644 index 0000000..bb1da1b --- /dev/null +++ b/cockpit/static/css/palantir-theme.css @@ -0,0 +1,867 @@ +@import url('tokens.css'); + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; + background: var(--bg-root); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; +} + +a { + color: var(--accent-cyan); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + background: var(--bg-surface); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + padding: 1.25rem 0; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 10; +} + +.brand { + padding: 0 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-subtle); + margin-bottom: 1rem; +} + +.brand-title { + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + margin: 0 0 0.25rem; +} + +.brand-name { + font-size: 1.05rem; + font-weight: 600; + margin: 0; + color: var(--text-primary); +} + +.nav { + list-style: none; + margin: 0; + padding: 0 0.75rem; + flex: 1; +} + +.nav li { + margin-bottom: 0.25rem; +} + +.nav a { + display: block; + padding: 0.55rem 0.75rem; + border-radius: var(--radius-md); + color: var(--text-secondary); + text-decoration: none; + font-weight: 500; +} + +.nav a:hover, +.nav a.active { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav a.active { + border-left: 2px solid var(--accent-cyan); +} + +.main { + margin-left: var(--sidebar-width); + flex: 1; + padding: 1.5rem 2rem 2rem; + max-width: 1400px; +} + +.page-header { + margin-bottom: 1.5rem; +} + +.page-header h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; + font-weight: 600; +} + +.page-header .subtitle { + margin: 0; + color: var(--text-muted); + font-size: 0.875rem; +} + +.panel { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-panel); + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +.panel h2 { + margin: 0 0 1rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.25rem; +} + +.kpi-card { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1rem 1.15rem; + position: relative; + overflow: hidden; +} + +.kpi-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--kpi-accent, var(--accent-cyan)); +} + +.kpi-card.purple::before { background: var(--accent-purple); } +.kpi-card.amber::before { background: var(--accent-amber); } +.kpi-card.green::before { background: var(--accent-green); } + +.kpi-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 0.35rem; +} + +.kpi-value { + font-size: 1.75rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; +} + +@media (max-width: 960px) { + .grid-2 { + grid-template-columns: 1fr; + } + .sidebar { + position: relative; + width: 100%; + } + .main { + margin-left: 0; + } + .layout { + flex-direction: column; + } +} + +.herman-briefing { + border-left: 3px solid var(--accent-cyan); + background: linear-gradient(135deg, var(--accent-cyan-dim), transparent); +} + +.herman-briefing .briefing-meta { + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.herman-briefing .briefing-body { + white-space: pre-wrap; + color: var(--text-secondary); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.45rem 0.85rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--bg-hover); + color: var(--text-primary); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; +} + +.btn:hover { + background: var(--bg-elevated); + text-decoration: none; +} + +.btn-primary { + background: var(--accent-cyan-dim); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.btn-approve { + border-color: var(--accent-green); + color: var(--accent-green); + background: var(--accent-green-dim); +} + +.btn-reject { + border-color: var(--accent-red); + color: var(--accent-red); + background: var(--accent-red-dim); +} + +.agent-feed { + list-style: none; + margin: 0; + padding: 0; + max-height: 420px; + overflow-y: auto; +} + +.agent-feed li { + display: flex; + gap: 0.75rem; + padding: 0.65rem 0; + border-bottom: 1px solid var(--border-subtle); + font-size: 0.8125rem; +} + +.agent-feed li:last-child { + border-bottom: none; +} + +.feed-time { + flex-shrink: 0; + width: 4.5rem; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.7rem; +} + +.feed-body { + flex: 1; + min-width: 0; +} + +.feed-message { + color: var(--text-secondary); +} + +.badge { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: var(--radius-sm); + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge-agent-herman, +.badge-agent-cyan { + background: var(--accent-cyan-dim); + color: var(--accent-cyan); +} + +.badge-agent-sales, +.badge-agent-purple { + background: var(--accent-purple-dim); + color: var(--accent-purple); +} + +.badge-agent-marketing, +.badge-agent-amber { + background: var(--accent-amber-dim); + color: var(--accent-amber); +} + +.badge-agent-ops, +.badge-agent-green { + background: var(--accent-green-dim); + color: var(--accent-green); +} + +.badge-status-needs_approval { + background: var(--accent-amber-dim); + color: var(--accent-amber); +} + +.badge-status-approved, +.badge-status-completed { + background: var(--accent-green-dim); + color: var(--accent-green); +} + +.badge-status-rejected { + background: var(--accent-red-dim); + color: var(--accent-red); +} + +.approval-queue .approval-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-subtle); +} + +.approval-queue .approval-item:last-child { + border-bottom: none; +} + +.approval-actions { + display: flex; + gap: 0.5rem; + margin-left: auto; +} + +.approval-actions form { + margin: 0; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.55rem 0.65rem; + border-bottom: 1px solid var(--border-subtle); +} + +.data-table th { + color: var(--text-muted); + font-weight: 600; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.data-table tbody tr:hover { + background: var(--bg-hover); +} + +.agent-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 1.25rem; +} + +.agent-card { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1rem; +} + +.agent-card.cyan { border-top: 2px solid var(--accent-cyan); } +.agent-card.purple { border-top: 2px solid var(--accent-purple); } +.agent-card.amber { border-top: 2px solid var(--accent-amber); } +.agent-card.green { border-top: 2px solid var(--accent-green); } + +.agent-card h3 { + margin: 0 0 0.25rem; + font-size: 1rem; +} + +.agent-card p { + margin: 0; + color: var(--text-muted); + font-size: 0.8rem; +} + +.muted { + color: var(--text-muted); +} + +.empty-state { + color: var(--text-muted); + font-style: italic; + padding: 0.5rem 0; +} + + +/* Fase 2-4: kanban, charts, voice */ +.kanban-board { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 1rem; margin-bottom: 1.25rem; } +.kanban-column { min-width: 220px; flex: 0 0 220px; background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 0.75rem; } +.kanban-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin: 0 0 0.75rem; } +.kanban-card { background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.65rem; margin-bottom: 0.5rem; font-size: 0.8125rem; } +.chart-placeholder { min-height: 160px; } +.chart-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.45rem; font-size: 0.75rem; } +.chart-bar .bar { height: 8px; background: var(--accent-cyan); border-radius: 4px; min-width: 4px; } +.chart-bar .bar-purple { background: var(--accent-purple); } +.voice-widget { text-align: center; } +.voice-mic { margin: 1rem auto; min-width: 160px; } +.voice-wave { display: flex; justify-content: center; gap: 0.35rem; height: 32px; align-items: flex-end; } +.voice-wave span { width: 4px; height: 12px; background: var(--accent-cyan-dim); border-radius: 2px; animation: voice-pulse 1.2s ease-in-out infinite; } +.voice-wave span:nth-child(2) { animation-delay: 0.2s; } +.voice-wave span:nth-child(3) { animation-delay: 0.4s; } +@keyframes voice-pulse { 0%, 100% { height: 8px; opacity: 0.5; } 50% { height: 24px; opacity: 1; } } +.herman-form textarea { width: 100%; background: var(--bg-root); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); padding: 0.75rem; font-family: inherit; margin-bottom: 0.75rem; } + +/* Command Center interactive UI */ +.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100; align-items: center; justify-content: center; padding: 1rem; } +.modal.open { display: flex; } +.modal-card { background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 1.5rem; max-width: 480px; width: 100%; box-shadow: var(--shadow-lg); } +.drawer { position: fixed; top: 0; right: 0; width: min(420px, 90vw); height: 100vh; background: var(--bg-surface); border-left: 1px solid var(--border-subtle); transform: translateX(100%); transition: transform .25s ease; z-index: 90; padding: 1.5rem; overflow: auto; } +.drawer.open { transform: translateX(0); } +.drawer-close { float: right; background: transparent; border: none; color: var(--text-primary); font-size: 1.5rem; cursor: pointer; } +.btn-group { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; } +.btn-sm { padding: .25rem .55rem; font-size: 12px; } +.clickable-row { cursor: pointer; transition: background .15s; } +.clickable-row:hover { background: var(--bg-hover); } +.clickable-kpi { text-decoration: none; color: inherit; display: block; transition: transform .15s, box-shadow .15s; } +.clickable-kpi:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.25); text-decoration: none; } +.tab-bar { display: flex; gap: .35rem; margin-bottom: 1rem; flex-wrap: wrap; } +.tab-bar button { background: var(--bg-surface); border: 1px solid var(--border-subtle); color: var(--text-secondary); padding: .45rem .9rem; border-radius: var(--radius-md); cursor: pointer; } +.tab-bar button.active { border-color: var(--accent-cyan); color: var(--text-primary); background: var(--bg-hover); } +.toast-container { position: fixed; bottom: 1rem; right: 1rem; z-index: 200; display: flex; flex-direction: column; gap: .5rem; } +.toast { padding: .75rem 1rem; border-radius: var(--radius-md); background: var(--bg-surface); border: 1px solid var(--border-subtle); animation: toastIn .2s ease; } +.toast-success { border-color: var(--accent-green); } +.toast-error { border-color: var(--accent-red, #f44); } +.toast-out { opacity: 0; transition: opacity .3s; } +@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } } +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin-bottom: 1rem; } +.form-input { width: 100%; padding: .55rem .75rem; border-radius: var(--radius-md); border: 1px solid var(--border-subtle); background: var(--bg-root); color: var(--text-primary); } +.data-table { width: 100%; border-collapse: collapse; } +.data-table th, .data-table td { padding: .55rem .65rem; border-bottom: 1px solid var(--border-subtle); text-align: left; } +.kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-top: 1rem; } +.kanban-col { background: var(--bg-surface); border-radius: var(--radius-lg); padding: .75rem; border: 1px solid var(--border-subtle); min-height: 200px; } +.kanban-card { background: var(--bg-root); border-radius: var(--radius-md); padding: .65rem; margin-bottom: .5rem; border: 1px solid var(--border-subtle); } +.chat-layout { display: flex; flex-direction: column; height: calc(100vh - 4rem); } +.chat-messages { flex: 1; overflow: auto; padding: 1rem; background: var(--bg-surface); border-radius: var(--radius-lg); margin-bottom: 1rem; } +.chat-msg { margin-bottom: .75rem; padding: .65rem; border-radius: var(--radius-md); } +.chat-msg.user { background: rgba(0,200,255,.08); } +.chat-msg.agent { background: var(--bg-root); border: 1px solid var(--border-subtle); } +.chat-input { display: flex; gap: .5rem; } +.page-tip { color: var(--text-muted); font-size: 13px; } +.ai-output { white-space: pre-wrap; background: var(--bg-root); padding: 1rem; border-radius: var(--radius-md); min-height: 80px; } +.log-list { list-style: none; padding: 0; max-height: 240px; overflow: auto; } +body.drawer-open { overflow: hidden; } +/* Herman Daily Briefing — visual dashboard */ +.herman-briefing { border-left: 3px solid var(--accent-cyan); } +.herman-briefing-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.25rem; } +.herman-briefing-header h2 { margin: 0; } +.briefing-meta { font-size: 0.75rem; color: var(--text-muted); } + +.briefing-summary-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-bottom: 1.25rem; } +@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } } + +.briefing-card { + background: var(--surface-elevated, rgba(15, 23, 42, 0.6)); + border: 1px solid var(--border-subtle, rgba(148, 163, 184, 0.15)); + border-radius: 10px; + padding: 1rem 1.15rem; +} +.briefing-card h3 { margin: 0 0 0.65rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent-gold, #e8a838); } +.briefing-card.summary { border-color: rgba(56, 189, 248, 0.25); } +.briefing-card.actions { border-color: rgba(232, 168, 56, 0.25); } +.briefing-card.actions ul { margin: 0; padding-left: 1.1rem; color: var(--text-secondary); line-height: 1.55; } +.briefing-card.actions li { margin-bottom: 0.35rem; } +#briefing-summary-text { color: var(--text-primary, #e2e8f0); line-height: 1.6; font-size: 0.95rem; } + +.briefing-kpi-row { display: flex; flex-wrap: wrap; gap: 0.65rem; margin-bottom: 1.25rem; } +.briefing-stat { + flex: 1; min-width: 100px; + background: rgba(15, 23, 42, 0.5); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 8px; + padding: 0.65rem 0.85rem; + text-align: center; +} +.briefing-stat.green { border-color: rgba(62, 207, 142, 0.35); } +.briefing-stat.purple { border-color: rgba(167, 139, 250, 0.35); } +.briefing-stat.cyan { border-color: rgba(56, 189, 248, 0.35); } +.briefing-stat.amber { border-color: rgba(232, 168, 56, 0.35); } +.briefing-stat-label { display: block; font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; } +.briefing-stat-value { display: block; font-size: 1.35rem; font-weight: 600; color: var(--text-primary); margin-top: 0.15rem; } + +.briefing-charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; } +@media (max-width: 900px) { .briefing-charts-grid { grid-template-columns: 1fr; } } +.briefing-chart-panel { + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.1); + border-radius: 10px; + padding: 0.75rem; + min-height: 220px; +} + +.briefing-details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; } +@media (max-width: 900px) { .briefing-details-grid { grid-template-columns: 1fr; } } + +.briefing-pending-item { + padding: 0.5rem 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + color: var(--text-secondary); + font-size: 0.9rem; +} + +.briefing-toggle { margin-top: 0.5rem; } +.briefing-full-report { + display: none; + margin-top: 0.75rem; + max-height: 280px; + overflow: auto; + white-space: pre-wrap; + font-size: 0.8rem; + color: var(--text-muted); + background: rgba(0,0,0,0.2); + border-radius: 8px; + padding: 1rem; + border: 1px solid rgba(148, 163, 184, 0.1); +} +.briefing-full-report.open { display: block; } + +.badge-positive { background: rgba(62,207,142,.2); color: #3ecf8e; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; } +.badge-neutral { background: rgba(107,114,128,.25); color: #cbd5e1; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; } +.badge-negative { background: rgba(239,68,68,.2); color: #ef4444; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; } +/* Settings page */ +.settings-header { margin-bottom: 0.5rem; } + +.settings-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.15); + padding-bottom: 0; +} +.settings-tab { + display: inline-block; + padding: 0.65rem 1.1rem; + color: var(--text-muted, #94a3b8); + text-decoration: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-size: 0.9rem; + transition: color 0.15s, border-color 0.15s; +} +.settings-tab:hover { color: var(--text-primary, #e2e8f0); } +.settings-tab.active { + color: var(--accent-gold, #e8a838); + border-bottom-color: var(--accent-gold, #e8a838); +} + +.settings-panel { border-left: 3px solid var(--accent-gold, #e8a838); } +.settings-panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} +.settings-active-banner { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 0.85rem; + background: rgba(62, 207, 142, 0.08); + border: 1px solid rgba(62, 207, 142, 0.25); + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.email-account-list { display: flex; flex-direction: column; gap: 0.75rem; } +.email-account-card { + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + padding: 1rem 1.1rem; +} +.email-account-card.is-active { border-color: rgba(62, 207, 142, 0.35); } +.email-account-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.email-account-card-head strong { display: block; } +.email-account-card-head .muted { font-size: 0.85rem; color: var(--text-muted); } +.email-account-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.settings-modal { max-width: 520px; width: 100%; } +.settings-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin: 0.75rem 0; +} +@media (max-width: 600px) { .settings-form-grid { grid-template-columns: 1fr; } } + +.form-label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem; } +.form-label .form-input { margin-top: 0.25rem; } +.form-check { display: flex; align-items: center; gap: 0.5rem; margin: 0.75rem 0; font-size: 0.9rem; } + +.settings-details { + margin: 0.75rem 0; + padding: 0.75rem; + border: 1px dashed rgba(148, 163, 184, 0.2); + border-radius: 8px; +} +.settings-details summary { cursor: pointer; color: var(--text-secondary); font-size: 0.85rem; } + +.sidebar .nav-settings { + margin-top: auto; + padding-top: 0.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.12); +} + +[x-cloak] { display: none !important; } +/* ── Horizontal top navigation ── */ +.topbar { + position: sticky; + top: 0; + z-index: 100; + background: linear-gradient(180deg, #0f172a 0%, #0b1220 100%); + border-bottom: 1px solid rgba(148, 163, 184, 0.15); + padding: 0.65rem 1.25rem 0; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35); +} +.topbar-brand { + display: inline-block; + margin-right: 1.5rem; + margin-bottom: 0.5rem; + vertical-align: middle; +} +.topbar-brand .brand-title { margin: 0; font-size: 0.7rem; letter-spacing: 0.12em; color: var(--accent-gold, #e8a838); text-transform: uppercase; } +.topbar-brand .brand-name { margin: 0; font-size: 1rem; font-weight: 600; color: var(--text-primary, #e2e8f0); } + +.topnav { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.35rem 1.25rem; + padding-bottom: 0; +} +.topnav-herman { + display: inline-flex; + align-items: center; + padding: 0.55rem 1.25rem; + margin-right: 0.5rem; + margin-bottom: -1px; + border-radius: 8px 8px 0 0; + font-weight: 700; + font-size: 0.95rem; + text-decoration: none; + color: #0f172a; + background: linear-gradient(135deg, #e8a838 0%, #f0c060 100%); + border: 1px solid rgba(232, 168, 56, 0.5); + border-bottom: none; + box-shadow: 0 -2px 12px rgba(232, 168, 56, 0.25); +} +.topnav-herman:hover { filter: brightness(1.05); color: #0f172a; } +.topnav-herman.active { background: linear-gradient(135deg, #e8a838 0%, #f5d078 100%); } + +.topnav-group { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.15rem; + padding-bottom: 0.45rem; + border-left: 1px solid rgba(148, 163, 184, 0.12); + padding-left: 1rem; +} +.topnav-group:first-of-type { border-left: none; padding-left: 0; } +.topnav-label { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted, #64748b); + margin-right: 0.35rem; + padding-right: 0.35rem; +} +.topnav-group a { + display: inline-block; + padding: 0.4rem 0.7rem; + border-radius: 6px; + font-size: 0.82rem; + text-decoration: none; + color: var(--text-secondary, #94a3b8); + border: 1px solid transparent; + transition: color 0.15s, background 0.15s, border-color 0.15s; + white-space: nowrap; +} +.topnav-group a:hover { + color: var(--text-primary, #e2e8f0); + background: rgba(148, 163, 184, 0.08); +} +.topnav-group a.active { + color: var(--accent-cyan, #38bdf8); + background: rgba(56, 189, 248, 0.1); + border-color: rgba(56, 189, 248, 0.25); +} +.topnav-group-system { margin-left: auto; border-left: 1px solid rgba(148, 163, 184, 0.12); } + +.main-topnav { + padding: 1.25rem 1.5rem 2rem; + min-height: calc(100vh - 80px); + background: var(--bg-root, #070b14); +} + +/* Herman hub layout */ +.herman-hub-header { margin-bottom: 1rem; } +.herman-hub-header h1 { margin: 0 0 0.25rem; } +.herman-hub-header .subtitle { margin: 0; } + +.hub-kpi-row { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.75rem; + margin-bottom: 1.25rem; +} +@media (max-width: 1100px) { .hub-kpi-row { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 700px) { .hub-kpi-row { grid-template-columns: repeat(2, 1fr); } } + +.hub-grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} +@media (max-width: 1000px) { .hub-grid { grid-template-columns: 1fr; } } + +.hub-section-tabs { + display: flex; + gap: 0.35rem; + margin-bottom: 1rem; + flex-wrap: wrap; + border-bottom: 1px solid rgba(148, 163, 184, 0.12); + padding-bottom: 0.5rem; +} +.hub-section-tabs button, .hub-section-tabs a { + padding: 0.45rem 0.9rem; + border-radius: 6px 6px 0 0; + font-size: 0.85rem; + background: transparent; + border: 1px solid transparent; + color: var(--text-muted); + text-decoration: none; + cursor: pointer; +} +.hub-section-tabs .active { + color: var(--accent-gold); + border-color: rgba(232, 168, 56, 0.3); + background: rgba(232, 168, 56, 0.08); +} + +.hub-feed-item { + padding: 0.55rem 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + font-size: 0.88rem; +} +.hub-feed-time { color: var(--text-muted); font-size: 0.75rem; margin-right: 0.5rem; } + +/* Hide old sidebar layout when topnav is active */ +body:has(.topbar) .layout { display: block; } +body:has(.topbar) .sidebar { display: none; } +body:has(.topbar) .main { margin-left: 0; } + +@media (max-width: 900px) { + .topnav { gap: 0.25rem 0.75rem; } + .topnav-group { padding-left: 0.5rem; } + .topnav-label { display: none; } +} + +/* Compact Herman briefing charts */ +.briefing-charts-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + margin-bottom: 0.75rem; + max-height: 180px; +} +@media (max-width: 1100px) { .briefing-charts-grid { grid-template-columns: repeat(2, 1fr); max-height: none; } } +.briefing-chart-panel { + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.1); + border-radius: 8px; + padding: 0.35rem 0.5rem; + min-height: 0 !important; + height: 155px; + position: relative; +} +.briefing-chart-panel canvas { max-height: 130px !important; } +.briefing-summary-grid { margin-bottom: 0.75rem; } +.briefing-kpi-row { margin-bottom: 0.75rem; } +.briefing-kpi-row .briefing-stat { padding: 0.45rem 0.6rem; } +.briefing-kpi-row .briefing-stat-value { font-size: 1.1rem; } + +.browser-instruct-box { + background: rgba(56, 189, 248, 0.06); + border: 1px solid rgba(56, 189, 248, 0.2); + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; +} +.browser-instruct-box textarea { min-height: 70px; } +.browser-steps-log { + font-size: 0.8rem; color: var(--text-muted); + margin-top: 0.5rem; max-height: 100px; overflow: auto; +} +.browser-action-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; } + +.novnc-fallback { + padding: 1rem; text-align: center; background: #111; + border-radius: 8px; color: #94a3b8; font-size: 0.85rem; +} +.novnc-fallback a { color: var(--accent-cyan); } diff --git a/cockpit/static/css/pulse-theme.css b/cockpit/static/css/pulse-theme.css new file mode 100644 index 0000000..53ece39 --- /dev/null +++ b/cockpit/static/css/pulse-theme.css @@ -0,0 +1,335 @@ +/* Telegram-inspired pulse & live feed theme — global readability */ +:root { + --pulse-blue: #38bdf8; + --pulse-green: #4ade80; + --pulse-purple: #c084fc; + --pulse-orange: #fb923c; + --pulse-glow: rgba(56, 189, 248, 0.5); +} + +/* ——— Leesbaarheid ——— */ +body, .panel, .main-topnav { color: var(--text-body); } +h1, h2, h3, h4, strong, .kpi-value, .briefing-stat-value { color: var(--text-primary) !important; } +.subtitle, .page-tip, .page-header .subtitle { color: var(--text-secondary) !important; font-size: 0.95rem !important; } +.muted, .empty-state, small { color: var(--text-muted) !important; } +.panel p, .feed-card p, .note-bubble p, .hub-feed-item, .data-table td { color: var(--text-body); } +.data-table th { color: var(--text-secondary); } +.feed-card .source { color: var(--pulse-blue) !important; font-weight: 600; } +a, .feed-card a, .ticker-link { color: var(--pulse-blue); text-decoration: none; } +a:hover, .feed-card a:hover, .ticker-link:hover { color: #7dd3fc; text-decoration: underline; } +.form-input, select, textarea, input { color: var(--text-primary) !important; } + +@keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 var(--pulse-glow); } + 70% { box-shadow: 0 0 0 12px rgba(56, 189, 248, 0); } + 100% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); } +} + +@keyframes pulse-ring-green { + 0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.45); } + 70% { box-shadow: 0 0 0 12px rgba(74, 222, 128, 0); } + 100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0); } +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(0.82); } +} + +@keyframes live-shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes ticker-scroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes tg-breathe { + 0%, 100% { box-shadow: 0 0 0 rgba(56,189,248,0); } + 50% { box-shadow: 0 0 20px rgba(56,189,248,0.08); } +} + +@keyframes glow-border { + 0%, 100% { border-color: rgba(56, 189, 248, 0.25); } + 50% { border-color: rgba(56, 189, 248, 0.55); } +} + +/* ——— Pulse buttons (Telegram style) ——— */ +.btn-pulse, .btn-pulse-green, .btn-pulse-purple { + position: relative; + font-weight: 600; + color: var(--text-primary) !important; +} +.btn-pulse { + animation: pulse-ring 2.2s infinite; + border: 1px solid var(--pulse-blue) !important; + background: linear-gradient(135deg, rgba(56,189,248,0.2), rgba(56,189,248,0.06)) !important; +} +.btn-pulse-green { + animation: pulse-ring-green 2.2s infinite; + border-color: var(--pulse-green) !important; + background: linear-gradient(135deg, rgba(74,222,128,0.2), rgba(74,222,128,0.06)) !important; +} +.btn-pulse-purple { + animation: pulse-ring 2.2s infinite; + --pulse-glow: rgba(192, 132, 252, 0.45); + border-color: var(--pulse-purple) !important; + background: linear-gradient(135deg, rgba(192,132,252,0.2), rgba(192,132,252,0.06)) !important; +} + +.btn-primary { + animation: pulse-ring 3s infinite; + font-weight: 600; + color: var(--text-primary) !important; +} + +/* Geen globale panel-beweging — alleen KPI kaarten animeren via hm-neo */ + +.live-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--pulse-green); +} +.live-badge::before { + content: ""; + width: 8px; height: 8px; + border-radius: 50%; + background: var(--pulse-green); + animation: pulse-dot 1.2s ease-in-out infinite; +} + +.live-feed-bar { + background: linear-gradient(90deg, #0f172a 20%, #1a3a5c 50%, #0f172a 80%); + background-size: 200% 100%; + animation: live-shimmer 4s linear infinite; + border: 1px solid rgba(56, 189, 248, 0.35); + border-radius: 12px; + padding: 0.65rem 1rem; + margin-bottom: 1rem; + overflow: hidden; +} +.ticker-track { display: flex; gap: 2rem; white-space: nowrap; animation: ticker-scroll 45s linear infinite; } +.ticker-track:hover { animation-play-state: paused; } +.ticker-item, .ticker-link { + background: rgba(56,189,248,0.12); + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.85rem; + color: var(--text-body) !important; + border: 1px solid rgba(56,189,248,0.25); + display: inline-block; + transition: all 0.2s; +} +.ticker-link:hover { + background: rgba(56,189,248,0.25); + color: var(--text-primary) !important; + transform: scale(1.02); +} + +.side-nav { display: flex; flex-direction: column; gap: 0.35rem; padding: 0.5rem; } +.side-nav-btn { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.65rem 0.85rem; + border: 1px solid transparent; border-radius: 10px; + background: transparent; color: var(--text-body); cursor: pointer; + text-align: left; font-size: 0.9rem; transition: all 0.2s; +} +.side-nav-btn:hover { background: rgba(56,189,248,0.1); border-color: rgba(56,189,248,0.25); color: var(--text-primary); } +.side-nav-btn.active { + background: rgba(56,189,248,0.18); + border-color: var(--pulse-blue); + box-shadow: 0 0 14px rgba(56,189,248,0.25); + color: var(--text-primary); +} + +.section-tabs { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.75rem 0; } +.section-tab { + padding: 0.4rem 0.85rem; border-radius: 999px; + border: 1px solid var(--border-subtle); + background: var(--bg-elevated); + font-size: 0.8rem; cursor: pointer; + color: var(--text-secondary); transition: all 0.2s; +} +.section-tab.active { + border-color: var(--pulse-blue); + background: rgba(56,189,248,0.22); + box-shadow: 0 0 10px rgba(56,189,248,0.3); + color: var(--text-primary); +} + +.feed-card { + padding: 0.85rem 1rem; + border-radius: 12px; + border: 1px solid var(--border-subtle); + margin-bottom: 0.6rem; + animation: fade-up 0.4s ease; + transition: border-color 0.2s, box-shadow 0.2s; + background: var(--bg-elevated); +} +.feed-card:hover { + border-color: var(--pulse-blue); + box-shadow: 0 0 16px rgba(56,189,248,0.12); +} +.feed-card a strong { color: var(--text-primary); font-size: 0.95rem; } +.feed-card .feed-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; flex-wrap: wrap; } +.feed-card .btn-source { + font-size: 0.75rem; padding: 0.25rem 0.65rem; + border-radius: 999px; border: 1px solid rgba(56,189,248,0.4); + background: rgba(56,189,248,0.1); color: var(--pulse-blue); + cursor: pointer; text-decoration: none; display: inline-block; +} +.feed-card .btn-source:hover { background: rgba(56,189,248,0.22); color: var(--text-primary); } +.feed-card .btn-bookmark { cursor: pointer; border: 1px solid rgba(255,215,0,0.35); background: rgba(255,215,0,0.08); color: #fcd34d; } +.feed-card .btn-bookmark.saved { background: rgba(255,215,0,0.22); color: #ffd700; } +.feed-card.saved-rss { border-left: 3px solid #ffd700; } +.promo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; } +.promo-card-link { display: block; text-decoration: none; color: inherit; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; } +.promo-card-link:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.25); } +.promo-card-link strong, .promo-card-link p, .promo-card-link small { color: inherit; } +.promo-cover { width: 100%; max-height: 180px; object-fit: cover; border-radius: 8px; margin-bottom: 0.65rem; } +.hm-neo-kpi-link { text-decoration: none; color: inherit; display: block; } +.hm-neo-kpi-link:hover .hm-neo-kpi { border-color: rgba(0,229,255,0.35); } + +.weather-row { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.weather-day { + flex: 1; min-width: 70px; text-align: center; padding: 0.5rem; + border-radius: 8px; background: rgba(56,189,248,0.1); + border: 1px solid rgba(56,189,248,0.2); font-size: 0.8rem; color: var(--text-body); +} +.weather-day strong { display: block; font-size: 1.05rem; color: var(--text-primary); } + +.note-bubble { + background: rgba(56,189,248,0.1); + border-left: 3px solid var(--pulse-blue); + padding: 0.65rem 0.85rem; border-radius: 0 8px 8px 0; + margin-bottom: 0.5rem; font-size: 0.9rem; +} + +.milestone-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-bottom: 1px solid var(--border-subtle); } +.milestone-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pulse-orange); animation: pulse-dot 2s infinite; } +.milestone-dot.done { background: var(--pulse-green); animation: none; } + +.pro-tab-bar { display: flex; gap: 0; border-bottom: none; margin-bottom: 1rem; } + +/* Neo tabs — shared with Beurs page (beurs.css extends this) */ +.neo-tab-bar.pro-tab-bar { + position: relative; + background: rgba(10, 14, 22, 0.9); + border: 1px solid rgba(56,189,248,0.2); + border-radius: 14px; + padding: 0.35rem; +} +.neo-tab-bar .neo-tab, .neo-tab-bar .pro-tab { + flex: 1; border: none; background: transparent; + color: #94a3b8; cursor: pointer; transition: color 0.2s, transform 0.2s; + border-bottom: none !important; margin-bottom: 0 !important; + padding: 0.65rem 0.5rem; font-weight: 600; font-size: 0.82rem; +} +.neo-tab-bar .neo-tab.active, .neo-tab-bar .pro-tab.active { + color: #f8fafc; + background: linear-gradient(135deg, rgba(56,189,248,0.2), rgba(74,222,128,0.08)); + border-radius: 10px; + box-shadow: 0 0 16px rgba(56,189,248,0.15); +} +.neo-tab-bar .neo-tab:hover, .neo-tab-bar .pro-tab:hover { color: #e2e8f0; transform: translateY(-1px); } + +.pro-tab { + padding: 0.75rem 1.25rem; border: none; background: transparent; + color: var(--text-muted); cursor: pointer; font-size: 0.9rem; + border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; +} +.pro-tab.active { color: var(--pulse-blue); border-bottom-color: var(--pulse-blue); } +.pro-tab:hover { color: var(--text-primary); } + +.kpi-pulse strong { + background: linear-gradient(90deg, var(--pulse-blue), var(--pulse-purple)); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; +} + +/* ——— Herman dashboard pulse ——— */ +.herman-hero { + background: linear-gradient(135deg, rgba(56,189,248,0.12), rgba(192,132,252,0.08)); + border: 1px solid rgba(56,189,248,0.3); + border-radius: 16px; + padding: 1.5rem 1.75rem; + margin-bottom: 1.25rem; + animation: glow-border 3s ease-in-out infinite; +} +.herman-hero h1 { margin: 0 0 0.35rem; font-size: 1.75rem; } +.herman-hero .hero-sub { color: var(--text-secondary); font-size: 1rem; margin: 0; } + +.pulse-panel { + border: 1px solid rgba(56,189,248,0.2); + border-radius: 12px; + animation: glow-border 4s ease-in-out infinite; +} + +.hub-kpi-row .kpi-card, .clickable-kpi { + transition: transform 0.2s, box-shadow 0.2s; + animation: fade-up 0.5s ease; +} +.hub-kpi-row .kpi-card:hover, .clickable-kpi:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(56,189,248,0.15); +} + +.hub-feed-item { + padding: 0.6rem 0; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-body); + animation: fade-up 0.35s ease; +} + +.briefing-typewriter { + color: var(--text-primary); + line-height: 1.75; + font-size: 1rem; + min-height: 4rem; +} + +.briefing-horizon-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin: 1rem 0; +} +@media (max-width: 900px) { .briefing-horizon-grid { grid-template-columns: 1fr; } } + +.horizon-card { + background: var(--bg-elevated); + border-radius: 12px; + padding: 1rem 1.15rem; + border: 1px solid var(--border-subtle); +} +.horizon-card.short { border-color: rgba(251,146,60,0.35); } +.horizon-card.long { border-color: rgba(192,132,252,0.35); } +.horizon-card h3 { margin: 0 0 0.65rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; } +.horizon-card.short h3 { color: var(--pulse-orange); } +.horizon-card.long h3 { color: var(--pulse-purple); } +.horizon-card ul { margin: 0; padding-left: 1.1rem; color: var(--text-body); line-height: 1.65; } +.horizon-card li { margin-bottom: 0.4rem; } + +.retail-highlight { + display: flex; justify-content: space-between; align-items: center; + padding: 0.55rem 0; border-bottom: 1px solid var(--border-subtle); + color: var(--text-body); font-size: 0.9rem; +} +.retail-highlight strong { color: var(--text-primary); } + +.panel { color: var(--text-body); } +.panel h2 { color: var(--text-primary); } + +.kpi-card span { color: var(--text-secondary) !important; } +.kpi-card strong { color: var(--text-primary) !important; } diff --git a/cockpit/static/css/retail.css b/cockpit/static/css/retail.css new file mode 100644 index 0000000..9ee5f79 --- /dev/null +++ b/cockpit/static/css/retail.css @@ -0,0 +1,116 @@ +.retail-workspace { + display: grid; + grid-template-columns: 200px 280px 1fr 360px; + gap: 1rem; + min-height: 75vh; +} + +.retail-sidebar { overflow-y: auto; max-height: 75vh; } +.retail-filters label { + display: block; + margin-bottom: 0.75rem; + font-size: 0.85rem; +} +.retail-filters select, +.retail-filters input[type="text"], +.retail-filters input[type="number"] { + width: 100%; + margin-top: 0.25rem; + padding: 0.4rem 0.5rem; + border: 1px solid var(--border, #334155); + border-radius: 6px; + background: var(--panel-bg, #0f172a); + color: inherit; +} +.retail-filters h4 { margin: 1rem 0 0.5rem; font-size: 0.9rem; } +.retail-filters .checkbox { display: flex; align-items: center; gap: 0.5rem; } + +.retail-map-wrap { position: relative; min-height: 500px; } +.retail-map { height: 75vh; min-height: 500px; border-radius: 8px; border: 1px solid #334155; } +.map-loading { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.3); border-radius: 8px; pointer-events: none; +} + +.retail-panel { overflow-y: auto; max-height: 75vh; } +.retail-panel .detail-section { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid #334155; } +.retail-panel .detail-dl { + display: grid; grid-template-columns: 1fr 1fr; gap: 0.25rem 0.5rem; font-size: 0.85rem; +} +.retail-panel .detail-dl dt { opacity: 0.7; } +.retail-panel .detail-dl dd { margin: 0; font-weight: 600; text-align: right; } + +.clickable-row { cursor: pointer; } +.clickable-row:hover { opacity: 0.85; } + +.retail-actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } +.page-header { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem; } + +.kpi-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; } +.kpi-card { + flex: 1; min-width: 110px; padding: 0.75rem 1rem; + background: var(--panel-bg, #1e293b); border-radius: 8px; border: 1px solid #334155; +} +.kpi-card span { display: block; font-size: 0.75rem; opacity: 0.7; } +.kpi-card strong { font-size: 1.25rem; } +.kpi-green strong { color: #22c55e; } + +.retail-main { min-height: 500px; } +.retail-table-wrap { max-height: 75vh; overflow: auto; } +.retail-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; } +.retail-table th, .retail-table td { padding: 0.4rem 0.5rem; border-bottom: 1px solid #334155; text-align: left; } +.retail-table th { position: sticky; top: 0; background: var(--panel-bg, #1e293b); z-index: 1; } + +.trends-ticker { margin-bottom: 1rem; padding: 0.75rem 1rem; overflow-x: auto; } +.ticker-items { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 0.5rem; } +.ticker-item { + background: rgba(42,171,238,0.1); padding: 0.25rem 0.6rem; border-radius: 999px; + font-size: 0.8rem; white-space: nowrap; border: 1px solid rgba(42,171,238,0.2); +} + +.field-picker { margin-top: 1rem; font-size: 0.8rem; } +.field-group { margin: 0.5rem 0; } +.btn-block { width: 100%; margin-top: 0.35rem; } +.muted { opacity: 0.75; font-size: 0.9rem; } +.hint { font-size: 0.8rem; opacity: 0.7; } + +.wholesale-list { max-height: 75vh; overflow-y: auto; } +.wholesale-item { + padding: 0.6rem; border-bottom: 1px solid #1e293b; cursor: pointer; font-size: 0.85rem; +} +.wholesale-item:hover { background: rgba(42,171,238,0.06); } +.wholesale-item.active { border-left: 3px solid var(--pulse-blue, #2aabee); } + +.filter-group { + border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; + margin-bottom: 0.65rem; padding: 0.35rem 0.5rem; background: rgba(0,0,0,0.15); +} +.filter-group summary { + cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #cbd5e1; + padding: 0.35rem 0; list-style: none; +} +.filter-group summary::-webkit-details-marker { display: none; } +.filter-actions { margin: 0.75rem 0; } +.retail-filters { overflow-y: auto; max-height: 75vh; } + +.retail-regs-wrap { max-height: 75vh; overflow-y: auto; } +.regs-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } +.regs-columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } +@media (max-width: 1100px) { .regs-columns { grid-template-columns: 1fr; } } +.regs-columns h4 { font-size: 0.85rem; color: #94a3b8; margin: 0 0 0.5rem; } +.reg-item { padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); } +.reg-item a { color: #e2e8f0; text-decoration: none; font-size: 0.85rem; } +.reg-item a:hover { color: var(--pulse-blue, #2aabee); } +.reg-item small { display: block; color: #64748b; font-size: 0.7rem; margin-top: 0.15rem; } +.reg-badge { + display: inline-block; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.08em; + padding: 0.1rem 0.35rem; border-radius: 4px; margin-bottom: 0.2rem; + background: rgba(168,85,247,0.15); color: #a855f7; +} +.reg-badge.cbs { background: rgba(56,189,248,0.15); color: #38bdf8; } +.reg-badge.markt { background: rgba(255,159,67,0.15); color: #ff9f43; } + +@media (max-width: 1400px) { + .retail-workspace { grid-template-columns: 180px 260px 1fr; } + .retail-panel { grid-column: 1 / -1; max-height: 50vh; } +} diff --git a/cockpit/static/css/tokens.css b/cockpit/static/css/tokens.css new file mode 100644 index 0000000..a882b7e --- /dev/null +++ b/cockpit/static/css/tokens.css @@ -0,0 +1,29 @@ +:root { + --bg-root: #0a0e14; + --bg-surface: #121820; + --bg-elevated: #182030; + --bg-hover: #1e2a3a; + --border-subtle: #2d3f54; + --border-strong: #3d5268; + --text-primary: #f4f8fc; + --text-secondary: #c8d4e0; + --text-muted: #9fb0c4; + --text-body: #dce6f0; + --accent-cyan: #38bdf8; + --accent-cyan-dim: rgba(56, 189, 248, 0.18); + --accent-purple: #c084fc; + --accent-purple-dim: rgba(192, 132, 252, 0.18); + --accent-amber: #fcd34d; + --accent-amber-dim: rgba(252, 211, 77, 0.18); + --accent-green: #4ade80; + --accent-green-dim: rgba(74, 222, 128, 0.18); + --accent-red: #fb7185; + --accent-red-dim: rgba(251, 113, 133, 0.18); + --shadow-panel: 0 4px 24px rgba(0, 0, 0, 0.45); + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --font-sans: "Inter", "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; + --sidebar-width: 240px; +} diff --git a/cockpit/static/css/topnav-neo.css b/cockpit/static/css/topnav-neo.css new file mode 100644 index 0000000..505ea38 --- /dev/null +++ b/cockpit/static/css/topnav-neo.css @@ -0,0 +1,196 @@ +/* Neo topnav — unieke kleur per tab + glow pill effect */ + +.topnav { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.4rem 0.85rem; + padding-bottom: 0.55rem; +} + +/* Shared pill base */ +.nav-pill, +.topnav-herman.nav-pill { + --pill-color: #38bdf8; + --pill-glow: rgba(56, 189, 248, 0.45); + --pill-bg: rgba(56, 189, 248, 0.12); + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.45rem 0.85rem; + border-radius: 10px; + font-size: 0.8rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + color: #cbd5e1; + border: 1px solid color-mix(in srgb, var(--pill-color) 28%, rgba(255, 255, 255, 0.06)); + background: rgba(15, 23, 42, 0.6); + transition: transform 0.2s, box-shadow 0.25s, border-color 0.25s, color 0.2s; + overflow: hidden; + isolation: isolate; +} + +.nav-pill::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(135deg, var(--pill-bg), transparent 70%); + opacity: 0.85; + z-index: -1; + transition: opacity 0.25s; +} + +.nav-pill::after { + content: ""; + position: absolute; + top: -50%; + left: -60%; + width: 50%; + height: 200%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent); + transform: skewX(-20deg); + opacity: 0; + transition: opacity 0.3s, left 0.5s; + pointer-events: none; +} + +.nav-pill:hover { + transform: translateY(-2px); + color: #f8fafc; + border-color: color-mix(in srgb, var(--pill-color) 50%, transparent); + box-shadow: 0 4px 20px var(--pill-glow), 0 0 0 1px color-mix(in srgb, var(--pill-color) 25%, transparent); +} + +.nav-pill:hover::after { + opacity: 1; + left: 120%; +} + +.nav-pill.active { + color: #fff; + border-color: var(--pill-color); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--pill-color) 40%, transparent), + 0 0 22px var(--pill-glow), + inset 0 0 18px color-mix(in srgb, var(--pill-color) 18%, transparent); + text-shadow: 0 0 12px var(--pill-glow); + animation: nav-pill-pulse 2.5s ease-in-out infinite; +} + +.nav-pill.active::before { + opacity: 1; + background: linear-gradient(145deg, color-mix(in srgb, var(--pill-color) 35%, transparent), color-mix(in srgb, var(--pill-color) 8%, transparent)); +} + +@keyframes nav-pill-pulse { + 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--pill-color) 40%, transparent), 0 0 18px var(--pill-glow), inset 0 0 14px color-mix(in srgb, var(--pill-color) 15%, transparent); } + 50% { box-shadow: 0 0 0 1px var(--pill-color), 0 0 28px var(--pill-glow), inset 0 0 22px color-mix(in srgb, var(--pill-color) 22%, transparent); } +} + +/* Herman — reset legacy gold tab, use neo pill */ +.topnav-herman.nav-pill { + margin-right: 0; + margin-bottom: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 23, 42, 0.6); + color: #cbd5e1; + box-shadow: none; +} +.topnav-herman.nav-pill.active { + color: #fff; + background: rgba(15, 23, 42, 0.6); +} + +.nav-pill-herman { + --pill-color: #fbbf24; + --pill-glow: rgba(251, 191, 36, 0.55); + --pill-bg: rgba(251, 191, 36, 0.2); + font-weight: 700; + font-size: 0.88rem; + padding: 0.5rem 1.1rem; +} + +/* CRM */ +.nav-pill-clients { --pill-color: #38bdf8; --pill-glow: rgba(56, 189, 248, 0.5); --pill-bg: rgba(56, 189, 248, 0.15); } +.nav-pill-deals { --pill-color: #4ade80; --pill-glow: rgba(74, 222, 128, 0.5); --pill-bg: rgba(74, 222, 128, 0.12); } +.nav-pill-products { --pill-color: #a855f7; --pill-glow: rgba(168, 85, 247, 0.5); --pill-bg: rgba(168, 85, 247, 0.12); } +.nav-pill-suppliers { --pill-color: #fb923c; --pill-glow: rgba(251, 146, 60, 0.5); --pill-bg: rgba(251, 146, 60, 0.12); } + +/* Intel */ +.nav-pill-beurs { --pill-color: #b8ff3c; --pill-glow: rgba(184, 255, 60, 0.45); --pill-bg: rgba(184, 255, 60, 0.1); } +.nav-pill-retail { --pill-color: #0066cc; --pill-glow: rgba(0, 102, 204, 0.55); --pill-bg: rgba(0, 102, 204, 0.18); } +.nav-pill-browser { --pill-color: #14b8a6; --pill-glow: rgba(20, 184, 166, 0.5); --pill-bg: rgba(20, 184, 166, 0.12); } +.nav-pill-documents { --pill-color: #94a3b8; --pill-glow: rgba(148, 163, 184, 0.4); --pill-bg: rgba(148, 163, 184, 0.1); } + +/* Hermes */ +.nav-pill-telegram { --pill-color: #2aabee; --pill-glow: rgba(42, 171, 238, 0.55); --pill-bg: rgba(42, 171, 238, 0.15); } + +/* Agents */ +.nav-pill-agents { --pill-color: #00e5ff; --pill-glow: rgba(0, 229, 255, 0.5); --pill-bg: rgba(0, 229, 255, 0.1); } +.nav-pill-marketing { --pill-color: #ff6b9d; --pill-glow: rgba(255, 107, 157, 0.5); --pill-bg: rgba(255, 107, 157, 0.12); } +.nav-pill-chat { --pill-color: #fcd34d; --pill-glow: rgba(252, 211, 77, 0.45); --pill-bg: rgba(252, 211, 77, 0.12); } +.nav-pill-studio { --pill-color: #ec4899; --pill-glow: rgba(236, 72, 153, 0.5); --pill-bg: rgba(236, 72, 153, 0.12); } +.nav-pill-reports { --pill-color: #6366f1; --pill-glow: rgba(99, 102, 241, 0.5); --pill-bg: rgba(99, 102, 241, 0.12); } + +/* System */ +.nav-pill-analytics { --pill-color: #8b5cf6; --pill-glow: rgba(139, 92, 246, 0.5); --pill-bg: rgba(139, 92, 246, 0.12); } +.nav-pill-voice { --pill-color: #f97316; --pill-glow: rgba(249, 115, 22, 0.5); --pill-bg: rgba(249, 115, 22, 0.12); } +.nav-pill-ops { --pill-color: #22d3ee; --pill-glow: rgba(34, 211, 238, 0.5); --pill-bg: rgba(34, 211, 238, 0.12); } +.nav-pill-settings { --pill-color: #e2e8f0; --pill-glow: rgba(226, 232, 240, 0.35); --pill-bg: rgba(226, 232, 240, 0.08); } + +/* Group labels — subtiel gekleurd */ +.topnav-group { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.35rem 0.65rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.04); + background: rgba(0, 0, 0, 0.15); +} +.topnav-group:first-of-type { border-left: none; } +.topnav-group-system { margin-left: auto; } + +.topnav-label { + font-size: 0.58rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #64748b; + margin-right: 0.15rem; + padding: 0.2rem 0.35rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); +} + +.topnav-group-crm .topnav-label { color: #38bdf8; } +.topnav-group-intel .topnav-label { color: #b8ff3c; } +.topnav-group-hermes .topnav-label { color: #2aabee; } +.topnav-group-agents .topnav-label { color: #00e5ff; } + +/* Override legacy palantir topnav link styles */ +.topnav-group a.nav-pill { + margin: 0; +} +.topnav-group a.nav-pill:hover, +.topnav-group a.nav-pill.active { + background: rgba(15, 23, 42, 0.6); +} + +.topbar { + padding: 0.65rem 1.25rem 0.35rem; +} + +@media (max-width: 900px) { + .topnav-label { display: none; } + .nav-pill { padding: 0.4rem 0.65rem; font-size: 0.75rem; } + .topnav-group { padding: 0.25rem 0.4rem; } +} + +@media (prefers-reduced-motion: reduce) { + .nav-pill.active { animation: none; } + .nav-pill:hover { transform: none; } +} diff --git a/cockpit/static/css/vertical-tabs.css b/cockpit/static/css/vertical-tabs.css new file mode 100644 index 0000000..d6044ce --- /dev/null +++ b/cockpit/static/css/vertical-tabs.css @@ -0,0 +1,117 @@ +/* Vertical tab navigation with animated active indicator */ + +.vtabs-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 1rem; + align-items: start; + min-height: 60vh; +} +.vtabs-nav { + position: sticky; + top: 1rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.75rem; + border-radius: 14px; + background: rgba(10, 14, 20, 0.85); + border: 1px solid rgba(148, 163, 184, 0.12); + overflow: hidden; +} +.vtabs-nav::before { + content: ''; + position: absolute; + left: 0; + top: var(--vt-indicator-top, 0); + width: 3px; + height: var(--vt-indicator-h, 40px); + background: linear-gradient(180deg, var(--vt-color, #00e5ff), transparent); + border-radius: 0 4px 4px 0; + transition: top 0.35s cubic-bezier(0.34, 1.4, 0.64, 1), height 0.25s ease; + box-shadow: 0 0 12px var(--vt-color, #00e5ff); +} +.vtab-btn { + position: relative; + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.65rem 0.85rem; + border: none; + border-radius: 10px; + background: transparent; + color: #94a3b8; + font-size: 0.85rem; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: color 0.2s, background 0.25s, transform 0.2s; + text-decoration: none; +} +.vtab-btn:hover { + color: #e2e8f0; + background: rgba(255, 255, 255, 0.04); + transform: translateX(4px); +} +.vtab-btn.active { + color: #fff; + background: color-mix(in srgb, var(--vt-color, #00e5ff) 12%, transparent); + text-shadow: 0 0 10px color-mix(in srgb, var(--vt-color, #00e5ff) 50%, transparent); +} +.vtab-icon { font-size: 1.1rem; flex-shrink: 0; } +.vtabs-content { min-width: 0; animation: vtabFadeIn 0.35s ease; } +@keyframes vtabFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* App sidebar layout */ +.app-shell { + display: grid; + grid-template-columns: 200px 1fr; + min-height: 100vh; +} +.app-sidebar { + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + padding: 1rem 0.65rem; + background: linear-gradient(180deg, rgba(8, 12, 18, 0.98), rgba(12, 18, 28, 0.95)); + border-right: 1px solid rgba(148, 163, 184, 0.1); + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.app-sidebar-brand { + padding: 0.5rem 0.75rem 1rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + margin-bottom: 0.5rem; +} +.app-sidebar-brand .brand-title { font-size: 0.65rem; letter-spacing: 0.12em; color: #64748b; margin: 0; } +.app-sidebar-brand .brand-name { font-size: 1rem; font-weight: 700; color: #e2e8f0; margin: 0.15rem 0 0; } +.app-sidebar-group { margin-bottom: 0.75rem; } +.app-sidebar-label { + font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.1em; + color: #64748b; padding: 0.25rem 0.75rem; margin-bottom: 0.25rem; +} +.app-sidebar .nav-pill { + display: flex; width: 100%; justify-content: flex-start; + margin-bottom: 0.2rem; border-radius: 10px; + animation: none; +} +.app-sidebar .nav-pill.active { animation: nav-pill-pulse 2.5s ease-in-out infinite; } +.app-main { min-width: 0; } +.app-topbar { + display: flex; justify-content: space-between; align-items: center; + padding: 0.65rem 1.25rem; border-bottom: 1px solid rgba(148, 163, 184, 0.08); + background: rgba(8, 12, 18, 0.6); +} +.app-clock { + font-family: ui-monospace, monospace; font-size: 0.85rem; color: #94a3b8; +} +.app-clock strong { color: #e2e8f0; } +.main-topnav { padding: 1rem 1.25rem; } + +/* Mobile layout → see mobile.css */ diff --git a/cockpit/static/icons/app-icon.svg b/cockpit/static/icons/app-icon.svg new file mode 100644 index 0000000..a17e225 --- /dev/null +++ b/cockpit/static/icons/app-icon.svg @@ -0,0 +1,6 @@ + + + + F + + diff --git a/cockpit/static/js/agents-mesh.js b/cockpit/static/js/agents-mesh.js new file mode 100644 index 0000000..96c7472 --- /dev/null +++ b/cockpit/static/js/agents-mesh.js @@ -0,0 +1,109 @@ +(function () { + const NS = 'http://www.w3.org/2000/svg'; + + function make(tag, attrs = {}) { + const el = document.createElementNS(NS, tag); + Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v))); + return el; + } + + function clear(el) { + while (el.firstChild) el.removeChild(el.firstChild); + } + + function curvePath(x1, y1, x2, y2) { + const cx1 = x1 + (x2 - x1) * 0.35; + const cy1 = y1; + const cx2 = x1 + (x2 - x1) * 0.72; + const cy2 = y2; + return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`; + } + + async function loadMeshData() { + const r = await fetch('/api/agents/mesh'); + if (!r.ok) throw new Error('Mesh API unavailable'); + return r.json(); + } + + function drawMesh(svg, data) { + clear(svg); + const vb = svg.viewBox.baseVal; + const width = vb && vb.width ? vb.width : 1000; + const height = vb && vb.height ? vb.height : 620; + const cx = width / 2; + const cy = height / 2; + const radius = Math.min(width, height) * 0.35; + + const souls = (data.nodes || []).filter((n) => (n.agent_key || '').toLowerCase() !== 'herman'); + const edgesBySource = {}; + (data.edges || []).forEach((e) => { edgesBySource[e.source] = e; }); + + souls.forEach((node, i) => { + const a = (Math.PI * 2 * i) / Math.max(1, souls.length) - Math.PI / 2; + node._x = cx + Math.cos(a) * radius; + node._y = cy + Math.sin(a) * radius; + }); + + const edgesLayer = make('g'); + const nodesLayer = make('g'); + svg.appendChild(edgesLayer); + svg.appendChild(nodesLayer); + + souls.forEach((node) => { + const pathDef = curvePath(node._x, node._y, cx, cy); + const edge = make('path', { d: pathDef, class: 'mesh-edge' }); + edgesLayer.appendChild(edge); + + if (edgesBySource[node.agent_key]) { + const pulse = make('path', { d: pathDef, class: 'mesh-pulse' }); + pulse.style.animationDuration = `${Math.max(1.2, 3.5 - Math.min(2.2, edgesBySource[node.agent_key].weight / 8))}s`; + edgesLayer.appendChild(pulse); + } + }); + + const herman = make('g', { class: 'mesh-node mesh-herman' }); + herman.appendChild(make('circle', { class: 'ring', cx, cy, r: 62 })); + herman.appendChild(make('circle', { class: 'main', cx, cy, r: 46 })); + const crown = make('text', { x: cx, y: cy - 2, 'font-size': 24, 'text-anchor': 'middle' }); + crown.textContent = '👑'; + herman.appendChild(crown); + const label = make('text', { x: cx, y: cy + 22 }); + label.textContent = 'Herman · Co-CEO'; + herman.appendChild(label); + nodesLayer.appendChild(herman); + + souls.forEach((node) => { + const group = make('g', { class: `mesh-node ${node.health || 'idle'}` }); + group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 24 })); + const shortName = (node.display_name || node.agent_key || '?').split(' ')[0]; + const t = make('text', { x: node._x, y: node._y + 4 }); + t.textContent = shortName; + group.appendChild(t); + const role = make('text', { x: node._x, y: node._y + 42, 'font-size': 10, opacity: 0.82 }); + role.textContent = node.role_title || node.agent_key; + group.appendChild(role); + group.addEventListener('click', () => { + if (window.Cockpit && Cockpit.toast) { + Cockpit.toast(`${node.display_name || node.agent_key}: ${node.health || 'idle'}`, 'success'); + } + }); + nodesLayer.appendChild(group); + }); + } + + async function mount(targetId) { + const svg = document.getElementById(targetId); + if (!svg) return; + try { + const data = await loadMeshData(); + drawMesh(svg, data); + } catch (e) { + clear(svg); + const msg = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 }); + msg.textContent = 'Mesh kon niet geladen worden'; + svg.appendChild(msg); + } + } + + window.AgentsMesh = { mount }; +})(); diff --git a/cockpit/static/js/analytics.js b/cockpit/static/js/analytics.js new file mode 100644 index 0000000..ce0718a --- /dev/null +++ b/cockpit/static/js/analytics.js @@ -0,0 +1,181 @@ +window.AnalyticsCharts = (function () { + var charts = {}; + var colors = { + gold: 'rgba(252, 211, 77, 0.85)', cyan: 'rgba(56, 189, 248, 0.85)', + green: 'rgba(74, 222, 128, 0.85)', red: 'rgba(251, 113, 133, 0.85)', + purple: 'rgba(168, 85, 247, 0.85)', orange: 'rgba(255, 159, 67, 0.85)', + gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.12)', text: '#c8d4e0', + }; + var palette = [colors.gold, colors.cyan, colors.green, colors.purple, colors.orange, colors.red, colors.gray]; + + function destroyAll() { + Object.keys(charts).forEach(function (k) { + if (charts[k]) { charts[k].destroy(); charts[k] = null; } + }); + } + + function barChart(id, labels, values, label, horizontal) { + var canvas = document.getElementById(id); + if (!canvas || typeof Chart === 'undefined') return; + if (charts[id]) charts[id].destroy(); + charts[id] = new Chart(canvas, { + type: 'bar', + data: { + labels: labels, + datasets: [{ label: label, data: values, backgroundColor: palette.slice(0, labels.length), borderRadius: 6 }], + }, + options: { + indexAxis: horizontal ? 'y' : 'x', + responsive: true, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: colors.text, maxRotation: 45 }, grid: { color: colors.grid } }, + y: { ticks: { color: colors.text }, grid: { color: colors.grid } }, + }, + }, + }); + } + + function doughnut(id, labels, values) { + var canvas = document.getElementById(id); + if (!canvas || typeof Chart === 'undefined') return; + if (charts[id]) charts[id].destroy(); + charts[id] = new Chart(canvas, { + type: 'doughnut', + data: { labels: labels, datasets: [{ data: values, backgroundColor: palette, borderWidth: 0 }] }, + options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: colors.text, boxWidth: 12 } } } }, + }); + } + + function lineChart(id, labels, values) { + var canvas = document.getElementById(id); + if (!canvas || typeof Chart === 'undefined') return; + if (charts[id]) charts[id].destroy(); + charts[id] = new Chart(canvas, { + type: 'line', + data: { + labels: labels, + datasets: [{ data: values, borderColor: colors.cyan, backgroundColor: 'rgba(56,189,248,0.15)', fill: true, tension: 0.3 }], + }, + options: { + responsive: true, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: colors.text }, grid: { color: colors.grid } }, + y: { ticks: { color: colors.text }, grid: { color: colors.grid } }, + }, + }, + }); + } + + function renderKpis(container, kpis) { + if (!container || !kpis) return; + var items = [ + ['Klanten totaal', kpis.clients_total], ['Actieve klanten', kpis.clients_active], + ['Pipeline €', '€' + Math.round(kpis.pipeline_eur || 0).toLocaleString('nl-NL')], + ['Supermarkten', kpis.supermarkets], ['Groothandels', kpis.wholesalers], + ['CRM actief', kpis.crm_partnerships], ['RSS items', kpis.rss_items], + ['Bookmarks', kpis.rss_bookmarks], ['Promo's', kpis.promo_campaigns], + ['Agent events', kpis.agent_events], ['Goedkeuringen', kpis.pending_approvals], + ['Contacten SM', kpis.contacts_supermarket], ['NAS docs', kpis.nas_docs], + ]; + container.innerHTML = items.map(function (it) { + return '
' + it[0] + '' + it[1] + '
'; + }).join(''); + } + + function renderTables(data) { + var dealsEl = document.getElementById('analytics-deals-table'); + var eventsEl = document.getElementById('analytics-events-table'); + if (dealsEl) { + dealsEl.innerHTML = (data.recent_deals || []).map(function (d) { + return '' + (d.title || '—') + '' + (d.stage || '') + '€' + Math.round(Number(d.value || 0)).toLocaleString('nl-NL') + ''; + }).join('') || 'Geen deals'; + } + if (eventsEl) { + eventsEl.innerHTML = (data.recent_events || []).map(function (e) { + var t = (e.created_at || '').substring(0, 16).replace('T', ' '); + return '' + (e.agent_name || '') + '' + (e.title || e.event_type || '') + '' + t + ''; + }).join('') || 'Geen events'; + } + } + + function render(data) { + if (!data) return; + renderKpis(document.getElementById('analytics-kpis'), data.kpis); + barChart('chart-deals', (data.deals_by_stage || []).map(function (r) { return r.stage; }), + (data.deals_by_stage || []).map(function (r) { return Number(r.total || r.cnt || 0); }), 'EUR'); + barChart('chart-clients', (data.clients_by_stage || []).map(function (r) { return r.stage; }), + (data.clients_by_stage || []).map(function (r) { return Number(r.cnt || 0); }), 'Klanten'); + barChart('chart-chains', (data.supermarkets_by_chain || []).map(function (r) { return r.chain; }), + (data.supermarkets_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen'); + doughnut('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }), + (data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); })); + doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }), + (data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); })); + barChart('chart-agents', (data.events_by_agent || []).map(function (r) { return r.agent_name; }), + (data.events_by_agent || []).map(function (r) { return Number(r.cnt || 0); }), 'Events', true); + lineChart('chart-timeline', (data.events_timeline || []).map(function (r) { return String(r.day || '').substring(5); }), + (data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); })); + doughnut('chart-rss', (data.rss_by_category || []).map(function (r) { return r.category || 'other'; }), + (data.rss_by_category || []).map(function (r) { return Number(r.cnt || 0); })); + barChart('chart-wholesale', (data.wholesalers_by_province || []).map(function (r) { return r.province; }), + (data.wholesalers_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'GH'); + doughnut('chart-sentiment', (data.sentiment_distribution || []).map(function (r) { return r.sentiment_label || 'neutral'; }), + (data.sentiment_distribution || []).map(function (r) { return Number(r.cnt || 0); })); + barChart('chart-words', (data.top_words || []).slice(0, 10).map(function (r) { return r.lemma; }), + (data.top_words || []).slice(0, 10).map(function (r) { return Number(r.total || 0); }), 'Count', true); + barChart('chart-opportunities', (data.top_opportunities || []).map(function (r) { return (r.chain || '') + ' ' + (r.city || ''); }), + (data.top_opportunities || []).map(function (r) { return Math.round(Number(r.halal_opportunity_score || 0)); }), 'Score', true); + barChart('chart-promo', (data.promo_by_chain || []).map(function (r) { return r.chain || '?'; }), + (data.promo_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Promo'); + doughnut('chart-milestones', (data.milestones_by_status || []).map(function (r) { return r.status; }), + (data.milestones_by_status || []).map(function (r) { return Number(r.cnt || 0); })); + renderTables(data); + } + + return { render: render, destroyAll: destroyAll }; +})(); + +function analyticsHub() { + return { + loading: false, + updatedAt: '—', + liveLabel: 'Live', + f: { chain: '', province: '', stage: '', agent: '', days: 90 }, + meta: {}, + _pollStop: null, + init() { + var initial = window.ANALYTICS_INITIAL || {}; + this.meta = initial.filter_meta || {}; + AnalyticsCharts.render(initial); + this.updatedAt = (initial.generated_at || '').substring(0, 19).replace('T', ' '); + this._pollStop = CockpitLive.startPolling(function () { return this.refresh(false); }.bind(this), 30000); + }, + params() { + var p = new URLSearchParams(); + Object.entries(this.f).forEach(function (e) { + if (e[1] !== null && e[1] !== '' && e[1] !== undefined) p.set(e[0], e[1]); + }); + return p.toString(); + }, + async refresh(toast) { + this.loading = true; + try { + var data = await fetch('/analytics/api/data?' + this.params()).then(function (r) { return r.json(); }); + this.meta = data.filter_meta || this.meta; + AnalyticsCharts.render(data); + this.updatedAt = (data.generated_at || '').substring(0, 19).replace('T', ' '); + if (toast !== false) Cockpit.toast('Analytics bijgewerkt', 'success'); + } catch (e) { + if (toast !== false) Cockpit.toast(e.message, 'error'); + } finally { + this.loading = false; + } + }, + resetFilters() { + this.f = { chain: '', province: '', stage: '', agent: '', days: 90 }; + this.refresh(); + }, + }; +} diff --git a/cockpit/static/js/beurs.js b/cockpit/static/js/beurs.js new file mode 100644 index 0000000..b91e7c3 --- /dev/null +++ b/cockpit/static/js/beurs.js @@ -0,0 +1,100 @@ +window.beursHub = function () { + return { + tab: new URLSearchParams(location.search).get('tab') || 'beurs', + busy: false, + lastRefresh: '', + listed: [], + unlisted: [], + summary: {}, + trends: { halal_meat_trends: [], top_dishes: [], market_trends: [] }, + concepts: [], + events: [], + platformStats: {}, + eventFilter: '', + pollTimer: null, + indicatorStyle: { left: '0%', width: '25%' }, + + init() { + this.updateIndicator(); + this.refreshAll(); + this.pollTimer = setInterval(() => { + if (this.tab === 'events') this.loadEvents(false); + }, 5000); + }, + + setTab(t) { + this.tab = t; + const url = new URL(location.href); + url.searchParams.set('tab', t); + history.replaceState({}, '', url); + this.updateIndicator(); + if (t === 'trends' && !this.trends.halal_meat_trends?.length) this.loadTrends(); + if (t === 'concepten' && !this.concepts.length) this.loadConcepts(); + if (t === 'events') this.loadEvents(); + }, + + updateIndicator() { + const tabs = ['beurs', 'trends', 'concepten', 'events']; + const i = tabs.indexOf(this.tab); + const w = 100 / tabs.length; + this.indicatorStyle = { left: (i * w) + '%', width: w + '%' }; + }, + + sparkPoints(arr) { + if (!arr || !arr.length) return ''; + const min = Math.min.apply(null, arr); + const max = Math.max.apply(null, arr); + const range = max - min || 1; + return arr.map(function (v, idx) { + var x = (idx / (arr.length - 1 || 1)) * 100; + var y = 28 - ((v - min) / range) * 24; + return x.toFixed(1) + ',' + y.toFixed(1); + }).join(' '); + }, + + async refreshAll() { + this.busy = true; + try { + await Promise.all([ + this.loadMarket(), + this.loadTrends(), + this.loadConcepts(), + this.loadEvents(false), + ]); + this.lastRefresh = new Date().toLocaleString('nl-NL'); + if (typeof Cockpit !== 'undefined') Cockpit.toast('Beurs data bijgewerkt', 'success'); + } catch (e) { + if (typeof Cockpit !== 'undefined') Cockpit.toast(e.message, 'error'); + } + this.busy = false; + }, + + async loadMarket() { + const d = await fetch('/api/retail/market/supermarkets').then(function (r) { return r.json(); }); + this.listed = d.listed || []; + this.unlisted = d.unlisted_nl || []; + this.summary = d.summary || {}; + }, + + async loadTrends() { + const d = await fetch('/api/retail/market/food-trends').then(function (r) { return r.json(); }); + this.trends = d; + }, + + async loadConcepts() { + const d = await fetch('/api/retail/market/concepts?limit=6').then(function (r) { return r.json(); }); + this.concepts = d.concepts || []; + }, + + async loadEvents(toast) { + var q = this.eventFilter ? '?limit=80&agent=' + encodeURIComponent(this.eventFilter) : '?limit=80'; + try { + var r = await fetch('/api/live/platform' + q).then(function (x) { return x.json(); }); + this.events = r.events || []; + this.platformStats = r.stats || {}; + } catch (e) { + if (toast !== false && typeof Cockpit !== 'undefined') Cockpit.toast(e.message, 'error'); + } + }, + }; +}; diff --git a/cockpit/static/js/briefing-charts.js b/cockpit/static/js/briefing-charts.js new file mode 100644 index 0000000..7962809 --- /dev/null +++ b/cockpit/static/js/briefing-charts.js @@ -0,0 +1,347 @@ +window.BriefingCharts = (function () { + var charts = {}; + var typewriterTimer = null; + + function destroyAll() { + Object.keys(charts).forEach(function (k) { + if (charts[k]) { charts[k].destroy(); charts[k] = null; } + }); + } + + function parseContent(content) { + var parts = (content || '').split(/\n---\n/); + var ai = parts[0] || ''; + var summary = '', actions = [], longTerm = []; + var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|##\s*Lange termijn|$)/i); + if (sm) summary = sm[1].trim().replace(/\*\*/g, ''); + var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*?)(?=##\s*Lange termijn|$)/i); + if (am) actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean); + var lm = ai.match(/##\s*Lange termijn[^\n]*\n([\s\S]*)/i); + if (lm) longTerm = lm[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean); + if (!summary && ai.trim()) summary = ai.trim().slice(0, 800).replace(/\*\*/g, ''); + return { summary: summary, actions: actions, longTerm: longTerm }; + } + + function typewriter(el, text, speed) { + if (!el) return; + if (typewriterTimer) clearInterval(typewriterTimer); + el.textContent = ''; + if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; } + var i = 0; + typewriterTimer = setInterval(function () { + if (i < text.length) { el.textContent += text.charAt(i); i++; } + else clearInterval(typewriterTimer); + }, speed || 8); + } + + function countUp(el, end, prefix, suffix) { + if (!el) return; + prefix = prefix || ''; suffix = suffix || ''; + var start = 0, dur = 600, t0 = performance.now(); + function step(t) { + var p = Math.min(1, (t - t0) / dur); + var v = Math.round(start + (end - start) * p); + el.textContent = prefix + v.toLocaleString('nl-NL') + suffix; + if (p < 1) requestAnimationFrame(step); + } + requestAnimationFrame(step); + } + + function chartColors() { + return { + gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)', + green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)', + gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0', + }; + } + + function renderPipeline(canvas, stats) { + if (!canvas || typeof Chart === 'undefined') return; + var rows = stats.deals_by_stage || []; + var c = chartColors(); + if (charts.pipeline) charts.pipeline.destroy(); + charts.pipeline = new Chart(canvas, { + type: 'bar', + data: { labels: rows.map(function (r) { return r.stage || '?'; }), datasets: [{ label: 'EUR', data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.gold, borderRadius: 6 }] }, + options: { responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } }, + scales: { y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } }, x: { ticks: { color: c.text }, grid: { display: false } } } }, + }); + } + + function renderSentiment(canvas, stats) { + if (!canvas || typeof Chart === 'undefined') return; + var files = stats.nas_files || [], counts = { positive: 0, neutral: 0, negative: 0 }; + files.forEach(function (f) { var s = (f.sentiment_label || 'neutral').toLowerCase(); if (counts[s] !== undefined) counts[s]++; }); + if (!files.length) counts.neutral = 1; + var c = chartColors(); + if (charts.sentiment) charts.sentiment.destroy(); + charts.sentiment = new Chart(canvas, { + type: 'doughnut', + data: { labels: ['Positief', 'Neutraal', 'Negatief'], datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }] }, + options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } } }, + }); + } + + function renderWords(canvas, stats) { + if (!canvas || typeof Chart === 'undefined') return; + var rows = (stats.top_words || []).slice(0, 8), c = chartColors(); + if (charts.words) charts.words.destroy(); + charts.words = new Chart(canvas, { + type: 'bar', + data: { labels: rows.map(function (r) { return r.lemma; }), datasets: [{ data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.cyan, borderRadius: 6 }] }, + options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } }, + scales: { x: { ticks: { color: c.text }, grid: { color: c.grid } }, y: { ticks: { color: c.text }, grid: { display: false } } } }, + }); + } + + function renderAgents(canvas, stats) { + if (!canvas || typeof Chart === 'undefined') return; + var map = {}; + (stats.recent_events || []).forEach(function (e) { var a = e.agent_name || 'other'; map[a] = (map[a] || 0) + 1; }); + var labels = Object.keys(map), c = chartColors(); + if (charts.agents) charts.agents.destroy(); + charts.agents = new Chart(canvas, { + type: 'polarArea', + data: { labels: labels, datasets: [{ data: labels.map(function (k) { return map[k]; }), backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray] }] }, + options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } }, + scales: { r: { ticks: { display: false }, grid: { color: c.grid } } } }, + }); + } + + function eqBars() { + return '
'; + } + + function sparklineSvg(values, trend) { + if (!values || !values.length) return ''; + var min = Math.min.apply(null, values), max = Math.max.apply(null, values); + var range = max - min || 1; + var pts = values.map(function (v, i) { + var x = (i / (values.length - 1 || 1)) * 100; + var y = 100 - ((v - min) / range) * 80 - 10; + return x.toFixed(1) + ',' + y.toFixed(1); + }).join(' '); + var color = trend === 'down' ? '#fb7185' : '#4ade80'; + return ''; + } + + function stockEqBars(trend) { + var cls = trend === 'down' ? ' hm-eq-down' : ''; + return '
'; + } + + function renderStocksMini(container, stats) { + if (!container) return; + var stocks = (stats.market_stocks || []).slice(0, 4); + var summary = stats.market_summary || {}; + if (!stocks.length) { + container.innerHTML = 'Beurs openen →'; + return; + } + container.innerHTML = stocks.map(function (s) { + var pct = Number(s.change_pct || 0); + return '' + + '' + (s.symbol || s.name) + ' ' + + (pct >= 0 ? '+' : '') + pct.toFixed(2) + '%'; + }).join('') + 'Gem. ' + + (summary.avg_change_pct || 0) + '% · alle koersen →'; + } + + function renderStocks(container, stats) { + if (!container) return; + var stocks = stats.market_stocks || []; + var summary = stats.market_summary || {}; + var meta = document.getElementById('market-updated-at'); + if (meta) { + var avg = summary.avg_change_pct; + meta.textContent = stocks.length ? ('Gem. ' + (avg >= 0 ? '+' : '') + Number(avg || 0).toFixed(2) + '% · ' + (summary.quote_count || stocks.length) + ' quotes') : 'Beurs data laden…'; + } + if (!stocks.length) { + container.innerHTML = '

Beursdata tijdelijk niet beschikbaar — probeer Live data opnieuw.

'; + return; + } + container.innerHTML = stocks.map(function (s) { + var pct = Number(s.change_pct || 0); + var up = pct >= 0; + var price = s.price != null ? Number(s.price).toFixed(2) : '—'; + var cur = s.currency || 'EUR'; + return '
' + + '
' + (s.symbol || '') + '' + (s.name || '') + '
' + + '' + (up ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(2) + '%
' + + '
' + price + ' ' + cur + '
' + + '
' + (s.chain || s.market || '') + '
' + + sparklineSvg(s.sparkline || [], s.trend) + stockEqBars(s.trend) + '
'; + }).join(''); + } + + function renderFoodHighlights(container, stats) { + if (!container) return; + var items = stats.food_market_highlights || stats.rss_highlights || []; + if (!items.length) { + container.innerHTML = '

Geen highlights — RSS ophalen

'; + return; + } + container.innerHTML = items.map(function (r) { + var cat = (r.category || 'markt').toUpperCase(); + return '
' + cat + '' + + '' + (r.title || '') + '' + + '' + (r.feed_name || '') + '
'; + }).join(''); + } + + function renderRegulations(container, stats) { + if (!container) return; + var items = stats.regulation_highlights || []; + if (!items.length) { + container.innerHTML = '

Regelgeving feeds — klik RSS refresh in Retail 360

'; + return; + } + container.innerHTML = items.map(function (r) { + var cat = r.category === 'cbs' ? 'CBS' : 'REG'; + return '
' + cat + '' + + '' + (r.title || '') + '' + + '' + (r.feed_name || '') + '
'; + }).join(''); + } + + function renderTrends(canvas, stats) { + if (!canvas || typeof Chart === 'undefined') return; + var rows = stats.market_trends || []; + var c = chartColors(); + if (charts.trends) charts.trends.destroy(); + if (!rows.length) return; + charts.trends = new Chart(canvas, { + type: 'bar', + data: { + labels: rows.map(function (r) { return (r.trend_name || '?').slice(0, 18); }), + datasets: [{ + label: 'Kans %', + data: rows.map(function (r) { return Math.round(Number(r.opportunity_score || 0) * 100); }), + backgroundColor: [c.green, c.cyan, c.gold, c.purple || '#a855f7'], + borderRadius: 6, + }], + }, + options: { + responsive: true, + plugins: { legend: { display: false }, title: { display: true, text: 'Markt trend scores', color: c.text } }, + scales: { + y: { max: 100, ticks: { color: c.text, callback: function (v) { return v + '%'; } }, grid: { color: c.grid } }, + x: { ticks: { color: c.text, maxRotation: 45 }, grid: { display: false } }, + }, + }, + }); + } + + function renderKpis(container, stats) { + if (!container) return; + var summary = stats.market_summary || {}; + var avgPct = Number(summary.avg_change_pct || 0); + var best = summary.best_performer || {}; + var items = [ + { label: 'Pipeline', sub: 'actieve deals', icon: '💰', color: '#00e5ff', pct: '72%', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL') }, + { label: 'Actieve klanten', sub: 'CRM · zaken mee', icon: '🤝', color: '#22c55e', pct: Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%', val: (stats.clients_active || 0) + ' / ' + (stats.clients_total || 0), link: '/clients' }, + { label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', pct: '45%', val: stats.crm_partnerships || 0, link: '/retail' }, + { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🏪', color: '#b8ff3c', pct: '88%', val: (stats.supermarkets || 0).toLocaleString('nl-NL') }, + { label: 'Halal kansen', sub: 'top score', icon: '🎯', color: '#a855f7', pct: '65%', val: stats.top_opportunities && stats.top_opportunities[0] ? Math.round(Number(stats.top_opportunities[0].halal_opportunity_score || 0)) + '/100' : '—' }, + { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', pct: '30%', val: stats.pending_approvals || 0 }, + ]; + container.className = 'hm-neo-kpi-row'; + container.innerHTML = items.map(function (it) { + var inner = '
' + + '
' + it.icon + '
' + + '
' + it.label + '
' + it.sub + '
' + + '
' + it.val + '
' + eqBars() + '
'; + return it.link ? '' + inner + '' : inner; + }).join(''); + } + + function renderRetail(container, stats) { + if (!container) return; + var opps = stats.top_opportunities || []; + if (!opps.length) { container.innerHTML = '

Geen kansen — open Retail 360

'; return; } + container.innerHTML = opps.map(function (o) { + var score = Math.round(Number(o.halal_opportunity_score) || 0); + return '' + (o.chain || '') + ' · ' + (o.name || '') + '
' + (o.city || '') + '
' + score + '/100
'; + }).join(''); + } + + function renderMilestones(container, stats) { + if (!container) return; + var ms = stats.milestones_pending || []; + if (!ms.length) { + container.innerHTML = '

Nog geen milestones — voeg toe via Retail 360 → Sales tab

'; + return; + } + container.innerHTML = ms.map(function (m) { + return '
' + (m.title || '') + '
' + (m.chain || '') + ' ' + (m.store_name || '') + '
'; + }).join(''); + } + + function renderExecutiveSummary(container, stats) { + if (!container) return; + var items = [ + { icon: '🤝', label: 'Actieve klanten', val: (stats.clients_active || 0) + ' van ' + (stats.clients_total || stats.clients || 0), link: '/clients' }, + { icon: '💰', label: 'Pipeline', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), link: '/deals' }, + { icon: '🏪', label: 'CRM partnerships', val: stats.crm_partnerships || 0, link: '/retail' }, + { icon: '🛒', label: 'Supermarkten DB', val: (stats.supermarkets || 0).toLocaleString('nl-NL'), link: '/retail' }, + { icon: '📦', label: 'Groothandels', val: stats.wholesalers || 0, link: '/retail' }, + { icon: '✓', label: 'Goedkeuringen open', val: stats.pending_approvals || 0, link: '/' }, + { icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' }, + { icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' }, + ]; + var opps = (stats.top_opportunities || []).slice(0, 3); + var ms = (stats.milestones_pending || []).slice(0, 3); + var html = '
' + items.map(function (it) { + return '' + it.icon + '' + + '
' + it.label + '
' + it.val + '
'; + }).join('') + '
'; + if (opps.length) { + html += '

Top halal kansen

'; + } + if (ms.length) { + html += '

Open milestones

'; + } + html += ''; + container.innerHTML = html; + } + + function renderText(root, content, createdAt) { + if (!root) return; + var parsed = parseContent(content); + var meta = document.getElementById('briefing-meta'); + if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' '); + typewriter(document.getElementById('briefing-summary-text'), parsed.summary, 8); + var actEl = document.getElementById('briefing-actions-list'); + if (actEl) actEl.innerHTML = parsed.actions.length ? parsed.actions.map(function (a) { return '
  • ' + a + '
  • '; }).join('') : '
  • Genereer dagrapport voor actiepunten
  • '; + var longEl = document.getElementById('briefing-longterm-list'); + if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '
  • ' + a + '
  • '; }).join('') : '
  • Halal kant-en-klaar partnerships schalen
  • '; + } + + function renderLive(root, stats) { + if (!root || !stats) return; + renderKpis(document.getElementById('briefing-kpis'), stats); + renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats); + renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats); + renderRetail(document.getElementById('briefing-retail'), stats); + renderMilestones(document.getElementById('briefing-milestones'), stats); + renderPipeline(document.getElementById('chart-pipeline'), stats); + renderSentiment(document.getElementById('chart-sentiment'), stats); + renderWords(document.getElementById('chart-words'), stats); + renderAgents(document.getElementById('chart-agents'), stats); + } + + function render(root, stats, content, createdAt) { + renderLive(root, stats); + renderText(root, content, createdAt); + } + + return { render: render, renderLive: renderLive, renderText: renderText, renderExecutiveSummary: renderExecutiveSummary, destroyAll: destroyAll }; +})(); diff --git a/cockpit/static/js/cockpit.js b/cockpit/static/js/cockpit.js new file mode 100644 index 0000000..ffb9853 --- /dev/null +++ b/cockpit/static/js/cockpit.js @@ -0,0 +1,75 @@ +window.Cockpit = (function () { + const API = '/api/admin'; + + function toast(message, type) { + type = type || 'info'; + let root = document.getElementById('cockpit-toasts'); + if (!root) { + root = document.createElement('div'); + root.id = 'cockpit-toasts'; + root.className = 'toast-container'; + document.body.appendChild(root); + } + const el = document.createElement('div'); + el.className = 'toast toast-' + type; + el.textContent = message; + root.appendChild(el); + setTimeout(function () { el.classList.add('toast-out'); setTimeout(function () { el.remove(); }, 300); }, 3500); + } + + function clearEmbeddedIframes() { + document.querySelectorAll('iframe.browser-novnc, iframe[data-clear-on-nav]').forEach(function (f) { + try { f.src = 'about:blank'; } catch (e) {} + }); + } + + async function request(path, options) { + options = options || {}; + const url = path.startsWith('http') || path.startsWith('/api/') ? path : API + path; + const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {}); + const fetchOpts = Object.assign({}, options, { headers: headers }); + if (options.signal) fetchOpts.signal = options.signal; + const res = await fetch(url, fetchOpts); + let data = null; + try { data = await res.json(); } catch (e) { data = null; } + if (!res.ok) { + const msg = (data && (data.detail || data.message)) || res.statusText; + throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)); + } + return data; + } + + function confirmDelete(message) { + return window.confirm(message || 'Delete this item?'); + } + + function openDrawer(id) { + document.body.classList.add('drawer-open'); + var el = document.getElementById(id); + if (el) el.classList.add('open'); + } + + function closeDrawer(id) { + document.body.classList.remove('drawer-open'); + if (id) { + var el = document.getElementById(id); + if (el) el.classList.remove('open'); + } + document.querySelectorAll('.drawer.open').forEach(function (d) { d.classList.remove('open'); }); + } + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeDrawer(); + }); + + document.addEventListener('click', function (e) { + var link = e.target.closest('a[href]'); + if (!link || link.target === '_blank' || link.hasAttribute('download')) return; + var href = link.getAttribute('href') || ''; + if (!href || href.charAt(0) === '#') return; + if (href.indexOf('6080') !== -1 || href.indexOf('7788') !== -1) return; + if (href.charAt(0) === '/' || href.indexOf('http') === 0) clearEmbeddedIframes(); + }, true); + + return { toast: toast, api: request, confirmDelete: confirmDelete, openDrawer: openDrawer, closeDrawer: closeDrawer, clearIframes: clearEmbeddedIframes }; +})(); diff --git a/cockpit/static/js/hermes-ui.js b/cockpit/static/js/hermes-ui.js new file mode 100644 index 0000000..1febe97 --- /dev/null +++ b/cockpit/static/js/hermes-ui.js @@ -0,0 +1,346 @@ +/** + * Hermes Neo Command Center + */ +function hermesControl() { + const PA_SITES = ['Airbnb', 'Booking.com', 'DuckDuckGo', 'HolidayCheck']; + + /** Canonical team — never duplicate role as name */ + const ROSTER = [ + { chat_id: 8859782446, role: 'CEO', name: 'Aïssa' }, + { chat_id: 789036463, role: 'CTO', name: 'Mo' }, + ]; + + return { + tab: 'feed', + live: true, + stats: { conversations: 0, messages: 0, edges: 0, embeddings: 0, inbound: 0, outbound: 0 }, + conversations: [], + messages: [], + users: [], + agentEvents: [], + selectedChatId: null, + graphChatFilter: '', + searchQuery: '', + searchResults: [], + searchRan: false, + graphNetwork: null, + pollTimer: null, + paPollTimer: null, + paPolling: false, + paLive: { status: 'idle', query: '', slots: [] }, + paScreenshotTs: Date.now(), + hermesOnline: false, + hermesStatus: {}, + actionBusy: false, + actionMsg: '', + chartDirection: null, + chartAgents: null, + + get teamRoster() { + const byId = {}; + (this.users || []).forEach(u => { byId[u.chat_id] = u; }); + (this.conversations || []).forEach(c => { + if (!byId[c.chat_id]) byId[c.chat_id] = c; + else { + byId[c.chat_id].message_count = c.message_count || byId[c.chat_id].message_count; + } + }); + return ROSTER.map(r => ({ + ...r, + online: !!(byId[r.chat_id]?.online), + pa_mode: !!(byId[r.chat_id]?.pa_mode), + message_count: byId[r.chat_id]?.message_count || 0, + })); + }, + + get paLiveUser() { + const cid = this.paLive.chat_id; + if (!cid) return ''; + const m = ROSTER.find(r => r.chat_id === cid); + return m ? `${m.role} — ${m.name}` : this.paLive.user_name || ''; + }, + + get paSlots() { + const slots = this.paLive.slots || []; + if (slots.length >= 4) return slots; + const byLabel = {}; + slots.forEach(s => { byLabel[s.label] = s; }); + return PA_SITES.map(label => byLabel[label] || { + label, status: 'idle', has_screenshot: false, + }); + }, + + rosterPerson(chatId) { + return ROSTER.find(r => r.chat_id === Number(chatId)) || null; + }, + + msgLabel(msg) { + const p = this.rosterPerson(msg.chat_id); + if (p) return `${p.role} (${p.name})`; + if (msg.user_role && msg.user_name) return `${msg.user_role} (${msg.user_name})`; + return msg.user_role || msg.chat_id || '?'; + }, + + async init() { + const hash = (window.location.hash || '').replace('#', ''); + if (['feed', 'pa', 'graph', 'search', 'control'].includes(hash)) this.tab = hash; + await this.refreshStats(); + await this.refreshConversations(); + await this.refreshUsers(); + await this.refreshFeed(); + await this.refreshPaLive(); + setTimeout(() => this.initCharts(), 200); + this.pollTimer = setInterval(() => { + if (this.tab === 'feed' && this.live) this.refreshFeed(true); + }, 5000); + this.paPollTimer = setInterval(() => { + if (this.tab === 'pa' || this.paLive.status === 'running') this.refreshPaLive(true); + }, 2000); + window.addEventListener('hashchange', () => { + const h = (window.location.hash || '').replace('#', ''); + if (['feed', 'pa', 'graph', 'search', 'control'].includes(h)) this.tab = h; + }); + }, + + initCharts() { + if (typeof Chart === 'undefined') return; + const neoColors = ['#00e5ff', '#ff2d95', '#b8ff3c', '#ff9f43', '#a855f7', '#ffd700']; + Chart.defaults.color = '#64748b'; + Chart.defaults.borderColor = 'rgba(0,229,255,0.08)'; + + const dirEl = document.getElementById('hm-chart-direction'); + if (dirEl && !this.chartDirection) { + this.chartDirection = new Chart(dirEl, { + type: 'doughnut', + data: { + labels: ['Inbound', 'Outbound', 'Vectors'], + datasets: [{ + data: [1, 1, 1], + backgroundColor: ['#00e5ff', '#a855f7', '#b8ff3c'], + borderWidth: 0, + hoverOffset: 8, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: '62%', + plugins: { + legend: { position: 'right', labels: { boxWidth: 12, padding: 10, color: '#94a3b8' } }, + }, + }, + }); + } + + const agEl = document.getElementById('hm-chart-agents'); + if (agEl && !this.chartAgents) { + this.chartAgents = new Chart(agEl, { + type: 'bar', + data: { + labels: ['herman', 'browser', 'marketing'], + datasets: [{ + label: 'events', + data: [0, 0, 0], + backgroundColor: 'rgba(0, 229, 255, 0.55)', + borderColor: '#00e5ff', + borderWidth: 1, + borderRadius: 4, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { stepSize: 1 } }, + x: { grid: { display: false } }, + }, + plugins: { legend: { display: false } }, + }, + }); + } + this.updateCharts(); + }, + + updateCharts() { + const s = this.stats; + if (this.chartDirection) { + this.chartDirection.data.datasets[0].data = [ + Math.max(s.inbound || 0, 0), + Math.max(s.outbound || 0, 0), + Math.max(s.embeddings || 0, 0), + ]; + this.chartDirection.update('none'); + } + if (this.chartAgents) { + const counts = {}; + (this.agentEvents || []).forEach(ev => { + const n = (ev.agent_name || 'other').toLowerCase(); + counts[n] = (counts[n] || 0) + 1; + }); + const labels = Object.keys(counts).slice(0, 8); + if (!labels.length) labels.push('herman'); + this.chartAgents.data.labels = labels; + this.chartAgents.data.datasets[0].data = labels.map(l => counts[l] || 0); + this.chartAgents.update('none'); + } + }, + + setTab(t) { + this.tab = t; + history.replaceState(null, '', t === 'feed' ? '/hermes' : '/hermes#' + t); + if (t === 'graph') setTimeout(() => this.loadGraph(), 100); + if (t === 'control') { this.refreshStats(); this.refreshUsers(); } + if (t === 'pa') { this.paPolling = true; this.refreshPaLive(); } + }, + + formatTime(iso) { + if (!iso) return ''; + try { + return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }); + } catch { return iso; } + }, + + paStatusLabel() { + const m = { idle: 'Idle', running: 'Zoekt…', comparing: 'Vergelijkt…', done: 'Klaar', failed: 'Mislukt' }; + return m[this.paLive.status] || this.paLive.status || 'Idle'; + }, + + slotStatusLabel(s) { + const m = { idle: 'Idle', waiting: 'Wacht', loading: 'Bezig', completed: 'Klaar', failed: 'Fout' }; + return m[s] || s || 'Idle'; + }, + + slotScreenshotUrl(label) { + if (!label) return ''; + return `/api/admin/hermes/pa/live/${encodeURIComponent(label)}/screenshot.jpg?t=${this.paScreenshotTs}`; + }, + + async selectChat(chatId) { + this.selectedChatId = chatId; + await this.refreshFeed(); + }, + + async refreshStats() { + try { + const data = await Cockpit.api('/hermes/stats'); + this.stats = data.stats || this.stats; + this.agentEvents = data.agent_events || []; + this.updateCharts(); + } catch (e) { console.warn('stats', e); } + }, + + async refreshUsers() { + try { + const data = await Cockpit.api('/hermes/users'); + this.users = data.users || []; + } catch (e) { console.warn('users', e); } + }, + + async refreshConversations() { + try { + const data = await Cockpit.api('/hermes/conversations'); + this.conversations = data.items || []; + } catch (e) { console.warn('conversations', e); } + }, + + async refreshFeed(quiet) { + try { + let path = '/hermes/feed?limit=80'; + if (this.selectedChatId) path += '&chat_id=' + this.selectedChatId; + const data = await Cockpit.api(path); + this.messages = data.items || []; + if (!quiet) await this.refreshStats(); + } catch (e) { console.warn('feed', e); } + }, + + async refreshPaLive(quiet) { + try { + const data = await Cockpit.api('/hermes/pa/live'); + this.paLive = data; + this.paScreenshotTs = Date.now(); + if (data.hermes) { + this.hermesOnline = data.hermes.online !== false; + this.hermesStatus = data.hermes; + } + } catch (e) { + if (!quiet) console.warn('pa live', e); + } + }, + + async loadGraph() { + try { + let data; + if (this.graphChatFilter) { + data = await Cockpit.api('/hermes/graph/' + this.graphChatFilter + '?limit=80'); + } else { + data = await Cockpit.api('/hermes/graph?limit=100'); + } + this.renderGraph(data); + } catch (e) { + Cockpit.toast('Graph laden mislukt: ' + e.message, 'error'); + } + }, + + renderGraph(data) { + const el = document.getElementById('hermes-graph'); + if (!el || typeof vis === 'undefined') return; + const nodes = new vis.DataSet((data.nodes || []).map(n => ({ + id: n.id, + label: this.truncate(n.label || n.id, 40), + color: n.direction === 'in' + ? { background: '#164e63', border: '#00e5ff' } + : { background: '#4c1d95', border: '#a855f7' }, + font: { color: '#e8edf5', size: 11 }, + }))); + const edges = new vis.DataSet((data.edges || []).map(e => ({ + id: 'e' + e.id, from: e.from, to: e.to, + label: e.type || '', arrows: 'to', + color: { color: '#ff9f43' }, + })).filter(e => e.to)); + if (this.graphNetwork) this.graphNetwork.destroy(); + this.graphNetwork = new vis.Network(el, { nodes, edges }, { + physics: { stabilization: true }, + interaction: { hover: true }, + }); + }, + + truncate(s, n) { + s = String(s || ''); + return s.length > n ? s.slice(0, n) + '…' : s; + }, + + async runSearch() { + if (!this.searchQuery || this.searchQuery.length < 2) { + Cockpit.toast('Minimaal 2 tekens', 'warn'); + return; + } + this.searchRan = true; + try { + const data = await Cockpit.api('/hermes/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: this.searchQuery, chat_id: this.selectedChatId || null, limit: 15 }), + }); + this.searchResults = data.results || []; + } catch (e) { + Cockpit.toast('Zoeken mislukt: ' + e.message, 'error'); + } + }, + + async runAction(action) { + this.actionBusy = true; + this.actionMsg = ''; + try { + const data = await Cockpit.api('/hermes/actions/' + action, { method: 'POST' }); + this.actionMsg = data.output || '✅ Actie uitgevoerd'; + Cockpit.toast(this.actionMsg.slice(0, 80), 'success'); + await this.refreshStats(); + } catch (e) { + this.actionMsg = '❌ ' + e.message; + Cockpit.toast(this.actionMsg, 'error'); + } finally { + this.actionBusy = false; + } + }, + }; +} diff --git a/cockpit/static/js/live-pulse.js b/cockpit/static/js/live-pulse.js new file mode 100644 index 0000000..a962102 --- /dev/null +++ b/cockpit/static/js/live-pulse.js @@ -0,0 +1,54 @@ +window.CockpitLive = (function () { + var ws = null; + var reconnectTimer = null; + + function startPolling(fn, intervalMs) { + intervalMs = intervalMs || 30000; + fn(); + var id = setInterval(fn, intervalMs); + return function () { clearInterval(id); }; + } + + function connectFeed(onMessage) { + if (typeof WebSocket === 'undefined') return function () {}; + var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + function connect() { + try { + ws = new WebSocket(proto + '//' + location.host + '/ws/feed'); + ws.onmessage = function (ev) { + try { + var data = JSON.parse(ev.data); + if (onMessage) onMessage(data); + } catch (e) {} + }; + ws.onclose = function () { + reconnectTimer = setTimeout(connect, 5000); + }; + } catch (e) { + reconnectTimer = setTimeout(connect, 5000); + } + } + connect(); + return function () { + if (reconnectTimer) clearTimeout(reconnectTimer); + if (ws) { try { ws.close(); } catch (e) {} ws = null; } + }; + } + + function updateLiveBadge(el, at) { + if (!el) return; + el.textContent = 'Live · ' + (at || new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })); + } + + function renderAgentFeed(container, events) { + if (!container || !events) return; + container.innerHTML = events.slice(0, 12).map(function (ev) { + var t = (ev.created_at || '').substring(11, 16); + return '
    ' + t + '' + + '' + (ev.agent_name || '') + ' ' + + (ev.title || ev.event_type || '') + '
    '; + }).join('') || '

    Nog geen events.

    '; + } + + return { startPolling: startPolling, connectFeed: connectFeed, updateLiveBadge: updateLiveBadge, renderAgentFeed: renderAgentFeed }; +})(); diff --git a/cockpit/static/manifest.json b/cockpit/static/manifest.json new file mode 100644 index 0000000..5daf408 --- /dev/null +++ b/cockpit/static/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Foodlinkk Command Center", + "short_name": "Foodlinkk", + "description": "Herman CEO dashboard · CRM · Retail · Marketing", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0a0e14", + "theme_color": "#0a0e14", + "lang": "nl", + "icons": [ + { + "src": "/static/icons/app-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/static/icons/app-icon.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "maskable" + } + ] +} diff --git a/cockpit/static/retail.css b/cockpit/static/retail.css new file mode 100644 index 0000000..1286e4d --- /dev/null +++ b/cockpit/static/retail.css @@ -0,0 +1,180 @@ +/* Retail 360 workspace layout */ + +.retail-workspace { + display: grid; + grid-template-columns: 200px 1fr 360px; + gap: 1rem; + min-height: 75vh; + align-items: start; +} +.retail-workspace.with-filters { + grid-template-columns: 200px 300px 1fr 360px; +} + +.retail-sidebar { overflow-y: auto; max-height: 75vh; position: sticky; top: 0.5rem; } + +/* Filter panel */ +.retail-filters { + overflow-y: auto; + max-height: 75vh; + position: sticky; + top: 0.5rem; + padding: 0.85rem 1rem !important; +} +.filters-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 0.75rem; padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(255,255,255,0.08); +} +.filters-head h3 { margin: 0; font-size: 1rem; } +.filter-count { + font-size: 0.65rem; padding: 0.15rem 0.45rem; border-radius: 999px; + background: rgba(42,171,238,0.15); color: #38bdf8; +} + +.filter-group { + border: 1px solid rgba(255,255,255,0.07); + border-radius: 10px; + margin-bottom: 0.55rem; + background: rgba(0,0,0,0.2); + overflow: hidden; +} +.filter-group summary { + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; + color: #e2e8f0; + padding: 0.55rem 0.65rem; + list-style: none; + user-select: none; +} +.filter-group summary::-webkit-details-marker { display: none; } +.filter-group[open] summary { + border-bottom: 1px solid rgba(255,255,255,0.06); + background: rgba(255,255,255,0.02); +} +.filter-group-body { + padding: 0.55rem 0.65rem 0.65rem; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.filter-field { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin: 0; + width: 100%; +} +.filter-label { + display: block; + font-size: 0.72rem; + font-weight: 500; + color: #94a3b8; + letter-spacing: 0.02em; +} +.filter-field select, +.filter-field input[type="text"], +.filter-field input[type="number"] { + display: block; + width: 100%; + box-sizing: border-box; + padding: 0.45rem 0.55rem; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + background: #0a0f18; + color: #f1f5f9; + font-size: 0.85rem; + margin: 0; +} +.filter-field select:focus, +.filter-field input:focus { + outline: none; + border-color: rgba(42,171,238,0.5); + box-shadow: 0 0 0 2px rgba(42,171,238,0.15); +} +.filter-field.filter-check { + flex-direction: row; + align-items: center; + gap: 0.5rem; + padding: 0.15rem 0; +} +.filter-field.filter-check input[type="checkbox"] { + width: 16px; height: 16px; flex-shrink: 0; margin: 0; + accent-color: #2aabee; +} +.filter-field.filter-check span { + font-size: 0.82rem; + color: #cbd5e1; +} + +.filter-actions { margin: 0.65rem 0; display: flex; flex-direction: column; gap: 0.35rem; } +.filter-sync { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid rgba(255,255,255,0.06); } +.filter-sync h4 { margin: 0 0 0.5rem; font-size: 0.85rem; color: #94a3b8; } + +.retail-map-wrap { position: relative; min-height: 500px; } +.retail-map { height: 75vh; min-height: 500px; border-radius: 8px; border: 1px solid #334155; } +.map-loading { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.3); border-radius: 8px; pointer-events: none; +} + +.retail-panel { overflow-y: auto; max-height: 75vh; position: sticky; top: 0.5rem; } +.retail-panel .detail-section { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid #334155; } +.retail-panel .detail-dl { + display: grid; grid-template-columns: 1fr 1fr; gap: 0.25rem 0.5rem; font-size: 0.85rem; +} +.retail-panel .detail-dl dt { opacity: 0.7; } +.retail-panel .detail-dl dd { margin: 0; font-weight: 600; text-align: right; } + +.clickable-row { cursor: pointer; } +.clickable-row:hover { opacity: 0.85; } + +.retail-actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } +.page-header { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem; } + +.kpi-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1rem; } +.kpi-card { + flex: 1; min-width: 110px; padding: 0.75rem 1rem; + background: var(--panel-bg, #1e293b); border-radius: 8px; border: 1px solid #334155; +} +.kpi-card span { display: block; font-size: 0.75rem; opacity: 0.7; } +.kpi-card strong { font-size: 1.25rem; } +.kpi-green strong { color: #22c55e; } + +.retail-main { min-height: 500px; min-width: 0; } +.retail-table-wrap { max-height: 75vh; overflow: auto; } +.retail-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; } +.retail-table th, .retail-table td { padding: 0.4rem 0.5rem; border-bottom: 1px solid #334155; text-align: left; } +.retail-table th { position: sticky; top: 0; background: var(--panel-bg, #1e293b); z-index: 1; } + +.trends-ticker { margin-bottom: 1rem; padding: 0.75rem 1rem; overflow-x: auto; } +.ticker-items { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 0.5rem; } +.ticker-item { + background: rgba(42,171,238,0.1); padding: 0.25rem 0.6rem; border-radius: 999px; + font-size: 0.8rem; white-space: nowrap; border: 1px solid rgba(42,171,238,0.2); +} + +.field-picker { margin-top: 1rem; font-size: 0.8rem; } +.field-group { margin: 0.5rem 0; } +.btn-block { width: 100%; margin-top: 0; } +.muted { opacity: 0.75; font-size: 0.9rem; } +.hint { font-size: 0.8rem; opacity: 0.7; } + +.wholesale-list { max-height: 75vh; overflow-y: auto; } +.wholesale-item { + padding: 0.6rem; border-bottom: 1px solid #1e293b; cursor: pointer; font-size: 0.85rem; +} +.wholesale-item:hover { background: rgba(42,171,238,0.06); } +.wholesale-item.active { border-left: 3px solid var(--pulse-blue, #2aabee); } + +@media (max-width: 1400px) { + .retail-workspace.with-filters { grid-template-columns: 180px 280px 1fr; } + .retail-panel { grid-column: 1 / -1; max-height: 45vh; position: static; } +} +@media (max-width: 900px) { + .retail-workspace, + .retail-workspace.with-filters { grid-template-columns: 1fr; } + .retail-sidebar, .retail-filters, .retail-panel { position: static; max-height: none; } +} diff --git a/cockpit/static/retail.html b/cockpit/static/retail.html new file mode 100644 index 0000000..6d7d0ca --- /dev/null +++ b/cockpit/static/retail.html @@ -0,0 +1,247 @@ +{% extends "base.html" %} +{% block content %} + + + + + +
    + + +
    +
    Totaal
    +
    Actief
    +
    CBS data
    +
    Gem. inkomen
    +
    Gem. halal-markt*
    +
    Resultaat
    +
    + +
    + + +
    +
    +
    Kaart laden…
    +
    + + +
    +
    + + + + +{% endblock %} diff --git a/cockpit/static/retail.py b/cockpit/static/retail.py new file mode 100644 index 0000000..2ff3bd0 --- /dev/null +++ b/cockpit/static/retail.py @@ -0,0 +1,69 @@ +"""Retail intelligence map page + API proxy.""" +from __future__ import annotations + +import os +from typing import Any, Optional + +import httpx +from fastapi import APIRouter, Query, Request +from fastapi.responses import JSONResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path + +router = APIRouter() +BASE = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE / "templates")) +TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/") + + +async def _tools_get(path: str, params: Optional[dict] = None) -> Any: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(f"{TOOLS}{path}", params=params or {}) + resp.raise_for_status() + return resp.json() + + +@router.get("/retail") +async def retail_page(request: Request): + stats = await _tools_get("/retail/stats") + filters = await _tools_get("/retail/filters") + enrich = await _tools_get("/retail/enrich/status") + recs = await _tools_get("/recommendations/pending", {"limit": 5}) + return templates.TemplateResponse( + "retail.html", + { + "request": request, + "stats": stats, + "filters": filters, + "enrichment": enrich, + "recommendations": recs.get("items", []), + }, + ) + + +@router.get("/api/retail/stats") +async def api_retail_stats(request: Request): + return JSONResponse(await _tools_get("/retail/stats", dict(request.query_params))) + + +@router.get("/api/retail/filters") +async def api_retail_filters(): + return JSONResponse(await _tools_get("/retail/filters")) + + +@router.get("/api/retail/map") +async def api_retail_map(request: Request): + return JSONResponse(await _tools_get("/retail/map", dict(request.query_params))) + + +@router.get("/api/retail/supermarkets/{store_id}") +async def api_retail_store(store_id: int): + return JSONResponse(await _tools_get(f"/retail/supermarkets/{store_id}")) + + +@router.post("/api/retail/enrich") +async def api_retail_enrich(limit: int = Query(50, ge=1, le=200)): + async with httpx.AsyncClient(timeout=300) as client: + resp = await client.post(f"{TOOLS}/retail/enrich", params={"limit": limit}) + resp.raise_for_status() + return JSONResponse(resp.json()) diff --git a/cockpit/static/sw.js b/cockpit/static/sw.js new file mode 100644 index 0000000..821b0ea --- /dev/null +++ b/cockpit/static/sw.js @@ -0,0 +1,46 @@ +/* Foodlinkk PWA — light cache for shell */ +const CACHE = 'foodlinkk-v1'; +const ASSETS = [ + '/', + '/static/css/palantir-theme.css', + '/static/css/pulse-theme.css', + '/static/css/mobile.css', + '/static/css/vertical-tabs.css', + '/static/js/cockpit.js', + '/static/js/live-pulse.js', + '/static/icons/app-icon.svg', + '/static/manifest.json', +]; + +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(CACHE).then((c) => c.addAll(ASSETS).catch(() => {})) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (e) => { + if (e.request.method !== 'GET') return; + const url = new URL(e.request.url); + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return; + e.respondWith( + fetch(e.request) + .then((r) => { + if (r.ok && url.pathname.startsWith('/static/')) { + const clone = r.clone(); + caches.open(CACHE).then((c) => c.put(e.request, clone)); + } + return r; + }) + .catch(() => caches.match(e.request).then((m) => m || caches.match('/'))) + ); +}); diff --git a/cockpit/templates/agents.html b/cockpit/templates/agents.html new file mode 100644 index 0000000..dd70de8 --- /dev/null +++ b/cockpit/templates/agents.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} +{% block extra_head %} + + + +{% endblock %} +{% block content %} +
    + + +
    + + +
    +
    +
    +
    🤖
    Agents
    geregistreerd
    0
    +
    Events
    totaal gelogd
    0
    +
    Actief
    agents online
    0
    +
    👑
    Herman
    orchestrator
    CEO
    +
    + +
    + +
    + +
    +
    +
    +
    +
    + + + + + +
    +
    +
    +
    +

    +

    +
    +
    + + Herman rechten → +
    +
    + + + +

    Recente activiteit

    + +

    Nog geen events voor deze agent.

    +
    +
    + +
    +
    +

    Netwerk

    +

    Communication mesh met Herman als centrale Co-CEO hub.

    +
    + +
    +
    + Groen = healthy + Oranje = waarschuwing + Blauw = idle + Grijs = offline +
    +
    +
    +
    +
    +
    +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/cockpit/templates/analytics.html b/cockpit/templates/analytics.html new file mode 100644 index 0000000..7e46d0c --- /dev/null +++ b/cockpit/templates/analytics.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block extra_head %} + + +{% endblock %} +{% block content %} +
    + + + + +
    +
    + +
    +

    Pipeline per stage

    +

    Klanten per stage

    +

    Supermarkten per keten

    +

    Provincie verdeling

    +

    CRM partnerships

    +

    Agent activiteit

    +

    Events timeline

    +

    RSS per categorie

    +

    Groothandels provincie

    +

    NAS sentiment

    +

    Top woorden NAS

    +

    Halal kansen top 10

    +

    Actieve promo folders

    +

    Milestones status

    +
    + +
    +

    Recente deals

    + +
    TitelStageWaarde
    +
    +

    Agent events

    + +
    AgentEventTijd
    +
    +
    +
    +
    +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/cockpit/templates/api.py b/cockpit/templates/api.py new file mode 100644 index 0000000..897deca --- /dev/null +++ b/cockpit/templates/api.py @@ -0,0 +1,93 @@ +from datetime import date, datetime +from typing import Any, Optional + +import httpx +from fastapi import APIRouter, HTTPException + +from app.config import settings +from app.db import execute, fetch_all, fetch_one +from app.services.briefing import collect_briefing_data, generate_daily_briefing, serialize_stats + +router = APIRouter(prefix="/api", tags=["api"]) + + +def _stats_payload() -> dict[str, Any]: + stats: dict[str, Any] = { + "deals": 0, + "clients": 0, + "pending_approvals": 0, + "pipeline_value": 0, + } + try: + row = fetch_one("SELECT COUNT(*) AS c FROM deals") + stats["deals"] = int(row["c"]) if row else 0 + except Exception: + pass + try: + row = fetch_one("SELECT COUNT(*) AS c FROM clients") + stats["clients"] = int(row["c"]) if row else 0 + except Exception: + pass + try: + row = fetch_one("SELECT COUNT(*) AS c FROM agent_events WHERE status = 'needs_approval'") + stats["pending_approvals"] = int(row["c"]) if row else 0 + except Exception: + pass + try: + row = fetch_one( + "SELECT COALESCE(SUM(value), 0) AS total FROM deals WHERE stage NOT IN ('won', 'lost')" + ) + stats["pipeline_value"] = float(row["total"]) if row else 0 + except Exception: + pass + return stats + + +@router.get("/herman/briefing/stats") +async def herman_briefing_stats(): + """Live stats from DB — always fresh for dashboard panels.""" + return {"ok": True, "stats": serialize_stats(collect_briefing_data()), "at": datetime.utcnow().isoformat()} + + +@router.post("/herman/briefing") +async def herman_briefing(): + try: + content, stats = await generate_daily_briefing() + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return {"ok": True, "content": content, "stats": stats, "generated_at": datetime.utcnow().isoformat()} + + +@router.get("/herman/briefing/latest") +async def herman_briefing_latest(): + try: + row = fetch_one( + "SELECT id, content, generated_by, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1" + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + live_stats = serialize_stats(collect_briefing_data()) + if not row: + return {"ok": True, "content": None, "stats": live_stats} + if row.get("created_at") and hasattr(row["created_at"], "isoformat"): + row["created_at"] = row["created_at"].isoformat() + return {"ok": True, **row, "stats": live_stats} + + +@router.get("/events") +async def list_events(limit: int = 50): + limit = max(1, min(limit, 200)) + try: + rows = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events ORDER BY created_at DESC LIMIT %s + """, + (limit,), + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + for row in rows: + if row.get("created_at"): + row["created_at"] = row["created_at"].isoformat() + return {"events": rows} diff --git a/cockpit/templates/base.html b/cockpit/templates/base.html new file mode 100644 index 0000000..731a183 --- /dev/null +++ b/cockpit/templates/base.html @@ -0,0 +1,158 @@ + + + + + + + + + + + {% block title %}{{ page_title }} · Foodlinkk{% endblock %} + + + + + + + + + + + {% block extra_head %}{% endblock %} + + +
    + + +
    + +
    +
    + + Foodlinkk +
    ·
    +
    +
    {% block content %}{% endblock %}
    +
    +
    + +
    +

    📱 Installeer Foodlinkk als app op je telefoon

    + + +
    + + + + + {% block scripts %}{% endblock %} + + diff --git a/cockpit/templates/beurs.html b/cockpit/templates/beurs.html new file mode 100644 index 0000000..b91098f --- /dev/null +++ b/cockpit/templates/beurs.html @@ -0,0 +1,221 @@ +{% extends "base.html" %} +{% block extra_head %} + + +{% endblock %} +{% block content %} +
    + +
    +
    +

    Beurs & Live Intel Live

    +

    Supermarkt aandelen · halal trends · concept lab · platform events

    + +
    +
    + + Herman Dashboard +
    +
    + +
    + + + + +
    +
    + + +
    +
    +
    + Gem. dag % + +
    +
    + Beursgenoteerd + +
    +
    + NL privé ketens + +
    +
    + Databron + Yahoo Finance ↗ +
    +
    + + +
    + +
    + + +

    Jumbo, Plus, Dirk, Lidl en ALDI zijn geen beursgenoteerde entiteiten — geen live koers via Yahoo Finance.

    +
    + +
    +
    + + +
    +
    +
    +

    ☪️ Halal vlees trends RSS Live

    + +

    Geen halal vlees trends — refresh RSS feeds.

    +
    +
    +

    🍱 Top gerechten & maaltijden

    + +

    Markt trends DB

    + +
    +
    +
    + + +
    +
    +

    💡 Concept Lab

    +

    Automatisch gegenereerde productconcepten op basis van live beurs, halal trends en top gerechten.

    + +
    +
    + +
    +
    + + +
    +
    +
    Events totaal
    +
    Laatste uur
    +
    Wacht op OK
    +
    +
    + + Auto-refresh 5s +
    +
    + +

    Geen events gelogd.

    +
    +
    + +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/briefing-charts.js b/cockpit/templates/briefing-charts.js new file mode 100644 index 0000000..72e5ba4 --- /dev/null +++ b/cockpit/templates/briefing-charts.js @@ -0,0 +1,212 @@ +window.BriefingCharts = (function () { + var charts = {}; + + function destroyAll() { + Object.keys(charts).forEach(function (k) { + if (charts[k]) { charts[k].destroy(); charts[k] = null; } + }); + } + + function parseContent(content) { + var parts = (content || '').split(/\n---\n/); + var ai = parts[0] || ''; + var summary = ''; + var actions = []; + var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|$)/i); + if (sm) summary = sm[1].trim(); + var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*)/i); + if (am) { + actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean); + } + if (!summary && ai.trim()) summary = ai.trim().slice(0, 600); + return { summary: summary, actions: actions }; + } + + function chartColors() { + return { + gold: 'rgba(232, 168, 56, 0.85)', + goldDim: 'rgba(232, 168, 56, 0.35)', + cyan: 'rgba(56, 189, 248, 0.85)', + green: 'rgba(62, 207, 142, 0.85)', + red: 'rgba(239, 68, 68, 0.85)', + gray: 'rgba(107, 114, 128, 0.85)', + grid: 'rgba(148, 163, 184, 0.12)', + text: '#94a3b8', + }; + } + + function renderPipeline(canvas, stats) { + if (!canvas) return; + var rows = stats.deals_by_stage || []; + var c = chartColors(); + charts.pipeline = new Chart(canvas, { + type: 'bar', + data: { + labels: rows.map(function (r) { return r.stage || '?'; }), + datasets: [{ + label: 'Pipeline EUR', + data: rows.map(function (r) { return Number(r.total) || 0; }), + backgroundColor: c.gold, + borderRadius: 6, + }], + }, + options: { + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } }, + scales: { + y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } }, + x: { ticks: { color: c.text }, grid: { display: false } }, + }, + }, + }); + } + + function renderSentiment(canvas, stats) { + if (!canvas) return; + var files = stats.nas_files || []; + var counts = { positive: 0, neutral: 0, negative: 0 }; + files.forEach(function (f) { + var s = (f.sentiment_label || 'neutral').toLowerCase(); + if (counts[s] !== undefined) counts[s]++; + }); + if (!files.length) { counts.neutral = 1; } + var c = chartColors(); + charts.sentiment = new Chart(canvas, { + type: 'doughnut', + data: { + labels: ['Positief', 'Neutraal', 'Negatief'], + datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }], + }, + options: { + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } }, + }, + }); + } + + function renderWords(canvas, stats) { + if (!canvas) return; + var rows = (stats.top_words || []).slice(0, 8); + var c = chartColors(); + charts.words = new Chart(canvas, { + type: 'bar', + data: { + labels: rows.map(function (r) { return r.lemma; }), + datasets: [{ + label: 'Count', + data: rows.map(function (r) { return Number(r.total) || 0; }), + backgroundColor: c.cyan, + borderRadius: 6, + }], + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } }, + scales: { + x: { ticks: { color: c.text }, grid: { color: c.grid } }, + y: { ticks: { color: c.text }, grid: { display: false } }, + }, + }, + }); + } + + function renderAgents(canvas, stats) { + if (!canvas) return; + var events = stats.recent_events || []; + var map = {}; + events.forEach(function (e) { + var a = e.agent_name || 'other'; + map[a] = (map[a] || 0) + 1; + }); + var labels = Object.keys(map); + var values = labels.map(function (k) { return map[k]; }); + var c = chartColors(); + charts.agents = new Chart(canvas, { + type: 'polarArea', + data: { + labels: labels, + datasets: [{ data: values, backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray, c.goldDim] }], + }, + options: { + responsive: true, + maintainAspectRatio: true, + plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } }, + scales: { r: { ticks: { display: false }, grid: { color: c.grid } } }, + }, + }); + } + + function renderKpis(container, stats) { + if (!container) return; + var items = [ + { label: 'Pipeline', value: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), cls: 'green' }, + { label: 'Klanten', value: stats.clients || 0, cls: 'purple' }, + { label: 'Deals', value: stats.deals || 0, cls: '' }, + { label: 'NAS docs', value: stats.nas_docs || 0, cls: 'cyan' }, + { label: 'Goedkeuringen', value: stats.pending_approvals || 0, cls: 'amber' }, + ]; + container.innerHTML = items.map(function (it) { + return '
    ' + it.label + '' + it.value + '
    '; + }).join(''); + } + + function renderNasList(container, stats) { + if (!container) return; + var files = stats.nas_files || []; + if (!files.length) { container.innerHTML = '

    Geen NAS documenten.

    '; return; } + container.innerHTML = '' + + files.map(function (f) { + var badge = 'badge-' + (f.sentiment_label || 'neutral'); + return ''; + }).join('') + '
    BestandTypeSentimentWoorden
    ' + (f.filename || '') + '' + (f.doc_type || '') + '' + (f.sentiment_label || '') + '' + (f.word_count || '—') + '
    '; + } + + function renderPending(container, stats) { + if (!container) return; + var items = stats.pending_items || []; + if (!items.length) { container.innerHTML = '

    Geen openstaande goedkeuringen 🎉

    '; return; } + container.innerHTML = items.map(function (it) { + return '
    ' + (it.agent_name || '') + ' ' + (it.title || it.event_type || '') + '
    '; + }).join(''); + } + + function render(root, stats, content, createdAt) { + if (!root || !stats) return; + destroyAll(); + var parsed = parseContent(content); + + root.style.display = 'block'; + var empty = document.getElementById('briefing-empty'); + if (empty) empty.style.display = 'none'; + + var meta = document.getElementById('briefing-meta'); + if (meta) meta.textContent = createdAt ? ('Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ')) : ''; + + var sumEl = document.getElementById('briefing-summary-text'); + if (sumEl) sumEl.textContent = parsed.summary || (content ? 'Geen samenvatting.' : 'Klik op «Genereer dagrapport» voor een AI-samenvatting en actiepunten.'); + + var actEl = document.getElementById('briefing-actions-list'); + if (actEl) { + actEl.innerHTML = parsed.actions.length + ? parsed.actions.map(function (a) { return '
  • ' + a + '
  • '; }).join('') + : '
  • Geen actiepunten — genereer opnieuw.
  • '; + } + + renderKpis(document.getElementById('briefing-kpis'), stats); + renderPipeline(document.getElementById('chart-pipeline'), stats); + renderSentiment(document.getElementById('chart-sentiment'), stats); + renderWords(document.getElementById('chart-words'), stats); + renderAgents(document.getElementById('chart-agents'), stats); + renderNasList(document.getElementById('briefing-nas-list'), stats); + renderPending(document.getElementById('briefing-pending-list'), stats); + + var full = document.getElementById('briefing-full-text'); + if (full) full.textContent = content || ''; + } + + return { render: render, destroyAll: destroyAll, parseContent: parseContent }; +})(); diff --git a/cockpit/templates/briefing.py b/cockpit/templates/briefing.py new file mode 100644 index 0000000..c9bd5e5 --- /dev/null +++ b/cockpit/templates/briefing.py @@ -0,0 +1,268 @@ +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 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]: + """JSON-safe copy of briefing stats (datetimes, decimals).""" + + 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"] = [] + + 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']}", + f"- **Deals totaal:** {data['deals']}", + f"- **Pipeline (actief):** €{data['pipeline_eur']:,.0f}", + f"- **Producten:** {data['products']}", + f"- **Leveranciers:** {data['suppliers']}", + f"- **Openstaande goedkeuringen:** {data['pending_approvals']}", + "", + "## Pipeline per stage", + ] + if data.get("deals_by_stage"): + for row in data["deals_by_stage"]: + lines.append(f"- **{row.get('stage')}:** {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}") + else: + lines.append("- Geen deals in database.") + + lines.extend(["", "## Recente klanten"]) + for row in data.get("recent_clients") or []: + lines.append(f"- {row.get('name')} ({row.get('stage')})") + if not data.get("recent_clients"): + lines.append("- Geen klanten.") + + lines.extend([ + "", + "## NAS share — documenten", + f"- Ingelezen documenten: **{data['nas_docs']}**", + f"- Totaal woorden geanalyseerd: **{data['nas_words']}**", + f"- Gemiddeld sentiment: **{data['nas_sentiment']}**", + "", + ]) + for row in data.get("nas_files") or []: + lines.append(f"- {row.get('filename')} · {row.get('doc_type')} · sentiment: {row.get('sentiment_label')}") + + if data.get("top_words"): + lines.extend(["", "## Top woorden (NAS corpus)"]) + for row in data["top_words"]: + lines.append(f"- {row.get('lemma')}: {row.get('total')}×") + + 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 '-'})") + + lines.extend(["", "## Recente agent activiteit"]) + for row in data.get("recent_events") or []: + ts = row.get("created_at") + ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16] + lines.append(f"- [{ts_s}] **{row.get('agent_name')}** — {row.get('title') or row.get('event_type')}") + + 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], template: str) -> str: + prompt = ( + "Schrijf alleen deze twee secties in het Nederlands (markdown):\n" + "## Samenvatting\n(4-6 zinnen voor CEO Aïssa)\n\n" + "## Actiepunten vandaag\n(minimaal 5 concrete bullets)\n\n" + f"Gebaseerd op:\n- Pipeline €{data['pipeline_eur']:,.0f}\n" + f"- {data['clients']} klanten, {data['deals']} deals\n" + f"- {data['pending_approvals']} goedkeuringen open\n" + f"- {data['nas_docs']} NAS documenten\n" + ) + system = "Je bent Herman, co-CEO Foodlinkk. Kort, zakelijk, actionable." + try: + return await ollama.generate(prompt, system=system, timeout=120.0) + except Exception: + return "" + + +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, template), timeout=90.0) + except (asyncio.TimeoutError, Exception): + ai_part = "" + + if ai_part and len(ai_part.strip()) > 40: + content = ai_part.strip() + "\n\n---\n\n" + template + else: + content = template + "\n\n---\n\n*AI-samenvatting niet beschikbaar (Ollama busy) — bovenstaande data is live uit je database.*" + + _save_briefing(content, data) + return content, serialize_stats(data) diff --git a/cockpit/templates/browser.html b/cockpit/templates/browser.html new file mode 100644 index 0000000..e96bcb6 --- /dev/null +++ b/cockpit/templates/browser.html @@ -0,0 +1,561 @@ +{% extends "base.html" %} +{% block extra_head %} + + +{% endblock %} +{% block content %} +
    + + +
    + Achtergrondtaak + + + + +
    + +
    +

    Snelle sites

    +

    Klik om URL te laden. TikTok/Instagram vereisen soms inloggen via VNC.

    +
    + Bidfood Mekkafood + Sligro + TikTok + Instagram + Cheema +
    +
    + + + +
    +
    + +
    + + +
    + +
    +
    +

    Screenshot

    + Browser screenshot +
    +
    +

    Live desktop noVNC + Open apart ↗ +

    + +

    Wachtwoord: Foodlinkk2026. Tip: open VNC ook in apart venster — dan geen navigatie-waarschuwing bij wisselen van pagina.

    +
    +
    + +
    +
    +

    Interactief — leer Herman wat te klikken

    +

    Beschrijf in het Nederlands wat de browser moet doen. Voorbeeld: Accepteren cookies, scroll naar beneden, klik op Mekkafood filter

    + +
    + + + + + + + 🤖 Browser Use AI +
    +
    +
    + OCR woorden (compact) +
    + +
    +
    +
    + +
    +
    + + + + + +
    +
    +
    + +
    +
    +
    +

    Geëxtraheerde data

    + + + + + +
    Regel / productGewichtPrijsBron
    +
    +
    +

    +

    +
    + +
    +
    + +
    +
    + +
    +
    +
    Active sites
    {{ stats.active_sites }}
    +
    Changes 24h
    {{ stats.changes_24h }}
    +
    +
    +

    Website toevoegen

    +
    + + + + +
    +
    +
    +

    Gemonitorde websites

    + + {% for s in sites %} + + + + + {% else %} + + {% endfor %}
    NaamURLLaatst gechecktActies
    {{ s.name }}{{ s.url }}{{ s.last_crawled or '—' }} + + + +
    Geen sites — voeg een URL toe of klik + Monitor bij Research.
    +
    +
    +

    Wijzigingen

    +
      {% for c in page_changes %}
    • {{ c.name or c.url }} · {{ c.changed_at }}
    • {% else %}
    • Geen wijzigingen
    • {% endfor %}
    +
    +

    Crawl logs

    +
      {% for l in crawl_logs %}
    • {{ l.status }} {{ l.message }} {{ l.logged_at }}
    • {% endfor %}
    +
    +
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/clients.html b/cockpit/templates/clients.html new file mode 100644 index 0000000..5fec861 --- /dev/null +++ b/cockpit/templates/clients.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block content %} +
    + + +
    +{% set stages = ['intake','discovery','proposal','active','churned'] %} +{% for st in stages %} +

    {{ st }}

    +{% for c in clients if c.stage == st %} +
    + {{ c.name }}
    {{ c.sector or '' }} + +
    +{% endfor %} +
    +{% endfor %} +
    + + +
    +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/cockpit.js b/cockpit/templates/cockpit.js new file mode 100644 index 0000000..ffb9853 --- /dev/null +++ b/cockpit/templates/cockpit.js @@ -0,0 +1,75 @@ +window.Cockpit = (function () { + const API = '/api/admin'; + + function toast(message, type) { + type = type || 'info'; + let root = document.getElementById('cockpit-toasts'); + if (!root) { + root = document.createElement('div'); + root.id = 'cockpit-toasts'; + root.className = 'toast-container'; + document.body.appendChild(root); + } + const el = document.createElement('div'); + el.className = 'toast toast-' + type; + el.textContent = message; + root.appendChild(el); + setTimeout(function () { el.classList.add('toast-out'); setTimeout(function () { el.remove(); }, 300); }, 3500); + } + + function clearEmbeddedIframes() { + document.querySelectorAll('iframe.browser-novnc, iframe[data-clear-on-nav]').forEach(function (f) { + try { f.src = 'about:blank'; } catch (e) {} + }); + } + + async function request(path, options) { + options = options || {}; + const url = path.startsWith('http') || path.startsWith('/api/') ? path : API + path; + const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {}); + const fetchOpts = Object.assign({}, options, { headers: headers }); + if (options.signal) fetchOpts.signal = options.signal; + const res = await fetch(url, fetchOpts); + let data = null; + try { data = await res.json(); } catch (e) { data = null; } + if (!res.ok) { + const msg = (data && (data.detail || data.message)) || res.statusText; + throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)); + } + return data; + } + + function confirmDelete(message) { + return window.confirm(message || 'Delete this item?'); + } + + function openDrawer(id) { + document.body.classList.add('drawer-open'); + var el = document.getElementById(id); + if (el) el.classList.add('open'); + } + + function closeDrawer(id) { + document.body.classList.remove('drawer-open'); + if (id) { + var el = document.getElementById(id); + if (el) el.classList.remove('open'); + } + document.querySelectorAll('.drawer.open').forEach(function (d) { d.classList.remove('open'); }); + } + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeDrawer(); + }); + + document.addEventListener('click', function (e) { + var link = e.target.closest('a[href]'); + if (!link || link.target === '_blank' || link.hasAttribute('download')) return; + var href = link.getAttribute('href') || ''; + if (!href || href.charAt(0) === '#') return; + if (href.indexOf('6080') !== -1 || href.indexOf('7788') !== -1) return; + if (href.charAt(0) === '/' || href.indexOf('http') === 0) clearEmbeddedIframes(); + }, true); + + return { toast: toast, api: request, confirmDelete: confirmDelete, openDrawer: openDrawer, closeDrawer: closeDrawer, clearIframes: clearEmbeddedIframes }; +})(); diff --git a/cockpit/templates/dashboard.html b/cockpit/templates/dashboard.html new file mode 100644 index 0000000..e62b623 --- /dev/null +++ b/cockpit/templates/dashboard.html @@ -0,0 +1,194 @@ +{% extends "base.html" %} +{% block extra_head %} + + + +{% endblock %} +{% block content %} +
    + +
    +

    Herman · Command Center Live

    +

    CEO overzicht · klanten · briefing · retail · alles op een rij

    +
    + +
    +
    +
    +

    +
    + +
    +
    +

    Dagelijkse briefing

    +
    +
    +
    + + + CRM Clients + Reclame folders + Analytics +
    +
    + +
    + +
    +

    CEO KPI's

    +
    +
    + +
    +

    Alles op een rij

    +
    +

    Executive samenvatting

    live database · geen RSS — zie Marketing Hub
    +
    +
    +
    +
    + +
    +

    Herman briefing

    +
    +
    +

    Samenvatting

    +

    +
    +
    +
    +

    ⚡ Korte termijn

    +
      +
      +
      +

      🎯 Lange termijn

      +
        +
        +
        +
        +
        + +
        +

        Retail operatie

        +
        +
        +

        Top retail kansen

        Retail 360 →
        +
        +
        +
        +

        Sales milestones

        live DB
        +
        +
        +
        + +
        + +
        +

        Data & analytics

        +
        +
        +

        Pipeline

        live deals
        +
        +
        +
        +

        NAS sentiment

        documenten
        +
        +
        +
        +

        NAS top woorden

        corpus
        +
        +
        +
        +

        Agent activiteit

        laatste events
        +
        +
        +
        +
        + +
        +
        + +
        +
        +

        Agent feed Live

        +
        + {% for ev in agent_feed[:12] %} +
        + {{ ev.created_at[11:16] if ev.created_at else '' }} + {{ ev.agent_name }} + {{ ev.title or ev.event_type }} +
        + {% else %}

        Nog geen events.

        {% endfor %} +
        +
        +
        +

        Wacht op OK

        + {% for item in approvals[:8] %} +
        {{ item.agent_name }} {{ item.title or item.event_type }}
        + {% else %}

        Geen goedkeuringen 🎉

        {% endfor %} +
        +
        +
        +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/cockpit/templates/dashboard.py b/cockpit/templates/dashboard.py new file mode 100644 index 0000000..7e7a46f --- /dev/null +++ b/cockpit/templates/dashboard.py @@ -0,0 +1,162 @@ +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path +import json + +from app.db import fetch_all, fetch_one +from app.services.briefing import collect_briefing_data, serialize_stats + +router = APIRouter(tags=["dashboard"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + + +def _safe_count(table: str, where: str = "") -> int: + try: + clause = f" WHERE {where}" if where else "" + row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}") + return int(row["c"]) if row else 0 + except Exception: + return 0 + + +def _safe_sum(table: str, column: str, where: str = "") -> float: + try: + clause = f" WHERE {where}" if where else "" + row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}") + return float(row["total"]) if row else 0.0 + except Exception: + return 0.0 + + +def _briefing_payload(briefing: dict | None) -> dict: + """Always use live DB stats; briefing text may be cached.""" + payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None} + if not briefing: + return payload + + payload["content"] = briefing.get("content") + payload["created_at"] = briefing.get("created_at") + return payload + + +@router.get("/") +async def dashboard(request: Request): + kpis = { + "deals_count": _safe_count("deals"), + "clients_count": _safe_count("clients"), + "pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"), + "pipeline_value": _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')"), + "browser_sessions_24h": 0, + "monitor_sites": _safe_count("monitored_sites", "is_active = TRUE"), + "supermarkets_count": _safe_count("supermarkets"), + "crm_partnerships": _safe_count("client_supermarket_links", "partnership_status = 'active'"), + } + + briefing = None + try: + briefing = fetch_one( + "SELECT id, content, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1" + ) + if briefing and briefing.get("created_at"): + briefing["created_at"] = briefing["created_at"].isoformat() + except Exception: + briefing = None + + agent_feed: list = [] + try: + agent_feed = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events ORDER BY created_at DESC LIMIT 25 + """ + ) + for ev in agent_feed: + if ev.get("created_at"): + ev["created_at"] = ev["created_at"].isoformat() + except Exception: + agent_feed = [] + + approvals: list = [] + try: + approvals = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, created_at + FROM agent_events WHERE status = 'needs_approval' + ORDER BY created_at ASC LIMIT 25 + """ + ) + for ev in approvals: + if ev.get("created_at"): + ev["created_at"] = ev["created_at"].isoformat() + except Exception: + approvals = [] + + browser_sessions: list = [] + try: + browser_sessions = fetch_all( + """ + SELECT id, url, final_url, title, task, status, created_at, + LEFT(content_text, 300) AS preview + FROM browser_sessions + ORDER BY created_at DESC LIMIT 8 + """ + ) + kpis["browser_sessions_24h"] = _safe_count( + "browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'" + ) + for s in browser_sessions: + if s.get("created_at"): + s["created_at"] = s["created_at"].isoformat() + except Exception: + browser_sessions = [] + + monitor_sites: list = [] + monitor_changes: list = [] + try: + monitor_sites = fetch_all( + """ + SELECT id, name, url, last_title, last_crawled, is_active + FROM monitored_sites WHERE is_active = TRUE ORDER BY last_crawled DESC NULLS LAST LIMIT 10 + """ + ) + for s in monitor_sites: + if s.get("last_crawled"): + s["last_crawled"] = s["last_crawled"].isoformat() + monitor_changes = fetch_all( + """ + SELECT pc.id, pc.changed_at, ms.name, ms.url + FROM page_changes pc + JOIN monitored_sites ms ON ms.id = pc.site_id + WHERE pc.changed_at >= NOW() - INTERVAL '7 days' + ORDER BY pc.changed_at DESC LIMIT 10 + """ + ) + for c in monitor_changes: + if c.get("changed_at"): + c["changed_at"] = c["changed_at"].isoformat() + except Exception: + pass + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "page_title": "Herman · Command Center", + "kpis": kpis, + "briefing": briefing, + "briefing_payload": _briefing_payload(briefing), + "agent_feed": agent_feed, + "approvals": approvals, + "browser_sessions": browser_sessions, + "monitor_sites": monitor_sites, + "monitor_changes": monitor_changes, + }, + ) + + +@router.get("/marketing-redirect") +async def marketing_redirect(): + return RedirectResponse(url="/marketing", status_code=302) diff --git a/cockpit/templates/deals.html b/cockpit/templates/deals.html new file mode 100644 index 0000000..c985a5e --- /dev/null +++ b/cockpit/templates/deals.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block content %} +
        + + + +{% for d in deals %} + + +{% endfor %}
        TitleClientValueStage
        {{ d.title }}{{ d.client_name or '—' }}€{{ d.value }}{{ d.stage }}
        + +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/documents.html b/cockpit/templates/documents.html new file mode 100644 index 0000000..53f29dd --- /dev/null +++ b/cockpit/templates/documents.html @@ -0,0 +1,383 @@ +{% extends "base.html" %} +{% block title %}Documents & Sentiment · Foodlinkk Command Center{% endblock %} +{% block extra_head %} + + +{% endblock %} +{% block content %} +
        + + +
        +
        Documenten
        +
        Totaal woorden
        {{ summary.total_words }}
        +
        Foto's in DB
        +
        Gem. sentiment
        {{ summary.avg_sentiment }}
        +
        + +
        + + + + + + +
        + +
        +
        +

        Top woorden klik = filter

        + +
        +
        +

        Sentiment klik = filter

        + +
        +
        + +
        +
        +

        Woord zoeken

        +
        + + + +
        +
        + + + + + + +
        LemmaTokenTotaalDocumenten
        +
        + +
        +

        Documenten op NAS

        + + + + + + + + +
        BestandPadTypeWoordenSentimentScoreGeanalyseerd
        Geen documenten voor dit filter.
        +
        + +
        +
        +

        Foto's — OCR & detectie

        +
        + + +
        +
        +

        Telegram-foto's worden automatisch opgeslagen. Klik een foto voor detectie-boxes (gewicht/prijs extractie).

        +
        + +

        Nog geen foto's — upload hier of stuur via Telegram.

        +
        +
        + +
        +

        Detectie overlay — foto #

        +
        + + +
        +
        
        +  
        +    
        +    
        +      
        +    
        +  
        GeëxtraheerdGewichtPrijs
        +
        +
        +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/cockpit/templates/herman-dashboard.css b/cockpit/templates/herman-dashboard.css new file mode 100644 index 0000000..2cbae2e --- /dev/null +++ b/cockpit/templates/herman-dashboard.css @@ -0,0 +1,122 @@ +/* Herman dashboard — Telegram-tab style motion */ + +@keyframes tg-ripple { + 0% { transform: scale(0.95); opacity: 0.7; box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.5); } + 70% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 14px rgba(56, 189, 248, 0); } + 100% { transform: scale(0.95); opacity: 0.7; box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); } +} + +@keyframes tg-orbit { + 0% { transform: rotate(0deg) translateX(4px) rotate(0deg); } + 100% { transform: rotate(360deg) translateX(4px) rotate(-360deg); } +} + +@keyframes tg-gradient-flow { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes tg-breathe { + 0%, 100% { opacity: 0.85; filter: brightness(1); } + 50% { opacity: 1; filter: brightness(1.15); } +} + +@keyframes spin-dots { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +.herman-hero { + position: relative; + overflow: hidden; +} +.herman-hero::before { + content: ""; + position: absolute; + top: -50%; left: -50%; + width: 200%; height: 200%; + background: radial-gradient(circle at 30% 40%, rgba(56,189,248,0.12) 0%, transparent 45%), + radial-gradient(circle at 70% 60%, rgba(192,132,252,0.1) 0%, transparent 40%); + animation: tg-gradient-flow 8s ease infinite; + background-size: 200% 200%; + pointer-events: none; +} + +.herman-briefing.pulse-panel { + animation: tg-ripple 3s ease-in-out infinite; +} + +.briefing-card, .briefing-chart-panel, .briefing-stat { + animation: tg-breathe 4s ease-in-out infinite; +} +.briefing-card:nth-child(2) { animation-delay: 0.5s; } +.briefing-card:nth-child(3) { animation-delay: 1s; } + +.hub-kpi-row .kpi-card { + animation: tg-ripple 3.5s ease-in-out infinite; +} +.hub-kpi-row .kpi-card:nth-child(2) { animation-delay: 0.4s; } +.hub-kpi-row .kpi-card:nth-child(3) { animation-delay: 0.8s; } +.hub-kpi-row .kpi-card:nth-child(4) { animation-delay: 1.2s; } +.hub-kpi-row .kpi-card:nth-child(5) { animation-delay: 1.6s; } + +.live-badge::before { animation: tg-orbit 2s linear infinite, pulse-dot 1.2s ease-in-out infinite; } + +.briefing-loading-overlay { + position: absolute; + inset: 0; + background: rgba(10, 14, 20, 0.75); + backdrop-filter: blur(4px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + z-index: 20; + border-radius: 12px; +} +.briefing-loading-overlay .loading-dots { + display: flex; + gap: 0.5rem; +} +.briefing-loading-overlay .loading-dots span { + width: 12px; height: 12px; + border-radius: 50%; + background: var(--pulse-blue, #38bdf8); + animation: spin-dots 1.2s ease-in-out infinite; +} +.briefing-loading-overlay .loading-dots span:nth-child(2) { animation-delay: 0.15s; } +.briefing-loading-overlay .loading-dots span:nth-child(3) { animation-delay: 0.3s; } +.briefing-loading-overlay p { color: var(--text-primary); font-size: 1rem; margin: 0; } + +.herman-briefing { position: relative; } + +.retail-highlight { + animation: fade-up 0.4s ease; + text-decoration: none; + color: inherit; + transition: transform 0.2s, background 0.2s; + padding: 0.4rem 0.25rem; + border-radius: 8px; +} +.retail-highlight:hover { + background: rgba(56,189,248,0.1); + transform: translateX(4px); +} + +.feed-card { animation: tg-breathe 5s ease-in-out infinite; } + +.count-up { display: inline-block; transition: color 0.3s; } + +.section-fill:not(:empty) { + min-height: 2rem; +} + +.briefing-card h3, .horizon-card h3 { + text-shadow: 0 0 20px rgba(56,189,248,0.25); +} + +.topnav-herman.active, .topnav a.active { + animation: pulse-ring 2.5s infinite; +} diff --git a/cockpit/templates/herman_chat.html b/cockpit/templates/herman_chat.html new file mode 100644 index 0000000..44ea2b9 --- /dev/null +++ b/cockpit/templates/herman_chat.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block content %} +
        + + +
        +

        Foto genereren (ComfyUI)

        +

        Beschrijf wat je wilt zien. Op CPU duurt dit 1–3 minuten.

        +
        + + +
        +
        Ik ga even de gegevens ophalen — afbeelding wordt gemaakt…
        +
        + Gegenereerde afbeelding +
        +
        + +
        + {% if user_message %}

        {{ user_message }}

        {% endif %} + {% if last_reply %}
        {{ last_agent }}

        {{ last_reply }}

        {% endif %} + {% for h in history[:10] %} +
        {{ h.created_at }}

        {{ h.title or h.body }}

        + {% endfor %} +
        +
        + + +
        +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/hermes.html b/cockpit/templates/hermes.html new file mode 100644 index 0000000..6753a1e --- /dev/null +++ b/cockpit/templates/hermes.html @@ -0,0 +1,244 @@ +{% extends "base.html" %} +{% block title %}Hermes · Telegram Command Center{% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +
        + + + + +
        +
        +
        +
        💬
        +
        Conversaties
        Telegram chats
        +
        +
        0
        +
        +
        +
        +
        +
        📨
        +
        Berichten
        in + out
        +
        +
        0
        +
        +
        +
        +
        +
        🕸
        +
        Relaties
        graph edges
        +
        +
        0
        +
        +
        +
        +
        +
        🧠
        +
        Vectors
        pgvector
        +
        +
        0
        +
        +
        +
        + + +
        +
        +
        +

        Berichten per richting

        + klik voor detail +
        +
        +
        +
        +
        +

        Agent activiteit

        + laatste events +
        +
        +
        +
        + +
        + + + + + +
        + + +
        + + +
        +
        +

        Telegram live feed

        + + +
        +
        + +

        Nog geen berichten — stuur iets via @klaploper_bot

        +
        +
        +
        + + +
        +
        +
        + Personal PA — 4 site browsers +

        +
        + + + +
        +
        + +
        +
        + + +
        +
        +

        Second brain — message graph

        + + +
        +
        +
        + + +
        +

        Vector + full-text memory search

        + +
        + +
        +
        + + +
        +
        +

        Hermes status

        +
          +
        • Bot: @klaploper_bot
        • +
        • CEO: Aïssa · CTO: Mo
        • +
        • Control API:
        • +
        • Inbound: · Outbound:
        • +
        +
        + + + +
        +

        +
        +
        +

        Team

        + +
        +
        +

        Agent events

        + +
        +
        +
        +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/marketing.html b/cockpit/templates/marketing.html new file mode 100644 index 0000000..b1b2a59 --- /dev/null +++ b/cockpit/templates/marketing.html @@ -0,0 +1,612 @@ +{% extends "base.html" %} +{% block content %} +
        + + +
        +
        + +
        +
        + +
        +
        RSS items
        +
        Markt trends
        +
        Top kansen
        +
        Mentions{{ analytics.mention_count }}
        +
        + +
        + +
        +
        +
        +
        +

        Kant-en-klaar & supermarkt RSS Gefilterd

        +

        Alleen kant-en-klaar maaltijden, retail en supermarkten — geen algemeen nieuws.

        + + +

        Geen items — klik RSS ophalen.

        +
        +
        +

        Markt trends Live

        + +

        Top halal kansen

        + +
        +
        +
        + + +
        +

        Snelle acties Pulse

        +
        + Open Retail 360 + CRM Clients + CRM Deals + +
        +

        Herman Recommendations

        +

        Laden…

        +

        Geplande posts

        + + {% for p in scheduled_posts %} + + {% endfor %}
        AccountContentTijdStatus
        {{ p.platform }} @{{ p.username }}{{ p.content[:60] }}{{ p.scheduled_time }}{{ p.status }} +
        +
        + + +
        +
        +
        +

        Supermarkt reclame folders Acties

        +

        Live folders van Reclamefolder.nl — klik om de folder te openen.

        +
        +
        + + Bron site → +
        +
        +
        +
        Ketens
        +
        Folders
        +
        Totaal geladen
        +
        +
        + + + + +
        +
        + +
        +

        Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».

        +

        Nieuwe folder registreren

        +
        + + + + + +
        +
        + + +
        +
        +
        +

        Social automatisering 202 Async

        +

        Publiceer naar alle kanalen tegelijk. Zonder API-keys worden kanalen overgeslagen.

        +
        + ⚙️ Social API's instellen +
        + +
        + +
        + +
        + +
        + + + +
        + + Preview + + Job # · +
        + +

        Publicatie historie

        + + + + + +
        #TijdKanalenStatusResultaat
        +

        Nog geen publicaties.

        +
        + + +
        +

        Opgeslagen RSS artikelen

        + +

        Nog geen opgeslagen artikelen — klik ☆ Bewaren in Live Feed.

        +
        + + +
        +
        +
        +

        Regelgeving · CBS · Food markt Live RSS

        +

        NVWA, EU food law, CBS statistiek en retail M&A — apart van kant-en-klaar nieuws.

        +
        + +
        +
        +
        +

        ⚖️ Regelgeving & NVWA

        + +

        Geen items — klik Feeds ophalen.

        +
        +
        +

        📊 CBS statistiek

        + +

        Geen CBS feeds — refresh RSS.

        +
        +
        +

        🏪 Food markt & M&A

        + +

        Geen markt news.

        +
        +
        +
        + + +
        +

        Marketing strategie · halal kant-en-klaar

        +
        + 🎯 Focus: halal-gap filialen +

        Filialen zonder halal certificering in buurten met hoge demografische vraag — direct benaderen via CRM koppeling in Retail 360.

        + Bekijk kansen → +
        +
        + 📊 Data bronnen actief +

        CBS demografie · PDOK geocoding · Open-Meteo weer · RSS feeds (Foodlog, RetailDetail, VMT, Nu.nl) · OSM groothandels

        +
        +
        + 🌤️ Weer & seizoen +

        Gebruik weer voorspelling per filiaal in Retail 360 om promoties te timen (warme dagen = kant-en-klaar salades, kou = stoofgerechten).

        +
        + +
        
        +
        + + +
        +

        Mentions

        +
        + + + +
        + + {% for m in social_mentions %} + + {% endfor %}
        PlatformTextSentiment
        {{ m.platform }}{{ m.text[:80] }}{{ m.sentiment_score }}
        +

        Accounts

        +
        + + + +
        + + {% for a in accounts %}{% endfor %}
        PlatformUsernameActive
        {{ a.platform }}{{ a.username }}{{ a.is_active }}
        +
        + + +
        +

        AI Studio

        +
        + + + +
        +
        
        +  

        Agent rules

        +
        + + + +
        + + {% for r in agent_rules %}{% endfor %}
        NameConditionActive
        {{ r.name }}{{ r.condition_type }}{{ r.is_active }}
        +
        +
        +
        +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/monitor.html b/cockpit/templates/monitor.html new file mode 100644 index 0000000..193da3d --- /dev/null +++ b/cockpit/templates/monitor.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block content %} +
        + +
        +
        Active sites
        {{ stats.active_sites }}
        +
        Changes 24h
        {{ stats.changes_24h }}
        +
        +
        +

        Website toevoegen

        +
        + + + + +
        +
        +
        +

        Gemonitorde websites

        + + {% for s in sites %} + + + + + + {% else %} + + {% endfor %}
        NaamURLLaatste titelLaatst gechecktActies
        {{ s.name }}{{ s.url }}{{ s.last_title or '—' }}{{ s.last_crawled or '—' }} + + + +
        Geen sites.
        +

        Verwijder = permanent uit database. Pauze = tijdelijk uitgeschakeld.

        +
        +
        +

        Wijzigingen

        +
          {% for c in page_changes %}
        • {{ c.name or c.url }} · {{ c.changed_at }}
        • {% else %}
        • Geen wijzigingen
        • {% endfor %}
        +
        +

        Crawl logs

        +
          {% for l in crawl_logs %}
        • {{ l.status }} {{ l.message }} {{ l.logged_at }}
        • {% endfor %}
        +
        +
        +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/ops.html b/cockpit/templates/ops.html new file mode 100644 index 0000000..3306168 --- /dev/null +++ b/cockpit/templates/ops.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} +{% block extra_head %} + +{% endblock %} +{% block content %} +
        +
        +
        +

        IT Ops Infrastructure

        +

        Realtime Proxmox + service health topology

        +
        +
        + + +
        +
        + +
        +
        Health
        +
        Nodes
        +
        Online
        +
        Offline
        +
        + +
        + + + + + + + + + +
        + +

        Last update:

        +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/packaging.html b/cockpit/templates/packaging.html new file mode 100644 index 0000000..e2e606d --- /dev/null +++ b/cockpit/templates/packaging.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} +{% block title %}Packaging Studio · Foodlinkk{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block content %} +
        + + +
        +
        +
        + + + + + +
        + +

        Elementen

        +
        + + + + +
        + +
        + + Download SVG + Download PNG + Download PDF +
        +
        + + +
        + +
        +
        +
        +

        Start met Genereren om de preview te zien.

        +
        +
        +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/palantir-theme.css b/cockpit/templates/palantir-theme.css new file mode 100644 index 0000000..bb1da1b --- /dev/null +++ b/cockpit/templates/palantir-theme.css @@ -0,0 +1,867 @@ +@import url('tokens.css'); + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; + background: var(--bg-root); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; +} + +a { + color: var(--accent-cyan); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + background: var(--bg-surface); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + padding: 1.25rem 0; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 10; +} + +.brand { + padding: 0 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-subtle); + margin-bottom: 1rem; +} + +.brand-title { + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + margin: 0 0 0.25rem; +} + +.brand-name { + font-size: 1.05rem; + font-weight: 600; + margin: 0; + color: var(--text-primary); +} + +.nav { + list-style: none; + margin: 0; + padding: 0 0.75rem; + flex: 1; +} + +.nav li { + margin-bottom: 0.25rem; +} + +.nav a { + display: block; + padding: 0.55rem 0.75rem; + border-radius: var(--radius-md); + color: var(--text-secondary); + text-decoration: none; + font-weight: 500; +} + +.nav a:hover, +.nav a.active { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav a.active { + border-left: 2px solid var(--accent-cyan); +} + +.main { + margin-left: var(--sidebar-width); + flex: 1; + padding: 1.5rem 2rem 2rem; + max-width: 1400px; +} + +.page-header { + margin-bottom: 1.5rem; +} + +.page-header h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; + font-weight: 600; +} + +.page-header .subtitle { + margin: 0; + color: var(--text-muted); + font-size: 0.875rem; +} + +.panel { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-panel); + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +.panel h2 { + margin: 0 0 1rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.25rem; +} + +.kpi-card { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1rem 1.15rem; + position: relative; + overflow: hidden; +} + +.kpi-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--kpi-accent, var(--accent-cyan)); +} + +.kpi-card.purple::before { background: var(--accent-purple); } +.kpi-card.amber::before { background: var(--accent-amber); } +.kpi-card.green::before { background: var(--accent-green); } + +.kpi-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 0.35rem; +} + +.kpi-value { + font-size: 1.75rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; +} + +@media (max-width: 960px) { + .grid-2 { + grid-template-columns: 1fr; + } + .sidebar { + position: relative; + width: 100%; + } + .main { + margin-left: 0; + } + .layout { + flex-direction: column; + } +} + +.herman-briefing { + border-left: 3px solid var(--accent-cyan); + background: linear-gradient(135deg, var(--accent-cyan-dim), transparent); +} + +.herman-briefing .briefing-meta { + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.herman-briefing .briefing-body { + white-space: pre-wrap; + color: var(--text-secondary); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.45rem 0.85rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--bg-hover); + color: var(--text-primary); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; +} + +.btn:hover { + background: var(--bg-elevated); + text-decoration: none; +} + +.btn-primary { + background: var(--accent-cyan-dim); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.btn-approve { + border-color: var(--accent-green); + color: var(--accent-green); + background: var(--accent-green-dim); +} + +.btn-reject { + border-color: var(--accent-red); + color: var(--accent-red); + background: var(--accent-red-dim); +} + +.agent-feed { + list-style: none; + margin: 0; + padding: 0; + max-height: 420px; + overflow-y: auto; +} + +.agent-feed li { + display: flex; + gap: 0.75rem; + padding: 0.65rem 0; + border-bottom: 1px solid var(--border-subtle); + font-size: 0.8125rem; +} + +.agent-feed li:last-child { + border-bottom: none; +} + +.feed-time { + flex-shrink: 0; + width: 4.5rem; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.7rem; +} + +.feed-body { + flex: 1; + min-width: 0; +} + +.feed-message { + color: var(--text-secondary); +} + +.badge { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: var(--radius-sm); + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge-agent-herman, +.badge-agent-cyan { + background: var(--accent-cyan-dim); + color: var(--accent-cyan); +} + +.badge-agent-sales, +.badge-agent-purple { + background: var(--accent-purple-dim); + color: var(--accent-purple); +} + +.badge-agent-marketing, +.badge-agent-amber { + background: var(--accent-amber-dim); + color: var(--accent-amber); +} + +.badge-agent-ops, +.badge-agent-green { + background: var(--accent-green-dim); + color: var(--accent-green); +} + +.badge-status-needs_approval { + background: var(--accent-amber-dim); + color: var(--accent-amber); +} + +.badge-status-approved, +.badge-status-completed { + background: var(--accent-green-dim); + color: var(--accent-green); +} + +.badge-status-rejected { + background: var(--accent-red-dim); + color: var(--accent-red); +} + +.approval-queue .approval-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-subtle); +} + +.approval-queue .approval-item:last-child { + border-bottom: none; +} + +.approval-actions { + display: flex; + gap: 0.5rem; + margin-left: auto; +} + +.approval-actions form { + margin: 0; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.55rem 0.65rem; + border-bottom: 1px solid var(--border-subtle); +} + +.data-table th { + color: var(--text-muted); + font-weight: 600; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.data-table tbody tr:hover { + background: var(--bg-hover); +} + +.agent-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 1.25rem; +} + +.agent-card { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1rem; +} + +.agent-card.cyan { border-top: 2px solid var(--accent-cyan); } +.agent-card.purple { border-top: 2px solid var(--accent-purple); } +.agent-card.amber { border-top: 2px solid var(--accent-amber); } +.agent-card.green { border-top: 2px solid var(--accent-green); } + +.agent-card h3 { + margin: 0 0 0.25rem; + font-size: 1rem; +} + +.agent-card p { + margin: 0; + color: var(--text-muted); + font-size: 0.8rem; +} + +.muted { + color: var(--text-muted); +} + +.empty-state { + color: var(--text-muted); + font-style: italic; + padding: 0.5rem 0; +} + + +/* Fase 2-4: kanban, charts, voice */ +.kanban-board { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 1rem; margin-bottom: 1.25rem; } +.kanban-column { min-width: 220px; flex: 0 0 220px; background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 0.75rem; } +.kanban-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin: 0 0 0.75rem; } +.kanban-card { background: var(--bg-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 0.65rem; margin-bottom: 0.5rem; font-size: 0.8125rem; } +.chart-placeholder { min-height: 160px; } +.chart-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.45rem; font-size: 0.75rem; } +.chart-bar .bar { height: 8px; background: var(--accent-cyan); border-radius: 4px; min-width: 4px; } +.chart-bar .bar-purple { background: var(--accent-purple); } +.voice-widget { text-align: center; } +.voice-mic { margin: 1rem auto; min-width: 160px; } +.voice-wave { display: flex; justify-content: center; gap: 0.35rem; height: 32px; align-items: flex-end; } +.voice-wave span { width: 4px; height: 12px; background: var(--accent-cyan-dim); border-radius: 2px; animation: voice-pulse 1.2s ease-in-out infinite; } +.voice-wave span:nth-child(2) { animation-delay: 0.2s; } +.voice-wave span:nth-child(3) { animation-delay: 0.4s; } +@keyframes voice-pulse { 0%, 100% { height: 8px; opacity: 0.5; } 50% { height: 24px; opacity: 1; } } +.herman-form textarea { width: 100%; background: var(--bg-root); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); padding: 0.75rem; font-family: inherit; margin-bottom: 0.75rem; } + +/* Command Center interactive UI */ +.modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100; align-items: center; justify-content: center; padding: 1rem; } +.modal.open { display: flex; } +.modal-card { background: var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 1.5rem; max-width: 480px; width: 100%; box-shadow: var(--shadow-lg); } +.drawer { position: fixed; top: 0; right: 0; width: min(420px, 90vw); height: 100vh; background: var(--bg-surface); border-left: 1px solid var(--border-subtle); transform: translateX(100%); transition: transform .25s ease; z-index: 90; padding: 1.5rem; overflow: auto; } +.drawer.open { transform: translateX(0); } +.drawer-close { float: right; background: transparent; border: none; color: var(--text-primary); font-size: 1.5rem; cursor: pointer; } +.btn-group { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; } +.btn-sm { padding: .25rem .55rem; font-size: 12px; } +.clickable-row { cursor: pointer; transition: background .15s; } +.clickable-row:hover { background: var(--bg-hover); } +.clickable-kpi { text-decoration: none; color: inherit; display: block; transition: transform .15s, box-shadow .15s; } +.clickable-kpi:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.25); text-decoration: none; } +.tab-bar { display: flex; gap: .35rem; margin-bottom: 1rem; flex-wrap: wrap; } +.tab-bar button { background: var(--bg-surface); border: 1px solid var(--border-subtle); color: var(--text-secondary); padding: .45rem .9rem; border-radius: var(--radius-md); cursor: pointer; } +.tab-bar button.active { border-color: var(--accent-cyan); color: var(--text-primary); background: var(--bg-hover); } +.toast-container { position: fixed; bottom: 1rem; right: 1rem; z-index: 200; display: flex; flex-direction: column; gap: .5rem; } +.toast { padding: .75rem 1rem; border-radius: var(--radius-md); background: var(--bg-surface); border: 1px solid var(--border-subtle); animation: toastIn .2s ease; } +.toast-success { border-color: var(--accent-green); } +.toast-error { border-color: var(--accent-red, #f44); } +.toast-out { opacity: 0; transition: opacity .3s; } +@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } } +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin-bottom: 1rem; } +.form-input { width: 100%; padding: .55rem .75rem; border-radius: var(--radius-md); border: 1px solid var(--border-subtle); background: var(--bg-root); color: var(--text-primary); } +.data-table { width: 100%; border-collapse: collapse; } +.data-table th, .data-table td { padding: .55rem .65rem; border-bottom: 1px solid var(--border-subtle); text-align: left; } +.kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-top: 1rem; } +.kanban-col { background: var(--bg-surface); border-radius: var(--radius-lg); padding: .75rem; border: 1px solid var(--border-subtle); min-height: 200px; } +.kanban-card { background: var(--bg-root); border-radius: var(--radius-md); padding: .65rem; margin-bottom: .5rem; border: 1px solid var(--border-subtle); } +.chat-layout { display: flex; flex-direction: column; height: calc(100vh - 4rem); } +.chat-messages { flex: 1; overflow: auto; padding: 1rem; background: var(--bg-surface); border-radius: var(--radius-lg); margin-bottom: 1rem; } +.chat-msg { margin-bottom: .75rem; padding: .65rem; border-radius: var(--radius-md); } +.chat-msg.user { background: rgba(0,200,255,.08); } +.chat-msg.agent { background: var(--bg-root); border: 1px solid var(--border-subtle); } +.chat-input { display: flex; gap: .5rem; } +.page-tip { color: var(--text-muted); font-size: 13px; } +.ai-output { white-space: pre-wrap; background: var(--bg-root); padding: 1rem; border-radius: var(--radius-md); min-height: 80px; } +.log-list { list-style: none; padding: 0; max-height: 240px; overflow: auto; } +body.drawer-open { overflow: hidden; } +/* Herman Daily Briefing — visual dashboard */ +.herman-briefing { border-left: 3px solid var(--accent-cyan); } +.herman-briefing-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.25rem; } +.herman-briefing-header h2 { margin: 0; } +.briefing-meta { font-size: 0.75rem; color: var(--text-muted); } + +.briefing-summary-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-bottom: 1.25rem; } +@media (max-width: 900px) { .briefing-summary-grid { grid-template-columns: 1fr; } } + +.briefing-card { + background: var(--surface-elevated, rgba(15, 23, 42, 0.6)); + border: 1px solid var(--border-subtle, rgba(148, 163, 184, 0.15)); + border-radius: 10px; + padding: 1rem 1.15rem; +} +.briefing-card h3 { margin: 0 0 0.65rem; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent-gold, #e8a838); } +.briefing-card.summary { border-color: rgba(56, 189, 248, 0.25); } +.briefing-card.actions { border-color: rgba(232, 168, 56, 0.25); } +.briefing-card.actions ul { margin: 0; padding-left: 1.1rem; color: var(--text-secondary); line-height: 1.55; } +.briefing-card.actions li { margin-bottom: 0.35rem; } +#briefing-summary-text { color: var(--text-primary, #e2e8f0); line-height: 1.6; font-size: 0.95rem; } + +.briefing-kpi-row { display: flex; flex-wrap: wrap; gap: 0.65rem; margin-bottom: 1.25rem; } +.briefing-stat { + flex: 1; min-width: 100px; + background: rgba(15, 23, 42, 0.5); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 8px; + padding: 0.65rem 0.85rem; + text-align: center; +} +.briefing-stat.green { border-color: rgba(62, 207, 142, 0.35); } +.briefing-stat.purple { border-color: rgba(167, 139, 250, 0.35); } +.briefing-stat.cyan { border-color: rgba(56, 189, 248, 0.35); } +.briefing-stat.amber { border-color: rgba(232, 168, 56, 0.35); } +.briefing-stat-label { display: block; font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; } +.briefing-stat-value { display: block; font-size: 1.35rem; font-weight: 600; color: var(--text-primary); margin-top: 0.15rem; } + +.briefing-charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.25rem; } +@media (max-width: 900px) { .briefing-charts-grid { grid-template-columns: 1fr; } } +.briefing-chart-panel { + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.1); + border-radius: 10px; + padding: 0.75rem; + min-height: 220px; +} + +.briefing-details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; } +@media (max-width: 900px) { .briefing-details-grid { grid-template-columns: 1fr; } } + +.briefing-pending-item { + padding: 0.5rem 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + color: var(--text-secondary); + font-size: 0.9rem; +} + +.briefing-toggle { margin-top: 0.5rem; } +.briefing-full-report { + display: none; + margin-top: 0.75rem; + max-height: 280px; + overflow: auto; + white-space: pre-wrap; + font-size: 0.8rem; + color: var(--text-muted); + background: rgba(0,0,0,0.2); + border-radius: 8px; + padding: 1rem; + border: 1px solid rgba(148, 163, 184, 0.1); +} +.briefing-full-report.open { display: block; } + +.badge-positive { background: rgba(62,207,142,.2); color: #3ecf8e; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; } +.badge-neutral { background: rgba(107,114,128,.25); color: #cbd5e1; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; } +.badge-negative { background: rgba(239,68,68,.2); color: #ef4444; padding: .15rem .45rem; border-radius: 999px; font-size: .72rem; } +/* Settings page */ +.settings-header { margin-bottom: 0.5rem; } + +.settings-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.15); + padding-bottom: 0; +} +.settings-tab { + display: inline-block; + padding: 0.65rem 1.1rem; + color: var(--text-muted, #94a3b8); + text-decoration: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-size: 0.9rem; + transition: color 0.15s, border-color 0.15s; +} +.settings-tab:hover { color: var(--text-primary, #e2e8f0); } +.settings-tab.active { + color: var(--accent-gold, #e8a838); + border-bottom-color: var(--accent-gold, #e8a838); +} + +.settings-panel { border-left: 3px solid var(--accent-gold, #e8a838); } +.settings-panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} +.settings-active-banner { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem 0.85rem; + background: rgba(62, 207, 142, 0.08); + border: 1px solid rgba(62, 207, 142, 0.25); + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.email-account-list { display: flex; flex-direction: column; gap: 0.75rem; } +.email-account-card { + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + padding: 1rem 1.1rem; +} +.email-account-card.is-active { border-color: rgba(62, 207, 142, 0.35); } +.email-account-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.email-account-card-head strong { display: block; } +.email-account-card-head .muted { font-size: 0.85rem; color: var(--text-muted); } +.email-account-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.settings-modal { max-width: 520px; width: 100%; } +.settings-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin: 0.75rem 0; +} +@media (max-width: 600px) { .settings-form-grid { grid-template-columns: 1fr; } } + +.form-label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem; } +.form-label .form-input { margin-top: 0.25rem; } +.form-check { display: flex; align-items: center; gap: 0.5rem; margin: 0.75rem 0; font-size: 0.9rem; } + +.settings-details { + margin: 0.75rem 0; + padding: 0.75rem; + border: 1px dashed rgba(148, 163, 184, 0.2); + border-radius: 8px; +} +.settings-details summary { cursor: pointer; color: var(--text-secondary); font-size: 0.85rem; } + +.sidebar .nav-settings { + margin-top: auto; + padding-top: 0.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.12); +} + +[x-cloak] { display: none !important; } +/* ── Horizontal top navigation ── */ +.topbar { + position: sticky; + top: 0; + z-index: 100; + background: linear-gradient(180deg, #0f172a 0%, #0b1220 100%); + border-bottom: 1px solid rgba(148, 163, 184, 0.15); + padding: 0.65rem 1.25rem 0; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35); +} +.topbar-brand { + display: inline-block; + margin-right: 1.5rem; + margin-bottom: 0.5rem; + vertical-align: middle; +} +.topbar-brand .brand-title { margin: 0; font-size: 0.7rem; letter-spacing: 0.12em; color: var(--accent-gold, #e8a838); text-transform: uppercase; } +.topbar-brand .brand-name { margin: 0; font-size: 1rem; font-weight: 600; color: var(--text-primary, #e2e8f0); } + +.topnav { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.35rem 1.25rem; + padding-bottom: 0; +} +.topnav-herman { + display: inline-flex; + align-items: center; + padding: 0.55rem 1.25rem; + margin-right: 0.5rem; + margin-bottom: -1px; + border-radius: 8px 8px 0 0; + font-weight: 700; + font-size: 0.95rem; + text-decoration: none; + color: #0f172a; + background: linear-gradient(135deg, #e8a838 0%, #f0c060 100%); + border: 1px solid rgba(232, 168, 56, 0.5); + border-bottom: none; + box-shadow: 0 -2px 12px rgba(232, 168, 56, 0.25); +} +.topnav-herman:hover { filter: brightness(1.05); color: #0f172a; } +.topnav-herman.active { background: linear-gradient(135deg, #e8a838 0%, #f5d078 100%); } + +.topnav-group { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.15rem; + padding-bottom: 0.45rem; + border-left: 1px solid rgba(148, 163, 184, 0.12); + padding-left: 1rem; +} +.topnav-group:first-of-type { border-left: none; padding-left: 0; } +.topnav-label { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted, #64748b); + margin-right: 0.35rem; + padding-right: 0.35rem; +} +.topnav-group a { + display: inline-block; + padding: 0.4rem 0.7rem; + border-radius: 6px; + font-size: 0.82rem; + text-decoration: none; + color: var(--text-secondary, #94a3b8); + border: 1px solid transparent; + transition: color 0.15s, background 0.15s, border-color 0.15s; + white-space: nowrap; +} +.topnav-group a:hover { + color: var(--text-primary, #e2e8f0); + background: rgba(148, 163, 184, 0.08); +} +.topnav-group a.active { + color: var(--accent-cyan, #38bdf8); + background: rgba(56, 189, 248, 0.1); + border-color: rgba(56, 189, 248, 0.25); +} +.topnav-group-system { margin-left: auto; border-left: 1px solid rgba(148, 163, 184, 0.12); } + +.main-topnav { + padding: 1.25rem 1.5rem 2rem; + min-height: calc(100vh - 80px); + background: var(--bg-root, #070b14); +} + +/* Herman hub layout */ +.herman-hub-header { margin-bottom: 1rem; } +.herman-hub-header h1 { margin: 0 0 0.25rem; } +.herman-hub-header .subtitle { margin: 0; } + +.hub-kpi-row { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.75rem; + margin-bottom: 1.25rem; +} +@media (max-width: 1100px) { .hub-kpi-row { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 700px) { .hub-kpi-row { grid-template-columns: repeat(2, 1fr); } } + +.hub-grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} +@media (max-width: 1000px) { .hub-grid { grid-template-columns: 1fr; } } + +.hub-section-tabs { + display: flex; + gap: 0.35rem; + margin-bottom: 1rem; + flex-wrap: wrap; + border-bottom: 1px solid rgba(148, 163, 184, 0.12); + padding-bottom: 0.5rem; +} +.hub-section-tabs button, .hub-section-tabs a { + padding: 0.45rem 0.9rem; + border-radius: 6px 6px 0 0; + font-size: 0.85rem; + background: transparent; + border: 1px solid transparent; + color: var(--text-muted); + text-decoration: none; + cursor: pointer; +} +.hub-section-tabs .active { + color: var(--accent-gold); + border-color: rgba(232, 168, 56, 0.3); + background: rgba(232, 168, 56, 0.08); +} + +.hub-feed-item { + padding: 0.55rem 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + font-size: 0.88rem; +} +.hub-feed-time { color: var(--text-muted); font-size: 0.75rem; margin-right: 0.5rem; } + +/* Hide old sidebar layout when topnav is active */ +body:has(.topbar) .layout { display: block; } +body:has(.topbar) .sidebar { display: none; } +body:has(.topbar) .main { margin-left: 0; } + +@media (max-width: 900px) { + .topnav { gap: 0.25rem 0.75rem; } + .topnav-group { padding-left: 0.5rem; } + .topnav-label { display: none; } +} + +/* Compact Herman briefing charts */ +.briefing-charts-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + margin-bottom: 0.75rem; + max-height: 180px; +} +@media (max-width: 1100px) { .briefing-charts-grid { grid-template-columns: repeat(2, 1fr); max-height: none; } } +.briefing-chart-panel { + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.1); + border-radius: 8px; + padding: 0.35rem 0.5rem; + min-height: 0 !important; + height: 155px; + position: relative; +} +.briefing-chart-panel canvas { max-height: 130px !important; } +.briefing-summary-grid { margin-bottom: 0.75rem; } +.briefing-kpi-row { margin-bottom: 0.75rem; } +.briefing-kpi-row .briefing-stat { padding: 0.45rem 0.6rem; } +.briefing-kpi-row .briefing-stat-value { font-size: 1.1rem; } + +.browser-instruct-box { + background: rgba(56, 189, 248, 0.06); + border: 1px solid rgba(56, 189, 248, 0.2); + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; +} +.browser-instruct-box textarea { min-height: 70px; } +.browser-steps-log { + font-size: 0.8rem; color: var(--text-muted); + margin-top: 0.5rem; max-height: 100px; overflow: auto; +} +.browser-action-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; } + +.novnc-fallback { + padding: 1rem; text-align: center; background: #111; + border-radius: 8px; color: #94a3b8; font-size: 0.85rem; +} +.novnc-fallback a { color: var(--accent-cyan); } diff --git a/cockpit/templates/products.html b/cockpit/templates/products.html new file mode 100644 index 0000000..50daacb --- /dev/null +++ b/cockpit/templates/products.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block content %} +
        + + +

        Margin calculator

        +
        + +
        +

        Margin: %

        + +{% for p in products %} + +{% endfor %}
        NameStatusMargin %
        {{ p.name }}{{ p.status }}{{ p.margin_pct }}
        + +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/pulse-theme.css b/cockpit/templates/pulse-theme.css new file mode 100644 index 0000000..1c0a1c5 --- /dev/null +++ b/cockpit/templates/pulse-theme.css @@ -0,0 +1,258 @@ +/* Telegram-inspired pulse & live feed theme */ +:root { + --pulse-blue: #2aabee; + --pulse-green: #22c55e; + --pulse-purple: #a855f7; + --pulse-orange: #f97316; + --pulse-glow: rgba(42, 171, 238, 0.45); +} + +@keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 var(--pulse-glow); } + 70% { box-shadow: 0 0 0 10px rgba(42, 171, 238, 0); } + 100% { box-shadow: 0 0 0 0 rgba(42, 171, 238, 0); } +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.85); } +} + +@keyframes live-shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes ticker-scroll { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +.btn-pulse { + position: relative; + animation: pulse-ring 2s infinite; + border: 1px solid var(--pulse-blue) !important; + background: linear-gradient(135deg, rgba(42,171,238,0.15), rgba(42,171,238,0.05)) !important; +} + +.btn-pulse-green { + animation-name: pulse-ring; + --pulse-glow: rgba(34, 197, 94, 0.45); + border-color: var(--pulse-green) !important; + background: linear-gradient(135deg, rgba(34,197,94,0.15), rgba(34,197,94,0.05)) !important; +} + +.btn-pulse-purple { + --pulse-glow: rgba(168, 85, 247, 0.45); + border-color: var(--pulse-purple) !important; + background: linear-gradient(135deg, rgba(168,85,247,0.15), rgba(168,85,247,0.05)) !important; +} + +.live-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--pulse-green); +} + +.live-badge::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--pulse-green); + animation: pulse-dot 1.2s ease-in-out infinite; +} + +.live-feed-bar { + background: linear-gradient(90deg, #0f172a 25%, #1e3a5f 50%, #0f172a 75%); + background-size: 200% 100%; + animation: live-shimmer 3s linear infinite; + border: 1px solid rgba(42, 171, 238, 0.3); + border-radius: 10px; + padding: 0.6rem 1rem; + margin-bottom: 1rem; + overflow: hidden; +} + +.ticker-track { + display: flex; + gap: 2rem; + white-space: nowrap; + animation: ticker-scroll 40s linear infinite; +} + +.ticker-track:hover { animation-play-state: paused; } + +.side-nav { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem; +} + +.side-nav-btn { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem 0.85rem; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; + font-size: 0.9rem; + transition: all 0.2s; +} + +.side-nav-btn:hover { + background: rgba(42, 171, 238, 0.08); + border-color: rgba(42, 171, 238, 0.2); +} + +.side-nav-btn.active { + background: rgba(42, 171, 238, 0.15); + border-color: var(--pulse-blue); + box-shadow: 0 0 12px rgba(42, 171, 238, 0.2); +} + +.section-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0.75rem 0; +} + +.section-tab { + padding: 0.35rem 0.75rem; + border-radius: 999px; + border: 1px solid #334155; + background: #0f172a; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.section-tab.active { + border-color: var(--pulse-blue); + background: rgba(42, 171, 238, 0.2); + box-shadow: 0 0 8px rgba(42, 171, 238, 0.25); +} + +.feed-card { + padding: 0.75rem; + border-radius: 10px; + border: 1px solid #334155; + margin-bottom: 0.5rem; + transition: border-color 0.2s; +} + +.feed-card:hover { + border-color: var(--pulse-blue); +} + +.feed-card .source { + font-size: 0.7rem; + color: var(--pulse-blue); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.weather-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.weather-day { + flex: 1; + min-width: 70px; + text-align: center; + padding: 0.5rem; + border-radius: 8px; + background: rgba(42, 171, 238, 0.08); + border: 1px solid rgba(42, 171, 238, 0.15); + font-size: 0.75rem; +} + +.weather-day strong { display: block; font-size: 1rem; } + +.note-bubble { + background: rgba(42, 171, 238, 0.08); + border-left: 3px solid var(--pulse-blue); + padding: 0.6rem 0.75rem; + border-radius: 0 8px 8px 0; + margin-bottom: 0.5rem; + font-size: 0.85rem; +} + +.milestone-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border-bottom: 1px solid #1e293b; +} + +.milestone-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--pulse-orange); + animation: pulse-dot 2s infinite; +} + +.milestone-dot.done { background: var(--pulse-green); animation: none; } + +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 0.5rem; +} + +.media-thumb { + aspect-ratio: 1; + border-radius: 8px; + object-fit: cover; + border: 1px solid #334155; +} + +.pro-tab-bar { + display: flex; + gap: 0; + border-bottom: 2px solid #334155; + margin-bottom: 1rem; +} + +.pro-tab { + padding: 0.75rem 1.25rem; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 0.9rem; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + opacity: 0.7; + transition: all 0.2s; +} + +.pro-tab.active { + opacity: 1; + border-bottom-color: var(--pulse-blue); + color: var(--pulse-blue); +} + +.pro-tab:hover { opacity: 1; } + +.kpi-pulse strong { + background: linear-gradient(90deg, var(--pulse-blue), var(--pulse-purple)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} diff --git a/cockpit/templates/reports.html b/cockpit/templates/reports.html new file mode 100644 index 0000000..c9b3bc4 --- /dev/null +++ b/cockpit/templates/reports.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block extra_head %} + + +{% endblock %} +{% block content %} +
        + + +
        +
        Datasets
        +
        Totaal records
        +
        Briefings{{ briefings|length }}
        +
        + +
        +

        Data export Alle tabellen

        +

        Klik CSV of JSON per dataset. Volledige bundle bevat alles in één JSON bestand.

        +
        + +
        +
        + +
        +

        Dagrapport preview

        +
        
        +
        + +
        +

        Briefing archief

        +
          {% for b in briefings %} +
        • {{ b.created_at }} — {{ b.content[:80] }}…
        • + {% else %}
        • Nog geen rapporten
        • {% endfor %}
        +
        +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/retail.html b/cockpit/templates/retail.html new file mode 100644 index 0000000..4fac794 --- /dev/null +++ b/cockpit/templates/retail.html @@ -0,0 +1,750 @@ +{% extends "base.html" %} +{% block content %} + + + + + +
        + + +
        +
        + +
        +
        + +
        +
        Totaal filialen
        +
        CRM actief
        +
        Halal cert.
        +
        CBS data
        +
        Groothandels
        +
        Resultaat
        +
        + +
        + + + + + + + + + + +
        +
        +
        +
        Laden…
        +
        +
        +

        Groothandels OSM + contact

        + +

        Geen groothandels — pas filters aan of klik Groothandels import.

        +
        +
        +

        Top halal-markt kansen

        +
        + +
        +
        + + + + + +
        ScoreFiliaalStadHalal%CRM
        +
        +
        +

        Stad demografie CBS

        +
        + +
        +
        + + + + + +
        StadInwonersHuishoudensHalal-markt%Filialen
        +
        +
        + + +
        +
        + + + + + +{% endblock %} diff --git a/cockpit/templates/settings.html b/cockpit/templates/settings.html new file mode 100644 index 0000000..fffcc26 --- /dev/null +++ b/cockpit/templates/settings.html @@ -0,0 +1,432 @@ +{% extends "base.html" %} +{% block content %} +
        + + + + + {% if active_tab == 'email' %} +
        +
        +
        +

        Email accounts

        +

        Kies welk mailbox Herman gebruikt om te versturen. IMAP velden zijn voor latere inbox-sync.

        +
        + +
        + +
        + Actief + +
        + + +
        + + + {% elif active_tab == 'permissions' %} +
        +
        +
        +

        Herman — Module rechten

        +

        Bepaal welke bedrijfsprocessen Herman mag benaderen. Grant all = volledige toegang.

        +
        + +
        +

        Live

        +
        + +
        +
        + {% elif active_tab == 'social' %} +
        +
        +
        +

        Social API's

        +

        Configureer later je API-sleutels per platform. Zonder keys worden posts in Automatisering overgeslagen (skip-modus).

        +
        + → Marketing Automatisering +
        + +
        + {% elif active_tab == 'general' %} +
        +

        General

        +

        Dashboard: http://10.4.7.18:8600 · Telegram: @klaploper_bot · Ochtend briefing: 07:00

        +

        Meer instellingen (Telegram tijd, timezone) komen hier later.

        +
        + {% endif %} +
        +{% endblock %} + +{% block scripts %} + +{% if active_tab == 'email' %} + +{% endif %} +{% endblock %} diff --git a/cockpit/templates/studio.html b/cockpit/templates/studio.html new file mode 100644 index 0000000..911b1cc --- /dev/null +++ b/cockpit/templates/studio.html @@ -0,0 +1,170 @@ +{% extends "base.html" %} +{% block title %}AI Studio · ComfyUI{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block content %} +
        + + +
        +
        +

        Positieve prompt

        + + +

        Negatieve prompt

        + + +
        + + + +
        + +
        + + ComfyUI backend ↗ +
        + +
        +
        + + +
        +
        +

        +
        +
        + + +
        + +
        +
        + Gegenereerde afbeelding +

        Nog geen afbeelding — vul prompt in en klik Genereer.

        +

        ComfyUI werkt op de achtergrond…

        +
        +
        +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/suppliers.html b/cockpit/templates/suppliers.html new file mode 100644 index 0000000..d22ed0c --- /dev/null +++ b/cockpit/templates/suppliers.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block content %} +
        + + + +{% for s in suppliers %} + +{% endfor %}
        NameCountryRating
        {{ s.name }}{{ s.country }}{{ s.rating }}
        + +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/cockpit/templates/tokens.css b/cockpit/templates/tokens.css new file mode 100644 index 0000000..5cc50b0 --- /dev/null +++ b/cockpit/templates/tokens.css @@ -0,0 +1,28 @@ +:root { + --bg-root: #0b0f14; + --bg-surface: #111820; + --bg-elevated: #161e28; + --bg-hover: #1c2633; + --border-subtle: #243041; + --border-strong: #334155; + --text-primary: #e8eef5; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --accent-cyan: #22d3ee; + --accent-cyan-dim: rgba(34, 211, 238, 0.15); + --accent-purple: #a78bfa; + --accent-purple-dim: rgba(167, 139, 250, 0.15); + --accent-amber: #fbbf24; + --accent-amber-dim: rgba(251, 191, 36, 0.15); + --accent-green: #34d399; + --accent-green-dim: rgba(52, 211, 153, 0.15); + --accent-red: #f87171; + --accent-red-dim: rgba(248, 113, 113, 0.15); + --shadow-panel: 0 4px 24px rgba(0, 0, 0, 0.45); + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --font-sans: "Inter", "Segoe UI", system-ui, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; + --sidebar-width: 240px; +} diff --git a/cockpit/templates/topnav-neo.css b/cockpit/templates/topnav-neo.css new file mode 100644 index 0000000..a58428b --- /dev/null +++ b/cockpit/templates/topnav-neo.css @@ -0,0 +1,195 @@ +/* Neo topnav — unieke kleur per tab + glow pill effect */ + +.topnav { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.4rem 0.85rem; + padding-bottom: 0.55rem; +} + +/* Shared pill base */ +.nav-pill, +.topnav-herman.nav-pill { + --pill-color: #38bdf8; + --pill-glow: rgba(56, 189, 248, 0.45); + --pill-bg: rgba(56, 189, 248, 0.12); + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.45rem 0.85rem; + border-radius: 10px; + font-size: 0.8rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + color: #cbd5e1; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 23, 42, 0.6); + transition: transform 0.2s, box-shadow 0.25s, border-color 0.25s, color 0.2s; + overflow: hidden; + isolation: isolate; +} + +.nav-pill::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(135deg, var(--pill-bg), transparent 70%); + opacity: 0.85; + z-index: -1; + transition: opacity 0.25s; +} + +.nav-pill::after { + content: ""; + position: absolute; + top: -50%; + left: -60%; + width: 50%; + height: 200%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent); + transform: skewX(-20deg); + opacity: 0; + transition: opacity 0.3s, left 0.5s; + pointer-events: none; +} + +.nav-pill:hover { + transform: translateY(-2px); + color: #f8fafc; + border-color: color-mix(in srgb, var(--pill-color) 50%, transparent); + box-shadow: 0 4px 20px var(--pill-glow), 0 0 0 1px color-mix(in srgb, var(--pill-color) 25%, transparent); +} + +.nav-pill:hover::after { + opacity: 1; + left: 120%; +} + +.nav-pill.active { + color: #fff; + border-color: var(--pill-color); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--pill-color) 40%, transparent), + 0 0 22px var(--pill-glow), + inset 0 0 18px color-mix(in srgb, var(--pill-color) 18%, transparent); + text-shadow: 0 0 12px var(--pill-glow); + animation: nav-pill-pulse 2.5s ease-in-out infinite; +} + +.nav-pill.active::before { + opacity: 1; + background: linear-gradient(145deg, color-mix(in srgb, var(--pill-color) 35%, transparent), color-mix(in srgb, var(--pill-color) 8%, transparent)); +} + +@keyframes nav-pill-pulse { + 0%, 100% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--pill-color) 40%, transparent), 0 0 18px var(--pill-glow), inset 0 0 14px color-mix(in srgb, var(--pill-color) 15%, transparent); } + 50% { box-shadow: 0 0 0 1px var(--pill-color), 0 0 28px var(--pill-glow), inset 0 0 22px color-mix(in srgb, var(--pill-color) 22%, transparent); } +} + +/* Herman — reset legacy gold tab, use neo pill */ +.topnav-herman.nav-pill { + margin-right: 0; + margin-bottom: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(15, 23, 42, 0.6); + color: #cbd5e1; + box-shadow: none; +} +.topnav-herman.nav-pill.active { + color: #fff; + background: rgba(15, 23, 42, 0.6); +} + +.nav-pill-herman { + --pill-color: #fbbf24; + --pill-glow: rgba(251, 191, 36, 0.55); + --pill-bg: rgba(251, 191, 36, 0.2); + font-weight: 700; + font-size: 0.88rem; + padding: 0.5rem 1.1rem; +} + +/* CRM */ +.nav-pill-clients { --pill-color: #38bdf8; --pill-glow: rgba(56, 189, 248, 0.5); --pill-bg: rgba(56, 189, 248, 0.15); } +.nav-pill-deals { --pill-color: #4ade80; --pill-glow: rgba(74, 222, 128, 0.5); --pill-bg: rgba(74, 222, 128, 0.12); } +.nav-pill-products { --pill-color: #a855f7; --pill-glow: rgba(168, 85, 247, 0.5); --pill-bg: rgba(168, 85, 247, 0.12); } +.nav-pill-suppliers { --pill-color: #fb923c; --pill-glow: rgba(251, 146, 60, 0.5); --pill-bg: rgba(251, 146, 60, 0.12); } + +/* Intel */ +.nav-pill-beurs { --pill-color: #b8ff3c; --pill-glow: rgba(184, 255, 60, 0.45); --pill-bg: rgba(184, 255, 60, 0.1); } +.nav-pill-retail { --pill-color: #0066cc; --pill-glow: rgba(0, 102, 204, 0.55); --pill-bg: rgba(0, 102, 204, 0.18); } +.nav-pill-browser { --pill-color: #14b8a6; --pill-glow: rgba(20, 184, 166, 0.5); --pill-bg: rgba(20, 184, 166, 0.12); } +.nav-pill-documents { --pill-color: #94a3b8; --pill-glow: rgba(148, 163, 184, 0.4); --pill-bg: rgba(148, 163, 184, 0.1); } + +/* Hermes */ +.nav-pill-telegram { --pill-color: #2aabee; --pill-glow: rgba(42, 171, 238, 0.55); --pill-bg: rgba(42, 171, 238, 0.15); } + +/* Agents */ +.nav-pill-agents { --pill-color: #00e5ff; --pill-glow: rgba(0, 229, 255, 0.5); --pill-bg: rgba(0, 229, 255, 0.1); } +.nav-pill-marketing { --pill-color: #ff6b9d; --pill-glow: rgba(255, 107, 157, 0.5); --pill-bg: rgba(255, 107, 157, 0.12); } +.nav-pill-chat { --pill-color: #fcd34d; --pill-glow: rgba(252, 211, 77, 0.45); --pill-bg: rgba(252, 211, 77, 0.12); } +.nav-pill-studio { --pill-color: #ec4899; --pill-glow: rgba(236, 72, 153, 0.5); --pill-bg: rgba(236, 72, 153, 0.12); } +.nav-pill-reports { --pill-color: #6366f1; --pill-glow: rgba(99, 102, 241, 0.5); --pill-bg: rgba(99, 102, 241, 0.12); } + +/* System */ +.nav-pill-analytics { --pill-color: #8b5cf6; --pill-glow: rgba(139, 92, 246, 0.5); --pill-bg: rgba(139, 92, 246, 0.12); } +.nav-pill-voice { --pill-color: #f97316; --pill-glow: rgba(249, 115, 22, 0.5); --pill-bg: rgba(249, 115, 22, 0.12); } +.nav-pill-settings { --pill-color: #e2e8f0; --pill-glow: rgba(226, 232, 240, 0.35); --pill-bg: rgba(226, 232, 240, 0.08); } + +/* Group labels — subtiel gekleurd */ +.topnav-group { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.35rem 0.65rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.04); + background: rgba(0, 0, 0, 0.15); +} +.topnav-group:first-of-type { border-left: none; } +.topnav-group-system { margin-left: auto; } + +.topnav-label { + font-size: 0.58rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #64748b; + margin-right: 0.15rem; + padding: 0.2rem 0.35rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); +} + +.topnav-group-crm .topnav-label { color: #38bdf8; } +.topnav-group-intel .topnav-label { color: #b8ff3c; } +.topnav-group-hermes .topnav-label { color: #2aabee; } +.topnav-group-agents .topnav-label { color: #00e5ff; } + +/* Override legacy palantir topnav link styles */ +.topnav-group a.nav-pill { + margin: 0; +} +.topnav-group a.nav-pill:hover, +.topnav-group a.nav-pill.active { + background: rgba(15, 23, 42, 0.6); +} + +.topbar { + padding: 0.65rem 1.25rem 0.35rem; +} + +@media (max-width: 900px) { + .topnav-label { display: none; } + .nav-pill { padding: 0.4rem 0.65rem; font-size: 0.75rem; } + .topnav-group { padding: 0.25rem 0.4rem; } +} + +@media (prefers-reduced-motion: reduce) { + .nav-pill.active { animation: none; } + .nav-pill:hover { transform: none; } +} diff --git a/cockpit/templates/voice.html b/cockpit/templates/voice.html new file mode 100644 index 0000000..cea0664 --- /dev/null +++ b/cockpit/templates/voice.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block content %} +
        + +
        +
        + + + + +
        + +
        
        +
        + +
        +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/deploy-360.sh b/deploy-360.sh new file mode 100755 index 0000000..1fec310 --- /dev/null +++ b/deploy-360.sh @@ -0,0 +1,96 @@ +#!/bin/bash +set -e +HOST="aissa@10.4.7.18" +PASS='Foodlinkk#2026' +BASE="/tmp/foodlinkk-deploy" +REMOTE="~/foodlinkk-command-center" + +echo "=== Deploying 360 upgrade to VM 106 ===" + +# Tools API +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/tools-api/app/retail_360.py" \ + "$BASE/tools-api/app/retail_360_routes.py" \ + "$BASE/tools-api/app/wholesaler_scrapers.py" \ + "$BASE/tools-api/app/connectors/rss_feeds.py" \ + "$HOST:$REMOTE/tools-api/app/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/tools-api/app/connectors/rss_feeds.py" \ + "$HOST:$REMOTE/tools-api/app/connectors/" + +# Migration +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/migrations/011_retail_360.sql" \ + "$HOST:$REMOTE/migrations/" + +# Cockpit +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/templates/retail.html" \ + "$BASE/cockpit/templates/marketing.html" \ + "$HOST:$REMOTE/cockpit/templates/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/retail.css" \ + "$HOST:$REMOTE/cockpit/static/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/css/pulse-theme.css" \ + "$HOST:$REMOTE/cockpit/static/css/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/routes/retail.py" \ + "$HOST:$REMOTE/cockpit/app/routes/" + +echo "=== Patching main.py and base.html ===" +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$HOST" bash <<'REMOTE' +set -e +cd ~/foodlinkk-command-center + +# Add retail_360 router to tools-api main.py +if ! grep -q retail_360_routes ~/foodlinkk-command-center/tools-api/app/main.py; then + sed -i '/from app.retail import router as retail_router/a from app.retail_360_routes import router as retail_360_router' tools-api/app/main.py + sed -i '/app.include_router(retail_router)/a app.include_router(retail_360_router)' tools-api/app/main.py +fi + +# Add pulse-theme.css to base.html +if ! grep -q pulse-theme.css ~/foodlinkk-command-center/cockpit/templates/base.html; then + sed -i 's|palantir-theme.css|palantir-theme.css" />\n /tmp/wholesale-import.log 2>&1 & +echo "Started wholesaler import in background" + +echo "=== City demographics sync ===" +curl -sf -X POST "http://localhost:8700/retail/cities/sync?limit=30" | head -c 200 +echo "" + +echo "=== DONE ===" +REMOTE + +echo "=== Notify team ===" +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no aissa@10.4.7.19 \ + 'python3 ~/foodlinkk-ai/hermes/notify.py --team "Retail 360 upgrade live: pulse UI, notes/media/milestones/ownership, RSS feeds, groothandels, stad demografie, marketing hub uitgebreid. Bekijk http://10.4.7.18:8600/retail"' diff --git a/deploy-all.sh b/deploy-all.sh new file mode 100755 index 0000000..9ee6316 --- /dev/null +++ b/deploy-all.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Foodlinkk Command Center — volledige deploy (migraties + build + health) +set -euo pipefail + +PASS="${FOODLINKK_SSH_PASS:-Foodlinkk#2026}" +VM="${FOODLINKK_VM:-aissa@10.4.7.18}" +REMOTE_DIR="${FOODLINKK_REMOTE_DIR:-/home/aissa/foodlinkk-command-center}" +LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== Foodlinkk deploy-all ===" +echo "Local: $LOCAL_DIR" +echo "Remote: $VM:$REMOTE_DIR" + +if ! command -v sshpass >/dev/null 2>&1; then + echo "sshpass is vereist (apt install sshpass)" + exit 1 +fi + +echo "=== Rsync naar VM106 ===" +sshpass -p "$PASS" rsync -az --delete \ + --exclude '.git' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude '.env' \ + --exclude 'node_modules' \ + -e "ssh -o StrictHostKeyChecking=no" \ + "$LOCAL_DIR/" "$VM:$REMOTE_DIR/" + +echo "=== Migraties + Docker rebuild op VM106 ===" +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$VM" bash -s </dev/null || true + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/main.py" "$HOST:$R/cockpit/app/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/routes/beurs.py" "$HOST:$R/cockpit/app/routes/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/routes/api.py" \ + "$BASE/cockpit/app/routes/retail.py" \ + "$HOST:$R/cockpit/app/routes/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/services/platform_live.py" "$HOST:$R/cockpit/app/services/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/templates/beurs.html" \ + "$BASE/cockpit/templates/base.html" \ + "$BASE/cockpit/templates/dashboard.html" \ + "$BASE/cockpit/templates/marketing.html" \ + "$HOST:$R/cockpit/templates/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/css/beurs.css" \ + "$BASE/cockpit/static/css/pulse-theme.css" \ + "$BASE/cockpit/static/css/herman-dashboard.css" \ + "$HOST:$R/cockpit/static/css/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/js/beurs.js" \ + "$BASE/cockpit/static/js/briefing-charts.js" \ + "$HOST:$R/cockpit/static/js/" + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$HOST" bash <<'REMOTE' +set -e +cd ~/foodlinkk-command-center +docker compose build tools-api cockpit +docker compose up -d tools-api cockpit +sleep 10 +curl -sf -o /dev/null -w "beurs: %{http_code}\n" http://localhost:8600/beurs +curl -sf http://localhost:8700/retail/market/supermarkets | head -c 250 +echo "" +curl -sf http://localhost:8600/api/live/platform?limit=3 | head -c 200 +echo "" +REMOTE + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no aissa@10.4.7.19 \ + 'python3 ~/foodlinkk-ai/hermes/notify.py --team "Beurs Live tab: supermarkt aandelen (AH/Jumbo info), halal trends, concept lab, platform events monitor. http://10.4.7.18:8600/beurs — Ctrl+Shift+R"' + +echo DONE diff --git a/deploy-ceo-market.sh b/deploy-ceo-market.sh new file mode 100755 index 0000000..39877a1 --- /dev/null +++ b/deploy-ceo-market.sh @@ -0,0 +1,79 @@ +#!/bin/bash +set -e +HOST="aissa@10.4.7.18" +PASS='Foodlinkk#2026' +BASE="/tmp/foodlinkk-deploy" +R="~/foodlinkk-command-center" + +echo "=== CEO Market + Retail Filters + Agent Avatars ===" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/migrations/014_market_regulation_avatars.sql" "$HOST:$R/migrations/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/tools-api/app/connectors/market_stocks.py" \ + "$BASE/tools-api/app/connectors/rss_feeds.py" \ + "$BASE/tools-api/app/retail_360_routes.py" \ + "$HOST:$R/tools-api/app/connectors/" 2>/dev/null || true + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/tools-api/app/connectors/market_stocks.py" "$HOST:$R/tools-api/app/connectors/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/tools-api/app/connectors/rss_feeds.py" \ + "$BASE/tools-api/app/retail_360_routes.py" \ + "$HOST:$R/tools-api/app/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/services/briefing.py" \ + "$BASE/cockpit/app/services/market_stocks.py" \ + "$HOST:$R/cockpit/app/services/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/routes/retail.py" \ + "$HOST:$R/cockpit/app/routes/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/templates/dashboard.html" \ + "$BASE/cockpit/templates/retail.html" \ + "$BASE/cockpit/templates/agents.html" \ + "$HOST:$R/cockpit/templates/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/js/briefing-charts.js" \ + "$HOST:$R/cockpit/static/js/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/css/herman-dashboard.css" \ + "$BASE/cockpit/static/retail.css" \ + "$HOST:$R/cockpit/static/css/" 2>/dev/null || true + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/css/herman-dashboard.css" "$HOST:$R/cockpit/static/css/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/retail.css" "$HOST:$R/cockpit/static/" + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$HOST" bash <<'REMOTE' +set -e +cd ~/foodlinkk-command-center + +docker exec -i foodlinkk_db psql -U aissa -d foodlinkk < migrations/014_market_regulation_avatars.sql + +docker compose build tools-api cockpit +docker compose up -d tools-api cockpit +sleep 10 + +curl -sf -o /dev/null -w "dashboard: %{http_code}\n" http://localhost:8600/ +curl -sf -o /dev/null -w "retail: %{http_code}\n" http://localhost:8600/retail +curl -sf -o /dev/null -w "agents: %{http_code}\n" http://localhost:8600/agents +curl -sf http://localhost:8700/retail/market/stocks | head -c 200 +echo "" +curl -sf -X POST http://localhost:8700/retail/rss/refresh | head -c 150 +echo "" +REMOTE + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no aissa@10.4.7.19 \ + 'python3 ~/foodlinkk-ai/hermes/notify.py --team "CEO dashboard upgrade: supermarkt beurs KPIs met neo equalizers, food markt highlights, CBS+regelgeving feeds. Retail 360 uitgebreide filters. Agents tab met animated avatars. Hard refresh Ctrl+Shift+R → http://10.4.7.18:8600/"' + +echo DONE diff --git a/deploy-layout-fix.sh b/deploy-layout-fix.sh new file mode 100755 index 0000000..59d7327 --- /dev/null +++ b/deploy-layout-fix.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e +HOST="aissa@10.4.7.18" +PASS='Foodlinkk#2026' +BASE="/tmp/foodlinkk-deploy" +R="~/foodlinkk-command-center" + +echo "=== Layout fix: Herman dashboard + Retail filters + Regelgeving → Marketing ===" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/templates/dashboard.html" \ + "$BASE/cockpit/templates/retail.html" \ + "$BASE/cockpit/templates/marketing.html" \ + "$HOST:$R/cockpit/templates/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/css/herman-dashboard.css" \ + "$HOST:$R/cockpit/static/css/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/retail.css" \ + "$HOST:$R/cockpit/static/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/js/briefing-charts.js" \ + "$HOST:$R/cockpit/static/js/" + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$HOST" bash <<'REMOTE' +set -e +cd ~/foodlinkk-command-center +docker compose build cockpit +docker compose up -d cockpit +sleep 6 +curl -sf -o /dev/null -w "dashboard: %{http_code}\n" http://localhost:8600/ +curl -sf -o /dev/null -w "retail: %{http_code}\n" http://localhost:8600/retail +curl -sf -o /dev/null -w "marketing: %{http_code}\n" http://localhost:8600/marketing +REMOTE + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no aissa@10.4.7.19 \ + 'python3 ~/foodlinkk-ai/hermes/notify.py --team "UI layout fix: Herman dashboard netjes in secties. Retail filters uitgelijnd. Regelgeving verplaatst naar Marketing Hub tab. Hard refresh Ctrl+Shift+R"' + +echo DONE diff --git a/deploy-neo-agents.sh b/deploy-neo-agents.sh new file mode 100755 index 0000000..23f2ab6 --- /dev/null +++ b/deploy-neo-agents.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -e +HOST="aissa@10.4.7.18" +PASS='Foodlinkk#2026' +BASE="/tmp/foodlinkk-deploy" +R="~/foodlinkk-command-center" + +echo "=== Herman neo + Agents souls + Permissions ===" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/migrations/013_agent_souls_permissions.sql" "$HOST:$R/migrations/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/services/agent_souls.py" "$HOST:$R/cockpit/app/services/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/routes/agents_api.py" \ + "$BASE/cockpit/app/routes/settings_api.py" \ + "$BASE/cockpit/app/routes/settings.py" \ + "$HOST:$R/cockpit/app/routes/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/templates/dashboard.html" \ + "$BASE/cockpit/templates/agents.html" \ + "$BASE/cockpit/templates/settings.html" \ + "$HOST:$R/cockpit/templates/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/css/herman-dashboard.css" \ + "$BASE/cockpit/static/css/pulse-theme.css" \ + "$BASE/cockpit/static/css/hermes.css" \ + "$HOST:$R/cockpit/static/css/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/js/briefing-charts.js" "$HOST:$R/cockpit/static/js/" + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$HOST" bash <<'REMOTE' +set -e +cd ~/foodlinkk-command-center + +# Register agents_api router +if ! grep -q agents_api ~/foodlinkk-command-center/cockpit/app/main.py; then + sed -i '/from app.routes.settings_api import settings_router/a from app.routes.agents_api import router as agents_api_router' cockpit/app/main.py + sed -i '/settings_router,/a\ agents_api_router,' cockpit/app/main.py +fi + +docker exec -i foodlinkk_db psql -U aissa -d foodlinkk < migrations/013_agent_souls_permissions.sql + +docker compose build cockpit +docker compose up -d cockpit +sleep 6 + +curl -sf -o /dev/null -w "dashboard: %{http_code}\n" http://localhost:8600/ +curl -sf -o /dev/null -w "agents: %{http_code}\n" http://localhost:8600/agents +curl -sf -o /dev/null -w "settings perms: %{http_code}\n" "http://localhost:8600/settings?tab=permissions" +curl -sf http://localhost:8600/api/agents/souls | head -c 150 +echo "" +curl -sf http://localhost:8600/api/settings/permissions | head -c 150 +echo "" +REMOTE + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no aissa@10.4.7.19 \ + 'python3 ~/foodlinkk-ai/hermes/notify.py --team "Herman neo UI: KPI rings zoals Hermes, groot veld vast. Agents soul.md registry + Settings rechten Herman. Hard refresh Ctrl+Shift+R"' + +echo DONE diff --git a/deploy-platform-upgrade.sh b/deploy-platform-upgrade.sh new file mode 100755 index 0000000..7b9627f --- /dev/null +++ b/deploy-platform-upgrade.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail +REMOTE="aissa@10.4.7.18" +PASS='Foodlinkk#2026' +SRC="/tmp/foodlinkk-deploy" +DEST="~/foodlinkk-command-center" + +echo "=== Deploy platform upgrade ===" + +sshpass -p "$PASS" rsync -avz --relative \ + "$SRC/./migrations/015_platform_upgrade.sql" \ + "$SRC/./cockpit/app/services/analytics_data.py" \ + "$SRC/./cockpit/app/routes/analytics.py" \ + "$SRC/./cockpit/app/routes/api.py" \ + "$SRC/./cockpit/app/routes/dashboard.py" \ + "$SRC/./cockpit/app/routes/retail.py" \ + "$SRC/./cockpit/app/services/briefing.py" \ + "$SRC/./cockpit/templates/analytics.html" \ + "$SRC/./cockpit/templates/dashboard.html" \ + "$SRC/./cockpit/templates/marketing.html" \ + "$SRC/./cockpit/templates/retail.html" \ + "$SRC/./cockpit/templates/base.html" \ + "$SRC/./cockpit/static/js/analytics.js" \ + "$SRC/./cockpit/static/js/briefing-charts.js" \ + "$SRC/./cockpit/static/js/live-pulse.js" \ + "$SRC/./cockpit/static/css/analytics.css" \ + "$SRC/./cockpit/static/css/pulse-theme.css" \ + "$SRC/./cockpit/static/css/herman-dashboard.css" \ + "$SRC/./tools-api/app/retail_360_routes.py" \ + "$SRC/./tools-api/app/briefing.py" \ + "$REMOTE:$DEST/" + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$REMOTE" bash -s <<'REMOTE_SCRIPT' +set -e +cd ~/foodlinkk-command-center +echo "Running migration 015..." +docker compose exec -T postgres psql -U foodlinkk -d foodlinkk -f /migrations/015_platform_upgrade.sql 2>/dev/null || \ + cat migrations/015_platform_upgrade.sql | docker compose exec -T postgres psql -U foodlinkk -d foodlinkk + +echo "Rebuilding containers..." +docker compose build tools-api cockpit +docker compose up -d tools-api cockpit +sleep 4 +curl -sf http://localhost:8600/analytics/api/data?days=30 | head -c 200 && echo " ... analytics OK" || echo "WARN: analytics check" +curl -sf http://localhost:8600/api/herman/briefing/stats | head -c 200 && echo " ... briefing OK" || echo "WARN: briefing check" +echo "Deploy complete." +REMOTE_SCRIPT + +echo "=== Done ===" diff --git a/deploy-ui-upgrade.sh b/deploy-ui-upgrade.sh new file mode 100755 index 0000000..425a55e --- /dev/null +++ b/deploy-ui-upgrade.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -euo pipefail +REMOTE="aissa@10.4.7.18" +PASS='Foodlinkk#2026' +SRC="/tmp/foodlinkk-deploy" +DEST="~/foodlinkk-command-center" + +sshpass -p "$PASS" rsync -avz --relative \ + "$SRC/./cockpit/" "$SRC/./tools-api/app/comfyui.py" "$SRC/./tools-api/app/main.py" \ + "$SRC/./tools-api/app/retail_360_routes.py" \ + "$REMOTE:$DEST/" + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$REMOTE" 'cd ~/foodlinkk-command-center && docker compose build tools-api cockpit && docker compose up -d tools-api cockpit && sleep 4 && curl -sf http://localhost:8600/api/server-time && echo && curl -sf http://localhost:8600/reports/api/datasets | head -c 150 && echo' +sshpass -p 'Foodlinkk#2026' ssh -o StrictHostKeyChecking=no aissa@10.4.7.19 'python3 ~/foodlinkk-ai/hermes/notify.py --team "UI upgrade: verticale nav, Herman zonder RSS, reclame tab, agent avatars, retail filters, ComfyUI neg prompt, Reports export — http://10.4.7.18:8600"' 2>/dev/null || true +echo "Deploy done." diff --git a/deploy-ui-v2.sh b/deploy-ui-v2.sh new file mode 100755 index 0000000..716154f --- /dev/null +++ b/deploy-ui-v2.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -e +HOST="aissa@10.4.7.18" +PASS='Foodlinkk#2026' +BASE="/tmp/foodlinkk-deploy" +REMOTE="~/foodlinkk-command-center" + +echo "=== UI readability + RSS focus + Herman dashboard ===" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/css/tokens.css" \ + "$BASE/cockpit/static/css/pulse-theme.css" \ + "$HOST:$REMOTE/cockpit/static/css/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/templates/dashboard.html" \ + "$BASE/cockpit/templates/marketing.html" \ + "$BASE/cockpit/templates/retail.html" \ + "$HOST:$REMOTE/cockpit/templates/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/static/js/briefing-charts.js" \ + "$HOST:$REMOTE/cockpit/static/js/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/services/briefing.py" \ + "$HOST:$REMOTE/cockpit/app/services/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/cockpit/app/routes/dashboard.py" \ + "$HOST:$REMOTE/cockpit/app/routes/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/tools-api/app/connectors/rss_feeds.py" \ + "$HOST:$REMOTE/tools-api/app/connectors/" + +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$BASE/migrations/012_rss_focus.sql" \ + "$HOST:$REMOTE/migrations/" + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no "$HOST" bash <<'REMOTE' +set -e +cd ~/foodlinkk-command-center +docker exec -i foodlinkk_db psql -U aissa -d foodlinkk < migrations/012_rss_focus.sql +docker compose build tools-api cockpit +docker compose up -d tools-api cockpit +sleep 8 +curl -sf -X POST http://localhost:8700/retail/rss/refresh | head -c 400 +echo "" +curl -sf -o /dev/null -w "dashboard: %{http_code}\n" http://localhost:8600/ +curl -sf -o /dev/null -w "marketing: %{http_code}\n" http://localhost:8600/marketing +curl -sf http://localhost:8700/retail/rss/live?limit=5 | head -c 300 +echo "" +REMOTE + +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no aissa@10.4.7.19 \ + 'python3 ~/foodlinkk-ai/hermes/notify.py --team "UI upgrade live: betere leesbaarheid, RSS alleen kant-en-klaar/supermarkt (klikbare bronnen), Herman dashboard interactief met milestones + pulse effecten. http://10.4.7.18:8600/"' + +echo "=== DONE ===" diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..51355e5 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -e +PASS='Foodlinkk#2026' +VM18=aissa@10.4.7.18 +VM19=aissa@10.4.7.19 +DEP=/tmp/foodlinkk-deploy + +ssh $VM18 "mkdir -p /tmp/fk-deploy" + +echo "=== Copy files to VM 106 ===" +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$DEP/migrations/009_full_upgrade.sql" \ + "$DEP/tools-api/app/retail.py" \ + "$DEP/tools-api/app/research.py" \ + "$DEP/tools-api/app/recommendations.py" \ + "$DEP/tools-api/app/logging_middleware.py" \ + "$DEP/tools-api/patch_main.py" \ + "$DEP/cockpit/app/routes/retail.py" \ + "$DEP/cockpit/app/routes/reco_proxy.py" \ + "$DEP/cockpit/templates/retail.html" \ + "$DEP/cockpit/static/retail.css" \ + "$DEP/cockpit/patch_cockpit.py" \ + "$DEP/cockpit/patch_admin.py" \ + $VM18:/tmp/fk-deploy/ + +echo "=== Install on VM 106 ===" +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no $VM18 'bash -s' << 'REMOTE' +set -e +cp /tmp/fk-deploy/retail.py ~/foodlinkk-command-center/tools-api/app/ +cp /tmp/fk-deploy/research.py ~/foodlinkk-command-center/tools-api/app/ +cp /tmp/fk-deploy/recommendations.py ~/foodlinkk-command-center/tools-api/app/ +cp /tmp/fk-deploy/logging_middleware.py ~/foodlinkk-command-center/tools-api/app/ +cp /tmp/fk-deploy/retail.py ~/foodlinkk-command-center/cockpit/app/routes/ +cp /tmp/fk-deploy/reco_proxy.py ~/foodlinkk-command-center/cockpit/app/routes/ +cp /tmp/fk-deploy/retail.html ~/foodlinkk-command-center/cockpit/templates/ +cp /tmp/fk-deploy/retail.css ~/foodlinkk-command-center/cockpit/static/ +python3 /tmp/fk-deploy/patch_main.py +python3 /tmp/fk-deploy/patch_cockpit.py +python3 /tmp/fk-deploy/patch_admin.py +docker exec -i foodlinkk_db psql -U aissa -d foodlinkk < /tmp/fk-deploy/009_full_upgrade.sql +cd ~/foodlinkk-command-center +docker compose build tools-api cockpit +docker compose up -d tools-api cockpit +REMOTE + +echo "=== VM 105 cron ===" +sshpass -p "$PASS" scp -o StrictHostKeyChecking=no \ + "$DEP/scripts/research_refresh.py" $VM19:/home/aissa/foodlinkk-ai/scripts/ +sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no $VM19 \ + 'chmod +x ~/foodlinkk-ai/scripts/research_refresh.py; (crontab -l 2>/dev/null | grep -v research_refresh; echo "0 6 * * * /usr/bin/python3 /home/aissa/foodlinkk-ai/scripts/research_refresh.py >> /tmp/foodlinkk-research.log 2>&1") | crontab -' + +echo "=== Initial research cycle ===" +sleep 20 +curl -sf -X POST http://10.4.7.18:8700/research/run && echo +curl -sf -X POST http://10.4.7.18:8700/recommendations/generate && echo +curl -sf http://10.4.7.18:8700/retail/stats && echo +curl -sf -o /dev/null -w "cockpit_retail:%{http_code}\n" http://10.4.7.18:8600/retail +echo DONE diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d2cbb23 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,137 @@ +version: '3.8' +services: + redis: + image: redis:7-alpine + container_name: foodlinkk_redis + ports: + - "6379:6379" + restart: unless-stopped + + + tools-api: + build: ./tools-api + container_name: foodlinkk_tools_api + environment: + DB_HOST: foodlinkk_db + DB_USER: aissa + DB_PASSWORD: Foodlinkk#2026 + DB_NAME: foodlinkk + OLLAMA_URL: http://10.4.7.19:11434 + OLLAMA_MODEL: qwen3:8b + CHROMA_HOST: 10.4.7.19 + CHROMA_PORT: "8000" + DOC_INGEST_URL: http://10.4.7.19:8750 + BROWSER_USE_URL: http://browser-agent:7790 + BROWSER_AGENT_URL: http://browser-agent:7790 + COMFYUI_URL: http://10.4.7.18:8188 + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM: ${SMTP_FROM:-} + ports: + - "8700:8700" + extra_hosts: + - "foodlinkk_db:10.4.7.18" + restart: unless-stopped + + cockpit: + build: ./cockpit + container_name: foodlinkk_cockpit + environment: + DB_HOST: foodlinkk_db + DB_USER: aissa + DB_PASSWORD: Foodlinkk#2026 + DB_NAME: foodlinkk + VNC_CDP_URL: http://10.4.7.18:9223 + OLLAMA_URL: http://10.4.7.19:11434 + OLLAMA_MODEL: qwen3:8b + TOOLS_API_URL: http://tools-api:8700 + CHROMA_HOST: 10.4.7.19 + CHROMA_PORT: "8000" + DOC_INGEST_URL: http://10.4.7.19:8750 + BROWSER_AGENT_URL: http://browser-agent:7790 + COMFYUI_URL: http://10.4.7.18:8188 + HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090 + ports: + - "8600:8600" + depends_on: + - tools-api + extra_hosts: + - "foodlinkk_db:10.4.7.18" + restart: unless-stopped + + browser-agent: + build: ./browser-agent + container_name: foodlinkk_browser_agent + environment: + DB_HOST: foodlinkk_db + DB_USER: aissa + DB_PASSWORD: Foodlinkk#2026 + DB_NAME: foodlinkk + VNC_CDP_URL: http://10.4.7.18:9223 + ports: + - "7790:7790" + extra_hosts: + - "foodlinkk_db:10.4.7.18" + restart: unless-stopped + shm_size: "1gb" + + gitea: + image: gitea/gitea:1.21-rootless + container_name: foodlinkk_gitea + environment: + GITEA__database__DB_TYPE: sqlite3 + GITEA__server__ROOT_URL: http://10.4.7.18:3001/ + GITEA__server__HTTP_PORT: 3001 + GITEA__security__INSTALL_LOCK: "true" + GITEA__security__SECRET_KEY: foodlinkk-gitea-secret-2026 + GITEA__service__DISABLE_REGISTRATION: "true" + ports: + - "3001:3001" + volumes: + - gitea_data:/var/lib/gitea + restart: unless-stopped + + email-agent: + build: ./email-agent + container_name: foodlinkk_email_agent + environment: + DB_HOST: foodlinkk_db + DB_USER: aissa + DB_PASSWORD: Foodlinkk#2026 + DB_NAME: foodlinkk + TOOLS_API_URL: http://tools-api:8700 + SYNC_INTERVAL_SEC: 300 + ports: + - "8801:8801" + depends_on: + - tools-api + extra_hosts: + - "foodlinkk_db:10.4.7.18" + restart: unless-stopped + + prometheus: + image: prom/prometheus:v2.53.0 + container_name: foodlinkk_prometheus + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + restart: unless-stopped + + grafana: + image: grafana/grafana:11.1.0 + container_name: foodlinkk_grafana + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: Foodlinkk#2026 + GF_SERVER_ROOT_URL: http://10.4.7.18:3002 + ports: + - "3002:3000" + depends_on: + - prometheus + restart: unless-stopped + +volumes: + gitea_data: diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..587400a --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,24 @@ +# Agents + +## Mesh (16 agents + Herman) + +Herman fungeert als **Co-CEO** in het centrum van het netwerk. Agents communiceren via events (`agent_events`) en worden gevisualiseerd op `/agents?tab=mesh`. + +## Nieuwe agents (migratie 016) + +| Key | Naam | Rol | +|-----|------|-----| +| `sysops` | SysOps | Proxmox, Docker, VM monitoring | +| `packaging` | Packaging | SVG/PDF verpakkingsontwerp | + +## Souls + +Elke agent heeft een `soul_md` in PostgreSQL (`agent_souls`). Herman leest souls voor briefing en delegatie. + +## Permissions + +Module rechten voor Herman: `/settings?tab=permissions` — tabel `herman_permissions`. + +## Agent events + +Marketing publish, reclamefolder sync en ops refreshes loggen naar `agent_events` voor traceerbaarheid. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..9e92bd0 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,50 @@ +# API referentie (kern) + +Base URLs: Cockpit `http://10.4.7.18:8600` · Tools API `http://10.4.7.18:8700` + +## Reclame folders + +| Method | Path | Beschrijving | +|--------|------|--------------| +| GET | `/api/retail/reclamefolder/chains` | Alle supermarktketens | +| POST | `/api/retail/reclamefolder/refresh` | Sync van reclamefolder.nl | +| GET | `/api/retail/promo-campaigns` | Query: `chain`, `q`, `folder_type`, `valid_days`, `limit` | + +Folder links: `https://www.reclamefolder.nl/f/folders/{edition_id}/` + +## Marketing publish (Cockpit) + +| Method | Path | Beschrijving | +|--------|------|--------------| +| POST | `/api/marketing/upload` | multipart image upload | +| POST | `/api/marketing/publish` | 202 — start background job | +| GET | `/api/marketing/publish/{job_id}` | Job status + result | +| GET | `/api/marketing/publish/history` | Recente jobs | +| GET | `/api/marketing/channels` | Geconfigureerde kanalen | + +Kanalen: `twitter`, `linkedin`, `instagram`, `facebook`, `tiktok`, `pinterest` + +## Settings social + +| Method | Path | +|--------|------| +| GET | `/api/settings/social` | +| PUT | `/api/settings/social/{platform}` | +| POST | `/api/settings/social/{platform}/test` | + +## IT Ops + +| Method | Path | +|--------|------| +| GET | `/api/ops/topology` | +| GET | `/api/ops/snapshot` | +| POST | `/api/ops/refresh` | + +## Packaging + +| Method | Path | +|--------|------| +| POST | `/packaging/generate` | +| GET | `/packaging/preview/{id}` | + +Zie Tools API OpenAPI op `:8700/docs` voor volledige lijst. diff --git a/docs/APPS.md b/docs/APPS.md new file mode 100644 index 0000000..19e3bfd --- /dev/null +++ b/docs/APPS.md @@ -0,0 +1,34 @@ +# Apps & pagina's + +## Cockpit modules + +| Route | Functie | +|-------|---------| +| `/` | Herman CEO dashboard, briefing | +| `/retail` | Retail 360 — filialen, halal gaps, weer | +| `/marketing` | Marketing hub (tabs) | +| `/marketing?tab=reclame` | Reclame folders — filters keten/type/geldigheid/zoek | +| `/marketing?tab=publish` | Social automatisering — multi-channel publish | +| `/clients`, `/deals` | CRM | +| `/agents` | Agent overzicht | +| `/agents?tab=mesh` | Netwerk visualisatie (Herman centrum) | +| `/ops` | IT Ops topologie | +| `/packaging` | Verpakkingsontwerp | +| `/settings?tab=email` | SMTP/IMAP accounts | +| `/settings?tab=social` | Social API credentials | +| `/settings?tab=permissions` | Herman module rechten | +| `/hermes` | Chat met Hermes | +| `/beurs` | Markt/beurs data | +| `/studio` | ComfyUI studio | +| `/reports` | Rapportage export | + +## Tools API modules + +- `/retail/*` — Retail 360, promo campaigns, reclamefolder sync +- `/ops/*` — Proxmox/infra snapshots +- `/packaging/*` — SVG/PDF/PNG generatie +- `/research/*`, `/recommendations/*` — Research pipeline + +## Docker services + +Zie `docker-compose.yml`: redis, tools-api, cockpit, browser-agent, gitea, email-agent, prometheus (monitoring). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2e929f9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,52 @@ +# Architectuur + +## Overzicht + +```mermaid +flowchart TB + subgraph vm106 [VM106 - 10.4.7.18] + Cockpit[Cockpit :8600] + ToolsAPI[Tools API :8700] + Gitea[Gitea :3001] + PG[(PostgreSQL foodlinkk_db)] + Cockpit --> ToolsAPI + Cockpit --> PG + ToolsAPI --> PG + end + subgraph vm105 [VM105 - 10.4.7.19] + Ollama[Ollama / Hermes] + Chroma[ChromaDB] + end + subgraph proxmox [Proxmox - 10.4.7.14] + Nodes[VM105 + VM106] + end + Cockpit --> Ollama + ToolsAPI --> Ollama + ToolsAPI --> proxmox +``` + +## Lagen + +1. **Presentation** — Cockpit templates (Jinja2), Alpine.js, neo CSS thema +2. **API gateway** — Cockpit routes proxyen naar Tools API waar nodig (`retail.py`, `ops_api.py`) +3. **Services** — Tools API connectors (reclamefolder, Proxmox, RSS, CBS, PDOK) +4. **Data** — PostgreSQL met migraties `001`–`016` +5. **Agents** — Souls in `agent_souls`, events in `agent_events`, mesh op `/agents` + +## Marketing automatisering + +- Upload → `marketing_media` tabel + `/static/uploads/marketing/` +- Publish → `POST /api/marketing/publish` → BackgroundTasks → `social_publish_jobs` +- Credentials → `social_integrations` (Settings tab Social API's) +- Zonder keys: `skipped_not_configured` per kanaal + +## IT Ops + +- `tools-api/app/connectors/proxmox.py` — SSH/API naar Proxmox host +- Snapshots in `infra_snapshots` +- Cockpit `/ops` — SVG topologie met pulse animaties + +## Packaging + +- Python generator (`svgwrite`, Pillow, `python-barcode`, reportlab) +- Endpoints onder `/packaging/*` (Tools API) + Cockpit UI `/packaging` diff --git a/docs/CSS-THEME.md b/docs/CSS-THEME.md new file mode 100644 index 0000000..659264f --- /dev/null +++ b/docs/CSS-THEME.md @@ -0,0 +1,32 @@ +# CSS thema + +## Design tokens + +Bestand: `cockpit/static/css/tokens.css` + +- Donkere neo achtergrond +- Accent kleuren per module (marketing roze, ops cyaan, packaging groen) + +## Belangrijkste stylesheets + +| Bestand | Gebruik | +|---------|---------| +| `palantir-theme.css` | Basis layout, panels | +| `pulse-theme.css` | KPI cards, live badges, pulse animaties | +| `topnav-neo.css` | Navigatie pills | +| `vertical-tabs.css` | Marketing/agents vertical tabs | +| `mobile.css` | Responsive + PWA | +| `ops-topology.css` | IT Ops SVG topologie | +| `agents-mesh.css` | Agent netwerk visualisatie | + +## Conventies + +- `.panel` — content secties +- `.feed-card` — list items +- `.btn-pulse*` — gradient action buttons +- `.live-badge` — live indicator +- Alpine.js `x-cloak` voor flash-vrije tabs + +## PWA + +`static/manifest.json` + `static/sw.js` — installeerbaar op mobiel. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..d98fac0 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,53 @@ +# Deploy + +## Vereisten + +- Docker + Docker Compose op VM106 +- PostgreSQL container `foodlinkk_db` +- SSH toegang: `aissa@10.4.7.18` + +## Eén deploy (aanbevolen) + +Vanaf de monorepo root: + +```bash +chmod +x deploy-all.sh +./deploy-all.sh +``` + +Het script: + +1. Rsync't de volledige codebase naar `~/foodlinkk-command-center` op VM106 +2. Voert alle SQL migraties uit (`migrations/*.sql`) +3. Bouwt en herstart containers (`tools-api`, `cockpit`, `email-agent`, `browser-agent`) +4. Voert health checks uit + +## Omgevingsvariabelen + +Kopieer `.env.example` naar `.env` op de host. Gevoelige waarden nooit committen. + +## Gitea + +Repository host: `http://10.4.7.18:3001` + +```bash +git init +git remote add origin http://10.4.7.18:3001/aissa/foodlinkk-command-center.git +git add . +git commit -m "Platform bundle: ops, packaging, marketing publish" +git push -u origin main +``` + +## Handmatige migratie (indien nodig) + +```bash +docker exec -i foodlinkk_db psql -U aissa -d foodlinkk < migrations/016_platform_ops_packaging.sql +``` + +## Health checks + +```bash +curl http://10.4.7.18:8700/health +curl -o /dev/null -w "%{http_code}\n" http://10.4.7.18:8600/marketing +curl -o /dev/null -w "%{http_code}\n" http://10.4.7.18:8600/ops +``` diff --git a/email-agent/Dockerfile b/email-agent/Dockerfile new file mode 100644 index 0000000..1e945f5 --- /dev/null +++ b/email-agent/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8801"] diff --git a/email-agent/app/__init__.py b/email-agent/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/email-agent/app/config.py b/email-agent/app/config.py new file mode 100644 index 0000000..651391f --- /dev/null +++ b/email-agent/app/config.py @@ -0,0 +1,21 @@ +import os + + +class Settings: + DB_HOST: str = os.getenv("DB_HOST", "foodlinkk_db") + DB_PORT: int = int(os.getenv("DB_PORT", "5432")) + DB_USER: str = os.getenv("DB_USER", "aissa") + DB_PASSWORD: str = os.getenv("DB_PASSWORD", "Foodlinkk#2026") + DB_NAME: str = os.getenv("DB_NAME", "foodlinkk") + TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700") + SYNC_INTERVAL_SEC: int = int(os.getenv("SYNC_INTERVAL_SEC", "300")) + + @property + def database_dsn(self) -> str: + return ( + f"host={self.DB_HOST} port={self.DB_PORT} dbname={self.DB_NAME} " + f"user={self.DB_USER} password={self.DB_PASSWORD}" + ) + + +settings = Settings() diff --git a/email-agent/app/db.py b/email-agent/app/db.py new file mode 100644 index 0000000..78978af --- /dev/null +++ b/email-agent/app/db.py @@ -0,0 +1,72 @@ +from contextlib import contextmanager +from typing import Any, Optional + +import psycopg2 +from psycopg2 import pool +from psycopg2.extras import RealDictCursor, Json + +from app.config import settings + +_connection_pool: Optional[pool.SimpleConnectionPool] = None + + +def init_pool() -> None: + global _connection_pool + if _connection_pool is None: + _connection_pool = pool.SimpleConnectionPool(1, 5, dsn=settings.database_dsn) + + +def close_pool() -> None: + global _connection_pool + if _connection_pool is not None: + _connection_pool.closeall() + _connection_pool = None + + +@contextmanager +def get_connection(): + if _connection_pool is None: + init_pool() + conn = _connection_pool.getconn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + _connection_pool.putconn(conn) + + +def fetch_all(query: str, params: Optional[tuple] = None) -> list[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +def fetch_one(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + row = cur.fetchone() + return dict(row) if row else None + + +def execute(query: str, params: Optional[tuple] = None) -> int: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute(query, params) + return cur.rowcount + + +def execute_returning(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + row = cur.fetchone() + return dict(row) if row else None + + +def json_param(value: Any) -> Json: + return Json(value or {}) diff --git a/email-agent/app/imap_sync.py b/email-agent/app/imap_sync.py new file mode 100644 index 0000000..bd1224f --- /dev/null +++ b/email-agent/app/imap_sync.py @@ -0,0 +1,229 @@ +"""IMAP sync and CEO email approval helpers.""" +from __future__ import annotations + +import email +import imaplib +import json +from datetime import datetime, timezone +from email.header import decode_header +from typing import Any, Optional + +import httpx + +from app.config import settings +from app.db import execute, execute_returning, fetch_all, fetch_one, json_param + + +def _decode_header(value: Optional[str]) -> str: + if not value: + return "" + parts = decode_header(value) + out: list[str] = [] + for chunk, enc in parts: + if isinstance(chunk, bytes): + out.append(chunk.decode(enc or "utf-8", errors="replace")) + else: + out.append(str(chunk)) + return " ".join(out).strip() + + +def _accounts_to_sync() -> list[dict[str, Any]]: + return fetch_all( + """ + SELECT id, label, email_address, imap_host, imap_port, imap_user, + imap_password, imap_use_ssl, last_sync_at + FROM email_accounts + WHERE is_active = TRUE AND sync_enabled = TRUE + AND imap_host IS NOT NULL AND imap_host <> '' + ORDER BY id + """ + ) + + +def sync_all_accounts() -> dict[str, Any]: + accounts = _accounts_to_sync() + if not accounts: + return {"accounts": 0, "imported": 0, "skipped": 0, "message": "No IMAP accounts configured"} + + imported = skipped = 0 + details: list[dict[str, Any]] = [] + for acct in accounts: + try: + result = _sync_account(acct) + imported += result["imported"] + skipped += result["skipped"] + details.append({"account_id": acct["id"], **result}) + except Exception as exc: # noqa: BLE001 + details.append({"account_id": acct["id"], "error": str(exc)}) + + return {"accounts": len(accounts), "imported": imported, "skipped": skipped, "details": details} + + +def _sync_account(acct: dict[str, Any]) -> dict[str, Any]: + host = acct["imap_host"] + port = int(acct.get("imap_port") or 993) + user = acct.get("imap_user") or acct["email_address"] + password = acct.get("imap_password") or "" + use_ssl = acct.get("imap_use_ssl", True) + + if use_ssl: + mail = imaplib.IMAP4_SSL(host, port) + else: + mail = imaplib.IMAP4(host, port) + mail.login(user, password) + mail.select("INBOX") + + status, data = mail.search(None, "UNSEEN") + if status != "OK": + mail.logout() + return {"imported": 0, "skipped": 0} + + ids = data[0].split() if data[0] else [] + imported = skipped = 0 + for num in ids[-50:]: + status, msg_data = mail.fetch(num, "(RFC822)") + if status != "OK" or not msg_data or not msg_data[0]: + continue + raw = msg_data[0][1] + msg = email.message_from_bytes(raw) + message_id = (msg.get("Message-ID") or f"local-{acct['id']}-{num.decode()}").strip() + existing = fetch_one("SELECT id FROM emails WHERE message_id = %s", (message_id,)) + if existing: + skipped += 1 + continue + + subject = _decode_header(msg.get("Subject")) + from_addr = _decode_header(msg.get("From")) + to_addrs = [_decode_header(msg.get("To"))] if msg.get("To") else [] + body_text = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain" and not part.get_filename(): + payload = part.get_payload(decode=True) + if payload: + body_text = payload.decode(part.get_content_charset() or "utf-8", errors="replace") + break + else: + payload = msg.get_payload(decode=True) + if payload: + body_text = payload.decode(msg.get_content_charset() or "utf-8", errors="replace") + + row = execute_returning( + """ + INSERT INTO emails ( + message_id, direction, from_addr, to_addrs, subject, body_text, + received_at, is_read, raw_headers + ) VALUES (%s, 'in', %s, %s, %s, %s, %s, FALSE, %s) + RETURNING id + """, + ( + message_id, + from_addr, + to_addrs, + subject, + body_text[:50000] if body_text else None, + datetime.now(timezone.utc), + json_param({"account_id": acct["id"], "label": acct["label"]}), + ), + ) + imported += 1 + if row: + _create_review_recommendation(int(row["id"]), subject, from_addr) + + execute( + "UPDATE email_accounts SET last_sync_at = NOW() WHERE id = %s", + (acct["id"],), + ) + mail.logout() + return {"imported": imported, "skipped": skipped} + + +def _create_review_recommendation(email_id: int, subject: str, from_addr: str) -> None: + execute_returning( + """ + INSERT INTO ai_recommendations ( + recommendation_type, title, description, priority, status, + generated_by, related_entity_type, related_entity_id, data_sources + ) VALUES ( + 'email_review', %s, %s, 'medium', 'pending', + 'email-agent', 'email', %s, %s + ) + RETURNING id + """, + ( + f"Nieuwe e-mail: {subject[:200] or '(geen onderwerp)'}", + f"Inkomend bericht van {from_addr}. Beoordelen en eventueel beantwoorden.", + email_id, + json_param({"email_id": email_id, "from": from_addr}), + ), + ) + + +def list_pending_drafts() -> list[dict[str, Any]]: + return fetch_all( + """ + SELECT id, title, description, status, data_sources, created_at + FROM ai_recommendations + WHERE recommendation_type IN ('email_draft', 'email_review') + AND status = 'pending' + ORDER BY created_at DESC + LIMIT 50 + """ + ) + + +def create_draft(title: str, body: str, to_addr: str, subject: str) -> dict[str, Any]: + row = execute_returning( + """ + INSERT INTO ai_recommendations ( + recommendation_type, title, description, priority, status, + generated_by, data_sources + ) VALUES ( + 'email_draft', %s, %s, 'high', 'pending', 'email-agent', %s + ) + RETURNING id, title, status, created_at + """, + ( + title, + body, + json_param({"to": to_addr, "subject": subject, "body": body}), + ), + ) + return dict(row or {}) + + +def approve_draft(rec_id: int) -> dict[str, Any]: + rec = fetch_one( + """ + SELECT id, recommendation_type, data_sources, status + FROM ai_recommendations WHERE id = %s + """, + (rec_id,), + ) + if not rec: + raise ValueError("Recommendation not found") + if rec["status"] != "pending": + raise ValueError(f"Already {rec['status']}") + + ds = rec.get("data_sources") or {} + if isinstance(ds, str): + ds = json.loads(ds) + + if rec["recommendation_type"] == "email_draft": + payload = { + "to": ds.get("to", ""), + "subject": ds.get("subject", rec.get("title", "")), + "body": ds.get("body", ""), + } + with httpx.Client(timeout=30.0) as client: + resp = client.post(f"{settings.TOOLS_API_URL}/emails/send", json=payload) + resp.raise_for_status() + send_result = resp.json() + else: + send_result = {"action": "marked_reviewed"} + + execute( + "UPDATE ai_recommendations SET status = 'approved', updated_at = NOW() WHERE id = %s", + (rec_id,), + ) + return {"recommendation_id": rec_id, "send_result": send_result, "status": "approved"} diff --git a/email-agent/app/main.py b/email-agent/app/main.py new file mode 100644 index 0000000..fb4b4d9 --- /dev/null +++ b/email-agent/app/main.py @@ -0,0 +1,81 @@ +"""Foodlinkk Email Agent — IMAP sync + CEO approval workflow.""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +from app.config import settings +from app.db import close_pool, fetch_one, init_pool +from app import imap_sync + + +class DraftIn(BaseModel): + title: str = Field(..., min_length=3, max_length=255) + to: str + subject: str + body: str + + +async def _sync_loop() -> None: + while True: + try: + imap_sync.sync_all_accounts() + except Exception: + pass + await asyncio.sleep(settings.SYNC_INTERVAL_SEC) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_pool() + task = asyncio.create_task(_sync_loop()) + yield + task.cancel() + close_pool() + + +app = FastAPI(title="Foodlinkk Email Agent", version="1.0.0", lifespan=lifespan) + + +@app.get("/health") +def health() -> dict[str, Any]: + db_ok = bool(fetch_one("SELECT 1 AS ok")) + accounts = fetch_one( + "SELECT COUNT(*) AS n FROM email_accounts WHERE is_active = TRUE AND sync_enabled = TRUE" + ) + return { + "status": "ok" if db_ok else "degraded", + "database": "connected" if db_ok else "error", + "imap_accounts": int((accounts or {}).get("n") or 0), + } + + +@app.post("/sync") +def sync_now() -> dict[str, Any]: + return imap_sync.sync_all_accounts() + + +@app.get("/drafts/pending") +def pending_drafts() -> dict[str, Any]: + items = imap_sync.list_pending_drafts() + return {"items": items, "count": len(items)} + + +@app.post("/drafts") +def create_draft(payload: DraftIn) -> dict[str, Any]: + row = imap_sync.create_draft(payload.title, payload.body, payload.to, payload.subject) + return {"draft": row} + + +@app.post("/drafts/{rec_id}/approve") +def approve_draft(rec_id: int) -> dict[str, Any]: + try: + return imap_sync.approve_draft(rec_id) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, str(exc)) from exc diff --git a/email-agent/requirements.txt b/email-agent/requirements.txt new file mode 100644 index 0000000..3af5717 --- /dev/null +++ b/email-agent/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +psycopg2-binary==2.9.9 +httpx==0.27.2 diff --git a/migrations/001_fase1.sql b/migrations/001_fase1.sql new file mode 100644 index 0000000..5aa3f96 --- /dev/null +++ b/migrations/001_fase1.sql @@ -0,0 +1,112 @@ +-- Foodlinkk Command Center — Fase 1 migrations + +CREATE TABLE IF NOT EXISTS agent_events ( + id SERIAL PRIMARY KEY, + agent_name VARCHAR(64) NOT NULL, + agent_type VARCHAR(32) DEFAULT 'openswarm', + event_type VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT, + metadata JSONB DEFAULT '{}', + status VARCHAR(32) DEFAULT 'completed', + related_table VARCHAR(64), + related_id INTEGER, + channel VARCHAR(32) DEFAULT 'dashboard', + created_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_agent_events_created ON agent_events(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_agent_events_status ON agent_events(status); +CREATE INDEX IF NOT EXISTS idx_agent_events_agent ON agent_events(agent_name); + +CREATE TABLE IF NOT EXISTS clients ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + contact VARCHAR(255), + email VARCHAR(255), + stage VARCHAR(64) DEFAULT 'intake', + sector VARCHAR(128), + mrr_estimate NUMERIC(12,2), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS deals ( + id SERIAL PRIMARY KEY, + client_id INTEGER REFERENCES clients(id), + title VARCHAR(255) NOT NULL, + value NUMERIC(12,2) DEFAULT 0, + stage VARCHAR(64) DEFAULT 'lead', + agent_owner VARCHAR(64) DEFAULT 'herman', + next_action TEXT, + deadline DATE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + client_id INTEGER REFERENCES clients(id), + name VARCHAR(255) NOT NULL, + status VARCHAR(64) DEFAULT 'concept', + margin_pct NUMERIC(5,2), + moq INTEGER, + shelf_target VARCHAR(255), + launch_date DATE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS suppliers ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + country VARCHAR(64), + category VARCHAR(128), + moq INTEGER, + lead_time_days INTEGER, + rating NUMERIC(3,1), + contact TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS agent_tasks ( + id SERIAL PRIMARY KEY, + agent_name VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(32) DEFAULT 'pending', + priority VARCHAR(16) DEFAULT 'normal', + assigned_by VARCHAR(64) DEFAULT 'herman', + created_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS daily_briefings ( + id SERIAL PRIMARY KEY, + content TEXT NOT NULL, + generated_by VARCHAR(64) DEFAULT 'herman', + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +INSERT INTO clients (name, contact, stage, sector, mrr_estimate, notes) +SELECT 'Chai N Masala', 'Raj Patel', 'active', 'Spices & Tea', 4500, 'Launch partner — retail expansion' +WHERE NOT EXISTS (SELECT 1 FROM clients WHERE name = 'Chai N Masala'); + +INSERT INTO clients (name, contact, stage, sector, mrr_estimate, notes) +SELECT 'Van den Tweel', 'Inkoper', 'proposal', 'Retail', 12000, 'Category introduction pipeline' +WHERE NOT EXISTS (SELECT 1 FROM clients WHERE name = 'Van den Tweel'); + +INSERT INTO deals (client_id, title, value, stage, next_action, deadline) +SELECT c.id, 'Q3 spice range listing', 85000, 'proposal', 'Send updated margin sheet', CURRENT_DATE + 14 +FROM clients c WHERE c.name = 'Chai N Masala' +AND NOT EXISTS (SELECT 1 FROM deals WHERE title = 'Q3 spice range listing'); + +INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel) +SELECT 'herman', 'co_ceo', 'briefing', 'Command Center online', 'Foodlinkk AI Command Center Fase 1 gestart.', 'completed', 'dashboard' +WHERE NOT EXISTS (SELECT 1 FROM agent_events WHERE title = 'Command Center online'); + +INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel) +SELECT 'marketing', 'marketing', 'draft', 'Instagram draft — sourcing dienst', 'Behind every great food brand is a supply chain that works. At Foodlinkk we connect producers to shelf. #foodinnovation #foodlinkk', 'needs_approval', 'dashboard' +WHERE NOT EXISTS (SELECT 1 FROM agent_events WHERE title = 'Instagram draft — sourcing dienst'); diff --git a/migrations/002_fase234.sql b/migrations/002_fase234.sql new file mode 100644 index 0000000..c0ffd34 --- /dev/null +++ b/migrations/002_fase234.sql @@ -0,0 +1,61 @@ +-- Foodlinkk Command Center — Fase 2-4 + +CREATE TABLE IF NOT EXISTS monitored_sites ( + id SERIAL PRIMARY KEY, + url TEXT NOT NULL, + label VARCHAR(255), + check_interval_min INTEGER DEFAULT 60, + last_status VARCHAR(32), + last_checked_at TIMESTAMPTZ, + enabled BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS page_changes ( + id SERIAL PRIMARY KEY, + site_id INTEGER REFERENCES monitored_sites(id) ON DELETE CASCADE, + change_type VARCHAR(64) DEFAULT 'content', + summary TEXT, + diff_hash VARCHAR(64), + detected_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS crawl_logs ( + id SERIAL PRIMARY KEY, + site_id INTEGER REFERENCES monitored_sites(id) ON DELETE CASCADE, + status_code INTEGER, + duration_ms INTEGER, + error_message TEXT, + crawled_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS knowledge_documents ( + id SERIAL PRIMARY KEY, + title VARCHAR(512) NOT NULL, + source VARCHAR(128), + doc_type VARCHAR(64) DEFAULT 'general', + storage_path TEXT, + chroma_id VARCHAR(128), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS halal_documents ( + id SERIAL PRIMARY KEY, + title VARCHAR(512) NOT NULL, + cert_body VARCHAR(255), + expiry_date DATE, + status VARCHAR(64) DEFAULT 'valid', + storage_path TEXT, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +INSERT INTO monitored_sites (url, label, last_status, last_checked_at) +SELECT 'https://foodlinkk.com', 'Foodlinkk main', 'ok', NOW() +WHERE NOT EXISTS (SELECT 1 FROM monitored_sites WHERE url = 'https://foodlinkk.com'); + +INSERT INTO suppliers (name, country, category, moq, lead_time_days, rating) +SELECT 'Global Spice Co', 'IN', 'Spices', 500, 21, 4.2 +WHERE NOT EXISTS (SELECT 1 FROM suppliers WHERE name = 'Global Spice Co'); diff --git a/migrations/003_word_analytics.sql b/migrations/003_word_analytics.sql new file mode 100644 index 0000000..6e7d507 --- /dev/null +++ b/migrations/003_word_analytics.sql @@ -0,0 +1,50 @@ +-- Word tracking & sentiment analytics for NAS documents + +CREATE TABLE IF NOT EXISTS document_analytics ( + id SERIAL PRIMARY KEY, + storage_path TEXT UNIQUE NOT NULL, + filename VARCHAR(512), + doc_type VARCHAR(64) DEFAULT 'general', + language VARCHAR(16), + word_count INT DEFAULT 0, + unique_lemmas INT DEFAULT 0, + sentence_count INT DEFAULT 0, + sentiment_compound FLOAT, + sentiment_positive FLOAT, + sentiment_negative FLOAT, + sentiment_neutral FLOAT, + sentiment_subjectivity FLOAT, + sentiment_label VARCHAR(32), + extraction_method VARCHAR(64) DEFAULT 'standard', + file_sig VARCHAR(64), + analyzed_at TIMESTAMPTZ DEFAULT NOW(), + metadata JSONB DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS document_word_counts ( + id SERIAL PRIMARY KEY, + storage_path TEXT NOT NULL, + lemma VARCHAR(128) NOT NULL, + token VARCHAR(128), + pos_tag VARCHAR(16), + count INT DEFAULT 1, + is_stopword BOOLEAN DEFAULT FALSE, + language VARCHAR(16), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (storage_path, lemma) +); + +CREATE INDEX IF NOT EXISTS idx_doc_word_lemma ON document_word_counts (lemma); +CREATE INDEX IF NOT EXISTS idx_doc_word_path ON document_word_counts (storage_path); +CREATE INDEX IF NOT EXISTS idx_doc_word_stop ON document_word_counts (is_stopword); +CREATE INDEX IF NOT EXISTS idx_doc_analytics_sentiment ON document_analytics (sentiment_label); +CREATE INDEX IF NOT EXISTS idx_doc_analytics_analyzed ON document_analytics (analyzed_at DESC); + +CREATE OR REPLACE VIEW global_word_frequency AS +SELECT + lemma, + MAX(token) AS sample_token, + SUM(count) AS total_count, + COUNT(DISTINCT storage_path) AS document_count +FROM document_word_counts +GROUP BY lemma; diff --git a/migrations/004_emails_calendar_brain.sql b/migrations/004_emails_calendar_brain.sql new file mode 100644 index 0000000..ee38d02 --- /dev/null +++ b/migrations/004_emails_calendar_brain.sql @@ -0,0 +1,74 @@ +-- Foodlinkk 2nd brain: emails, agenda, LLM memory + +CREATE TABLE IF NOT EXISTS emails ( + id SERIAL PRIMARY KEY, + message_id TEXT UNIQUE NOT NULL, + thread_id TEXT, + client_id INT REFERENCES clients(id) ON DELETE SET NULL, + deal_id INT REFERENCES deals(id) ON DELETE SET NULL, + direction VARCHAR(8) NOT NULL CHECK (direction IN ('in', 'out')), + from_addr TEXT, + to_addrs TEXT[] DEFAULT '{}', + cc_addrs TEXT[] DEFAULT '{}', + subject TEXT, + body_text TEXT, + body_html TEXT, + received_at TIMESTAMPTZ, + sent_at TIMESTAMPTZ, + is_read BOOLEAN DEFAULT FALSE, + labels TEXT[] DEFAULT '{}', + raw_headers JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_emails_received ON emails(received_at DESC); +CREATE INDEX IF NOT EXISTS idx_emails_client ON emails(client_id); +CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_addr); + +CREATE TABLE IF NOT EXISTS calendar_events ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ, + client_id INT REFERENCES clients(id) ON DELETE SET NULL, + deal_id INT REFERENCES deals(id) ON DELETE SET NULL, + location TEXT, + source VARCHAR(32) DEFAULT 'manual', + external_id TEXT, + all_day BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_calendar_starts ON calendar_events(starts_at); + +CREATE TABLE IF NOT EXISTS llm_memory ( + id SERIAL PRIMARY KEY, + category VARCHAR(64) NOT NULL DEFAULT 'fact', + subject TEXT, + content TEXT NOT NULL, + client_id INT REFERENCES clients(id) ON DELETE SET NULL, + deal_id INT REFERENCES deals(id) ON DELETE SET NULL, + source VARCHAR(64) DEFAULT 'herman', + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_llm_memory_category ON llm_memory(category); +CREATE INDEX IF NOT EXISTS idx_llm_memory_updated ON llm_memory(updated_at DESC); + +-- Seed: deal deadlines als agenda-items (zichtbaar in ochtendbericht) +INSERT INTO calendar_events (title, starts_at, ends_at, deal_id, source, description) +SELECT + 'Deadline: ' || d.title, + d.deadline::timestamptz, + d.deadline::timestamptz + INTERVAL '1 hour', + d.id, + 'deal_deadline', + COALESCE(d.next_action, '') +FROM deals d +WHERE d.deadline IS NOT NULL + AND d.stage NOT IN ('won', 'lost') + AND NOT EXISTS ( + SELECT 1 FROM calendar_events ce + WHERE ce.deal_id = d.id AND ce.source = 'deal_deadline' + ); diff --git a/migrations/005_email_accounts.sql b/migrations/005_email_accounts.sql new file mode 100644 index 0000000..1273cf3 --- /dev/null +++ b/migrations/005_email_accounts.sql @@ -0,0 +1,27 @@ +-- Email accounts configurable via Settings UI + +CREATE TABLE IF NOT EXISTS email_accounts ( + id SERIAL PRIMARY KEY, + label VARCHAR(128) NOT NULL DEFAULT 'Primary', + email_address VARCHAR(255) NOT NULL, + provider VARCHAR(32) DEFAULT 'custom', + is_active BOOLEAN DEFAULT FALSE, + smtp_host VARCHAR(255), + smtp_port INT DEFAULT 587, + smtp_user VARCHAR(255), + smtp_password TEXT, + imap_host VARCHAR(255), + imap_port INT DEFAULT 993, + imap_user VARCHAR(255), + imap_password TEXT, + imap_use_ssl BOOLEAN DEFAULT TRUE, + sync_enabled BOOLEAN DEFAULT FALSE, + last_sync_at TIMESTAMPTZ, + last_test_at TIMESTAMPTZ, + last_test_status VARCHAR(64), + last_test_message TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_email_accounts_active ON email_accounts(is_active) WHERE is_active = TRUE; diff --git a/migrations/006_browser_sessions.sql b/migrations/006_browser_sessions.sql new file mode 100644 index 0000000..932a0bc --- /dev/null +++ b/migrations/006_browser_sessions.sql @@ -0,0 +1,30 @@ +-- Browser sessions + enriched crawl storage + +CREATE TABLE IF NOT EXISTS browser_sessions ( + id SERIAL PRIMARY KEY, + url TEXT NOT NULL, + final_url TEXT, + title TEXT, + task TEXT, + status VARCHAR(32) DEFAULT 'running', + content_text TEXT, + content_html TEXT, + screenshot_b64 TEXT, + links JSONB DEFAULT '[]', + metadata JSONB DEFAULT '{}', + site_id INT REFERENCES monitored_sites(id) ON DELETE SET NULL, + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_browser_sessions_created ON browser_sessions(created_at DESC); + +ALTER TABLE crawled_pages ADD COLUMN IF NOT EXISTS site_id INT REFERENCES monitored_sites(id) ON DELETE SET NULL; +ALTER TABLE crawled_pages ADD COLUMN IF NOT EXISTS final_url TEXT; +ALTER TABLE crawled_pages ADD COLUMN IF NOT EXISTS content_html TEXT; +ALTER TABLE crawled_pages ADD COLUMN IF NOT EXISTS screenshot_b64 TEXT; +ALTER TABLE crawled_pages ADD COLUMN IF NOT EXISTS links JSONB DEFAULT '[]'; +ALTER TABLE crawled_pages ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'; + +ALTER TABLE monitored_sites ADD COLUMN IF NOT EXISTS last_title TEXT; +ALTER TABLE monitored_sites ADD COLUMN IF NOT EXISTS last_snapshot_id INT REFERENCES browser_sessions(id) ON DELETE SET NULL; diff --git a/migrations/007_photo_imports.sql b/migrations/007_photo_imports.sql new file mode 100644 index 0000000..73417b0 --- /dev/null +++ b/migrations/007_photo_imports.sql @@ -0,0 +1,17 @@ +-- Photo imports + OCR/detections from browser, Telegram, NAS upload +CREATE TABLE IF NOT EXISTS photo_imports ( + id SERIAL PRIMARY KEY, + source VARCHAR(64) NOT NULL DEFAULT 'upload', + filename VARCHAR(512), + storage_path VARCHAR(1024), + image_b64 TEXT NOT NULL, + ocr_text TEXT, + detections JSONB DEFAULT '[]'::jsonb, + extracted_items JSONB DEFAULT '[]'::jsonb, + metadata JSONB DEFAULT '{}'::jsonb, + session_id INTEGER REFERENCES browser_sessions(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_photo_imports_source ON photo_imports(source); +CREATE INDEX IF NOT EXISTS idx_photo_imports_created ON photo_imports(created_at DESC); diff --git a/migrations/008_pgvector_telegram_brain.sql b/migrations/008_pgvector_telegram_brain.sql new file mode 100644 index 0000000..98591cc --- /dev/null +++ b/migrations/008_pgvector_telegram_brain.sql @@ -0,0 +1,84 @@ +-- Second brain: Telegram graph + pgvector (768 = nomic-embed-text) +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS telegram_conversations ( + id SERIAL PRIMARY KEY, + chat_id BIGINT NOT NULL UNIQUE, + chat_type VARCHAR(32) DEFAULT 'private', + user_name TEXT, + user_role VARCHAR(32), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS telegram_messages ( + id SERIAL PRIMARY KEY, + conversation_id INT NOT NULL REFERENCES telegram_conversations(id) ON DELETE CASCADE, + telegram_message_id BIGINT, + direction VARCHAR(8) NOT NULL CHECK (direction IN ('in', 'out')), + role VARCHAR(16) NOT NULL DEFAULT 'user', + content_type VARCHAR(32) DEFAULT 'text', + content_text TEXT, + content_json JSONB DEFAULT '{}', + reply_to_message_id INT REFERENCES telegram_messages(id) ON DELETE SET NULL, + agent_name VARCHAR(64), + agent_event_id INT REFERENCES agent_events(id) ON DELETE SET NULL, + channel VARCHAR(32) DEFAULT 'telegram', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_tg_msg_conv_created ON telegram_messages(conversation_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_tg_msg_telegram_id ON telegram_messages(telegram_message_id); +CREATE INDEX IF NOT EXISTS idx_tg_msg_fts ON telegram_messages + USING gin(to_tsvector('simple', coalesce(content_text, ''))); + +CREATE TABLE IF NOT EXISTS telegram_message_embeddings ( + id SERIAL PRIMARY KEY, + message_id INT NOT NULL REFERENCES telegram_messages(id) ON DELETE CASCADE, + chunk_index INT DEFAULT 0, + embedding vector(768) NOT NULL, + model VARCHAR(64) DEFAULT 'nomic-embed-text', + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (message_id, chunk_index) +); + +CREATE INDEX IF NOT EXISTS idx_tg_emb_hnsw ON telegram_message_embeddings + USING hnsw (embedding vector_cosine_ops); + +CREATE TABLE IF NOT EXISTS telegram_message_edges ( + id SERIAL PRIMARY KEY, + source_message_id INT NOT NULL REFERENCES telegram_messages(id) ON DELETE CASCADE, + target_message_id INT REFERENCES telegram_messages(id) ON DELETE SET NULL, + target_entity_type VARCHAR(64), + target_entity_id INT, + edge_type VARCHAR(64) NOT NULL, + weight FLOAT DEFAULT 1.0, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_tg_edges_source ON telegram_message_edges(source_message_id); +CREATE INDEX IF NOT EXISTS idx_tg_edges_target ON telegram_message_edges(target_message_id); +CREATE INDEX IF NOT EXISTS idx_tg_edges_type ON telegram_message_edges(edge_type); +CREATE INDEX IF NOT EXISTS idx_tg_edges_entity ON telegram_message_edges(target_entity_type, target_entity_id); + +CREATE OR REPLACE VIEW telegram_graph_view AS +SELECT + e.id AS edge_id, + e.edge_type, + e.weight, + e.target_entity_type, + e.target_entity_id, + sm.id AS source_id, + sm.content_text AS source_text, + sm.direction AS source_direction, + sm.agent_name AS source_agent, + tm.id AS target_id, + tm.content_text AS target_text, + c.chat_id, + e.created_at +FROM telegram_message_edges e +JOIN telegram_messages sm ON sm.id = e.source_message_id +LEFT JOIN telegram_messages tm ON tm.id = e.target_message_id +JOIN telegram_conversations c ON c.id = sm.conversation_id; diff --git a/migrations/009_full_upgrade.sql b/migrations/009_full_upgrade.sql new file mode 100644 index 0000000..b009609 --- /dev/null +++ b/migrations/009_full_upgrade.sql @@ -0,0 +1,303 @@ +-- Foodlinkk 009: research pipeline + retail intelligence + recommendations +-- Safe: IF NOT EXISTS throughout + +CREATE TABLE IF NOT EXISTS data_providers ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + provider_type VARCHAR(50), + config JSONB DEFAULT '{}', + refresh_interval INTERVAL, + last_fetch_at TIMESTAMP, + last_status VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS data_snapshots ( + id SERIAL PRIMARY KEY, + provider_id INTEGER REFERENCES data_providers(id), + fetched_at TIMESTAMP DEFAULT NOW(), + payload JSONB NOT NULL, + record_count INTEGER, + ttl_expires_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS research_briefs ( + id SERIAL PRIMARY KEY, + domain VARCHAR(50), + title VARCHAR(255), + summary TEXT, + key_findings JSONB DEFAULT '[]', + source_snapshot_ids INTEGER[], + generated_at TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS market_trends ( + id SERIAL PRIMARY KEY, + category VARCHAR(100) NOT NULL, + trend_name VARCHAR(255) NOT NULL, + description TEXT, + source VARCHAR(255), + confidence_score DECIMAL(3, 2), + opportunity_score DECIMAL(3, 2), + related_products TEXT[], + action_items TEXT[], + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS competitor_products ( + id SERIAL PRIMARY KEY, + competitor VARCHAR(100) NOT NULL, + product_name VARCHAR(255) NOT NULL, + category VARCHAR(100), + price DECIMAL(10, 2), + launch_date DATE, + halal_certified BOOLEAN DEFAULT FALSE, + ingredients TEXT, + market_position VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS design_projects ( + id SERIAL PRIMARY KEY, + client_id INTEGER, + project_type VARCHAR(50) NOT NULL, + brief TEXT, + concepts JSONB, + selected_design TEXT, + approval_status VARCHAR(20), + files_generated TEXT[], + brand_guidelines JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS brand_assets ( + id SERIAL PRIMARY KEY, + client_id INTEGER, + asset_type VARCHAR(50) NOT NULL, + file_path TEXT NOT NULL, + usage_rights TEXT, + version VARCHAR(20), + created_at TIMESTAMP DEFAULT NOW(), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS supermarkets ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + chain VARCHAR(100) NOT NULL, + address TEXT NOT NULL, + postcode VARCHAR(7) NOT NULL, + city VARCHAR(100) NOT NULL, + province VARCHAR(50), + latitude DECIMAL(10, 8), + longitude DECIMAL(11, 8), + store_type VARCHAR(50), + size_m2 INTEGER, + opening_hours JSONB, + phone VARCHAR(20), + email VARCHAR(255), + website VARCHAR(255), + halal_certified BOOLEAN DEFAULT FALSE, + halal_certifier VARCHAR(100), + partnership_status VARCHAR(20) DEFAULT 'none', + client_id INTEGER, + deal_id INTEGER, + halal_certificate_number VARCHAR(100), + halal_expiry_date DATE, + has_halal_section BOOLEAN DEFAULT FALSE, + organic_section BOOLEAN DEFAULT FALSE, + alcohol_section BOOLEAN DEFAULT FALSE, + last_updated TIMESTAMP DEFAULT NOW(), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS butcher_shops ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + address TEXT NOT NULL, + postcode VARCHAR(7) NOT NULL, + city VARCHAR(100) NOT NULL, + province VARCHAR(50), + latitude DECIMAL(10, 8), + longitude DECIMAL(11, 8), + halal_certified BOOLEAN DEFAULT FALSE, + halal_certifier VARCHAR(100), + specialty VARCHAR(100), + opening_hours JSONB, + phone VARCHAR(20), + website VARCHAR(255), + last_updated TIMESTAMP DEFAULT NOW(), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS wholesalers ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + address TEXT NOT NULL, + postcode VARCHAR(7) NOT NULL, + city VARCHAR(100) NOT NULL, + province VARCHAR(50), + latitude DECIMAL(10, 8), + longitude DECIMAL(11, 8), + halal_certified BOOLEAN DEFAULT FALSE, + halal_certifier VARCHAR(100), + product_categories TEXT[], + minimum_order DECIMAL(10, 2), + delivery_areas TEXT[], + opening_hours JSONB, + phone VARCHAR(20), + email VARCHAR(255), + website VARCHAR(255), + last_updated TIMESTAMP DEFAULT NOW(), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS area_analysis ( + id SERIAL PRIMARY KEY, + postcode VARCHAR(7) NOT NULL UNIQUE, + city VARCHAR(100), + population INTEGER, + households INTEGER, + avg_household_size DECIMAL(3, 2), + avg_income DECIMAL(10, 2), + median_income DECIMAL(10, 2), + education_level JSONB, + age_distribution JSONB, + ethnic_composition JSONB, + religious_composition JSONB, + unemployment_rate DECIMAL(5, 2), + housing_type JSONB, + car_ownership DECIMAL(5, 2), + last_updated TIMESTAMP DEFAULT NOW(), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS weather_data ( + id SERIAL PRIMARY KEY, + location_id INTEGER, + location_type VARCHAR(50), + region VARCHAR(100), + city VARCHAR(100), + date DATE NOT NULL, + temperature_c DECIMAL(4, 1), + feels_like_c DECIMAL(4, 1), + humidity INTEGER, + wind_speed_kmh DECIMAL(5, 1), + precipitation_mm DECIMAL(5, 1), + weather_condition VARCHAR(50), + uv_index DECIMAL(3, 1), + data_source VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS marketing_strategies ( + id SERIAL PRIMARY KEY, + strategy_name VARCHAR(255) NOT NULL, + strategy_type VARCHAR(50), + description TEXT, + target_audience TEXT[], + product_categories TEXT[], + weather_conditions TEXT[], + temperature_range_min DECIMAL(4, 1), + temperature_range_max DECIMAL(4, 1), + seasons TEXT[], + channels TEXT[], + budget_range DECIMAL(10, 2), + expected_roi DECIMAL(5, 2), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS marketing_recommendations ( + id SERIAL PRIMARY KEY, + strategy_id INTEGER, + location_id INTEGER, + location_type VARCHAR(50), + weather_data_id INTEGER, + recommendation_text TEXT NOT NULL, + priority VARCHAR(20), + action_items TEXT[], + expected_impact TEXT, + generated_at TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP, + status VARCHAR(20), + data_source VARCHAR(100) +); + +CREATE TABLE IF NOT EXISTS ai_recommendations ( + id SERIAL PRIMARY KEY, + recommendation_type VARCHAR(50), + title VARCHAR(255) NOT NULL, + description TEXT, + priority VARCHAR(20), + impact_score DECIMAL(3, 2), + confidence_score DECIMAL(3, 2), + data_sources JSONB, + action_items TEXT[], + estimated_value DECIMAL(10, 2), + generated_by VARCHAR(100), + related_entity_type VARCHAR(50), + related_entity_id INTEGER, + status VARCHAR(20) DEFAULT 'pending', + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS herman_conversations ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(100), + session_id VARCHAR(100), + message TEXT NOT NULL, + role VARCHAR(20), + context JSONB, + timestamp TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS herman_actions ( + id SERIAL PRIMARY KEY, + action_type VARCHAR(50), + title VARCHAR(255) NOT NULL, + description TEXT, + target_agent VARCHAR(100), + parameters JSONB, + status VARCHAR(20), + result JSONB, + created_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_data_snapshots_provider ON data_snapshots(provider_id); +CREATE INDEX IF NOT EXISTS idx_research_briefs_domain ON research_briefs(domain); +CREATE INDEX IF NOT EXISTS idx_supermarkets_chain ON supermarkets(chain); +CREATE INDEX IF NOT EXISTS idx_supermarkets_partnership ON supermarkets(partnership_status); +CREATE INDEX IF NOT EXISTS idx_ai_recommendations_status ON ai_recommendations(status); + +INSERT INTO data_providers (name, provider_type, config, is_active) VALUES + ('crm', 'internal', '{"description":"PostgreSQL CRM"}', true), + ('weather', 'api', '{"url":"https://api.open-meteo.com/v1/forecast"}', true), + ('social', 'internal', '{"table":"social_mentions"}', true), + ('retail_manual', 'internal', '{"description":"Seed retail locations"}', true) +ON CONFLICT (name) DO NOTHING; + +INSERT INTO supermarkets (name, chain, address, postcode, city, province, latitude, longitude, partnership_status, data_source) +VALUES + ('Jumbo Bos en Lommer', 'Jumbo', 'Bos en Lommerweg 123', '1055RW', 'Amsterdam', 'Noord-Holland', 52.378100, 4.832000, 'active', 'seed'), + ('AH Chai N Masala regio', 'Albert Heijn', 'Haarlemmerdijk 1', '1013EM', 'Amsterdam', 'Noord-Holland', 52.384500, 4.883200, 'proposal', 'seed') +ON CONFLICT DO NOTHING; + +INSERT INTO ai_recommendations (recommendation_type, title, description, priority, impact_score, confidence_score, status, generated_by, action_items) +SELECT 'deal_action', 'Chai N Masala — margin sheet', 'Deal Q3 spice range deadline 20 jun — stuur margin sheet', 'high', 0.85, 0.90, 'pending', 'herman', ARRAY['Margin sheet versturen','Follow-up call plannen'] +WHERE NOT EXISTS (SELECT 1 FROM ai_recommendations WHERE title = 'Chai N Masala — margin sheet'); + +INSERT INTO ai_recommendations (recommendation_type, title, description, priority, impact_score, confidence_score, status, generated_by, action_items) +SELECT 'retail_target', 'Van den Tweel Jumbo regio', 'Follow-up voorstel supermarkt placement Amsterdam-West', 'medium', 0.72, 0.80, 'pending', 'herman', ARRAY['Contact Van den Tweel','Update proposal deck'] +WHERE NOT EXISTS (SELECT 1 FROM ai_recommendations WHERE title = 'Van den Tweel Jumbo regio'); diff --git a/migrations/010_retail_crm_intel.sql b/migrations/010_retail_crm_intel.sql new file mode 100644 index 0000000..daf373a --- /dev/null +++ b/migrations/010_retail_crm_intel.sql @@ -0,0 +1,83 @@ +-- Retail CRM intelligence layer +CREATE TABLE IF NOT EXISTS supermarket_contacts ( + id SERIAL PRIMARY KEY, + supermarket_id INTEGER NOT NULL REFERENCES supermarkets(id) ON DELETE CASCADE, + role VARCHAR(64) NOT NULL DEFAULT 'manager', + full_name VARCHAR(255), + phone VARCHAR(32), + email VARCHAR(255), + linkedin_url VARCHAR(512), + source VARCHAR(100), + confidence NUMERIC(3,2) DEFAULT 0.50, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_supermarket_contacts_store ON supermarket_contacts(supermarket_id); +CREATE INDEX IF NOT EXISTS idx_supermarket_contacts_role ON supermarket_contacts(role); + +CREATE TABLE IF NOT EXISTS supermarket_profiles ( + supermarket_id INTEGER PRIMARY KEY REFERENCES supermarkets(id) ON DELETE CASCADE, + staff_count_estimate INTEGER, + manager_name VARCHAR(255), + manager_phone VARCHAR(32), + manager_email VARCHAR(255), + services JSONB DEFAULT '{}'::jsonb, + facilities JSONB DEFAULT '{}'::jsonb, + web_data JSONB DEFAULT '{}'::jsonb, + scrape_url VARCHAR(512), + last_scraped_at TIMESTAMPTZ, + data_completeness NUMERIC(3,2) DEFAULT 0, + notes TEXT +); + +CREATE TABLE IF NOT EXISTS client_supermarket_links ( + id SERIAL PRIMARY KEY, + client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + supermarket_id INTEGER NOT NULL REFERENCES supermarkets(id) ON DELETE CASCADE, + deal_id INTEGER REFERENCES deals(id) ON DELETE SET NULL, + relationship_type VARCHAR(64) DEFAULT 'prospect', + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(client_id, supermarket_id) +); +CREATE INDEX IF NOT EXISTS idx_csl_client ON client_supermarket_links(client_id); +CREATE INDEX IF NOT EXISTS idx_csl_store ON client_supermarket_links(supermarket_id); + +CREATE TABLE IF NOT EXISTS halal_certifications ( + id SERIAL PRIMARY KEY, + supermarket_id INTEGER REFERENCES supermarkets(id) ON DELETE SET NULL, + certifier VARCHAR(100) NOT NULL, + certificate_number VARCHAR(100), + business_name VARCHAR(255), + address TEXT, + city VARCHAR(100), + expiry_date DATE, + status VARCHAR(32) DEFAULT 'active', + registry_source VARCHAR(100), + matched_confidence NUMERIC(3,2), + raw_data JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_halal_cert_store ON halal_certifications(supermarket_id); +CREATE INDEX IF NOT EXISTS idx_halal_cert_status ON halal_certifications(status); + +CREATE TABLE IF NOT EXISTS retail_opportunity_scores ( + supermarket_id INTEGER PRIMARY KEY REFERENCES supermarkets(id) ON DELETE CASCADE, + halal_opportunity_score NUMERIC(5,2), + market_potential_score NUMERIC(5,2), + factors JSONB DEFAULT '{}'::jsonb, + computed_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_retail_opp_halal ON retail_opportunity_scores(halal_opportunity_score DESC); + +ALTER TABLE supermarkets ADD COLUMN IF NOT EXISTS employee_count INTEGER; +ALTER TABLE supermarkets ADD COLUMN IF NOT EXISTS manager_name VARCHAR(255); +ALTER TABLE supermarkets ADD COLUMN IF NOT EXISTS enrichment_score NUMERIC(3,2) DEFAULT 0; +ALTER TABLE supermarkets ADD COLUMN IF NOT EXISTS halal_opportunity_score NUMERIC(5,2); + +INSERT INTO data_providers (name, provider_type, config, is_active) VALUES + ('halal_registry', 'scrape', '{"sources": ["osm", "hqc", "manual"]}', true), + ('branch_scraper', 'scrape', '{"chains": ["ah", "jumbo", "plus", "lidl", "aldi", "dirk"]}', true), + ('market_trends', 'api', '{"categories": ["kant-en-klaar", "halal", "ready-meals"]}', true) +ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config, is_active = true; diff --git a/migrations/011_retail_360.sql b/migrations/011_retail_360.sql new file mode 100644 index 0000000..81c5dc7 --- /dev/null +++ b/migrations/011_retail_360.sql @@ -0,0 +1,113 @@ +-- Retail 360: notes, media, milestones, ownership, RSS, city demographics + +CREATE TABLE IF NOT EXISTS entity_notes ( + id SERIAL PRIMARY KEY, + entity_type VARCHAR(32) NOT NULL, + entity_id INTEGER NOT NULL, + title VARCHAR(255), + body TEXT NOT NULL, + note_type VARCHAR(32) DEFAULT 'general', + created_by VARCHAR(64) DEFAULT 'team', + pinned BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_entity_notes_entity ON entity_notes(entity_type, entity_id); + +CREATE TABLE IF NOT EXISTS entity_media ( + id SERIAL PRIMARY KEY, + entity_type VARCHAR(32) NOT NULL, + entity_id INTEGER NOT NULL, + filename VARCHAR(255) NOT NULL, + content_type VARCHAR(64) DEFAULT 'image/jpeg', + storage_path TEXT NOT NULL, + caption TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_entity_media_entity ON entity_media(entity_type, entity_id); + +CREATE TABLE IF NOT EXISTS sales_milestones ( + id SERIAL PRIMARY KEY, + supermarket_id INTEGER REFERENCES supermarkets(id) ON DELETE CASCADE, + client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL, + deal_id INTEGER REFERENCES deals(id) ON DELETE SET NULL, + milestone_type VARCHAR(64) NOT NULL DEFAULT 'custom', + title VARCHAR(255) NOT NULL, + status VARCHAR(32) DEFAULT 'pending', + target_date DATE, + completed_at TIMESTAMPTZ, + value_eur NUMERIC(12,2), + notes TEXT, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_milestones_store ON sales_milestones(supermarket_id); + +CREATE TABLE IF NOT EXISTS ownership_changes ( + id SERIAL PRIMARY KEY, + entity_type VARCHAR(32) DEFAULT 'supermarket', + entity_id INTEGER, + chain VARCHAR(100), + previous_owner VARCHAR(255), + new_owner VARCHAR(255) NOT NULL, + change_type VARCHAR(64) DEFAULT 'acquisition', + effective_date DATE, + source VARCHAR(255), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_ownership_chain ON ownership_changes(chain); + +CREATE TABLE IF NOT EXISTS city_demographics ( + id SERIAL PRIMARY KEY, + city VARCHAR(100) NOT NULL, + province VARCHAR(50), + gemeente_code VARCHAR(16), + population INTEGER, + households INTEGER, + avg_income NUMERIC(12,2), + muslim_proxy_pct NUMERIC(5,2), + data_source VARCHAR(100) DEFAULT 'cbs', + last_updated TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(city, province) +); +CREATE INDEX IF NOT EXISTS idx_city_demo_city ON city_demographics(city); + +CREATE TABLE IF NOT EXISTS rss_feeds ( + id SERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL UNIQUE, + url TEXT NOT NULL, + category VARCHAR(64) DEFAULT 'general', + is_active BOOLEAN DEFAULT TRUE, + last_fetch_at TIMESTAMPTZ, + last_status VARCHAR(32) +); + +CREATE TABLE IF NOT EXISTS rss_items ( + id SERIAL PRIMARY KEY, + feed_id INTEGER NOT NULL REFERENCES rss_feeds(id) ON DELETE CASCADE, + title TEXT NOT NULL, + link TEXT, + summary TEXT, + published_at TIMESTAMPTZ, + fetched_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(feed_id, link) +); +CREATE INDEX IF NOT EXISTS idx_rss_items_published ON rss_items(published_at DESC); + +ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS supermarket_id INTEGER REFERENCES supermarkets(id) ON DELETE SET NULL; +ALTER TABLE wholesalers ADD COLUMN IF NOT EXISTS external_id VARCHAR(64); +CREATE UNIQUE INDEX IF NOT EXISTS idx_wholesalers_external ON wholesalers(external_id) WHERE external_id IS NOT NULL; + +INSERT INTO rss_feeds (name, url, category) VALUES + ('Foodlog NL', 'https://www.foodlog.nl/feed/', 'kant-en-klaar'), + ('RetailDetail', 'https://www.retaildetail.nl/feed/', 'retail'), + ('VMT Food', 'https://www.vmt.nl/feed/', 'food-industry'), + ('Nu.nl Economie', 'https://www.nu.nl/rss/Economie', 'economie'), + ('Google Halal Food NL', 'https://news.google.com/rss/search?q=halal+food+supermarkt+nederland&hl=nl&gl=NL&ceid=NL:nl', 'halal') +ON CONFLICT (name) DO NOTHING; + +INSERT INTO data_providers (name, provider_type, config, is_active) VALUES + ('rss_feeds', 'api', '{"type": "rss"}', true), + ('wholesale_osm', 'scrape', '{"chains": ["Sligro", "Hanos", "Makro", "Bidfood"]}', true) +ON CONFLICT (name) DO UPDATE SET is_active = true; diff --git a/migrations/012_rss_focus.sql b/migrations/012_rss_focus.sql new file mode 100644 index 0000000..8d4099b --- /dev/null +++ b/migrations/012_rss_focus.sql @@ -0,0 +1,34 @@ +-- RSS focus: alleen kant-en-klaar maaltijden & supermarkten (geen algemeen nieuws) + +UPDATE rss_feeds SET is_active = FALSE WHERE name IN ('Nu.nl Economie', 'Foodlog NL', 'VMT Food'); + +UPDATE rss_feeds SET + url = 'https://news.google.com/rss/search?q=kant+en+klaar+maaltijd+supermarkt+nederland&hl=nl&gl=NL&ceid=NL:nl', + category = 'kant-en-klaar', + is_active = TRUE +WHERE name = 'Google Halal Food NL'; + +UPDATE rss_feeds SET name = 'Google Kant-en-klaar NL' WHERE name = 'Google Halal Food NL'; + +UPDATE rss_feeds SET category = 'supermarkt', is_active = TRUE WHERE name = 'RetailDetail'; + +INSERT INTO rss_feeds (name, url, category, is_active) VALUES + ('Google Supermarkt Retail NL', 'https://news.google.com/rss/search?q=supermarkt+retail+nederland+maaltijd&hl=nl&gl=NL&ceid=NL:nl', 'supermarkt', true), + ('Google Ready Meals NL', 'https://news.google.com/rss/search?q=ready+meal+supermarket+netherlands+halal&hl=nl&gl=NL&ceid=NL:nl', 'kant-en-klaar', true), + ('Google AH Jumbo NL', 'https://news.google.com/rss/search?q=Albert+Heijn+OR+Jumbo+kant+en+klaar+schap&hl=nl&gl=NL&ceid=NL:nl', 'supermarkt', true) +ON CONFLICT (name) DO UPDATE SET url = EXCLUDED.url, category = EXCLUDED.category, is_active = true; + +-- Verwijder irrelevante items (algemeen nieuws) +DELETE FROM rss_items WHERE feed_id IN (SELECT id FROM rss_feeds WHERE is_active = FALSE); + +DELETE FROM rss_items WHERE + title NOT ILIKE ANY (ARRAY[ + '%kant%klaa%', '%maaltijd%', '%meal%', '%supermarkt%', '%supermarket%', + '%retail%', '%jumbo%', '%albert heijn%', '%ah %', '%plus %', '%lidl%', + '%aldi%', '%dirk%', '%halal%', '%convenience%', '%schap%', '%filiaal%', + '%food%', '%grocery%', '%vers%', '%ready%', '%horeca%', '%foodservice%' + ]) + AND COALESCE(summary, '') NOT ILIKE ANY (ARRAY[ + '%kant%klaa%', '%maaltijd%', '%meal%', '%supermarkt%', '%supermarket%', + '%retail%', '%jumbo%', '%albert heijn%', '%halal%', '%food%' + ]); diff --git a/migrations/013_agent_souls_permissions.sql b/migrations/013_agent_souls_permissions.sql new file mode 100644 index 0000000..969fcaa --- /dev/null +++ b/migrations/013_agent_souls_permissions.sql @@ -0,0 +1,206 @@ +-- Agent souls (character profiles) + Herman module permissions + +CREATE TABLE IF NOT EXISTS agent_souls ( + agent_key VARCHAR(64) PRIMARY KEY, + display_name VARCHAR(128) NOT NULL, + role_title VARCHAR(128), + soul_md TEXT NOT NULL DEFAULT '', + responsibilities TEXT, + permissions JSONB DEFAULT '[]'::jsonb, + is_active BOOLEAN DEFAULT TRUE, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS herman_permissions ( + module_key VARCHAR(64) PRIMARY KEY, + module_label VARCHAR(128) NOT NULL, + description TEXT, + category VARCHAR(64) DEFAULT 'general', + granted BOOLEAN DEFAULT FALSE, + granted_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +INSERT INTO agent_souls (agent_key, display_name, role_title, soul_md, responsibilities) VALUES +('herman', 'Herman', 'AI Co-CEO & Orchestrator', +'# Herman — Soul + +Je bent Herman, de AI co-CEO van Foodlinkk. Warm, direct, zakelijk. + +## Karakter +- Spreekt Aïssa (CEO) aan met respect en helderheid +- Denkt in pipeline, retail partnerships en halal kant-en-klaar groei +- Delegeert naar specialisten maar houdt het totaaloverzicht + +## Werkzaamheden +- Dagelijkse CEO briefing genereren +- Agent events routeren en goedkeuringen voorbereiden +- OpenSwarm delegatie naar marketing, bizdev, finance, etc. +- CRM + retail intelligence samenvoegen in actiepunten', +'CEO briefing · orchestratie · delegatie · pipeline overzicht · goedkeuringen'), + +('marketing', 'Marketing', 'Social & Brand', +'# Marketing Agent — Soul + +Foodlinkk brand voice: halal kant-en-klaar, premium, toegankelijk. + +## Werkzaamheden +- Social mentions monitoren +- Content planning & scheduled posts +- RSS/trends vertalen naar campagnes +- Herman recommendations voor promoties', +'Social media · content · trends · campagnes · mentions'), + +('bizdev', 'BizDev', 'Retail Partnerships', +'# BizDev — Soul + +Focus: supermarkt listings, Jumbo/AH/Plus partnerships, margin sheets. + +## Werkzaamheden +- Deal pipeline follow-up +- Retail 360 kansen benaderen +- Voorstellen en proposals +- CRM koppeling supermarkten', +'Deals · retail partnerships · proposals · CRM supermarkt links'), + +('finance', 'Finance', 'Margins & Pricing', +'# Finance — Soul + +Prijzen, marges, cashflow voor kant-en-klaar SKU''s. + +## Werkzaamheden +- Deal waardering +- Margin sheets +- Pricing advies per retail keten', +'Pipeline waarde · marges · pricing · cashflow'), + +('sourcing', 'Sourcing', 'Procurement', +'# Sourcing — Soul + +Leveranciers, MOQ, lead times, halal ingredient sourcing. + +## Werkzaamheden +- Supplier database +- Inkoopvoorwaarden +- Lead time tracking', +'Suppliers · MOQ · inkoop · ingredient vetting'), + +('product', 'Product', 'SKU & Launch', +'# Product — Soul + +Kant-en-klaar SKU ontwikkeling, shelf readiness, launch timelines. + +## Werkzaamheden +- Product catalogus +- Launch planning +- Retail listing requirements', +'Products · SKU · launch · shelf readiness'), + +('halal', 'Halal', 'Certificering', +'# Halal Agent — Soul + +Halal compliance, certificering, ingredient vetting voor Foodlinkk. + +## Werkzaamheden +- Halal registry sync +- Certifier tracking +- Gap-analyse supermarkten', +'Halal cert · compliance · registry · opportunity scores'), + +('design', 'Design', 'Visual & Packaging', +'# Design — Soul + +Packaging, productfoto''s via ComfyUI, retail presentatie. + +## Werkzaamheden +- ComfyUI image generation +- Packaging visuals +- Studio assets', +'ComfyUI · packaging · visuals · studio'), + +('knowledge', 'Knowledge', 'RAG & Docs', +'# Knowledge — Soul + +Interne docs, NAS corpus, RAG antwoorden. + +## Werkzaamheden +- Document analytics +- NAS word counts & sentiment +- Policy antwoorden', +'NAS documents · RAG · document analytics · woorden corpus'), + +('retail', 'Retail Intel', 'Supermarkt 360', +'# Retail Intel — Soul + +3.000+ filialen, CBS demografie, halal kansen, groothandels. + +## Werkzaamheden +- OSM import & enrichment +- Opportunity scoring +- CRM 360 workspace', +'Supermarkten · CBS · halal scores · groothandels · RSS'), + +('browser', 'Browser', 'Web Research', +'# Browser Agent — Soul + +Autonome web research voor markt intel. + +## Werkzaamheden +- Browser sessies +- Pagina scraping +- Competitor monitoring', +'Browser sessions · web research · monitor'), + +('hermes', 'Hermes', 'Telegram', +'# Hermes — Soul + +Telegram command center, PA browsers, second brain vectors. + +## Werkzaamheden +- Telegram feed +- Team notificaties CEO/CTO +- Brain graph & memory', +'Telegram · PA live · brain vectors · team notify'), + +('email', 'Email', 'Mailbox', +'# Email Agent — Soul + +IMAP/SMTP voor Foodlinkk communicatie. + +## Werkzaamheden +- Email sync (wanneer actief) +- Draft voorstellen +- SMTP verzenden', +'Email accounts · SMTP · inbox sync'), + +('research', 'Research', 'Markt Intel', +'# Research — Soul + +Externe data feeds, weather, trends aggregatie. + +## Werkzaamheden +- Research refresh jobs +- Data provider sync +- Trend seeds', +'Research API · trends · weather · data providers') +ON CONFLICT (agent_key) DO NOTHING; + +INSERT INTO herman_permissions (module_key, module_label, description, category, granted) VALUES +('crm_clients', 'CRM — Clients', 'Klanten bekijken en bewerken', 'crm', false), +('crm_deals', 'CRM — Deals', 'Deals pipeline beheren', 'crm', false), +('crm_products', 'CRM — Products', 'Productcatalogus', 'crm', false), +('crm_suppliers', 'CRM — Suppliers', 'Leveranciers', 'crm', false), +('retail_360', 'Retail 360', 'Supermarkten, groothandels, kansen', 'intel', false), +('marketing_hub', 'Marketing Hub', 'Social, RSS, campagnes', 'intel', false), +('documents_nas', 'Documents / NAS', 'NAS bestanden lezen & analyseren', 'intel', false), +('telegram_hermes', 'Hermes Telegram', 'Berichten versturen, feed lezen', 'comms', false), +('browser_agent', 'Browser Agent', 'Web research starten', 'agents', false), +('email_send', 'Email versturen', 'SMTP verzenden namens Foodlinkk', 'comms', false), +('settings_admin', 'Settings', 'Instellingen wijzigen', 'admin', false), +('approvals', 'Goedkeuringen', 'Agent events goedkeuren/afwijzen', 'admin', false), +('recommendations', 'Recommendations', 'Herman aanbevelingen approve/dismiss', 'admin', false), +('voice', 'Voice', 'Voice agent', 'agents', false), +('studio', 'Studio / ComfyUI', 'Image generation', 'agents', false), +('reports', 'Reports', 'Rapportages genereren', 'intel', false), +('analytics', 'Analytics', 'Analytics dashboards', 'intel', false) +ON CONFLICT (module_key) DO NOTHING; diff --git a/migrations/014_market_regulation_avatars.sql b/migrations/014_market_regulation_avatars.sql new file mode 100644 index 0000000..d32296b --- /dev/null +++ b/migrations/014_market_regulation_avatars.sql @@ -0,0 +1,29 @@ +-- Retail market regulation RSS + agent avatar metadata + +ALTER TABLE agent_souls ADD COLUMN IF NOT EXISTS avatar_emoji VARCHAR(8) DEFAULT '🤖'; +ALTER TABLE agent_souls ADD COLUMN IF NOT EXISTS avatar_color VARCHAR(16) DEFAULT '#00e5ff'; +ALTER TABLE agent_souls ADD COLUMN IF NOT EXISTS avatar_mood VARCHAR(16) DEFAULT 'happy'; + +UPDATE agent_souls SET avatar_emoji = '👔', avatar_color = '#ffd700', avatar_mood = 'ceo' WHERE agent_key = 'herman'; +UPDATE agent_souls SET avatar_emoji = '📣', avatar_color = '#ff6b9d', avatar_mood = 'creative' WHERE agent_key = 'marketing'; +UPDATE agent_souls SET avatar_emoji = '🤝', avatar_color = '#00e5ff', avatar_mood = 'focused' WHERE agent_key = 'bizdev'; +UPDATE agent_souls SET avatar_emoji = '💰', avatar_color = '#22c55e', avatar_mood = 'calm' WHERE agent_key = 'finance'; +UPDATE agent_souls SET avatar_emoji = '📦', avatar_color = '#f97316', avatar_mood = 'busy' WHERE agent_key = 'sourcing'; +UPDATE agent_souls SET avatar_emoji = '🍱', avatar_color = '#a855f7', avatar_mood = 'happy' WHERE agent_key = 'product'; +UPDATE agent_souls SET avatar_emoji = '☪️', avatar_color = '#34d399', avatar_mood = 'calm' WHERE agent_key = 'halal'; +UPDATE agent_souls SET avatar_emoji = '🎨', avatar_color = '#ec4899', avatar_mood = 'creative' WHERE agent_key = 'design'; +UPDATE agent_souls SET avatar_emoji = '📚', avatar_color = '#6366f1', avatar_mood = 'focused' WHERE agent_key = 'knowledge'; +UPDATE agent_souls SET avatar_emoji = '🏪', avatar_color = '#b8ff3c', avatar_mood = 'busy' WHERE agent_key = 'retail'; +UPDATE agent_souls SET avatar_emoji = '🔍', avatar_color = '#38bdf8', avatar_mood = 'focused' WHERE agent_key = 'browser'; +UPDATE agent_souls SET avatar_emoji = '✈️', avatar_color = '#2aabee', avatar_mood = 'happy' WHERE agent_key = 'hermes'; +UPDATE agent_souls SET avatar_emoji = '📧', avatar_color = '#94a3b8', avatar_mood = 'calm' WHERE agent_key = 'email'; +UPDATE agent_souls SET avatar_emoji = '🔬', avatar_color = '#14b8a6', avatar_mood = 'focused' WHERE agent_key = 'research'; + +INSERT INTO rss_feeds (name, url, category, is_active) VALUES + ('NVWA Voedselveiligheid', 'https://news.google.com/rss/search?q=NVWA+voedselveiligheid+regelgeving&hl=nl&gl=NL&ceid=NL:nl', 'regelgeving', true), + ('EU Food Regulation', 'https://news.google.com/rss/search?q=EU+food+regulation+labeling+halal&hl=en&gl=EU&ceid=EU:en', 'regelgeving', true), + ('Rijksoverheid Voedsel', 'https://news.google.com/rss/search?q=site:rijksoverheid.nl+voedsel+waren+regelgeving&hl=nl&gl=NL&ceid=NL:nl', 'regelgeving', true), + ('CBS Retail & Supermarkt', 'https://news.google.com/rss/search?q=CBS+supermarkt+retail+statistiek+nederland&hl=nl&gl=NL&ceid=NL:nl', 'cbs', true), + ('CBS Voedselconsumptie', 'https://news.google.com/rss/search?q=CBS+voedsel+consumptie+prijzen&hl=nl&gl=NL&ceid=NL:nl', 'cbs', true), + ('Food Retail M&A', 'https://news.google.com/rss/search?q=supermarket+merger+acquisition+retail+food&hl=en&gl=US&ceid=US:en', 'markt', true) +ON CONFLICT (name) DO NOTHING; diff --git a/migrations/015_platform_upgrade.sql b/migrations/015_platform_upgrade.sql new file mode 100644 index 0000000..1ea6376 --- /dev/null +++ b/migrations/015_platform_upgrade.sql @@ -0,0 +1,66 @@ +-- RSS bookmarks, promo campaigns, wholesaler contacts + +CREATE TABLE IF NOT EXISTS rss_bookmarks ( + id SERIAL PRIMARY KEY, + rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE, + title VARCHAR(512), + link VARCHAR(1024), + feed_name VARCHAR(255), + notes TEXT, + tags TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(rss_item_id) +); +CREATE INDEX IF NOT EXISTS idx_rss_bookmarks_created ON rss_bookmarks(created_at DESC); + +CREATE TABLE IF NOT EXISTS promo_campaigns ( + id SERIAL PRIMARY KEY, + chain VARCHAR(100), + supermarket_id INTEGER REFERENCES supermarkets(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + folder_path VARCHAR(512), + folder_label VARCHAR(255), + description TEXT, + image_url VARCHAR(512), + valid_from DATE, + valid_to DATE, + status VARCHAR(32) DEFAULT 'active', + promo_type VARCHAR(64) DEFAULT 'folder', + tags TEXT[] DEFAULT '{}', + source VARCHAR(100) DEFAULT 'manual', + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_promo_campaigns_chain ON promo_campaigns(chain); +CREATE INDEX IF NOT EXISTS idx_promo_campaigns_status ON promo_campaigns(status); +CREATE INDEX IF NOT EXISTS idx_promo_campaigns_dates ON promo_campaigns(valid_from, valid_to); + +CREATE TABLE IF NOT EXISTS wholesaler_contacts ( + id SERIAL PRIMARY KEY, + wholesaler_id INTEGER NOT NULL REFERENCES wholesalers(id) ON DELETE CASCADE, + role VARCHAR(64) NOT NULL DEFAULT 'contact', + full_name VARCHAR(255), + phone VARCHAR(32), + email VARCHAR(255), + linkedin_url VARCHAR(512), + source VARCHAR(100) DEFAULT 'manual', + confidence NUMERIC(3,2) DEFAULT 0.50, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_wholesaler_contacts_wid ON wholesaler_contacts(wholesaler_id); + +ALTER TABLE wholesalers ADD COLUMN IF NOT EXISTS linkedin_url VARCHAR(512); + +-- Seed example promo folders (user can edit/add via UI) +INSERT INTO promo_campaigns (chain, title, folder_path, folder_label, description, valid_from, valid_to, status, promo_type, tags) +SELECT v.chain, v.title, v.folder_path, v.folder_label, v.description, v.valid_from, v.valid_to, 'active', 'folder', v.tags +FROM (VALUES + ('Albert Heijn', 'Bonus folder', '/nas/marketing/reclame/ah-bonus', 'AH Bonus week', 'Wekelijkse AH Bonus acties — koppeling met kant-en-klaar promoties', CURRENT_DATE, CURRENT_DATE + 7, ARRAY['bonus','kant-en-klaar']), + ('Jumbo', 'Aanbiedingen folder', '/nas/marketing/reclame/jumbo-folder', 'Jumbo folder', 'Jumbo reclamefolder — check halal/kant-en-klaar paginas', CURRENT_DATE, CURRENT_DATE + 14, ARRAY['folder','promo']), + ('Lidl', 'Lidl folder', '/nas/marketing/reclame/lidl', 'Lidl acties', 'Lidl weekacties en seizoenspromoties', CURRENT_DATE, CURRENT_DATE + 7, ARRAY['folder']), + ('Plus', 'Plus folder', '/nas/marketing/reclame/plus', 'Plus aanbiedingen', 'Plus supermarkt reclame — regionale varianten', CURRENT_DATE, CURRENT_DATE + 7, ARRAY['folder']), + ('Dirk', 'Dirk aanbiedingen', '/nas/marketing/reclame/dirk', 'Dirk folder', 'Dirk van den Broek weekfolder', CURRENT_DATE, CURRENT_DATE + 7, ARRAY['folder']) +) AS v(chain, title, folder_path, folder_label, description, valid_from, valid_to, tags) +WHERE NOT EXISTS (SELECT 1 FROM promo_campaigns LIMIT 1); diff --git a/migrations/016_platform_ops_packaging.sql b/migrations/016_platform_ops_packaging.sql new file mode 100644 index 0000000..f1cee89 --- /dev/null +++ b/migrations/016_platform_ops_packaging.sql @@ -0,0 +1,223 @@ +-- Platform bundle: social publish, ops, agent souls, packaging + +CREATE TABLE IF NOT EXISTS marketing_media ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255), + file_path TEXT NOT NULL, + media_url TEXT NOT NULL, + mime_type VARCHAR(64), + size_bytes INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS social_integrations ( + platform VARCHAR(32) PRIMARY KEY, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + is_active BOOLEAN DEFAULT FALSE, + last_test_at TIMESTAMPTZ, + last_test_status VARCHAR(64), + last_test_message TEXT, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS social_publish_jobs ( + id SERIAL PRIMARY KEY, + text TEXT NOT NULL, + image_url VARCHAR(1024), + media_ids JSONB DEFAULT '[]'::jsonb, + channels JSONB DEFAULT '[]'::jsonb, + status VARCHAR(32) DEFAULT 'queued', + result JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_social_publish_jobs_created ON social_publish_jobs(created_at DESC); + +CREATE TABLE IF NOT EXISTS infra_snapshots ( + id SERIAL PRIMARY KEY, + snapshot JSONB NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_promo_edition ON promo_campaigns((metadata->>'edition_id')); + +INSERT INTO social_integrations (platform, is_active, config) VALUES + ('twitter', false, '{}'), + ('linkedin', false, '{}'), + ('instagram', false, '{}'), + ('facebook', false, '{}'), + ('tiktok', false, '{}'), + ('pinterest', false, '{}') +ON CONFLICT (platform) DO NOTHING; + +-- New agents + soul updates (see 016 continuation in agent soul block below) + +INSERT INTO agent_souls (agent_key, display_name, role_title, soul_md, responsibilities) VALUES +('sysops', 'SysOps', 'IT Infrastructure Monitor', +'# SysOps — Soul + +Je bewaakt de Foodlinkk AI-omgeving: Proxmox, VMs, Docker, app-health. + +## Karakter +- Nuchter, alert, rapporteert feiten zonder paniek +- Alleen monitoren — geen auto-fix zonder Herman-goedkeuring + +## Werkzaamheden +- Proxmox node + VM105/106 status +- Docker container health +- Error aggregatie uit agent_events +- Infra topologie live bijhouden + +## Output naar Herman +- Infra alerts, downtime, hoge CPU/RAM + +## Samenwerking +- Hermes: notify bij kritieke outages', +'Proxmox · Docker · VM105/106 · errors · infra topologie'), + +('packaging', 'Packaging', 'Verpakkingsontwerp', +'# Packaging — Soul + +Python-gedreven verpakkingsontwerp voor Foodlinkk producten. + +## Karakter +- Precies op maat (mm), drukwerk-ready +- Foodlinkk brand: hands-on, premium halal kant-en-klaar + +## Werkzaamheden +- Stanstekeningen SVG/PDF +- Barcode + logo-vlak + vouw/snijlijnen + +## Samenwerking +- Design: ComfyUI productfoto''s · Marketing: label copy', +'SVG · PDF · PNG · stanstekening · Foodlinkk brand') +ON CONFLICT (agent_key) DO UPDATE SET + display_name = EXCLUDED.display_name, + role_title = EXCLUDED.role_title, + soul_md = EXCLUDED.soul_md, + responsibilities = EXCLUDED.responsibilities, + updated_at = NOW(); + +UPDATE agent_souls SET soul_md = '# Herman — Soul + +Je bent Herman, AI Co-CEO van Foodlinkk. Warm, direct, zakelijk. + +## Karakter +- Neemt alle agent-output tot zich: retail, marketing, ops, packaging +- Delegeert maar houdt totaaloverzicht + +## Werkzaamheden +- CEO briefing · Agent mesh · Goedkeuringen + +## Samenwerking +- Alle agents rapporteren naar Herman', +responsibilities = 'Co-CEO · briefing · orchestratie · mesh hub · goedkeuringen' +WHERE agent_key = 'herman'; + +UPDATE agent_souls SET soul_md = '# Marketing — Soul + +Foodlinkk brand: halal kant-en-klaar, premium, toegankelijk. + +## Werkzaamheden +- Multi-channel social publish (6 platforms) +- Reclamefolder.nl sync +- RSS/trends → campagnes · Emoji content + +## Output naar Herman +- Publish status, campagne KPIs', +responsibilities = 'Social publish · reclame folders · RSS · campagnes' +WHERE agent_key = 'marketing'; + +UPDATE agent_souls SET soul_md = '# Design — Soul + +ComfyUI productfoto''s & studio visuals. + +## Samenwerking +- Packaging: verpakking technisch', +responsibilities = 'ComfyUI · productfoto''s · studio' +WHERE agent_key = 'design'; + +UPDATE agent_souls SET soul_md = '# BizDev — Soul + +Retail partnerships, deals, margin sheets. + +## Output naar Herman +- Partnership updates', +responsibilities = 'Deals · retail partnerships · proposals' +WHERE agent_key = 'bizdev'; + +UPDATE agent_souls SET soul_md = '# Finance — Soul + +Marges, pricing, cashflow. + +## Output naar Herman +- Margin sheets, pipeline waardering', +responsibilities = 'Marges · pricing · cashflow' +WHERE agent_key = 'finance'; + +UPDATE agent_souls SET soul_md = '# Sourcing — Soul + +Leveranciers, MOQ, halal ingredient inkoop.', +responsibilities = 'Suppliers · MOQ · inkoop' +WHERE agent_key = 'sourcing'; + +UPDATE agent_souls SET soul_md = '# Product — Soul + +SKU, shelf readiness, launch timelines.', +responsibilities = 'SKU · launch · shelf readiness' +WHERE agent_key = 'product'; + +UPDATE agent_souls SET soul_md = '# Halal — Soul + +Halal compliance, certificering, gap-analyse.', +responsibilities = 'Halal cert · registry · compliance' +WHERE agent_key = 'halal'; + +UPDATE agent_souls SET soul_md = '# Knowledge — Soul + +NAS corpus, RAG, document analytics.', +responsibilities = 'NAS · RAG · document analytics' +WHERE agent_key = 'knowledge'; + +UPDATE agent_souls SET soul_md = '# Retail Intel — Soul + +3000+ filialen, CBS, halal kansen, groothandels, reclamefolders.', +responsibilities = 'Supermarkten · CBS · halal · Retail 360' +WHERE agent_key = 'retail'; + +UPDATE agent_souls SET soul_md = '# Browser — Soul + +Autonome web research, competitor monitoring.', +responsibilities = 'Browser · web research · monitor' +WHERE agent_key = 'browser'; + +UPDATE agent_souls SET soul_md = '# Hermes — Soul + +Telegram, team notify, brain vectors.', +responsibilities = 'Telegram · notify · brain' +WHERE agent_key = 'hermes'; + +UPDATE agent_souls SET soul_md = '# Email — Soul + +IMAP/SMTP Foodlinkk communicatie.', +responsibilities = 'Email sync · SMTP' +WHERE agent_key = 'email'; + +UPDATE agent_souls SET soul_md = '# Research — Soul + +Trends, weather, external data feeds.', +responsibilities = 'Research · trends · weather' +WHERE agent_key = 'research'; + +INSERT INTO herman_permissions (module_key, module_label, description, category, granted) VALUES +('ops', 'IT Ops', 'Infra monitoring & topologie', 'admin', false), +('packaging', 'Packaging Design', 'Verpakkingsontwerp studio', 'agents', false), +('social_publish', 'Social Publish', 'Multi-channel marketing publish', 'intel', false), +('social_integrations', 'Social API Keys', 'Social platform credentials', 'admin', false) +ON CONFLICT (module_key) DO NOTHING; + +UPDATE agent_souls SET avatar_emoji = COALESCE(avatar_emoji, '🖥️'), avatar_color = COALESCE(avatar_color, '#38bdf8'), avatar_mood = 'ops' WHERE agent_key = 'sysops'; +UPDATE agent_souls SET avatar_emoji = COALESCE(avatar_emoji, '📦'), avatar_color = COALESCE(avatar_color, '#f97316'), avatar_mood = 'packaging' WHERE agent_key = 'packaging'; diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 0000000..9896d76 --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + - job_name: tools-api + static_configs: + - targets: ["tools-api:8700"] + metrics_path: /metrics + - job_name: email-agent + static_configs: + - targets: ["email-agent:8801"] + metrics_path: /health diff --git a/scripts/research_refresh.py b/scripts/research_refresh.py new file mode 100644 index 0000000..c77add5 --- /dev/null +++ b/scripts/research_refresh.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Nightly research + recommendations refresh.""" +import json +import urllib.request + +TOOLS = "http://10.4.7.18:8700" + +def post(path): + req = urllib.request.Request(f"{TOOLS}{path}", method="POST", headers={"Content-Length": "0"}) + with urllib.request.urlopen(req, timeout=120) as r: + return json.loads(r.read()) + +if __name__ == "__main__": + print("research", post("/research/run")) + print("recommendations", post("/recommendations/generate")) diff --git a/tools-api/Dockerfile b/tools-api/Dockerfile new file mode 100644 index 0000000..e85765a --- /dev/null +++ b/tools-api/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8700"] diff --git a/tools-api/__init__.py b/tools-api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools-api/app/__init__.py b/tools-api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools-api/app/brain.py b/tools-api/app/brain.py new file mode 100644 index 0000000..351584d --- /dev/null +++ b/tools-api/app/brain.py @@ -0,0 +1,425 @@ +"""PostgreSQL second brain — Telegram messages, graph edges, pgvector.""" +from __future__ import annotations + +import logging +import os +from typing import Any, Optional + +import httpx + +from app.db import execute, execute_returning, fetch_all, fetch_one, json_param + +log = logging.getLogger("tools.brain") + +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.4.7.19:11434").rstrip("/") +EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text") +EMBED_DIM = 768 + + +async def embed_text(text: str) -> list[float]: + text = (text or "").strip()[:4000] + if not text: + return [] + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post( + f"{OLLAMA_URL}/api/embeddings", + json={"model": EMBED_MODEL, "prompt": text}, + ) + r.raise_for_status() + vec = r.json().get("embedding") or [] + if len(vec) != EMBED_DIM: + raise ValueError(f"embedding dim {len(vec)} != {EMBED_DIM}") + return vec + + +def _vec_param(vec: list[float]) -> str: + return "[" + ",".join(f"{x:.8f}" for x in vec) + "]" + + +def upsert_conversation( + chat_id: int, + *, + chat_type: str = "private", + user_name: str | None = None, + user_role: str | None = None, + metadata: dict | None = None, +) -> dict[str, Any]: + row = execute_returning( + """ + INSERT INTO telegram_conversations (chat_id, chat_type, user_name, user_role, metadata, updated_at) + VALUES (%s, %s, %s, %s, %s, NOW()) + ON CONFLICT (chat_id) DO UPDATE SET + user_name = COALESCE(EXCLUDED.user_name, telegram_conversations.user_name), + user_role = COALESCE(EXCLUDED.user_role, telegram_conversations.user_role), + metadata = telegram_conversations.metadata || EXCLUDED.metadata, + updated_at = NOW() + RETURNING * + """, + (chat_id, chat_type, user_name, user_role, json_param(metadata or {})), + ) + return row or {} + + +def store_message( + chat_id: int, + *, + direction: str, + content_text: str | None = None, + content_type: str = "text", + role: str = "user", + telegram_message_id: int | None = None, + reply_to_db_id: int | None = None, + agent_name: str | None = None, + content_json: dict | None = None, + user_name: str | None = None, + user_role: str | None = None, + chat_type: str = "private", +) -> dict[str, Any]: + conv = upsert_conversation( + chat_id, chat_type=chat_type, user_name=user_name, user_role=user_role + ) + row = execute_returning( + """ + INSERT INTO telegram_messages ( + conversation_id, telegram_message_id, direction, role, content_type, + content_text, content_json, reply_to_message_id, agent_name + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + conv["id"], + telegram_message_id, + direction, + role, + content_type, + (content_text or "")[:8000] or None, + json_param(content_json or {}), + reply_to_db_id, + agent_name, + ), + ) + return row or {} + + +def store_edge( + source_message_id: int, + edge_type: str, + *, + target_message_id: int | None = None, + target_entity_type: str | None = None, + target_entity_id: int | None = None, + weight: float = 1.0, + metadata: dict | None = None, +) -> dict[str, Any]: + row = execute_returning( + """ + INSERT INTO telegram_message_edges ( + source_message_id, target_message_id, target_entity_type, + target_entity_id, edge_type, weight, metadata + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + source_message_id, + target_message_id, + target_entity_type, + target_entity_id, + edge_type, + weight, + json_param(metadata or {}), + ), + ) + return row or {} + + +def store_embedding(message_id: int, vec: list[float], chunk_index: int = 0) -> None: + execute( + """ + INSERT INTO telegram_message_embeddings (message_id, chunk_index, embedding, model) + VALUES (%s, %s, %s::vector, %s) + ON CONFLICT (message_id, chunk_index) DO UPDATE SET + embedding = EXCLUDED.embedding, + model = EXCLUDED.model + """, + (message_id, chunk_index, _vec_param(vec), EMBED_MODEL), + ) + + +async def store_message_with_embedding( + chat_id: int, + *, + direction: str, + content_text: str | None = None, + **kwargs: Any, +) -> dict[str, Any]: + msg = store_message(chat_id, direction=direction, content_text=content_text, **kwargs) + text = (content_text or "").strip() + if text and len(text) >= 8: + try: + vec = await embed_text(text) + if vec: + store_embedding(msg["id"], vec) + except Exception as exc: + log.warning("embed failed msg=%s: %s", msg.get("id"), exc) + return msg + + +def get_graph(chat_id: int, limit: int = 50) -> dict[str, Any]: + conv = fetch_one("SELECT * FROM telegram_conversations WHERE chat_id = %s", (chat_id,)) + if not conv: + return {"chat_id": chat_id, "nodes": [], "edges": []} + + messages = fetch_all( + """ + SELECT id, direction, role, content_type, content_text, agent_name, + reply_to_message_id, created_at + FROM telegram_messages + WHERE conversation_id = %s + ORDER BY created_at DESC + LIMIT %s + """, + (conv["id"], limit), + ) + msg_ids = [m["id"] for m in messages] + edges: list[dict] = [] + if msg_ids: + edges = fetch_all( + """ + SELECT e.*, sm.content_text AS source_preview, tm.content_text AS target_preview + FROM telegram_message_edges e + JOIN telegram_messages sm ON sm.id = e.source_message_id + LEFT JOIN telegram_messages tm ON tm.id = e.target_message_id + WHERE e.source_message_id = ANY(%s) OR e.target_message_id = ANY(%s) + ORDER BY e.created_at DESC + LIMIT %s + """, + (msg_ids, msg_ids, limit * 2), + ) + + nodes = [ + { + "id": m["id"], + "label": (m.get("content_text") or "")[:80], + "direction": m.get("direction"), + "role": m.get("role"), + "agent": m.get("agent_name"), + "type": m.get("content_type"), + "created_at": m.get("created_at"), + } + for m in reversed(messages) + ] + edge_list = [ + { + "id": e["id"], + "from": e["source_message_id"], + "to": e.get("target_message_id"), + "type": e["edge_type"], + "entity_type": e.get("target_entity_type"), + "entity_id": e.get("target_entity_id"), + "weight": e.get("weight"), + } + for e in edges + ] + return { + "chat_id": chat_id, + "conversation_id": conv["id"], + "nodes": nodes, + "edges": edge_list, + "stats": {"messages": len(nodes), "edges": len(edge_list)}, + } + + +async def search_memory( + query: str, + *, + chat_id: int | None = None, + limit: int = 8, +) -> list[dict[str, Any]]: + query = query.strip() + if not query: + return [] + + results: list[dict[str, Any]] = [] + + try: + vec = await embed_text(query) + if vec: + v = _vec_param(vec) + if chat_id is not None: + rows = fetch_all( + """ + SELECT m.id, m.content_text, m.direction, m.agent_name, m.created_at, + c.chat_id, + 1 - (e.embedding <=> %s::vector) AS score + FROM telegram_message_embeddings e + JOIN telegram_messages m ON m.id = e.message_id + JOIN telegram_conversations c ON c.id = m.conversation_id + WHERE m.content_text IS NOT NULL AND c.chat_id = %s + ORDER BY e.embedding <=> %s::vector + LIMIT %s + """, + (v, chat_id, v, limit), + ) + else: + rows = fetch_all( + """ + SELECT m.id, m.content_text, m.direction, m.agent_name, m.created_at, + c.chat_id, + 1 - (e.embedding <=> %s::vector) AS score + FROM telegram_message_embeddings e + JOIN telegram_messages m ON m.id = e.message_id + JOIN telegram_conversations c ON c.id = m.conversation_id + WHERE m.content_text IS NOT NULL + ORDER BY e.embedding <=> %s::vector + LIMIT %s + """, + (v, v, limit), + ) + results.extend(rows) + except Exception as exc: + log.warning("vector search failed: %s", exc) + + if len(results) < limit: + params2: list[Any] = [query, limit - len(results)] + chat_clause = "" + if chat_id is not None: + chat_clause = "AND c.chat_id = %s" + params2.append(chat_id) + fts = fetch_all( + f""" + SELECT m.id, m.content_text, m.direction, m.agent_name, m.created_at, c.chat_id, + ts_rank(to_tsvector('simple', coalesce(m.content_text, '')), + plainto_tsquery('simple', %s)) AS score + FROM telegram_messages m + JOIN telegram_conversations c ON c.id = m.conversation_id + WHERE to_tsvector('simple', coalesce(m.content_text, '')) @@ plainto_tsquery('simple', %s) + {chat_clause} + ORDER BY score DESC + LIMIT %s + """, + tuple([query, query, *([chat_id] if chat_id else []), limit - len(results)]), + ) + seen = {r["id"] for r in results} + for row in fts: + if row["id"] not in seen: + results.append(row) + + return results[:limit] + +# Append to tools-api/app/brain.py + +def list_conversations(limit: int = 50) -> list[dict[str, Any]]: + return fetch_all( + """ + SELECT c.*, + (SELECT COUNT(*) FROM telegram_messages m WHERE m.conversation_id = c.id) AS message_count, + (SELECT MAX(m2.created_at) FROM telegram_messages m2 WHERE m2.conversation_id = c.id) AS last_message_at + FROM telegram_conversations c + ORDER BY (SELECT MAX(m3.created_at) FROM telegram_messages m3 WHERE m3.conversation_id = c.id) DESC NULLS LAST + LIMIT %s + """, + (limit,), + ) + + +def list_feed(*, chat_id: int | None = None, limit: int = 80, offset: int = 0) -> list[dict[str, Any]]: + if chat_id is not None: + return fetch_all( + """ + SELECT m.*, c.chat_id, c.user_name, c.user_role, + EXISTS(SELECT 1 FROM telegram_message_embeddings e WHERE e.message_id = m.id) AS has_embedding + FROM telegram_messages m + JOIN telegram_conversations c ON c.id = m.conversation_id + WHERE c.chat_id = %s + ORDER BY m.created_at DESC + LIMIT %s OFFSET %s + """, + (chat_id, limit, offset), + ) + return fetch_all( + """ + SELECT m.*, c.chat_id, c.user_name, c.user_role, + EXISTS(SELECT 1 FROM telegram_message_embeddings e WHERE e.message_id = m.id) AS has_embedding + FROM telegram_messages m + JOIN telegram_conversations c ON c.id = m.conversation_id + ORDER BY m.created_at DESC + LIMIT %s OFFSET %s + """, + (limit, offset), + ) + + +def get_dashboard_stats() -> dict[str, Any]: + stats = fetch_one( + """ + SELECT + (SELECT COUNT(*) FROM telegram_conversations) AS conversations, + (SELECT COUNT(*) FROM telegram_messages) AS messages, + (SELECT COUNT(*) FROM telegram_messages WHERE direction = 'in') AS inbound, + (SELECT COUNT(*) FROM telegram_messages WHERE direction = 'out') AS outbound, + (SELECT COUNT(*) FROM telegram_message_edges) AS edges, + (SELECT COUNT(*) FROM telegram_message_embeddings) AS embeddings + """ + ) or {} + recent = list_feed(limit=15) + events = fetch_all( + """ + SELECT id, agent_name, event_type, title, body, status, channel, created_at + FROM agent_events + WHERE channel = 'telegram' OR agent_name IN ('hermes', 'herman', 'personal_pa') + ORDER BY created_at DESC + LIMIT 20 + """ + ) + return {"stats": stats, "recent_messages": recent, "agent_events": events} + + +def get_global_graph(limit: int = 100) -> dict[str, Any]: + messages = fetch_all( + """ + SELECT m.id, m.direction, m.role, m.content_type, m.content_text, m.agent_name, + m.created_at, c.chat_id, c.user_name + FROM telegram_messages m + JOIN telegram_conversations c ON c.id = m.conversation_id + ORDER BY m.created_at DESC + LIMIT %s + """, + (limit,), + ) + if not messages: + return {"nodes": [], "edges": []} + ids = [m["id"] for m in messages] + edges = fetch_all( + """ + SELECT e.* + FROM telegram_message_edges e + WHERE e.source_message_id = ANY(%s) OR e.target_message_id = ANY(%s) + """, + (ids, ids), + ) + nodes = [ + { + "id": m["id"], + "label": (m.get("content_text") or "")[:100], + "direction": m.get("direction"), + "role": m.get("role"), + "agent": m.get("agent_name"), + "chat_id": m.get("chat_id"), + "user_name": m.get("user_name"), + "type": m.get("content_type"), + "created_at": m.get("created_at"), + } + for m in reversed(messages) + ] + edge_list = [ + { + "id": e["id"], + "from": e["source_message_id"], + "to": e.get("target_message_id"), + "type": e["edge_type"], + "entity_type": e.get("target_entity_type"), + "entity_id": e.get("target_entity_id"), + } + for e in edges + ] + return {"nodes": nodes, "edges": edge_list, "stats": {"nodes": len(nodes), "edges": len(edge_list)}} diff --git a/tools-api/app/briefing.py b/tools-api/app/briefing.py new file mode 100644 index 0000000..95006fa --- /dev/null +++ b/tools-api/app/briefing.py @@ -0,0 +1,352 @@ +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 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["crm_partnerships"] = _safe_count("supermarkets", "partnership_status = 'active'") + data["wholesalers"] = _safe_count("wholesalers") + + 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.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 6""" + ) + 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"] = [] + + 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) diff --git a/tools-api/app/comfyui.py b/tools-api/app/comfyui.py new file mode 100644 index 0000000..58884f2 --- /dev/null +++ b/tools-api/app/comfyui.py @@ -0,0 +1,396 @@ +"""ComfyUI client — HD generation with resilient progress tracking.""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import random +import uuid +from typing import Any, Optional +from urllib.parse import urlencode + +import httpx + +log = logging.getLogger("tools-api.comfyui") + +COMFYUI_URL = os.getenv("COMFYUI_URL", "http://10.4.7.18:8188").rstrip("/") +CHECKPOINT = os.getenv("COMFYUI_CHECKPOINT", "v1-5-pruned-emaonly.safetensors") +DEFAULT_STEPS = int(os.getenv("COMFYUI_STEPS", "15")) +POLL_INTERVAL = float(os.getenv("COMFYUI_POLL_INTERVAL", "2.0")) +MAX_WAIT = float(os.getenv("COMFYUI_MAX_WAIT", "1800")) +WS_IDLE_TIMEOUT = float(os.getenv("COMFYUI_WS_IDLE", "90")) + +QUALITY_PRESETS: dict[str, dict[str, Any]] = { + "fast": {"width": 512, "height": 512, "steps": 15, "label": "Snel (512px)"}, + "hd": {"width": 1024, "height": 1024, "steps": 28, "label": "HD (1024px)"}, + "ultra": {"width": 1024, "height": 1024, "steps": 35, "label": "Ultra HD (1024px, 35 steps)"}, +} + +_jobs: dict[str, dict[str, Any]] = {} + + +def resolve_quality( + quality: str | None = None, + width: int | None = None, + height: int | None = None, + steps: int | None = None, +) -> tuple[int, int, int, str]: + q = (quality or "hd").lower() + preset = QUALITY_PRESETS.get(q, QUALITY_PRESETS["hd"]) + w = width or preset["width"] + h = height or preset["height"] + s = steps or preset["steps"] + label = preset["label"] + return w, h, s, label + + +def get_job(prompt_id: str) -> dict[str, Any] | None: + return _jobs.get(prompt_id) + + +def build_workflow( + prompt: str, + negative: str = "blurry, low quality, watermark, text, ugly, deformed", + width: int = 1024, + height: int = 1024, + steps: int = DEFAULT_STEPS, + seed: Optional[int] = None, +) -> dict[str, Any]: + seed = seed if seed is not None else random.randint(1, 2**31 - 1) + return { + "3": { + "class_type": "KSampler", + "inputs": { + "seed": seed, + "steps": steps, + "cfg": 7.5, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + "model": ["4", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["5", 0], + }, + }, + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": CHECKPOINT}}, + "5": { + "class_type": "EmptyLatentImage", + "inputs": {"width": width, "height": height, "batch_size": 1}, + }, + "6": { + "class_type": "CLIPTextEncode", + "inputs": {"text": prompt, "clip": ["4", 1]}, + }, + "7": { + "class_type": "CLIPTextEncode", + "inputs": {"text": negative, "clip": ["4", 1]}, + }, + "8": { + "class_type": "VAEDecode", + "inputs": {"samples": ["3", 0], "vae": ["4", 2]}, + }, + "9": { + "class_type": "SaveImage", + "inputs": {"filename_prefix": "foodlinkk", "images": ["8", 0]}, + }, + } + + +def view_url(filename: str, subfolder: str = "", img_type: str = "output") -> str: + params = urlencode({"filename": filename, "type": img_type, "subfolder": subfolder}) + return f"{COMFYUI_URL}/view?{params}" + + +def _new_job(prompt_id: str, prompt: str, width: int, height: int, steps: int, quality: str) -> None: + _jobs[prompt_id] = { + "prompt_id": prompt_id, + "status": "queued", + "percent": 0, + "step": 0, + "max_step": steps, + "node": None, + "message": "In wachtrij bij ComfyUI…", + "prompt": prompt[:500], + "width": width, + "height": height, + "steps": steps, + "quality": quality, + "events": [], + "result": None, + "error": None, + } + + +def _append_event(prompt_id: str, message: str) -> None: + job = _jobs.get(prompt_id) + if not job: + return + job["message"] = message + events: list[str] = job.setdefault("events", []) + if not events or events[-1] != message: + events.append(message) + if len(events) > 50: + del events[: len(events) - 50] + + +async def submit_prompt(workflow: dict[str, Any]) -> tuple[str, str]: + client_id = str(uuid.uuid4()) + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{COMFYUI_URL}/prompt", + json={"prompt": workflow, "client_id": client_id}, + ) + resp.raise_for_status() + data = resp.json() + if data.get("node_errors"): + raise RuntimeError(f"ComfyUI node errors: {data['node_errors']}") + return data["prompt_id"], client_id + + +async def _prompt_in_history(prompt_id: str) -> bool: + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.get(f"{COMFYUI_URL}/history/{prompt_id}") + if resp.status_code == 200 and prompt_id in resp.json(): + return True + return False + + +async def _prompt_in_queue(prompt_id: str) -> bool: + async with httpx.AsyncClient(timeout=20.0) as client: + resp = await client.get(f"{COMFYUI_URL}/queue") + if resp.status_code != 200: + return False + data = resp.json() + for bucket in ("queue_running", "queue_pending"): + for item in data.get(bucket) or []: + if isinstance(item, (list, tuple)) and len(item) > 1 and item[1] == prompt_id: + return True + return False + + +async def _is_still_running(prompt_id: str) -> bool: + if await _prompt_in_history(prompt_id): + return False + return await _prompt_in_queue(prompt_id) + + +async def wait_for_output(prompt_id: str) -> dict[str, Any]: + deadline = asyncio.get_event_loop().time() + MAX_WAIT + tick = 0 + async with httpx.AsyncClient(timeout=30.0) as client: + while asyncio.get_event_loop().time() < deadline: + resp = await client.get(f"{COMFYUI_URL}/history/{prompt_id}") + if resp.status_code == 200: + hist = resp.json() + if prompt_id in hist: + outputs = hist[prompt_id].get("outputs") or {} + for node_out in outputs.values(): + images = node_out.get("images") or [] + if images: + img = images[0] + return { + "filename": img["filename"], + "subfolder": img.get("subfolder", ""), + "type": img.get("type", "output"), + } + tick += 1 + if tick % 15 == 0: + _append_event(prompt_id, "ComfyUI CPU render duurt even — nog bezig…") + await asyncio.sleep(POLL_INTERVAL) + raise TimeoutError(f"ComfyUI generation timed out after {int(MAX_WAIT)}s") + + +async def _track_ws(client_id: str, prompt_id: str) -> None: + try: + import websockets + except ImportError: + _append_event(prompt_id, "Polling modus (geen websocket)") + return + + ws_url = COMFYUI_URL.replace("https://", "wss://").replace("http://", "ws://") + f"/ws?clientId={client_id}" + try: + async with websockets.connect(ws_url, ping_interval=30, ping_timeout=60, close_timeout=10) as ws: + finished = False + while not finished: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=WS_IDLE_TIMEOUT) + except asyncio.TimeoutError: + if await _prompt_in_history(prompt_id): + finished = True + break + if await _is_still_running(prompt_id): + _append_event(prompt_id, "Nog bezig op CPU (geen WS update)…") + continue + break + + data = json.loads(raw) + msg_type = data.get("type") + payload = data.get("data") or {} + pid = payload.get("prompt_id") + if pid not in (None, prompt_id): + continue + + if msg_type == "execution_start": + _jobs[prompt_id]["status"] = "running" + _append_event(prompt_id, "ComfyUI gestart") + + elif msg_type == "progress": + val = int(payload.get("value") or 0) + mx = int(payload.get("max") or 1) + pct = int(100 * val / mx) if mx else 0 + _jobs[prompt_id].update( + status="running", + percent=pct, + step=val, + max_step=mx, + node=payload.get("node"), + ) + _append_event(prompt_id, f"KSampler {val}/{mx} ({pct}%)") + + elif msg_type == "executing": + node = payload.get("node") + if node is None: + _jobs[prompt_id]["status"] = "finishing" + _append_event(prompt_id, "Render klaar — opslaan…") + finished = True + else: + _jobs[prompt_id]["node"] = node + _append_event(prompt_id, f"Node {node}") + + elif msg_type == "execution_error": + err = payload.get("exception_message") or "ComfyUI execution error" + raise RuntimeError(str(err)) + except Exception as exc: + log.warning("WS tracking ended for %s: %s — falling back to poll", prompt_id, exc) + if await _prompt_in_history(prompt_id): + return + if await _is_still_running(prompt_id): + _append_event(prompt_id, "Voortgang via polling (WS verbroken)") + return + raise + + +async def _run_job( + prompt_id: str, + client_id: str, + prompt: str, + width: int, + height: int, + steps: int, + quality: str, + seed: Optional[int], +) -> None: + try: + try: + await _track_ws(client_id, prompt_id) + except Exception as ws_exc: + log.warning("WS phase issue %s: %s", prompt_id, ws_exc) + if not await _is_still_running(prompt_id) and not await _prompt_in_history(prompt_id): + raise + + img = await wait_for_output(prompt_id) + result = { + "prompt_id": prompt_id, + "filename": img["filename"], + "subfolder": img.get("subfolder", ""), + "type": img.get("type", "output"), + "image_url": view_url(img["filename"], img.get("subfolder", ""), img.get("type", "output")), + "prompt": prompt, + "width": width, + "height": height, + "steps": steps, + "quality": quality, + } + _jobs[prompt_id].update(status="done", percent=100, result=result, message="Klaar!") + _append_event(prompt_id, "Afbeelding klaar") + log.info("ComfyUI done %s (%dx%d)", prompt_id, width, height) + except Exception as exc: + log.exception("ComfyUI job failed %s", prompt_id) + if await _prompt_in_history(prompt_id): + try: + img = await wait_for_output(prompt_id) + result = { + "prompt_id": prompt_id, + "filename": img["filename"], + "subfolder": img.get("subfolder", ""), + "type": img.get("type", "output"), + "image_url": view_url(img["filename"], img.get("subfolder", ""), img.get("type", "output")), + "prompt": prompt, + "width": width, + "height": height, + "steps": steps, + "quality": quality, + } + _jobs[prompt_id].update(status="done", percent=100, result=result, message="Klaar!") + return + except Exception: + pass + _jobs[prompt_id].update(status="error", error=str(exc), message=str(exc)) + _append_event(prompt_id, f"Fout: {exc}") + + +async def start_generation( + prompt: str, + *, + negative: str = "blurry, low quality, watermark, text, ugly, deformed", + quality: str = "hd", + width: int | None = None, + height: int | None = None, + steps: int | None = None, + seed: Optional[int] = None, +) -> dict[str, Any]: + w, h, s, label = resolve_quality(quality, width, height, steps) + workflow = build_workflow(prompt, negative=negative, width=w, height=h, steps=s, seed=seed) + prompt_id, client_id = await submit_prompt(workflow) + _new_job(prompt_id, prompt, w, h, s, quality) + _append_event(prompt_id, f"Gestart — {label}") + asyncio.create_task(_run_job(prompt_id, client_id, prompt, w, h, s, quality, seed)) + return { + "prompt_id": prompt_id, + "client_id": client_id, + "quality": quality, + "width": w, + "height": h, + "steps": s, + "quality_label": label, + } + + +async def generate_image( + prompt: str, + width: int = 1024, + height: int = 1024, + steps: int = DEFAULT_STEPS, + seed: Optional[int] = None, + quality: str = "hd", +) -> dict[str, Any]: + if quality and quality != "custom": + width, height, steps, _ = resolve_quality(quality, width, height, steps) + started = await start_generation( + prompt, + quality="custom", + width=width, + height=height, + steps=steps, + seed=seed, + ) + prompt_id = started["prompt_id"] + deadline = asyncio.get_event_loop().time() + MAX_WAIT + while asyncio.get_event_loop().time() < deadline: + job = _jobs.get(prompt_id) or {} + if job.get("status") == "done" and job.get("result"): + return job["result"] + if job.get("status") == "error": + raise RuntimeError(job.get("error") or "Generation failed") + await asyncio.sleep(POLL_INTERVAL) + raise TimeoutError(f"ComfyUI generation timed out after {int(MAX_WAIT)}s") + + +async def fetch_image_bytes(filename: str, subfolder: str = "", img_type: str = "output") -> bytes: + params = {"filename": filename, "type": img_type, "subfolder": subfolder} + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.get(f"{COMFYUI_URL}/view", params=params) + resp.raise_for_status() + return resp.content diff --git a/tools-api/app/config.py b/tools-api/app/config.py new file mode 100644 index 0000000..35a2c4b --- /dev/null +++ b/tools-api/app/config.py @@ -0,0 +1,20 @@ +import os + + +class Settings: + DB_HOST: str = os.getenv("DB_HOST", "foodlinkk_db") + DB_PORT: int = int(os.getenv("DB_PORT", "5432")) + DB_USER: str = os.getenv("DB_USER", "aissa") + DB_PASSWORD: str = os.getenv("DB_PASSWORD", "Foodlinkk#2026") + DB_NAME: str = os.getenv("DB_NAME", "foodlinkk") + OLLAMA_URL: str = os.getenv("OLLAMA_URL", "http://10.4.7.19:11434") + + @property + def database_dsn(self) -> str: + return ( + f"host={self.DB_HOST} port={self.DB_PORT} dbname={self.DB_NAME} " + f"user={self.DB_USER} password={self.DB_PASSWORD}" + ) + + +settings = Settings() diff --git a/tools-api/app/connectors/__init__.py b/tools-api/app/connectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools-api/app/connectors/cbs.py b/tools-api/app/connectors/cbs.py new file mode 100644 index 0000000..e65976f --- /dev/null +++ b/tools-api/app/connectors/cbs.py @@ -0,0 +1,142 @@ +"""CBS Open Data — gemeente demografie via OData.""" +from __future__ import annotations + +import json +import re +import urllib.parse +import urllib.request +from typing import Any, Optional + +CBS_BASE = "https://opendata.cbs.nl/ODataApi/OData" +_GEMEENTE_CACHE: dict[str, dict[str, Any]] = {} + + +def _int_val(raw: Any) -> Optional[int]: + if raw is None: + return None + s = str(raw).strip().replace(".", "") + if not s or s == ".": + return None + try: + return int(s) + except ValueError: + return None + + +def _float_val(raw: Any) -> Optional[float]: + if raw is None: + return None + s = str(raw).strip() + if not s or s == ".": + return None + try: + return float(s) + except ValueError: + return None + + +def _fetch_untyped(dataset: str, filter_expr: str, top: int = 1) -> list[dict[str, Any]]: + params = urllib.parse.urlencode( + {"$filter": filter_expr, "$top": str(top), "$format": "json"}, + quote_via=urllib.parse.quote, + ) + url = f"{CBS_BASE}/{dataset}/UntypedDataSet?{params}" + with urllib.request.urlopen(url, timeout=45) as resp: + data = json.loads(resp.read().decode()) + return data.get("value", []) + + +def _normalize_gm(code: str) -> str: + code = (code or "").strip().upper() + if code.startswith("GM"): + return code + digits = re.sub(r"\D", "", code) + return f"GM{digits}" if digits else code + + +def fetch_gemeente_stats(gemeente_code: str) -> Optional[dict[str, Any]]: + gm = _normalize_gm(gemeente_code) + if not gm: + return None + if gm in _GEMEENTE_CACHE: + return _GEMEENTE_CACHE[gm] + + pop_rows = _fetch_untyped( + "03759ned", + f"RegioS eq '{gm}' and Leeftijd eq '10000' and Geslacht eq 'T001038' " + f"and BurgerlijkeStaat eq 'T001019' and substringof('2024',Perioden)", + ) + income_rows = _fetch_untyped( + "86005NED", + f"RegioS eq '{gm}' and substringof('2023',Perioden) and Geslacht eq 'T001038'", + ) + area_rows = _fetch_untyped( + "84583NED", + f"startswith(WijkenEnBuurten,'{gm}') and SoortRegio_2 eq 'Gemeente '", + ) + + population = _int_val(pop_rows[0].get("BevolkingOp1Januari_1")) if pop_rows else None + avg_income = None + median_income = None + if income_rows: + avg_income = _float_val(income_rows[0].get("GemiddeldPersoonlijkInkomen_6")) + median_income = _float_val(income_rows[0].get("MediaanPersoonlijkInkomen_7")) + if avg_income: + avg_income *= 1000 + if median_income: + median_income *= 1000 + + area = area_rows[0] if area_rows else {} + pop_area = _int_val(area.get("AantalInwoners_5")) or population + households = _int_val(area.get("HuishoudensTotaal_28")) + niet_westers = _int_val(area.get("NietWestersTotaal_18")) + marokko = _int_val(area.get("Marokko_19")) + turkije = _int_val(area.get("Turkije_22")) + suriname = _int_val(area.get("Suriname_21")) + avg_hh_size = _float_val(area.get("GemiddeldeHuishoudensgrootte_32")) + income_per_inhabitant = _float_val(area.get("GemiddeldInkomenPerInwoner_72")) + if income_per_inhabitant and not avg_income: + avg_income = income_per_inhabitant * 1000 + + muslim_proxy_pct = None + niet_westers_pct = None + if pop_area and pop_area > 0: + if marokko is not None and turkije is not None: + muslim_proxy_pct = round((marokko + turkije) / pop_area * 100, 2) + if niet_westers is not None: + niet_westers_pct = round(niet_westers / pop_area * 100, 2) + + stats = { + "gemeente_code": gm, + "population": pop_area, + "households": households, + "avg_household_size": avg_hh_size, + "avg_income": avg_income, + "median_income": median_income, + "unemployment_rate": None, + "ethnic_composition": { + "niet_westers_totaal": niet_westers, + "niet_westers_pct": niet_westers_pct, + "marokko": marokko, + "turkije": turkije, + "suriname": suriname, + }, + "religious_composition": { + "muslim_proxy_pct": muslim_proxy_pct, + "note": "Indicatief: Marokko+Turkije / bevolking (CBS Kerncijfers wijken en buurten)", + }, + "education_level": { + "laag": _int_val(area.get("OpleidingsniveauLaag_64")), + "middelbaar": _int_val(area.get("OpleidingsniveauMiddelbaar_65")), + "hoog": _int_val(area.get("OpleidingsniveauHoog_66")), + }, + "housing_type": { + "koop_pct": _float_val(area.get("Koopwoningen_40")), + "huur_pct": _float_val(area.get("HuurwoningenTotaal_41")), + }, + "car_ownership": _float_val(area.get("PersonenautoSPerHuishouden_102")), + "data_granularity": "gemeente", + "data_source": "cbs+pdok", + } + _GEMEENTE_CACHE[gm] = stats + return stats diff --git a/tools-api/app/connectors/food_trends.py b/tools-api/app/connectors/food_trends.py new file mode 100644 index 0000000..4580069 --- /dev/null +++ b/tools-api/app/connectors/food_trends.py @@ -0,0 +1,167 @@ +"""Halal vlees trends, top gerechten en food concept seeds.""" +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Any + +from app.db import fetch_all + +HALAL_MEAT_KEYWORDS = ( + "halal vlees", "halal meat", "halal kip", "halal lam", "halal rund", + "halal gehakt", "halal chicken", "halal beef", "halal slacht", + "halal certific", "vleesvervanger halal", +) + +TOP_DISH_KEYWORDS = ( + "kant-en-klaar", "kant en klaar", "ready meal", "maaltijd", "gerecht", + "curry", "stamppot", "biryani", "tagine", "lasagne", "schotel", + "meal prep", "microwave meal", "diepvries maaltijd", +) + +CONCEPT_SEEDS = [ + "Halal {dish} single-serve voor {chain} schappen in regio's met score >{score}", + "Premium halal {meat} maaltijdlijn — inspelen op trend: {trend}", + "Seizoens {dish} tray (4-portions) voor Plus/Jumbo non-listed partnership pitch", + "AH {dish} variant — benchmark tegen {competitor} koers momentum ({pct}%)", + "Halal-gap fill: {dish} + {meat} combo voor filialen zonder halal schap", +] + + +def _match_keywords(text: str, keywords: tuple[str, ...]) -> bool: + blob = (text or "").lower() + return any(k in blob for k in keywords) + + +def fetch_halal_meat_trends(limit: int = 15) -> list[dict[str, Any]]: + rows = fetch_all( + """SELECT i.title, i.link, i.summary, i.published_at, 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 + ORDER BY i.published_at DESC NULLS LAST LIMIT 200""" + ) + out = [] + for r in rows: + title = r.get("title") or "" + summary = r.get("summary") or "" + if not _match_keywords(f"{title} {summary}", HALAL_MEAT_KEYWORDS): + continue + out.append({ + "title": title, + "link": r.get("link"), + "summary": (summary or "")[:280], + "feed_name": r.get("feed_name"), + "feed_url": r.get("feed_url"), + "published_at": r.get("published_at").isoformat() if r.get("published_at") else None, + "category": "halal_vlees", + "source_url": r.get("link"), + }) + if len(out) >= limit: + break + return out + + +def fetch_top_dishes(limit: int = 12) -> list[dict[str, Any]]: + rows = fetch_all( + """SELECT i.title, i.link, i.summary, i.published_at, 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 + ORDER BY i.published_at DESC NULLS LAST LIMIT 250""" + ) + scored: dict[str, dict[str, Any]] = {} + for r in rows: + title = (r.get("title") or "").lower() + summary = (r.get("summary") or "").lower() + blob = f"{title} {summary}" + if not _match_keywords(blob, TOP_DISH_KEYWORDS): + continue + for kw in TOP_DISH_KEYWORDS: + if kw in blob: + key = kw.strip() + if key not in scored: + scored[key] = { + "dish_keyword": key, + "mentions": 0, + "latest_title": r.get("title"), + "latest_link": r.get("link"), + "feed_name": r.get("feed_name"), + "source_url": r.get("link"), + } + scored[key]["mentions"] += 1 + break + items = sorted(scored.values(), key=lambda x: x["mentions"], reverse=True)[:limit] + return items + + +def fetch_market_trend_rows(limit: int = 8) -> list[dict[str, Any]]: + rows = fetch_all( + """SELECT trend_name, description, opportunity_score, source, category, updated_at + FROM market_trends ORDER BY updated_at DESC NULLS LAST LIMIT %s""", + (limit,), + ) + out = [] + for r in rows: + out.append({ + "trend_name": r.get("trend_name"), + "description": r.get("description"), + "opportunity_score": float(r.get("opportunity_score") or 0), + "source": r.get("source") or "Foodlinkk trends", + "source_url": "/retail", + "category": r.get("category") or "markt", + }) + return out + + +def generate_concepts( + dishes: list[dict[str, Any]] | None = None, + halal_items: list[dict[str, Any]] | None = None, + market_best: dict[str, Any] | None = None, + limit: int = 6, +) -> list[dict[str, Any]]: + dishes = dishes or fetch_top_dishes(5) + halal_items = halal_items or fetch_halal_meat_trends(5) + best_chain = (market_best or {}).get("chains", ["Albert Heijn"])[0] + pct = (market_best or {}).get("change_pct", 0) + competitor = (market_best or {}).get("name", "Ahold Delhaize") + + concepts = [] + for i, tmpl in enumerate(CONCEPT_SEEDS[:limit]): + dish = dishes[i % len(dishes)]["dish_keyword"] if dishes else "kant-en-klaar maaltijd" + meat = "halal kip" if halal_items else "halal vlees" + trend = halal_items[i % len(halal_items)]["title"][:60] if halal_items else "groei halal convenience" + text = tmpl.format( + dish=dish, + meat=meat, + chain=best_chain, + score=75, + trend=trend, + competitor=competitor, + pct=pct, + ) + concepts.append({ + "id": i + 1, + "concept": text, + "based_on": { + "dish": dish, + "halal_trend": trend, + "market_signal": f"{competitor} {pct:+.1f}%" if market_best else "retail DB", + }, + "source_urls": [ + u for u in [ + dishes[i % len(dishes)].get("source_url") if dishes else None, + halal_items[i % len(halal_items)].get("source_url") if halal_items else None, + (market_best or {}).get("source_url"), + ] if u + ], + }) + return concepts + + +def food_trends_dashboard() -> dict[str, Any]: + halal = fetch_halal_meat_trends() + dishes = fetch_top_dishes() + trends = fetch_market_trend_rows() + return { + "halal_meat_trends": halal, + "top_dishes": dishes, + "market_trends": trends, + "updated_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/tools-api/app/connectors/halal_registry.py b/tools-api/app/connectors/halal_registry.py new file mode 100644 index 0000000..fbe42a5 --- /dev/null +++ b/tools-api/app/connectors/halal_registry.py @@ -0,0 +1,155 @@ +"""Halal certification registry sync and supermarket matching.""" +from __future__ import annotations + +import json +import re +import urllib.parse +import urllib.request +from typing import Any, Optional + +from app.db import execute, execute_returning, fetch_all, fetch_one, json_param + +OVERPASS_URL = "https://overpass.kumi.systems/api/interpreter" + +# Known halal-friendly retail brands (indicative — verified via certifier when possible) +HALAL_FRIENDLY_CHAINS = { + "Spar": {"has_halal_section": True, "note": "chain policy varies by franchise"}, + "Ekoplaza": {"halal_certified": False, "has_halal_section": True}, +} + + +def _fetch_overpass(query: str) -> list[dict[str, Any]]: + data = urllib.parse.urlencode({"data": query}).encode() + req = urllib.request.Request(OVERPASS_URL, data=data, method="POST") + with urllib.request.urlopen(req, timeout=120) as resp: + payload = json.loads(resp.read().decode()) + return payload.get("elements", []) + + +def sync_osm_halal_tags() -> dict[str, Any]: + """Mark supermarkets with OSM diet:halal=yes and import certification records.""" + query = ( + '[out:json][timeout:120];area["ISO3166-1"="NL"]->.nl;' + '(node["shop"="supermarket"]["diet:halal"="yes"](area.nl);' + 'way["shop"="supermarket"]["diet:halal"="yes"](area.nl););out tags center;' + ) + elements = _fetch_overpass(query) + matched = created = 0 + for el in elements: + tags = el.get("tags") or {} + external_id = f"osm:{el.get('type')}:{el.get('id')}" + store = fetch_one("SELECT id, name FROM supermarkets WHERE external_id = %s", (external_id,)) + if not store: + name = tags.get("name") or tags.get("brand") or "Unknown" + store = fetch_one( + "SELECT id, name FROM supermarkets WHERE name ILIKE %s LIMIT 1", + (f"%{name[:40]}%",), + ) + if not store: + continue + matched += 1 + execute( + """UPDATE supermarkets SET halal_certified = TRUE, has_halal_section = TRUE, + halal_certifier = COALESCE(halal_certifier, 'OSM diet:halal'), + last_updated = NOW() WHERE id = %s""", + (store["id"],), + ) + existing = fetch_one( + "SELECT id FROM halal_certifications WHERE supermarket_id = %s AND registry_source = 'osm'", + (store["id"],), + ) + if not existing: + execute_returning( + """INSERT INTO halal_certifications ( + supermarket_id, certifier, business_name, status, registry_source, + matched_confidence, raw_data + ) VALUES (%s, 'OSM', %s, 'active', 'osm', 0.85, %s) RETURNING id""", + (store["id"], store["name"], json_param(tags)), + ) + created += 1 + return {"osm_halal_elements": len(elements), "stores_matched": matched, "certs_created": created} + + +def sync_osm_contact_tags(limit: int = 500) -> dict[str, Any]: + """Pull phone/email/website/operator from OSM for existing stores.""" + stores = fetch_all( + """SELECT id, external_id, phone, email, website, manager_name + FROM supermarkets WHERE external_id LIKE %s + AND (phone IS NULL OR email IS NULL OR manager_name IS NULL) + LIMIT %s""", + ("osm:%", limit), + ) + updated = contacts = 0 + for store in stores: + parts = (store.get("external_id") or "").split(":") + if len(parts) != 3: + continue + osm_type, osm_id = parts[1], parts[2] + query = f'[out:json][timeout:30];{osm_type}({osm_id});out tags;' + try: + elements = _fetch_overpass(query) + except Exception: + continue + if not elements: + continue + tags = elements[0].get("tags") or {} + phone = tags.get("phone") or tags.get("contact:phone") + email = tags.get("email") or tags.get("contact:email") + website = tags.get("website") or tags.get("contact:website") + operator = tags.get("operator") or tags.get("contact:name") + manager = tags.get("manager") or tags.get("contact:manager") or operator + + sets, params = [], [] + if phone and not store.get("phone"): + sets.append("phone = %s"); params.append(str(phone)[:20]) + if email and not store.get("email"): + sets.append("email = %s"); params.append(str(email)[:255]) + if website and not store.get("website"): + sets.append("website = %s"); params.append(str(website)[:255]) + if manager and not store.get("manager_name"): + sets.append("manager_name = %s"); params.append(str(manager)[:255]) + if sets: + params.append(store["id"]) + execute(f"UPDATE supermarkets SET {', '.join(sets)}, last_updated = NOW() WHERE id = %s", tuple(params)) + updated += 1 + + if manager or phone or email: + existing = fetch_one( + "SELECT id FROM supermarket_contacts WHERE supermarket_id = %s AND source = 'osm' LIMIT 1", + (store["id"],), + ) + if not existing: + execute( + """INSERT INTO supermarket_contacts ( + supermarket_id, role, full_name, phone, email, source, confidence + ) VALUES (%s, 'manager', %s, %s, %s, 'osm', 0.6)""", + (store["id"], manager, phone, email), + ) + contacts += 1 + execute( + """INSERT INTO supermarket_profiles (supermarket_id, manager_name, manager_phone, + manager_email, web_data, last_scraped_at, data_completeness) + VALUES (%s,%s,%s,%s,%s,NOW(),0.4) + ON CONFLICT (supermarket_id) DO UPDATE SET + manager_name = COALESCE(EXCLUDED.manager_name, supermarket_profiles.manager_name), + manager_phone = COALESCE(EXCLUDED.manager_phone, supermarket_profiles.manager_phone), + manager_email = COALESCE(EXCLUDED.manager_email, supermarket_profiles.manager_email), + web_data = supermarket_profiles.web_data || EXCLUDED.web_data, + last_scraped_at = NOW()""", + (store["id"], manager, phone, email, json_param({"osm_tags": tags})), + ) + return {"scanned": len(stores), "stores_updated": updated, "contacts_added": contacts} + + +def list_halal_certified(limit: int = 500) -> list[dict[str, Any]]: + return fetch_all( + """ + SELECT s.*, h.certifier, h.certificate_number, h.expiry_date, h.registry_source, + h.matched_confidence + FROM supermarkets s + LEFT JOIN halal_certifications h ON h.supermarket_id = s.id AND h.status = 'active' + WHERE s.halal_certified = TRUE OR s.has_halal_section = TRUE OR h.id IS NOT NULL + ORDER BY s.chain, s.city LIMIT %s + """, + (limit,), + ) diff --git a/tools-api/app/connectors/market_stocks.py b/tools-api/app/connectors/market_stocks.py new file mode 100644 index 0000000..4044e89 --- /dev/null +++ b/tools-api/app/connectors/market_stocks.py @@ -0,0 +1,273 @@ +"""Live supermarket & food-retail stock quotes — Yahoo Finance.""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any +from urllib.parse import quote +from urllib.request import Request, urlopen + +USER_AGENT = "Foodlinkk-MarketIntel/1.0" +DATA_SOURCE = "Yahoo Finance" +SOURCE_BASE = "https://finance.yahoo.com/quote/" + +# Beursgenoteerde supermarkt / food-retail ketens (geen FMCG zoals Unilever) +LISTED_SUPERMARKET_STOCKS = [ + { + "symbol": "AD.AS", + "name": "Ahold Delhaize", + "chains": ["Albert Heijn", "Gall & Gall", "Etos", "Bol"], + "country": "NL/EU", + "exchange": "Euronext Amsterdam", + "listed": True, + "color": "#0066cc", + }, + { + "symbol": "CAR.PA", + "name": "Carrefour", + "chains": ["Carrefour", "Carrefour Express"], + "country": "EU", + "exchange": "Euronext Paris", + "listed": True, + "color": "#005baa", + }, + { + "symbol": "TSCO.L", + "name": "Tesco", + "chains": ["Tesco", "Tesco Express"], + "country": "UK", + "exchange": "London Stock Exchange", + "listed": True, + "color": "#0050aa", + }, + { + "symbol": "SBRY.L", + "name": "Sainsbury's", + "chains": ["Sainsbury's", "Argos food"], + "country": "UK", + "exchange": "London Stock Exchange", + "listed": True, + "color": "#f06c00", + }, + { + "symbol": "MRW.L", + "name": "Morrisons", + "chains": ["Morrisons"], + "country": "UK", + "exchange": "London Stock Exchange", + "listed": True, + "color": "#f5c518", + }, + { + "symbol": "MKS.L", + "name": "Marks & Spencer", + "chains": ["M&S Food"], + "country": "UK", + "exchange": "London Stock Exchange", + "listed": True, + "color": "#00663d", + }, + { + "symbol": "COLR.BR", + "name": "Colruyt Group", + "chains": ["Colruyt", "Bio-Planet", "OKay"], + "country": "BE/EU", + "exchange": "Euronext Brussels", + "listed": True, + "color": "#e30613", + }, + { + "symbol": "ICA-B.ST", + "name": "ICA Gruppen", + "chains": ["ICA", "Maxi", "Rimi"], + "country": "Nordics", + "exchange": "Nasdaq Stockholm", + "listed": True, + "color": "#e30613", + }, + { + "symbol": "KR", + "name": "Kroger", + "chains": ["Kroger", "Albertsons merger context"], + "country": "USA", + "exchange": "NYSE", + "listed": True, + "color": "#004b87", + }, + { + "symbol": "WMT", + "name": "Walmart", + "chains": ["Walmart", "Sam's Club"], + "country": "USA", + "exchange": "NYSE", + "listed": True, + "color": "#0071ce", + }, + { + "symbol": "COST", + "name": "Costco", + "chains": ["Costco Wholesale"], + "country": "USA/Global", + "exchange": "NASDAQ", + "listed": True, + "color": "#e31837", + }, +] + +# NL supermarkten — niet beursgenoteerd (transparantie voor CEO) +UNLISTED_NL_CHAINS = [ + { + "symbol": None, + "name": "Jumbo", + "chains": ["Jumbo", "Jumbo City"], + "country": "NL", + "exchange": "Familiebedrijf · niet beursgenoteerd", + "listed": False, + "parent": "Van Eerd familie", + "color": "#ffcc00", + "info_url": "https://www.jumbo.com/over-jumbo", + }, + { + "symbol": None, + "name": "Plus", + "chains": ["Plus", "Plus Compact"], + "country": "NL", + "exchange": "Coöperatief · niet beursgenoteerd", + "listed": False, + "parent": "Plus Retail (coöperatie)", + "color": "#008040", + "info_url": "https://www.plus.nl", + }, + { + "symbol": None, + "name": "Dirk van den Broek", + "chains": ["Dirk", "Dekamarkt"], + "country": "NL", + "exchange": "Privé · niet beursgenoteerd", + "listed": False, + "parent": "Schuitema / Dirk van den Broek", + "color": "#e30613", + "info_url": "https://www.dirk.nl", + }, + { + "symbol": None, + "name": "Lidl", + "chains": ["Lidl"], + "country": "NL/EU", + "exchange": "Schwarz Group · privé", + "listed": False, + "parent": "Schwarz Gruppe (DE)", + "color": "#0050aa", + "info_url": "https://www.lidl.nl", + }, + { + "symbol": None, + "name": "ALDI", + "chains": ["ALDI", "ALDI Nord/Süd"], + "country": "NL/EU", + "exchange": "Privé · niet beursgenoteerd", + "listed": False, + "parent": "Aldi Süd / Aldi Nord", + "color": "#0066b3", + "info_url": "https://www.aldi.nl", + }, +] + + +def _yahoo_url(symbol: str) -> str: + return f"{SOURCE_BASE}{quote(symbol, safe='')}" + + +def _fetch_chart(symbol: str) -> dict[str, Any]: + url = ( + f"https://query1.finance.yahoo.com/v8/finance/chart/{quote(symbol, safe='')}" + f"?interval=1d&range=1mo&includePrePost=false" + ) + req = Request(url, headers={"User-Agent": USER_AGENT}) + with urlopen(req, timeout=14) 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), + "change_abs": meta.get("regularMarketChange"), + "sparkline": [round(float(v), 2) for v in sparkline], + "market_state": meta.get("marketState") or "CLOSED", + "exchange_name": meta.get("exchangeName") or meta.get("fullExchangeName"), + "quote_time": meta.get("regularMarketTime"), + } + + +def fetch_supermarket_quotes() -> list[dict[str, Any]]: + now = datetime.now(timezone.utc).isoformat() + items: list[dict[str, Any]] = [] + + for stock in LISTED_SUPERMARKET_STOCKS: + row = dict(stock) + sym = stock["symbol"] + row["source"] = DATA_SOURCE + row["source_url"] = _yahoo_url(sym) + row["chart_api"] = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}" + row["fetched_at"] = now + try: + chart = _fetch_chart(sym) + row.update(chart) + row["trend"] = "up" if (row.get("change_pct") or 0) >= 0 else "down" + row["live"] = row.get("price") is not None + except Exception as exc: # noqa: BLE001 + row["error"] = str(exc)[:100] + row["price"] = None + row["change_pct"] = 0 + row["sparkline"] = [] + row["trend"] = "flat" + row["live"] = False + items.append(row) + + for chain in UNLISTED_NL_CHAINS: + row = dict(chain) + row["source"] = "Foodlinkk Intel" + row["source_url"] = chain.get("info_url") + row["fetched_at"] = now + row["live"] = False + row["price"] = None + row["change_pct"] = None + row["note"] = "Niet beursgenoteerd — geen live koers beschikbaar" + items.append(row) + + return items + + +def fetch_retail_quotes() -> list[dict[str, Any]]: + """Back-compat — alleen beursgenoteerde supermarkt-aandelen.""" + return [q for q in fetch_supermarket_quotes() if q.get("listed")] + + +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), + "listed_count": len(LISTED_SUPERMARKET_STOCKS), + "unlisted_nl_count": len(UNLISTED_NL_CHAINS), + "data_source": DATA_SOURCE, + "updated_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/tools-api/app/connectors/pdok.py b/tools-api/app/connectors/pdok.py new file mode 100644 index 0000000..55917b4 --- /dev/null +++ b/tools-api/app/connectors/pdok.py @@ -0,0 +1,56 @@ +"""PDOK Locatieserver — postcode geocoding.""" +from __future__ import annotations + +import json +import re +import urllib.parse +import urllib.request +from typing import Any, Optional + +PDOK_URL = "https://api.pdok.nl/bzk/locatieserver/search/v3_1/free" +POSTCODE_RE = re.compile(r"^(\d{4})\s?([A-Za-z]{2})$") + + +def normalize_postcode(raw: str) -> str: + cleaned = (raw or "").strip().upper().replace(" ", "") + m = POSTCODE_RE.match(cleaned) + if m: + return f"{m.group(1)}{m.group(2)}" + return cleaned[:6] if cleaned else "" + + +def _parse_point(value: Optional[str]) -> tuple[Optional[float], Optional[float]]: + if not value or "POINT" not in value: + return None, None + nums = re.findall(r"[-+]?\d*\.?\d+", value) + if len(nums) >= 2: + return float(nums[1]), float(nums[0]) # lat, lon + return None, None + + +def lookup_postcode(postcode: str) -> Optional[dict[str, Any]]: + pc = normalize_postcode(postcode) + if len(pc) < 6: + return None + q = urllib.parse.urlencode({"q": pc, "rows": 1, "fq": "type:postcode"}) + with urllib.request.urlopen(f"{PDOK_URL}?{q}", timeout=20) as resp: + data = json.loads(resp.read().decode()) + docs = data.get("response", {}).get("docs", []) + if not docs: + return None + doc = docs[0] + lat, lon = _parse_point(doc.get("centroide_ll")) + gemeente_code = (doc.get("gemeentecode") or "").strip() + if gemeente_code and not gemeente_code.startswith("GM"): + gemeente_code = f"GM{gemeente_code}" + return { + "postcode": pc, + "city": (doc.get("woonplaatsnaam") or "").strip(), + "province": (doc.get("provincienaam") or "").strip(), + "province_code": (doc.get("provinciecode") or "").strip(), + "municipality": (doc.get("gemeentenaam") or "").strip(), + "municipality_code": gemeente_code, + "street": (doc.get("straatnaam") or "").strip(), + "latitude": lat, + "longitude": lon, + } diff --git a/tools-api/app/connectors/proxmox.py b/tools-api/app/connectors/proxmox.py new file mode 100644 index 0000000..5d98d11 --- /dev/null +++ b/tools-api/app/connectors/proxmox.py @@ -0,0 +1,381 @@ +"""Proxmox infrastructure monitoring connector for Foodlinkk IT Ops.""" +from __future__ import annotations + +import json +import os +import shlex +import socket +import ssl +import subprocess +import time +from datetime import datetime, timezone +from typing import Any +from urllib.error import URLError +from urllib.request import Request, urlopen + +from app.db import execute, fetch_all, fetch_one + +PROXMOX_HOST = "10.4.7.14" +PROXMOX_API_URL = f"https://{PROXMOX_HOST}:8006/api2/json" +SSH_USER = "aissa" +SSH_PASSWORD = "Foodlinkk#2026" + +VM_105_IP = "10.4.7.19" +VM_106_IP = "10.4.7.18" + +SERVICE_LAYOUT: list[dict[str, Any]] = [ + {"id": "svc-cockpit", "label": "cockpit:8600", "host": VM_106_IP, "parent": "vm106-command", "port": 8600}, + {"id": "svc-tools-api", "label": "tools-api:8700", "host": VM_106_IP, "parent": "vm106-command", "port": 8700}, + {"id": "svc-email-agent", "label": "email-agent:8801", "host": VM_106_IP, "parent": "vm106-command", "port": 8801}, + {"id": "svc-gitea", "label": "gitea:3001", "host": VM_105_IP, "parent": "vm105-hermes", "port": 3001}, + {"id": "svc-ollama", "label": "ollama:11434", "host": VM_105_IP, "parent": "vm105-hermes", "port": 11434}, +] + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _run_ssh(host: str, command: str, timeout: int = 12) -> dict[str, Any]: + ssh_cmd = [ + "sshpass", + "-p", + SSH_PASSWORD, + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=7", + f"{SSH_USER}@{host}", + command, + ] + try: + proc = subprocess.run( # noqa: S603 + ssh_cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + return {"ok": False, "error": f"ssh tooling missing: {exc}"} + except subprocess.TimeoutExpired: + return {"ok": False, "error": "ssh timeout"} + return { + "ok": proc.returncode == 0, + "code": proc.returncode, + "stdout": (proc.stdout or "").strip(), + "stderr": (proc.stderr or "").strip(), + } + + +def _http_get_json(url: str, headers: dict[str, str] | None = None, timeout: int = 8) -> dict[str, Any]: + req = Request(url, headers=headers or {}) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with urlopen(req, timeout=timeout, context=ctx) as resp: # noqa: S310 + payload = resp.read().decode("utf-8") + return json.loads(payload) + + +def _build_token_header(token_value: str) -> str: + val = token_value.strip() + if val.startswith("PVEAPIToken="): + return val + return f"PVEAPIToken={val}" + + +def _create_api_token_via_ssh() -> str | None: + token_name = f"ops{int(time.time())}" + cmd = ( + f"pveum user token add {shlex.quote(SSH_USER + '@pam')} {shlex.quote(token_name)} " + "--privsep 0 --expire 0 --output-format json" + ) + result = _run_ssh(PROXMOX_HOST, cmd, timeout=15) + if not result.get("ok"): + return None + try: + parsed = json.loads(result.get("stdout") or "{}") + except json.JSONDecodeError: + return None + tokenid = parsed.get("full-tokenid") + secret = parsed.get("value") + if tokenid and secret: + return f"PVEAPIToken={tokenid}={secret}" + return None + + +def _fetch_nodes_via_api() -> tuple[list[dict[str, Any]], str, str | None]: + token = os.getenv("PROXMOX_TOKEN") + tried = [] + if token: + tried.append("env-token") + try: + data = _http_get_json( + f"{PROXMOX_API_URL}/nodes", + headers={"Authorization": _build_token_header(token)}, + ) + return data.get("data") or [], "api-token-env", None + except Exception as exc: # noqa: BLE001 + tried.append(f"env-failed:{exc}") + created = _create_api_token_via_ssh() + if created: + tried.append("ssh-created-token") + try: + data = _http_get_json( + f"{PROXMOX_API_URL}/nodes", + headers={"Authorization": created}, + ) + return data.get("data") or [], "api-token-ssh", None + except Exception as exc: # noqa: BLE001 + tried.append(f"ssh-token-failed:{exc}") + return [], "none", ", ".join(tried) if tried else "no-token" + + +def _fetch_nodes_via_ssh() -> tuple[list[dict[str, Any]], str, str | None]: + pvesh = _run_ssh(PROXMOX_HOST, "pvesh get /nodes --output-format json") + if pvesh.get("ok"): + try: + return json.loads(pvesh["stdout"] or "[]"), "ssh-pvesh", None + except json.JSONDecodeError: + pass + qm = _run_ssh(PROXMOX_HOST, "qm list") + rows: list[dict[str, Any]] = [] + if qm.get("ok") and qm.get("stdout"): + lines = (qm["stdout"] or "").splitlines() + for line in lines[1:]: + parts = line.split() + if not parts: + continue + vmid = parts[0] + rows.append( + { + "node": "pve", + "type": "qemu", + "id": f"qemu/{vmid}", + "vmid": int(vmid) if vmid.isdigit() else vmid, + "status": parts[2] if len(parts) > 2 else "unknown", + } + ) + return rows, "ssh-qm-list", None + err = pvesh.get("stderr") or qm.get("stderr") or "ssh lookup failed" + return [], "none", err + + +def _port_health(host: str, port: int, timeout: float = 1.5) -> bool: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + return sock.connect_ex((host, port)) == 0 + finally: + sock.close() + + +def check_docker_services() -> dict[str, Any]: + result = _run_ssh(VM_106_IP, "docker ps --format json") + method = "docker-ps-json" + if not result.get("ok"): + result = _run_ssh(VM_106_IP, "docker ps --format '{{json .}}'") + method = "docker-ps-template-json" + if not result.get("ok"): + return {"ok": False, "source": method, "error": result.get("stderr") or "docker check failed", "containers": []} + + containers: list[dict[str, Any]] = [] + for line in (result.get("stdout") or "").splitlines(): + line = line.strip() + if not line: + continue + try: + parsed = json.loads(line) + containers.append(parsed if isinstance(parsed, dict) else {"raw": parsed}) + except json.JSONDecodeError: + containers.append({"raw": line}) + return {"ok": True, "source": method, "containers": containers} + + +def get_topology() -> dict[str, Any]: + api_nodes, api_source, api_error = _fetch_nodes_via_api() + ssh_nodes: list[dict[str, Any]] = [] + ssh_source = "none" + ssh_error: str | None = None + if not api_nodes: + ssh_nodes, ssh_source, ssh_error = _fetch_nodes_via_ssh() + + api_node = next((n for n in api_nodes if (n.get("node") or "").strip()), None) if api_nodes else None + host_cpu = float(api_node.get("cpu", 0)) if api_node else 0.0 + host_mem = float(api_node.get("mem", 0)) if api_node else 0.0 + host_status = api_node.get("status") if api_node else "unknown" + if host_status == "unknown" and ssh_nodes: + host_status = "online" + + vm_states: dict[str, str] = {"105": "unknown", "106": "unknown"} + source_rows = api_nodes or ssh_nodes + for row in source_rows: + vmid = str(row.get("vmid") or "").strip() + if vmid in vm_states: + vm_states[vmid] = str(row.get("status") or "unknown") + + docker_state = check_docker_services() + docker_names = { + str(c.get("Names") or c.get("Names.0") or c.get("Name") or "").lower(): c for c in docker_state.get("containers", []) + } + + vm105_children: list[dict[str, Any]] = [] + vm106_children: list[dict[str, Any]] = [] + for svc in SERVICE_LAYOUT: + up = _port_health(str(svc["host"]), int(svc["port"])) + hinted = "unknown" + for name, details in docker_names.items(): + if svc["label"].split(":")[0].replace("-", "") in name.replace("-", ""): + hinted = str(details.get("State") or details.get("Status") or "running") + break + item = { + "id": svc["id"], + "label": svc["label"], + "type": "service", + "status": "online" if up else "offline", + "cpu": None, + "mem": None, + "host": svc["host"], + "hint": hinted, + "children": [], + } + if svc["parent"] == "vm105-hermes": + vm105_children.append(item) + else: + vm106_children.append(item) + + topology_nodes = [ + { + "id": "proxmox-host", + "label": f"proxmox-host ({PROXMOX_HOST})", + "type": "proxmox", + "status": host_status, + "cpu": host_cpu, + "mem": host_mem, + "children": [ + { + "id": "vm105-hermes", + "label": f"vm105-hermes ({VM_105_IP})", + "type": "vm", + "status": vm_states["105"], + "cpu": None, + "mem": None, + "children": vm105_children, + }, + { + "id": "vm106-command", + "label": f"vm106-command ({VM_106_IP})", + "type": "vm", + "status": vm_states["106"], + "cpu": None, + "mem": None, + "children": vm106_children, + }, + ], + } + ] + return { + "generated_at": _iso_now(), + "nodes": topology_nodes, + "meta": { + "proxmox_host": PROXMOX_HOST, + "api_source": api_source, + "api_error": api_error, + "ssh_source": ssh_source, + "ssh_error": ssh_error, + "docker_source": docker_state.get("source"), + "docker_ok": docker_state.get("ok", False), + }, + } + + +def get_status_summary() -> dict[str, Any]: + topo = get_topology() + flat: list[dict[str, Any]] = [] + + def _collect(node: dict[str, Any]) -> None: + flat.append(node) + for child in node.get("children") or []: + _collect(child) + + for root in topo.get("nodes") or []: + _collect(root) + + total = len(flat) + online = sum(1 for n in flat if str(n.get("status")).lower() in {"online", "running", "up"}) + degraded = sum(1 for n in flat if str(n.get("status")).lower() in {"unknown", "degraded"}) + offline = max(0, total - online - degraded) + return { + "generated_at": topo.get("generated_at"), + "health": "healthy" if offline == 0 else ("degraded" if online > 0 else "down"), + "counts": {"total": total, "online": online, "degraded": degraded, "offline": offline}, + "sources": topo.get("meta", {}), + "topology": topo, + } + + +def _table_exists(table_name: str) -> bool: + row = fetch_one( + """ + SELECT EXISTS( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = %s + ) AS ok + """, + (table_name,), + ) + return bool(row and row.get("ok")) + + +def poll_and_snapshot() -> dict[str, Any]: + status = get_status_summary() + if not _table_exists("infra_snapshots"): + return {"ok": False, "saved": False, "reason": "infra_snapshots table not found", "status": status} + + cols = fetch_all( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'infra_snapshots' + ORDER BY ordinal_position + """ + ) + colset = {c.get("column_name") for c in cols} + payload = { + "source": "proxmox", + "topology": status.get("topology"), + "summary": {k: v for k, v in status.items() if k != "topology"}, + "generated_at": status.get("generated_at"), + } + + value_map: dict[str, Any] = {} + if "source" in colset: + value_map["source"] = "proxmox" + if "provider" in colset: + value_map["provider"] = "proxmox" + if "snapshot" in colset: + value_map["snapshot"] = json.dumps(payload) + if "payload" in colset: + value_map["payload"] = json.dumps(payload) + if "topology" in colset: + value_map["topology"] = json.dumps(status.get("topology")) + if "summary" in colset: + value_map["summary"] = json.dumps({k: v for k, v in status.items() if k != "topology"}) + if "created_at" in colset: + value_map["created_at"] = datetime.now(timezone.utc) + + if not value_map: + return {"ok": False, "saved": False, "reason": "infra_snapshots has no compatible columns", "status": status} + + columns = list(value_map.keys()) + placeholders = ", ".join(["%s"] * len(columns)) + sql = f"INSERT INTO infra_snapshots ({', '.join(columns)}) VALUES ({placeholders})" + execute(sql, tuple(value_map[c] for c in columns)) + return {"ok": True, "saved": True, "columns": columns, "status": status} diff --git a/tools-api/app/connectors/reclamefolder.py b/tools-api/app/connectors/reclamefolder.py new file mode 100644 index 0000000..dd3c944 --- /dev/null +++ b/tools-api/app/connectors/reclamefolder.py @@ -0,0 +1,211 @@ +"""Fetch live supermarket folders from reclamefolder.nl — all chains.""" +from __future__ import annotations + +import json +import re +import xml.etree.ElementTree as ET +from datetime import date, datetime, timezone +from typing import Any, Optional +from urllib.parse import unquote +from urllib.request import HTTPCookieProcessor, Request, build_opener + +from app.db import execute, fetch_all, fetch_one + +BASE = "https://www.reclamefolder.nl" +SUPERMARKT_URL = f"{BASE}/categorieen/supermarkt/" +SITEMAP_RETAILERS = f"{BASE}/sitemap/retailers.xml" +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + +SUPERMARKT_KEYWORDS = ( + "supermarkt", "markt", "ah", "jumbo", "lidl", "aldi", "plus", "dirk", + "coop", "boni", "vomar", "deka", "ekoplaza", "mitra", "spar", "hoogvliet", + "food", "gall", "poiesz", "nettorama", +) + +_opener = None + + +def _get_opener(): + global _opener + if _opener is None: + _opener = build_opener(HTTPCookieProcessor()) + return _opener + + +def _fetch_html(url: str) -> str: + headers = {"User-Agent": USER_AGENT, "Accept-Language": "nl-NL,nl;q=0.9"} + html = _get_opener().open(Request(url, headers=headers), timeout=35).read().decode("utf-8", "ignore") + if "__NEXT_DATA__" not in html: + cb = re.search(r"decodeURIComponent\('([^']+)'\)", html) + if cb: + html = _get_opener().open(Request(unquote(cb.group(1)), headers=headers), timeout=35).read().decode("utf-8", "ignore") + return html + + +def _fetch_page_props(url: str) -> dict[str, Any]: + html = _fetch_html(url) + match = re.search(r'', html) + if not match: + return {} + data = json.loads(match.group(1)) + return data.get("props", {}).get("pageProps", {}) or {} + + +def _parse_day(raw: Optional[str]) -> Optional[date]: + if not raw: + return None + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).date() + except Exception: + return None + + +def _folder_item(row: dict[str, Any], source: str = "category") -> Optional[dict[str, Any]]: + retailer = row.get("retailer") or {} + if source == "retailer_page": + chain = retailer.get("name") or row.get("name", "") + edition_id = row.get("id") + valid_label = "" + cover = row.get("cover") or {} + folder_name = row.get("name") or "Folder" + else: + chain = retailer.get("name") + edition_id = row.get("editionId") or row.get("id") + valid_label = row.get("validLabel") or "" + cover = row.get("cover") or {} + folder_name = valid_label or "Folder" + + if not edition_id or not chain: + return None + + valid_to = _parse_day(row.get("validThru")) + today = datetime.now(timezone.utc).date() + if valid_to and valid_to < today: + return None + + permaname = retailer.get("permaname") or "" + url = f"{BASE}/f/folders/{edition_id}/" + title = f"{chain} — {folder_name}" if folder_name != chain else f"{chain} folder ({valid_label})".strip(" ()") + + return { + "chain": chain, + "title": title, + "folder_path": url, + "folder_label": valid_label or folder_name or chain, + "description": f"Reclamefolder.nl · {valid_label or folder_name}".strip(" ·"), + "valid_from": _parse_day(row.get("validFrom")), + "valid_to": valid_to, + "image_url": cover.get("imageUrl") if isinstance(cover, dict) else None, + "status": "active", + "promo_type": row.get("name") or "folder", + "source": "reclamefolder.nl", + "metadata": { + "edition_id": str(edition_id), + "version_id": str(row.get("versionId") or row.get("id") or ""), + "retailer_permaname": permaname, + "retailer_url": f"{BASE}/winkels/{permaname}/" if permaname else "", + "folder_type": row.get("name") or "folder", + }, + } + + +def fetch_retailer_slugs() -> list[str]: + try: + req = Request(SITEMAP_RETAILERS, headers={"User-Agent": USER_AGENT}) + data = _get_opener().open(req, timeout=45).read() + text = data.decode("utf-8", "ignore") + slugs = set() + for m in re.finditer(r"https://www\.reclamefolder\.nl/winkels/([a-z0-9-]+)/", text): + slug = m.group(1) + if "vestiging" in slug: + continue + if any(k in slug for k in SUPERMARKET_KEYWORDS): + slugs.add(slug) + return sorted(slugs) + except Exception: + return [ + "albert-heijn", "jumbo", "lidl", "plus", "dirk", "aldi", "dekamarkt", + "ekoplaza", "vomar", "coop-supermarkten", "boni-supermarkt", "mitra", + ] + + +def fetch_supermarkt_folders(include_all_retailers: bool = True) -> list[dict[str, Any]]: + seen: set[str] = set() + items: list[dict[str, Any]] = [] + + props = _fetch_page_props(SUPERMARKT_URL) + for row in props.get("foldersFromProps") or []: + item = _folder_item(row, "category") + if item and item["metadata"]["edition_id"] not in seen: + seen.add(item["metadata"]["edition_id"]) + items.append(item) + + if include_all_retailers: + for slug in fetch_retailer_slugs(): + try: + rprops = _fetch_page_props(f"{BASE}/winkels/{slug}/") + retailer = rprops.get("retailer") or {} + for folder in retailer.get("folders") or []: + folder = dict(folder) + folder["retailer"] = retailer + item = _folder_item(folder, "retailer_page") + if item and item["metadata"]["edition_id"] not in seen: + seen.add(item["metadata"]["edition_id"]) + items.append(item) + except Exception: + continue + + items.sort(key=lambda x: (x.get("chain") or "", x.get("valid_to") or date.max)) + return items + + +def sync_to_db() -> dict[str, Any]: + folders = fetch_supermarkt_folders() + execute( + "UPDATE promo_campaigns SET status = 'expired', updated_at = NOW() WHERE source = 'reclamefolder.nl'" + ) + inserted = 0 + chains: set[str] = set() + for f in folders: + fetch_one( + """INSERT INTO promo_campaigns + (chain, title, folder_path, folder_label, description, valid_from, valid_to, + status, promo_type, image_url, source, metadata) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb) + RETURNING id""", + ( + f["chain"], f["title"], f["folder_path"], f["folder_label"], + f["description"], f["valid_from"], f["valid_to"], + f["status"], f["promo_type"], f.get("image_url"), + f["source"], json.dumps(f["metadata"]), + ), + ) + inserted += 1 + chains.add(f["chain"]) + return { + "ok": True, + "source": "reclamefolder.nl", + "synced": inserted, + "chains": len(chains), + "items": folders, + } + + +def list_cached(limit: int = 200) -> list[dict[str, Any]]: + rows = fetch_all( + """SELECT * FROM promo_campaigns + WHERE source = 'reclamefolder.nl' AND status = 'active' + ORDER BY valid_to ASC NULLS LAST, chain ASC + LIMIT %s""", + (limit,), + ) + return [dict(r) for r in rows] + + +def list_chains() -> list[str]: + rows = fetch_all( + """SELECT DISTINCT chain FROM promo_campaigns + WHERE source = 'reclamefolder.nl' AND status = 'active' AND chain IS NOT NULL + ORDER BY chain""" + ) + return [r["chain"] for r in rows if r.get("chain")] diff --git a/tools-api/app/connectors/retail_360_routes.py b/tools-api/app/connectors/retail_360_routes.py new file mode 100644 index 0000000..58fa692 --- /dev/null +++ b/tools-api/app/connectors/retail_360_routes.py @@ -0,0 +1,239 @@ +"""Retail 360 workspace API — notes, media, milestones, RSS, wholesalers.""" +from __future__ import annotations + +import json +import urllib.request +from datetime import datetime +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from app.db import fetch_all, fetch_one +from app.middleware import log_agent_event +from app import retail_360 +from app import wholesaler_scrapers +from app.connectors import market_stocks, rss_feeds + +router = APIRouter(prefix="/retail", tags=["retail-360"]) + + +class NoteIn(BaseModel): + body: str = Field(..., min_length=1) + title: Optional[str] = None + note_type: str = "general" + + +class MilestoneIn(BaseModel): + title: str + milestone_type: str = "custom" + client_id: Optional[int] = None + deal_id: Optional[int] = None + target_date: Optional[str] = None + value_eur: Optional[float] = None + notes: Optional[str] = None + + +class OwnershipIn(BaseModel): + new_owner: str + previous_owner: Optional[str] = None + change_type: str = "acquisition" + effective_date: Optional[str] = None + source: Optional[str] = None + notes: Optional[str] = None + + +class CalendarIn(BaseModel): + title: str + starts_at: str + description: Optional[str] = None + ends_at: Optional[str] = None + client_id: Optional[int] = None + deal_id: Optional[int] = None + location: Optional[str] = None + + +class MediaIn(BaseModel): + filename: str + storage_path: str + content_type: str = "image/jpeg" + caption: Optional[str] = None + + +def _row(row: dict | None) -> dict[str, Any]: + if not row: + raise HTTPException(404, "Not found") + out: dict[str, Any] = {} + for k, v in row.items(): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + elif v is not None and hasattr(v, "__float__") and type(v).__name__ == "Decimal": + out[k] = float(v) + else: + out[k] = v + return out + + +def _fetch_weather_forecast(lat: float, lon: float) -> list[dict[str, Any]]: + url = ( + f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"&daily=temperature_2m_max,precipitation_sum,weathercode" + f"&timezone=Europe%2FAmsterdam&forecast_days=7" + ) + try: + with urllib.request.urlopen(url, timeout=15) as resp: + data = json.loads(resp.read().decode()) + days = data.get("daily", {}).get("time", []) + temps = data.get("daily", {}).get("temperature_2m_max", []) + prec = data.get("daily", {}).get("precipitation_sum", []) + return [ + {"date": days[i], "temperature_c": temps[i] if i < len(temps) else None, + "precipitation_mm": prec[i] if i < len(prec) else None, "source": "open-meteo-live"} + for i in range(len(days)) + ] + except Exception: + return [] + + +@router.get("/360/{store_id}") +def get_360_view(store_id: int) -> dict[str, Any]: + try: + data = retail_360.get_store_360(store_id) + except ValueError as exc: + raise HTTPException(404, str(exc)) from exc + store = data["store"] + if store.get("latitude") and store.get("longitude"): + live = _fetch_weather_forecast(float(store["latitude"]), float(store["longitude"])) + if live: + data["weather_forecast"] = live + for key in ("notes", "media", "milestones", "ownership_changes", "calendar", "weather"): + data[key] = [_row(x) for x in data.get(key, [])] + if data.get("area_analysis"): + data["area_analysis"] = _row(data["area_analysis"]) + return data + + +@router.post("/360/{store_id}/notes") +def add_store_note(store_id: int, payload: NoteIn) -> dict[str, Any]: + note = retail_360.add_note("supermarket", store_id, payload.body, payload.title, payload.note_type) + log_agent_event(agent_name="retail_360", event_type="note", title=f"Note on store {store_id}") + return {"note": _row(note)} + + +@router.post("/360/{store_id}/milestones") +def add_store_milestone(store_id: int, payload: MilestoneIn) -> dict[str, Any]: + ms = retail_360.add_milestone(store_id, payload.title, payload.milestone_type, **payload.model_dump(exclude={"title", "milestone_type"})) + return {"milestone": _row(ms)} + + +@router.post("/360/{store_id}/ownership") +def add_store_ownership(store_id: int, payload: OwnershipIn) -> dict[str, Any]: + store = fetch_one("SELECT chain FROM supermarkets WHERE id = %s", (store_id,)) + row = retail_360.add_ownership(entity_id=store_id, chain=store.get("chain") if store else None, **payload.model_dump()) + return {"ownership": _row(row)} + + +@router.post("/360/{store_id}/calendar") +def add_store_calendar(store_id: int, payload: CalendarIn) -> dict[str, Any]: + ev = retail_360.add_calendar_event(store_id, payload.title, payload.starts_at, **payload.model_dump(exclude={"title", "starts_at"})) + return {"event": _row(ev)} + + +@router.post("/360/{store_id}/media") +def register_store_media(store_id: int, payload: MediaIn) -> dict[str, Any]: + media = retail_360.register_media("supermarket", store_id, payload.filename, payload.storage_path, payload.content_type, payload.caption) + return {"media": _row(media)} + + +@router.get("/cities") +def list_cities(limit: int = Query(200, ge=1, le=1000)) -> dict[str, Any]: + rows = fetch_all( + """SELECT c.*, (SELECT COUNT(*) FROM supermarkets s WHERE s.city ILIKE c.city) AS store_count + FROM city_demographics c ORDER BY c.population DESC NULLS LAST LIMIT %s""", + (limit,), + ) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.post("/cities/sync") +def sync_cities(limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: + return retail_360.sync_city_demographics(limit) + + +@router.get("/wholesalers") +def list_wholesalers(limit: int = Query(500, ge=1, le=2000), q: Optional[str] = None) -> dict[str, Any]: + clauses, params = [], [] + if q: + clauses.append("(name ILIKE %s OR city ILIKE %s OR address ILIKE %s)") + like = f"%{q}%" + params.extend([like, like, like]) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + rows = fetch_all(f"SELECT * FROM wholesalers{where} ORDER BY name LIMIT %s", tuple(params + [limit])) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.post("/wholesalers/import") +def import_wholesalers(background: bool = Query(False)) -> dict[str, Any]: + log_agent_event(agent_name="wholesale_scraper", event_type="import", title="OSM wholesalers import") + if background: + import threading + threading.Thread(target=wholesaler_scrapers.import_wholesalers, daemon=True).start() + return {"status": "started", "message": "Wholesaler import running in background"} + return wholesaler_scrapers.import_wholesalers() + + +@router.get("/rss/live") +def rss_live(limit: int = Query(30, ge=1, le=100), category: Optional[str] = None) -> dict[str, Any]: + rows = rss_feeds.list_live_feed(limit, category) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.post("/rss/refresh") +def rss_refresh() -> dict[str, Any]: + log_agent_event(agent_name="rss_feeds", event_type="refresh", title="RSS feeds refresh") + return rss_feeds.refresh_all_feeds() + + +@router.get("/market/stocks") +def retail_market_stocks() -> dict[str, Any]: + quotes = market_stocks.fetch_retail_quotes() + return { + "items": quotes, + "summary": market_stocks.market_summary(quotes), + "updated_at": datetime.utcnow().isoformat(), + } + + +@router.get("/regulations") +def retail_regulations(limit: int = Query(30, ge=1, le=100)) -> dict[str, Any]: + reg = rss_feeds.list_live_feed(limit, "regelgeving") + cbs = rss_feeds.list_live_feed(limit, "cbs") + markt = rss_feeds.list_live_feed(min(limit, 15), "markt") + return { + "regelgeving": [_row(r) for r in reg], + "cbs": [_row(r) for r in cbs], + "markt": [_row(r) for r in markt], + "updated_at": datetime.utcnow().isoformat(), + } + + +@router.get("/live-dashboard") +def live_dashboard() -> dict[str, Any]: + trends = fetch_all( + "SELECT * FROM market_trends ORDER BY updated_at DESC NULLS LAST LIMIT 8" + ) + rss = rss_feeds.list_live_feed(12) + 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""" + ) + quotes = market_stocks.fetch_retail_quotes() + return { + "trends": [_row(t) for t in trends], + "rss": [_row(r) for r in rss], + "top_opportunities": [_row(o) for o in opportunities], + "market_stocks": quotes, + "market_summary": market_stocks.market_summary(quotes), + "updated_at": datetime.utcnow().isoformat(), + } diff --git a/tools-api/app/connectors/rss_feeds.py b/tools-api/app/connectors/rss_feeds.py new file mode 100644 index 0000000..92f019e --- /dev/null +++ b/tools-api/app/connectors/rss_feeds.py @@ -0,0 +1,152 @@ +"""RSS feed ingestion — filtered for kant-en-klaar & supermarkt only.""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Any, Optional +from urllib.request import Request, urlopen + +from app.db import execute, execute_returning, fetch_all, fetch_one + +USER_AGENT = "Foodlinkk-Intel/1.0" + +INCLUDE_KEYWORDS = ( + "kant en klaar", "kant-en-klaar", "kant&klaa", "ready meal", "ready-to-eat", + "maaltijd", "maaltijden", "supermarkt", "supermarket", "retail", "jumbo", + "albert heijn", "ah ", " plus ", "lidl", "aldi", "dirk", "halal", + "convenience", "schap", "filiaal", "foodservice", "vers", "meal", + "grocery", "food retail", "kant-en-klaar", +) + +EXCLUDE_KEYWORDS = ( + "voetbal", "sport", "politiek", "verkiezing", "trump", "bbc", "oorlog", + "crypto", "bitcoin", "aandelenbeurs", "beurs ", "weerbericht", +) + + +def _parse_date(raw: Optional[str]) -> Optional[datetime]: + if not raw: + return None + try: + return parsedate_to_datetime(raw).astimezone(timezone.utc) + except Exception: + pass + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return None + + +def _strip_html(text: str) -> str: + return re.sub(r"<[^>]+>", "", text or "").strip()[:2000] + + +def is_relevant(title: str, summary: Optional[str] = None) -> bool: + blob = f"{title} {summary or ''}".lower() + for bad in EXCLUDE_KEYWORDS: + if bad in blob: + return False + for good in INCLUDE_KEYWORDS: + if good in blob: + return True + return False + + +def _fetch_xml(url: str) -> ET.Element: + req = Request(url, headers={"User-Agent": USER_AGENT}) + with urlopen(req, timeout=25) as resp: + data = resp.read() + return ET.fromstring(data) + + +def _skip_keyword_filter(category: Optional[str]) -> bool: + return category in ("regelgeving", "cbs", "markt") + + +def refresh_feed(feed_id: int) -> dict[str, Any]: + feed = fetch_one("SELECT * FROM rss_feeds WHERE id = %s AND is_active = TRUE", (feed_id,)) + if not feed: + return {"error": "feed not found"} + skip_filter = _skip_keyword_filter(feed.get("category")) + root = _fetch_xml(feed["url"]) + items = root.findall(".//item") or root.findall(".//{http://www.w3.org/2005/Atom}entry") + inserted = skipped = 0 + for item in items[:50]: + title = (item.findtext("title") or item.findtext("{http://www.w3.org/2005/Atom}title") or "").strip() + link = (item.findtext("link") or "").strip() + if not link: + link_el = item.find("{http://www.w3.org/2005/Atom}link") + if link_el is not None: + link = link_el.get("href") or "" + summary = item.findtext("description") or item.findtext("summary") or item.findtext("{http://www.w3.org/2005/Atom}summary") or "" + pub = item.findtext("pubDate") or item.findtext("published") or item.findtext("{http://www.w3.org/2005/Atom}published") + if not title or not link: + continue + clean_summary = _strip_html(summary) + cat = (feed.get("category") or "").lower() + if cat not in ("regelgeving", "cbs", "markt") and not is_relevant(title, clean_summary): + skipped += 1 + continue + try: + execute_returning( + """INSERT INTO rss_items (feed_id, title, link, summary, published_at) + VALUES (%s, %s, %s, %s, %s) RETURNING id""", + (feed_id, title[:500], link[:1000], clean_summary, _parse_date(pub)), + ) + inserted += 1 + except Exception: + pass + execute( + "UPDATE rss_feeds SET last_fetch_at = NOW(), last_status = 'ok' WHERE id = %s", + (feed_id,), + ) + return {"feed": feed["name"], "inserted": inserted, "skipped": skipped} + + +def refresh_all_feeds() -> dict[str, Any]: + feeds = fetch_all("SELECT id, name FROM rss_feeds WHERE is_active = TRUE") + results = [] + for f in feeds: + try: + results.append(refresh_feed(int(f["id"]))) + except Exception as exc: # noqa: BLE001 + execute("UPDATE rss_feeds SET last_status = %s WHERE id = %s", (str(exc)[:32], f["id"])) + results.append({"feed": f["name"], "error": str(exc)}) + return {"feeds": len(feeds), "results": results} + + +def list_live_feed(limit: int = 40, category: Optional[str] = None) -> list[dict[str, Any]]: + params: list[Any] = [] + if category and category.lower() in ("regelgeving", "cbs", "markt"): + base = """ + SELECT i.*, f.name AS feed_name, f.category, 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 f.category = %s + """ + params.append(category.lower()) + else: + like_clauses = " OR ".join( + f"(i.title ILIKE %s OR COALESCE(i.summary,'') ILIKE %s)" for _ in INCLUDE_KEYWORDS[:12] + ) + for kw in INCLUDE_KEYWORDS[:12]: + p = f"%{kw}%" + params.extend([p, p]) + base = f""" + SELECT i.*, f.name AS feed_name, f.category, 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 ({like_clauses}) + """ + if category: + base += " AND f.category = %s" + params.append(category) + base += " ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT %s" + params.append(limit) + rows = fetch_all(base, tuple(params)) + skip_filter = category and category.lower() in ("regelgeving", "cbs", "markt") + if skip_filter: + return [dict(r) for r in rows] + return [dict(r) for r in rows if is_relevant(r.get("title") or "", r.get("summary"))] diff --git a/tools-api/app/connectors/trends_feed.py b/tools-api/app/connectors/trends_feed.py new file mode 100644 index 0000000..cbd0bf6 --- /dev/null +++ b/tools-api/app/connectors/trends_feed.py @@ -0,0 +1,104 @@ +"""Market trends feed for kant-en-klaar / halal ready meals.""" +from __future__ import annotations + +from typing import Any + +from app.db import execute_returning, fetch_all, json_param + + +TREND_SEEDS = [ + { + "category": "kant-en-klaar", + "trend_name": "Halal ready-meals groei stedelijk", + "description": "Stedelijke gebieden met hoge niet-westerse bevolking tonen vraag naar halal kant-en-klaar zonder voldoende schap-aanbod.", + "source": "CBS + retail intelligence", + "confidence_score": 0.82, + "opportunity_score": 0.88, + "related_products": ["halal maaltijden", "microwave meals", "salades"], + "action_items": ["Target Plus/Jumbo regio's met halal-gap score >60", "Pilot schap bij 3 filialen"], + }, + { + "category": "halal", + "trend_name": "Certificering als vertrouwen-driver", + "description": "Filialen met halal-certificering maar beperkt ready-meal assortiment = upsell kans voor Foodlinkk.", + "source": "halal_registry + CRM", + "confidence_score": 0.75, + "opportunity_score": 0.80, + "related_products": ["HQC gecertificeerde maaltijden"], + "action_items": ["Match HQC stores met CRM pipeline", "Cross-sell bestaande klanten"], + }, + { + "category": "kant-en-klaar", + "trend_name": "Convenience trend post-COVID", + "description": "Gemiddeld inkomen en eenpersoonshuishoudens correleren met groei kant-en-klaar segment.", + "source": "CBS kerncijfers", + "confidence_score": 0.70, + "opportunity_score": 0.72, + "related_products": ["single-serve", "meal kits"], + "action_items": ["Filter winkels op huishoudens + inkomen >€35k"], + }, +] + + +def refresh_trends_from_social() -> dict[str, Any]: + """Derive trend signals from social mentions keywords.""" + mentions = fetch_all( + """SELECT platform, text, sentiment_score, created_at FROM social_mentions + WHERE created_at > NOW() - interval '30 days' + ORDER BY created_at DESC LIMIT 100""" + ) + keywords = { + "halal": 0, "kant-en-klaar": 0, "ready meal": 0, "meal prep": 0, + "supermarkt": 0, "schap": 0, "afhalen": 0, + } + for m in mentions: + text = (m.get("text") or "").lower() + for kw in keywords: + if kw in text: + keywords[kw] += 1 + + created = 0 + for kw, count in keywords.items(): + if count < 1: + continue + execute_returning( + """INSERT INTO market_trends (category, trend_name, description, source, + confidence_score, opportunity_score, related_products, data_source) + VALUES (%s, %s, %s, 'social_mentions', %s, %s, %s, 'live_feed') + RETURNING id""", + ( + "kant-en-klaar" if "meal" in kw or "kant" in kw else "halal", + f"Social buzz: {kw} ({count} mentions)", + f"{count} vermeldingen afgelopen 30 dagen rond '{kw}'.", + min(0.95, 0.5 + count * 0.05), + min(0.95, 0.4 + count * 0.06), + [kw], + ), + ) + created += 1 + + for seed in TREND_SEEDS: + exists = fetch_all( + "SELECT id FROM market_trends WHERE trend_name = %s LIMIT 1", + (seed["trend_name"],), + ) + if not exists: + execute_returning( + """INSERT INTO market_trends (category, trend_name, description, source, + confidence_score, opportunity_score, related_products, action_items, data_source) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'seed') RETURNING id""", + ( + seed["category"], seed["trend_name"], seed["description"], seed["source"], + seed["confidence_score"], seed["opportunity_score"], + seed["related_products"], seed["action_items"], + ), + ) + created += 1 + return {"trends_created": created, "keyword_hits": keywords} + + +def list_live_trends(limit: int = 20) -> list[dict[str, Any]]: + return fetch_all( + """SELECT * FROM market_trends ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT %s""", + (limit,), + ) diff --git a/tools-api/app/db.py b/tools-api/app/db.py new file mode 100644 index 0000000..c7d979f --- /dev/null +++ b/tools-api/app/db.py @@ -0,0 +1,76 @@ +from contextlib import contextmanager +from typing import Any, Optional + +import psycopg2 +from psycopg2 import pool +from psycopg2.extras import RealDictCursor, Json + +from app.config import settings + +_connection_pool: Optional[pool.SimpleConnectionPool] = None + + +def init_pool(minconn: int = 1, maxconn: int = 10) -> None: + global _connection_pool + if _connection_pool is None: + _connection_pool = pool.SimpleConnectionPool( + minconn, + maxconn, + dsn=settings.database_dsn, + ) + + +def close_pool() -> None: + global _connection_pool + if _connection_pool is not None: + _connection_pool.closeall() + _connection_pool = None + + +@contextmanager +def get_connection(): + if _connection_pool is None: + init_pool() + conn = _connection_pool.getconn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + _connection_pool.putconn(conn) + + +def fetch_all(query: str, params: Optional[tuple] = None) -> list[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +def fetch_one(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + row = cur.fetchone() + return dict(row) if row else None + + +def execute_returning(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + row = cur.fetchone() + return dict(row) if row else None + + +def execute(query: str, params: Optional[tuple] = None) -> int: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute(query, params) + return cur.rowcount + + +def json_param(value: Any) -> Json: + return Json(value or {}) diff --git a/tools-api/app/email_config.py b/tools-api/app/email_config.py new file mode 100644 index 0000000..f99d270 --- /dev/null +++ b/tools-api/app/email_config.py @@ -0,0 +1,46 @@ +"""Load active email account from PostgreSQL for tools-api.""" + +from __future__ import annotations + +import os +from typing import Any + +from app.db import fetch_one + + +def get_active_email_config() -> dict[str, Any]: + """Return SMTP config: DB active account first, then env fallback.""" + try: + row = fetch_one( + """ + SELECT id, label, email_address, provider, smtp_host, smtp_port, + smtp_user, smtp_password, imap_host, imap_port, imap_user, imap_password + FROM email_accounts WHERE is_active = TRUE + ORDER BY updated_at DESC LIMIT 1 + """ + ) + if row and row.get("smtp_host"): + return { + "source": "database", + "account_id": row.get("id"), + "label": row.get("label"), + "smtp_host": (row.get("smtp_host") or "").strip(), + "smtp_port": int(row.get("smtp_port") or 587), + "smtp_user": (row.get("smtp_user") or row.get("email_address") or "").strip(), + "smtp_pass": row.get("smtp_password") or "", + "smtp_from": (row.get("email_address") or row.get("smtp_user") or "").strip(), + } + except Exception: + pass + + smtp_user = os.getenv("SMTP_USER", "").strip() + return { + "source": "env", + "account_id": None, + "label": "Environment", + "smtp_host": os.getenv("SMTP_HOST", "").strip(), + "smtp_port": int(os.getenv("SMTP_PORT", "587")), + "smtp_user": smtp_user, + "smtp_pass": os.getenv("SMTP_PASS", "").strip(), + "smtp_from": os.getenv("SMTP_FROM", smtp_user).strip(), + } diff --git a/tools-api/app/logging_middleware.py b/tools-api/app/logging_middleware.py new file mode 100644 index 0000000..2061ca5 --- /dev/null +++ b/tools-api/app/logging_middleware.py @@ -0,0 +1,26 @@ +"""Log Tools API mutations to agent_events.""" +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from app.middleware import log_agent_event + + +class AgentLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + if request.method in ("POST", "PUT", "PATCH", "DELETE") and response.status_code < 400: + path = request.url.path + if path.startswith(("/research", "/recommendations", "/retail", "/events")): + try: + log_agent_event( + agent_name="tools_api", + event_type="api_call", + title=f"{request.method} {path}", + channel="api", + metadata={"status": response.status_code}, + ) + except Exception: + pass + return response diff --git a/tools-api/app/main.py b/tools-api/app/main.py new file mode 100644 index 0000000..3a8857b --- /dev/null +++ b/tools-api/app/main.py @@ -0,0 +1,750 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Any, Optional + +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel, Field + +from app.db import close_pool, execute_returning, fetch_all, fetch_one, init_pool +from app.middleware import log_agent_event +from app.packaging_routes import router as packaging_router +from app.retail import router as retail_router +from app.retail_360_routes import router as retail_360_router +from app.research import router as research_router +from app.recommendations import router as recommendations_router +from app.ops_routes import router as ops_router +from app.logging_middleware import AgentLoggingMiddleware +from app import brain as brain_svc + + +class BrainMessageIn(BaseModel): + chat_id: int + direction: str = Field(..., pattern="^(in|out)$") + content_text: Optional[str] = None + content_type: str = "text" + role: str = "user" + telegram_message_id: Optional[int] = None + reply_to_db_id: Optional[int] = None + agent_name: Optional[str] = None + content_json: dict[str, Any] = Field(default_factory=dict) + user_name: Optional[str] = None + user_role: Optional[str] = None + chat_type: str = "private" + embed: bool = True + + +class BrainEdgeIn(BaseModel): + source_message_id: int + edge_type: str + target_message_id: Optional[int] = None + target_entity_type: Optional[str] = None + target_entity_id: Optional[int] = None + weight: float = 1.0 + metadata: dict[str, Any] = Field(default_factory=dict) + + +class BrainSearchIn(BaseModel): + query: str = Field(..., min_length=2) + chat_id: Optional[int] = None + limit: int = Field(default=8, ge=1, le=30) + + +from app.email_config import get_active_email_config +from app.comfyui import fetch_image_bytes, generate_image, get_job, start_generation, QUALITY_PRESETS +import json +import os + +DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750") + +app = FastAPI(title="Foodlinkk Tools API", version="1.1.0") +app.add_middleware(AgentLoggingMiddleware) +app.include_router(retail_router) +app.include_router(retail_360_router) +app.include_router(research_router) +app.include_router(recommendations_router) +app.include_router(packaging_router) +app.include_router(ops_router) + + +class AgentEventCreate(BaseModel): + agent_name: str = Field(..., max_length=64) + event_type: str = Field(..., max_length=64) + title: str = Field(..., max_length=255) + body: Optional[str] = None + agent_type: str = Field(default="openswarm", max_length=32) + metadata: dict[str, Any] = Field(default_factory=dict) + status: str = Field(default="completed", max_length=32) + related_table: Optional[str] = Field(default=None, max_length=64) + related_id: Optional[int] = None + channel: str = Field(default="dashboard", max_length=32) + + +def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in row.items(): + if isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif isinstance(value, Decimal): + out[key] = float(value) + else: + out[key] = value + return out + + +@app.on_event("startup") +def on_startup() -> None: + init_pool() + + +@app.on_event("shutdown") +def on_shutdown() -> None: + close_pool() + + +@app.get("/health") +def health() -> dict[str, str]: + try: + fetch_one("SELECT 1 AS ok") + return {"status": "ok", "database": "connected"} + except Exception as exc: + return {"status": "degraded", "database": str(exc)} + + +@app.post("/events") +def create_event(payload: AgentEventCreate) -> dict[str, Any]: + try: + row = log_agent_event(**payload.model_dump()) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return _serialize_row(row) + + +@app.get("/events") +def list_events( + limit: int = Query(default=50, ge=1, le=200), + status: Optional[str] = Query(default=None), + agent_name: Optional[str] = Query(default=None), +) -> dict[str, Any]: + clauses: list[str] = [] + params: list[Any] = [] + if status: + clauses.append("status = %s") + params.append(status) + if agent_name: + clauses.append("agent_name = %s") + params.append(agent_name) + where = f"WHERE { AND .join(clauses)}" if clauses else "" + params.append(limit) + try: + rows = fetch_all( + f""" + SELECT * + FROM agent_events + {where} + ORDER BY created_at DESC + LIMIT %s + """, + tuple(params), + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"events": [_serialize_row(r) for r in rows]} + + +@app.post("/events/{event_id}/approve") +def approve_event(event_id: int) -> dict[str, Any]: + row = execute_returning( + """ + UPDATE agent_events + SET status = %s, completed_at = NOW() + WHERE id = %s + RETURNING * + """, + ("approved", event_id), + ) + if row is None: + raise HTTPException(status_code=404, detail="Event not found") + return _serialize_row(row) + + +@app.post("/events/{event_id}/reject") +def reject_event(event_id: int) -> dict[str, Any]: + row = execute_returning( + """ + UPDATE agent_events + SET status = %s, completed_at = NOW() + WHERE id = %s + RETURNING * + """, + ("rejected", event_id), + ) + if row is None: + raise HTTPException(status_code=404, detail="Event not found") + return _serialize_row(row) + +class BrowserTask(BaseModel): + url: str + task: str = Field(default="open") + metadata: dict[str, Any] = Field(default_factory=dict) + + +class HermanDelegate(BaseModel): + message: str + target_agent: Optional[str] = Field(default=None, max_length=64) + + +@app.post("/browser/task") +async def browser_task(payload: BrowserTask) -> dict[str, Any]: + import httpx + import os + browser_url = os.getenv("BROWSER_USE_URL", "http://browser-agent:7790") + result = {"ok": True, "browser_url": browser_url, "url": payload.url} + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(f"{browser_url.rstrip('/')}/task", json=payload.model_dump()) + result["upstream_status"] = resp.status_code + if resp.status_code < 500: + try: + result["upstream"] = resp.json() + except Exception: + result["upstream"] = resp.text[:500] + except Exception as exc: + result["upstream_error"] = str(exc) + try: + log_agent_event( + agent_name="browser", + event_type="browser_task", + title=f"Browser task: {payload.url[:120]}", + body=payload.task, + metadata={"url": payload.url, **payload.metadata, **result}, + channel="tools-api", + ) + except Exception: + pass + return result + + +@app.post("/herman/delegate") +def herman_delegate(payload: HermanDelegate) -> dict[str, Any]: + agent = payload.target_agent or "herman" + try: + row = log_agent_event( + agent_name=agent, + agent_type="herman_delegate", + event_type="delegate", + title="Herman delegation", + body=payload.message, + metadata={"target_agent": agent}, + channel="herman", + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return _serialize_row(row) + + +@app.get("/knowledge/search") +async def knowledge_search(q: str = Query(..., min_length=1), limit: int = Query(default=10, ge=1, le=50)) -> dict[str, Any]: + import httpx + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{DOC_INGEST_URL.rstrip('/')}/search", params={"q": q, "limit": limit}) + if r.status_code == 200: + data = r.json() + return {"query": q, "results": data.get("results", []), "source": "chroma"} + except Exception: + pass + try: + rows = fetch_all( + """ + SELECT id, title, source, doc_type, metadata, created_at + FROM knowledge_documents + WHERE title ILIKE %s OR source ILIKE %s + ORDER BY updated_at DESC NULLS LAST + LIMIT %s + """, + (f"%{q}%", f"%{q}%", limit), + ) + except Exception: + rows = [] + return {"query": q, "results": [_serialize_row(r) for r in rows], "source": "postgres"} + + +@app.post("/knowledge/ingest") +async def knowledge_ingest(force: bool = Query(default=False)) -> dict[str, Any]: + import httpx + try: + async with httpx.AsyncClient(timeout=600.0) as client: + r = await client.post(f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan", params={"force": force}) + r.raise_for_status() + return r.json() + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@app.get("/knowledge/nas") +async def knowledge_nas(limit: int = Query(default=50, ge=1, le=500)) -> dict[str, Any]: + import httpx + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(f"{DOC_INGEST_URL.rstrip('/')}/nas/list", params={"limit": limit}) + r.raise_for_status() + return r.json() + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + +# --- CRM / Email / LLM memory (2nd brain) --- + +class EmailSend(BaseModel): + to: list[str] = Field(..., min_length=1) + subject: str = Field(..., max_length=500) + body: str + client_id: Optional[int] = None + deal_id: Optional[int] = None + cc: list[str] = Field(default_factory=list) + + +class LlmMemoryCreate(BaseModel): + category: str = Field(default="fact", max_length=64) + subject: Optional[str] = Field(default=None, max_length=255) + content: str + client_id: Optional[int] = None + deal_id: Optional[int] = None + source: str = Field(default="herman", max_length=64) + metadata: dict[str, Any] = Field(default_factory=dict) + + +def _crm_safe_all(sql: str, params: tuple = ()) -> list[dict[str, Any]]: + try: + return fetch_all(sql, params or None) + except Exception: + return [] + + +@app.get("/crm/context") +def crm_context(days: int = Query(default=7, ge=1, le=30)) -> dict[str, Any]: + """Live PostgreSQL bundle for Herman / morning briefing.""" + pipeline = fetch_one( + "SELECT COALESCE(SUM(value), 0) AS total, COUNT(*) AS cnt FROM deals WHERE stage NOT IN ('won', 'lost')" + ) + clients = _crm_safe_all( + """SELECT id, name, contact, email, stage, sector, notes, mrr_estimate + FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 25""" + ) + deals = _crm_safe_all( + """ + SELECT d.id, d.title, d.value, d.stage, d.next_action, d.deadline, d.agent_owner, + c.name AS client_name, c.email AS client_email + FROM deals d + LEFT JOIN clients c ON c.id = d.client_id + WHERE d.stage NOT IN ('won', 'lost') + ORDER BY d.deadline ASC NULLS LAST, d.updated_at DESC NULLS LAST + LIMIT 20 + """ + ) + upcoming = _crm_safe_all( + """ + SELECT d.id, d.title, d.value, d.stage, d.deadline, d.next_action, c.name AS client_name + FROM deals d + LEFT JOIN clients c ON c.id = d.client_id + WHERE d.deadline IS NOT NULL + AND d.deadline <= CURRENT_DATE + make_interval(days => %s) + AND d.stage NOT IN ('won', 'lost') + ORDER BY d.deadline ASC + LIMIT 15 + """, + (days,), + ) + calendar = _crm_safe_all( + """ + SELECT ce.id, ce.title, ce.starts_at, ce.ends_at, ce.location, ce.source, + c.name AS client_name, d.title AS deal_title + FROM calendar_events ce + LEFT JOIN clients c ON c.id = ce.client_id + LEFT JOIN deals d ON d.id = ce.deal_id + WHERE ce.starts_at >= NOW() - INTERVAL '1 day' + AND ce.starts_at <= NOW() + make_interval(days => %s) + ORDER BY ce.starts_at ASC + LIMIT 25 + """, + (days,), + ) + pending = _crm_safe_all( + """ + SELECT id, agent_name, title, event_type, created_at + FROM agent_events WHERE status = 'needs_approval' + ORDER BY created_at ASC LIMIT 10 + """ + ) + emails_recent = _crm_safe_all( + """ + SELECT id, from_addr, subject, received_at, sent_at, direction, is_read, client_id + FROM emails ORDER BY COALESCE(received_at, sent_at) DESC NULLS LAST LIMIT 15 + """ + ) + memories = _crm_safe_all( + """ + SELECT id, category, subject, content, client_id, deal_id, source, updated_at + FROM llm_memory ORDER BY updated_at DESC LIMIT 20 + """ + ) + return { + "generated_at": datetime.utcnow().isoformat() + "Z", + "pipeline_eur": float(pipeline["total"]) if pipeline else 0.0, + "active_deals": int(pipeline["cnt"]) if pipeline else 0, + "clients_count": len(clients), + "clients": [_serialize_row(c) for c in clients], + "deals": [_serialize_row(d) for d in deals], + "upcoming_deadlines": [_serialize_row(u) for u in upcoming], + "calendar": [_serialize_row(c) for c in calendar], + "pending_approvals": [_serialize_row(p) for p in pending], + "recent_emails": [_serialize_row(e) for e in emails_recent], + "llm_memory": [_serialize_row(m) for m in memories], + "links": { + "dashboard": "http://10.4.7.18:8600", + "clients": "http://10.4.7.18:8600/clients", + "deals": "http://10.4.7.18:8600/deals", + "reports": "http://10.4.7.18:8600/reports", + }, + } + + +@app.get("/crm/clients") +def crm_clients(limit: int = Query(default=25, ge=1, le=100)) -> dict[str, Any]: + rows = _crm_safe_all( + "SELECT id, name, contact, email, stage, sector, notes FROM clients ORDER BY name LIMIT %s", + (limit,), + ) + return {"clients": [_serialize_row(r) for r in rows]} + + +@app.get("/crm/deals/upcoming") +def crm_deals_upcoming(days: int = Query(default=7, ge=1, le=60)) -> dict[str, Any]: + rows = _crm_safe_all( + """ + SELECT d.id, d.title, d.value, d.stage, d.deadline, d.next_action, c.name AS client_name + FROM deals d LEFT JOIN clients c ON c.id = d.client_id + WHERE d.deadline IS NOT NULL AND d.deadline <= CURRENT_DATE + make_interval(days => %s) + AND d.stage NOT IN ('won', 'lost') + ORDER BY d.deadline ASC LIMIT 30 + """, + (days,), + ) + return {"days": days, "deals": [_serialize_row(r) for r in rows]} + + +@app.get("/emails/recent") +def emails_recent(limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]: + rows = _crm_safe_all( + """ + SELECT id, message_id, from_addr, to_addrs, subject, direction, + received_at, sent_at, is_read, client_id + FROM emails ORDER BY COALESCE(received_at, sent_at) DESC NULLS LAST LIMIT %s + """, + (limit,), + ) + return {"emails": [_serialize_row(r) for r in rows]} + + +@app.post("/emails/send") +def emails_send(payload: EmailSend) -> dict[str, Any]: + """Verstuur e-mail via SMTP (configureer SMTP_* env vars). Log in PostgreSQL.""" + import smtplib + from email.mime.text import MIMEText + from email.utils import formatdate, make_msgid + import uuid + + cfg = get_active_email_config() + smtp_host = cfg["smtp_host"] + smtp_port = cfg["smtp_port"] + smtp_user = cfg["smtp_user"] + smtp_pass = cfg["smtp_pass"] + smtp_from = cfg["smtp_from"] + + if not smtp_host or not smtp_from: + raise HTTPException( + status_code=503, + detail="Geen email account geconfigureerd. Ga naar Settings → Email in het dashboard.", + ) + + msg = MIMEText(payload.body, "plain", "utf-8") + msg["Subject"] = payload.subject + msg["From"] = smtp_from + msg["To"] = ", ".join(payload.to) + if payload.cc: + msg["Cc"] = ", ".join(payload.cc) + msg["Date"] = formatdate(localtime=True) + message_id = make_msgid() + msg["Message-ID"] = message_id + + recipients = list(payload.to) + list(payload.cc) + try: + with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server: + server.ehlo() + if smtp_port == 587: + server.starttls() + if smtp_user and smtp_pass: + server.login(smtp_user, smtp_pass) + server.sendmail(smtp_from, recipients, msg.as_string()) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"SMTP send failed: {exc}") from exc + + mid = message_id.strip("<>") + try: + row = execute_returning( + """ + INSERT INTO emails ( + message_id, client_id, deal_id, direction, from_addr, to_addrs, + subject, body_text, sent_at, is_read + ) VALUES (%s, %s, %s, 'out', %s, %s, %s, %s, NOW(), TRUE) + RETURNING id, message_id, subject, sent_at + """, + (mid, payload.client_id, payload.deal_id, smtp_from, payload.to, payload.subject, payload.body), + ) + except Exception: + row = {"message_id": mid, "subject": payload.subject} + + try: + log_agent_event( + agent_name="herman", + event_type="email_sent", + title=f"Email: {payload.subject[:120]}", + body=payload.body[:2000], + metadata={"to": payload.to, "client_id": payload.client_id}, + channel="email", + ) + except Exception: + pass + + return {"ok": True, "message_id": mid, "email": _serialize_row(row) if isinstance(row, dict) else row} + + +@app.post("/llm/memory") +def llm_memory_create(payload: LlmMemoryCreate) -> dict[str, Any]: + row = execute_returning( + """ + INSERT INTO llm_memory (category, subject, content, client_id, deal_id, source, metadata) + VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb) + RETURNING id, category, subject, content, created_at + """, + ( + payload.category, + payload.subject, + payload.content, + payload.client_id, + payload.deal_id, + payload.source, + json.dumps(payload.metadata), + ), + ) + return {"ok": True, "memory": _serialize_row(row)} + + +@app.get("/llm/memory/search") +def llm_memory_search(q: str = Query(..., min_length=1), limit: int = Query(default=15, ge=1, le=50)) -> dict[str, Any]: + rows = _crm_safe_all( + """ + SELECT id, category, subject, content, client_id, deal_id, source, updated_at + FROM llm_memory + WHERE content ILIKE %s OR subject ILIKE %s OR category ILIKE %s + ORDER BY updated_at DESC LIMIT %s + """, + (f"%{q}%", f"%{q}%", f"%{q}%", limit), + ) + return {"query": q, "results": [_serialize_row(r) for r in rows]} + +@app.get("/settings/email/active") +def settings_email_active() -> dict[str, Any]: + cfg = get_active_email_config() + return { + "configured": bool(cfg.get("smtp_host") and cfg.get("smtp_from")), + "source": cfg.get("source"), + "label": cfg.get("label"), + "from": cfg.get("smtp_from"), + "account_id": cfg.get("account_id"), + } + +class ImageGenerateBody(BaseModel): + prompt: str = Field(..., min_length=3, max_length=2000) + width: int = Field(default=1024, ge=256, le=1024) + height: int = Field(default=1024, ge=256, le=1024) + steps: int = Field(default=28, ge=5, le=40) + seed: Optional[int] = None + quality: str = Field(default="hd", pattern="^(fast|hd|ultra|custom)$") + + +class ImageStartBody(BaseModel): + prompt: str = Field(..., min_length=3, max_length=2000) + negative_prompt: str = Field(default="blurry, low quality, watermark, text, ugly, deformed", max_length=2000) + quality: str = Field(default="hd", pattern="^(fast|hd|ultra)$") + seed: Optional[int] = None + + +@app.post("/images/generate/start") +async def images_generate_start(body: ImageStartBody) -> dict[str, Any]: + try: + return await start_generation( + body.prompt.strip(), + negative=body.negative_prompt.strip(), + quality=body.quality, + seed=body.seed, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"ComfyUI: {exc}") from exc + + +@app.get("/images/progress/{prompt_id}") +async def images_progress(prompt_id: str) -> dict[str, Any]: + job = get_job(prompt_id) + if not job: + raise HTTPException(status_code=404, detail="Onbekende job") + return job + + +@app.get("/images/presets") +def images_presets() -> dict[str, Any]: + return {"presets": QUALITY_PRESETS} + + +@app.post("/images/generate") +async def images_generate(body: ImageGenerateBody) -> dict[str, Any]: + try: + result = await generate_image( + body.prompt.strip(), + width=body.width, + height=body.height, + steps=body.steps, + seed=body.seed, + quality=body.quality, + ) + except TimeoutError as exc: + raise HTTPException(status_code=504, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"ComfyUI: {exc}") from exc + try: + log_agent_event( + agent_name="design", + event_type="image_generated", + title="ComfyUI foto gegenereerd", + body=body.prompt[:500], + metadata={"filename": result["filename"], "prompt_id": result["prompt_id"]}, + channel="cockpit", + ) + except Exception: + pass + return {"ok": True, **result} + + +@app.get("/images/view") +async def images_view( + filename: str = Query(..., min_length=1), + subfolder: str = Query(default=""), + type: str = Query(default="output"), +): + from fastapi.responses import Response + + try: + data = await fetch_image_bytes(filename, subfolder, type) + except Exception as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + media = "image/png" if filename.lower().endswith(".png") else "image/jpeg" + return Response(content=data, media_type=media) + +@app.post("/brain/messages") +async def brain_store_message(body: BrainMessageIn) -> dict[str, Any]: + try: + if body.embed and (body.content_text or "").strip(): + row = await brain_svc.store_message_with_embedding( + body.chat_id, + direction=body.direction, + content_text=body.content_text, + content_type=body.content_type, + role=body.role, + telegram_message_id=body.telegram_message_id, + reply_to_db_id=body.reply_to_db_id, + agent_name=body.agent_name, + content_json=body.content_json, + user_name=body.user_name, + user_role=body.user_role, + chat_type=body.chat_type, + ) + else: + row = brain_svc.store_message( + body.chat_id, + direction=body.direction, + content_text=body.content_text, + content_type=body.content_type, + role=body.role, + telegram_message_id=body.telegram_message_id, + reply_to_db_id=body.reply_to_db_id, + agent_name=body.agent_name, + content_json=body.content_json, + user_name=body.user_name, + user_role=body.user_role, + chat_type=body.chat_type, + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return _serialize_row(row) + + +@app.post("/brain/edges") +def brain_store_edge(body: BrainEdgeIn) -> dict[str, Any]: + try: + row = brain_svc.store_edge( + body.source_message_id, + body.edge_type, + target_message_id=body.target_message_id, + target_entity_type=body.target_entity_type, + target_entity_id=body.target_entity_id, + weight=body.weight, + metadata=body.metadata, + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return _serialize_row(row) + + +@app.get("/brain/graph/{chat_id}") +def brain_graph(chat_id: int, limit: int = Query(default=50, ge=1, le=200)) -> dict[str, Any]: + return brain_svc.get_graph(chat_id, limit=limit) + + +@app.post("/brain/search") +async def brain_search(body: BrainSearchIn) -> dict[str, Any]: + try: + results = await brain_svc.search_memory(body.query, chat_id=body.chat_id, limit=body.limit) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"results": [_serialize_row(r) for r in results]} + +# Append to tools-api main.py before end + +@app.get("/brain/conversations") +def brain_list_conversations(limit: int = Query(default=50, ge=1, le=200)) -> dict[str, Any]: + rows = brain_svc.list_conversations(limit=limit) + return {"items": [_serialize_row(r) for r in rows]} + + +@app.get("/brain/feed") +def brain_feed( + chat_id: Optional[int] = Query(default=None), + limit: int = Query(default=80, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> dict[str, Any]: + rows = brain_svc.list_feed(chat_id=chat_id, limit=limit, offset=offset) + return {"items": [_serialize_row(r) for r in rows]} + + +@app.get("/brain/stats") +def brain_stats() -> dict[str, Any]: + data = brain_svc.get_dashboard_stats() + return { + "stats": _serialize_row(data.get("stats") or {}), + "recent_messages": [_serialize_row(r) for r in data.get("recent_messages") or []], + "agent_events": [_serialize_row(r) for r in data.get("agent_events") or []], + } + + +@app.get("/brain/graph") +def brain_global_graph(limit: int = Query(default=100, ge=1, le=300)) -> dict[str, Any]: + return brain_svc.get_global_graph(limit=limit) diff --git a/tools-api/app/middleware.py b/tools-api/app/middleware.py new file mode 100644 index 0000000..f095d6a --- /dev/null +++ b/tools-api/app/middleware.py @@ -0,0 +1,45 @@ +"""Agent event logging helpers used by the Tools API.""" + +from typing import Any, Optional + +from app.db import execute_returning, json_param + + +def log_agent_event( + *, + agent_name: str, + event_type: str, + title: str, + body: Optional[str] = None, + agent_type: str = "openswarm", + metadata: Optional[dict[str, Any]] = None, + status: str = "completed", + related_table: Optional[str] = None, + related_id: Optional[int] = None, + channel: str = "dashboard", +) -> dict[str, Any]: + row = execute_returning( + """ + INSERT INTO agent_events ( + agent_name, agent_type, event_type, title, body, metadata, + status, related_table, related_id, channel + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + agent_name, + agent_type, + event_type, + title, + body, + json_param(metadata), + status, + related_table, + related_id, + channel, + ), + ) + if row is None: + raise RuntimeError("Failed to insert agent event") + return row diff --git a/tools-api/app/ops_routes.py b/tools-api/app/ops_routes.py new file mode 100644 index 0000000..9d21872 --- /dev/null +++ b/tools-api/app/ops_routes.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from app.connectors.proxmox import get_status_summary, get_topology, poll_and_snapshot + +router = APIRouter(prefix="/ops", tags=["ops"]) + + +@router.get("/status") +def ops_status() -> dict: + try: + return get_status_summary() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"ops status failed: {exc}") from exc + + +@router.get("/topology") +def ops_topology() -> dict: + try: + return get_topology() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"ops topology failed: {exc}") from exc + + +@router.post("/refresh") +def ops_refresh() -> dict: + try: + return poll_and_snapshot() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=f"ops refresh failed: {exc}") from exc diff --git a/tools-api/app/packaging/__init__.py b/tools-api/app/packaging/__init__.py new file mode 100644 index 0000000..90ac8fb --- /dev/null +++ b/tools-api/app/packaging/__init__.py @@ -0,0 +1 @@ +"""Packaging helpers for the tools API.""" diff --git a/tools-api/app/packaging/export.py b/tools-api/app/packaging/export.py new file mode 100644 index 0000000..63d4ade --- /dev/null +++ b/tools-api/app/packaging/export.py @@ -0,0 +1,52 @@ +"""Export helpers for packaging assets.""" +from __future__ import annotations + +from io import BytesIO + +from PIL import Image, ImageDraw +from reportlab.lib.pagesizes import A4 +from reportlab.lib.utils import ImageReader +from reportlab.pdfgen import canvas + +try: + import cairosvg # type: ignore +except Exception: # pragma: no cover + cairosvg = None + + +def svg_to_png_bytes(svg_content: str, width: int = 1400, height: int = 1000) -> bytes: + """Convert SVG content to PNG bytes with a Pillow fallback.""" + if cairosvg is not None: + return cairosvg.svg2png(bytestring=svg_content.encode("utf-8")) + + # Fallback when cairosvg is unavailable: branded placeholder raster. + img = Image.new("RGB", (width, height), "#0b1220") + draw = ImageDraw.Draw(img) + draw.rectangle((24, 24, width - 24, height - 24), outline="#00e5ff", width=3) + draw.text((48, 56), "Foodlinkk Packaging Preview", fill="#e2e8f0") + draw.text((48, 92), "Install cairosvg for full SVG rendering.", fill="#94a3b8") + stream = BytesIO() + img.save(stream, format="PNG") + return stream.getvalue() + + +def svg_to_pdf_bytes(svg_content: str) -> bytes: + """Render SVG in a PDF by first rasterizing to PNG.""" + png_data = svg_to_png_bytes(svg_content, width=1800, height=1300) + png_image = Image.open(BytesIO(png_data)).convert("RGB") + + output = BytesIO() + pdf = canvas.Canvas(output, pagesize=A4) + page_w, page_h = A4 + + img_w, img_h = png_image.size + scale = min((page_w - 64) / img_w, (page_h - 64) / img_h) + draw_w = img_w * scale + draw_h = img_h * scale + x = (page_w - draw_w) / 2 + y = (page_h - draw_h) / 2 + + pdf.drawImage(ImageReader(png_image), x, y, width=draw_w, height=draw_h, preserveAspectRatio=True, mask="auto") + pdf.showPage() + pdf.save() + return output.getvalue() diff --git a/tools-api/app/packaging/generator.py b/tools-api/app/packaging/generator.py new file mode 100644 index 0000000..cb306ba --- /dev/null +++ b/tools-api/app/packaging/generator.py @@ -0,0 +1,233 @@ +"""SVG packaging generator for Foodlinkk.""" +from __future__ import annotations + +import base64 +from io import BytesIO +from typing import Any + +import svgwrite +from barcode import Code128 +from barcode.writer import SVGWriter + + +MM_TO_PX = 3.7795275591 # 96 DPI conversion +DEFAULT_BARCODE_VALUE = "8710000000012" + +FOODLINKK_BRAND = { + "bg": "#0b1220", + "panel": "#101a2d", + "primary": "#00e5ff", + "secondary": "#ffd700", + "text": "#e2e8f0", + "cut_line": "#ef4444", + "fold_line": "#60a5fa", +} + + +def _mm(mm: float) -> float: + return round(float(mm) * MM_TO_PX, 2) + + +def _elements_enabled(elements: Any, name: str) -> bool: + if isinstance(elements, dict): + return bool(elements.get(name)) + if isinstance(elements, list): + return name in elements + return False + + +def _barcode_data_uri(value: str) -> str: + barcode = Code128(value, writer=SVGWriter()) + stream = BytesIO() + barcode.write(stream) + encoded = base64.b64encode(stream.getvalue()).decode("ascii") + return f"data:image/svg+xml;base64,{encoded}" + + +def generate_packaging(spec: dict[str, Any]) -> str: + """Create an SVG packaging design based on a simple spec.""" + ptype = (spec.get("type") or "folding_box").strip().lower() + width_mm = float(spec.get("width_mm", 120)) + height_mm = float(spec.get("height_mm", 80)) + depth_mm = float(spec.get("depth_mm", 40)) + elements = spec.get("elements", {}) + brand = {**FOODLINKK_BRAND, **(spec.get("brand") or {})} + + if ptype == "folding_box": + canvas_w = _mm((width_mm * 2) + (depth_mm * 2) + 20) + canvas_h = _mm(height_mm + depth_mm + 20) + elif ptype == "wrap": + canvas_w = _mm(width_mm + 20) + canvas_h = _mm(height_mm + 20) + elif ptype == "round_label": + diameter = max(min(width_mm, height_mm), 20) + canvas_w = _mm(diameter + 20) + canvas_h = _mm(diameter + 20) + else: + raise ValueError(f"Unsupported packaging type: {ptype}") + + dwg = svgwrite.Drawing(size=(canvas_w, canvas_h)) + dwg.viewbox(0, 0, canvas_w, canvas_h) + + # Background and frame + dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=brand["bg"])) + dwg.add( + dwg.rect( + insert=(4, 4), + size=(canvas_w - 8, canvas_h - 8), + fill=brand["panel"], + rx=10, + ry=10, + stroke=brand["primary"], + stroke_opacity=0.25, + stroke_width=2, + ) + ) + + margin = 24 + if ptype == "folding_box": + body_w = _mm(width_mm) + body_h = _mm(height_mm) + depth_w = _mm(depth_mm) + x0 = margin + y0 = margin + panels = [depth_w, body_w, depth_w, body_w] + x = x0 + for idx, panel_w in enumerate(panels): + dwg.add( + dwg.rect( + insert=(x, y0), + size=(panel_w, body_h), + fill="none", + stroke=brand["primary"] if idx % 2 else brand["secondary"], + stroke_opacity=0.45, + stroke_width=1.6, + ) + ) + x += panel_w + + if _elements_enabled(elements, "fold_lines"): + x = x0 + panels[0] + for panel_w in panels[1:]: + dwg.add( + dwg.line( + start=(x, y0), + end=(x, y0 + body_h), + stroke=brand["fold_line"], + stroke_dasharray="8,6", + stroke_width=1.2, + ) + ) + x += panel_w + + if _elements_enabled(elements, "cut_lines"): + dwg.add( + dwg.rect( + insert=(x0, y0), + size=(sum(panels), body_h), + fill="none", + stroke=brand["cut_line"], + stroke_dasharray="5,4", + stroke_width=1.1, + ) + ) + + logo_x = x0 + panels[0] + (_mm(width_mm) * 0.12) + logo_y = y0 + (_mm(height_mm) * 0.16) + logo_w = _mm(width_mm) * 0.76 + logo_h = _mm(height_mm) * 0.42 + elif ptype == "wrap": + body_w = _mm(width_mm) + body_h = _mm(height_mm) + x0 = margin + y0 = margin + dwg.add( + dwg.rect( + insert=(x0, y0), + size=(body_w, body_h), + fill="none", + stroke=brand["primary"], + stroke_width=2.2, + ) + ) + if _elements_enabled(elements, "cut_lines"): + dwg.add( + dwg.rect( + insert=(x0, y0), + size=(body_w, body_h), + fill="none", + stroke=brand["cut_line"], + stroke_dasharray="6,4", + stroke_width=1.1, + ) + ) + logo_x = x0 + (body_w * 0.14) + logo_y = y0 + (body_h * 0.14) + logo_w = body_w * 0.72 + logo_h = body_h * 0.36 + else: + diameter = min(canvas_w, canvas_h) - (margin * 2) + cx = canvas_w / 2 + cy = canvas_h / 2 + dwg.add( + dwg.circle( + center=(cx, cy), + r=diameter / 2, + fill="none", + stroke=brand["primary"], + stroke_width=2.4, + ) + ) + if _elements_enabled(elements, "cut_lines"): + dwg.add( + dwg.circle( + center=(cx, cy), + r=(diameter / 2) - 4, + fill="none", + stroke=brand["cut_line"], + stroke_dasharray="4,4", + stroke_width=1.0, + ) + ) + logo_w = diameter * 0.64 + logo_h = diameter * 0.22 + logo_x = cx - (logo_w / 2) + logo_y = cy - (logo_h / 2) - 8 + body_w = diameter + body_h = diameter + x0 = cx - (diameter / 2) + y0 = cy - (diameter / 2) + + if _elements_enabled(elements, "logo_area"): + dwg.add( + dwg.rect( + insert=(logo_x, logo_y), + size=(logo_w, logo_h), + rx=8, + ry=8, + fill="none", + stroke=brand["secondary"], + stroke_width=2, + ) + ) + dwg.add( + dwg.text( + "FOODLINKK", + insert=(logo_x + 12, logo_y + (logo_h / 2) + 5), + fill=brand["text"], + font_size=18, + font_family="Arial, sans-serif", + font_weight="bold", + ) + ) + + if _elements_enabled(elements, "barcode"): + barcode_uri = _barcode_data_uri(str(spec.get("barcode_value") or DEFAULT_BARCODE_VALUE)) + bar_w = max(140, body_w * 0.35) + bar_h = max(50, body_h * 0.16) + bar_x = x0 + body_w - bar_w - 14 + bar_y = y0 + body_h - bar_h - 14 + dwg.add(dwg.rect(insert=(bar_x - 4, bar_y - 4), size=(bar_w + 8, bar_h + 8), fill="#ffffff")) + dwg.add(dwg.image(href=barcode_uri, insert=(bar_x, bar_y), size=(bar_w, bar_h))) + + return dwg.tostring() diff --git a/tools-api/app/packaging_routes.py b/tools-api/app/packaging_routes.py new file mode 100644 index 0000000..a34a06b --- /dev/null +++ b/tools-api/app/packaging_routes.py @@ -0,0 +1,80 @@ +"""Packaging generation API routes.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal +from uuid import uuid4 + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import Response +from pydantic import BaseModel, Field + +from app.packaging.export import svg_to_pdf_bytes, svg_to_png_bytes +from app.packaging.generator import FOODLINKK_BRAND, generate_packaging + +router = APIRouter(prefix="/packaging", tags=["packaging"]) + +_PROJECTS: dict[str, dict[str, Any]] = {} + + +class PackagingSpec(BaseModel): + type: Literal["folding_box", "wrap", "round_label"] + width_mm: float = Field(default=120, gt=0, le=4000) + height_mm: float = Field(default=80, gt=0, le=4000) + depth_mm: float = Field(default=40, ge=0, le=4000) + elements: dict[str, bool] = Field(default_factory=dict) + brand: dict[str, str] = Field(default_factory=dict) + barcode_value: str | None = Field(default=None, max_length=64) + + +@router.post("/generate") +def packaging_generate(spec: PackagingSpec) -> dict[str, Any]: + spec_data = spec.model_dump() + if not spec_data["brand"]: + spec_data["brand"] = dict(FOODLINKK_BRAND) + svg = generate_packaging(spec_data) + project_id = uuid4().hex + now = datetime.utcnow().isoformat() + "Z" + _PROJECTS[project_id] = { + "id": project_id, + "created_at": now, + "spec": spec_data, + "svg": svg, + } + return {"id": project_id, "created_at": now, "svg": svg, "spec": spec_data} + + +@router.get("/projects") +def packaging_projects(limit: int = Query(default=30, ge=1, le=200)) -> dict[str, Any]: + items = sorted(_PROJECTS.values(), key=lambda x: x["created_at"], reverse=True)[:limit] + return { + "items": [{"id": p["id"], "created_at": p["created_at"], "spec": p["spec"]} for p in items], + "count": len(items), + } + + +@router.get("/download/{project_id}") +def packaging_download(project_id: str, format: str = Query(default="svg", pattern="^(svg|png|pdf)$")): + project = _PROJECTS.get(project_id) + if not project: + raise HTTPException(status_code=404, detail="Packaging project not found") + + svg = project["svg"] + filename = f"foodlinkk-packaging-{project_id[:8]}.{format}" + if format == "svg": + return Response( + content=svg.encode("utf-8"), + media_type="image/svg+xml", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + if format == "png": + return Response( + content=svg_to_png_bytes(svg), + media_type="image/png", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + return Response( + content=svg_to_pdf_bytes(svg), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/tools-api/app/recommendations.py b/tools-api/app/recommendations.py new file mode 100644 index 0000000..e53faed --- /dev/null +++ b/tools-api/app/recommendations.py @@ -0,0 +1,165 @@ +"""AI recommendations — Herman filter output.""" +from __future__ import annotations + +import json +import os +import urllib.request +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query + +from app.db import execute, execute_returning, fetch_all, fetch_one, json_param +from app.middleware import log_agent_event + +router = APIRouter(prefix="/recommendations", tags=["recommendations"]) + +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.4.7.19:11434") +OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:8b") +HERMAN_URL = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090") + + +def _serialize(row: dict) -> dict[str, Any]: + out = {} + for k, v in row.items(): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + elif type(v).__name__ == "Decimal": + out[k] = float(v) + else: + out[k] = v + return out + + +@router.get("/pending") +def pending_recommendations(limit: int = Query(10, ge=1, le=50)) -> dict[str, Any]: + rows = fetch_all( + """SELECT * FROM ai_recommendations WHERE status = 'pending' + ORDER BY impact_score DESC NULLS LAST, created_at DESC LIMIT %s""", + (limit,), + ) + return {"items": [_serialize(r) for r in rows]} + + +@router.get("/strategies") +def list_strategies(active_only: bool = True) -> dict[str, Any]: + q = "SELECT * FROM marketing_strategies" + if active_only: + q += " WHERE is_active = true" + q += " ORDER BY updated_at DESC LIMIT 20" + return {"items": [_serialize(r) for r in fetch_all(q)]} + + +@router.post("/generate") +def generate_recommendations() -> dict[str, Any]: + briefs = fetch_all("SELECT domain, title, summary FROM research_briefs ORDER BY generated_at DESC LIMIT 5") + deals = fetch_all( + "SELECT d.id, d.title, d.value, d.stage, d.next_action, c.name AS client_name FROM deals d LEFT JOIN clients c ON c.id = d.client_id WHERE d.stage NOT IN ('won','lost')" + ) + pending_approvals = fetch_all( + "SELECT id, agent_name, title FROM agent_events WHERE status = 'needs_approval' LIMIT 5" + ) + context = { + "briefs": [dict(b) for b in briefs], + "deals": [dict(d) for d in deals], + "approvals": [dict(a) for a in pending_approvals], + } + + created = [] + for deal in deals: + val = float(deal.get("value") or 0) + title = f"{deal.get('client_name') or 'Deal'} — {deal.get('next_action') or 'follow-up'}" + existing = fetch_one( + "SELECT id FROM ai_recommendations WHERE title = %s AND status = 'pending'", + (title[:255],), + ) + if existing: + continue + row = execute_returning( + """INSERT INTO ai_recommendations + (recommendation_type, title, description, priority, impact_score, confidence_score, + data_sources, action_items, generated_by, related_entity_type, related_entity_id, status, expires_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'pending',%s) RETURNING id""", + ( + "deal_action", + title[:255], + f"Deal {deal.get('title')} — stage {deal.get('stage')} — €{val:,.0f}", + "high" if val >= 50000 else "medium", + min(0.95, 0.5 + val / 200000), + 0.85, + json_param({"source": "crm", "deal_id": deal.get("id")}), + [deal.get("next_action") or "Follow-up"], + "herman", + "deal", + deal.get("id"), + datetime.now(timezone.utc) + timedelta(days=7), + ), + ) + created.append(row["id"]) + + for appr in pending_approvals: + title = f"Goedkeuren: {appr.get('title')}" + existing = fetch_one( + "SELECT id FROM ai_recommendations WHERE title = %s AND status = 'pending'", + (title[:255],), + ) + if not existing: + row = execute_returning( + """INSERT INTO ai_recommendations + (recommendation_type, title, description, priority, impact_score, confidence_score, + generated_by, status, expires_at) + VALUES ('operational', %s, %s, 'high', 0.80, 0.90, 'herman', 'pending', %s) + RETURNING id""", + ( + title[:255], + f"Agent {appr.get('agent_name')} wacht op CEO", + datetime.now(timezone.utc) + timedelta(days=3), + ), + ) + created.append(row["id"]) + + if not created and not fetch_one("SELECT id FROM ai_recommendations WHERE status='pending' LIMIT 1"): + row = execute_returning( + """INSERT INTO ai_recommendations + (recommendation_type, title, description, priority, impact_score, confidence_score, + generated_by, status) + VALUES ('retail_target', 'Retail intelligence uitbreiden', 'Run AH/Jumbo scrapers voor volledige kaart', 'medium', 0.70, 0.75, 'herman', 'pending') + RETURNING id""" + ) + created.append(row["id"]) + + log_agent_event( + agent_name="herman", + event_type="recommendations_generated", + title=f"Generated {len(created)} recommendations", + metadata={"ids": created, "context_keys": list(context.keys())}, + ) + return {"created": created, "pending_count": len(fetch_all("SELECT id FROM ai_recommendations WHERE status='pending'"))} + + +@router.post("/{rec_id}/approve") +def approve_recommendation(rec_id: int) -> dict[str, Any]: + row = execute_returning( + "UPDATE ai_recommendations SET status = 'approved', updated_at = NOW() WHERE id = %s RETURNING *", + (rec_id,), + ) + if not row: + raise HTTPException(404, "Recommendation not found") + execute_returning( + """INSERT INTO herman_actions (action_type, title, description, status, completed_at) + VALUES ('recommendation_approved', %s, %s, 'completed', NOW()) RETURNING id""", + (row["title"], row.get("description")), + ) + log_agent_event(agent_name="herman", event_type="approval", title=f"Approved recommendation {rec_id}", status="completed") + return _serialize(row) + + +@router.post("/{rec_id}/dismiss") +def dismiss_recommendation(rec_id: int) -> dict[str, Any]: + row = execute_returning( + "UPDATE ai_recommendations SET status = 'dismissed', updated_at = NOW() WHERE id = %s RETURNING *", + (rec_id,), + ) + if not row: + raise HTTPException(404, "Recommendation not found") + return _serialize(row) diff --git a/tools-api/app/research.py b/tools-api/app/research.py new file mode 100644 index 0000000..c5dbbb3 --- /dev/null +++ b/tools-api/app/research.py @@ -0,0 +1,183 @@ +"""Research pipeline: data providers, snapshots, briefs.""" +from __future__ import annotations + +import json +import urllib.request +from datetime import date, datetime, timedelta, timezone +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app.db import execute, execute_returning, fetch_all, fetch_one, json_param +from app.middleware import log_agent_event + +router = APIRouter(prefix="/research", tags=["research"]) + + +def _json_safe(obj: Any) -> Any: + if isinstance(obj, dict): + return {k: _json_safe(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_json_safe(x) for x in obj] + if hasattr(obj, "isoformat"): + return obj.isoformat() + if type(obj).__name__ == "Decimal": + return float(obj) + return obj + + +def _http_get(url: str, timeout: int = 30) -> dict: + with urllib.request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + + +@router.get("/providers") +def list_providers() -> dict[str, Any]: + rows = fetch_all("SELECT * FROM data_providers ORDER BY name") + return {"items": [dict(r) for r in rows]} + + +@router.post("/providers/{provider_id}/refresh") +def refresh_provider(provider_id: int) -> dict[str, Any]: + prov = fetch_one("SELECT * FROM data_providers WHERE id = %s", (provider_id,)) + if not prov: + raise HTTPException(404, "Provider not found") + name = prov["name"] + payload: dict[str, Any] = {} + count = 0 + if name == "crm": + clients = fetch_all("SELECT id, name, stage FROM clients ORDER BY updated_at DESC LIMIT 20") + deals = fetch_all( + "SELECT id, title, value, stage, next_action, deadline FROM deals ORDER BY updated_at DESC LIMIT 20" + ) + payload = { + "clients": _json_safe([dict(c) for c in clients]), + "deals": _json_safe([dict(d) for d in deals]), + } + count = len(clients) + len(deals) + elif name == "weather": + url = ( + "https://api.open-meteo.com/v1/forecast?" + "latitude=52.37&longitude=4.89&daily=temperature_2m_max,precipitation_sum&timezone=Europe%2FAmsterdam&forecast_days=7" + ) + payload = _http_get(url) + count = len(payload.get("daily", {}).get("time", [])) + for i, day in enumerate(payload.get("daily", {}).get("time", [])[:7]): + temps = payload["daily"].get("temperature_2m_max", []) + prec = payload["daily"].get("precipitation_sum", []) + execute( + """INSERT INTO weather_data (region, city, date, temperature_c, precipitation_mm, weather_condition, data_source) + VALUES ('Noord-Holland', 'Amsterdam', %s, %s, %s, 'forecast', 'open-meteo')""", + (day, temps[i] if i < len(temps) else None, prec[i] if i < len(prec) else None), + ) + elif name == "social": + rows = fetch_all( + "SELECT platform, text, sentiment_score, created_at FROM social_mentions ORDER BY created_at DESC LIMIT 30" + ) + payload = _json_safe({"mentions": [dict(r) for r in rows]}) + count = len(rows) + elif name == "retail_manual": + rows = fetch_all("SELECT id, name, chain, city, partnership_status FROM supermarkets ORDER BY id") + payload = _json_safe({"stores": [dict(r) for r in rows]}) + count = len(rows) + elif name == "cbs": + from app import retail_enrichment + status = retail_enrichment.enrichment_status() + batch = retail_enrichment.enrich_batch(limit=30, offset=0) + payload = _json_safe({"status": status, "batch": batch}) + count = batch.get("ok", 0) + elif name == "pdok": + from app.connectors import pdok as pdok_conn + sample = fetch_all( + "SELECT DISTINCT postcode FROM supermarkets WHERE postcode <> '0000AA' LIMIT 5" + ) + lookups = [pdok_conn.lookup_postcode(r["postcode"]) for r in sample] + payload = _json_safe({"lookups": [x for x in lookups if x]}) + count = len(payload.get("lookups", [])) + else: + payload = {"status": "noop"} + snap = execute_returning( + """INSERT INTO data_snapshots (provider_id, payload, record_count) + VALUES (%s, %s, %s) RETURNING id, fetched_at""", + (provider_id, json_param(payload), count), + ) + execute( + "UPDATE data_providers SET last_fetch_at = NOW(), last_status = 'ok' WHERE id = %s", + (provider_id,), + ) + log_agent_event( + agent_name="research", + event_type="data_refresh", + title=f"Provider {name} refreshed", + metadata={"provider_id": provider_id, "records": count}, + ) + return {"provider": name, "snapshot_id": snap["id"], "record_count": count} + + +@router.get("/briefs") +def list_briefs(limit: int = 20) -> dict[str, Any]: + rows = fetch_all( + "SELECT * FROM research_briefs ORDER BY generated_at DESC LIMIT %s", + (limit,), + ) + return {"items": [dict(r) for r in rows]} + + +def _build_brief(domain: str, title: str, summary: str, findings: list[dict]) -> dict: + row = execute_returning( + """INSERT INTO research_briefs (domain, title, summary, key_findings, expires_at) + VALUES (%s, %s, %s, %s, %s) RETURNING *""", + ( + domain, + title, + summary, + json_param(findings), + datetime.now(timezone.utc) + timedelta(days=1), + ), + ) + return dict(row) + + +@router.post("/run") +def run_research() -> dict[str, Any]: + providers = fetch_all("SELECT id, name FROM data_providers WHERE is_active = true") + snapshot_ids = [] + for p in providers: + result = refresh_provider(int(p["id"])) + snapshot_ids.append(result.get("snapshot_id")) + + clients_n = fetch_one("SELECT COUNT(*) AS c FROM clients")["c"] + deals = fetch_all("SELECT title, value, stage FROM deals WHERE stage NOT IN ('won','lost')") + pipeline = sum(float(d.get("value") or 0) for d in deals) + stores = fetch_one("SELECT COUNT(*) AS c FROM supermarkets")["c"] + mentions = fetch_one("SELECT COUNT(*) AS c FROM social_mentions WHERE created_at > NOW() - interval '7 days'")["c"] + + crm_brief = _build_brief( + "crm", + f"CRM snapshot {date.today()}", + f"{clients_n} klanten, pipeline €{pipeline:,.0f}, {len(deals)} actieve deals.", + [{"finding": f"Pipeline €{pipeline:,.0f}", "relevance": "high"}], + ) + retail_brief = _build_brief( + "retail", + f"Retail NL {date.today()}", + f"{stores} supermarkten in database, partnerships actief/proposal gemapt.", + [{"finding": f"{stores} locaties geladen", "relevance": "medium"}], + ) + social_brief = _build_brief( + "social", + f"Social week {date.today()}", + f"{mentions} mentions afgelopen 7 dagen.", + [{"finding": f"{mentions} mentions", "relevance": "medium"}], + ) + + log_agent_event( + agent_name="research", + event_type="research_run", + title="Full research cycle completed", + metadata={"briefs": 3, "snapshots": len(snapshot_ids)}, + ) + return { + "snapshots": snapshot_ids, + "briefs": [crm_brief["id"], retail_brief["id"], social_brief["id"]], + } diff --git a/tools-api/app/retail.html b/tools-api/app/retail.html new file mode 100644 index 0000000..d6c0e9c --- /dev/null +++ b/tools-api/app/retail.html @@ -0,0 +1,411 @@ +{% extends "base.html" %} +{% block content %} + + + + + +
        + + +
        +
        + +
        +
        + +
        +
        Totaal filialen
        +
        CRM actief
        +
        Halal cert.
        +
        CBS data
        +
        Groothandels
        +
        Resultaat
        +
        + +
        + + + + +
        +
        +
        +
        Laden…
        +
        +
        +

        Groothandels OSM

        + + +

        Geen groothandels — klik Groothandels import.

        +
        +
        +

        Top halal-markt kansen

        + + + + + +
        ScoreFiliaalStadHalal%CRM
        +
        +
        +

        Stad demografie CBS

        + + + + + +
        StadInwonersHuishoudensHalal-markt%Filialen
        +
        +
        + + +
        +
        + + + + +{% endblock %} diff --git a/tools-api/app/retail.py b/tools-api/app/retail.py new file mode 100644 index 0000000..b3eeffe --- /dev/null +++ b/tools-api/app/retail.py @@ -0,0 +1,557 @@ +"""Retail intelligence API routes.""" +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from app.db import execute_returning, fetch_all, fetch_one +from app.middleware import log_agent_event +from app import retail_scrapers +from app import retail_enrichment +from app import retail_crm +from app import retail_opportunities +from app.connectors import halal_registry, trends_feed + +router = APIRouter(prefix="/retail", tags=["retail"]) + +FIELD_SCHEMA = { + "locatie": ["id", "name", "chain", "address", "postcode", "city", "province", "store_type", "latitude", "longitude"], + "contact": ["phone", "email", "website", "manager_name", "employee_count"], + "halal": ["halal_certified", "halal_certifier", "has_halal_section", "halal_certificate_number", "halal_expiry_date"], + "crm": ["partnership_status", "client_id", "deal_id", "halal_opportunity_score"], + "cbs": ["area_population", "area_avg_income", "area_households", "muslim_proxy_pct", "area_data_source"], + "meta": ["data_source", "external_id", "last_updated", "enrichment_score"], +} + + +class CrmLinkIn(BaseModel): + client_id: int + deal_id: Optional[int] = None + relationship_type: str = "prospect" + partnership_status: Optional[str] = None + notes: Optional[str] = None + + +STORE_SELECT = """ + SELECT s.*, + a.population AS area_population, + a.avg_income AS area_avg_income, + a.households AS area_households, + a.religious_composition AS area_religious, + a.ethnic_composition AS area_ethnic, + a.data_source AS area_data_source, + ros.halal_opportunity_score AS opp_halal_score, + ros.market_potential_score AS opp_market_score, + sp.manager_name AS profile_manager, + sp.manager_phone AS profile_manager_phone, + sp.manager_email AS profile_manager_email, + sp.staff_count_estimate, + sp.data_completeness AS profile_completeness + FROM supermarkets s + LEFT JOIN area_analysis a ON a.postcode = s.postcode + LEFT JOIN retail_opportunity_scores ros ON ros.supermarket_id = s.id + LEFT JOIN supermarket_profiles sp ON sp.supermarket_id = s.id +""" + + +def _row(row: dict | None) -> dict[str, Any]: + if not row: + raise HTTPException(404, "Not found") + out: dict[str, Any] = {} + for k, v in row.items(): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + elif v is not None and hasattr(v, "__float__") and type(v).__name__ == "Decimal": + out[k] = float(v) + else: + out[k] = v + if out.get("area_religious") and isinstance(out["area_religious"], dict): + out["muslim_proxy_pct"] = out["area_religious"].get("muslim_proxy_pct") + return out + + +def _build_filters( + chain: Optional[str] = None, + province: Optional[str] = None, + city: Optional[str] = None, + partnership: Optional[str] = None, + halal_certified: Optional[bool] = None, + has_halal_section: Optional[bool] = None, + store_type: Optional[str] = None, + postcode_prefix: Optional[str] = None, + q: Optional[str] = None, + min_population: Optional[int] = None, + max_population: Optional[int] = None, + min_avg_income: Optional[float] = None, + max_avg_income: Optional[float] = None, + min_muslim_pct: Optional[float] = None, + has_area_data: Optional[bool] = None, + min_halal_opportunity: Optional[float] = None, + max_halal_opportunity: Optional[float] = None, + has_phone: Optional[bool] = None, + has_email: Optional[bool] = None, + has_manager: Optional[bool] = None, + halal_gap_only: Optional[bool] = None, + linked_to_crm: Optional[bool] = None, + has_halal_cert_registry: Optional[bool] = None, +) -> tuple[list[str], list[Any]]: + clauses: list[str] = ["s.postcode <> '0000AA'"] + params: list[Any] = [] + + if chain: + clauses.append("s.chain ILIKE %s") + params.append(f"%{chain}%") + if province: + clauses.append("s.province ILIKE %s") + params.append(f"%{province}%") + if city: + clauses.append("s.city ILIKE %s") + params.append(f"%{city}%") + if partnership: + clauses.append("s.partnership_status = %s") + params.append(partnership) + if halal_certified is not None: + clauses.append("s.halal_certified = %s") + params.append(halal_certified) + if has_halal_section is not None: + clauses.append("s.has_halal_section = %s") + params.append(has_halal_section) + if store_type: + clauses.append("s.store_type ILIKE %s") + params.append(f"%{store_type}%") + if postcode_prefix: + clauses.append("s.postcode LIKE %s") + params.append(f"{postcode_prefix.upper()}%") + if q: + clauses.append("(s.name ILIKE %s OR s.address ILIKE %s OR s.city ILIKE %s)") + like = f"%{q}%" + params.extend([like, like, like]) + if min_population is not None: + clauses.append("a.population >= %s") + params.append(min_population) + if max_population is not None: + clauses.append("a.population <= %s") + params.append(max_population) + if min_avg_income is not None: + clauses.append("a.avg_income >= %s") + params.append(min_avg_income) + if max_avg_income is not None: + clauses.append("a.avg_income <= %s") + params.append(max_avg_income) + if min_muslim_pct is not None: + clauses.append("(a.religious_composition->>'muslim_proxy_pct')::float >= %s") + params.append(min_muslim_pct) + if has_area_data is True: + clauses.append("a.id IS NOT NULL") + elif has_area_data is False: + clauses.append("a.id IS NULL") + if min_halal_opportunity is not None: + clauses.append("COALESCE(ros.halal_opportunity_score, s.halal_opportunity_score, 0) >= %s") + params.append(min_halal_opportunity) + if max_halal_opportunity is not None: + clauses.append("COALESCE(ros.halal_opportunity_score, s.halal_opportunity_score, 0) <= %s") + params.append(max_halal_opportunity) + if has_phone is True: + clauses.append("(s.phone IS NOT NULL AND s.phone <> '')") + elif has_phone is False: + clauses.append("(s.phone IS NULL OR s.phone = '')") + if has_email is True: + clauses.append("(s.email IS NOT NULL AND s.email <> '')") + elif has_email is False: + clauses.append("(s.email IS NULL OR s.email = '')") + if has_manager is True: + clauses.append("(s.manager_name IS NOT NULL OR sp.manager_name IS NOT NULL)") + elif has_manager is False: + clauses.append("(s.manager_name IS NULL AND sp.manager_name IS NULL)") + if halal_gap_only: + clauses.append("s.halal_certified = FALSE AND s.has_halal_section = FALSE") + clauses.append("(a.religious_composition->>'muslim_proxy_pct')::float >= 5") + if linked_to_crm is True: + clauses.append("s.client_id IS NOT NULL") + elif linked_to_crm is False: + clauses.append("s.client_id IS NULL") + if has_halal_cert_registry: + clauses.append( + "EXISTS (SELECT 1 FROM halal_certifications h WHERE h.supermarket_id = s.id AND h.status = 'active')" + ) + + return clauses, params + + +@router.get("/filters") +def retail_filters() -> dict[str, Any]: + chains = fetch_all( + "SELECT chain, COUNT(*) AS n FROM supermarkets GROUP BY chain ORDER BY n DESC" + ) + provinces = fetch_all( + """SELECT COALESCE(province, 'Onbekend') AS province, COUNT(*) AS n + FROM supermarkets GROUP BY province ORDER BY n DESC""" + ) + partnerships = fetch_all( + "SELECT partnership_status, COUNT(*) AS n FROM supermarkets GROUP BY partnership_status" + ) + status = retail_enrichment.enrichment_status() + income = fetch_one( + """SELECT MIN(avg_income) AS min_income, MAX(avg_income) AS max_income, + MIN(population) AS min_pop, MAX(population) AS max_pop + FROM area_analysis WHERE avg_income IS NOT NULL""" + ) + return { + "chains": [dict(r) for r in chains], + "provinces": [dict(r) for r in provinces], + "partnerships": [dict(r) for r in partnerships], + "enrichment": status, + "ranges": { + "min_income": float(income["min_income"]) if income and income.get("min_income") else None, + "max_income": float(income["max_income"]) if income and income.get("max_income") else None, + "min_population": int(income["min_pop"]) if income and income.get("min_pop") else None, + "max_population": int(income["max_pop"]) if income and income.get("max_pop") else None, + }, + } + + +@router.get("/supermarkets") +def list_supermarkets( + chain: Optional[str] = None, + province: Optional[str] = None, + city: Optional[str] = None, + partnership: Optional[str] = None, + halal_certified: Optional[bool] = None, + has_halal_section: Optional[bool] = None, + store_type: Optional[str] = None, + postcode_prefix: Optional[str] = None, + q: Optional[str] = None, + min_population: Optional[int] = Query(None, ge=0), + max_population: Optional[int] = Query(None, ge=0), + min_avg_income: Optional[float] = Query(None, ge=0), + max_avg_income: Optional[float] = Query(None, ge=0), + min_muslim_pct: Optional[float] = Query(None, ge=0, le=100), + has_area_data: Optional[bool] = None, + min_halal_opportunity: Optional[float] = Query(None, ge=0, le=100), + max_halal_opportunity: Optional[float] = Query(None, ge=0, le=100), + has_phone: Optional[bool] = None, + has_email: Optional[bool] = None, + has_manager: Optional[bool] = None, + halal_gap_only: Optional[bool] = None, + linked_to_crm: Optional[bool] = None, + has_halal_cert_registry: Optional[bool] = None, + sort: Optional[str] = Query("name", pattern="^(name|halal_opportunity|population|chain)$"), + limit: int = Query(2000, ge=1, le=5000), + offset: int = Query(0, ge=0), +) -> dict[str, Any]: + clauses, params = _build_filters( + chain, province, city, partnership, halal_certified, has_halal_section, + store_type, postcode_prefix, q, min_population, max_population, + min_avg_income, max_avg_income, min_muslim_pct, has_area_data, + min_halal_opportunity, max_halal_opportunity, has_phone, has_email, + has_manager, halal_gap_only, linked_to_crm, has_halal_cert_registry, + ) + where = " WHERE " + " AND ".join(clauses) + order = { + "halal_opportunity": "COALESCE(ros.halal_opportunity_score,0) DESC, s.name", + "population": "COALESCE(a.population,0) DESC, s.name", + "chain": "s.chain, s.name", + "name": "s.chain, s.name", + }.get(sort or "name", "s.chain, s.name") + rows = fetch_all( + f"{STORE_SELECT}{where} ORDER BY {order} LIMIT %s OFFSET %s", + tuple(params + [limit, offset]), + ) + total = fetch_one( + f"""SELECT COUNT(*) AS n FROM supermarkets s + LEFT JOIN area_analysis a ON a.postcode = s.postcode + LEFT JOIN retail_opportunity_scores ros ON ros.supermarket_id = s.id + LEFT JOIN supermarket_profiles sp ON sp.supermarket_id = s.id + {where}""", + tuple(params), + ) + return { + "items": [_row(r) for r in rows], + "count": len(rows), + "total": int((total or {}).get("n") or 0), + } + + +@router.get("/map") +def map_points( + chain: Optional[str] = None, + province: Optional[str] = None, + partnership: Optional[str] = None, + halal_certified: Optional[bool] = None, + has_halal_section: Optional[bool] = None, + min_muslim_pct: Optional[float] = None, + min_population: Optional[int] = None, + min_halal_opportunity: Optional[float] = None, + halal_gap_only: Optional[bool] = None, + linked_to_crm: Optional[bool] = None, + q: Optional[str] = None, + limit: int = Query(5000, ge=1, le=5000), +) -> dict[str, Any]: + clauses, params = _build_filters( + chain, province, None, partnership, halal_certified, has_halal_section, + None, None, q, min_population, None, None, None, min_muslim_pct, None, + min_halal_opportunity, None, None, None, None, halal_gap_only, linked_to_crm, None, + ) + where = " WHERE " + " AND ".join(clauses) + " AND s.latitude IS NOT NULL AND s.longitude IS NOT NULL" + rows = fetch_all( + f"""SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, + s.latitude, s.longitude, s.partnership_status, s.halal_certified, + s.has_halal_section, s.phone, s.manager_name, + a.population AS area_population, + (a.religious_composition->>'muslim_proxy_pct')::float AS muslim_proxy_pct, + COALESCE(ros.halal_opportunity_score, s.halal_opportunity_score) AS halal_opportunity_score + FROM supermarkets s + LEFT JOIN area_analysis a ON a.postcode = s.postcode + LEFT JOIN retail_opportunity_scores ros ON ros.supermarket_id = s.id + LEFT JOIN supermarket_profiles sp ON sp.supermarket_id = s.id + {where} LIMIT %s""", + tuple(params + [limit]), + ) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.get("/supermarkets/{store_id}") +def get_supermarket(store_id: int) -> dict[str, Any]: + row = fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)) + data = _row(row) + area = fetch_one("SELECT * FROM area_analysis WHERE postcode = %s", (data.get("postcode"),)) + if area: + data["area_analysis"] = _row(area) + weather = fetch_all( + "SELECT * FROM weather_data WHERE city ILIKE %s ORDER BY date DESC LIMIT 3", + (f"%{data.get('city', '')}%",), + ) + data["weather"] = [_row(w) for w in weather] + recs = fetch_all( + """SELECT * FROM ai_recommendations + WHERE related_entity_type = 'supermarket' AND related_entity_id = %s + ORDER BY created_at DESC LIMIT 3""", + (store_id,), + ) + data["recommendations"] = [_row(r) for r in recs] + nearby = fetch_all( + """ + SELECT id, name, chain, partnership_status, distance_km FROM ( + SELECT id, name, chain, partnership_status, + (6371 * acos( + LEAST(1.0, cos(radians(%s)) * cos(radians(latitude)) + * cos(radians(longitude) - radians(%s)) + + sin(radians(%s)) * sin(radians(latitude))) + )) AS distance_km + FROM supermarkets + WHERE id <> %s AND latitude IS NOT NULL AND longitude IS NOT NULL + ) nearby_q + WHERE distance_km < 3 + ORDER BY distance_km LIMIT 8 + """, + ( + data.get("latitude"), data.get("longitude"), data.get("latitude"), + store_id, + ), + ) + data["nearby_stores"] = [_row(n) for n in nearby] + data["crm"] = retail_crm.get_store_crm_context(store_id) + opp = fetch_one("SELECT * FROM retail_opportunity_scores WHERE supermarket_id = %s", (store_id,)) + if opp: + data["opportunity"] = _row(opp) + return data + + +@router.get("/scrape/chains") +def list_scrape_chains() -> dict[str, Any]: + return {"chains": retail_scrapers.list_chains()} + + +@router.post("/scrape/all") +def scrape_all_chains() -> dict[str, Any]: + log_agent_event(agent_name="retail_scraper", event_type="scrape", title="OSM import all chains") + return retail_scrapers.import_all_chains() + + +@router.post("/scrape/{chain_key}") +def scrape_chain(chain_key: str) -> dict[str, Any]: + log_agent_event( + agent_name="retail_scraper", + event_type="scrape", + title=f"OSM import {chain_key}", + ) + try: + return retail_scrapers.import_chain(chain_key.lower()) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(502, str(exc)) from exc + + +@router.post("/enrich") +def enrich_areas( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> dict[str, Any]: + log_agent_event( + agent_name="retail_enrichment", + event_type="enrich", + title=f"CBS/PDOK enrichment batch limit={limit}", + ) + return retail_enrichment.enrich_batch(limit=limit, offset=offset) + + +@router.get("/enrich/status") +def enrich_status() -> dict[str, Any]: + return retail_enrichment.enrichment_status() + + +@router.get("/stats") +def retail_stats( + chain: Optional[str] = None, + province: Optional[str] = None, + partnership: Optional[str] = None, + min_muslim_pct: Optional[float] = None, +) -> dict[str, Any]: + clauses, params = _build_filters( + chain, province, None, partnership, None, None, None, None, None, + None, None, None, None, min_muslim_pct, None, + ) + where = " WHERE " + " AND ".join(clauses) + row = fetch_one( + f""" + SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE s.partnership_status = 'active') AS active_partnerships, + COUNT(*) FILTER (WHERE s.halal_certified) AS halal_certified, + COUNT(*) FILTER (WHERE a.id IS NOT NULL) AS with_area_data, + ROUND(AVG(a.avg_income)::numeric, 0) AS avg_area_income, + ROUND(AVG((a.religious_composition->>'muslim_proxy_pct')::float)::numeric, 1) AS avg_muslim_proxy_pct + FROM supermarkets s + LEFT JOIN area_analysis a ON a.postcode = s.postcode + {where} + """, + tuple(params), + ) + out = {k: int(v or 0) if k in ("total", "active_partnerships", "halal_certified", "with_area_data") else v + for k, v in (row or {}).items()} + if out.get("avg_area_income") is not None: + out["avg_area_income"] = float(out["avg_area_income"]) + if out.get("avg_muslim_proxy_pct") is not None: + out["avg_muslim_proxy_pct"] = float(out["avg_muslim_proxy_pct"]) + halal_n = fetch_one("SELECT COUNT(*) AS n FROM supermarkets WHERE halal_certified = TRUE") + out["halal_certified_count"] = int((halal_n or {}).get("n") or 0) + return out + + +@router.get("/schema") +def retail_schema() -> dict[str, Any]: + return {"groups": FIELD_SCHEMA, "all_fields": [f for fields in FIELD_SCHEMA.values() for f in fields]} + + +@router.get("/crm/options") +def crm_options() -> dict[str, Any]: + return retail_crm.list_crm_options() + + +@router.post("/supermarkets/{store_id}/link") +def link_store_crm(store_id: int, payload: CrmLinkIn) -> dict[str, Any]: + try: + row = retail_crm.link_client_to_store( + store_id, payload.client_id, payload.deal_id, + payload.relationship_type, payload.partnership_status, payload.notes, + ) + log_agent_event(agent_name="retail_crm", event_type="link", title=f"Linked store {store_id} to client {payload.client_id}") + return {"link": row} + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.delete("/supermarkets/{store_id}/link/{client_id}") +def unlink_store_crm(store_id: int, client_id: int) -> dict[str, Any]: + ok = retail_crm.unlink_client_from_store(store_id, client_id) + return {"unlinked": ok} + + +@router.get("/halal") +def list_halal_stores(limit: int = Query(500, ge=1, le=2000)) -> dict[str, Any]: + rows = halal_registry.list_halal_certified(limit) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.get("/opportunities") +def list_opportunities( + limit: int = Query(50, ge=1, le=500), + min_score: float = Query(30, ge=0, le=100), + chain: Optional[str] = None, + province: Optional[str] = None, +) -> dict[str, Any]: + rows = retail_opportunities.top_opportunities(limit, min_score, chain, province) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.get("/trends") +def list_trends(limit: int = Query(20, ge=1, le=100)) -> dict[str, Any]: + rows = trends_feed.list_live_trends(limit) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.post("/sync/halal") +def sync_halal() -> dict[str, Any]: + log_agent_event(agent_name="halal_registry", event_type="sync", title="Halal OSM sync") + return halal_registry.sync_osm_halal_tags() + + +@router.post("/sync/contacts") +def sync_contacts(limit: int = Query(100, ge=1, le=300)) -> dict[str, Any]: + log_agent_event(agent_name="branch_scraper", event_type="sync", title=f"OSM contacts limit={limit}") + return halal_registry.sync_osm_contact_tags(limit) + + +@router.post("/sync/trends") +def sync_trends() -> dict[str, Any]: + return trends_feed.refresh_trends_from_social() + + +@router.post("/compute-opportunities") +def compute_opportunities(limit: int = Query(5000, ge=100, le=10000)) -> dict[str, Any]: + log_agent_event(agent_name="retail_intel", event_type="score", title="Compute halal opportunity scores") + return retail_opportunities.compute_all_scores(limit) + + +@router.get("/export") +def export_csv( + chain: Optional[str] = None, + province: Optional[str] = None, + halal_certified: Optional[bool] = None, + min_halal_opportunity: Optional[float] = None, + limit: int = Query(5000, ge=1, le=5000), +): + clauses, params = _build_filters( + chain, province, None, None, halal_certified, None, None, None, None, + None, None, None, None, None, None, min_halal_opportunity, None, + None, None, None, None, None, None, + ) + where = " WHERE " + " AND ".join(clauses) + rows = fetch_all( + f"{STORE_SELECT}{where} ORDER BY s.chain, s.name LIMIT %s", + tuple(params + [limit]), + ) + + def generate(): + headers = ["id", "name", "chain", "city", "province", "postcode", "phone", "email", + "manager_name", "halal_certified", "has_halal_section", "partnership_status", + "muslim_proxy_pct", "area_population", "area_avg_income", "halal_opportunity_score"] + yield ",".join(headers) + "\n" + for r in rows: + rel = r.get("area_religious") or {} + if isinstance(rel, str): + rel = {} + vals = [ + r.get("id"), r.get("name"), r.get("chain"), r.get("city"), r.get("province"), + r.get("postcode"), r.get("phone"), r.get("email"), r.get("manager_name"), + r.get("halal_certified"), r.get("has_halal_section"), r.get("partnership_status"), + rel.get("muslim_proxy_pct") if isinstance(rel, dict) else None, + r.get("area_population"), r.get("area_avg_income"), + r.get("opp_halal_score") or r.get("halal_opportunity_score"), + ] + yield ",".join('"' + str(v or "").replace('"', '""') + '"' for v in vals) + "\n" + + return StreamingResponse(generate(), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=retail_export.csv"}) diff --git a/tools-api/app/retail_360.py b/tools-api/app/retail_360.py new file mode 100644 index 0000000..b673ffa --- /dev/null +++ b/tools-api/app/retail_360.py @@ -0,0 +1,182 @@ +"""City demographics and 360 entity workspace.""" +from __future__ import annotations + +from typing import Any, Optional + +from app.connectors import cbs, pdok +from app.db import execute, execute_returning, fetch_all, fetch_one, json_param + + +def sync_city_demographics(limit: int = 100) -> dict[str, Any]: + cities = fetch_all( + """SELECT DISTINCT city, province FROM supermarkets + WHERE city IS NOT NULL AND city <> 'Onbekend' + AND city NOT IN (SELECT city FROM city_demographics) + LIMIT %s""", + (limit,), + ) + synced = 0 + for row in cities: + pc_rows = fetch_all( + "SELECT postcode FROM supermarkets WHERE city = %s AND postcode <> '0000AA' LIMIT 1", + (row["city"],), + ) + if not pc_rows: + continue + pd = pdok.lookup_postcode(pc_rows[0]["postcode"]) + if not pd or not pd.get("municipality_code"): + continue + stats = cbs.fetch_gemeente_stats(pd["municipality_code"]) + if not stats: + continue + rel = stats.get("religious_composition") or {} + execute_returning( + """INSERT INTO city_demographics (city, province, gemeente_code, population, households, + avg_income, muslim_proxy_pct, data_source, last_updated) + VALUES (%s,%s,%s,%s,%s,%s,%s,'cbs+pdok',NOW()) + ON CONFLICT (city, province) DO UPDATE SET + population=EXCLUDED.population, households=EXCLUDED.households, + avg_income=EXCLUDED.avg_income, muslim_proxy_pct=EXCLUDED.muslim_proxy_pct, + last_updated=NOW() RETURNING id""", + ( + row["city"], row.get("province") or pd.get("province"), + pd.get("municipality_code"), stats.get("population"), stats.get("households"), + stats.get("avg_income"), rel.get("muslim_proxy_pct"), + ), + ) + synced += 1 + return {"synced": synced} + + +def get_city_context(city: str, province: Optional[str] = None) -> Optional[dict[str, Any]]: + if province: + row = fetch_one( + "SELECT * FROM city_demographics WHERE city ILIKE %s AND province ILIKE %s", + (city, province), + ) + else: + row = fetch_one("SELECT * FROM city_demographics WHERE city ILIKE %s LIMIT 1", (city,)) + if not row: + return None + stores = fetch_one( + "SELECT COUNT(*) AS n FROM supermarkets WHERE city ILIKE %s", (city,) + ) + out = dict(row) + out["stores_in_city"] = int((stores or {}).get("n") or 0) + return out + + +def get_store_360(supermarket_id: int) -> dict[str, Any]: + store = fetch_one("SELECT * FROM supermarkets WHERE id = %s", (supermarket_id,)) + if not store: + raise ValueError("Store not found") + city_ctx = get_city_context(store["city"], store.get("province")) + notes = fetch_all( + "SELECT * FROM entity_notes WHERE entity_type='supermarket' AND entity_id=%s ORDER BY pinned DESC, created_at DESC", + (supermarket_id,), + ) + media = fetch_all( + "SELECT * FROM entity_media WHERE entity_type='supermarket' AND entity_id=%s ORDER BY created_at DESC", + (supermarket_id,), + ) + milestones = fetch_all( + "SELECT * FROM sales_milestones WHERE supermarket_id=%s ORDER BY sort_order, target_date NULLS LAST", + (supermarket_id,), + ) + ownership = fetch_all( + """SELECT * FROM ownership_changes + WHERE (entity_id=%s AND entity_type='supermarket') OR chain ILIKE %s + ORDER BY effective_date DESC NULLS LAST LIMIT 10""", + (supermarket_id, f"%{store['chain']}%"), + ) + calendar = fetch_all( + """SELECT * FROM calendar_events WHERE supermarket_id=%s OR (client_id=%s AND client_id IS NOT NULL) + ORDER BY starts_at DESC LIMIT 10""", + (supermarket_id, store.get("client_id")), + ) + nearby_count = fetch_one( + "SELECT COUNT(*) AS n FROM supermarkets WHERE city ILIKE %s AND id <> %s", + (store["city"], supermarket_id), + ) + area = fetch_one("SELECT * FROM area_analysis WHERE postcode = %s", (store.get("postcode"),)) + weather = fetch_all( + "SELECT * FROM weather_data WHERE city ILIKE %s ORDER BY date DESC LIMIT 7", + (f"%{store.get('city', '')}%",), + ) + return { + "store": dict(store), + "city": city_ctx, + "catchment": { + "city_population": (city_ctx or {}).get("population"), + "stores_in_city": int((nearby_count or {}).get("n") or 0), + "gemeente_population": (area or {}).get("population"), + "postcode_population_proxy": (area or {}).get("population"), + }, + "notes": [dict(n) for n in notes], + "media": [dict(m) for m in media], + "milestones": [dict(m) for m in milestones], + "ownership_changes": [dict(o) for o in ownership], + "calendar": [dict(c) for c in calendar], + "area_analysis": dict(area) if area else None, + "weather": [dict(w) for w in weather], + } + + +def add_note(entity_type: str, entity_id: int, body: str, title: Optional[str] = None, note_type: str = "general") -> dict[str, Any]: + row = execute_returning( + """INSERT INTO entity_notes (entity_type, entity_id, title, body, note_type) + VALUES (%s,%s,%s,%s,%s) RETURNING *""", + (entity_type, entity_id, title, body, note_type), + ) + return dict(row or {}) + + +def add_milestone(supermarket_id: int, title: str, milestone_type: str = "custom", **kwargs: Any) -> dict[str, Any]: + row = execute_returning( + """INSERT INTO sales_milestones (supermarket_id, client_id, deal_id, milestone_type, title, + status, target_date, value_eur, notes, sort_order) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + ( + supermarket_id, kwargs.get("client_id"), kwargs.get("deal_id"), milestone_type, title, + kwargs.get("status", "pending"), kwargs.get("target_date"), kwargs.get("value_eur"), + kwargs.get("notes"), kwargs.get("sort_order", 0), + ), + ) + return dict(row or {}) + + +def add_ownership(**kwargs: Any) -> dict[str, Any]: + row = execute_returning( + """INSERT INTO ownership_changes (entity_type, entity_id, chain, previous_owner, new_owner, + change_type, effective_date, source, notes) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""", + ( + kwargs.get("entity_type", "supermarket"), kwargs.get("entity_id"), kwargs.get("chain"), + kwargs.get("previous_owner"), kwargs["new_owner"], kwargs.get("change_type", "acquisition"), + kwargs.get("effective_date"), kwargs.get("source"), kwargs.get("notes"), + ), + ) + return dict(row or {}) + + +def add_calendar_event(supermarket_id: int, title: str, starts_at: str, **kwargs: Any) -> dict[str, Any]: + row = execute_returning( + """INSERT INTO calendar_events (title, description, starts_at, ends_at, client_id, deal_id, + supermarket_id, location, source) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'retail_360') RETURNING *""", + ( + title, kwargs.get("description"), starts_at, kwargs.get("ends_at"), + kwargs.get("client_id"), kwargs.get("deal_id"), supermarket_id, + kwargs.get("location"), + ), + ) + return dict(row or {}) + + +def register_media(entity_type: str, entity_id: int, filename: str, storage_path: str, content_type: str, caption: Optional[str] = None) -> dict[str, Any]: + row = execute_returning( + """INSERT INTO entity_media (entity_type, entity_id, filename, storage_path, content_type, caption) + VALUES (%s,%s,%s,%s,%s,%s) RETURNING *""", + (entity_type, entity_id, filename, storage_path, content_type, caption), + ) + return dict(row or {}) diff --git a/tools-api/app/retail_360_routes.py b/tools-api/app/retail_360_routes.py new file mode 100644 index 0000000..c66967c --- /dev/null +++ b/tools-api/app/retail_360_routes.py @@ -0,0 +1,511 @@ +"""Retail 360 workspace API — notes, media, milestones, RSS, wholesalers.""" +from __future__ import annotations + +import json +import urllib.request +from datetime import datetime +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from app.db import fetch_all, fetch_one +from app.middleware import log_agent_event +from app import retail_360 +from app import wholesaler_scrapers +from app.connectors import market_stocks, rss_feeds +from app.connectors import food_trends + +router = APIRouter(prefix="/retail", tags=["retail-360"]) + + +class NoteIn(BaseModel): + body: str = Field(..., min_length=1) + title: Optional[str] = None + note_type: str = "general" + + +class MilestoneIn(BaseModel): + title: str + milestone_type: str = "custom" + client_id: Optional[int] = None + deal_id: Optional[int] = None + target_date: Optional[str] = None + value_eur: Optional[float] = None + notes: Optional[str] = None + + +class OwnershipIn(BaseModel): + new_owner: str + previous_owner: Optional[str] = None + change_type: str = "acquisition" + effective_date: Optional[str] = None + source: Optional[str] = None + notes: Optional[str] = None + + +class CalendarIn(BaseModel): + title: str + starts_at: str + description: Optional[str] = None + ends_at: Optional[str] = None + client_id: Optional[int] = None + deal_id: Optional[int] = None + location: Optional[str] = None + + +class MediaIn(BaseModel): + filename: str + storage_path: str + content_type: str = "image/jpeg" + caption: Optional[str] = None + + +def _row(row: dict | None) -> dict[str, Any]: + if not row: + raise HTTPException(404, "Not found") + out: dict[str, Any] = {} + for k, v in row.items(): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + elif v is not None and hasattr(v, "__float__") and type(v).__name__ == "Decimal": + out[k] = float(v) + else: + out[k] = v + return out + + +def _fetch_weather_forecast(lat: float, lon: float) -> list[dict[str, Any]]: + url = ( + f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" + f"&daily=temperature_2m_max,precipitation_sum,weathercode" + f"&timezone=Europe%2FAmsterdam&forecast_days=7" + ) + try: + with urllib.request.urlopen(url, timeout=15) as resp: + data = json.loads(resp.read().decode()) + days = data.get("daily", {}).get("time", []) + temps = data.get("daily", {}).get("temperature_2m_max", []) + prec = data.get("daily", {}).get("precipitation_sum", []) + return [ + {"date": days[i], "temperature_c": temps[i] if i < len(temps) else None, + "precipitation_mm": prec[i] if i < len(prec) else None, "source": "open-meteo-live"} + for i in range(len(days)) + ] + except Exception: + return [] + + +@router.get("/360/{store_id}") +def get_360_view(store_id: int) -> dict[str, Any]: + try: + data = retail_360.get_store_360(store_id) + except ValueError as exc: + raise HTTPException(404, str(exc)) from exc + store = data["store"] + if store.get("latitude") and store.get("longitude"): + live = _fetch_weather_forecast(float(store["latitude"]), float(store["longitude"])) + if live: + data["weather_forecast"] = live + for key in ("notes", "media", "milestones", "ownership_changes", "calendar", "weather"): + data[key] = [_row(x) for x in data.get(key, [])] + if data.get("area_analysis"): + data["area_analysis"] = _row(data["area_analysis"]) + return data + + +@router.post("/360/{store_id}/notes") +def add_store_note(store_id: int, payload: NoteIn) -> dict[str, Any]: + note = retail_360.add_note("supermarket", store_id, payload.body, payload.title, payload.note_type) + log_agent_event(agent_name="retail_360", event_type="note", title=f"Note on store {store_id}") + return {"note": _row(note)} + + +@router.post("/360/{store_id}/milestones") +def add_store_milestone(store_id: int, payload: MilestoneIn) -> dict[str, Any]: + ms = retail_360.add_milestone(store_id, payload.title, payload.milestone_type, **payload.model_dump(exclude={"title", "milestone_type"})) + return {"milestone": _row(ms)} + + +@router.post("/360/{store_id}/ownership") +def add_store_ownership(store_id: int, payload: OwnershipIn) -> dict[str, Any]: + store = fetch_one("SELECT chain FROM supermarkets WHERE id = %s", (store_id,)) + row = retail_360.add_ownership(entity_id=store_id, chain=store.get("chain") if store else None, **payload.model_dump()) + return {"ownership": _row(row)} + + +@router.post("/360/{store_id}/calendar") +def add_store_calendar(store_id: int, payload: CalendarIn) -> dict[str, Any]: + ev = retail_360.add_calendar_event(store_id, payload.title, payload.starts_at, **payload.model_dump(exclude={"title", "starts_at"})) + return {"event": _row(ev)} + + +@router.post("/360/{store_id}/media") +def register_store_media(store_id: int, payload: MediaIn) -> dict[str, Any]: + media = retail_360.register_media("supermarket", store_id, payload.filename, payload.storage_path, payload.content_type, payload.caption) + return {"media": _row(media)} + + +@router.get("/cities") +def list_cities( + limit: int = Query(200, ge=1, le=1000), + q: Optional[str] = None, + min_population: Optional[int] = None, + min_muslim_pct: Optional[float] = None, + sort: str = Query("population", pattern="^(population|muslim|stores|city)$"), +) -> dict[str, Any]: + clauses, params = [], [] + if q: + clauses.append("c.city ILIKE %s") + params.append(f"%{q}%") + if min_population: + clauses.append("c.population >= %s") + params.append(min_population) + if min_muslim_pct: + clauses.append("c.muslim_proxy_pct >= %s") + params.append(min_muslim_pct) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + order_map = { + "population": "c.population DESC NULLS LAST", + "muslim": "c.muslim_proxy_pct DESC NULLS LAST", + "stores": "store_count DESC", + "city": "c.city ASC", + } + order = order_map.get(sort, order_map["population"]) + rows = fetch_all( + f"""SELECT c.*, (SELECT COUNT(*) FROM supermarkets s WHERE s.city ILIKE c.city) AS store_count + FROM city_demographics c{where} ORDER BY {order} LIMIT %s""", + tuple(params + [limit]), + ) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.post("/cities/sync") +def sync_cities(limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: + return retail_360.sync_city_demographics(limit) + + +@router.get("/wholesalers") +def list_wholesalers( + limit: int = Query(500, ge=1, le=2000), + q: Optional[str] = None, + province: Optional[str] = None, + city: Optional[str] = None, + halal_certified: Optional[bool] = None, + has_phone: Optional[bool] = None, + has_email: Optional[bool] = None, + sort: str = Query("name", pattern="^(name|city|province)$"), +) -> dict[str, Any]: + clauses, params = [], [] + if q: + clauses.append("(name ILIKE %s OR city ILIKE %s OR address ILIKE %s OR email ILIKE %s)") + like = f"%{q}%" + params.extend([like, like, like, like]) + if province: + clauses.append("province ILIKE %s") + params.append(province) + if city: + clauses.append("city ILIKE %s") + params.append(f"%{city}%") + if halal_certified is True: + clauses.append("halal_certified = TRUE") + if has_phone is True: + clauses.append("phone IS NOT NULL AND phone <> ''") + if has_email is True: + clauses.append("email IS NOT NULL AND email <> ''") + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + order = {"name": "name", "city": "city", "province": "province"}.get(sort, "name") + rows = fetch_all(f"SELECT * FROM wholesalers{where} ORDER BY {order} LIMIT %s", tuple(params + [limit])) + total = fetch_one(f"SELECT COUNT(*) AS n FROM wholesalers{where}", tuple(params) if params else None) + return {"items": [_row(r) for r in rows], "count": len(rows), "total": int(total["n"]) if total else len(rows)} + + +@router.get("/wholesalers/meta") +def wholesalers_meta() -> dict[str, Any]: + provinces = fetch_all( + "SELECT province, COUNT(*) AS n FROM wholesalers WHERE province IS NOT NULL GROUP BY province ORDER BY n DESC" + ) + return { + "provinces": [_row(p) for p in provinces], + "total": _safe_count_wh("wholesalers"), + } + + +def _safe_count_wh(table: str) -> int: + row = fetch_one(f"SELECT COUNT(*) AS n FROM {table}") + return int(row["n"]) if row else 0 + + +@router.get("/wholesalers/{wh_id}/contacts") +def wholesaler_contacts(wh_id: int) -> dict[str, Any]: + rows = fetch_all( + "SELECT * FROM wholesaler_contacts WHERE wholesaler_id = %s ORDER BY confidence DESC, full_name", + (wh_id,), + ) + wh = fetch_one("SELECT id, name, phone, email, address, city, province, website, linkedin_url FROM wholesalers WHERE id = %s", (wh_id,)) + if not wh: + raise HTTPException(404, "Wholesaler not found") + return {"wholesaler": _row(wh), "contacts": [_row(r) for r in rows]} + + +class WholesalerContactIn(BaseModel): + full_name: str + role: str = "contact" + phone: Optional[str] = None + email: Optional[str] = None + linkedin_url: Optional[str] = None + + +@router.post("/wholesalers/{wh_id}/contacts") +def add_wholesaler_contact(wh_id: int, payload: WholesalerContactIn) -> dict[str, Any]: + row = fetch_one( + """INSERT INTO wholesaler_contacts (wholesaler_id, full_name, role, phone, email, linkedin_url, source) + VALUES (%s, %s, %s, %s, %s, %s, 'manual') RETURNING *""", + (wh_id, payload.full_name, payload.role, payload.phone, payload.email, payload.linkedin_url), + ) + return {"contact": _row(row)} + + +class RssBookmarkIn(BaseModel): + rss_item_id: int + title: Optional[str] = None + link: Optional[str] = None + feed_name: Optional[str] = None + notes: Optional[str] = None + + +@router.get("/rss/bookmarks") +def list_rss_bookmarks(limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: + rows = fetch_all( + """SELECT b.*, i.title AS item_title, i.link AS item_link, f.name AS feed_name + FROM rss_bookmarks b + LEFT JOIN rss_items i ON i.id = b.rss_item_id + LEFT JOIN rss_feeds f ON f.id = i.feed_id + ORDER BY b.created_at DESC LIMIT %s""", + (limit,), + ) + out = [] + for r in rows: + row = _row(r) + row["title"] = row.get("title") or row.get("item_title") + row["link"] = row.get("link") or row.get("item_link") + out.append(row) + return {"items": out, "count": len(out)} + + +@router.post("/rss/bookmarks") +def add_rss_bookmark(payload: RssBookmarkIn) -> dict[str, Any]: + from app.db import execute + item = fetch_one("SELECT id, title, link FROM rss_items WHERE id = %s", (payload.rss_item_id,)) + if not item: + raise HTTPException(404, "RSS item not found") + execute( + """INSERT INTO rss_bookmarks (rss_item_id, title, link, feed_name, notes) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (rss_item_id) DO UPDATE SET title=EXCLUDED.title, link=EXCLUDED.link, feed_name=EXCLUDED.feed_name, notes=EXCLUDED.notes""", + ( + payload.rss_item_id, + payload.title or item.get("title"), + payload.link or item.get("link"), + payload.feed_name, + payload.notes, + ), + ) + row = fetch_one("SELECT * FROM rss_bookmarks WHERE rss_item_id = %s", (payload.rss_item_id,)) + return {"bookmark": _row(row)} + + +@router.delete("/rss/bookmarks/{rss_item_id}") +def delete_rss_bookmark(rss_item_id: int) -> dict[str, Any]: + from app.db import execute + execute("DELETE FROM rss_bookmarks WHERE rss_item_id = %s", (rss_item_id,)) + return {"ok": True} + + +@router.get("/promo-campaigns") +def list_promo_campaigns( + chain: Optional[str] = None, + status: str = Query("active"), + q: Optional[str] = None, + folder_type: Optional[str] = None, + valid_days: Optional[int] = Query(None, ge=1, le=365), + limit: int = Query(100, ge=1, le=500), +) -> dict[str, Any]: + clauses, params = ["p.status = %s"], [status] + if chain: + clauses.append("p.chain ILIKE %s") + params.append(f"%{chain}%") + if q: + clauses.append("(p.title ILIKE %s OR p.chain ILIKE %s OR p.description ILIKE %s)") + params.extend([f"%{q}%"] * 3) + if folder_type: + clauses.append("(p.promo_type ILIKE %s OR p.metadata->>'folder_type' ILIKE %s)") + params.extend([f"%{folder_type}%", f"%{folder_type}%"]) + if valid_days: + clauses.append("p.valid_to IS NOT NULL AND p.valid_to <= CURRENT_DATE + %s * INTERVAL '1 day'") + params.append(valid_days) + where = " WHERE " + " AND ".join(clauses) + rows = fetch_all( + f"""SELECT p.*, s.name AS store_name FROM promo_campaigns p + LEFT JOIN supermarkets s ON s.id = p.supermarket_id + {where} ORDER BY p.valid_to ASC NULLS LAST, p.chain ASC, p.created_at DESC LIMIT %s""", + tuple(params + [limit]), + ) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.get("/reclamefolder/chains") +def reclamefolder_chains() -> dict[str, Any]: + from app.connectors import reclamefolder + chains = reclamefolder.list_chains() + return {"chains": chains, "count": len(chains)} + + +class PromoCampaignIn(BaseModel): + chain: Optional[str] = None + title: str + folder_path: Optional[str] = None + folder_label: Optional[str] = None + description: Optional[str] = None + valid_from: Optional[str] = None + valid_to: Optional[str] = None + status: str = "active" + promo_type: str = "folder" + + +@router.post("/promo-campaigns") +def add_promo_campaign(payload: PromoCampaignIn) -> dict[str, Any]: + row = fetch_one( + """INSERT INTO promo_campaigns (chain, title, folder_path, folder_label, description, valid_from, valid_to, status, promo_type) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING *""", + ( + payload.chain, payload.title, payload.folder_path, payload.folder_label, + payload.description, payload.valid_from, payload.valid_to, payload.status, payload.promo_type, + ), + ) + return {"campaign": _row(row)} + + +@router.post("/reclamefolder/refresh") +def refresh_reclamefolders() -> dict[str, Any]: + from app.connectors import reclamefolder + from app.middleware import log_agent_event + + log_agent_event( + agent_name="reclamefolder", + event_type="refresh", + title="Reclamefolder.nl sync", + ) + return reclamefolder.sync_to_db() + + +@router.get("/reclamefolder/live") +def live_reclamefolders(limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: + from app.connectors import reclamefolder + + try: + result = reclamefolder.sync_to_db() + items = result.get("items") or [] + except Exception as exc: + items = reclamefolder.list_cached(limit) + return {"items": items, "count": len(items), "cached": True, "error": str(exc)} + return {"items": items[:limit], "count": len(items), "synced": True, "source": "reclamefolder.nl"} + + +@router.post("/wholesalers/import") +def import_wholesalers(background: bool = Query(False)) -> dict[str, Any]: + log_agent_event(agent_name="wholesale_scraper", event_type="import", title="OSM wholesalers import") + if background: + import threading + threading.Thread(target=wholesaler_scrapers.import_wholesalers, daemon=True).start() + return {"status": "started", "message": "Wholesaler import running in background"} + return wholesaler_scrapers.import_wholesalers() + + +@router.get("/rss/live") +def rss_live(limit: int = Query(30, ge=1, le=100), category: Optional[str] = None) -> dict[str, Any]: + rows = rss_feeds.list_live_feed(limit, category) + return {"items": [_row(r) for r in rows], "count": len(rows)} + + +@router.post("/rss/refresh") +def rss_refresh() -> dict[str, Any]: + log_agent_event(agent_name="rss_feeds", event_type="refresh", title="RSS feeds refresh") + return rss_feeds.refresh_all_feeds() + + +@router.get("/market/stocks") +def retail_market_stocks() -> dict[str, Any]: + quotes = market_stocks.fetch_retail_quotes() + return { + "items": quotes, + "summary": market_stocks.market_summary(quotes), + "updated_at": datetime.utcnow().isoformat(), + } + + +@router.get("/market/supermarkets") +def supermarket_market_board() -> dict[str, Any]: + quotes = market_stocks.fetch_supermarket_quotes() + listed = [q for q in quotes if q.get("listed")] + return { + "items": [_row(q) for q in quotes], + "listed": [_row(q) for q in listed], + "unlisted_nl": [_row(q) for q in quotes if not q.get("listed")], + "summary": market_stocks.market_summary(listed), + "data_source": market_stocks.DATA_SOURCE, + "updated_at": datetime.utcnow().isoformat(), + } + + +@router.get("/market/food-trends") +def market_food_trends() -> dict[str, Any]: + return food_trends.food_trends_dashboard() + + +@router.get("/market/concepts") +def market_concepts(limit: int = Query(6, ge=1, le=12)) -> dict[str, Any]: + listed = market_stocks.fetch_retail_quotes() + summary = market_stocks.market_summary(listed) + best = summary.get("best_performer") + concepts = food_trends.generate_concepts(market_best=best, limit=limit) + return { + "concepts": concepts, + "summary": summary, + "updated_at": datetime.utcnow().isoformat(), + } + + +@router.get("/regulations") +def retail_regulations(limit: int = Query(30, ge=1, le=100)) -> dict[str, Any]: + reg = rss_feeds.list_live_feed(limit, "regelgeving") + cbs = rss_feeds.list_live_feed(limit, "cbs") + markt = rss_feeds.list_live_feed(min(limit, 15), "markt") + return { + "regelgeving": [_row(r) for r in reg], + "cbs": [_row(r) for r in cbs], + "markt": [_row(r) for r in markt], + "updated_at": datetime.utcnow().isoformat(), + } + + +@router.get("/live-dashboard") +def live_dashboard() -> dict[str, Any]: + trends = fetch_all( + "SELECT * FROM market_trends ORDER BY updated_at DESC NULLS LAST LIMIT 8" + ) + rss = rss_feeds.list_live_feed(12) + 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""" + ) + quotes = market_stocks.fetch_retail_quotes() + return { + "trends": [_row(t) for t in trends], + "rss": [_row(r) for r in rss], + "top_opportunities": [_row(o) for o in opportunities], + "market_stocks": quotes, + "market_summary": market_stocks.market_summary(quotes), + "updated_at": datetime.utcnow().isoformat(), + } diff --git a/tools-api/app/retail_crm.py b/tools-api/app/retail_crm.py new file mode 100644 index 0000000..aeed44c --- /dev/null +++ b/tools-api/app/retail_crm.py @@ -0,0 +1,96 @@ +"""CRM linking for retail locations.""" +from __future__ import annotations + +from typing import Any, Optional + +from app.db import execute, execute_returning, fetch_all, fetch_one + + +def link_client_to_store( + supermarket_id: int, + client_id: int, + deal_id: Optional[int] = None, + relationship_type: str = "prospect", + partnership_status: Optional[str] = None, + notes: Optional[str] = None, +) -> dict[str, Any]: + store = fetch_one("SELECT id FROM supermarkets WHERE id = %s", (supermarket_id,)) + client = fetch_one("SELECT id, name FROM clients WHERE id = %s", (client_id,)) + if not store or not client: + raise ValueError("Store or client not found") + + row = execute_returning( + """ + INSERT INTO client_supermarket_links (client_id, supermarket_id, deal_id, relationship_type, notes) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (client_id, supermarket_id) DO UPDATE SET + deal_id = COALESCE(EXCLUDED.deal_id, client_supermarket_links.deal_id), + relationship_type = EXCLUDED.relationship_type, + notes = COALESCE(EXCLUDED.notes, client_supermarket_links.notes) + RETURNING * + """, + (client_id, supermarket_id, deal_id, relationship_type, notes), + ) + if partnership_status: + execute( + """UPDATE supermarkets SET client_id = %s, deal_id = %s, + partnership_status = %s, last_updated = NOW() WHERE id = %s""", + (client_id, deal_id, partnership_status, supermarket_id), + ) + return dict(row or {}) + + +def unlink_client_from_store(supermarket_id: int, client_id: int) -> bool: + n = execute( + "DELETE FROM client_supermarket_links WHERE supermarket_id = %s AND client_id = %s", + (supermarket_id, client_id), + ) + execute( + """UPDATE supermarkets SET client_id = NULL, deal_id = NULL, + partnership_status = 'none', last_updated = NOW() + WHERE id = %s AND client_id = %s""", + (supermarket_id, client_id), + ) + return n > 0 + + +def get_store_crm_context(supermarket_id: int) -> dict[str, Any]: + links = fetch_all( + """ + SELECT l.*, c.name AS client_name, c.email AS client_email, c.contact AS client_contact, + c.stage AS client_stage, d.title AS deal_title, d.value AS deal_value, d.stage AS deal_stage + FROM client_supermarket_links l + JOIN clients c ON c.id = l.client_id + LEFT JOIN deals d ON d.id = l.deal_id + WHERE l.supermarket_id = %s + ORDER BY l.created_at DESC + """, + (supermarket_id,), + ) + contacts = fetch_all( + "SELECT * FROM supermarket_contacts WHERE supermarket_id = %s ORDER BY confidence DESC", + (supermarket_id,), + ) + profile = fetch_one("SELECT * FROM supermarket_profiles WHERE supermarket_id = %s", (supermarket_id,)) + halal = fetch_all( + "SELECT * FROM halal_certifications WHERE supermarket_id = %s ORDER BY matched_confidence DESC", + (supermarket_id,), + ) + return { + "links": [dict(x) for x in links], + "contacts": [dict(x) for x in contacts], + "profile": dict(profile) if profile else None, + "halal_certifications": [dict(x) for x in halal], + } + + +def list_crm_options() -> dict[str, Any]: + clients = fetch_all( + "SELECT id, name, contact, email, stage, sector FROM clients ORDER BY name LIMIT 500" + ) + deals = fetch_all( + """SELECT d.id, d.title, d.value, d.stage, d.client_id, c.name AS client_name + FROM deals d LEFT JOIN clients c ON c.id = d.client_id + WHERE d.stage NOT IN ('won','lost') ORDER BY d.updated_at DESC LIMIT 200""" + ) + return {"clients": [dict(c) for c in clients], "deals": [dict(d) for d in deals]} diff --git a/tools-api/app/retail_enrichment.py b/tools-api/app/retail_enrichment.py new file mode 100644 index 0000000..2babdda --- /dev/null +++ b/tools-api/app/retail_enrichment.py @@ -0,0 +1,158 @@ +"""Enrich supermarket postcodes with PDOK geocoding + CBS demografie.""" +from __future__ import annotations + +import time +from typing import Any, Optional + +from app.connectors import cbs, pdok +from app.db import execute, execute_returning, fetch_all, fetch_one, json_param + + +def _postcodes_to_enrich(limit: int = 100, offset: int = 0) -> list[str]: + rows = fetch_all( + """ + SELECT DISTINCT s.postcode + FROM supermarkets s + LEFT JOIN area_analysis a ON a.postcode = s.postcode + WHERE s.postcode IS NOT NULL + AND s.postcode <> '0000AA' + AND a.id IS NULL + ORDER BY s.postcode + LIMIT %s OFFSET %s + """, + (limit, offset), + ) + return [r["postcode"] for r in rows] + + +def enrich_postcode(postcode: str) -> dict[str, Any]: + pc = pdok.normalize_postcode(postcode) + pdok_data = pdok.lookup_postcode(pc) + if not pdok_data: + return {"postcode": pc, "status": "pdok_not_found"} + + gm_code = pdok_data.get("municipality_code") + cbs_data = cbs.fetch_gemeente_stats(gm_code) if gm_code else None + if not cbs_data: + return {"postcode": pc, "status": "cbs_not_found", "pdok": pdok_data} + + religious = cbs_data.get("religious_composition") or {} + row = execute_returning( + """ + INSERT INTO area_analysis ( + postcode, city, population, households, avg_household_size, + avg_income, median_income, education_level, ethnic_composition, + religious_composition, unemployment_rate, housing_type, car_ownership, + data_source, last_updated + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW()) + ON CONFLICT (postcode) DO UPDATE SET + city = EXCLUDED.city, + population = EXCLUDED.population, + households = EXCLUDED.households, + avg_household_size = EXCLUDED.avg_household_size, + avg_income = EXCLUDED.avg_income, + median_income = EXCLUDED.median_income, + education_level = EXCLUDED.education_level, + ethnic_composition = EXCLUDED.ethnic_composition, + religious_composition = EXCLUDED.religious_composition, + housing_type = EXCLUDED.housing_type, + car_ownership = EXCLUDED.car_ownership, + data_source = EXCLUDED.data_source, + last_updated = NOW() + RETURNING id, postcode + """, + ( + pc, + pdok_data.get("city") or cbs_data.get("city"), + cbs_data.get("population"), + cbs_data.get("households"), + cbs_data.get("avg_household_size"), + cbs_data.get("avg_income"), + cbs_data.get("median_income"), + json_param(cbs_data.get("education_level")), + json_param(cbs_data.get("ethnic_composition")), + json_param(religious), + cbs_data.get("unemployment_rate"), + json_param(cbs_data.get("housing_type")), + cbs_data.get("car_ownership"), + f"cbs+pdok ({cbs_data.get('data_granularity', 'gemeente')})", + ), + ) + + execute( + """ + UPDATE supermarkets SET + city = COALESCE(NULLIF(city, 'Onbekend'), %s), + province = COALESCE(province, %s), + latitude = COALESCE(latitude, %s), + longitude = COALESCE(longitude, %s), + last_updated = NOW() + WHERE postcode = %s AND ( + city = 'Onbekend' OR province IS NULL OR latitude IS NULL OR longitude IS NULL + ) + """, + ( + pdok_data.get("city"), + pdok_data.get("province"), + pdok_data.get("latitude"), + pdok_data.get("longitude"), + pc, + ), + ) + return { + "postcode": pc, + "status": "ok", + "area_id": row["id"] if row else None, + "municipality": pdok_data.get("municipality"), + "population": cbs_data.get("population"), + "muslim_proxy_pct": religious.get("muslim_proxy_pct"), + } + + +def enrich_batch(limit: int = 50, offset: int = 0, delay_sec: float = 0.15) -> dict[str, Any]: + postcodes = _postcodes_to_enrich(limit, offset) + results: list[dict[str, Any]] = [] + ok = failed = 0 + for pc in postcodes: + try: + result = enrich_postcode(pc) + results.append(result) + if result.get("status") == "ok": + ok += 1 + else: + failed += 1 + except Exception as exc: # noqa: BLE001 + results.append({"postcode": pc, "status": "error", "error": str(exc)}) + failed += 1 + time.sleep(delay_sec) + + remaining = fetch_one( + """ + SELECT COUNT(DISTINCT s.postcode) AS n + FROM supermarkets s + LEFT JOIN area_analysis a ON a.postcode = s.postcode + WHERE s.postcode <> '0000AA' AND a.id IS NULL + """ + ) + return { + "processed": len(postcodes), + "ok": ok, + "failed": failed, + "remaining": int((remaining or {}).get("n") or 0), + "results": results[:20], + } + + +def enrichment_status() -> dict[str, Any]: + total_pc = fetch_one( + "SELECT COUNT(DISTINCT postcode) AS n FROM supermarkets WHERE postcode <> '0000AA'" + ) + enriched = fetch_one("SELECT COUNT(*) AS n FROM area_analysis") + providers = fetch_all( + "SELECT name, provider_type, last_fetch_at, last_status, is_active FROM data_providers ORDER BY name" + ) + return { + "unique_postcodes": int((total_pc or {}).get("n") or 0), + "enriched_postcodes": int((enriched or {}).get("n") or 0), + "data_providers": [dict(p) for p in providers], + } diff --git a/tools-api/app/retail_opportunities.py b/tools-api/app/retail_opportunities.py new file mode 100644 index 0000000..eace1c9 --- /dev/null +++ b/tools-api/app/retail_opportunities.py @@ -0,0 +1,108 @@ +"""Halal market opportunity scoring for retail locations.""" +from __future__ import annotations + +from typing import Any + +from app.db import execute, fetch_all, fetch_one, json_param + + +def compute_all_scores(limit: int = 5000) -> dict[str, Any]: + rows = fetch_all( + """ + SELECT s.id, s.chain, s.city, s.postcode, s.halal_certified, s.has_halal_section, + s.partnership_status, a.population, a.avg_income, + (a.religious_composition->>'muslim_proxy_pct')::float AS muslim_pct, + (a.ethnic_composition->>'niet_westers_pct')::float AS niet_westers_pct + FROM supermarkets s + LEFT JOIN area_analysis a ON a.postcode = s.postcode + WHERE s.postcode <> '0000AA' + LIMIT %s + """, + (limit,), + ) + computed = 0 + for r in rows: + muslim = float(r.get("muslim_pct") or 0) + niet_w = float(r.get("niet_westers_pct") or 0) + pop = int(r.get("population") or 0) + income = float(r.get("avg_income") or 0) + + halal_gap = 0.0 + if not r.get("halal_certified") and not r.get("has_halal_section"): + halal_gap = min(100, muslim * 1.5 + niet_w * 0.5) + elif r.get("has_halal_section") and not r.get("halal_certified"): + halal_gap = min(80, muslim * 0.8) + + market_potential = 0.0 + if pop > 0: + market_potential += min(40, pop / 25000) + if income > 0: + market_potential += min(30, income / 1500) + market_potential += min(30, muslim * 0.4) + + partnership_bonus = 15 if r.get("partnership_status") == "active" else 0 + halal_opp = round(min(100, halal_gap + market_potential * 0.3), 1) + market_score = round(min(100, market_potential + partnership_bonus), 1) + + factors = { + "muslim_proxy_pct": muslim, + "niet_westers_pct": niet_w, + "population": pop, + "avg_income": income, + "halal_gap": round(halal_gap, 1), + "has_halal_section": bool(r.get("has_halal_section")), + "halal_certified": bool(r.get("halal_certified")), + "partnership_status": r.get("partnership_status"), + } + execute( + """ + INSERT INTO retail_opportunity_scores (supermarket_id, halal_opportunity_score, + market_potential_score, factors, computed_at) + VALUES (%s, %s, %s, %s, NOW()) + ON CONFLICT (supermarket_id) DO UPDATE SET + halal_opportunity_score = EXCLUDED.halal_opportunity_score, + market_potential_score = EXCLUDED.market_potential_score, + factors = EXCLUDED.factors, + computed_at = NOW() + """, + (r["id"], halal_opp, market_score, json_param(factors)), + ) + execute( + "UPDATE supermarkets SET halal_opportunity_score = %s WHERE id = %s", + (halal_opp, r["id"]), + ) + computed += 1 + return {"computed": computed} + + +def top_opportunities( + limit: int = 50, + min_score: float = 30, + chain: str | None = None, + province: str | None = None, +) -> list[dict[str, Any]]: + clauses = ["ros.halal_opportunity_score >= %s"] + params: list[Any] = [min_score] + if chain: + clauses.append("s.chain ILIKE %s") + params.append(f"%{chain}%") + if province: + clauses.append("s.province ILIKE %s") + params.append(f"%{province}%") + where = " AND ".join(clauses) + return fetch_all( + f""" + SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, + s.partnership_status, s.halal_certified, s.has_halal_section, + ros.halal_opportunity_score, ros.market_potential_score, ros.factors, + a.population, a.avg_income, + (a.religious_composition->>'muslim_proxy_pct')::float AS muslim_proxy_pct + FROM retail_opportunity_scores ros + JOIN supermarkets s ON s.id = ros.supermarket_id + LEFT JOIN area_analysis a ON a.postcode = s.postcode + WHERE {where} + ORDER BY ros.halal_opportunity_score DESC + LIMIT %s + """, + tuple(params + [limit]), + ) diff --git a/tools-api/app/retail_scrapers.py b/tools-api/app/retail_scrapers.py new file mode 100644 index 0000000..ceef35c --- /dev/null +++ b/tools-api/app/retail_scrapers.py @@ -0,0 +1,253 @@ +"""Import supermarket locations from OpenStreetMap via Overpass API.""" +from __future__ import annotations + +import re +import time +from typing import Any, Optional + +import httpx + +from app.db import execute, fetch_one, json_param + +OVERPASS_URLS = [ + "https://overpass-api.de/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", +] + +CHAIN_CONFIG: dict[str, dict[str, Any]] = { + "ah": { + "label": "Albert Heijn", + "brands": ["Albert Heijn", "Albert Heijn XL", "AH"], + "db_chain": "Albert Heijn", + }, + "jumbo": { + "label": "Jumbo", + "brands": ["Jumbo"], + "db_chain": "Jumbo", + }, + "plus": { + "label": "Plus", + "brands": ["Plus", "PLUS"], + "brand_regex": "Plus", + "operators": ["Plus", "Plus Retail", "Plus Supermarkt"], + "db_chain": "Plus", + }, + "lidl": { + "label": "Lidl", + "brands": ["Lidl"], + "db_chain": "Lidl", + }, + "aldi": { + "label": "ALDI", + "brands": ["ALDI", "Aldi"], + "db_chain": "ALDI", + }, + "dirk": { + "label": "Dirk", + "brands": ["Dirk", "Dirk van den Broek"], + "db_chain": "Dirk", + }, +} + +POSTCODE_RE = re.compile(r"^\d{4}\s?[A-Za-z]{2}$") + + +def list_chains() -> list[dict[str, str]]: + return [{"key": k, "label": v["label"], "db_chain": v["db_chain"]} for k, v in CHAIN_CONFIG.items()] + + +def _build_overpass_query(cfg: dict[str, Any]) -> str: + brands: list[str] = cfg.get("brands", []) + brand_regex: Optional[str] = cfg.get("brand_regex") + operators: list[str] = cfg.get("operators", []) + parts: list[str] = [] + for b in brands: + parts.append(f'node["shop"="supermarket"]["brand"="{b}"](area.nl);') + parts.append(f'way["shop"="supermarket"]["brand"="{b}"](area.nl);') + if brand_regex: + parts.append(f'node["shop"="supermarket"]["brand"~"{brand_regex}",i](area.nl);') + parts.append(f'way["shop"="supermarket"]["brand"~"{brand_regex}",i](area.nl);') + for op in operators: + parts.append(f'node["shop"="supermarket"]["operator"="{op}"](area.nl);') + parts.append(f'way["shop"="supermarket"]["operator"="{op}"](area.nl);') + return f'[out:json][timeout:180];area["ISO3166-1"="NL"]->.nl;({" ".join(parts)});out center tags;' + + +def _build_overpass_query_legacy(brands: list[str]) -> str: + return _build_overpass_query({"brands": brands}) + + +def _fetch_overpass(query: str) -> list[dict[str, Any]]: + last_error: Optional[str] = None + for url in OVERPASS_URLS: + for attempt in range(3): + try: + with httpx.Client(timeout=200.0) as client: + resp = client.post(url, data={"data": query}) + if resp.status_code == 429: + time.sleep(15 * (attempt + 1)) + continue + resp.raise_for_status() + data = resp.json() + return data.get("elements", []) + except Exception as exc: # noqa: BLE001 + last_error = str(exc) + time.sleep(5 * (attempt + 1)) + raise RuntimeError(f"Overpass query failed: {last_error}") + + +def _coords(el: dict[str, Any]) -> tuple[Optional[float], Optional[float]]: + if el.get("type") == "node": + return el.get("lat"), el.get("lon") + center = el.get("center") or {} + return center.get("lat"), center.get("lon") + + +def _normalize_postcode(raw: Optional[str]) -> str: + if not raw: + return "0000AA" + cleaned = raw.strip().upper().replace(" ", "") + if len(cleaned) == 6 and cleaned[:4].isdigit() and cleaned[4:].isalpha(): + return cleaned + return "0000AA" + + +def _parse_store(el: dict[str, Any], db_chain: str) -> Optional[dict[str, Any]]: + tags = el.get("tags") or {} + lat, lon = _coords(el) + if lat is None or lon is None: + return None + + street = tags.get("addr:street") or tags.get("addr:place") or "" + housenumber = tags.get("addr:housenumber") or "" + address = " ".join(p for p in [street, housenumber] if p).strip() + if not address: + address = tags.get("name") or f"{db_chain} ({lat:.4f}, {lon:.4f})" + + city = tags.get("addr:city") or tags.get("addr:town") or tags.get("addr:village") or "Onbekend" + province = tags.get("addr:province") or tags.get("is_in:state") + name = tags.get("name") or tags.get("brand") or db_chain + + brand = tags.get("brand") or db_chain + store_type = None + if "XL" in brand or tags.get("shop") == "supermarket" and "xl" in name.lower(): + store_type = "XL" + elif brand == "AH" or "to go" in name.lower(): + store_type = "To Go" + + external_id = f"osm:{el.get('type')}:{el.get('id')}" + opening_hours = tags.get("opening_hours") + + return { + "external_id": external_id, + "name": name[:255], + "chain": db_chain, + "address": address, + "postcode": _normalize_postcode(tags.get("addr:postcode")), + "city": city[:100], + "province": (province or "")[:50] or None, + "latitude": lat, + "longitude": lon, + "store_type": store_type, + "phone": (tags.get("phone") or tags.get("contact:phone") or "")[:20] or None, + "website": (tags.get("website") or tags.get("contact:website") or "")[:255] or None, + "opening_hours": {"raw": opening_hours} if opening_hours else None, + "data_source": "openstreetmap", + } + + +def _ensure_schema() -> None: + col = fetch_one( + """ + SELECT column_name FROM information_schema.columns + WHERE table_name = 'supermarkets' AND column_name = 'external_id' + """ + ) + if not col: + execute("ALTER TABLE supermarkets ADD COLUMN IF NOT EXISTS external_id VARCHAR(64)") + execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_supermarkets_external_id + ON supermarkets (external_id) WHERE external_id IS NOT NULL + """ + ) + + +def import_chain(chain_key: str) -> dict[str, Any]: + cfg = CHAIN_CONFIG.get(chain_key) + if not cfg: + raise ValueError(f"Unknown chain: {chain_key}") + + _ensure_schema() + query = _build_overpass_query(cfg) + elements = _fetch_overpass(query) + + parsed: list[dict[str, Any]] = [] + for el in elements: + store = _parse_store(el, cfg["db_chain"]) + if store: + parsed.append(store) + + inserted = updated = skipped = 0 + for store in parsed: + existing = fetch_one( + "SELECT id FROM supermarkets WHERE external_id = %s", + (store["external_id"],), + ) + if existing: + execute( + """ + UPDATE supermarkets SET + name = %s, chain = %s, address = %s, postcode = %s, city = %s, + province = %s, latitude = %s, longitude = %s, store_type = %s, + phone = %s, website = %s, opening_hours = %s, + last_updated = NOW(), data_source = %s + WHERE external_id = %s + """, + ( + store["name"], store["chain"], store["address"], store["postcode"], + store["city"], store["province"], store["latitude"], store["longitude"], + store["store_type"], store["phone"], store["website"], + json_param(store["opening_hours"]), store["data_source"], store["external_id"], + ), + ) + updated += 1 + else: + execute( + """ + INSERT INTO supermarkets ( + external_id, name, chain, address, postcode, city, province, + latitude, longitude, store_type, phone, website, opening_hours, + data_source, partnership_status + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'none') + """, + ( + store["external_id"], store["name"], store["chain"], store["address"], + store["postcode"], store["city"], store["province"], store["latitude"], + store["longitude"], store["store_type"], store["phone"], store["website"], + json_param(store["opening_hours"]), store["data_source"], + ), + ) + inserted += 1 + + return { + "chain": chain_key, + "label": cfg["label"], + "fetched": len(elements), + "parsed": len(parsed), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + + +def import_all_chains() -> dict[str, Any]: + results = [] + for key in CHAIN_CONFIG: + try: + results.append(import_chain(key)) + time.sleep(8) + except Exception as exc: # noqa: BLE001 + results.append({"chain": key, "error": str(exc)}) + total = fetch_one("SELECT COUNT(*) AS n FROM supermarkets WHERE data_source = 'openstreetmap'") + return {"chains": results, "total_osm_stores": int((total or {}).get("n") or 0)} diff --git a/tools-api/app/rss_feeds.py b/tools-api/app/rss_feeds.py new file mode 100644 index 0000000..92f019e --- /dev/null +++ b/tools-api/app/rss_feeds.py @@ -0,0 +1,152 @@ +"""RSS feed ingestion — filtered for kant-en-klaar & supermarkt only.""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Any, Optional +from urllib.request import Request, urlopen + +from app.db import execute, execute_returning, fetch_all, fetch_one + +USER_AGENT = "Foodlinkk-Intel/1.0" + +INCLUDE_KEYWORDS = ( + "kant en klaar", "kant-en-klaar", "kant&klaa", "ready meal", "ready-to-eat", + "maaltijd", "maaltijden", "supermarkt", "supermarket", "retail", "jumbo", + "albert heijn", "ah ", " plus ", "lidl", "aldi", "dirk", "halal", + "convenience", "schap", "filiaal", "foodservice", "vers", "meal", + "grocery", "food retail", "kant-en-klaar", +) + +EXCLUDE_KEYWORDS = ( + "voetbal", "sport", "politiek", "verkiezing", "trump", "bbc", "oorlog", + "crypto", "bitcoin", "aandelenbeurs", "beurs ", "weerbericht", +) + + +def _parse_date(raw: Optional[str]) -> Optional[datetime]: + if not raw: + return None + try: + return parsedate_to_datetime(raw).astimezone(timezone.utc) + except Exception: + pass + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return None + + +def _strip_html(text: str) -> str: + return re.sub(r"<[^>]+>", "", text or "").strip()[:2000] + + +def is_relevant(title: str, summary: Optional[str] = None) -> bool: + blob = f"{title} {summary or ''}".lower() + for bad in EXCLUDE_KEYWORDS: + if bad in blob: + return False + for good in INCLUDE_KEYWORDS: + if good in blob: + return True + return False + + +def _fetch_xml(url: str) -> ET.Element: + req = Request(url, headers={"User-Agent": USER_AGENT}) + with urlopen(req, timeout=25) as resp: + data = resp.read() + return ET.fromstring(data) + + +def _skip_keyword_filter(category: Optional[str]) -> bool: + return category in ("regelgeving", "cbs", "markt") + + +def refresh_feed(feed_id: int) -> dict[str, Any]: + feed = fetch_one("SELECT * FROM rss_feeds WHERE id = %s AND is_active = TRUE", (feed_id,)) + if not feed: + return {"error": "feed not found"} + skip_filter = _skip_keyword_filter(feed.get("category")) + root = _fetch_xml(feed["url"]) + items = root.findall(".//item") or root.findall(".//{http://www.w3.org/2005/Atom}entry") + inserted = skipped = 0 + for item in items[:50]: + title = (item.findtext("title") or item.findtext("{http://www.w3.org/2005/Atom}title") or "").strip() + link = (item.findtext("link") or "").strip() + if not link: + link_el = item.find("{http://www.w3.org/2005/Atom}link") + if link_el is not None: + link = link_el.get("href") or "" + summary = item.findtext("description") or item.findtext("summary") or item.findtext("{http://www.w3.org/2005/Atom}summary") or "" + pub = item.findtext("pubDate") or item.findtext("published") or item.findtext("{http://www.w3.org/2005/Atom}published") + if not title or not link: + continue + clean_summary = _strip_html(summary) + cat = (feed.get("category") or "").lower() + if cat not in ("regelgeving", "cbs", "markt") and not is_relevant(title, clean_summary): + skipped += 1 + continue + try: + execute_returning( + """INSERT INTO rss_items (feed_id, title, link, summary, published_at) + VALUES (%s, %s, %s, %s, %s) RETURNING id""", + (feed_id, title[:500], link[:1000], clean_summary, _parse_date(pub)), + ) + inserted += 1 + except Exception: + pass + execute( + "UPDATE rss_feeds SET last_fetch_at = NOW(), last_status = 'ok' WHERE id = %s", + (feed_id,), + ) + return {"feed": feed["name"], "inserted": inserted, "skipped": skipped} + + +def refresh_all_feeds() -> dict[str, Any]: + feeds = fetch_all("SELECT id, name FROM rss_feeds WHERE is_active = TRUE") + results = [] + for f in feeds: + try: + results.append(refresh_feed(int(f["id"]))) + except Exception as exc: # noqa: BLE001 + execute("UPDATE rss_feeds SET last_status = %s WHERE id = %s", (str(exc)[:32], f["id"])) + results.append({"feed": f["name"], "error": str(exc)}) + return {"feeds": len(feeds), "results": results} + + +def list_live_feed(limit: int = 40, category: Optional[str] = None) -> list[dict[str, Any]]: + params: list[Any] = [] + if category and category.lower() in ("regelgeving", "cbs", "markt"): + base = """ + SELECT i.*, f.name AS feed_name, f.category, 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 f.category = %s + """ + params.append(category.lower()) + else: + like_clauses = " OR ".join( + f"(i.title ILIKE %s OR COALESCE(i.summary,'') ILIKE %s)" for _ in INCLUDE_KEYWORDS[:12] + ) + for kw in INCLUDE_KEYWORDS[:12]: + p = f"%{kw}%" + params.extend([p, p]) + base = f""" + SELECT i.*, f.name AS feed_name, f.category, 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 ({like_clauses}) + """ + if category: + base += " AND f.category = %s" + params.append(category) + base += " ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT %s" + params.append(limit) + rows = fetch_all(base, tuple(params)) + skip_filter = category and category.lower() in ("regelgeving", "cbs", "markt") + if skip_filter: + return [dict(r) for r in rows] + return [dict(r) for r in rows if is_relevant(r.get("title") or "", r.get("summary"))] diff --git a/tools-api/app/wholesaler_scrapers.py b/tools-api/app/wholesaler_scrapers.py new file mode 100644 index 0000000..cc9cff2 --- /dev/null +++ b/tools-api/app/wholesaler_scrapers.py @@ -0,0 +1,111 @@ +"""Import wholesalers from OpenStreetMap.""" +from __future__ import annotations + +import json +import time +import urllib.parse +import urllib.request +from typing import Any, Optional + +from app.db import execute, fetch_one + +OVERPASS_URL = "https://overpass.kumi.systems/api/interpreter" + +WHOLESALE_BRANDS = [ + "Sligro", "Hanos", "Makro", "Bidfood", "Metro", "Van Gelder", + "De Klok", "Hoogvliet Groothandel", +] + + +def _fetch(query: str) -> list[dict[str, Any]]: + data = urllib.parse.urlencode({"data": query}).encode() + req = urllib.request.Request(OVERPASS_URL, data=data, method="POST") + with urllib.request.urlopen(req, timeout=300) as resp: + payload = json.loads(resp.read().decode()) + return payload.get("elements", []) + + +def _coords(el: dict[str, Any]) -> tuple[Optional[float], Optional[float]]: + if el.get("type") == "node": + return el.get("lat"), el.get("lon") + c = el.get("center") or {} + return c.get("lat"), c.get("lon") + + +def _normalize_pc(raw: Optional[str]) -> str: + if not raw: + return "0000AA" + c = raw.strip().upper().replace(" ", "") + return c if len(c) >= 6 else "0000AA" + + +def import_wholesalers() -> dict[str, Any]: + query = ( + '[out:json][timeout:120];area["ISO3166-1"="NL"]->.nl;(' + 'node["shop"="wholesale"](area.nl);way["shop"="wholesale"](area.nl);' + 'node["shop"="cash_and_carry"](area.nl);way["shop"="cash_and_carry"](area.nl);' + 'node["wholesale"](area.nl);way["wholesale"](area.nl);' + ');out center tags;' + ) + elements: list[dict[str, Any]] = [] + try: + elements = _fetch(query) + except Exception: + # Fallback: smaller per-brand queries + for brand in WHOLESALE_BRANDS[:4]: + q = ( + f'[out:json][timeout:60];area["ISO3166-1"="NL"]->.nl;(' + f'node["name"~"{brand}",i](area.nl);way["name"~"{brand}",i](area.nl);' + f');out center tags;' + ) + try: + elements.extend(_fetch(q)) + time.sleep(2) + except Exception: + continue + inserted = updated = 0 + seen: set[str] = set() + for el in elements: + tags = el.get("tags") or {} + lat, lon = _coords(el) + if lat is None: + continue + external_id = f"osm:{el.get('type')}:{el.get('id')}" + if external_id in seen: + continue + seen.add(external_id) + name = tags.get("name") or tags.get("brand") or "Groothandel" + brand = tags.get("brand") or name.split()[0] + street = tags.get("addr:street") or "" + hn = tags.get("addr:housenumber") or "" + address = f"{street} {hn}".strip() or name + city = tags.get("addr:city") or tags.get("addr:town") or "Onbekend" + existing = fetch_one("SELECT id FROM wholesalers WHERE external_id = %s", (external_id,)) + if existing: + execute( + """UPDATE wholesalers SET name=%s, address=%s, postcode=%s, city=%s, province=%s, + latitude=%s, longitude=%s, phone=%s, email=%s, website=%s, last_updated=NOW() + WHERE external_id=%s""", + ( + name[:255], address, _normalize_pc(tags.get("addr:postcode")), city[:100], + (tags.get("addr:province") or "")[:50], lat, lon, + (tags.get("phone") or "")[:20], (tags.get("email") or "")[:255], + (tags.get("website") or "")[:255], external_id, + ), + ) + updated += 1 + else: + execute( + """INSERT INTO wholesalers (external_id, name, address, postcode, city, province, + latitude, longitude, phone, email, website, data_source, product_categories) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'openstreetmap',%s)""", + ( + external_id, name[:255], address, _normalize_pc(tags.get("addr:postcode")), + city[:100], (tags.get("addr:province") or "")[:50], lat, lon, + (tags.get("phone") or "")[:20], (tags.get("email") or "")[:255], + (tags.get("website") or "")[:255], [brand], + ), + ) + inserted += 1 + total = fetch_one("SELECT COUNT(*) AS n FROM wholesalers") + return {"fetched": len(elements), "inserted": inserted, "updated": updated, "total": int((total or {}).get("n") or 0)} diff --git a/tools-api/db.py b/tools-api/db.py new file mode 100644 index 0000000..c7d979f --- /dev/null +++ b/tools-api/db.py @@ -0,0 +1,76 @@ +from contextlib import contextmanager +from typing import Any, Optional + +import psycopg2 +from psycopg2 import pool +from psycopg2.extras import RealDictCursor, Json + +from app.config import settings + +_connection_pool: Optional[pool.SimpleConnectionPool] = None + + +def init_pool(minconn: int = 1, maxconn: int = 10) -> None: + global _connection_pool + if _connection_pool is None: + _connection_pool = pool.SimpleConnectionPool( + minconn, + maxconn, + dsn=settings.database_dsn, + ) + + +def close_pool() -> None: + global _connection_pool + if _connection_pool is not None: + _connection_pool.closeall() + _connection_pool = None + + +@contextmanager +def get_connection(): + if _connection_pool is None: + init_pool() + conn = _connection_pool.getconn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + _connection_pool.putconn(conn) + + +def fetch_all(query: str, params: Optional[tuple] = None) -> list[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] + + +def fetch_one(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + row = cur.fetchone() + return dict(row) if row else None + + +def execute_returning(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]: + with get_connection() as conn: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + row = cur.fetchone() + return dict(row) if row else None + + +def execute(query: str, params: Optional[tuple] = None) -> int: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute(query, params) + return cur.rowcount + + +def json_param(value: Any) -> Json: + return Json(value or {}) diff --git a/tools-api/patch_main.py b/tools-api/patch_main.py new file mode 100644 index 0000000..80be6b6 --- /dev/null +++ b/tools-api/patch_main.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Patch tools-api main.py to register new routers.""" +from pathlib import Path + +p = Path("/home/aissa/foodlinkk-command-center/tools-api/app/main.py") +text = p.read_text() + +if "from app.retail import router as retail_router" not in text: + text = text.replace( + "from app.middleware import log_agent_event", + "from app.middleware import log_agent_event\nfrom app.retail import router as retail_router\nfrom app.research import router as research_router\nfrom app.recommendations import router as recommendations_router\nfrom app.logging_middleware import AgentLoggingMiddleware", + ) + +if "app.include_router(retail_router)" not in text: + text = text.replace( + 'app = FastAPI(title="Foodlinkk Tools API", version="1.0.0")', + 'app = FastAPI(title="Foodlinkk Tools API", version="1.1.0")\napp.add_middleware(AgentLoggingMiddleware)\napp.include_router(retail_router)\napp.include_router(research_router)\napp.include_router(recommendations_router)', + ) + +p.write_text(text) +print("patched tools-api main.py") diff --git a/tools-api/requirements.txt b/tools-api/requirements.txt new file mode 100644 index 0000000..1763226 --- /dev/null +++ b/tools-api/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.6 +uvicorn[standard]==0.32.1 +psycopg2-binary==2.9.9 +pydantic==2.10.3 +httpx==0.27.2 +websockets==14.1 +svgwrite +Pillow +python-barcode +reportlab