SysOps: deploy-all — 2026-06-09 10:41 UTC
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""Agent action approval queue — gate before executing sensitive queries."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
|
||||
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
for key, val in list(out.items()):
|
||||
if hasattr(val, "isoformat"):
|
||||
out[key] = val.isoformat()
|
||||
return out
|
||||
|
||||
|
||||
def has_pending_request(agent_key: str, action_type: str | None = None) -> bool:
|
||||
clauses = ["agent_key = %s", "status = 'pending'", "created_at >= CURRENT_DATE"]
|
||||
params: list[Any] = [agent_key.strip().lower()]
|
||||
if action_type:
|
||||
clauses.append("action_type = %s")
|
||||
params.append(action_type)
|
||||
row = fetch_one(
|
||||
f"SELECT id FROM agent_action_requests WHERE {' AND '.join(clauses)} LIMIT 1",
|
||||
tuple(params),
|
||||
)
|
||||
return bool(row)
|
||||
|
||||
|
||||
def create_request(
|
||||
agent_key: str,
|
||||
title: str,
|
||||
action_type: str = "query",
|
||||
query_payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
key = (agent_key or "").strip().lower()
|
||||
if not key or not title.strip():
|
||||
raise ValueError("agent_key and title are required")
|
||||
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO agent_action_requests (agent_key, action_type, title, query_payload, status)
|
||||
VALUES (%s, %s, %s, %s::jsonb, 'pending')
|
||||
RETURNING *
|
||||
""",
|
||||
(key, action_type, title.strip(), json.dumps(query_payload or {})),
|
||||
)
|
||||
req = _serialize(row) or {}
|
||||
|
||||
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)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
"agent_request",
|
||||
"approval_request",
|
||||
title.strip(),
|
||||
f"Wacht op goedkeuring — {action_type}",
|
||||
"needs_approval",
|
||||
"agents",
|
||||
json.dumps({"request_id": req.get("id"), "action_type": action_type}),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return req
|
||||
|
||||
|
||||
def list_requests(status: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
|
||||
clauses, params = [], []
|
||||
if status:
|
||||
clauses.append("status = %s")
|
||||
params.append(status)
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
safe_limit = max(1, min(limit, 200))
|
||||
rows = fetch_all(
|
||||
f"SELECT * FROM agent_action_requests{where} ORDER BY created_at DESC LIMIT %s",
|
||||
tuple(params + [safe_limit]),
|
||||
)
|
||||
return [_serialize(r) for r in rows]
|
||||
|
||||
|
||||
def get_request(request_id: int) -> dict[str, Any] | None:
|
||||
return _serialize(fetch_one("SELECT * FROM agent_action_requests WHERE id = %s", (request_id,)))
|
||||
|
||||
|
||||
def approve_request(request_id: int, approved_by: str = "ceo") -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
UPDATE agent_action_requests
|
||||
SET status = 'approved', approved_by = %s, reviewed_at = NOW()
|
||||
WHERE id = %s AND status = 'pending'
|
||||
RETURNING *
|
||||
""",
|
||||
(approved_by, request_id),
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("Request not found or not pending")
|
||||
req = _serialize(row) or {}
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
UPDATE agent_events SET status = 'approved'
|
||||
WHERE status = 'needs_approval'
|
||||
AND metadata->>'request_id' = %s
|
||||
""",
|
||||
(str(request_id),),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return req
|
||||
|
||||
|
||||
def reject_request(request_id: int, reason: str = "", rejected_by: str = "ceo") -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
UPDATE agent_action_requests
|
||||
SET status = 'rejected', approved_by = %s, rejection_reason = %s, reviewed_at = NOW()
|
||||
WHERE id = %s AND status = 'pending'
|
||||
RETURNING *
|
||||
""",
|
||||
(rejected_by, (reason or "")[:500], request_id),
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("Request not found or not pending")
|
||||
req = _serialize(row) or {}
|
||||
try:
|
||||
execute(
|
||||
"""
|
||||
UPDATE agent_events SET status = 'rejected'
|
||||
WHERE status = 'needs_approval'
|
||||
AND metadata->>'request_id' = %s
|
||||
""",
|
||||
(str(request_id),),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return req
|
||||
|
||||
|
||||
def mark_executed(request_id: int, result: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
UPDATE agent_action_requests
|
||||
SET status = 'executed', executed_at = NOW(), result = %s::jsonb
|
||||
WHERE id = %s AND status = 'approved'
|
||||
RETURNING *
|
||||
""",
|
||||
(json.dumps(result or {}), request_id),
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("Request not approved or not found")
|
||||
req = _serialize(row) or {}
|
||||
|
||||
try:
|
||||
from app.services import projects as project_svc
|
||||
|
||||
payload = req.get("query_payload") or {}
|
||||
if isinstance(payload, str):
|
||||
import json as _json
|
||||
try:
|
||||
payload = _json.loads(payload)
|
||||
except Exception:
|
||||
payload = {}
|
||||
pid = payload.get("project_id")
|
||||
project_svc.register_agent_output(
|
||||
asset_type=str(req.get("action_type") or "agent_action"),
|
||||
title=req.get("title") or f"Agent actie #{request_id}",
|
||||
ref_id=str(request_id),
|
||||
payload={"result": result or {}, "action_type": req.get("action_type")},
|
||||
project_id=int(pid) if pid else None,
|
||||
source_agent=str(req.get("agent_key") or "agent"),
|
||||
created_by=str(req.get("approved_by") or "ceo"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return req
|
||||
|
||||
|
||||
def require_approved(request_id: int) -> dict[str, Any]:
|
||||
req = get_request(request_id)
|
||||
if not req:
|
||||
raise PermissionError("Approval request not found")
|
||||
if req.get("status") != "approved":
|
||||
raise PermissionError(f"Request status is {req.get('status')}, approval required")
|
||||
return req
|
||||
@@ -10,7 +10,13 @@ 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
|
||||
(SELECT MAX(created_at) FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key) AS last_event_at,
|
||||
(SELECT title FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_task,
|
||||
(SELECT status FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_status,
|
||||
(SELECT event_type FROM agent_events e WHERE LOWER(e.agent_name) = s.agent_key
|
||||
ORDER BY created_at DESC LIMIT 1) AS current_event_type
|
||||
FROM agent_souls s ORDER BY s.display_name"""
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
@@ -25,13 +31,27 @@ def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
|
||||
)
|
||||
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]
|
||||
out["recent_events"] = list_agent_events(agent_key, limit=15)
|
||||
return out
|
||||
|
||||
|
||||
def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s""",
|
||||
(agent_key.lower(), limit),
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
ev = dict(row)
|
||||
for key in ("created_at", "completed_at"):
|
||||
if ev.get(key) is not None and hasattr(ev[key], "isoformat"):
|
||||
ev[key] = ev[key].isoformat()
|
||||
out.append(ev)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,74 @@ def collect_briefing_data() -> dict[str, Any]:
|
||||
data["pipeline_eur"] = _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')")
|
||||
data["pending_approvals"] = _safe_count("agent_events", "status = 'needs_approval'")
|
||||
|
||||
try:
|
||||
data["pending_approval_requests"] = fetch_all(
|
||||
"""SELECT id, agent_key, action_type, title, query_payload, created_at
|
||||
FROM agent_action_requests WHERE status = 'pending'
|
||||
ORDER BY created_at ASC LIMIT 15"""
|
||||
)
|
||||
data["pending_approvals"] = len(data["pending_approval_requests"])
|
||||
except Exception:
|
||||
data["pending_approval_requests"] = []
|
||||
|
||||
try:
|
||||
data["recent_executed_actions"] = fetch_all(
|
||||
"""SELECT id, agent_key, action_type, title, result, executed_at, approved_by
|
||||
FROM agent_action_requests
|
||||
WHERE status = 'executed' AND executed_at >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY executed_at DESC LIMIT 12"""
|
||||
)
|
||||
except Exception:
|
||||
data["recent_executed_actions"] = []
|
||||
|
||||
try:
|
||||
data["project_assets_recent"] = fetch_all(
|
||||
"""SELECT pa.title, pa.asset_type, pa.source_agent, pa.created_at, cp.name AS project_name
|
||||
FROM project_assets pa
|
||||
JOIN cockpit_projects cp ON cp.id = pa.project_id
|
||||
WHERE pa.created_at >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY pa.created_at DESC LIMIT 15"""
|
||||
)
|
||||
except Exception:
|
||||
data["project_assets_recent"] = []
|
||||
|
||||
try:
|
||||
data["ops_maintenance_open"] = fetch_all(
|
||||
"""SELECT severity, title, body, created_at FROM ops_maintenance_notes
|
||||
WHERE resolved = false ORDER BY created_at DESC LIMIT 8"""
|
||||
)
|
||||
except Exception:
|
||||
data["ops_maintenance_open"] = []
|
||||
|
||||
try:
|
||||
data["config_backups_recent"] = fetch_all(
|
||||
"""SELECT status, message, commit_ref, created_at FROM config_backups
|
||||
ORDER BY created_at DESC LIMIT 5"""
|
||||
)
|
||||
except Exception:
|
||||
data["config_backups_recent"] = []
|
||||
|
||||
try:
|
||||
data["sysops_activity_24h"] = fetch_all(
|
||||
"""SELECT action_type, title, body, commit_ref, files_changed, status, created_at
|
||||
FROM sysops_activity
|
||||
WHERE created_at >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY created_at DESC LIMIT 20"""
|
||||
)
|
||||
except Exception:
|
||||
data["sysops_activity_24h"] = []
|
||||
|
||||
try:
|
||||
data["sysops_events_24h"] = fetch_all(
|
||||
"""SELECT event_type, title, body, status, created_at, metadata
|
||||
FROM agent_events
|
||||
WHERE LOWER(agent_name) = 'sysops'
|
||||
AND created_at >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY created_at DESC LIMIT 15"""
|
||||
)
|
||||
except Exception:
|
||||
data["sysops_events_24h"] = []
|
||||
|
||||
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"
|
||||
@@ -204,20 +272,85 @@ def collect_briefing_data() -> dict[str, Any]:
|
||||
except Exception:
|
||||
data["regulation_highlights"] = []
|
||||
|
||||
try:
|
||||
data["trending_food"] = fetch_all(
|
||||
"""SELECT i.id, i.title, i.link, i.summary, f.name AS feed_name, f.url AS feed_url,
|
||||
f.category, i.published_at
|
||||
FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id AND f.is_active = TRUE
|
||||
WHERE f.category IN ('food', 'markt', 'supermarkt', 'retail', 'kant-en-klaar')
|
||||
OR f.name ILIKE '%retaildetail%'
|
||||
ORDER BY i.published_at DESC NULLS LAST, i.fetched_at DESC LIMIT 10"""
|
||||
)
|
||||
except Exception:
|
||||
data["trending_food"] = []
|
||||
|
||||
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%'])
|
||||
WHERE f.category IN ('food', 'markt', 'supermarkt', 'kant-en-klaar', 'retail')
|
||||
OR f.name ILIKE '%retaildetail%'
|
||||
ORDER BY i.published_at DESC NULLS LAST LIMIT 10"""
|
||||
)
|
||||
except Exception:
|
||||
data["food_market_highlights"] = []
|
||||
|
||||
data["activity_log"] = _build_activity_log(data)
|
||||
return data
|
||||
|
||||
|
||||
def _build_activity_log(data: dict[str, Any]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for row in data.get("pending_approval_requests") or []:
|
||||
agent = row.get("agent_key") or "agent"
|
||||
action = row.get("action_type") or "actie"
|
||||
title = row.get("title") or ""
|
||||
if action == "maintenance_scan":
|
||||
lines.append(f"⏳ SysOps vraagt toestemming voor update-scan: {title}")
|
||||
elif action == "config_backup":
|
||||
lines.append(f"⏳ SysOps vraagt goedkeuring backup: {title}")
|
||||
else:
|
||||
lines.append(f"⏳ {agent} wacht op goedkeuring ({action}): {title}")
|
||||
|
||||
for row in data.get("recent_executed_actions") or []:
|
||||
lines.append(f"✓ Uitgevoerd door {row.get('agent_key')}: {row.get('title')}")
|
||||
|
||||
for row in data.get("project_assets_recent") or []:
|
||||
ts = row.get("created_at")
|
||||
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts or "")[:16]
|
||||
lines.append(
|
||||
f"📁 Project asset ({row.get('project_name')}): {row.get('title')} "
|
||||
f"[{row.get('asset_type')} · {row.get('source_agent')}] {ts_s}"
|
||||
)
|
||||
|
||||
for row in data.get("ops_maintenance_open") or []:
|
||||
lines.append(f"🔧 IT Ops [{row.get('severity')}]: {row.get('title')}")
|
||||
|
||||
for row in data.get("config_backups_recent") or []:
|
||||
lines.append(f"💾 Backup {row.get('status')}: {row.get('message') or row.get('commit_ref')}")
|
||||
|
||||
for row in data.get("sysops_activity_24h") or []:
|
||||
ts = row.get("created_at")
|
||||
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
|
||||
cref = f" [{row.get('commit_ref')}]" if row.get("commit_ref") else ""
|
||||
lines.append(f"🖥️ SysOps {row.get('action_type')}: {row.get('title')}{cref} ({ts_s})")
|
||||
|
||||
for row in data.get("sysops_events_24h") or []:
|
||||
if (row.get("event_type") or "") == "gitea_sync":
|
||||
continue
|
||||
ts = row.get("created_at")
|
||||
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
|
||||
lines.append(f"🔧 SysOps: {row.get('title')} ({ts_s})")
|
||||
|
||||
for row in (data.get("recent_events") or [])[:8]:
|
||||
ts = row.get("created_at")
|
||||
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
|
||||
lines.append(f"⚡ {row.get('agent_name')}: {row.get('title')} ({ts_s})")
|
||||
|
||||
return lines[:25]
|
||||
|
||||
|
||||
def build_template_report(data: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
f"# Foodlinkk Dagrapport — {data['date']}",
|
||||
@@ -272,8 +405,30 @@ def build_template_report(data: dict[str, Any]) -> str:
|
||||
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
|
||||
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
|
||||
|
||||
if data.get("sysops_activity_24h"):
|
||||
lines.extend(["", "## SysOps IT — laatste 24 uur"])
|
||||
for row in data["sysops_activity_24h"][:12]:
|
||||
ts = row.get("created_at")
|
||||
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
|
||||
cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
|
||||
lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
|
||||
if row.get("body"):
|
||||
lines.append(f" {str(row.get('body'))[:200]}")
|
||||
|
||||
if data.get("pending_approval_requests"):
|
||||
lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
|
||||
for row in data["pending_approval_requests"]:
|
||||
action = row.get("action_type") or ""
|
||||
label = "Update-scan VM106" if action == "maintenance_scan" else action
|
||||
lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
|
||||
|
||||
if data.get("activity_log"):
|
||||
lines.extend(["", "## Herman activiteitenlog (24u)"])
|
||||
for entry in data["activity_log"][:20]:
|
||||
lines.append(f"- {entry}")
|
||||
|
||||
if data.get("pending_items"):
|
||||
lines.extend(["", "## ⚠️ Wacht op jouw goedkeuring"])
|
||||
lines.extend(["", "## Legacy goedkeuringen"])
|
||||
for row in data["pending_items"]:
|
||||
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
|
||||
|
||||
@@ -289,21 +444,26 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
|
||||
for row in data.get("milestones_pending") or []:
|
||||
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
|
||||
|
||||
activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
|
||||
|
||||
prompt = (
|
||||
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
|
||||
"## Samenvatting\n(5-7 zinnen: 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"
|
||||
"## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
|
||||
"## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
|
||||
"## Lange termijn focus\n(3-5 bullets)\n\n"
|
||||
"## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
|
||||
f"Data vandaag ({data['date']}):\n"
|
||||
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
|
||||
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
|
||||
f"- {data['pending_approvals']} goedkeuringen open\n"
|
||||
f"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
|
||||
f"Activiteitenlog:\n{activity}\n"
|
||||
f"Top kansen:\n{opp_lines or '- geen data'}\n"
|
||||
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
|
||||
)
|
||||
system = (
|
||||
"Je bent Herman, AI co-CEO van Foodlinkk. Schrijf warm, professioneel en actionable. "
|
||||
"Focus op halal kant-en-klaar retail groei in Nederland. Geen vage tekst — concrete namen en acties."
|
||||
"Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
|
||||
"Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
|
||||
"Schrijf warm, professioneel, actionable."
|
||||
)
|
||||
try:
|
||||
return await ollama.generate(prompt, system=system, timeout=120.0)
|
||||
@@ -327,10 +487,14 @@ def _fallback_summary(data: dict[str, Any]) -> str:
|
||||
)
|
||||
lines.extend(["", "## Actiepunten vandaag — korte termijn"])
|
||||
actions = [
|
||||
f"Keur {data['pending_approvals']} open agent-verzoeken goed (dashboard → Goedkeuringen)",
|
||||
"Open Retail 360 en benader top-3 halal-gap filialen via CRM koppeling",
|
||||
f"Behandel {data['pending_approvals']} openstaande agent-goedkeuringen",
|
||||
"Check Marketing Live Feed voor kant-en-klaar trends",
|
||||
]
|
||||
for row in data.get("pending_approval_requests") or []:
|
||||
if row.get("action_type") == "maintenance_scan":
|
||||
actions.insert(0, f"**SysOps update-scan:** {row.get('title')} — keur goed op dashboard")
|
||||
break
|
||||
if ms:
|
||||
actions.insert(0, f"Follow-up milestone: **{ms[0].get('title')}**")
|
||||
for a in actions[:6]:
|
||||
@@ -354,6 +518,18 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
execute(
|
||||
"""INSERT INTO llm_memory (category, subject, content, source, metadata, updated_at)
|
||||
VALUES ('herman_daily', %s, %s, 'herman', %s::jsonb, NOW())""",
|
||||
(
|
||||
f"Briefing {data['date']}",
|
||||
content[:8000],
|
||||
json.dumps({"date": data["date"], "activity_count": len(data.get("activity_log") or [])}),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
execute(
|
||||
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
|
||||
@@ -361,7 +537,7 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None:
|
||||
(
|
||||
"herman", "herman_delegate", "briefing",
|
||||
f"CEO dagrapport {data['date']}", content[:2000],
|
||||
"completed", "dashboard", json.dumps({"stats": safe}),
|
||||
"completed", "dashboard", json.dumps({"stats": safe, "activity_log": data.get("activity_log", [])}),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -5,6 +5,7 @@ import httpx
|
||||
from app.config import settings
|
||||
from app.db import execute, fetch_one
|
||||
from app.services import ollama
|
||||
from app.services import packaging_agent
|
||||
|
||||
AGENTS: dict[str, dict[str, str]] = {
|
||||
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
|
||||
@@ -14,6 +15,7 @@ AGENTS: dict[str, dict[str, str]] = {
|
||||
"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."},
|
||||
"packaging": {"name": "Packaging", "persona": "SVG/PDF verpakkingsontwerp, stanstekeningen, drukwerk."},
|
||||
"knowledge": {"name": "Knowledge", "persona": "Internal docs, RAG, policy answers."},
|
||||
}
|
||||
|
||||
@@ -82,6 +84,72 @@ def _pick_agent(raw: str) -> str:
|
||||
return "knowledge"
|
||||
|
||||
async def chat(message: str) -> dict[str, Any]:
|
||||
if packaging_agent.wants_packaging(message):
|
||||
try:
|
||||
outcome = await packaging_agent.generate_from_message(message)
|
||||
name = outcome.get("design_name") or "Design"
|
||||
pid = outcome.get("packaging_id", "")[:8]
|
||||
reply_lines = [
|
||||
f"Packaging agent heeft een design klaar voor je: {name}",
|
||||
f"Type: {outcome.get('type')} · {outcome.get('dimensions')}",
|
||||
f"Project #{outcome.get('cockpit_project_id')}",
|
||||
f"Studio: {outcome.get('studio_url')}",
|
||||
f"PDF: {outcome.get('pdf_url')}",
|
||||
]
|
||||
if outcome.get("nas", {}).get("ok"):
|
||||
reply_lines.append("Bestanden staan op de NAS in de packaging-map van het project.")
|
||||
reply = "\n".join(reply_lines)
|
||||
|
||||
await _log_event(
|
||||
"packaging",
|
||||
"packaging_created",
|
||||
f"Design klaar: {name}",
|
||||
f"Herman-opdracht: {message[:1500]}\n\nDesign-ID: {outcome.get('packaging_id')}",
|
||||
{
|
||||
"packaging_id": outcome.get("packaging_id"),
|
||||
"project_id": outcome.get("cockpit_project_id"),
|
||||
"studio_url": outcome.get("studio_url"),
|
||||
"pdf_url": outcome.get("pdf_url"),
|
||||
"nas": outcome.get("nas"),
|
||||
"for_herman": True,
|
||||
},
|
||||
)
|
||||
await _log_event(
|
||||
"herman",
|
||||
"packaging_delivered",
|
||||
f"Packaging → Herman: {name}",
|
||||
reply[:2000],
|
||||
{
|
||||
"source_agent": "packaging",
|
||||
"packaging_id": outcome.get("packaging_id"),
|
||||
"project_id": outcome.get("cockpit_project_id"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"agent": "packaging",
|
||||
"agent_label": "Packaging → Herman",
|
||||
"reply": reply,
|
||||
"delegated_agents": ["packaging", "herman"],
|
||||
"routing_reason": "Packaging-opdracht gedetecteerd — design gegenereerd en aan Herman gerapporteerd",
|
||||
"packaging_id": outcome.get("packaging_id"),
|
||||
"packaging_studio_url": outcome.get("studio_url"),
|
||||
"packaging_pdf_url": outcome.get("pdf_url"),
|
||||
}
|
||||
except Exception as exc:
|
||||
await _log_event(
|
||||
"packaging",
|
||||
"packaging_error",
|
||||
"Packaging generatie mislukt",
|
||||
str(exc)[:1500],
|
||||
{"message": message[:500]},
|
||||
)
|
||||
return {
|
||||
"agent": "packaging",
|
||||
"agent_label": "Packaging",
|
||||
"reply": f"Packaging agent kon geen design maken: {exc}",
|
||||
"delegated_agents": ["packaging"],
|
||||
}
|
||||
|
||||
if _wants_image(message):
|
||||
prompt = _extract_image_prompt(message)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""NAS folder structure per client and project — geen alles-op-een-hoop."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_one
|
||||
|
||||
NAS_ROOT = Path(os.getenv("NAS_CLIENTS_ROOT", "/data/nas-clients"))
|
||||
|
||||
PROJECT_SUBDIRS = ("photos", "documents", "packaging", "exports", "briefs")
|
||||
|
||||
PROJECT_TYPES = [
|
||||
{"id": "packaging", "label_nl": "Verpakking & label", "label_en": "Packaging & label", "icon": "📦", "nas_sub": "packaging"},
|
||||
{"id": "retail_listing", "label_nl": "Retail listing / schap", "label_en": "Retail listing", "icon": "🏪", "nas_sub": "documents"},
|
||||
{"id": "recipe", "label_nl": "Recept & productontwikkeling", "label_en": "Recipe & R&D", "icon": "🍱", "nas_sub": "briefs"},
|
||||
{"id": "marketing", "label_nl": "Marketing campagne", "label_en": "Marketing campaign", "icon": "📣", "nas_sub": "exports"},
|
||||
{"id": "halal", "label_nl": "Halal certificering", "label_en": "Halal certification", "icon": "☪️", "nas_sub": "documents"},
|
||||
{"id": "sourcing", "label_nl": "Sourcing & import", "label_en": "Sourcing & import", "icon": "🚢", "nas_sub": "documents"},
|
||||
{"id": "crm", "label_nl": "Klant & partnership", "label_en": "Client & partnership", "icon": "🤝", "nas_sub": "documents"},
|
||||
{"id": "research", "label_nl": "Marktonderzoek", "label_en": "Market research", "icon": "🔬", "nas_sub": "briefs"},
|
||||
{"id": "general", "label_nl": "Algemeen project", "label_en": "General project", "icon": "📁", "nas_sub": "documents"},
|
||||
]
|
||||
|
||||
|
||||
def slugify(name: str, max_len: int = 48) -> str:
|
||||
s = re.sub(r"[^a-zA-Z0-9]+", "-", (name or "project").strip().lower()).strip("-")
|
||||
return (s[:max_len] or "project")
|
||||
|
||||
|
||||
def _write_meta(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def ensure_client_folder(client_id: int, client_name: str) -> str:
|
||||
"""Maak NAS-map per klant: clients/{slug}/ met submappen."""
|
||||
slug = slugify(client_name)
|
||||
root = NAS_ROOT / slug
|
||||
for sub in ("projects", "photos", "documents", "inbox"):
|
||||
(root / sub).mkdir(parents=True, exist_ok=True)
|
||||
meta = {
|
||||
"client_id": client_id,
|
||||
"client_name": client_name,
|
||||
"slug": slug,
|
||||
"structure": ["projects", "photos", "documents", "inbox"],
|
||||
}
|
||||
_write_meta(root / "client.json", meta)
|
||||
rel = str(root)
|
||||
execute("UPDATE clients SET nas_folder = %s WHERE id = %s", (rel, client_id))
|
||||
return rel
|
||||
|
||||
|
||||
def ensure_project_folder(
|
||||
project_id: int,
|
||||
project_name: str,
|
||||
client_id: Optional[int],
|
||||
client_name: Optional[str],
|
||||
project_type: str = "general",
|
||||
) -> dict[str, Any]:
|
||||
"""Projectmap onder klant: clients/{client}/projects/{project}/"""
|
||||
client_root: Path | None = None
|
||||
if client_id and client_name:
|
||||
row = fetch_one("SELECT nas_folder FROM clients WHERE id = %s", (client_id,))
|
||||
if row and row.get("nas_folder"):
|
||||
client_root = Path(row["nas_folder"])
|
||||
else:
|
||||
client_root = Path(ensure_client_folder(client_id, client_name))
|
||||
else:
|
||||
client_root = NAS_ROOT / "_geen-klant"
|
||||
client_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
proj_slug = f"{project_id}-{slugify(project_name)}"
|
||||
proj_root = client_root / "projects" / proj_slug
|
||||
for sub in PROJECT_SUBDIRS:
|
||||
(proj_root / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
type_info = next((t for t in PROJECT_TYPES if t["id"] == project_type), PROJECT_TYPES[-1])
|
||||
_write_meta(
|
||||
proj_root / "project.json",
|
||||
{
|
||||
"project_id": project_id,
|
||||
"name": project_name,
|
||||
"client_id": client_id,
|
||||
"project_type": project_type,
|
||||
"folders": list(PROJECT_SUBDIRS),
|
||||
"primary_sub": type_info.get("nas_sub", "documents"),
|
||||
},
|
||||
)
|
||||
|
||||
nas_path = str(proj_root)
|
||||
client_rel = str(client_root)
|
||||
execute(
|
||||
"""UPDATE cockpit_projects SET nas_path = %s, nas_client_root = %s, updated_at = NOW() WHERE id = %s""",
|
||||
(nas_path, client_rel, project_id),
|
||||
)
|
||||
return {"nas_path": nas_path, "nas_client_root": client_rel, "project_slug": proj_slug}
|
||||
|
||||
|
||||
def list_types() -> list[dict[str, Any]]:
|
||||
return list(PROJECT_TYPES)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Packaging agent — parse Herman-opdrachten en genereer designs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services import packaging_nas, projects
|
||||
|
||||
PACKAGING_KEYWORDS = (
|
||||
"maak verpakking",
|
||||
"maak een verpakking",
|
||||
"genereer verpakking",
|
||||
"ontwerp verpakking",
|
||||
"packaging:",
|
||||
"packaging ",
|
||||
"/packaging",
|
||||
"maak packaging",
|
||||
"maak package",
|
||||
"stanstekening",
|
||||
"verpakkingsontwerp",
|
||||
)
|
||||
|
||||
TYPE_ALIASES: list[tuple[str, str]] = [
|
||||
("folding box", "folding_box"),
|
||||
("folding_box", "folding_box"),
|
||||
("sluitdoos", "folding_box"),
|
||||
("doos", "folding_box"),
|
||||
("banderole", "wrap"),
|
||||
("wrap", "wrap"),
|
||||
("rond label", "round_label"),
|
||||
("round label", "round_label"),
|
||||
("round_label", "round_label"),
|
||||
("sleeve", "sleeve"),
|
||||
("huls", "sleeve"),
|
||||
("pouch", "pouch"),
|
||||
("zak", "pouch"),
|
||||
("tray", "tray"),
|
||||
("schaal", "tray"),
|
||||
]
|
||||
|
||||
DEFAULT_ELEMENTS = {
|
||||
"barcode": True,
|
||||
"logo_area": True,
|
||||
"fold_lines": True,
|
||||
"cut_lines": True,
|
||||
"nutrition_panel": False,
|
||||
"ingredients": True,
|
||||
"halal_badge": False,
|
||||
"window": False,
|
||||
"qr_code": False,
|
||||
"glue_tabs": False,
|
||||
"bleed": True,
|
||||
"dimensions": True,
|
||||
}
|
||||
|
||||
|
||||
def wants_packaging(raw: str) -> bool:
|
||||
t = (raw or "").strip().lower()
|
||||
return any(k in t for k in PACKAGING_KEYWORDS)
|
||||
|
||||
|
||||
def extract_packaging_body(raw: str) -> str:
|
||||
t = raw.strip()
|
||||
lower = t.lower()
|
||||
for k in PACKAGING_KEYWORDS:
|
||||
if lower.startswith(k):
|
||||
rest = t[len(k) :].strip(" :,-")
|
||||
if rest:
|
||||
return rest
|
||||
for k in PACKAGING_KEYWORDS:
|
||||
if k in lower:
|
||||
idx = lower.index(k) + len(k)
|
||||
rest = t[idx:].strip(" :,-")
|
||||
if rest:
|
||||
return rest
|
||||
return t
|
||||
|
||||
|
||||
def _detect_type(text: str) -> str:
|
||||
lower = text.lower()
|
||||
for alias, ptype in TYPE_ALIASES:
|
||||
if alias in lower:
|
||||
return ptype
|
||||
return "folding_box"
|
||||
|
||||
|
||||
def _detect_dimensions(text: str) -> tuple[float, float, float]:
|
||||
m = re.search(r"(\d{2,4})\s*[x×]\s*(\d{2,4})(?:\s*[x×]\s*(\d{1,4}))?", text, re.I)
|
||||
if m:
|
||||
w, h = float(m.group(1)), float(m.group(2))
|
||||
d = float(m.group(3)) if m.group(3) else (40.0 if _detect_type(text) == "folding_box" else 20.0)
|
||||
return w, h, d
|
||||
return 120.0, 80.0, 40.0
|
||||
|
||||
|
||||
def _detect_project_id(text: str) -> int | None:
|
||||
m = re.search(r"project\s*#?\s*(\d+)", text, re.I)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
m = re.search(r"\bproject\s+(\d+)\b", text, re.I)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _detect_product_name(text: str) -> str:
|
||||
m = re.search(r'voor\s+["\']?([^"\']+?)["\']?(?:\s+project|\s*$|,)', text, re.I)
|
||||
if m:
|
||||
return m.group(1).strip()[:80]
|
||||
m = re.search(r'product\s*[:=]\s*["\']?([^"\']+)["\']?', text, re.I)
|
||||
if m:
|
||||
return m.group(1).strip()[:80]
|
||||
cleaned = text
|
||||
for alias, _ in TYPE_ALIASES:
|
||||
cleaned = re.sub(re.escape(alias), "", cleaned, flags=re.I)
|
||||
cleaned = re.sub(r"\d{2,4}\s*[x×]\s*\d{2,4}(?:\s*[x×]\s*\d{1,4})?", "", cleaned, flags=re.I)
|
||||
cleaned = re.sub(r"project\s*#?\s*\d+", "", cleaned, flags=re.I)
|
||||
for kw in ("halal-badge", "halal badge", "voedingswaarden", "nutrition", "qr-code", "qr code", "venster", "window"):
|
||||
cleaned = re.sub(re.escape(kw), "", cleaned, flags=re.I)
|
||||
cleaned = cleaned.strip(" ,:-")
|
||||
return (cleaned[:80] or "Foodlinkk Product")
|
||||
|
||||
|
||||
def parse_packaging_request(message: str) -> dict[str, Any]:
|
||||
body = extract_packaging_body(message)
|
||||
lower = body.lower()
|
||||
ptype = _detect_type(body)
|
||||
w, h, d = _detect_dimensions(body)
|
||||
product = _detect_product_name(body)
|
||||
project_id = _detect_project_id(message) or _detect_project_id(body)
|
||||
|
||||
elements = dict(DEFAULT_ELEMENTS)
|
||||
if any(k in lower for k in ("halal", "halal-badge", "halal badge")):
|
||||
elements["halal_badge"] = True
|
||||
if any(k in lower for k in ("voedingswaarden", "nutrition")):
|
||||
elements["nutrition_panel"] = True
|
||||
if any(k in lower for k in ("qr", "qrcode")):
|
||||
elements["qr_code"] = True
|
||||
if "venster" in lower or "window" in lower:
|
||||
elements["window"] = True
|
||||
|
||||
return {
|
||||
"type": ptype,
|
||||
"width_mm": w,
|
||||
"height_mm": h,
|
||||
"depth_mm": d,
|
||||
"bleed_mm": 3,
|
||||
"design_name": product,
|
||||
"barcode_value": "8710000000012",
|
||||
"elements": elements,
|
||||
"text": {
|
||||
"product_name": product,
|
||||
"tagline": "Premium halal kant-en-klaar",
|
||||
"subtitle": "",
|
||||
"ingredients": "",
|
||||
"origin": "Geproduceerd in NL",
|
||||
"best_before": "Ten minste houdbaar tot: zie verpakking",
|
||||
},
|
||||
"brand": {},
|
||||
"project_id": project_id,
|
||||
"created_by": "packaging",
|
||||
}
|
||||
|
||||
|
||||
async def _download_bytes(packaging_id: str, fmt: str) -> bytes | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
r = await client.get(
|
||||
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/download/{packaging_id}",
|
||||
params={"format": fmt},
|
||||
)
|
||||
if r.status_code < 400:
|
||||
return r.content
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def generate_from_message(message: str) -> dict[str, Any]:
|
||||
"""Genereer packaging design en exporteer naar NAS; retour voor Herman-reply."""
|
||||
spec = parse_packaging_request(message)
|
||||
|
||||
if not spec.get("project_id"):
|
||||
proj = projects.create_project(
|
||||
f"Packaging · {spec['design_name'][:48]}",
|
||||
description=f"Aangemaakt door packaging agent via Herman\n\nOpdracht: {message[:500]}",
|
||||
project_type="packaging",
|
||||
ensure_nas=True,
|
||||
created_by="packaging",
|
||||
)
|
||||
spec["project_id"] = proj.get("id")
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
r = await client.post(
|
||||
f"{settings.TOOLS_API_URL.rstrip('/')}/packaging/generate",
|
||||
json=spec,
|
||||
)
|
||||
r.raise_for_status()
|
||||
result = r.json()
|
||||
|
||||
packaging_id = result.get("id", "")
|
||||
cockpit_project_id = result.get("cockpit_project_id") or spec.get("project_id")
|
||||
svg = result.get("svg") or ""
|
||||
saved_spec = result.get("spec") or spec
|
||||
|
||||
nas_info: dict[str, Any] = {}
|
||||
if packaging_id and cockpit_project_id and svg:
|
||||
png_b = await _download_bytes(packaging_id, "png")
|
||||
pdf_b = await _download_bytes(packaging_id, "pdf")
|
||||
try:
|
||||
nas_info = packaging_nas.export_packaging_files(
|
||||
packaging_id, int(cockpit_project_id), svg, saved_spec, png_b, pdf_b
|
||||
)
|
||||
except Exception as exc:
|
||||
nas_info = {"ok": False, "error": str(exc)}
|
||||
|
||||
studio_url = f"/packaging?project_id={cockpit_project_id}"
|
||||
pdf_url = f"/api/packaging/download/{packaging_id}?format=pdf"
|
||||
|
||||
return {
|
||||
"packaging_id": packaging_id,
|
||||
"cockpit_project_id": cockpit_project_id,
|
||||
"design_name": saved_spec.get("design_name") or spec.get("design_name"),
|
||||
"type": saved_spec.get("type"),
|
||||
"dimensions": f"{saved_spec.get('width_mm')}×{saved_spec.get('height_mm')}×{saved_spec.get('depth_mm')} mm",
|
||||
"studio_url": studio_url,
|
||||
"pdf_url": pdf_url,
|
||||
"nas": nas_info,
|
||||
"spec": saved_spec,
|
||||
"original_message": message,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Export packaging designs naar NAS projectmap."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.db import execute, fetch_one
|
||||
from app.services import nas_folders
|
||||
|
||||
|
||||
def _packaging_dir(project_id: int) -> Path | None:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT p.id, p.name, p.nas_path, p.client_id, p.project_type, c.name AS client_name
|
||||
FROM cockpit_projects p
|
||||
LEFT JOIN clients c ON c.id = p.client_id
|
||||
WHERE p.id = %s
|
||||
""",
|
||||
(project_id,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
nas_path = row.get("nas_path")
|
||||
if not nas_path:
|
||||
try:
|
||||
paths = nas_folders.ensure_project_folder(
|
||||
int(row["id"]),
|
||||
row["name"] or f"project-{project_id}",
|
||||
row.get("client_id"),
|
||||
row.get("client_name"),
|
||||
row.get("project_type") or "packaging",
|
||||
)
|
||||
nas_path = paths.get("nas_path")
|
||||
except Exception:
|
||||
return None
|
||||
root = Path(nas_path) / "packaging"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def export_packaging_files(
|
||||
packaging_id: str,
|
||||
cockpit_project_id: int,
|
||||
svg_content: str,
|
||||
spec: dict[str, Any],
|
||||
png_bytes: bytes | None = None,
|
||||
pdf_bytes: bytes | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Schrijf SVG/PNG/PDF + manifest naar NAS packaging/ submap."""
|
||||
root = _packaging_dir(cockpit_project_id)
|
||||
if not root:
|
||||
return {"ok": False, "error": "Geen NAS-map voor project"}
|
||||
|
||||
design_name = spec.get("design_name") or spec.get("text", {}).get("product_name") or packaging_id[:8]
|
||||
slug = nas_folders.slugify(str(design_name))[:32]
|
||||
base = f"{packaging_id[:8]}-{slug}"
|
||||
|
||||
paths: dict[str, str] = {}
|
||||
svg_path = root / f"{base}.svg"
|
||||
svg_path.write_text(svg_content, encoding="utf-8")
|
||||
paths["svg"] = str(svg_path)
|
||||
|
||||
if png_bytes:
|
||||
png_path = root / f"{base}.png"
|
||||
png_path.write_bytes(png_bytes)
|
||||
paths["png"] = str(png_path)
|
||||
|
||||
if pdf_bytes:
|
||||
pdf_path = root / f"{base}.pdf"
|
||||
pdf_path.write_bytes(pdf_bytes)
|
||||
paths["pdf"] = str(pdf_path)
|
||||
|
||||
manifest = {
|
||||
"packaging_id": packaging_id,
|
||||
"design_name": design_name,
|
||||
"spec": spec,
|
||||
"files": paths,
|
||||
}
|
||||
manifest_path = root / f"{base}.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
paths["manifest"] = str(manifest_path)
|
||||
|
||||
primary = paths.get("pdf") or paths.get("svg")
|
||||
execute(
|
||||
"""
|
||||
UPDATE project_assets SET file_path = %s
|
||||
WHERE project_id = %s AND asset_type = 'packaging' AND ref_id = %s
|
||||
""",
|
||||
(primary, cockpit_project_id, packaging_id),
|
||||
)
|
||||
|
||||
return {"ok": True, "nas_packaging_dir": str(root), "files": paths}
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Unified cockpit projects and cross-module assets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
from app.services import nas_folders
|
||||
|
||||
|
||||
def _row(row: dict | None) -> dict | None:
|
||||
if not row:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k, v in list(out.items()):
|
||||
if hasattr(v, "isoformat"):
|
||||
out[k] = v.isoformat()
|
||||
return out
|
||||
|
||||
|
||||
def list_projects(client_id: Optional[int] = None, limit: int = 50) -> list[dict[str, Any]]:
|
||||
clauses, params = [], []
|
||||
if client_id:
|
||||
clauses.append("p.client_id = %s")
|
||||
params.append(client_id)
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
safe = max(1, min(limit, 200))
|
||||
rows = fetch_all(
|
||||
f"""
|
||||
SELECT p.*, c.name AS client_name,
|
||||
(SELECT COUNT(*) FROM project_assets a WHERE a.project_id = p.id) AS asset_count
|
||||
FROM cockpit_projects p
|
||||
LEFT JOIN clients c ON c.id = p.client_id
|
||||
{where}
|
||||
ORDER BY p.updated_at DESC, p.id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
tuple(params + [safe]),
|
||||
)
|
||||
return [_row(r) for r in rows]
|
||||
|
||||
|
||||
def get_project(project_id: int) -> dict[str, Any] | None:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT p.*, c.name AS client_name
|
||||
FROM cockpit_projects p
|
||||
LEFT JOIN clients c ON c.id = p.client_id
|
||||
WHERE p.id = %s
|
||||
""",
|
||||
(project_id,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
out = _row(row) or {}
|
||||
assets = fetch_all(
|
||||
"SELECT * FROM project_assets WHERE project_id = %s ORDER BY created_at DESC",
|
||||
(project_id,),
|
||||
)
|
||||
out["assets"] = [_row(a) for a in assets]
|
||||
return out
|
||||
|
||||
|
||||
def create_project(
|
||||
name: str,
|
||||
client_id: Optional[int] = None,
|
||||
description: str = "",
|
||||
created_by: str = "ceo",
|
||||
metadata: dict | None = None,
|
||||
project_type: str = "general",
|
||||
priority: str = "normal",
|
||||
ensure_nas: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO cockpit_projects (name, client_id, description, created_by, metadata, project_type, priority, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s, NOW())
|
||||
RETURNING *
|
||||
""",
|
||||
(name.strip(), client_id, description or None, created_by, json.dumps(metadata or {}), project_type, priority),
|
||||
)
|
||||
out = _row(row) or {}
|
||||
if ensure_nas and out.get("id"):
|
||||
client_name = None
|
||||
if client_id:
|
||||
c = fetch_one("SELECT name FROM clients WHERE id = %s", (client_id,))
|
||||
client_name = c["name"] if c else None
|
||||
try:
|
||||
paths = nas_folders.ensure_project_folder(
|
||||
int(out["id"]), name, client_id, client_name, project_type
|
||||
)
|
||||
out.update(paths)
|
||||
except Exception as exc:
|
||||
out["nas_error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def update_project(project_id: int, **fields: Any) -> dict[str, Any] | None:
|
||||
allowed = ("name", "description", "status", "client_id", "project_type", "priority")
|
||||
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:
|
||||
return get_project(project_id)
|
||||
params.append(project_id)
|
||||
execute(f"UPDATE cockpit_projects SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s", tuple(params))
|
||||
return get_project(project_id)
|
||||
|
||||
|
||||
def project_stats() -> dict[str, Any]:
|
||||
total = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects") or {"n": 0}
|
||||
by_type = fetch_all(
|
||||
"SELECT COALESCE(project_type, 'general') AS t, COUNT(*) AS n FROM cockpit_projects GROUP BY t"
|
||||
)
|
||||
with_nas = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects WHERE nas_path IS NOT NULL")
|
||||
with_client = fetch_one("SELECT COUNT(*) AS n FROM cockpit_projects WHERE client_id IS NOT NULL")
|
||||
assets = fetch_one("SELECT COUNT(*) AS n FROM project_assets")
|
||||
return {
|
||||
"total": int(total.get("n") or 0),
|
||||
"with_nas": int((with_nas or {}).get("n") or 0),
|
||||
"with_client": int((with_client or {}).get("n") or 0),
|
||||
"assets": int((assets or {}).get("n") or 0),
|
||||
"by_type": {r["t"]: int(r["n"]) for r in by_type},
|
||||
}
|
||||
|
||||
|
||||
def add_asset(
|
||||
project_id: int,
|
||||
asset_type: str,
|
||||
title: str,
|
||||
ref_id: str | None = None,
|
||||
file_path: str | None = None,
|
||||
payload: dict | None = None,
|
||||
created_by: str = "ceo",
|
||||
source_agent: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
INSERT INTO project_assets (project_id, asset_type, ref_id, title, file_path, payload, created_by, source_agent)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
asset_type,
|
||||
ref_id,
|
||||
title,
|
||||
file_path,
|
||||
json.dumps(payload or {}),
|
||||
created_by,
|
||||
source_agent,
|
||||
),
|
||||
)
|
||||
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (project_id,))
|
||||
return _row(row) or {}
|
||||
|
||||
|
||||
def get_or_create_agent_project(agent_key: str, client_id: Optional[int] = None) -> int:
|
||||
key = (agent_key or "agent").strip().lower()
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT id FROM cockpit_projects
|
||||
WHERE metadata->>'auto_agent' = %s AND status = 'active'
|
||||
ORDER BY updated_at DESC LIMIT 1
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
if row:
|
||||
return int(row["id"])
|
||||
created = create_project(
|
||||
f"Agent · {key}",
|
||||
client_id=client_id,
|
||||
description=f"Automatisch project voor {key} output",
|
||||
created_by=key,
|
||||
metadata={"auto_agent": key},
|
||||
)
|
||||
return int(created["id"])
|
||||
|
||||
|
||||
def register_agent_output(
|
||||
asset_type: str,
|
||||
title: str,
|
||||
ref_id: str | None = None,
|
||||
payload: dict | None = None,
|
||||
project_id: Optional[int] = None,
|
||||
source_agent: str | None = None,
|
||||
created_by: str = "ceo",
|
||||
file_path: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
agent = (source_agent or created_by or "agent").strip().lower()
|
||||
pid = project_id or get_or_create_agent_project(agent)
|
||||
|
||||
if ref_id:
|
||||
existing = fetch_one(
|
||||
"""
|
||||
SELECT id FROM project_assets
|
||||
WHERE project_id = %s AND asset_type = %s AND ref_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(pid, asset_type, ref_id),
|
||||
)
|
||||
if existing:
|
||||
return {"id": existing["id"], "project_id": pid, "dedup": True}
|
||||
|
||||
return add_asset(
|
||||
pid,
|
||||
asset_type,
|
||||
title,
|
||||
ref_id=ref_id,
|
||||
file_path=file_path,
|
||||
payload=payload,
|
||||
created_by=created_by or agent,
|
||||
source_agent=agent,
|
||||
)
|
||||
|
||||
|
||||
def link_photo_to_project(photo_id: int, project_id: int, created_by: str = "ceo") -> None:
|
||||
photo = fetch_one("SELECT id, filename, source FROM photo_imports WHERE id = %s", (photo_id,))
|
||||
if not photo:
|
||||
raise ValueError("Photo not found")
|
||||
execute(
|
||||
"UPDATE photo_imports SET project_id = %s, created_by = COALESCE(created_by, %s) WHERE id = %s",
|
||||
(project_id, created_by, photo_id),
|
||||
)
|
||||
add_asset(
|
||||
project_id,
|
||||
"photo",
|
||||
photo.get("filename") or f"Foto #{photo_id}",
|
||||
ref_id=str(photo_id),
|
||||
created_by=created_by,
|
||||
source_agent=photo.get("source"),
|
||||
)
|
||||
@@ -244,7 +244,14 @@ def test_connection(platform: str, integration: dict[str, Any] | None = None) ->
|
||||
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:
|
||||
def run_publish_job(
|
||||
job_id: int,
|
||||
text: str,
|
||||
channels: list[str],
|
||||
image_url: str | None,
|
||||
media_ids: list[int],
|
||||
project_id: int | None = None,
|
||||
) -> None:
|
||||
started_at = datetime.utcnow()
|
||||
execute(
|
||||
"UPDATE social_publish_jobs SET status=%s, started_at=NOW(), updated_at=NOW() WHERE id=%s",
|
||||
@@ -310,5 +317,26 @@ def run_publish_job(job_id: int, text: str, channels: list[str], image_url: str
|
||||
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},
|
||||
metadata={"job_id": job_id, "results": results, "project_id": project_id},
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services import projects as project_svc
|
||||
|
||||
project_svc.register_agent_output(
|
||||
asset_type="social_publish",
|
||||
title=f"Social publish #{job_id} · {final_status}",
|
||||
ref_id=str(job_id),
|
||||
payload={
|
||||
"job_id": job_id,
|
||||
"status": final_status,
|
||||
"channels": channels,
|
||||
"published": published,
|
||||
"text_preview": (text or "")[:200],
|
||||
},
|
||||
project_id=project_id,
|
||||
source_agent="marketing",
|
||||
created_by="marketing_automation",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""User UI preferences — dashboard layout and visualization modes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import execute, fetch_one
|
||||
|
||||
DEFAULT_LAYOUT = ["kpis", "executive", "briefing", "retail", "analytics", "approvals", "feed"]
|
||||
DEFAULT_VIZ = "neo-bars"
|
||||
|
||||
VIZ_MODES = [
|
||||
{"id": "neo-bars", "label": "Neo staafdiagram", "icon": "📊"},
|
||||
{"id": "neo-rings", "label": "Neo ringen", "icon": "🍩"},
|
||||
{"id": "neo-equalizer", "label": "Neo equalizer", "icon": "🎚️"},
|
||||
{"id": "neo-cards", "label": "Neo kaarten", "icon": "🃏"},
|
||||
{"id": "neo-table", "label": "Neo tabel", "icon": "📋"},
|
||||
]
|
||||
|
||||
|
||||
def get_preferences(user_key: str = "ceo") -> dict[str, Any]:
|
||||
row = fetch_one("SELECT * FROM user_ui_preferences WHERE user_key = %s", (user_key,))
|
||||
if not row:
|
||||
return {
|
||||
"user_key": user_key,
|
||||
"dashboard_layout": DEFAULT_LAYOUT,
|
||||
"viz_modes": {},
|
||||
"global_viz_mode": DEFAULT_VIZ,
|
||||
"locale": "nl",
|
||||
"viz_options": VIZ_MODES,
|
||||
}
|
||||
layout = row.get("dashboard_layout") or DEFAULT_LAYOUT
|
||||
if isinstance(layout, str):
|
||||
try:
|
||||
layout = json.loads(layout)
|
||||
except Exception:
|
||||
layout = DEFAULT_LAYOUT
|
||||
viz_modes = row.get("viz_modes") or {}
|
||||
if isinstance(viz_modes, str):
|
||||
try:
|
||||
viz_modes = json.loads(viz_modes)
|
||||
except Exception:
|
||||
viz_modes = {}
|
||||
return {
|
||||
"user_key": user_key,
|
||||
"dashboard_layout": layout,
|
||||
"viz_modes": viz_modes,
|
||||
"global_viz_mode": row.get("global_viz_mode") or DEFAULT_VIZ,
|
||||
"locale": row.get("locale") or "nl",
|
||||
"viz_options": VIZ_MODES,
|
||||
}
|
||||
|
||||
|
||||
def save_preferences(
|
||||
user_key: str,
|
||||
dashboard_layout: list[str] | None = None,
|
||||
global_viz_mode: str | None = None,
|
||||
viz_modes: dict[str, str] | None = None,
|
||||
locale: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = get_preferences(user_key)
|
||||
layout = dashboard_layout if dashboard_layout is not None else current["dashboard_layout"]
|
||||
gviz = global_viz_mode if global_viz_mode is not None else current["global_viz_mode"]
|
||||
vmodes = viz_modes if viz_modes is not None else current["viz_modes"]
|
||||
loc = locale if locale is not None else current.get("locale", "nl")
|
||||
if loc not in ("nl", "en"):
|
||||
loc = "nl"
|
||||
fetch_one(
|
||||
"""
|
||||
INSERT INTO user_ui_preferences (user_key, dashboard_layout, global_viz_mode, viz_modes, locale, updated_at)
|
||||
VALUES (%s, %s::jsonb, %s, %s::jsonb, %s, NOW())
|
||||
ON CONFLICT (user_key) DO UPDATE SET
|
||||
dashboard_layout = EXCLUDED.dashboard_layout,
|
||||
global_viz_mode = EXCLUDED.global_viz_mode,
|
||||
viz_modes = EXCLUDED.viz_modes,
|
||||
locale = EXCLUDED.locale,
|
||||
updated_at = NOW()
|
||||
RETURNING user_key
|
||||
""",
|
||||
(user_key, json.dumps(layout), gviz, json.dumps(vmodes), loc),
|
||||
)
|
||||
return get_preferences(user_key)
|
||||
Reference in New Issue
Block a user