Files

2393 lines
85 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 import ollama
from app.services import llm_router
from app.services.auto_ingest import last_auto_sync, run_full_auto_sync
from app.services.client_360 import get_client_360
from app.services import nas_folders
from app.services.monitor import add_site, build_parse_intelligence, get_parse_page, list_parse_results, remove_site, trigger_crawl
from app.services.agent_events_log import log_agent_event
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
channel: str = "cockpit"
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 c.*,
(SELECT COUNT(*) FROM client_supermarket_links l WHERE l.client_id = c.id)
+ (SELECT COUNT(*) FROM supermarkets s WHERE s.client_id = c.id
AND NOT EXISTS (
SELECT 1 FROM client_supermarket_links l2
WHERE l2.supermarket_id = s.id AND l2.client_id = c.id
)) AS store_count
FROM clients c
ORDER BY c.updated_at DESC NULLS LAST, c.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"]
try:
nas_folders.ensure_client_folder(cid, body.name)
except Exception:
pass
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}
@admin_router.get("/clients/stats")
def clients_stats():
stats: dict[str, Any] = {}
try:
rows = fetch_all("SELECT stage, COUNT(*) AS n FROM clients GROUP BY stage")
stats["by_stage"] = {r["stage"]: int(r["n"]) for r in rows}
stats["total"] = sum(stats["by_stage"].values())
stats["active"] = stats["by_stage"].get("active", 0)
row = fetch_one("SELECT COALESCE(SUM(mrr_estimate),0) AS s FROM clients WHERE stage NOT IN ('churned')")
stats["mrr_total"] = float(row["s"] or 0) if row else 0
row = fetch_one(
"""SELECT COALESCE(SUM(d.value),0) AS s FROM deals d
JOIN clients c ON c.id = d.client_id
WHERE d.stage NOT IN ('won','lost')"""
)
stats["pipeline_value"] = float(row["s"] or 0) if row else 0
row = fetch_one("SELECT COUNT(*) AS n FROM client_supermarket_links")
stats["store_links"] = int(row["n"] or 0) if row else 0
row = fetch_one("SELECT COUNT(*) AS n FROM deals")
stats["deals"] = int(row["n"] or 0) if row else 0
except Exception:
stats = {"total": 0, "active": 0, "mrr_total": 0, "pipeline_value": 0, "store_links": 0, "deals": 0, "by_stage": {}}
return {"ok": True, "stats": stats}
@admin_router.get("/clients/{client_id}/detail")
def client_detail(client_id: int):
client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
if not client:
raise HTTPException(404, "Client not found")
deals = fetch_all(
"""SELECT id, title, value, stage, next_action, deadline, updated_at
FROM deals WHERE client_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 20""",
(client_id,),
)
stores = fetch_all(
"""
SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
l.relationship_type, l.notes AS link_notes, l.deal_id, l.created_at AS linked_at
FROM client_supermarket_links l
JOIN supermarkets s ON s.id = l.supermarket_id
WHERE l.client_id = %s
ORDER BY s.chain, s.city, s.name
""",
(client_id,),
)
direct = fetch_all(
"""
SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
'direct' AS relationship_type, NULL AS link_notes, s.deal_id, s.last_updated AS linked_at
FROM supermarkets s
WHERE s.client_id = %s
AND NOT EXISTS (
SELECT 1 FROM client_supermarket_links l
WHERE l.supermarket_id = s.id AND l.client_id = s.client_id
)
ORDER BY s.chain, s.city, s.name
""",
(client_id,),
)
merged: dict[int, dict] = {}
for row in list(stores) + list(direct):
merged[int(row["id"])] = row
return {
"ok": True,
"client": _serialize(client),
"deals": _serialize_rows(deals),
"stores": _serialize_rows(list(merged.values())),
}
@admin_router.get("/clients/{client_id}/360")
def client_360_view(client_id: int) -> dict[str, Any]:
data = get_client_360(client_id)
if not data.get("ok"):
raise HTTPException(404, data.get("error", "not found"))
return data
@admin_router.get("/documents/links")
def document_links_list(
client_id: int | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
clauses, params = [], []
if client_id:
clauses.append("dl.client_id = %s")
params.append(client_id)
if project_id:
clauses.append("dl.project_id = %s")
params.append(project_id)
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = fetch_all(
f"""
SELECT dl.*, c.name AS client_name, p.name AS project_name
FROM document_links dl
LEFT JOIN clients c ON c.id = dl.client_id
LEFT JOIN cockpit_projects p ON p.id = dl.project_id
{where}
ORDER BY dl.created_at DESC
LIMIT 500
""",
tuple(params),
)
return {"ok": True, "items": _serialize_rows(rows)}
@admin_router.post("/documents/links")
def document_links_create(body: DocumentLinkBody) -> dict[str, Any]:
if not body.client_id and not body.project_id:
raise HTTPException(400, "client_id of project_id vereist")
row = fetch_one(
"""
INSERT INTO document_links (storage_path, is_folder, client_id, project_id, notes)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
""",
(body.storage_path.strip(), body.is_folder, body.client_id, body.project_id, body.notes or ""),
)
if body.project_id and row:
try:
from app.services import projects as projects_svc
projects_svc.add_asset(
body.project_id,
"nas_file",
body.storage_path.split("/")[-1],
file_path=body.storage_path.strip(),
source_agent="documents",
)
except Exception:
pass
return {"ok": True, "item": _serialize(row)}
@admin_router.delete("/documents/links/{link_id}")
def document_links_delete(link_id: int) -> dict[str, Any]:
execute("DELETE FROM document_links WHERE id = %s", (link_id,))
return {"ok": True}
@admin_router.post("/brain/auto-sync")
async def brain_auto_sync(force: bool = False) -> dict[str, Any]:
return await run_full_auto_sync(force_scan=force)
@admin_router.get("/brain/auto-sync/status")
def brain_auto_sync_status() -> dict[str, Any]:
last = last_auto_sync()
return {"ok": True, "last": last}
# --- 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/stats")
def products_stats():
stats: dict[str, Any] = {}
try:
row = fetch_one("SELECT COUNT(*) AS n FROM products")
stats["total"] = int(row["n"] or 0) if row else 0
rows = fetch_all("SELECT status, COUNT(*) AS n FROM products GROUP BY status")
stats["by_status"] = {r["status"]: int(r["n"]) for r in rows}
stats["active"] = stats["by_status"].get("active", 0)
row = fetch_one("SELECT AVG(margin_pct) AS a FROM products WHERE margin_pct IS NOT NULL")
stats["avg_margin"] = round(float(row["a"] or 0), 1) if row else 0
row = fetch_one("SELECT COUNT(*) AS n FROM products WHERE client_id IS NOT NULL")
stats["with_client"] = int(row["n"] or 0) if row else 0
except Exception:
stats = {"total": 0, "active": 0, "avg_margin": 0, "with_client": 0, "by_status": {}}
return {"ok": True, "stats": stats}
@admin_router.get("/products/{product_id}/detail")
def product_detail(product_id: int):
product = fetch_one(
"""SELECT p.*, c.name AS client_name, c.email AS client_email, c.stage AS client_stage
FROM products p LEFT JOIN clients c ON c.id = p.client_id WHERE p.id = %s""",
(product_id,),
)
if not product:
raise HTTPException(404, "Product not found")
return {"ok": True, "product": _serialize(product)}
@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/stats")
def suppliers_stats():
stats: dict[str, Any] = {}
try:
row = fetch_one("SELECT COUNT(*) AS n FROM suppliers")
stats["total"] = int(row["n"] or 0) if row else 0
row = fetch_one("SELECT AVG(rating) AS a FROM suppliers WHERE rating IS NOT NULL")
stats["avg_rating"] = round(float(row["a"] or 0), 1) if row else 0
row = fetch_one("SELECT COUNT(DISTINCT country) AS n FROM suppliers WHERE country IS NOT NULL AND country <> ''")
stats["countries"] = int(row["n"] or 0) if row else 0
row = fetch_one("SELECT AVG(lead_time_days) AS a FROM suppliers WHERE lead_time_days IS NOT NULL")
stats["avg_lead"] = round(float(row["a"] or 0), 0) if row else 0
rows = fetch_all(
"SELECT COALESCE(category, 'Overig') AS category, COUNT(*) AS n FROM suppliers GROUP BY category ORDER BY n DESC LIMIT 8"
)
stats["by_category"] = {r["category"]: int(r["n"]) for r in rows}
except Exception:
stats = {"total": 0, "avg_rating": 0, "countries": 0, "avg_lead": 0, "by_category": {}}
return {"ok": True, "stats": stats}
@admin_router.get("/suppliers/{supplier_id}/detail")
def supplier_detail(supplier_id: int):
supplier = fetch_one("SELECT * FROM suppliers WHERE id = %s", (supplier_id,))
if not supplier:
raise HTTPException(404, "Supplier not found")
return {"ok": True, "supplier": _serialize(supplier)}
@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.post("/scheduled-posts/publish-due")
def publish_due_scheduled_posts():
"""Mark due scheduled posts as published (marketing cron)."""
rows = fetch_all(
"""
SELECT id, content FROM scheduled_posts
WHERE status = 'scheduled' AND scheduled_time <= NOW()
ORDER BY scheduled_time ASC
LIMIT 50
"""
)
published = []
for row in rows:
pid = int(row["id"])
execute(
"UPDATE scheduled_posts SET status = 'published', posted_at = NOW() WHERE id = %s",
(pid,),
)
try:
execute(
"""
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
"marketing",
"marketing",
"post_published",
f"Scheduled post #{pid} gepubliceerd",
(row.get("content") or "")[:500],
"completed",
"cron",
json.dumps({"post_id": pid}),
),
)
except Exception:
pass
published.append(pid)
return {"ok": True, "published": published, "count": len(published)}
@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()
try:
log_agent_event(
"browser",
"monitor_site_added",
f"Site toegevoegd: {(body.name or body.url)[:80]}",
body.url,
channel="monitor",
metadata={"url": body.url, "name": body.name, "site_id": item.get("id")},
)
except Exception:
pass
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()):
result = trigger_crawl(body.site_id)
summary_bits = []
for r in result.get("results") or []:
if r.get("status") == "ERROR":
summary_bits.append(f"{r.get('name') or r.get('url')}: fout")
else:
summary_bits.append(
f"{r.get('title') or r.get('name')}: {r.get('word_count', 0)} woorden"
)
try:
log_agent_event(
"browser",
"monitor_crawl",
f"Crawl klaar — {result.get('sites', 0)} site(s), {result.get('changed', 0)} wijziging(en)",
"; ".join(summary_bits)[:800],
channel="monitor",
metadata={"site_id": body.site_id, **{k: v for k, v in result.items() if k != "results"}},
)
except Exception:
pass
return result
@admin_router.get("/monitor/parse-results")
def monitor_parse_results(site_id: int | None = None, limit: int = 20):
items = list_parse_results(site_id=site_id, limit=limit)
return {"items": items, "count": len(items)}
@admin_router.get("/monitor/intelligence")
def monitor_intelligence(site_id: int | None = None, q: str | None = None, limit: int = 100):
data = build_parse_intelligence(site_id=site_id, query=q, limit=min(limit, 100))
return data
@admin_router.get("/monitor/parse-results/{page_id}")
def monitor_parse_page_detail(page_id: int):
page = get_parse_page(page_id)
if not page:
raise HTTPException(404, "Parse result not found")
return {"page": page}
@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 llm_router.generate(prompt)
except Exception as exc:
raise HTTPException(502, f"LLM 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(), channel=(body.channel or "cockpit").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, label: str | None = None):
limit = max(1, min(limit, 200))
clauses: list[str] = []
params: list[Any] = []
if label and label.strip():
clauses.append("%s = ANY(user_labels)")
params.append(label.strip().lower())
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(limit)
try:
rows = fetch_all(
f"""
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,
COALESCE(user_labels, '{{}}') AS user_labels, label_notes, labeled_at
FROM document_analytics
{where}
ORDER BY analyzed_at 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]}
class DocumentLabelsBody(BaseModel):
storage_path: str = Field(..., min_length=1)
labels: list[str] = Field(default_factory=list)
notes: str | None = None
class PhotoLabelsBody(BaseModel):
labels: list[str] = Field(default_factory=list)
notes: str | None = None
def _normalize_labels(raw: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for item in raw:
tag = (item or "").strip().lower()
if not tag or tag in seen:
continue
seen.add(tag)
out.append(tag[:64])
return out
@admin_router.patch("/documents/labels")
def documents_patch_labels(body: DocumentLabelsBody) -> dict[str, Any]:
labels = _normalize_labels(body.labels)
notes = (body.notes or "").strip() or None
try:
row = fetch_one(
"""
UPDATE document_analytics
SET user_labels = %s, label_notes = %s, labeled_at = NOW()
WHERE storage_path = %s
RETURNING filename, storage_path, user_labels, label_notes, labeled_at
""",
(labels, notes, body.storage_path),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
if not row:
execute(
"""
INSERT INTO document_analytics (storage_path, filename, user_labels, label_notes, labeled_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (storage_path) DO UPDATE
SET user_labels = EXCLUDED.user_labels,
label_notes = EXCLUDED.label_notes,
labeled_at = NOW()
""",
(body.storage_path, body.storage_path.split("/")[-1], labels, notes),
)
row = fetch_one(
"""
SELECT filename, storage_path, user_labels, label_notes, labeled_at
FROM document_analytics WHERE storage_path = %s
""",
(body.storage_path,),
)
return {"ok": True, "document": _serialize(row)}
@admin_router.patch("/photos/{photo_id}/labels")
def photos_patch_labels(photo_id: int, body: PhotoLabelsBody) -> dict[str, Any]:
labels = _normalize_labels(body.labels)
notes = (body.notes or "").strip() or None
try:
row = fetch_one(
"""
UPDATE photo_imports
SET user_labels = %s, label_notes = %s, labeled_at = NOW()
WHERE id = %s
RETURNING id, filename, storage_path, user_labels, label_notes, labeled_at
""",
(labels, notes, photo_id),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
if not row:
raise HTTPException(404, "Photo not found")
return {"ok": True, "photo": _serialize(row)}
@admin_router.get("/labels/vocabulary")
def labels_vocabulary(limit: int = 80) -> dict[str, Any]:
limit = max(1, min(limit, 200))
try:
rows = fetch_all(
"""
SELECT label, COUNT(*) AS usage_count FROM (
SELECT unnest(COALESCE(user_labels, '{}')) AS label FROM document_analytics
UNION ALL
SELECT unnest(COALESCE(user_labels, '{}')) AS label FROM photo_imports
) t
WHERE label IS NOT NULL AND label <> ''
GROUP BY label
ORDER BY usage_count DESC, label ASC
LIMIT %s
""",
(limit,),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
return {"ok": True, "labels": [_serialize(r) for r in rows]}
PHOTO_LABEL_TAXONOMY = [
{"id": "productfoto", "label": "Productfoto", "emoji": "📦", "color": "#00e5ff"},
{"id": "prijslijst", "label": "Prijslijst", "emoji": "💰", "color": "#ffd700"},
{"id": "verpakking", "label": "Verpakking", "emoji": "📋", "color": "#b8ff3c"},
{"id": "halal", "label": "Halal certificaat", "emoji": "✅", "color": "#4ade80"},
{"id": "recept", "label": "Recept / technisch", "emoji": "🧪", "color": "#a78bfa"},
{"id": "factuur", "label": "Factuur / bon", "emoji": "🧾", "color": "#fb7185"},
{"id": "etiket", "label": "Etiket", "emoji": "🏷️", "color": "#f97316"},
{"id": "marketing", "label": "Marketing", "emoji": "📣", "color": "#38bdf8"},
{"id": "schap", "label": "Schap / retail", "emoji": "🛒", "color": "#22d3ee"},
{"id": "menu", "label": "Menu / horeca", "emoji": "🍽️", "color": "#fbbf24"},
{"id": "overig", "label": "Overig", "emoji": "📁", "color": "#94a3b8"},
]
DOCUMENT_LABEL_TAXONOMY = [
{"id": "contract", "label": "Contract", "emoji": "📄", "color": "#00e5ff"},
{"id": "presentatie", "label": "Presentatie", "emoji": "📊", "color": "#a78bfa"},
{"id": "rapport", "label": "Rapport", "emoji": "📈", "color": "#4ade80"},
{"id": "halal", "label": "Halal / certificaat", "emoji": "✅", "color": "#22c55e"},
{"id": "prijslijst", "label": "Prijslijst", "emoji": "💰", "color": "#ffd700"},
{"id": "marketing", "label": "Marketing", "emoji": "📣", "color": "#38bdf8"},
{"id": "juridisch", "label": "Juridisch", "emoji": "⚖️", "color": "#fb7185"},
{"id": "financieel", "label": "Financieel", "emoji": "🧾", "color": "#f97316"},
{"id": "product", "label": "Product info", "emoji": "📦", "color": "#b8ff3c"},
{"id": "overig", "label": "Overig", "emoji": "📁", "color": "#94a3b8"},
]
@admin_router.get("/labels/taxonomy")
def labels_taxonomy() -> dict[str, Any]:
return {
"ok": True,
"photo": PHOTO_LABEL_TAXONOMY,
"document": DOCUMENT_LABEL_TAXONOMY,
}
class DoclingConvertBody(BaseModel):
path: str = Field(..., min_length=1)
ocr: bool = True
tables: bool = True
page_images: bool = False
picture_images: bool = True
force_full_page_ocr: bool = False
formats: list[str] = Field(default_factory=lambda: ["markdown", "html", "json", "text", "doctags", "yaml"])
class DoclingBatchBody(BaseModel):
paths: list[str] = Field(default_factory=list)
limit: int = Field(default=10, ge=1, le=50)
ext: str | None = None
ocr: bool = True
tables: bool = True
page_images: bool = False
picture_images: bool = True
force_full_page_ocr: bool = False
formats: list[str] = Field(default_factory=lambda: ["markdown", "json"])
@admin_router.get("/docling/capabilities")
async def docling_capabilities_proxy() -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{doc_url.rstrip('/')}/docling/capabilities")
r.raise_for_status()
return r.json()
except Exception as exc:
return {"ok": False, "installed": False, "error": str(exc)}
@admin_router.post("/docling/convert")
async def docling_convert_proxy(body: DoclingConvertBody) -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=600.0) as client:
r = await client.post(f"{doc_url.rstrip('/')}/docling/convert", json=body.model_dump())
if r.status_code >= 400:
raise HTTPException(r.status_code, r.text[:500])
return r.json()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
@admin_router.post("/docling/batch")
async def docling_batch_proxy(body: DoclingBatchBody) -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=900.0) as client:
r = await client.post(f"{doc_url.rstrip('/')}/docling/batch", json=body.model_dump())
if r.status_code >= 400:
raise HTTPException(r.status_code, r.text[:500])
return r.json()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
@admin_router.get("/docling/history")
async def docling_history_proxy(limit: int = 30) -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{doc_url.rstrip('/')}/docling/history", params={"limit": limit})
r.raise_for_status()
return r.json()
except Exception as exc:
return {"ok": False, "items": [], "error": str(exc)}
@admin_router.get("/docling/result")
async def docling_result_proxy(path: str, file_sig: str | None = None) -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
params: dict[str, str] = {"path": path}
if file_sig:
params["file_sig"] = file_sig
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{doc_url.rstrip('/')}/docling/result", params=params)
if r.status_code == 404:
return {"ok": False, "result": None}
r.raise_for_status()
return r.json()
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
class WorkspaceBody(BaseModel):
storage_path: str = Field(..., min_length=1)
content: str = ""
source_format: str = "markdown"
class NasWriteBody(BaseModel):
path: str = Field(..., min_length=1)
content: str = ""
subdir: str = "Telegram/Exports"
class PptxSlideBody(BaseModel):
title: str = ""
bullets: list[str] = Field(default_factory=list)
class PptxCreateBody(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
subtitle: str = Field(default="Foodlinkk", max_length=200)
slides: list[PptxSlideBody] = Field(default_factory=list)
filename: str = ""
class DocumentsChatMessage(BaseModel):
role: str = Field(..., pattern="^(user|assistant)$")
content: str = ""
class DocumentsChatBody(BaseModel):
message: str = Field(..., min_length=1, max_length=8000)
path_prefix: str = ""
client_id: int | None = None
project_id: int | None = None
use_herman: bool = False
llm_provider_id: int | None = None
history: list[DocumentsChatMessage] = Field(default_factory=list)
class DocumentLinkBody(BaseModel):
storage_path: str = Field(..., min_length=1)
client_id: int | None = None
project_id: int | None = None
is_folder: bool = False
notes: str = ""
@admin_router.get("/docling/workspace")
def docling_workspace_get(path: str) -> dict[str, Any]:
try:
row = fetch_one(
"SELECT storage_path, source_format, content, updated_at FROM document_workspace WHERE storage_path = %s",
(path,),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
if not row:
return {"ok": True, "workspace": None}
return {"ok": True, "workspace": _serialize(row)}
@admin_router.put("/docling/workspace")
def docling_workspace_put(body: WorkspaceBody) -> dict[str, Any]:
try:
row = fetch_one(
"""
INSERT INTO document_workspace (storage_path, source_format, content, updated_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (storage_path) DO UPDATE
SET source_format = EXCLUDED.source_format,
content = EXCLUDED.content,
updated_at = NOW()
RETURNING storage_path, source_format, content, updated_at
""",
(body.storage_path, body.source_format, body.content),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
return {"ok": True, "workspace": _serialize(row)}
class DocumentVersionBody(BaseModel):
storage_path: str
content: str = ""
source_format: str = "markdown"
version_label: str = ""
nas_mtime: Optional[str] = None
@admin_router.get("/docling/versions")
def docling_versions_list(path: str, limit: int = 40) -> dict[str, Any]:
limit = max(1, min(limit, 100))
try:
rows = fetch_all(
"""
SELECT id, storage_path, source_format, version_label, nas_mtime, created_at,
LENGTH(content) AS content_length
FROM document_versions
WHERE storage_path = %s
ORDER BY created_at DESC
LIMIT %s
""",
(path, limit),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
return {"ok": True, "versions": [_serialize(r) for r in rows]}
@admin_router.get("/docling/versions/{version_id}")
def docling_version_get(version_id: int) -> dict[str, Any]:
try:
row = fetch_one(
"""
SELECT id, storage_path, content, source_format, version_label, nas_mtime, created_at
FROM document_versions WHERE id = %s
""",
(version_id,),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
if not row:
raise HTTPException(404, "Versie niet gevonden")
return {"ok": True, "version": _serialize(row)}
@admin_router.post("/docling/versions")
def docling_version_create(body: DocumentVersionBody) -> dict[str, Any]:
nas_mtime = body.nas_mtime or None
try:
row = fetch_one(
"""
INSERT INTO document_versions
(storage_path, content, source_format, version_label, nas_mtime)
VALUES (%s, %s, %s, %s, %s::timestamptz)
RETURNING id, storage_path, source_format, version_label, nas_mtime, created_at
""",
(
body.storage_path,
body.content,
body.source_format,
body.version_label or "bewerking",
nas_mtime,
),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
return {"ok": True, "version": _serialize(row)}
@admin_router.get("/docling/nas-read")
async def docling_nas_read_proxy(path: str) -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(
f"{doc_url.rstrip('/')}/docling/nas/read",
params={"path": path},
)
if r.status_code >= 400:
raise HTTPException(r.status_code, r.text[:500])
return r.json()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
@admin_router.get("/documents/nas-file")
async def documents_nas_file_proxy(path: str, inline: bool = True):
from fastapi.responses import Response
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.get(
f"{doc_url.rstrip('/')}/docling/nas/file",
params={"path": path, "inline": "true" if inline else "false"},
)
if r.status_code >= 400:
raise HTTPException(r.status_code, r.text[:300])
media = r.headers.get("content-type", "application/octet-stream")
fname = path.split("/")[-1]
disp = "inline" if inline else "attachment"
return Response(
content=r.content,
media_type=media,
headers={"Content-Disposition": f'{disp}; filename="{fname}"'},
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
@admin_router.post("/docling/save-nas")
async def docling_save_nas(body: NasWriteBody) -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.post(f"{doc_url.rstrip('/')}/docling/nas/write", json=body.model_dump())
if r.status_code >= 400:
raise HTTPException(r.status_code, r.text[:500])
return r.json()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
@admin_router.post("/pptx/create")
async def pptx_create_proxy(body: PptxCreateBody) -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
payload = {
"title": body.title,
"subtitle": body.subtitle,
"filename": body.filename,
"slides": [{"title": s.title, "bullets": s.bullets} for s in body.slides],
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(f"{doc_url.rstrip('/')}/convert/create-pptx", json=payload)
if r.status_code >= 400:
raise HTTPException(r.status_code, r.text[:500])
return r.json()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
@admin_router.post("/pptx/pdf-to-pptx")
async def pptx_from_pdf_proxy(
path: str,
mode: str = "smart",
max_pages: int = 50,
) -> dict[str, Any]:
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{doc_url.rstrip('/')}/convert/pdf-to-pptx",
params={"path": path, "mode": mode, "max_pages": max_pages},
)
if r.status_code >= 400:
raise HTTPException(r.status_code, r.text[:500])
return r.json()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
@admin_router.get("/pptx/download")
async def pptx_download_proxy(path: str):
from fastapi.responses import Response
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(
f"{doc_url.rstrip('/')}/convert/pdf-to-pptx/download",
params={"path": path},
)
if r.status_code != 200:
raise HTTPException(r.status_code, r.text[:300])
return Response(
content=r.content,
media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
headers={"Content-Disposition": f'attachment; filename="{path.split("/")[-1]}"'},
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, str(exc)) from exc
@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()
data = 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
try:
meta = data.get("session") or data
steps = (meta.get("metadata") or {}).get("steps") or []
log_agent_event(
"browser",
"browse",
f"Browse: {(meta.get('title') or body.url)[:100]}",
body.task or body.url,
channel="browser",
metadata={
"url": body.url,
"final_url": meta.get("final_url"),
"session_id": meta.get("id"),
"steps": steps[:12],
},
)
except Exception:
pass
return data
@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()
data = r.json()
try:
meta = data.get("session") or data
steps = (meta.get("metadata") or {}).get("steps") or [body.instruction[:200]]
log_agent_event(
"browser",
"browse_instruct",
f"Instructie: {body.instruction[:80]}",
" · ".join(steps[:8])[:500],
channel="browser",
metadata={"url": body.url, "instruction": body.instruction, "session_id": meta.get("id")},
)
except Exception:
pass
return data
@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])
data = r.json()
try:
log_agent_event(
"browser",
"vnc_navigate",
f"VNC → {body.url[:100]}",
body.instruction or "navigate",
channel="browser-vnc",
metadata={"url": body.url, "instruction": body.instruction},
)
except Exception:
pass
return data
@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])
data = r.json()
try:
meta = data.get("session") or data
log_agent_event(
"browser",
"extract_full",
f"Extract: {(meta.get('title') or body.url)[:100]}",
body.instruction or body.url,
channel="browser",
metadata={"url": body.url, "session_id": meta.get("id"), "scroll_pages": body.scroll_pages},
)
except Exception:
pass
return data
@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, 1000))
params: dict[str, Any] = {"limit": limit}
if ext:
params["ext"] = ext
try:
async with httpx.AsyncClient(timeout=120.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.get("/documents/share-browse")
async def documents_share_browse(
path: str = "",
limit: int = 500,
) -> dict[str, Any]:
"""Mappen + bestanden op één share-niveau (folder browser)."""
import httpx
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
limit = max(1, min(limit, 2000))
try:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.get(
f"{doc_url.rstrip('/')}/nas/browse",
params={"path": path, "limit": limit},
)
r.raise_for_status()
data = r.json()
return {"ok": True, **data}
except Exception as exc:
return {"ok": False, "entries": [], "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/search")
async def documents_search(
q: str,
limit: int = 8,
) -> dict[str, Any]:
"""Semantisch zoeken in NAS-documenten (Chroma RAG)."""
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(
f"{doc_url.rstrip('/')}/search",
params={"q": q, "limit": max(1, min(limit, 20))},
)
r.raise_for_status()
return {"ok": True, **r.json()}
except Exception as exc:
return {"ok": False, "results": [], "error": str(exc)}
@admin_router.get("/documents/nas-diagnostics")
async def documents_nas_diagnostics() -> dict[str, Any]:
"""NAS mount status + zichtbare vs. ontbrekende bestanden."""
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
out: dict[str, Any] = {"ok": True}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
h = await client.get(f"{doc_url.rstrip('/')}/health")
h.raise_for_status()
out["health"] = h.json()
f = await client.get(f"{doc_url.rstrip('/')}/nas/files", params={"limit": 1000})
f.raise_for_status()
files = f.json()
out["visible_files"] = files.get("total", 0)
out["sample_paths"] = [i.get("path") for i in (files.get("items") or [])[:8]]
except Exception as exc:
out["ok"] = False
out["error"] = str(exc)
out["hint"] = (
"Zie je mappen (CUCINA/Foodlinkk) maar weinig bestanden? "
"Geef gebruiker aissa Read/Write op de Synology share + submappen in DSM."
)
return out
@admin_router.post("/documents/chat")
async def documents_chat(body: DocumentsChatBody) -> dict[str, Any]:
"""Chat met NAS-data via RAG + Ollama (snel) of optioneel Herman."""
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
message = body.message.strip()
sources: list[str] = []
context = ""
linked_paths: list[str] = []
if body.client_id:
rows = fetch_all(
"SELECT storage_path, is_folder FROM document_links WHERE client_id = %s",
(body.client_id,),
)
linked_paths = [r["storage_path"] for r in rows if r.get("storage_path")]
if body.project_id:
rows = fetch_all(
"SELECT storage_path, is_folder FROM document_links WHERE project_id = %s",
(body.project_id,),
)
linked_paths.extend([r["storage_path"] for r in rows if r.get("storage_path")])
try:
async with httpx.AsyncClient(timeout=45.0) as client:
r = await client.get(
f"{doc_url.rstrip('/')}/context",
params={"q": message, "limit": 8},
)
r.raise_for_status()
data = r.json()
context = (data.get("context") or "").strip()
sources = [s for s in (data.get("sources") or []) if s]
except Exception:
context = ""
sources = []
scope_prefix = (body.path_prefix or "").strip().lstrip("/")
if linked_paths or scope_prefix:
try:
async with httpx.AsyncClient(timeout=45.0) as client:
sr = await client.get(
f"{doc_url.rstrip('/')}/search",
params={"q": message, "limit": 12},
)
sr.raise_for_status()
hits = sr.json().get("results") or []
def _in_scope(path: str) -> bool:
if not path:
return False
if scope_prefix and (path == scope_prefix or path.startswith(scope_prefix + "/")):
return True
for lp in linked_paths:
if path == lp or path.startswith(lp.rstrip("/") + "/"):
return True
return False
scoped = [h for h in hits if _in_scope(h.get("path") or "")]
if scoped:
parts = [f"[{h.get('filename', 'doc')}] {(h.get('text') or '')[:1200]}" for h in scoped[:8]]
context = "\n\n".join(parts)
sources = [h.get("path") for h in scoped if h.get("path")]
except Exception:
pass
if len(context) > 6000:
context = context[:6000] + "\n…(context ingekort)"
# Ollama op CPU kan grote RAG-context niet aan — inkorten vóór LLM-call
prov_hint = llm_router.resolve_provider(body.llm_provider_id)
is_ollama = (prov_hint.get("provider_type") or "ollama") == "ollama"
if is_ollama and len(context) > 2500:
context = context[:2500] + "\n…(ingekort voor lokale Ollama — gebruik cloud LLM voor volledige RAG)"
if body.use_herman:
try:
scoped_msg = message
if scope_prefix:
scoped_msg = f"[Scope: {scope_prefix}]\n{message}"
result = await herman_service.chat(scoped_msg, channel="documents")
result["rag_sources"] = result.get("rag_sources") or sources
result["ok"] = True
result["context_used"] = bool(context)
return result
except Exception as exc:
pass
client_note = ""
if body.client_id:
c = fetch_one("SELECT name FROM clients WHERE id = %s", (body.client_id,))
if c:
client_note = f"\nKlantcontext: {c['name']} (ID {body.client_id})"
system = (
"Je bent Herman, Foodlinkk document-assistent en second brain. Beantwoord in het Nederlands "
"op basis van de NAS-documentfragmenten hieronder. Citeer bronbestanden als [bestandsnaam]. "
"Als het antwoord niet in de context staat, zeg dat eerlijk."
f"{client_note}\n\nDOCUMENT CONTEXT:\n{context or '(geen documenten gevonden — geef algemeen antwoord)'}"
)
messages: list[dict[str, str]] = [{"role": "system", "content": system}]
for h in body.history[-6:]:
messages.append({"role": h.role, "content": h.content})
messages.append({"role": "user", "content": message})
try:
reply, llm_meta = await llm_router.chat_messages(
messages, timeout=12.0 if is_ollama else 100.0, provider_id=body.llm_provider_id
)
except Exception as exc:
if context and is_ollama:
src_list = "\n".join(f"• {s}" for s in sources[:8]) if sources else ""
reply = (
"⚠️ Ollama is te traag voor RAG op CPU. Hier zijn de relevante NAS-fragmenten:\n\n"
+ context[:3500]
+ (f"\n\nBronnen:\n{src_list}" if src_list else "")
+ "\n\n💡 Tip: voeg DeepSeek/Gemini toe via Instellingen → AI / LLM voor echte AI-antwoorden."
)
llm_meta = {
"provider_id": prov_hint.get("id"),
"provider_type": "ollama",
"provider_label": "RAG fallback",
"model": prov_hint.get("model"),
}
else:
raise HTTPException(502, f"LLM niet bereikbaar: {exc}") from exc
label = llm_meta.get("provider_label") or "LLM"
model = llm_meta.get("model") or ""
return {
"ok": True,
"agent": "knowledge",
"agent_label": f"Herman · NAS RAG · {label}",
"reply": reply,
"rag_sources": sources,
"context_used": bool(context),
"delegated_agents": ["knowledge"],
"llm_provider_id": llm_meta.get("provider_id"),
"llm_provider_type": llm_meta.get("provider_type"),
"llm_model": model,
}
@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", ".bmp")
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.post("/documents/nas-ocr")
async def documents_nas_ocr(
path: str | None = None,
force: bool = False,
) -> dict[str, Any]:
"""OCR alle NAS-afbeeldingen (jpg/png) + plaatjes uit pdf/docx via doc-ingest → browser-agent."""
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
params: dict[str, str] = {"force": "true" if force else "false"}
if path:
params["path"] = path
try:
async with httpx.AsyncClient(timeout=600.0) as client:
r = await client.post(
f"{doc_url.rstrip('/')}/ingest/extract-images",
params=params,
)
r.raise_for_status()
data = r.json()
try:
log_agent_event(
"browser",
"nas_ocr_batch",
f"NAS OCR: {data.get('images_extracted', 0)} afbeeldingen",
path or "alle NAS-bestanden",
channel="documents",
metadata={"path": path, "force": force, **data},
)
except Exception:
pass
return {"ok": True, **data}
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from 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()