Files
foodlinkk-command-center/cockpit/app/services/herman.py
T
Aissa 4dd1b8265f Add live CEO briefing, Wereldexport CRM, halal engine, and realtime cockpit.
Ship export intel with contact filters and CRM push, Herman live digest with Telegram briefing, halal recommendation engine, revenue cockpit, and dashboard polling/WebSocket fixes.
2026-07-19 18:25:21 +00:00

386 lines
15 KiB
Python

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
from app.services import packaging_agent
from app.services import webbuilder_agent
from app.services import voice_export_actions
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."},
"packaging": {"name": "Packaging", "persona": "SVG/PDF verpakkingsontwerp, stanstekeningen, drukwerk."},
"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",
)
BRIEFING_KEYWORDS = (
"/briefing", "briefing", "samenvatting", "dagrapport",
"ochtendbriefing", "avondbriefing", "ceo briefing", "live briefing",
)
def _wants_briefing(raw: str) -> bool:
t = (raw or "").strip().lower()
if not t:
return False
if t.startswith("/briefing"):
return True
if len(t) > 120:
return False
return any(k in t for k in BRIEFING_KEYWORDS)
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,
channel: str = "herman",
) -> 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": channel,
}
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", channel, 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"
def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dict] | None = None) -> list[dict[str, str]]:
steps: list[dict[str, str]] = []
for a in delegated:
key = (a or "").strip().lower()
if key and key != "herman":
steps.append({"agent": key, "status": "delegated", "message": routing_reason or "Aangestuurd door Herman"})
if extra:
steps.extend(extra)
return steps
async def chat(
message: str,
channel: str = "cockpit",
session_id: str | None = None,
confirm_action_id: str | None = None,
) -> dict[str, Any]:
use_voice_router = (
channel in voice_export_actions.VOICE_CHANNELS
or bool(session_id)
or voice_export_actions.wants_export_search(message)
or webbuilder_agent.wants_website(message)
)
if use_voice_router:
routed = await voice_export_actions.handle_voice_command(
message,
session_id=session_id,
confirm_action_id=confirm_action_id,
channel=channel,
)
if routed is not None:
await _log_event(
"herman",
"voice_command" if channel == "voice" else "browser_command",
(routed.get("reply") or "")[:120],
message[:2000],
{
"pending_action": routed.get("pending_action"),
"ui_actions": routed.get("ui_actions"),
"channel": channel,
},
channel=channel,
)
return routed
if channel == "telegram" and _wants_briefing(message):
from app.services.briefing import build_live_digest_text, collect_briefing_data
data = collect_briefing_data()
reply = build_live_digest_text(data)
await _log_event(
"herman",
"briefing",
"Live briefing (Telegram)",
reply[:1500],
{"channel": channel, "source": "live_digest"},
channel=channel,
)
return {
"agent": "herman",
"agent_label": "Herman · Briefing",
"reply": reply,
"delegated_agents": ["herman"],
"routing_reason": "Live cockpit-samenvatting",
"agent_steps": _agent_steps([], "Live digest uit briefing engine"),
}
if webbuilder_agent.wants_website(message):
try:
outcome = await webbuilder_agent.generate_from_message(message, channel=channel, wait=False)
project = outcome.get("project") or "website"
preview = outcome.get("preview_url") or ""
reply_lines = [
f"Web Builder is gestart voor project **{project}**.",
f"NAS: {outcome.get('nas_path') or '—'}",
f"Preview (na afloop): {preview}",
"Volg de live terminal: Agents → Terminals → Web Builder.",
]
reply = "\n".join(reply_lines)
await _log_event(
"herman",
"website_delegation",
f"Herman → Web Builder: {project}",
message[:2000],
{
"delegated": ["webbuilder"],
"delegated_agents": ["webbuilder"],
"project": project,
"preview_url": preview,
"channel": channel,
},
channel=channel,
)
delegated = ["webbuilder"]
return {
"agent": "webbuilder",
"agent_label": "Web Builder → Herman",
"reply": reply,
"delegated_agents": delegated,
"routing_reason": "Website-opdracht — Web Builder gestart op Hermes/agy",
"agent_steps": _agent_steps(delegated, "Website build gestart"),
"webbuilder_project": project,
"webbuilder_preview_url": preview,
"ui_actions": [
{
"type": "open_webbuilder_build",
"title": f"Website build — {project}",
"project": project,
"preview_url": preview,
"nas_path": outcome.get("nas_path") or "",
"agents_url": "/agents",
}
],
}
except Exception as exc:
await _log_event(
"webbuilder",
"website_build_error",
"Website start mislukt",
str(exc)[:1500],
{"message": message[:500]},
)
return {
"agent": "webbuilder",
"agent_label": "Web Builder",
"reply": f"Web Builder kon niet starten: {exc}",
"delegated_agents": ["webbuilder"],
}
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,
"delegated": ["packaging"],
"channel": channel,
},
channel=channel,
)
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"),
"delegated": ["packaging"],
"channel": channel,
},
channel=channel,
)
delegated = ["packaging"]
return {
"agent": "packaging",
"agent_label": "Packaging → Herman",
"reply": reply,
"delegated_agents": delegated,
"routing_reason": "Packaging-opdracht gedetecteerd — design gegenereerd en aan Herman gerapporteerd",
"agent_steps": _agent_steps(delegated, "Packaging design gegenereerd"),
"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:
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, "channel": channel}, channel=channel)
delegated = ["design"]
return {
"agent": "design",
"agent_label": "Design",
"reply": reply,
"image_url": proxy,
"prompt": prompt,
"delegated_agents": delegated,
"agent_steps": _agent_steps(delegated, "Afbeelding gegenereerd"),
}
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, "use_rag": True, "channel": channel},
)
r.raise_for_status()
data = r.json()
delegated = data.get("delegated_agents") or [data.get("agent", "herman")]
reason = data.get("routing_reason", "") or ""
await _log_event(
"herman",
"voice_delegation" if channel == "voice" else ("telegram_delegation" if channel == "telegram" else "openswarm_delegation"),
f"Herman → {', '.join(delegated)}",
message[:2000],
{"delegated": delegated, "delegated_agents": delegated, "reason": reason, "channel": channel},
channel=channel,
)
return {
"agent": data.get("agent", "herman"),
"agent_label": data.get("agent_label", "Herman"),
"reply": data.get("reply", ""),
"delegated_agents": delegated,
"routing_reason": reason,
"agent_steps": _agent_steps(delegated, reason),
"rag_sources": data.get("rag_sources") or [],
"crm_loaded": data.get("crm_loaded", False),
}
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