Files
foodlinkk-command-center/cockpit/app/routes/admin_api.py
T
Aissa 5d60d33db1 Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
2026-06-09 00:41:27 +00:00

1252 lines
43 KiB
Python

from __future__ import annotations
from app.routes.reco_proxy import register_recommendation_routes
import json
import os
from datetime import datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, File, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import herman as herman_service
from app.services.marketing import evaluate_agent_rules, sentiment_score
from app.services.monitor import add_site, remove_site, trigger_crawl
from app.services import ollama
admin_router = APIRouter(prefix="/api/admin", tags=["admin-api"])
register_recommendation_routes(admin_router)
ai_router = APIRouter(prefix="/api/ai", tags=["ai"])
herman_api = APIRouter(prefix="/api/herman", tags=["herman-api"])
voice_api = APIRouter(prefix="/api/voice", tags=["voice-api"])
def _serialize(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()
elif isinstance(v, (datetime,)):
out[k] = v.isoformat()
return out
def _serialize_rows(rows: list) -> list:
return [_serialize(r) for r in rows]
# --- Pydantic models ---
class ClientBody(BaseModel):
name: str
contact: Optional[str] = None
email: Optional[str] = None
stage: str = "intake"
sector: Optional[str] = None
mrr_estimate: Optional[float] = None
notes: Optional[str] = None
class DealBody(BaseModel):
client_id: Optional[int] = None
title: str
value: float = 0
stage: str = "lead"
agent_owner: str = "herman"
next_action: Optional[str] = None
deadline: Optional[str] = None
class ProductBody(BaseModel):
client_id: Optional[int] = None
name: str
status: str = "concept"
margin_pct: Optional[float] = None
moq: Optional[int] = None
shelf_target: Optional[str] = None
launch_date: Optional[str] = None
class SupplierBody(BaseModel):
name: str
country: Optional[str] = None
category: Optional[str] = None
moq: Optional[int] = None
lead_time_days: Optional[int] = None
rating: Optional[float] = None
contact: Optional[str] = None
class MentionBody(BaseModel):
platform: str
text: str
class AccountBody(BaseModel):
platform: str
username: str
is_active: bool = True
class ScheduledPostBody(BaseModel):
account_id: int
content: str
scheduled_time: str
status: str = "pending"
class PostStatusBody(BaseModel):
status: str
class AgentRuleBody(BaseModel):
name: str
condition_type: str
threshold: float
action: str = "notify"
is_active: bool = True
class SiteBody(BaseModel):
url: str
name: str
class MarginBody(BaseModel):
cost: float
sell: float
class AIGenerateBody(BaseModel):
platform: str = "Instagram"
topic: str
tone: str = "vriendelijk en professioneel"
class AIPostDraftBody(BaseModel):
account_id: Optional[int] = None
platform: str = "Instagram"
topic: str
tone: str = "vriendelijk en professioneel"
class HermanChatBody(BaseModel):
message: str
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)
quality: str = Field(default="hd")
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")
class CrawlBody(BaseModel):
site_id: Optional[int] = None
class DelegateBody(BaseModel):
agent: str
task: str
# --- Clients CRUD ---
@admin_router.get("/clients")
def list_clients():
return {"items": _serialize_rows(fetch_all(
"SELECT * FROM clients ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 500"
))}
@admin_router.post("/clients")
def create_client(body: ClientBody):
row = fetch_one(
"""INSERT INTO clients (name, contact, email, stage, sector, mrr_estimate, notes, updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,NOW()) RETURNING id""",
(body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes),
)
cid = row["id"]
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (cid,)))}
@admin_router.put("/clients/{client_id}")
def update_client(client_id: int, body: ClientBody):
execute(
"""UPDATE clients SET name=%s, contact=%s, email=%s, stage=%s, sector=%s,
mrr_estimate=%s, notes=%s, updated_at=NOW() WHERE id=%s""",
(body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes, client_id),
)
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (client_id,)))}
@admin_router.delete("/clients/{client_id}")
def delete_client(client_id: int):
execute("DELETE FROM clients WHERE id=%s", (client_id,))
return {"ok": True}
# --- Deals CRUD ---
@admin_router.get("/deals")
def list_deals():
return {"items": _serialize_rows(fetch_all(
"""SELECT d.*, c.name AS client_name FROM deals d
LEFT JOIN clients c ON c.id = d.client_id
ORDER BY d.updated_at DESC NULLS LAST LIMIT 500"""
))}
@admin_router.post("/deals")
def create_deal(body: DealBody):
row = fetch_one(
"""INSERT INTO deals (client_id, title, value, stage, agent_owner, next_action, deadline, updated_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,NOW()) RETURNING id""",
(body.client_id, body.title, body.value, body.stage, body.agent_owner, body.next_action, body.deadline or None),
)
did = row["id"]
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM deals WHERE id=%s", (did,)))}
@admin_router.put("/deals/{deal_id}")
def update_deal(deal_id: int, body: DealBody):
execute(
"""UPDATE deals SET client_id=%s, title=%s, value=%s, stage=%s, agent_owner=%s,
next_action=%s, deadline=%s, updated_at=NOW() WHERE id=%s""",
(body.client_id, body.title, body.value, body.stage, body.agent_owner, body.next_action, body.deadline or None, deal_id),
)
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM deals WHERE id=%s", (deal_id,)))}
@admin_router.delete("/deals/{deal_id}")
def delete_deal(deal_id: int):
execute("DELETE FROM deals WHERE id=%s", (deal_id,))
return {"ok": True}
# --- Products CRUD ---
@admin_router.get("/products")
def list_products():
return {"items": _serialize_rows(fetch_all(
"""SELECT p.*, c.name AS client_name FROM products p
LEFT JOIN clients c ON c.id = p.client_id ORDER BY p.created_at DESC LIMIT 500"""
))}
@admin_router.post("/products")
def create_product(body: ProductBody):
row = fetch_one(
"""INSERT INTO products (client_id, name, status, margin_pct, moq, shelf_target, launch_date)
VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
(body.client_id, body.name, body.status, body.margin_pct, body.moq, body.shelf_target, body.launch_date or None),
)
pid = row["id"]
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM products WHERE id=%s", (pid,)))}
@admin_router.put("/products/{product_id}")
def update_product(product_id: int, body: ProductBody):
execute(
"""UPDATE products SET client_id=%s, name=%s, status=%s, margin_pct=%s, moq=%s,
shelf_target=%s, launch_date=%s WHERE id=%s""",
(body.client_id, body.name, body.status, body.margin_pct, body.moq, body.shelf_target, body.launch_date or None, product_id),
)
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM products WHERE id=%s", (product_id,)))}
@admin_router.delete("/products/{product_id}")
def delete_product(product_id: int):
execute("DELETE FROM products WHERE id=%s", (product_id,))
return {"ok": True}
@admin_router.post("/products/margin-calc")
def margin_calc(body: MarginBody):
if body.sell <= 0:
raise HTTPException(400, "sell must be > 0")
margin = ((body.sell - body.cost) / body.sell) * 100
return {"cost": body.cost, "sell": body.sell, "margin_pct": round(margin, 2), "profit": round(body.sell - body.cost, 2)}
# --- Suppliers CRUD ---
@admin_router.get("/suppliers")
def list_suppliers():
return {"items": _serialize_rows(fetch_all("SELECT * FROM suppliers ORDER BY name LIMIT 500"))}
@admin_router.post("/suppliers")
def create_supplier(body: SupplierBody):
row = fetch_one(
"""INSERT INTO suppliers (name, country, category, moq, lead_time_days, rating, contact)
VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
(body.name, body.country, body.category, body.moq, body.lead_time_days, body.rating, body.contact),
)
sid = row["id"]
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM suppliers WHERE id=%s", (sid,)))}
@admin_router.put("/suppliers/{supplier_id}")
def update_supplier(supplier_id: int, body: SupplierBody):
execute(
"""UPDATE suppliers SET name=%s, country=%s, category=%s, moq=%s,
lead_time_days=%s, rating=%s, contact=%s WHERE id=%s""",
(body.name, body.country, body.category, body.moq, body.lead_time_days, body.rating, body.contact, supplier_id),
)
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM suppliers WHERE id=%s", (supplier_id,)))}
@admin_router.delete("/suppliers/{supplier_id}")
def delete_supplier(supplier_id: int):
execute("DELETE FROM suppliers WHERE id=%s", (supplier_id,))
return {"ok": True}
# --- Marketing ---
@admin_router.get("/mentions")
def list_mentions(platform: str = "all"):
if platform == "all":
rows = fetch_all("SELECT * FROM social_mentions ORDER BY created_at DESC LIMIT 200")
else:
rows = fetch_all(
"SELECT * FROM social_mentions WHERE platform=%s ORDER BY created_at DESC LIMIT 200",
(platform,),
)
evaluate_agent_rules()
return {"items": _serialize_rows(rows)}
@admin_router.post("/mentions")
def create_mention(body: MentionBody):
text = body.text.strip()
if not text:
raise HTTPException(400, "text required")
score = sentiment_score(text)
row = fetch_one(
"INSERT INTO social_mentions (platform, text, sentiment_score) VALUES (%s,%s,%s) RETURNING id",
(body.platform, text, score),
)
evaluate_agent_rules(row["id"])
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM social_mentions WHERE id=%s", (row["id"],)))}
@admin_router.delete("/mentions/{mention_id}")
def delete_mention(mention_id: int):
execute("DELETE FROM social_mentions WHERE id=%s", (mention_id,))
return {"ok": True}
@admin_router.get("/accounts")
def list_accounts():
return {"items": _serialize_rows(fetch_all("SELECT * FROM social_accounts ORDER BY platform, username"))}
@admin_router.post("/accounts")
def create_account(body: AccountBody):
row = fetch_one(
"INSERT INTO social_accounts (platform, username, is_active) VALUES (%s,%s,%s) RETURNING id",
(body.platform, body.username.strip(), body.is_active),
)
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM social_accounts WHERE id=%s", (row["id"],)))}
@admin_router.delete("/accounts/{account_id}")
def delete_account(account_id: int):
execute("DELETE FROM social_accounts WHERE id=%s", (account_id,))
return {"ok": True}
@admin_router.get("/scheduled-posts")
def list_scheduled_posts():
rows = fetch_all(
"""SELECT sp.*, sa.platform, sa.username FROM scheduled_posts sp
JOIN social_accounts sa ON sp.account_id = sa.id
ORDER BY sp.scheduled_time ASC"""
)
return {"items": _serialize_rows(rows)}
@admin_router.post("/scheduled-posts")
def create_scheduled_post(body: ScheduledPostBody):
row = fetch_one(
"INSERT INTO scheduled_posts (account_id, content, scheduled_time, status) VALUES (%s,%s,%s,%s) RETURNING id",
(body.account_id, body.content.strip(), body.scheduled_time, body.status),
)
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM scheduled_posts WHERE id=%s", (row["id"],)))}
@admin_router.patch("/scheduled-posts/{post_id}/status")
def update_scheduled_status(post_id: int, body: PostStatusBody):
allowed = {"pending", "approved", "posted", "rejected", "published", "cancelled"}
if body.status not in allowed:
raise HTTPException(400, "invalid status")
if body.status in ("posted", "published"):
execute(
"UPDATE scheduled_posts SET status=%s, posted_at=NOW() WHERE id=%s",
(body.status, post_id),
)
else:
execute("UPDATE scheduled_posts SET status=%s WHERE id=%s", (body.status, post_id))
return {"ok": True}
@admin_router.put("/scheduled-posts/{post_id}")
def update_scheduled_post(post_id: int, body: ScheduledPostBody):
execute(
"UPDATE scheduled_posts SET account_id=%s, content=%s, scheduled_time=%s, status=%s WHERE id=%s",
(body.account_id, body.content, body.scheduled_time, body.status, post_id),
)
return {"ok": True}
@admin_router.delete("/scheduled-posts/{post_id}")
def delete_scheduled_post(post_id: int):
execute("DELETE FROM scheduled_posts WHERE id=%s", (post_id,))
return {"ok": True}
@admin_router.get("/agent-rules")
def list_agent_rules():
evaluate_agent_rules()
return {"items": _serialize_rows(fetch_all("SELECT * FROM agent_rules ORDER BY id"))}
@admin_router.post("/agent-rules")
def create_agent_rule(body: AgentRuleBody):
row = fetch_one(
"""INSERT INTO agent_rules (name, condition_type, threshold, action, is_active)
VALUES (%s,%s,%s,%s,%s) RETURNING id""",
(body.name, body.condition_type, body.threshold, body.action, body.is_active),
)
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM agent_rules WHERE id=%s", (row["id"],)))}
@admin_router.patch("/agent-rules/{rule_id}/toggle")
def toggle_agent_rule(rule_id: int):
execute("UPDATE agent_rules SET is_active = NOT is_active WHERE id=%s", (rule_id,))
return {"ok": True}
@admin_router.delete("/agent-rules/{rule_id}")
def delete_agent_rule(rule_id: int):
execute("DELETE FROM agent_rules WHERE id=%s", (rule_id,))
return {"ok": True}
@admin_router.get("/agent-logs")
def list_agent_logs(limit: int = 100):
limit = max(1, min(limit, 500))
rows = fetch_all(
"""SELECT al.*, ar.name AS rule_name FROM agent_logs al
LEFT JOIN agent_rules ar ON al.rule_id = ar.id
ORDER BY al.created_at DESC LIMIT %s""",
(limit,),
)
return {"items": _serialize_rows(rows)}
@admin_router.get("/analytics/social")
def social_analytics():
account_stats = fetch_all(
"""SELECT sa.platform, sa.username, SUM(sa2.impressions) AS impressions,
SUM(sa2.engagements) AS engagements, SUM(sa2.reach) AS reach
FROM social_analytics sa2
JOIN social_accounts sa ON sa2.account_id = sa.id
WHERE sa2.date > CURRENT_DATE - interval '7 days'
GROUP BY sa.platform, sa.username ORDER BY impressions DESC"""
)
daily = fetch_all(
"""SELECT date, SUM(impressions) AS impressions, SUM(engagements) AS engagements
FROM social_analytics WHERE date > CURRENT_DATE - interval '7 days'
GROUP BY date ORDER BY date"""
)
mention_stats = fetch_one(
"SELECT COUNT(*) AS cnt, COALESCE(AVG(sentiment_score),0) AS avg_sentiment FROM social_mentions"
)
return {
"account_stats": _serialize_rows(account_stats),
"daily_stats": _serialize_rows(daily),
"mentions": _serialize(mention_stats),
}
# --- Monitor ---
@admin_router.get("/monitor/sites")
def list_monitor_sites():
rows = fetch_all(
"""SELECT id, url, name, last_hash, last_crawled, is_active FROM monitored_sites
ORDER BY last_crawled DESC NULLS LAST"""
)
active = fetch_one("SELECT COUNT(*) AS c FROM monitored_sites WHERE is_active = TRUE")
changes_24h = fetch_one(
"SELECT COUNT(*) AS c FROM page_changes WHERE changed_at > NOW() - interval '24 hours'"
)
return {
"items": _serialize_rows(rows),
"stats": {
"active_sites": int(active["c"]) if active else 0,
"changes_24h": int(changes_24h["c"]) if changes_24h else 0,
},
}
@admin_router.post("/monitor/sites")
def create_monitor_site(body: SiteBody):
try:
item = add_site(body.url, body.name)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
for k, v in list(item.items()):
if hasattr(v, "isoformat"):
item[k] = v.isoformat()
return {"ok": True, "item": item}
@admin_router.delete("/monitor/sites/{site_id}")
def delete_monitor_site(site_id: int, hard: bool = False):
remove_site(site_id, soft=not hard)
return {"ok": True}
@admin_router.patch("/monitor/sites/{site_id}/toggle")
def toggle_monitor_site(site_id: int):
execute("UPDATE monitored_sites SET is_active = NOT is_active WHERE id=%s", (site_id,))
return {"ok": True}
@admin_router.post("/monitor/trigger-crawl")
def monitor_trigger_crawl(body: CrawlBody = CrawlBody()):
return trigger_crawl(body.site_id)
@admin_router.get("/monitor/page-changes")
def monitor_page_changes(limit: int = 50):
rows = fetch_all(
"""SELECT pc.*, ms.url, ms.name FROM page_changes pc
JOIN monitored_sites ms ON pc.site_id = ms.id
ORDER BY pc.changed_at DESC LIMIT %s""",
(max(1, min(limit, 200)),),
)
return {"items": _serialize_rows(rows)}
@admin_router.get("/monitor/crawl-logs")
def monitor_crawl_logs(limit: int = 50):
rows = fetch_all(
"""SELECT cl.*, ms.url, ms.name FROM crawl_logs cl
LEFT JOIN monitored_sites ms ON cl.site_id = ms.id
ORDER BY cl.logged_at DESC LIMIT %s""",
(max(1, min(limit, 200)),),
)
return {"items": _serialize_rows(rows)}
# --- Agent events (approvals) ---
@admin_router.patch("/agent-events/{event_id}/status")
def patch_agent_event_status(event_id: int, body: PostStatusBody):
if body.status not in ("approved", "rejected", "needs_approval", "completed"):
raise HTTPException(400, "invalid status")
execute("UPDATE agent_events SET status=%s WHERE id=%s", (body.status, event_id))
return {"ok": True}
@admin_router.post("/delegate")
async def delegate_task(body: DelegateBody):
msg = f"[{body.agent}] {body.task}"
result = await herman_service.chat(msg)
return {"ok": True, "result": result}
# --- AI ---
async def _generate_social(prompt: str) -> str:
try:
return await ollama.generate(prompt)
except Exception as exc:
raise HTTPException(502, f"Ollama error: {exc}") from exc
@ai_router.post("/generate-content")
async def ai_generate_content(body: AIGenerateBody):
topic = body.topic.strip()
if not topic:
raise HTTPException(400, "topic required")
prompt = (
f"Schrijf een korte social media post voor {body.platform} over: {topic}. "
f"Toon: {body.tone}. Maximaal 280 tekens. Alleen de posttekst, geen uitleg."
)
content = await _generate_social(prompt)
return {"ok": True, "content": content}
@ai_router.post("/generate-image")
async def ai_generate_image(body: ImageGenerateBody):
import httpx
try:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate",
json=body.model_dump(),
)
r.raise_for_status()
data = r.json()
fn = data.get("filename", "")
sub = data.get("subfolder", "")
typ = data.get("type", "output")
data["proxy_url"] = f"/api/ai/generated-image?filename={fn}&subfolder={sub}&type={typ}"
return data
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:300] if exc.response else str(exc)
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@ai_router.get("/generated-image")
async def ai_generated_image(
filename: str,
subfolder: str = "",
type: str = "output",
):
import httpx
from fastapi.responses import Response
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(
f"{settings.TOOLS_API_URL.rstrip('/')}/images/view",
params={"filename": filename, "subfolder": subfolder, "type": type},
)
r.raise_for_status()
media = r.headers.get("content-type", "image/png")
return Response(content=r.content, media_type=media)
except Exception as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@ai_router.post("/generate-image/start")
async def ai_generate_image_start(body: ImageStartBody):
import httpx
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate/start",
json=body.model_dump(),
)
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:300] if exc.response else str(exc)
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@ai_router.get("/generate-image/progress/{prompt_id}")
async def ai_generate_image_progress(prompt_id: str):
import httpx
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/images/progress/{prompt_id}")
r.raise_for_status()
data = r.json()
result = data.get("result") or {}
if data.get("status") == "done" and result.get("filename"):
fn = result["filename"]
sub = result.get("subfolder", "")
typ = result.get("type", "output")
data["proxy_url"] = f"/api/ai/generated-image?filename={fn}&subfolder={sub}&type={typ}"
return data
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=exc.response.text[:300]) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@ai_router.post("/generate-post-draft")
async def ai_generate_post_draft(body: AIPostDraftBody):
platform = body.platform
if body.account_id:
acc = fetch_one("SELECT platform, username FROM social_accounts WHERE id=%s", (body.account_id,))
if acc:
platform = f"{acc['platform']} (@{acc['username']})"
prompt = (
f"Schrijf een geplande social post voor {platform} over: {body.topic.strip()}. "
f"Toon: {body.tone}. Geef titel + body."
)
content = await _generate_social(prompt)
return {"ok": True, "content": content, "platform": platform}
@herman_api.post("/chat")
async def herman_chat_api(body: HermanChatBody):
if not body.message.strip():
raise HTTPException(400, "message required")
result = await herman_service.chat(body.message.strip())
return {"ok": True, **result}
@voice_api.post("/transcribe")
async def voice_transcribe(file: UploadFile = File(...)):
url = "http://10.4.7.19:8877/v1/audio/transcriptions"
data = await file.read()
if not data:
raise HTTPException(400, "empty file")
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
url,
files={"file": (file.filename or "audio.webm", data, file.content_type or "audio/webm")},
data={"model": "Systran/faster-whisper-base", "language": "nl"},
)
resp.raise_for_status()
data = resp.json()
return {"text": data.get("text", ""), "raw": data}
except httpx.HTTPError as exc:
raise HTTPException(502, f"transcribe proxy failed: {exc}") from exc
# --- Document word analytics & sentiment ---
@admin_router.get("/documents/summary")
def documents_summary():
try:
row = fetch_one(
"""
SELECT COUNT(*) AS documents,
COALESCE(SUM(word_count), 0) AS total_words,
COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment
FROM document_analytics
"""
)
unique = fetch_one(
"SELECT COUNT(DISTINCT lemma) AS unique_words FROM document_word_counts WHERE NOT is_stopword"
)
sentiment = fetch_all(
"""
SELECT sentiment_label, COUNT(*) AS cnt
FROM document_analytics
GROUP BY sentiment_label
"""
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
return {
"ok": True,
"summary": {
"documents": int(row["documents"] or 0) if row else 0,
"total_words": int(row["total_words"] or 0) if row else 0,
"avg_sentiment": round(float(row["avg_sentiment"] or 0), 4) if row else 0,
"unique_words": int(unique["unique_words"] or 0) if unique else 0,
"sentiment": {r["sentiment_label"]: int(r["cnt"]) for r in sentiment},
},
}
@admin_router.get("/documents/words")
def documents_words(
q: str | None = None,
limit: int = 40,
stopwords: bool = False,
):
limit = max(1, min(limit, 200))
clauses = []
params: list = []
if not stopwords:
clauses.append("NOT is_stopword")
if q and q.strip():
clauses.append("lemma ILIKE %s")
params.append(f"%{q.strip()}%")
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(limit)
try:
rows = fetch_all(
f"""
SELECT lemma, MAX(token) AS token, SUM(count) AS total_count,
COUNT(DISTINCT storage_path) AS document_count
FROM document_word_counts
{where}
GROUP BY lemma
ORDER BY total_count DESC
LIMIT %s
""",
tuple(params),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
return {"ok": True, "items": [_serialize(r) for r in rows]}
@admin_router.get("/documents/list")
def documents_list(limit: int = 50):
limit = max(1, min(limit, 200))
try:
rows = fetch_all(
"""
SELECT filename, storage_path, doc_type, language, word_count, unique_lemmas,
sentiment_label, sentiment_compound, sentiment_positive,
sentiment_negative, sentiment_neutral, sentiment_subjectivity,
extraction_method, analyzed_at
FROM document_analytics
ORDER BY analyzed_at DESC
LIMIT %s
""",
(limit,),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
return {"ok": True, "items": [_serialize(r) for r in rows]}
@admin_router.get("/documents/{storage_path:path}/words")
def document_words(storage_path: str, limit: int = 100, stopwords: bool = False):
limit = max(1, min(limit, 500))
clauses = ["storage_path = %s"]
params: list = [storage_path]
if not stopwords:
clauses.append("NOT is_stopword")
try:
rows = fetch_all(
f"""
SELECT lemma, token, count, pos_tag, is_stopword, language
FROM document_word_counts
WHERE {' AND '.join(clauses)}
ORDER BY count DESC
LIMIT %s
""",
tuple(params + [limit]),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
return {"ok": True, "items": [_serialize(r) for r in rows]}
BROWSER_AGENT_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790")
class BrowserBrowseBody(BaseModel):
url: str
task: Optional[str] = None
site_id: Optional[int] = None
wait_seconds: float = 4.0
@admin_router.post("/browser/browse")
async def browser_browse(body: BrowserBrowseBody) -> dict[str, Any]:
import httpx
try:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse", json=body.model_dump())
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:500]
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@admin_router.get("/browser/sessions")
async def browser_sessions(limit: int = 20) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions", params={"limit": limit})
r.raise_for_status()
return r.json()
@admin_router.get("/browser/sessions/{session_id}")
async def browser_session_detail(session_id: int, include_screenshot: bool = True) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(
f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}",
params={"include_screenshot": include_screenshot},
)
r.raise_for_status()
return r.json()
@admin_router.get("/browser/live/screenshot.jpg")
async def browser_live_screenshot():
import httpx
from fastapi.responses import Response
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/live/screenshot.jpg")
if r.status_code == 404:
return Response(status_code=404)
return Response(content=r.content, media_type="image/jpeg")
except Exception:
return Response(status_code=404)
@admin_router.get("/browser/sessions/{session_id}/screenshot.jpg")
async def browser_session_screenshot(session_id: int):
import httpx
from fastapi.responses import Response
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}/screenshot.jpg")
if r.status_code != 200:
raise HTTPException(status_code=r.status_code, detail="Screenshot not found")
return Response(content=r.content, media_type="image/jpeg")
class BrowserInstructBody(BaseModel):
url: str
instruction: str
wait_seconds: float = 5.0
@admin_router.post("/browser/instruct")
async def browser_instruct(body: BrowserInstructBody) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=180.0) as client:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/instruct", json=body.model_dump())
r.raise_for_status()
return r.json()
@admin_router.get("/browser/sessions/{session_id}/ocr")
async def browser_session_ocr(session_id: int) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}/ocr")
r.raise_for_status()
return r.json()
class BrowserVncBody(BaseModel):
url: str
instruction: Optional[str] = None
wait_seconds: float = 4.0
class BrowserExtractFullBody(BaseModel):
url: str
instruction: Optional[str] = None
wait_seconds: float = 4.0
scroll_pages: int = 6
site_id: Optional[int] = None
also_vnc: bool = True
class PhotoUploadBody(BaseModel):
image_b64: str
source: str = "cockpit"
filename: Optional[str] = None
storage_path: Optional[str] = None
session_id: Optional[int] = None
@admin_router.post("/browser/vnc-navigate")
async def browser_vnc_navigate(body: BrowserVncBody) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/navigate", json=body.model_dump())
if r.status_code >= 400:
raise HTTPException(status_code=r.status_code, detail=r.text[:500])
return r.json()
@admin_router.post("/browser/extract-full")
async def browser_extract_full(body: BrowserExtractFullBody) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/extract-full", json=body.model_dump())
if r.status_code >= 400:
raise HTTPException(status_code=r.status_code, detail=r.text[:500])
return r.json()
@admin_router.get("/photos")
async def photos_list(limit: int = 30) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos", params={"limit": limit})
r.raise_for_status()
return r.json()
@admin_router.get("/photos/{photo_id}")
async def photos_detail(photo_id: int) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}")
r.raise_for_status()
return r.json()
@admin_router.get("/photos/{photo_id}/image.jpg")
async def photos_image(photo_id: int):
import httpx
from fastapi.responses import Response
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}/image.jpg")
if r.status_code != 200:
raise HTTPException(status_code=r.status_code)
return Response(content=r.content, media_type="image/jpeg")
@admin_router.get("/photos/{photo_id}/detections")
async def photos_detections(photo_id: int) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}/detections")
r.raise_for_status()
return r.json()
@admin_router.post("/photos/analyze")
async def photos_analyze(body: PhotoUploadBody) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/analyze", json=body.model_dump())
if r.status_code >= 400:
raise HTTPException(status_code=r.status_code, detail=r.text[:500])
return r.json()
@admin_router.get("/documents/share-files")
async def documents_share_files(
limit: int = 200,
ext: Optional[str] = None,
) -> dict[str, Any]:
"""Alle bestanden op NAS share — incl. pptx, pdf, etc."""
import httpx
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
limit = max(1, min(limit, 500))
params: dict[str, Any] = {"limit": limit}
if ext:
params["ext"] = ext
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{doc_url.rstrip('/')}/nas/files", params=params)
r.raise_for_status()
data = r.json()
return {"ok": True, **data}
except Exception as exc:
return {"ok": False, "items": [], "error": str(exc)}
@admin_router.post("/documents/trigger-scan")
async def documents_trigger_scan(force: bool = False) -> dict[str, Any]:
"""Start doc-ingest scan zodat nieuwe share-bestanden worden herkend."""
import httpx
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(
f"{doc_url.rstrip('/')}/ingest/scan",
params={"force": "true" if force else "false"},
)
r.raise_for_status()
return {"ok": True, **r.json()}
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@admin_router.get("/documents/nas-images")
async def documents_nas_images(limit: int = 40) -> dict[str, Any]:
import httpx
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
exts = (".jpg", ".jpeg", ".png", ".webp", ".gif")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{doc_url.rstrip('/')}/nas/list", params={"limit": 500})
r.raise_for_status()
data = r.json()
files = data.get("files") or []
images = [f for f in files if (f.get("path") or "").lower().endswith(exts)]
return {"ok": True, "items": images[:limit], "total": len(images)}
except Exception as exc:
return {"ok": False, "items": [], "error": str(exc)}
@admin_router.get("/browser/vnc/screenshot.jpg")
async def browser_vnc_screenshot():
import httpx
from fastapi.responses import Response
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/screenshot.jpg")
if r.status_code != 200:
return Response(status_code=404)
return Response(content=r.content, media_type="image/jpeg")
except Exception:
return Response(status_code=404)
# --- Hermes / Telegram second brain proxies ---
class HermesSearchBody(BaseModel):
query: str = Field(..., min_length=2)
chat_id: Optional[int] = None
limit: int = Field(default=15, ge=1, le=30)
@admin_router.get("/hermes/stats")
async def hermes_stats_proxy() -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/conversations")
async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit})
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/feed")
async def hermes_feed_proxy(
chat_id: Optional[int] = None,
limit: int = 80,
offset: int = 0,
) -> dict[str, Any]:
import httpx
params = {"limit": limit, "offset": offset}
if chat_id is not None:
params["chat_id"] = chat_id
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params)
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/graph")
async def hermes_graph_global(limit: int = 100) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/graph", params={"limit": limit})
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/graph/{chat_id}")
async def hermes_graph_chat(chat_id: int, limit: int = 80) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/graph/{chat_id}", params={"limit": limit})
r.raise_for_status()
return r.json()
@admin_router.post("/hermes/search")
async def hermes_search_proxy(body: HermesSearchBody) -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(
f"{settings.TOOLS_API_URL.rstrip('/')}/brain/search",
json=body.model_dump(),
)
r.raise_for_status()
return r.json()
# --- Hermes PA live + users + control (added by deploy) ---
HERMES_CONTROL_URL = os.getenv("HERMES_CONTROL_URL", "http://10.4.7.19:8799").rstrip("/")
@admin_router.get("/hermes/pa/live")
async def hermes_pa_live_proxy() -> dict[str, Any]:
import httpx
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/pa/live")
r.raise_for_status()
live = r.json()
try:
async with httpx.AsyncClient(timeout=8.0) as client:
ctrl = await client.get(f"{HERMES_CONTROL_URL}/status")
if ctrl.status_code == 200:
live["hermes"] = ctrl.json()
except Exception:
live["hermes"] = {"online": False}
return live
@admin_router.get("/hermes/pa/live/{label}/screenshot.jpg")
async def hermes_pa_slot_screenshot(label: str):
import httpx
from fastapi.responses import Response
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(
f"{BROWSER_AGENT_URL.rstrip('/')}/pa/live/{label}/screenshot.jpg"
)
if r.status_code != 200:
raise HTTPException(status_code=r.status_code, detail="No screenshot")
return Response(content=r.content, media_type="image/jpeg")
@admin_router.get("/hermes/users")
async def hermes_users_proxy() -> dict[str, Any]:
import httpx
users: list[dict[str, Any]] = []
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.get(f"{HERMES_CONTROL_URL}/users")
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=15.0) as client:
conv = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": 20})
items = conv.json().get("items") or [] if conv.status_code == 200 else []
default = [
{"chat_id": 8859782446, "name": "Aïssa", "role": "CEO", "allowed": True, "pa_mode": False, "online": True},
{"chat_id": 789036463, "name": "Mo", "role": "CTO", "allowed": True, "pa_mode": False, "online": False},
]
by_id = {u["chat_id"]: u for u in default}
for c in items:
cid = c.get("chat_id")
if cid in by_id:
by_id[cid]["message_count"] = c.get("message_count")
by_id[cid]["last_message_at"] = c.get("last_message_at")
by_id[cid]["online"] = True
else:
by_id[cid] = {
"chat_id": cid,
"name": c.get("user_name") or str(cid),
"role": c.get("user_role") or "user",
"allowed": True,
"pa_mode": False,
"online": True,
"message_count": c.get("message_count"),
"last_message_at": c.get("last_message_at"),
}
return {"users": list(by_id.values())}
@admin_router.post("/hermes/actions/{action}")
async def hermes_action_proxy(action: str) -> dict[str, Any]:
import httpx
allowed = {"evening-briefing", "morning-briefing", "resume-jobs", "test-briefing"}
if action not in allowed:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
async with httpx.AsyncClient(timeout=180.0) as client:
r = await client.post(f"{HERMES_CONTROL_URL}/actions/{action}")
if r.status_code >= 400:
raise HTTPException(status_code=r.status_code, detail=r.text[:300])
return r.json()