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

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