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.export_intel import router as export_intel_router 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) app.include_router(export_intel_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)