Files

753 lines
26 KiB
Python
Raw Permalink Normal View History

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)