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
+750
View File
@@ -0,0 +1,750 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Optional
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel, Field
from app.db import close_pool, execute_returning, fetch_all, fetch_one, init_pool
from app.middleware import log_agent_event
from app.packaging_routes import router as packaging_router
from app.retail import router as retail_router
from app.retail_360_routes import router as retail_360_router
from app.research import router as research_router
from app.recommendations import router as recommendations_router
from app.ops_routes import router as ops_router
from app.logging_middleware import AgentLoggingMiddleware
from app import brain as brain_svc
class BrainMessageIn(BaseModel):
chat_id: int
direction: str = Field(..., pattern="^(in|out)$")
content_text: Optional[str] = None
content_type: str = "text"
role: str = "user"
telegram_message_id: Optional[int] = None
reply_to_db_id: Optional[int] = None
agent_name: Optional[str] = None
content_json: dict[str, Any] = Field(default_factory=dict)
user_name: Optional[str] = None
user_role: Optional[str] = None
chat_type: str = "private"
embed: bool = True
class BrainEdgeIn(BaseModel):
source_message_id: int
edge_type: str
target_message_id: Optional[int] = None
target_entity_type: Optional[str] = None
target_entity_id: Optional[int] = None
weight: float = 1.0
metadata: dict[str, Any] = Field(default_factory=dict)
class BrainSearchIn(BaseModel):
query: str = Field(..., min_length=2)
chat_id: Optional[int] = None
limit: int = Field(default=8, ge=1, le=30)
from app.email_config import get_active_email_config
from app.comfyui import fetch_image_bytes, generate_image, get_job, start_generation, QUALITY_PRESETS
import json
import os
DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
app = FastAPI(title="Foodlinkk Tools API", version="1.1.0")
app.add_middleware(AgentLoggingMiddleware)
app.include_router(retail_router)
app.include_router(retail_360_router)
app.include_router(research_router)
app.include_router(recommendations_router)
app.include_router(packaging_router)
app.include_router(ops_router)
class AgentEventCreate(BaseModel):
agent_name: str = Field(..., max_length=64)
event_type: str = Field(..., max_length=64)
title: str = Field(..., max_length=255)
body: Optional[str] = None
agent_type: str = Field(default="openswarm", max_length=32)
metadata: dict[str, Any] = Field(default_factory=dict)
status: str = Field(default="completed", max_length=32)
related_table: Optional[str] = Field(default=None, max_length=64)
related_id: Optional[int] = None
channel: str = Field(default="dashboard", max_length=32)
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, value in row.items():
if isinstance(value, (datetime, date)):
out[key] = value.isoformat()
elif isinstance(value, Decimal):
out[key] = float(value)
else:
out[key] = value
return out
@app.on_event("startup")
def on_startup() -> None:
init_pool()
@app.on_event("shutdown")
def on_shutdown() -> None:
close_pool()
@app.get("/health")
def health() -> dict[str, str]:
try:
fetch_one("SELECT 1 AS ok")
return {"status": "ok", "database": "connected"}
except Exception as exc:
return {"status": "degraded", "database": str(exc)}
@app.post("/events")
def create_event(payload: AgentEventCreate) -> dict[str, Any]:
try:
row = log_agent_event(**payload.model_dump())
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return _serialize_row(row)
@app.get("/events")
def list_events(
limit: int = Query(default=50, ge=1, le=200),
status: Optional[str] = Query(default=None),
agent_name: Optional[str] = Query(default=None),
) -> dict[str, Any]:
clauses: list[str] = []
params: list[Any] = []
if status:
clauses.append("status = %s")
params.append(status)
if agent_name:
clauses.append("agent_name = %s")
params.append(agent_name)
where = f"WHERE { AND .join(clauses)}" if clauses else ""
params.append(limit)
try:
rows = fetch_all(
f"""
SELECT *
FROM agent_events
{where}
ORDER BY created_at DESC
LIMIT %s
""",
tuple(params),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"events": [_serialize_row(r) for r in rows]}
@app.post("/events/{event_id}/approve")
def approve_event(event_id: int) -> dict[str, Any]:
row = execute_returning(
"""
UPDATE agent_events
SET status = %s, completed_at = NOW()
WHERE id = %s
RETURNING *
""",
("approved", event_id),
)
if row is None:
raise HTTPException(status_code=404, detail="Event not found")
return _serialize_row(row)
@app.post("/events/{event_id}/reject")
def reject_event(event_id: int) -> dict[str, Any]:
row = execute_returning(
"""
UPDATE agent_events
SET status = %s, completed_at = NOW()
WHERE id = %s
RETURNING *
""",
("rejected", event_id),
)
if row is None:
raise HTTPException(status_code=404, detail="Event not found")
return _serialize_row(row)
class BrowserTask(BaseModel):
url: str
task: str = Field(default="open")
metadata: dict[str, Any] = Field(default_factory=dict)
class HermanDelegate(BaseModel):
message: str
target_agent: Optional[str] = Field(default=None, max_length=64)
@app.post("/browser/task")
async def browser_task(payload: BrowserTask) -> dict[str, Any]:
import httpx
import os
browser_url = os.getenv("BROWSER_USE_URL", "http://browser-agent:7790")
result = {"ok": True, "browser_url": browser_url, "url": payload.url}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{browser_url.rstrip('/')}/task", json=payload.model_dump())
result["upstream_status"] = resp.status_code
if resp.status_code < 500:
try:
result["upstream"] = resp.json()
except Exception:
result["upstream"] = resp.text[:500]
except Exception as exc:
result["upstream_error"] = str(exc)
try:
log_agent_event(
agent_name="browser",
event_type="browser_task",
title=f"Browser task: {payload.url[:120]}",
body=payload.task,
metadata={"url": payload.url, **payload.metadata, **result},
channel="tools-api",
)
except Exception:
pass
return result
@app.post("/herman/delegate")
def herman_delegate(payload: HermanDelegate) -> dict[str, Any]:
agent = payload.target_agent or "herman"
try:
row = log_agent_event(
agent_name=agent,
agent_type="herman_delegate",
event_type="delegate",
title="Herman delegation",
body=payload.message,
metadata={"target_agent": agent},
channel="herman",
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return _serialize_row(row)
@app.get("/knowledge/search")
async def knowledge_search(q: str = Query(..., min_length=1), limit: int = Query(default=10, ge=1, le=50)) -> dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{DOC_INGEST_URL.rstrip('/')}/search", params={"q": q, "limit": limit})
if r.status_code == 200:
data = r.json()
return {"query": q, "results": data.get("results", []), "source": "chroma"}
except Exception:
pass
try:
rows = fetch_all(
"""
SELECT id, title, source, doc_type, metadata, created_at
FROM knowledge_documents
WHERE title ILIKE %s OR source ILIKE %s
ORDER BY updated_at DESC NULLS LAST
LIMIT %s
""",
(f"%{q}%", f"%{q}%", limit),
)
except Exception:
rows = []
return {"query": q, "results": [_serialize_row(r) for r in rows], "source": "postgres"}
@app.post("/knowledge/ingest")
async def knowledge_ingest(force: bool = Query(default=False)) -> dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=600.0) as client:
r = await client.post(f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan", params={"force": force})
r.raise_for_status()
return r.json()
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.get("/knowledge/nas")
async def knowledge_nas(limit: int = Query(default=50, ge=1, le=500)) -> dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{DOC_INGEST_URL.rstrip('/')}/nas/list", params={"limit": limit})
r.raise_for_status()
return r.json()
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
# --- CRM / Email / LLM memory (2nd brain) ---
class EmailSend(BaseModel):
to: list[str] = Field(..., min_length=1)
subject: str = Field(..., max_length=500)
body: str
client_id: Optional[int] = None
deal_id: Optional[int] = None
cc: list[str] = Field(default_factory=list)
class LlmMemoryCreate(BaseModel):
category: str = Field(default="fact", max_length=64)
subject: Optional[str] = Field(default=None, max_length=255)
content: str
client_id: Optional[int] = None
deal_id: Optional[int] = None
source: str = Field(default="herman", max_length=64)
metadata: dict[str, Any] = Field(default_factory=dict)
def _crm_safe_all(sql: str, params: tuple = ()) -> list[dict[str, Any]]:
try:
return fetch_all(sql, params or None)
except Exception:
return []
@app.get("/crm/context")
def crm_context(days: int = Query(default=7, ge=1, le=30)) -> dict[str, Any]:
"""Live PostgreSQL bundle for Herman / morning briefing."""
pipeline = fetch_one(
"SELECT COALESCE(SUM(value), 0) AS total, COUNT(*) AS cnt FROM deals WHERE stage NOT IN ('won', 'lost')"
)
clients = _crm_safe_all(
"""SELECT id, name, contact, email, stage, sector, notes, mrr_estimate
FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 25"""
)
deals = _crm_safe_all(
"""
SELECT d.id, d.title, d.value, d.stage, d.next_action, d.deadline, d.agent_owner,
c.name AS client_name, c.email AS client_email
FROM deals d
LEFT JOIN clients c ON c.id = d.client_id
WHERE d.stage NOT IN ('won', 'lost')
ORDER BY d.deadline ASC NULLS LAST, d.updated_at DESC NULLS LAST
LIMIT 20
"""
)
upcoming = _crm_safe_all(
"""
SELECT d.id, d.title, d.value, d.stage, d.deadline, d.next_action, c.name AS client_name
FROM deals d
LEFT JOIN clients c ON c.id = d.client_id
WHERE d.deadline IS NOT NULL
AND d.deadline <= CURRENT_DATE + make_interval(days => %s)
AND d.stage NOT IN ('won', 'lost')
ORDER BY d.deadline ASC
LIMIT 15
""",
(days,),
)
calendar = _crm_safe_all(
"""
SELECT ce.id, ce.title, ce.starts_at, ce.ends_at, ce.location, ce.source,
c.name AS client_name, d.title AS deal_title
FROM calendar_events ce
LEFT JOIN clients c ON c.id = ce.client_id
LEFT JOIN deals d ON d.id = ce.deal_id
WHERE ce.starts_at >= NOW() - INTERVAL '1 day'
AND ce.starts_at <= NOW() + make_interval(days => %s)
ORDER BY ce.starts_at ASC
LIMIT 25
""",
(days,),
)
pending = _crm_safe_all(
"""
SELECT id, agent_name, title, event_type, created_at
FROM agent_events WHERE status = 'needs_approval'
ORDER BY created_at ASC LIMIT 10
"""
)
emails_recent = _crm_safe_all(
"""
SELECT id, from_addr, subject, received_at, sent_at, direction, is_read, client_id
FROM emails ORDER BY COALESCE(received_at, sent_at) DESC NULLS LAST LIMIT 15
"""
)
memories = _crm_safe_all(
"""
SELECT id, category, subject, content, client_id, deal_id, source, updated_at
FROM llm_memory ORDER BY updated_at DESC LIMIT 20
"""
)
return {
"generated_at": datetime.utcnow().isoformat() + "Z",
"pipeline_eur": float(pipeline["total"]) if pipeline else 0.0,
"active_deals": int(pipeline["cnt"]) if pipeline else 0,
"clients_count": len(clients),
"clients": [_serialize_row(c) for c in clients],
"deals": [_serialize_row(d) for d in deals],
"upcoming_deadlines": [_serialize_row(u) for u in upcoming],
"calendar": [_serialize_row(c) for c in calendar],
"pending_approvals": [_serialize_row(p) for p in pending],
"recent_emails": [_serialize_row(e) for e in emails_recent],
"llm_memory": [_serialize_row(m) for m in memories],
"links": {
"dashboard": "http://10.4.7.18:8600",
"clients": "http://10.4.7.18:8600/clients",
"deals": "http://10.4.7.18:8600/deals",
"reports": "http://10.4.7.18:8600/reports",
},
}
@app.get("/crm/clients")
def crm_clients(limit: int = Query(default=25, ge=1, le=100)) -> dict[str, Any]:
rows = _crm_safe_all(
"SELECT id, name, contact, email, stage, sector, notes FROM clients ORDER BY name LIMIT %s",
(limit,),
)
return {"clients": [_serialize_row(r) for r in rows]}
@app.get("/crm/deals/upcoming")
def crm_deals_upcoming(days: int = Query(default=7, ge=1, le=60)) -> dict[str, Any]:
rows = _crm_safe_all(
"""
SELECT d.id, d.title, d.value, d.stage, d.deadline, d.next_action, c.name AS client_name
FROM deals d LEFT JOIN clients c ON c.id = d.client_id
WHERE d.deadline IS NOT NULL AND d.deadline <= CURRENT_DATE + make_interval(days => %s)
AND d.stage NOT IN ('won', 'lost')
ORDER BY d.deadline ASC LIMIT 30
""",
(days,),
)
return {"days": days, "deals": [_serialize_row(r) for r in rows]}
@app.get("/emails/recent")
def emails_recent(limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]:
rows = _crm_safe_all(
"""
SELECT id, message_id, from_addr, to_addrs, subject, direction,
received_at, sent_at, is_read, client_id
FROM emails ORDER BY COALESCE(received_at, sent_at) DESC NULLS LAST LIMIT %s
""",
(limit,),
)
return {"emails": [_serialize_row(r) for r in rows]}
@app.post("/emails/send")
def emails_send(payload: EmailSend) -> dict[str, Any]:
"""Verstuur e-mail via SMTP (configureer SMTP_* env vars). Log in PostgreSQL."""
import smtplib
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid
import uuid
cfg = get_active_email_config()
smtp_host = cfg["smtp_host"]
smtp_port = cfg["smtp_port"]
smtp_user = cfg["smtp_user"]
smtp_pass = cfg["smtp_pass"]
smtp_from = cfg["smtp_from"]
if not smtp_host or not smtp_from:
raise HTTPException(
status_code=503,
detail="Geen email account geconfigureerd. Ga naar Settings → Email in het dashboard.",
)
msg = MIMEText(payload.body, "plain", "utf-8")
msg["Subject"] = payload.subject
msg["From"] = smtp_from
msg["To"] = ", ".join(payload.to)
if payload.cc:
msg["Cc"] = ", ".join(payload.cc)
msg["Date"] = formatdate(localtime=True)
message_id = make_msgid()
msg["Message-ID"] = message_id
recipients = list(payload.to) + list(payload.cc)
try:
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
server.ehlo()
if smtp_port == 587:
server.starttls()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.sendmail(smtp_from, recipients, msg.as_string())
except Exception as exc:
raise HTTPException(status_code=502, detail=f"SMTP send failed: {exc}") from exc
mid = message_id.strip("<>")
try:
row = execute_returning(
"""
INSERT INTO emails (
message_id, client_id, deal_id, direction, from_addr, to_addrs,
subject, body_text, sent_at, is_read
) VALUES (%s, %s, %s, 'out', %s, %s, %s, %s, NOW(), TRUE)
RETURNING id, message_id, subject, sent_at
""",
(mid, payload.client_id, payload.deal_id, smtp_from, payload.to, payload.subject, payload.body),
)
except Exception:
row = {"message_id": mid, "subject": payload.subject}
try:
log_agent_event(
agent_name="herman",
event_type="email_sent",
title=f"Email: {payload.subject[:120]}",
body=payload.body[:2000],
metadata={"to": payload.to, "client_id": payload.client_id},
channel="email",
)
except Exception:
pass
return {"ok": True, "message_id": mid, "email": _serialize_row(row) if isinstance(row, dict) else row}
@app.post("/llm/memory")
def llm_memory_create(payload: LlmMemoryCreate) -> dict[str, Any]:
row = execute_returning(
"""
INSERT INTO llm_memory (category, subject, content, client_id, deal_id, source, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, category, subject, content, created_at
""",
(
payload.category,
payload.subject,
payload.content,
payload.client_id,
payload.deal_id,
payload.source,
json.dumps(payload.metadata),
),
)
return {"ok": True, "memory": _serialize_row(row)}
@app.get("/llm/memory/search")
def llm_memory_search(q: str = Query(..., min_length=1), limit: int = Query(default=15, ge=1, le=50)) -> dict[str, Any]:
rows = _crm_safe_all(
"""
SELECT id, category, subject, content, client_id, deal_id, source, updated_at
FROM llm_memory
WHERE content ILIKE %s OR subject ILIKE %s OR category ILIKE %s
ORDER BY updated_at DESC LIMIT %s
""",
(f"%{q}%", f"%{q}%", f"%{q}%", limit),
)
return {"query": q, "results": [_serialize_row(r) for r in rows]}
@app.get("/settings/email/active")
def settings_email_active() -> dict[str, Any]:
cfg = get_active_email_config()
return {
"configured": bool(cfg.get("smtp_host") and cfg.get("smtp_from")),
"source": cfg.get("source"),
"label": cfg.get("label"),
"from": cfg.get("smtp_from"),
"account_id": cfg.get("account_id"),
}
class ImageGenerateBody(BaseModel):
prompt: str = Field(..., min_length=3, max_length=2000)
width: int = Field(default=1024, ge=256, le=1024)
height: int = Field(default=1024, ge=256, le=1024)
steps: int = Field(default=28, ge=5, le=40)
seed: Optional[int] = None
quality: str = Field(default="hd", pattern="^(fast|hd|ultra|custom)$")
class ImageStartBody(BaseModel):
prompt: str = Field(..., min_length=3, max_length=2000)
negative_prompt: str = Field(default="blurry, low quality, watermark, text, ugly, deformed", max_length=2000)
quality: str = Field(default="hd", pattern="^(fast|hd|ultra)$")
seed: Optional[int] = None
@app.post("/images/generate/start")
async def images_generate_start(body: ImageStartBody) -> dict[str, Any]:
try:
return await start_generation(
body.prompt.strip(),
negative=body.negative_prompt.strip(),
quality=body.quality,
seed=body.seed,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"ComfyUI: {exc}") from exc
@app.get("/images/progress/{prompt_id}")
async def images_progress(prompt_id: str) -> dict[str, Any]:
job = get_job(prompt_id)
if not job:
raise HTTPException(status_code=404, detail="Onbekende job")
return job
@app.get("/images/presets")
def images_presets() -> dict[str, Any]:
return {"presets": QUALITY_PRESETS}
@app.post("/images/generate")
async def images_generate(body: ImageGenerateBody) -> dict[str, Any]:
try:
result = await generate_image(
body.prompt.strip(),
width=body.width,
height=body.height,
steps=body.steps,
seed=body.seed,
quality=body.quality,
)
except TimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"ComfyUI: {exc}") from exc
try:
log_agent_event(
agent_name="design",
event_type="image_generated",
title="ComfyUI foto gegenereerd",
body=body.prompt[:500],
metadata={"filename": result["filename"], "prompt_id": result["prompt_id"]},
channel="cockpit",
)
except Exception:
pass
return {"ok": True, **result}
@app.get("/images/view")
async def images_view(
filename: str = Query(..., min_length=1),
subfolder: str = Query(default=""),
type: str = Query(default="output"),
):
from fastapi.responses import Response
try:
data = await fetch_image_bytes(filename, subfolder, type)
except Exception as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
media = "image/png" if filename.lower().endswith(".png") else "image/jpeg"
return Response(content=data, media_type=media)
@app.post("/brain/messages")
async def brain_store_message(body: BrainMessageIn) -> dict[str, Any]:
try:
if body.embed and (body.content_text or "").strip():
row = await brain_svc.store_message_with_embedding(
body.chat_id,
direction=body.direction,
content_text=body.content_text,
content_type=body.content_type,
role=body.role,
telegram_message_id=body.telegram_message_id,
reply_to_db_id=body.reply_to_db_id,
agent_name=body.agent_name,
content_json=body.content_json,
user_name=body.user_name,
user_role=body.user_role,
chat_type=body.chat_type,
)
else:
row = brain_svc.store_message(
body.chat_id,
direction=body.direction,
content_text=body.content_text,
content_type=body.content_type,
role=body.role,
telegram_message_id=body.telegram_message_id,
reply_to_db_id=body.reply_to_db_id,
agent_name=body.agent_name,
content_json=body.content_json,
user_name=body.user_name,
user_role=body.user_role,
chat_type=body.chat_type,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return _serialize_row(row)
@app.post("/brain/edges")
def brain_store_edge(body: BrainEdgeIn) -> dict[str, Any]:
try:
row = brain_svc.store_edge(
body.source_message_id,
body.edge_type,
target_message_id=body.target_message_id,
target_entity_type=body.target_entity_type,
target_entity_id=body.target_entity_id,
weight=body.weight,
metadata=body.metadata,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return _serialize_row(row)
@app.get("/brain/graph/{chat_id}")
def brain_graph(chat_id: int, limit: int = Query(default=50, ge=1, le=200)) -> dict[str, Any]:
return brain_svc.get_graph(chat_id, limit=limit)
@app.post("/brain/search")
async def brain_search(body: BrainSearchIn) -> dict[str, Any]:
try:
results = await brain_svc.search_memory(body.query, chat_id=body.chat_id, limit=body.limit)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"results": [_serialize_row(r) for r in results]}
# Append to tools-api main.py before end
@app.get("/brain/conversations")
def brain_list_conversations(limit: int = Query(default=50, ge=1, le=200)) -> dict[str, Any]:
rows = brain_svc.list_conversations(limit=limit)
return {"items": [_serialize_row(r) for r in rows]}
@app.get("/brain/feed")
def brain_feed(
chat_id: Optional[int] = Query(default=None),
limit: int = Query(default=80, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> dict[str, Any]:
rows = brain_svc.list_feed(chat_id=chat_id, limit=limit, offset=offset)
return {"items": [_serialize_row(r) for r in rows]}
@app.get("/brain/stats")
def brain_stats() -> dict[str, Any]:
data = brain_svc.get_dashboard_stats()
return {
"stats": _serialize_row(data.get("stats") or {}),
"recent_messages": [_serialize_row(r) for r in data.get("recent_messages") or []],
"agent_events": [_serialize_row(r) for r in data.get("agent_events") or []],
}
@app.get("/brain/graph")
def brain_global_graph(limit: int = Query(default=100, ge=1, le=300)) -> dict[str, Any]:
return brain_svc.get_global_graph(limit=limit)