"""Voice/browser/Telegram command router: export intel, webbuilder/agy, bevestiging + UI.""" from __future__ import annotations import re import time import uuid from typing import Any import httpx from app.config import settings from app.services import webbuilder_agent SESSION_TTL_SEC = 3600 _pending: dict[str, dict[str, Any]] = {} VOICE_CHANNELS = frozenset({"voice", "browser", "telegram"}) LOCATION_ALIASES: dict[str, dict[str, str]] = { "dubai": {"country": "AE", "q": "Dubai", "label": "Dubai (VAE)"}, "abu dhabi": {"country": "AE", "q": "Abu Dhabi", "label": "Abu Dhabi (VAE)"}, "sharjah": {"country": "AE", "q": "Sharjah", "label": "Sharjah (VAE)"}, "vae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"}, "uae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"}, "emiraten": {"country": "AE", "q": "", "label": "VAE"}, "saudi": {"country": "SA", "q": "", "label": "Saoedi-Arabië"}, "riyadh": {"country": "SA", "q": "Riyadh", "label": "Riyadh (SA)"}, "jeddah": {"country": "SA", "q": "Jeddah", "label": "Jeddah (SA)"}, "qatar": {"country": "QA", "q": "", "label": "Qatar"}, "doha": {"country": "QA", "q": "Doha", "label": "Doha (Qatar)"}, "kuwait": {"country": "KW", "q": "", "label": "Koeweit"}, "nederland": {"country": "NL", "q": "", "label": "Nederland"}, "amsterdam": {"country": "NL", "q": "Amsterdam", "label": "Amsterdam"}, "rotterdam": {"country": "NL", "q": "Rotterdam", "label": "Rotterdam"}, "belgië": {"country": "BE", "q": "", "label": "België"}, "belgie": {"country": "BE", "q": "", "label": "België"}, "antwerpen": {"country": "BE", "q": "Antwerp", "label": "Antwerpen"}, "duitsland": {"country": "DE", "q": "", "label": "Duitsland"}, "berlijn": {"country": "DE", "q": "Berlin", "label": "Berlijn"}, "frankfurt": {"country": "DE", "q": "Frankfurt", "label": "Frankfurt"}, "turkije": {"country": "TR", "q": "", "label": "Turkije"}, "istanbul": {"country": "TR", "q": "Istanbul", "label": "Istanbul"}, "marokko": {"country": "MA", "q": "", "label": "Marokko"}, "casablanca": {"country": "MA", "q": "Casablanca", "label": "Casablanca"}, } ENTITY_ALIASES: dict[str, str] = { "distri": "distributor,wholesaler,importer,logistics", "distributeur": "distributor,wholesaler,importer,logistics", "distributeurs": "distributor,wholesaler,importer,logistics", "groothandel": "wholesaler,distributor", "groothandels": "wholesaler,distributor", "importeur": "importer", "importeurs": "importer", "logistiek": "logistics", "restaurant": "restaurant", "restaurants": "restaurant", "cateraar": "caterer", "cateraars": "caterer", "slager": "butcher", "slagers": "butcher", "döner": "doner", "doner": "doner", } SEARCH_TRIGGERS = ( "zoek", "opzoeken", "vind", "zoeken", "toon", "laat zien", "lijst", "geef me", "haal op", "export intel", "wereldexport", ) YES_RE = re.compile( r"^(ja|jawel|jep|yep|ok|oke|oké|okay|klopt|bevestig|doe maar|graag|precies|goed|akkoord|start|uitvoeren|doorgaan)\b", re.I, ) NO_RE = re.compile(r"^(nee|neen|stop|annuleer|niet|cancel|laat maar|wacht)\b", re.I) def _cleanup_sessions() -> None: now = time.time() dead = [k for k, v in _pending.items() if now - float(v.get("_ts", 0)) > SESSION_TTL_SEC] for k in dead: _pending.pop(k, None) def is_confirmation_yes(text: str) -> bool: t = (text or "").strip() return bool(t and YES_RE.search(t)) def is_confirmation_no(text: str) -> bool: t = (text or "").strip() return bool(t and NO_RE.search(t)) def wants_export_search(text: str) -> bool: t = (text or "").lower() if not any(k in t for k in SEARCH_TRIGGERS): return False has_loc = any(alias in t for alias in LOCATION_ALIASES) has_ent = any(alias in t for alias in ENTITY_ALIASES) return has_loc or has_ent def _detect_location(text: str) -> dict[str, str] | None: t = text.lower() best: tuple[int, dict[str, str]] | None = None for alias, loc in LOCATION_ALIASES.items(): if alias in t: score = len(alias) if best is None or score > best[0]: best = (score, loc) return best[1] if best else None def _detect_entity_types(text: str) -> str: t = text.lower() found: list[str] = [] for alias, types in ENTITY_ALIASES.items(): if alias in t: for et in types.split(","): if et not in found: found.append(et) if not found and any(w in t for w in ("distri", "distributeur", "groothandel", "b2b", "leverancier")): return "distributor,wholesaler,importer,logistics" return ",".join(found) if found else "distributor,wholesaler,importer,logistics" def _entity_label(entity_types: str) -> str: labels = { "distributor": "distributeurs", "wholesaler": "groothandels", "importer": "importeurs", "logistics": "logistiek", "restaurant": "restaurants", "caterer": "cateraars", "butcher": "slagers", "doner": "dönerzaken", } parts = [labels.get(x.strip(), x.strip()) for x in entity_types.split(",") if x.strip()] return ", ".join(parts) if parts else "bedrijven" def parse_export_search(text: str) -> dict[str, Any]: loc = _detect_location(text) entity_types = _detect_entity_types(text) missing: list[str] = [] if not loc: missing.append("locatie") params: dict[str, Any] = { "country": (loc or {}).get("country", ""), "q": (loc or {}).get("q", ""), "entity_types": entity_types, "limit": 50, } label_loc = (loc or {}).get("label", "") return { "params": params, "location_label": label_loc, "entity_label": _entity_label(entity_types), "missing": missing, "incomplete": bool(missing), } def store_pending(session_id: str, action: dict[str, Any]) -> str: _cleanup_sessions() action_id = str(uuid.uuid4())[:12] action = dict(action) action["id"] = action_id action["_ts"] = time.time() _pending[session_id] = action return action_id def get_pending(session_id: str) -> dict[str, Any] | None: _cleanup_sessions() row = _pending.get(session_id) if not row: return None if time.time() - float(row.get("_ts", 0)) > SESSION_TTL_SEC: _pending.pop(session_id, None) return None return row def clear_pending(session_id: str) -> None: _pending.pop(session_id, None) def build_confirmation_question(parsed: dict[str, Any]) -> str: loc = parsed.get("location_label") or "de geselecteerde regio" ent = parsed.get("entity_label") or "bedrijven" return ( f"Ik ga **{ent}** in **{loc}** voor je opzoeken in Export Intel.\n\n" "Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren, " "of geef aan wat ik moet aanpassen (bijv. alleen distributeurs, of een andere stad)." ) def build_clarification_question(parsed: dict[str, Any]) -> str: missing = parsed.get("missing") or [] if "locatie" in missing: return ( "In welke **stad of welk land** wil je zoeken? " "Bijvoorbeeld: Dubai, VAE, Nederland, Frankfurt…" ) return "Kun je iets specifieker zijn over wat je zoekt en waar?" async def fetch_export_entities(params: dict[str, Any]) -> dict[str, Any]: query = {k: v for k, v in params.items() if v not in (None, "")} url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/entities" async with httpx.AsyncClient(timeout=60.0) as client: resp = await client.get(url, params=query) resp.raise_for_status() return resp.json() def _open_url(params: dict[str, Any]) -> str: qs = [] if params.get("country"): qs.append(f"country={params['country']}") if params.get("q"): qs.append(f"q={params['q']}") qs.append("tab=distributors") return "/export-intel?" + "&".join(qs) async def execute_search_action(action: dict[str, Any]) -> dict[str, Any]: params = action.get("params") or {} data = await fetch_export_entities(params) items = data.get("items") or [] total = int(data.get("total") or len(items)) loc = action.get("location_label") or params.get("q") or params.get("country") or "markt" ent = action.get("entity_label") or "bedrijven" title = f"{ent.title()} — {loc}" if not items: reply = ( f"Ik heb gezocht maar vond **geen** {ent} in {loc}. " "Wil je dat ik een sync start of een bredere regio probeer?" ) else: reply = ( f"Gevonden: **{total}** {ent} in {loc}. " f"Ik toon de eerste {min(len(items), 50)} in het resultatenvenster." ) return { "agent": "sourcing", "agent_label": "Export Intel → Herman", "reply": reply, "delegated_agents": ["sourcing", "export_intel"], "routing_reason": f"Export Intel zoekopdracht: {title}", "agent_steps": [ {"agent": "export_intel", "status": "done", "message": f"{total} resultaten"}, {"agent": "sourcing", "status": "delegated", "message": "Marktdata opgehaald"}, ], "needs_confirmation": False, "ui_actions": [ { "type": "show_export_results", "title": title, "entities": items[:50], "total": total, "params": params, "open_url": _open_url(params), } ], } def _pending_summary(action: dict[str, Any]) -> dict[str, Any]: atype = action.get("type", "") base = {"id": action.get("id"), "type": atype} if atype == "export_intel_search": base["location_label"] = action.get("location_label") base["entity_label"] = action.get("entity_label") base["params"] = action.get("params") elif atype == "webbuilder_build": base["project"] = action.get("project") base["entity_label"] = f"website {action.get('project', '')}" base["location_label"] = "Agy · Antigravity" return base def build_webbuilder_confirmation(project: str, raw: str) -> str: snippet = (raw or "")[:240] return ( f"Ik stuur **Agy (Antigravity)** op Hermes aan om een website te bouwen.\n\n" f"**Project:** {project}\n" f"**Opdracht:** {snippet}{'…' if len(raw or '') > 240 else ''}\n\n" "Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren." ) async def execute_webbuilder_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]: raw = action.get("raw_message") or "" project = action.get("project") or webbuilder_agent.extract_project_name(raw) try: outcome = await webbuilder_agent.generate_from_message(raw, channel=channel, wait=False) except Exception as exc: return { "agent": "webbuilder", "agent_label": "Web Builder", "reply": f"Agy kon niet starten op Hermes: {exc}\n\nControleer VM107 webbuilder-api (:8798).", "delegated_agents": ["webbuilder"], "needs_confirmation": False, } project = outcome.get("project") or project preview = outcome.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE nas_path = outcome.get("nas_path") or "" reply = ( f"Agy is gestart voor **{project}**.\n" f"Preview (na build): {preview}\n" f"NAS: {nas_path}\n" "Volg live: Agents → Terminals → Web Builder." ) return { "agent": "webbuilder", "agent_label": "Agy / Web Builder → Herman", "reply": reply, "delegated_agents": ["webbuilder"], "routing_reason": "Website via Antigravity CLI (agy) op Hermes VM107", "agent_steps": [ {"agent": "webbuilder", "status": "running", "message": f"agy bouwt {project}"}, {"agent": "herman", "status": "delegated", "message": "Voice → build gestart"}, ], "webbuilder_project": project, "webbuilder_preview_url": preview, "needs_confirmation": False, "ui_actions": [ { "type": "open_webbuilder_build", "title": f"Website build — {project}", "project": project, "preview_url": preview, "nas_path": nas_path, "agents_url": "/agents", "status_hint": "Build duurt enkele minuten — preview opent na afloop", } ], } async def execute_pending_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]: atype = action.get("type") if atype == "webbuilder_build": return await execute_webbuilder_action(action, channel=channel) return await execute_search_action(action) def _confirmation_reminder(pending: dict[str, Any]) -> str: if pending.get("type") == "webbuilder_build": return build_webbuilder_confirmation(pending.get("project", "website"), pending.get("raw_message", "")) return build_confirmation_question(pending) async def handle_voice_command( message: str, session_id: str | None = None, confirm_action_id: str | None = None, channel: str = "voice", ) -> dict[str, Any] | None: """Unified voice/browser router: bevestiging + export + webbuilder/agy.""" sid = (session_id or "").strip() or "default" text = (message or "").strip() if not text: return None pending = get_pending(sid) if confirm_action_id and pending and pending.get("id") == confirm_action_id: clear_pending(sid) return await execute_pending_action(pending, channel=channel) if pending: if is_confirmation_yes(text): clear_pending(sid) return await execute_pending_action(pending, channel=channel) if is_confirmation_no(text): clear_pending(sid) return { "agent": "herman", "agent_label": "Herman", "reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?", "needs_confirmation": False, } if pending.get("awaiting") == "location" and pending.get("type") == "export_intel_search": loc = _detect_location(text) if loc: merged = dict(pending) merged["params"] = dict(pending.get("params") or {}) merged["params"]["country"] = loc["country"] merged["params"]["q"] = loc.get("q", "") merged["location_label"] = loc["label"] merged.pop("awaiting", None) merged.pop("incomplete", None) merged.pop("missing", None) action_id = store_pending(sid, merged) return { "agent": "herman", "agent_label": "Herman", "reply": build_confirmation_question(merged), "needs_confirmation": True, "pending_action": _pending_summary(merged), } return { "agent": "herman", "agent_label": "Herman", "reply": build_clarification_question({"missing": ["locatie"]}), "needs_confirmation": True, "clarification": True, } if webbuilder_agent.wants_website(text): project = webbuilder_agent.extract_project_name(text) action_id = store_pending(sid, { "type": "webbuilder_build", "project": project, "raw_message": text, }) return { "agent": "herman", "agent_label": "Herman", "reply": build_webbuilder_confirmation(project, text), "needs_confirmation": True, "pending_action": _pending_summary(get_pending(sid) or {}), } if wants_export_search(text): parsed = parse_export_search(text) if not parsed.get("incomplete"): action_id = store_pending(sid, {**parsed, "type": "export_intel_search"}) return { "agent": "herman", "agent_label": "Herman", "reply": build_confirmation_question(parsed), "needs_confirmation": True, "pending_action": _pending_summary(get_pending(sid) or {}), } return { "agent": "herman", "agent_label": "Herman", "reply": ( f"Ik wacht nog op bevestiging.\n\n{_confirmation_reminder(pending)}\n\n" "Zeg **ja** of **nee**." ), "needs_confirmation": True, "pending_action": _pending_summary(pending), } if webbuilder_agent.wants_website(text): project = webbuilder_agent.extract_project_name(text) store_pending(sid, {"type": "webbuilder_build", "project": project, "raw_message": text}) pa = get_pending(sid) or {} return { "agent": "herman", "agent_label": "Herman", "reply": build_webbuilder_confirmation(project, text), "needs_confirmation": True, "pending_action": _pending_summary(pa), } return await handle_export_intent(message, session_id=session_id, confirm_action_id=confirm_action_id) async def handle_export_intent( message: str, session_id: str | None = None, confirm_action_id: str | None = None, ) -> dict[str, Any] | None: """Return Herman-shaped dict when export flow applies, else None.""" sid = (session_id or "").strip() or "default" text = (message or "").strip() if not text: return None pending = get_pending(sid) if confirm_action_id and pending and pending.get("id") == confirm_action_id: clear_pending(sid) return await execute_pending_action(pending) if pending: if is_confirmation_yes(text): clear_pending(sid) return await execute_pending_action(pending) if is_confirmation_no(text): clear_pending(sid) return { "agent": "herman", "agent_label": "Herman", "reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?", "needs_confirmation": False, } if pending.get("awaiting") == "location": loc = _detect_location(text) if loc: merged = dict(pending) merged["params"] = dict(pending.get("params") or {}) merged["params"]["country"] = loc["country"] merged["params"]["q"] = loc.get("q", "") merged["location_label"] = loc["label"] merged.pop("awaiting", None) merged.pop("incomplete", None) merged.pop("missing", None) action_id = store_pending(sid, merged) return { "agent": "herman", "agent_label": "Herman", "reply": build_confirmation_question(merged), "needs_confirmation": True, "pending_action": { "id": action_id, "type": "export_intel_search", "params": merged.get("params"), "location_label": merged.get("location_label"), "entity_label": merged.get("entity_label"), }, } return { "agent": "herman", "agent_label": "Herman", "reply": build_clarification_question({"missing": ["locatie"]}), "needs_confirmation": True, "clarification": True, } if wants_export_search(text): parsed = parse_export_search(text) if parsed.get("incomplete"): return { "agent": "herman", "agent_label": "Herman", "reply": build_clarification_question(parsed), "needs_confirmation": True, "clarification": True, } action_id = store_pending(sid, parsed) return { "agent": "herman", "agent_label": "Herman", "reply": build_confirmation_question(parsed), "needs_confirmation": True, "pending_action": { "id": action_id, "type": "export_intel_search", "params": parsed.get("params"), "location_label": parsed.get("location_label"), "entity_label": parsed.get("entity_label"), }, } return { "agent": "herman", "agent_label": "Herman", "reply": ( f"Ik wacht nog op bevestiging: {build_confirmation_question(pending)}\n\n" "Zeg **ja** of **nee**, of stel een nieuwe zoekopdracht." ), "needs_confirmation": True, "pending_action": { "id": pending.get("id"), "type": pending.get("type", "export_intel_search"), "params": pending.get("params"), "location_label": pending.get("location_label"), "entity_label": pending.get("entity_label"), }, } if not wants_export_search(text): return None parsed = parse_export_search(text) if parsed.get("incomplete"): store_pending(sid, {**parsed, "type": "export_intel_search", "awaiting": "location"}) return { "agent": "herman", "agent_label": "Herman", "reply": build_clarification_question(parsed), "needs_confirmation": True, "clarification": True, } action_id = store_pending(sid, {**parsed, "type": "export_intel_search"}) return { "agent": "herman", "agent_label": "Herman", "reply": build_confirmation_question(parsed), "needs_confirmation": True, "pending_action": { "id": action_id, "type": "export_intel_search", "params": parsed.get("params"), "location_label": parsed.get("location_label"), "entity_label": parsed.get("entity_label"), }, }