SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
"""Interactive agent terminal — parse and execute whitelisted commands."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import fetch_all, fetch_one
|
||||
from app.services import agent_integration, agent_souls, herman, webbuilder_agent
|
||||
from app.services.agent_events_log import log_agent_event
|
||||
from app.services.agent_names import normalize_agent_key
|
||||
|
||||
TOOLS_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
||||
BROWSER_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790").rstrip("/")
|
||||
|
||||
Line = dict[str, Any]
|
||||
|
||||
GLOBAL_HELP = [
|
||||
"help — dit overzicht",
|
||||
"status — huidige status & taak",
|
||||
"history — laatste events (in terminal)",
|
||||
"handoff <agent> <type> [notitie]",
|
||||
"say <bericht> — log een notitie",
|
||||
"ask <vraag> — AI-antwoord in rol van deze agent",
|
||||
]
|
||||
|
||||
AGENT_HELP: dict[str, list[str]] = {
|
||||
"herman": [
|
||||
"briefing — dagrapport genereren",
|
||||
"<tekst> — vraag / opdracht aan Herman",
|
||||
],
|
||||
"browser": [
|
||||
"monitor add <url> — site toevoegen aan monitor",
|
||||
"monitor list — actieve sites",
|
||||
"browse <url> — pagina openen",
|
||||
"crawl — alle sites crawlen + parse-overzicht",
|
||||
"parse — laatste parse-resultaten",
|
||||
"parse <id> — parse van specifieke site",
|
||||
"intel — hype & trend analyse (samenvatting)",
|
||||
"intel <zoekterm> — filter analyse",
|
||||
],
|
||||
"research": ["run — research pipeline starten", "briefs — recente briefs"],
|
||||
"retail": ["rss — retail RSS verversen", "scores — halal opportunity scores"],
|
||||
"sysops": [
|
||||
"backup — config backup (approval)",
|
||||
"scan — maintenance scan",
|
||||
"topology — infra overzicht",
|
||||
"status — sysops status",
|
||||
],
|
||||
"marketing": ["reco — aanbevelingen genereren"],
|
||||
"packaging": ["design <brief> — packaging opdracht (via Herman)"],
|
||||
"webbuilder": [
|
||||
"build <opdracht> — website bouwen (agy op Hermes)",
|
||||
"projects — bestaande website-projecten",
|
||||
"status [project] — build-status",
|
||||
"preview [project] — preview-URL",
|
||||
],
|
||||
"email": ["sync — inbox sync trigger"],
|
||||
"finance": ["margins — marge-overzicht (placeholder)"],
|
||||
}
|
||||
|
||||
|
||||
def _line(msg: str, *, detail: str = "", line_type: str = "output") -> Line:
|
||||
return {"type": line_type, "message": msg, "detail": detail}
|
||||
|
||||
|
||||
def _help_lines(key: str) -> list[Line]:
|
||||
lines = [_line("Beschikbare commando's:", line_type="output")]
|
||||
for h in GLOBAL_HELP:
|
||||
lines.append(_line(" " + h, line_type="output"))
|
||||
extra = AGENT_HELP.get(key, [])
|
||||
if extra:
|
||||
lines.append(_line(f"— {key} —", line_type="output"))
|
||||
for h in extra:
|
||||
lines.append(_line(" " + h, line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
def _status_lines(key: str, soul: dict[str, Any]) -> list[Line]:
|
||||
recent = soul.get("recent_events") or []
|
||||
last = recent[0] if recent else {}
|
||||
lines = [
|
||||
_line(f"{soul.get('display_name') or key} · {soul.get('role_title') or 'agent'}"),
|
||||
_line(f"Status: {last.get('status') or soul.get('current_status') or 'standby'}"),
|
||||
_line(f"Taak: {last.get('title') or soul.get('current_task') or '—'}"),
|
||||
_line(f"Events totaal: {soul.get('event_count') or 0}"),
|
||||
]
|
||||
resp = (soul.get("responsibilities") or "").strip()
|
||||
if resp:
|
||||
lines.append(_line(resp[:220], detail=resp if len(resp) > 220 else ""))
|
||||
return lines
|
||||
|
||||
|
||||
async def _tools_post(path: str, payload: dict | None = None, timeout: float = 180.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{TOOLS_URL}{path}", json=payload or {})
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": False, "detail": resp.text[:500]}
|
||||
if resp.status_code >= 400:
|
||||
data.setdefault("ok", False)
|
||||
data.setdefault("detail", resp.text[:500])
|
||||
return data
|
||||
|
||||
|
||||
async def _tools_get(path: str, timeout: float = 60.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"{TOOLS_URL}{path}")
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"ok": False, "detail": resp.text[:500]}
|
||||
|
||||
|
||||
async def _browser_post(path: str, payload: dict, timeout: float = 120.0) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(f"{BROWSER_URL}{path}", json=payload)
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"ok": False, "detail": resp.text[:500]}
|
||||
|
||||
|
||||
def _handoff(src: str, dst_raw: str, htype: str, note: str) -> list[Line]:
|
||||
dst = normalize_agent_key(dst_raw)
|
||||
if not dst:
|
||||
return [_line("Onbekende agent: " + dst_raw, line_type="error")]
|
||||
payload = {"note": note} if note else {}
|
||||
try:
|
||||
agent_integration.create_handoff(src, dst, handoff_type=htype, payload=payload)
|
||||
return [_line(f"Handoff → {dst} ({htype})" + (f": {note}" if note else ""))]
|
||||
except Exception as exc:
|
||||
return [_line("Handoff mislukt: " + str(exc), line_type="error")]
|
||||
|
||||
|
||||
async def _agent_ask(key: str, soul: dict[str, Any], question: str) -> list[Line]:
|
||||
name = soul.get("display_name") or key
|
||||
role = soul.get("role_title") or ""
|
||||
prompt = (
|
||||
f"Je bent {name} ({key}), {role}. "
|
||||
f"Beantwoord kort en actiegericht in het Nederlands.\n\nOpdracht: {question}"
|
||||
)
|
||||
result = await herman.chat(prompt, channel=f"terminal:{key}")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
delegated = result.get("delegated_agents") or []
|
||||
lines = [_line(reply or "(geen antwoord)", detail=reply)]
|
||||
if delegated:
|
||||
lines.append(_line("Gedelegeerd: " + ", ".join(delegated), line_type="handoff_out"))
|
||||
log_agent_event(
|
||||
key,
|
||||
"terminal_out",
|
||||
reply[:255] if reply else "Antwoord",
|
||||
reply[:4000],
|
||||
channel="terminal",
|
||||
metadata={"delegated": delegated, "question": question[:500]},
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _format_crawl_results(result: dict[str, Any]) -> list[Line]:
|
||||
if result.get("message") and not result.get("results"):
|
||||
return [_line(result["message"], line_type="output")]
|
||||
lines: list[Line] = [
|
||||
_line(
|
||||
f"Crawl klaar — {result.get('sites', 0)} site(s), "
|
||||
f"{result.get('changed', 0)} wijziging(en), {result.get('errors', 0)} fout(en)"
|
||||
)
|
||||
]
|
||||
for r in result.get("results") or []:
|
||||
if r.get("status") == "ERROR":
|
||||
lines.append(_line(f"✗ {r.get('name') or r.get('url')}: {r.get('error')}", line_type="error"))
|
||||
continue
|
||||
flag = "△" if r.get("changed") else "✓"
|
||||
lines.append(
|
||||
_line(
|
||||
f"{flag} {r.get('title') or r.get('name')} — {r.get('word_count', 0)} woorden, "
|
||||
f"{r.get('links_count', 0)} links",
|
||||
detail=(r.get("excerpt") or "")[:200],
|
||||
)
|
||||
)
|
||||
headings = r.get("headings") or []
|
||||
if headings:
|
||||
lines.append(_line(" H: " + " · ".join(headings[:4])[:180], line_type="output"))
|
||||
for link in (r.get("links_sample") or [])[:3]:
|
||||
lbl = link.get("label") or link.get("href") or ""
|
||||
lines.append(_line(" → " + lbl[:70], line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
def _format_parse_items(items: list[dict[str, Any]]) -> list[Line]:
|
||||
if not items:
|
||||
return [_line("Geen parse-resultaten — voer eerst crawl uit.")]
|
||||
lines: list[Line] = [_line(f"Parse-overzicht ({len(items)} pagina's):")]
|
||||
for p in items:
|
||||
lines.append(
|
||||
_line(
|
||||
f"#{p.get('site_id') or '—'} {p.get('title') or p.get('url')} — "
|
||||
f"{p.get('word_count', 0)} woorden",
|
||||
detail=(p.get("excerpt") or "")[:200],
|
||||
)
|
||||
)
|
||||
if p.get("meta_description"):
|
||||
lines.append(_line(" desc: " + str(p["meta_description"])[:120], line_type="output"))
|
||||
for h in (p.get("headings") or [])[:3]:
|
||||
lines.append(_line(" · " + h[:90], line_type="output"))
|
||||
return lines
|
||||
|
||||
|
||||
async def _run_agent_command(key: str, soul: dict[str, Any], cmd: str, args: list[str], raw: str) -> list[Line]:
|
||||
if key == "herman":
|
||||
if cmd == "briefing":
|
||||
try:
|
||||
content = await herman.generate_briefing()
|
||||
preview = (content or "")[:400]
|
||||
log_agent_event("herman", "terminal_out", "Briefing gegenereerd", content[:4000], channel="terminal")
|
||||
return [_line("CEO briefing gegenereerd.", detail=preview)]
|
||||
except Exception as exc:
|
||||
return [_line("Briefing mislukt: " + str(exc), line_type="error")]
|
||||
result = await herman.chat(raw, channel="terminal")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
delegated = result.get("delegated_agents") or []
|
||||
lines = [_line(reply or "(geen antwoord)", detail=reply)]
|
||||
if delegated:
|
||||
lines.append(_line("→ " + ", ".join(delegated), line_type="handoff_out"))
|
||||
log_agent_event("herman", "terminal_out", reply[:255] if reply else "Antwoord", reply[:4000], channel="terminal")
|
||||
return lines
|
||||
|
||||
if key == "browser":
|
||||
if cmd == "monitor" and args and args[0].lower() == "add" and len(args) >= 2:
|
||||
url = args[1]
|
||||
from app.services.monitor import add_site
|
||||
|
||||
site = add_site(url, name=url[:80])
|
||||
log_agent_event(
|
||||
"browser",
|
||||
"monitor_site_added",
|
||||
f"Terminal: site toegevoegd {url[:120]}",
|
||||
"",
|
||||
channel="terminal",
|
||||
metadata={"url": url, "site_id": site.get("id")},
|
||||
)
|
||||
return [_line(f"Monitor: {url} toegevoegd (id {site.get('id')})")]
|
||||
if cmd == "monitor" and args and args[0].lower() == "list":
|
||||
rows = fetch_all(
|
||||
"SELECT id, url, is_active FROM monitored_sites ORDER BY id DESC LIMIT 15"
|
||||
)
|
||||
if not rows:
|
||||
return [_line("Geen monitor-sites.")]
|
||||
return [_line(f"#{r['id']} {'✓' if r.get('is_active') else '○'} {r['url']}") for r in rows]
|
||||
if cmd == "browse" and args:
|
||||
url = args[0]
|
||||
data = await _browser_post("/browse", {"url": url, "purpose": "terminal"})
|
||||
log_agent_event("browser", "browse", f"Terminal browse: {url[:120]}", json.dumps(data)[:500], channel="terminal")
|
||||
title = data.get("title") or data.get("url") or url
|
||||
return [_line(f"Browse OK: {title}")]
|
||||
if cmd == "crawl":
|
||||
from app.services.monitor import trigger_crawl
|
||||
|
||||
site_id = int(args[0]) if args and str(args[0]).isdigit() else None
|
||||
result = trigger_crawl(site_id)
|
||||
log_agent_event(
|
||||
"browser",
|
||||
"monitor_crawl",
|
||||
f"Terminal crawl ({result.get('sites', 0)} sites)",
|
||||
json.dumps([{k: r.get(k) for k in ("url", "title", "word_count", "status")} for r in result.get("results", [])])[:800],
|
||||
channel="terminal",
|
||||
)
|
||||
return _format_crawl_results(result)
|
||||
if cmd == "parse":
|
||||
from app.services.monitor import list_parse_results
|
||||
|
||||
sid = int(args[0]) if args and str(args[0]).isdigit() else None
|
||||
return _format_parse_items(list_parse_results(site_id=sid, limit=10))
|
||||
if cmd == "intel":
|
||||
from app.services.monitor import build_parse_intelligence
|
||||
|
||||
q = " ".join(args) if args else None
|
||||
data = build_parse_intelligence(query=q, limit=20)
|
||||
lines = [
|
||||
_line(
|
||||
f"Analyse: {data['summary']['pages']} pagina's · "
|
||||
f"{data['summary']['total_words']} woorden · "
|
||||
f"{data['summary']['themes_detected']} trend-termen"
|
||||
),
|
||||
_line("Open Browser → tab Parse Analyse voor volledig overzicht", line_type="output"),
|
||||
]
|
||||
for t in (data.get("hype_terms") or [])[:12]:
|
||||
flag = "★ " if t.get("cross_site") else ""
|
||||
lines.append(
|
||||
_line(f"{flag}{t['term']} ({t['score']}) — {', '.join(t.get('sites') or [])[:3]}", line_type="output")
|
||||
)
|
||||
return lines
|
||||
|
||||
if key == "research":
|
||||
if cmd == "run":
|
||||
data = await _tools_post("/research/run")
|
||||
ok = data.get("ok", True)
|
||||
log_agent_event("research", "terminal_action", "Research run (terminal)", json.dumps(data)[:500], channel="terminal")
|
||||
return [_line("Research pipeline: " + ("OK" if ok else "fout"), detail=json.dumps(data)[:300])]
|
||||
if cmd == "briefs":
|
||||
data = await _tools_get("/research/briefs")
|
||||
items = data if isinstance(data, list) else data.get("items") or []
|
||||
if not items:
|
||||
return [_line("Geen briefs.")]
|
||||
return [_line(str(b.get("title") or b.get("id") or b))[:120] for b in items[:8]]
|
||||
|
||||
if key == "retail":
|
||||
if cmd == "rss":
|
||||
data = await _tools_post("/retail/rss/refresh")
|
||||
log_agent_event("retail", "terminal_action", "RSS refresh (terminal)", json.dumps(data)[:300], channel="terminal")
|
||||
return [_line("RSS verversd.", detail=json.dumps(data)[:200])]
|
||||
if cmd == "scores":
|
||||
data = await _tools_post("/retail/compute-opportunities")
|
||||
return [_line("Opportunity scores bijgewerkt.", detail=json.dumps(data)[:200])]
|
||||
|
||||
if key == "sysops":
|
||||
if cmd == "backup":
|
||||
data = await _tools_post("/ops/backup/run")
|
||||
log_agent_event("sysops", "terminal_action", "Backup (terminal)", json.dumps(data)[:400], channel="terminal")
|
||||
return [_line("Backup uitgevoerd.", detail=json.dumps(data)[:300])]
|
||||
if cmd == "scan":
|
||||
data = await _tools_post("/ops/maintenance/scan")
|
||||
return [_line("Maintenance scan klaar.", detail=json.dumps(data)[:300])]
|
||||
if cmd == "topology":
|
||||
data = await _tools_get("/ops/topology")
|
||||
return [_line("Topology geladen.", detail=json.dumps(data)[:400])]
|
||||
if cmd == "status":
|
||||
data = await _tools_get("/ops/status")
|
||||
return [_line(json.dumps(data)[:350], detail=json.dumps(data)[:800])]
|
||||
|
||||
if key == "marketing" and cmd == "reco":
|
||||
data = await _tools_post("/recommendations/generate")
|
||||
return [_line("Aanbevelingen gegenereerd.", detail=json.dumps(data)[:300])]
|
||||
|
||||
if key == "packaging" and cmd == "design":
|
||||
brief = " ".join(args) or raw
|
||||
result = await herman.chat(f"Packaging design: {brief}", channel="terminal:packaging")
|
||||
reply = (result.get("reply") or "").strip()
|
||||
return [_line(reply, detail=reply)]
|
||||
|
||||
if key == "webbuilder":
|
||||
if cmd == "build":
|
||||
brief = rest or raw
|
||||
if not brief:
|
||||
return [_line("Gebruik: build <opdracht>", line_type="error")]
|
||||
try:
|
||||
outcome = await webbuilder_agent.generate_from_message(brief, channel="terminal", wait=False)
|
||||
proj = outcome.get("project") or "?"
|
||||
preview = outcome.get("preview_url") or "—"
|
||||
return [
|
||||
_line(f"Build gestart: {proj}"),
|
||||
_line(f"Preview: {preview}", line_type="output"),
|
||||
_line("Volg voortgang live in dit terminal-venster.", line_type="output"),
|
||||
]
|
||||
except Exception as exc:
|
||||
return [_line("Build mislukt: " + str(exc), line_type="error")]
|
||||
if cmd == "projects":
|
||||
data = await webbuilder_agent.list_projects()
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return [_line("Geen projecten op Hermes/NAS.")]
|
||||
return [
|
||||
_line(
|
||||
f"{p.get('slug')} — {p.get('files', 0)} bestand(en)"
|
||||
+ (" · index.html ✓" if p.get("has_index") else "")
|
||||
)
|
||||
for p in items
|
||||
]
|
||||
if cmd == "status":
|
||||
slug = args[0] if args else ""
|
||||
if not slug:
|
||||
return [_line("Gebruik: status <project>", line_type="error")]
|
||||
st = await webbuilder_agent.get_build_status(slug)
|
||||
lines = [_line(f"{st.get('project')} · {st.get('status')} — {st.get('message') or ''}")]
|
||||
tail = (st.get("log_tail") or "")[:300]
|
||||
if tail:
|
||||
lines.append(_line(tail, line_type="output"))
|
||||
return lines
|
||||
if cmd == "preview":
|
||||
slug = args[0] if args else ""
|
||||
if slug:
|
||||
st = await webbuilder_agent.get_build_status(slug)
|
||||
url = st.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
|
||||
else:
|
||||
url = webbuilder_agent.HERMES_PREVIEW_BASE
|
||||
return [_line(f"Preview: {url}")]
|
||||
|
||||
return await _agent_ask(key, soul, raw)
|
||||
|
||||
|
||||
async def execute_terminal_command(
|
||||
agent_key: str,
|
||||
command: str,
|
||||
*,
|
||||
issued_by: str = "ceo",
|
||||
) -> dict[str, Any]:
|
||||
key = normalize_agent_key(agent_key)
|
||||
soul = agent_souls.get_soul(key)
|
||||
if not soul:
|
||||
raise ValueError("Agent niet gevonden")
|
||||
|
||||
raw = (command or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("Leeg commando")
|
||||
|
||||
log_agent_event(
|
||||
key,
|
||||
"terminal_in",
|
||||
raw[:255],
|
||||
"",
|
||||
channel="terminal",
|
||||
metadata={"issued_by": issued_by, "command": raw[:500]},
|
||||
)
|
||||
|
||||
parts = raw.split()
|
||||
cmd = parts[0].lower()
|
||||
args = parts[1:]
|
||||
rest = " ".join(args)
|
||||
|
||||
if cmd in ("help", "?", "h"):
|
||||
lines = _help_lines(key)
|
||||
elif cmd == "status":
|
||||
lines = _status_lines(key, soul)
|
||||
elif cmd == "handoff":
|
||||
if len(args) < 2:
|
||||
lines = [_line("Gebruik: handoff <agent> <type> [notitie]", line_type="error")]
|
||||
else:
|
||||
note = " ".join(args[2:]) if len(args) > 2 else ""
|
||||
lines = _handoff(key, args[0], args[1], note)
|
||||
elif cmd == "say":
|
||||
if not rest:
|
||||
lines = [_line("Gebruik: say <bericht>", line_type="error")]
|
||||
else:
|
||||
log_agent_event(key, "terminal_note", rest[:255], rest, channel="terminal")
|
||||
lines = [_line("Genoteerd.")]
|
||||
elif cmd == "ask":
|
||||
if not rest:
|
||||
lines = [_line("Gebruik: ask <vraag>", line_type="error")]
|
||||
else:
|
||||
lines = await _agent_ask(key, soul, rest)
|
||||
elif cmd == "history":
|
||||
events = agent_souls.list_agent_events(key, limit=8)
|
||||
if not events:
|
||||
lines = [_line("Geen history.")]
|
||||
else:
|
||||
lines = []
|
||||
for ev in reversed(events):
|
||||
t = (ev.get("created_at") or "")[11:19] or "--:--"
|
||||
lines.append(_line(f"{t} {ev.get('title') or ev.get('event_type')}"))
|
||||
else:
|
||||
lines = await _run_agent_command(key, soul, cmd, args, raw)
|
||||
|
||||
return {"ok": True, "agent_key": key, "command": raw, "lines": lines}
|
||||
Reference in New Issue
Block a user