1252 lines
43 KiB
Python
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()
|
||
|
|
|
||
|
|
|