main.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. # -*- coding: utf-8 -*-
  2. """
  3. Pizza-Bestell Plugin (Beispiel fuer Follow-Up Dialoge).
  4. Demonstriert:
  5. - Multi-Slot-Extraktion (Groesse, Typ, individuelle Belaege)
  6. - Follow-Up Rueckfragen mit Validierung
  7. - Mehrstufige Dialoge (Groesse → Belag → Bestaetigung)
  8. - Mehrere Pizzen in einer Bestellung
  9. - Teilweise Angaben ("grosse Pizza" → nur Belag fehlt)
  10. - Vollstaendige Angaben ("grosse Salami Pizza" → direkt Zusammenfassung)
  11. Beispiel-Dialoge:
  12. User: "Bestell mir eine Pizza"
  13. Trixy: "Welche Groesse? Klein, mittel, gross oder familien?"
  14. User: "Gross"
  15. Trixy: "Welchen Belag? Salami, Hawaii, Margherita, ... oder individuelle Belaege?"
  16. User: "Salami, Pepperoni und extra Kaese"
  17. Trixy: "Eine grosse Pizza mit Salami, Pepperoni und extra Kaese. Noch eine Pizza oder bestellen?"
  18. User: "Noch eine kleine Margherita"
  19. Trixy: "Eine grosse Pizza mit Salami, Pepperoni, extra Kaese und eine kleine Margherita. Bestellen?"
  20. User: "Ja"
  21. Trixy: "Bestellung aufgegeben: 2 Pizzen. Guten Appetit!"
  22. """
  23. from __future__ import annotations
  24. from pathlib import Path
  25. from typing import Any
  26. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  27. from trixy_core.nlp.decorators import intent, pattern, example
  28. from trixy_core.nlp.intent_registry import IntentRegistry
  29. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  30. from trixy_core.utils.debug import pdebug, pinfo
  31. class PizzaOrderPlugin(TrixyPlugin):
  32. """Pizza-Bestell Plugin mit Follow-Up Dialogen."""
  33. NAME = "pizza_order"
  34. VERSION = "1.0.0"
  35. DESCRIPTION = "Pizza bestellen mit Rueckfragen (Beispiel-Plugin)"
  36. AUTHOR = "Trixy"
  37. def __init__(self, application, plugin_path, config=None) -> None:
  38. super().__init__(application, plugin_path, config)
  39. self._registry = IntentRegistry.get_instance()
  40. # Aktive Bestellungen pro Satellite
  41. self._orders: dict[str, list[dict]] = {} # satellite_id → [pizza, pizza, ...]
  42. self._current_pizza: dict[str, dict] = {} # satellite_id → aktuelle Pizza
  43. async def on_load(self) -> None:
  44. pinfo(f"PizzaOrderPlugin: Lade v{self.VERSION}")
  45. async def on_unload(self) -> None:
  46. self._registry.unregister_plugin(self.NAME)
  47. self._orders.clear()
  48. self._current_pizza.clear()
  49. # === Hilfsmethoden ===
  50. def _get_sizes(self) -> list[str]:
  51. return self.get_config_value("sizes", ["klein", "mittel", "gross", "familien"])
  52. def _get_pizza_types(self) -> dict[str, list[str]]:
  53. return self.get_config_value("pizza_types", {})
  54. def _get_toppings(self) -> list[str]:
  55. return self.get_config_value("available_toppings", [])
  56. def _extract_size(self, text: str) -> str | None:
  57. """Extrahiert Groesse aus dem Text."""
  58. text_lower = text.lower()
  59. for size in self._get_sizes():
  60. if size in text_lower:
  61. return size
  62. # Synonyme
  63. if "mittelgross" in text_lower or "normal" in text_lower:
  64. return "mittel"
  65. if "riesig" in text_lower or "xxl" in text_lower:
  66. return "familien"
  67. return None
  68. def _extract_pizza_type(self, text: str) -> str | None:
  69. """Extrahiert Pizza-Typ aus dem Text."""
  70. text_lower = text.lower()
  71. for name in self._get_pizza_types():
  72. if name.lower() in text_lower:
  73. return name
  74. return None
  75. def _extract_toppings(self, text: str) -> list[str]:
  76. """Extrahiert individuelle Belaege aus dem Text."""
  77. text_lower = text.lower()
  78. found = []
  79. for topping in self._get_toppings():
  80. if topping.lower() in text_lower:
  81. found.append(topping)
  82. return found
  83. def _format_pizza(self, pizza: dict) -> str:
  84. """Formatiert eine Pizza als lesbaren Text."""
  85. size = pizza.get("size", "mittel")
  86. parts = [f"eine {size}"]
  87. pizza_type = pizza.get("type")
  88. toppings = pizza.get("toppings", [])
  89. if pizza_type:
  90. parts.append(f"Pizza {pizza_type}")
  91. elif toppings:
  92. parts.append(f"Pizza mit {self._join_list(toppings)}")
  93. else:
  94. parts.append("Pizza")
  95. return " ".join(parts)
  96. def _format_order(self, satellite_id: str) -> str:
  97. """Formatiert die gesamte Bestellung."""
  98. orders = self._orders.get(satellite_id, [])
  99. if not orders:
  100. return "Keine Bestellung"
  101. parts = [self._format_pizza(p) for p in orders]
  102. return self._join_list(parts)
  103. @staticmethod
  104. def _join_list(items: list[str]) -> str:
  105. """Verbindet Liste mit Komma und 'und'."""
  106. if not items:
  107. return ""
  108. if len(items) == 1:
  109. return items[0]
  110. return ", ".join(items[:-1]) + " und " + items[-1]
  111. def _add_pizza_to_order(self, satellite_id: str, pizza: dict) -> None:
  112. """Fuegt eine Pizza zur Bestellung hinzu."""
  113. if satellite_id not in self._orders:
  114. self._orders[satellite_id] = []
  115. self._orders[satellite_id].append(pizza)
  116. # === Intent-Handler ===
  117. @intent("pizza_order", description="Pizza bestellen")
  118. @example("Bestell mir eine Pizza", "Bestelle eine grosse Salami")
  119. @example("Eine Pizza mit Salami und Pilzen bitte")
  120. async def handle_pizza_order(self, data: IntentReceivedData) -> IntentResult:
  121. """Startet eine Pizza-Bestellung."""
  122. text = data.original_text
  123. sat_id = data.satellite_id
  124. # Vorherige Bestellung zuruecksetzen
  125. self._orders[sat_id] = []
  126. self._current_pizza[sat_id] = {}
  127. # Versuche so viel wie moeglich direkt zu extrahieren
  128. size = self._extract_size(text)
  129. pizza_type = self._extract_pizza_type(text)
  130. toppings = self._extract_toppings(text)
  131. pizza: dict[str, Any] = {}
  132. if size:
  133. pizza["size"] = size
  134. if pizza_type:
  135. pizza["type"] = pizza_type
  136. pizza["toppings"] = self._get_pizza_types()[pizza_type]
  137. elif toppings:
  138. pizza["toppings"] = toppings
  139. self._current_pizza[sat_id] = pizza
  140. # Was fehlt noch?
  141. if not pizza.get("size"):
  142. sizes = self._join_list(self._get_sizes())
  143. return IntentResult.follow_up(
  144. f"Welche Groesse? {sizes}?",
  145. follow_up_intent="pizza_select_size",
  146. valid_responses=self._get_sizes() + ["normal", "mittelgross", "xxl", "riesig"],
  147. retry_text=f"Bitte waehle eine Groesse: {sizes}.",
  148. order=pizza,
  149. )
  150. if not pizza.get("type") and not pizza.get("toppings"):
  151. types = self._join_list(list(self._get_pizza_types().keys()))
  152. return IntentResult.follow_up(
  153. f"Welchen Belag? {types}, oder nenne individuelle Belaege.",
  154. follow_up_intent="pizza_select_topping",
  155. # Kein valid_responses — akzeptiert alles (individuelle Belaege)
  156. order=pizza,
  157. )
  158. # Alles vorhanden — zusammenfassen
  159. if not pizza.get("size"):
  160. pizza["size"] = self.get_config_value("default_size", "mittel")
  161. self._add_pizza_to_order(sat_id, pizza)
  162. self._current_pizza[sat_id] = {}
  163. summary = self._format_order(sat_id)
  164. return IntentResult.follow_up(
  165. f"{summary}. Noch eine Pizza dazu, oder bestellen?",
  166. follow_up_intent="pizza_more_or_confirm",
  167. )
  168. @intent("pizza_select_size", description="Groesse waehlen (Follow-Up)")
  169. async def handle_select_size(self, data: IntentReceivedData) -> IntentResult:
  170. """Follow-Up: Groesse wurde gewaehlt."""
  171. sat_id = data.satellite_id
  172. text = data.original_text
  173. pizza = self._current_pizza.get(sat_id, {})
  174. size = self._extract_size(text)
  175. if not size:
  176. # Matched value aus Follow-Up Validierung
  177. matched = data.slots.get("matched_value", "")
  178. size = matched if matched else self.get_config_value("default_size", "mittel")
  179. pizza["size"] = size
  180. self._current_pizza[sat_id] = pizza
  181. # Belag schon vorhanden?
  182. if pizza.get("type") or pizza.get("toppings"):
  183. self._add_pizza_to_order(sat_id, pizza)
  184. self._current_pizza[sat_id] = {}
  185. summary = self._format_order(sat_id)
  186. return IntentResult.follow_up(
  187. f"{summary}. Noch eine Pizza, oder bestellen?",
  188. follow_up_intent="pizza_more_or_confirm",
  189. )
  190. # Belag fehlt noch
  191. types = self._join_list(list(self._get_pizza_types().keys()))
  192. return IntentResult.follow_up(
  193. f"Welchen Belag? {types}, oder nenne individuelle Belaege.",
  194. follow_up_intent="pizza_select_topping",
  195. )
  196. @intent("pizza_select_topping", description="Belag waehlen (Follow-Up)")
  197. async def handle_select_topping(self, data: IntentReceivedData) -> IntentResult:
  198. """Follow-Up: Belag wurde gewaehlt."""
  199. sat_id = data.satellite_id
  200. text = data.original_text
  201. pizza = self._current_pizza.get(sat_id, {})
  202. # Pizza-Typ oder individuelle Belaege?
  203. pizza_type = self._extract_pizza_type(text)
  204. toppings = self._extract_toppings(text)
  205. if pizza_type:
  206. pizza["type"] = pizza_type
  207. pizza["toppings"] = self._get_pizza_types()[pizza_type]
  208. elif toppings:
  209. pizza["toppings"] = toppings
  210. else:
  211. # Text direkt als Belag nehmen
  212. pizza["toppings"] = [text.strip()]
  213. if not pizza.get("size"):
  214. pizza["size"] = self.get_config_value("default_size", "mittel")
  215. self._add_pizza_to_order(sat_id, pizza)
  216. self._current_pizza[sat_id] = {}
  217. summary = self._format_order(sat_id)
  218. return IntentResult.follow_up(
  219. f"{summary}. Noch eine Pizza dazu, oder bestellen?",
  220. follow_up_intent="pizza_more_or_confirm",
  221. )
  222. @intent("pizza_more_or_confirm", description="Weitere Pizza oder bestellen (Follow-Up)")
  223. async def handle_more_or_confirm(self, data: IntentReceivedData) -> IntentResult:
  224. """Follow-Up: Noch eine Pizza oder bestellen?"""
  225. text = data.original_text.lower()
  226. # Abbruch?
  227. cancel_words = ["nein", "abbrechen", "stopp", "vergiss", "doch nicht", "stornieren"]
  228. if any(w in text for w in cancel_words):
  229. return self._cancel_order(data.satellite_id)
  230. # Bestaetigen?
  231. confirm_words = ["ja", "bestell", "genau", "passt", "richtig", "fertig", "abschicken"]
  232. if any(w in text for w in confirm_words):
  233. return self._confirm_order(data.satellite_id)
  234. # Noch eine Pizza?
  235. # Versuche eine neue Pizza aus dem Text zu extrahieren
  236. size = self._extract_size(text)
  237. pizza_type = self._extract_pizza_type(text)
  238. toppings = self._extract_toppings(text)
  239. if pizza_type or toppings or size:
  240. pizza: dict[str, Any] = {}
  241. if size:
  242. pizza["size"] = size
  243. if pizza_type:
  244. pizza["type"] = pizza_type
  245. pizza["toppings"] = self._get_pizza_types()[pizza_type]
  246. elif toppings:
  247. pizza["toppings"] = toppings
  248. if not pizza.get("size"):
  249. pizza["size"] = self.get_config_value("default_size", "mittel")
  250. self._add_pizza_to_order(data.satellite_id, pizza)
  251. summary = self._format_order(data.satellite_id)
  252. return IntentResult.follow_up(
  253. f"{summary}. Noch eine Pizza, oder bestellen?",
  254. follow_up_intent="pizza_more_or_confirm",
  255. )
  256. # Unverstaendlich
  257. return IntentResult.follow_up(
  258. "Moechtest du noch eine Pizza dazu, oder soll ich bestellen?",
  259. follow_up_intent="pizza_more_or_confirm",
  260. )
  261. @intent("pizza_add_more", description="Weitere Pizza hinzufuegen")
  262. @example("Und noch eine Salami dazu")
  263. async def handle_add_more(self, data: IntentReceivedData) -> IntentResult:
  264. """Fuegt eine weitere Pizza zur Bestellung hinzu."""
  265. text = data.original_text
  266. sat_id = data.satellite_id
  267. if sat_id not in self._orders:
  268. self._orders[sat_id] = []
  269. size = self._extract_size(text)
  270. pizza_type = self._extract_pizza_type(text)
  271. toppings = self._extract_toppings(text)
  272. pizza: dict[str, Any] = {"size": size or self.get_config_value("default_size", "mittel")}
  273. if pizza_type:
  274. pizza["type"] = pizza_type
  275. pizza["toppings"] = self._get_pizza_types()[pizza_type]
  276. elif toppings:
  277. pizza["toppings"] = toppings
  278. else:
  279. # Belag fehlt
  280. self._current_pizza[sat_id] = pizza
  281. types = self._join_list(list(self._get_pizza_types().keys()))
  282. return IntentResult.follow_up(
  283. f"Welchen Belag fuer die naechste Pizza? {types}?",
  284. follow_up_intent="pizza_select_topping",
  285. )
  286. self._add_pizza_to_order(sat_id, pizza)
  287. summary = self._format_order(sat_id)
  288. return IntentResult.follow_up(
  289. f"{summary}. Noch eine Pizza, oder bestellen?",
  290. follow_up_intent="pizza_more_or_confirm",
  291. )
  292. @intent("pizza_confirm", description="Bestellung bestaetigen")
  293. @example("Ja bestellen", "Passt so")
  294. async def handle_confirm(self, data: IntentReceivedData) -> IntentResult:
  295. """Bestaetigt die Bestellung."""
  296. return self._confirm_order(data.satellite_id)
  297. @intent("pizza_cancel", description="Bestellung abbrechen")
  298. @example("Abbrechen", "Vergiss es")
  299. async def handle_cancel(self, data: IntentReceivedData) -> IntentResult:
  300. """Bricht die Bestellung ab."""
  301. return self._cancel_order(data.satellite_id)
  302. # === Bestell-Logik ===
  303. def _confirm_order(self, satellite_id: str) -> IntentResult:
  304. """Gibt die Bestell-Zusammenfassung."""
  305. orders = self._orders.get(satellite_id, [])
  306. if not orders:
  307. return IntentResult.success_with_response(
  308. "Es gibt keine offene Bestellung."
  309. )
  310. count = len(orders)
  311. summary = self._format_order(satellite_id)
  312. # Bestellung "abschicken" (in diesem Beispiel nur Zusammenfassung)
  313. pinfo(f"PizzaOrder: Bestellung bestaetigt — {count} Pizza(s) fuer {satellite_id}")
  314. # Aufraeumen
  315. self._orders.pop(satellite_id, None)
  316. self._current_pizza.pop(satellite_id, None)
  317. return IntentResult.success_with_response(
  318. f"Bestellung aufgegeben: {summary}. "
  319. f"Das {'ist' if count == 1 else 'sind'} {count} "
  320. f"{'Pizza' if count == 1 else 'Pizzen'}. Guten Appetit!"
  321. )
  322. def _cancel_order(self, satellite_id: str) -> IntentResult:
  323. """Bricht die Bestellung ab."""
  324. self._orders.pop(satellite_id, None)
  325. self._current_pizza.pop(satellite_id, None)
  326. return IntentResult.success_with_response("Bestellung abgebrochen.")