Files

595 lines
22 KiB
Python

"""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"),
},
}