268 lines
8.1 KiB
Python
268 lines
8.1 KiB
Python
|
|
"""Web Builder agent — websites bouwen via Hermes/agy API op VM107."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from app.config import settings
|
||
|
|
from app.services.agent_events_log import log_agent_event
|
||
|
|
|
||
|
|
HERMES_BUILD_URL = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798").rstrip("/")
|
||
|
|
HERMES_PREVIEW_BASE = os.getenv("HERMES_PREVIEW_BASE", "http://10.4.7.27:8080").rstrip("/")
|
||
|
|
|
||
|
|
WEBSITE_KEYWORDS = (
|
||
|
|
"maak website",
|
||
|
|
"maak een website",
|
||
|
|
"bouw website",
|
||
|
|
"bouw een website",
|
||
|
|
"genereer website",
|
||
|
|
"website voor",
|
||
|
|
"website bouwen",
|
||
|
|
"landingspagina",
|
||
|
|
"landing page",
|
||
|
|
"webshop",
|
||
|
|
"webpagina",
|
||
|
|
"nieuwe site",
|
||
|
|
"nieuwe website",
|
||
|
|
"/website",
|
||
|
|
"web builder",
|
||
|
|
"webbuilder",
|
||
|
|
"agy",
|
||
|
|
"antigravity",
|
||
|
|
"anti gravity",
|
||
|
|
"anti-gravity",
|
||
|
|
"site maken",
|
||
|
|
"maak een site",
|
||
|
|
"bouw een site",
|
||
|
|
"website laten maken",
|
||
|
|
"laat agy",
|
||
|
|
"via agy",
|
||
|
|
)
|
||
|
|
|
||
|
|
PROJECT_RE = re.compile(
|
||
|
|
r"(?:website|site|project)\s+(?:voor\s+|called\s+|genaamd\s+)?['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
|
||
|
|
re.I,
|
||
|
|
)
|
||
|
|
FOR_RE = re.compile(
|
||
|
|
r"\bvoor\s+['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
|
||
|
|
re.I,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def wants_website(raw: str) -> bool:
|
||
|
|
t = (raw or "").strip().lower()
|
||
|
|
return any(k in t for k in WEBSITE_KEYWORDS)
|
||
|
|
|
||
|
|
|
||
|
|
def slugify(name: str) -> str:
|
||
|
|
s = (name or "").strip().lower()
|
||
|
|
s = re.sub(r"[^a-z0-9_-]+", "-", s)
|
||
|
|
s = re.sub(r"-+", "-", s).strip("-")
|
||
|
|
return s[:64] or "website"
|
||
|
|
|
||
|
|
|
||
|
|
def extract_project_name(raw: str) -> str:
|
||
|
|
t = (raw or "").strip()
|
||
|
|
m = PROJECT_RE.search(t)
|
||
|
|
if m:
|
||
|
|
return slugify(m.group(1))
|
||
|
|
m = FOR_RE.search(t)
|
||
|
|
if m:
|
||
|
|
name = m.group(1)
|
||
|
|
name = re.split(r"\s+(?:met|with|incl|including)\b", name, maxsplit=1, flags=re.I)[0]
|
||
|
|
return slugify(name)
|
||
|
|
for token in t.split():
|
||
|
|
clean = slugify(token)
|
||
|
|
if len(clean) >= 3 and clean not in (
|
||
|
|
"maak", "bouw", "website", "site", "voor", "een", "the", "and", "met",
|
||
|
|
"simpele", "landing", "page", "pagina", "landingspagina",
|
||
|
|
):
|
||
|
|
return clean
|
||
|
|
return slugify(t[:40]) or "website"
|
||
|
|
|
||
|
|
|
||
|
|
def extract_build_prompt(raw: str, project: str) -> str:
|
||
|
|
body = (raw or "").strip()
|
||
|
|
lower = body.lower()
|
||
|
|
for k in WEBSITE_KEYWORDS:
|
||
|
|
if lower.startswith(k):
|
||
|
|
rest = body[len(k) :].strip(" :,-")
|
||
|
|
if rest:
|
||
|
|
return rest
|
||
|
|
return (
|
||
|
|
f"Bouw een complete, moderne, responsive website voor project '{project}'. "
|
||
|
|
f"Opdracht: {body}. "
|
||
|
|
"Gebruik index.html, style.css, script.js en een assets/ map. "
|
||
|
|
"Foodlinkk halal kant-en-klaar food branding waar passend."
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def _hermes_get(path: str, timeout: float = 30.0) -> dict[str, Any]:
|
||
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||
|
|
resp = await client.get(f"{HERMES_BUILD_URL}{path}")
|
||
|
|
try:
|
||
|
|
data = resp.json()
|
||
|
|
except Exception:
|
||
|
|
data = {"ok": False, "detail": resp.text[:500]}
|
||
|
|
if resp.status_code >= 400:
|
||
|
|
data.setdefault("ok", False)
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
async def _hermes_post(path: str, payload: dict[str, Any], timeout: float = 60.0) -> dict[str, Any]:
|
||
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||
|
|
resp = await client.post(f"{HERMES_BUILD_URL}{path}", json=payload)
|
||
|
|
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 list_projects() -> dict[str, Any]:
|
||
|
|
return await _hermes_get("/api/projects")
|
||
|
|
|
||
|
|
|
||
|
|
async def get_build_status(project: str) -> dict[str, Any]:
|
||
|
|
slug = slugify(project)
|
||
|
|
return await _hermes_get(f"/api/build/{slug}/status")
|
||
|
|
|
||
|
|
|
||
|
|
async def build_from_message(raw: str, *, channel: str = "herman") -> dict[str, Any]:
|
||
|
|
project = extract_project_name(raw)
|
||
|
|
prompt = extract_build_prompt(raw, project)
|
||
|
|
|
||
|
|
log_agent_event(
|
||
|
|
"webbuilder",
|
||
|
|
"website_build_start",
|
||
|
|
f"Start build: {project}",
|
||
|
|
prompt[:4000],
|
||
|
|
channel=channel,
|
||
|
|
metadata={"project": project, "prompt": prompt[:500]},
|
||
|
|
status="running",
|
||
|
|
)
|
||
|
|
|
||
|
|
data = await _hermes_post(
|
||
|
|
"/api/build",
|
||
|
|
{"project": project, "prompt": prompt},
|
||
|
|
timeout=45.0,
|
||
|
|
)
|
||
|
|
if not data.get("ok", True) and data.get("detail"):
|
||
|
|
log_agent_event(
|
||
|
|
"webbuilder",
|
||
|
|
"website_build_error",
|
||
|
|
f"Build mislukt: {project}",
|
||
|
|
str(data.get("detail", data))[:2000],
|
||
|
|
channel=channel,
|
||
|
|
metadata={"project": project},
|
||
|
|
status="error",
|
||
|
|
)
|
||
|
|
raise RuntimeError(str(data.get("detail") or data))
|
||
|
|
|
||
|
|
job_id = data.get("job_id") or project
|
||
|
|
preview = data.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
|
||
|
|
nas_path = data.get("nas_path") or f"//10.4.7.11/share/Websites/{project}/"
|
||
|
|
|
||
|
|
log_agent_event(
|
||
|
|
"webbuilder",
|
||
|
|
"website_build_running",
|
||
|
|
f"agy bezig: {project}",
|
||
|
|
f"Job {job_id}\nPreview: {preview}\nNAS: {nas_path}",
|
||
|
|
channel=channel,
|
||
|
|
metadata={
|
||
|
|
"project": project,
|
||
|
|
"job_id": job_id,
|
||
|
|
"preview_url": preview,
|
||
|
|
"nas_path": nas_path,
|
||
|
|
"correlation_id": job_id,
|
||
|
|
},
|
||
|
|
status="running",
|
||
|
|
)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"project": project,
|
||
|
|
"job_id": job_id,
|
||
|
|
"preview_url": preview,
|
||
|
|
"nas_path": nas_path,
|
||
|
|
"prompt": prompt,
|
||
|
|
"hermes_status": data.get("status", "running"),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def poll_until_done(project: str, *, channel: str = "herman", timeout: float = 1800.0) -> dict[str, Any]:
|
||
|
|
"""Poll Hermes build status until completed or failed."""
|
||
|
|
import asyncio
|
||
|
|
|
||
|
|
slug = slugify(project)
|
||
|
|
elapsed = 0.0
|
||
|
|
interval = 5.0
|
||
|
|
last_log = ""
|
||
|
|
|
||
|
|
while elapsed < timeout:
|
||
|
|
st = await get_build_status(slug)
|
||
|
|
status = str(st.get("status") or "unknown")
|
||
|
|
msg = str(st.get("message") or status)
|
||
|
|
if msg != last_log:
|
||
|
|
log_agent_event(
|
||
|
|
"webbuilder",
|
||
|
|
"website_build_progress",
|
||
|
|
msg[:255],
|
||
|
|
(st.get("log_tail") or "")[:4000],
|
||
|
|
channel=channel,
|
||
|
|
metadata={"project": slug, "status": status},
|
||
|
|
status="running" if status in ("running", "queued") else status,
|
||
|
|
)
|
||
|
|
last_log = msg
|
||
|
|
|
||
|
|
if status == "completed":
|
||
|
|
files = st.get("files") or []
|
||
|
|
preview = st.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
|
||
|
|
log_agent_event(
|
||
|
|
"webbuilder",
|
||
|
|
"website_build_done",
|
||
|
|
f"Website klaar: {slug}",
|
||
|
|
f"Bestanden: {', '.join(files[:8])}\nPreview: {preview}",
|
||
|
|
channel=channel,
|
||
|
|
metadata={
|
||
|
|
"project": slug,
|
||
|
|
"preview_url": preview,
|
||
|
|
"files": files,
|
||
|
|
"for_herman": True,
|
||
|
|
"source_agent": "webbuilder",
|
||
|
|
},
|
||
|
|
status="completed",
|
||
|
|
)
|
||
|
|
return {**st, "project": slug, "preview_url": preview}
|
||
|
|
|
||
|
|
if status == "failed":
|
||
|
|
detail = str(st.get("error") or st.get("message") or "Build mislukt")
|
||
|
|
log_agent_event(
|
||
|
|
"webbuilder",
|
||
|
|
"website_build_error",
|
||
|
|
f"Build mislukt: {slug}",
|
||
|
|
detail[:2000],
|
||
|
|
channel=channel,
|
||
|
|
metadata={"project": slug},
|
||
|
|
status="error",
|
||
|
|
)
|
||
|
|
raise RuntimeError(detail)
|
||
|
|
|
||
|
|
await asyncio.sleep(interval)
|
||
|
|
elapsed += interval
|
||
|
|
|
||
|
|
raise TimeoutError(f"Build timeout voor {slug} na {int(timeout)}s")
|
||
|
|
|
||
|
|
|
||
|
|
async def generate_from_message(raw: str, *, channel: str = "herman", wait: bool = True) -> dict[str, Any]:
|
||
|
|
started = await build_from_message(raw, channel=channel)
|
||
|
|
if wait:
|
||
|
|
finished = await poll_until_done(started["project"], channel=channel)
|
||
|
|
started.update(finished)
|
||
|
|
return started
|