"""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