decorators.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. # -*- coding: utf-8 -*-
  2. """
  3. Intent Dekoratoren.
  4. Dekoratoren zum Markieren von Plugin-Methoden als Intent-Handler.
  5. Verwendung:
  6. class WeatherPlugin(TrixyPlugin):
  7. @intent("current_weather", description="Aktuelles Wetter abfragen")
  8. @pattern("(wie ist|wie wird) [denn] {das|} wetter [draussen|gerade]")
  9. @pattern("wetter [in] {city}")
  10. @example("Wie ist das Wetter?", "Wie warm ist es?")
  11. async def handle_weather(self, data, city: str = "") -> IntentResult:
  12. ...
  13. Slot-Erkennung aus Funktionssignatur:
  14. - Parameter mit Default-Wert → optionaler Slot
  15. - Parameter ohne Default-Wert → erforderlicher Slot
  16. - 'self' und 'data' werden uebersprungen
  17. """
  18. import inspect
  19. from functools import wraps
  20. from typing import Any, Callable, TypeVar
  21. from trixy_core.nlp.intent_registry import IntentSlot
  22. # Type variable fuer generische Funktionssignaturen
  23. F = TypeVar("F", bound=Callable[..., Any])
  24. # Attributnamen fuer Metadaten
  25. INTENT_METADATA_ATTR = "_trixy_intent_metadata"
  26. _PATTERN_LIST_ATTR = "_trixy_patterns"
  27. _EXAMPLE_LIST_ATTR = "_trixy_examples"
  28. # Parameter die bei der Slot-Erkennung uebersprungen werden
  29. _SKIP_PARAMS = {"self", "data", "return", "kwargs", "args"}
  30. def pattern(pattern_str: str) -> Callable[[F], F]:
  31. """
  32. Dekorator zum Hinzufuegen eines Matching-Patterns zu einem Intent-Handler.
  33. Kann mehrfach angewendet werden (stackbar).
  34. Syntax:
  35. wort — Pflicht-Keyword
  36. [wort] — Optionales Wort
  37. [a|b|c] — Optionale Alternativen
  38. (a|b) — Pflicht-Alternativen
  39. {slot_name} — Slot-Extraktion
  40. {a|b|} — Inline-Alternativen (letztes leer = optional)
  41. Args:
  42. pattern_str: Pattern-String in Snips-aehnlicher Syntax
  43. Beispiel:
  44. @intent("current_weather")
  45. @pattern("(wie ist|wie wird) [denn] wetter")
  46. @pattern("wetter [in] {city}")
  47. async def handle_weather(self, data, city: str = ""):
  48. ...
  49. """
  50. def decorator(func: F) -> F:
  51. # Pattern-Liste an der Funktion sammeln (LIFO → umkehren bei Auslesen)
  52. patterns: list[str] = getattr(func, _PATTERN_LIST_ATTR, [])
  53. patterns.append(pattern_str)
  54. setattr(func, _PATTERN_LIST_ATTR, patterns)
  55. return func
  56. return decorator
  57. def example(*phrases: str) -> Callable[[F], F]:
  58. """
  59. Dekorator zum Hinzufuegen von Beispiel-Phrasen zu einem Intent-Handler.
  60. Kann mehrfach angewendet werden (stackbar).
  61. Args:
  62. *phrases: Beispiel-Saetze fuer den Intent
  63. Beispiel:
  64. @intent("current_weather")
  65. @example("Wie ist das Wetter?", "Wie warm ist es?")
  66. @example("Temperatur draussen")
  67. async def handle_weather(self, data):
  68. ...
  69. """
  70. def decorator(func: F) -> F:
  71. examples: list[str] = getattr(func, _EXAMPLE_LIST_ATTR, [])
  72. examples.extend(phrases)
  73. setattr(func, _EXAMPLE_LIST_ATTR, examples)
  74. return func
  75. return decorator
  76. def admin_only(func: F) -> F:
  77. """
  78. Dekorator der einen Intent als Admin-Only markiert.
  79. Admin-Intents werden nur ausgefuehrt wenn das Wakeword
  80. als Admin-Wakeword (system_command) erkannt wurde.
  81. Beispiel:
  82. @intent("fritzbox_status", description="Netzwerkstatus")
  83. @admin_only
  84. async def handle_status(self, data):
  85. ...
  86. """
  87. setattr(func, "_trixy_admin_only", True)
  88. return func
  89. def intent(
  90. name: str,
  91. *,
  92. slots: dict[str, type | IntentSlot] | None = None,
  93. examples: list[str] | None = None,
  94. patterns: list[str] | None = None,
  95. description: str = "",
  96. priority: int = 0,
  97. ) -> Callable[[F], F]:
  98. """
  99. Dekorator zum Markieren einer Methode als Intent-Handler.
  100. Der PluginManager entdeckt diese Methoden automatisch und
  101. registriert sie in der IntentRegistry.
  102. Slots werden automatisch aus der Funktionssignatur erkannt:
  103. - Parameter mit Default-Wert → optionaler Slot
  104. - Parameter ohne Default-Wert → erforderlicher Slot
  105. - 'self' und 'data' werden uebersprungen
  106. Explizit uebergebene slots ueberschreiben signatur-basierte.
  107. Args:
  108. name: Eindeutiger Intent-Name
  109. slots: Explizite Slot-Definitionen (ueberschreibt Signatur)
  110. examples: Beispiel-Phrasen (alternativ: @example Dekorator)
  111. patterns: Pattern-Strings (alternativ: @pattern Dekorator)
  112. description: Beschreibung des Intents
  113. priority: Prioritaet (hoeher = wird bevorzugt)
  114. Beispiel:
  115. @intent("current_weather", description="Wetter abfragen")
  116. @pattern("(wie ist|wie wird) wetter")
  117. @example("Wie ist das Wetter?")
  118. async def handle_weather(self, data, city: str = "") -> IntentResult:
  119. # city ist optionaler Slot (hat Default-Wert)
  120. ...
  121. """
  122. def decorator(func: F) -> F:
  123. # 1. Slots aus Funktionssignatur erkennen
  124. sig_slots = _extract_slots_from_signature(func)
  125. # 2. Explizite Slots normalisieren und mergen (haben Vorrang)
  126. normalized_slots: dict[str, IntentSlot] = dict(sig_slots)
  127. if slots:
  128. for slot_name, slot_value in slots.items():
  129. if isinstance(slot_value, IntentSlot):
  130. normalized_slots[slot_name] = slot_value
  131. elif isinstance(slot_value, type):
  132. normalized_slots[slot_name] = IntentSlot(
  133. name=slot_name,
  134. slot_type=slot_value,
  135. )
  136. else:
  137. normalized_slots[slot_name] = IntentSlot(
  138. name=slot_name,
  139. slot_type=str,
  140. )
  141. # 3. Patterns aus @pattern Dekoratoren sammeln + explizite
  142. all_patterns: list[str] = list(patterns or [])
  143. deco_patterns: list[str] = getattr(func, _PATTERN_LIST_ATTR, [])
  144. if deco_patterns:
  145. all_patterns.extend(deco_patterns)
  146. # 4. Examples aus @example Dekoratoren sammeln + explizite
  147. all_examples: list[str] = list(examples or [])
  148. deco_examples: list[str] = getattr(func, _EXAMPLE_LIST_ATTR, [])
  149. if deco_examples:
  150. all_examples.extend(deco_examples)
  151. # 5. Admin-Only Flag von @admin_only Dekorator uebernehmen
  152. is_admin = getattr(func, "_trixy_admin_only", False)
  153. # 6. Metadaten an Funktion anhaengen
  154. metadata = {
  155. "name": name,
  156. "slots": normalized_slots,
  157. "examples": all_examples,
  158. "patterns": all_patterns,
  159. "description": description,
  160. "priority": priority,
  161. "admin_only": is_admin,
  162. }
  163. setattr(func, INTENT_METADATA_ATTR, metadata)
  164. @wraps(func)
  165. async def wrapper(*args: Any, **kwargs: Any) -> Any:
  166. return await func(*args, **kwargs)
  167. # Metadaten auch auf Wrapper uebertragen
  168. setattr(wrapper, INTENT_METADATA_ATTR, metadata)
  169. return wrapper # type: ignore
  170. return decorator
  171. def _extract_slots_from_signature(func: Callable[..., Any]) -> dict[str, IntentSlot]:
  172. """
  173. Extrahiert Slot-Definitionen aus der Funktionssignatur.
  174. Parameter mit Default-Wert → optionaler Slot.
  175. Parameter ohne Default-Wert → erforderlicher Slot.
  176. 'self', 'data', '*args', '**kwargs' werden uebersprungen.
  177. Args:
  178. func: Die zu analysierende Funktion
  179. Returns:
  180. Dict von Slot-Name → IntentSlot
  181. """
  182. slots: dict[str, IntentSlot] = {}
  183. try:
  184. sig = inspect.signature(func)
  185. except (ValueError, TypeError):
  186. return slots
  187. for param_name, param in sig.parameters.items():
  188. # Ueberspringe self, data, *args, **kwargs
  189. if param_name in _SKIP_PARAMS:
  190. continue
  191. if param.kind in (
  192. inspect.Parameter.VAR_POSITIONAL,
  193. inspect.Parameter.VAR_KEYWORD,
  194. ):
  195. continue
  196. # Typ bestimmen
  197. annotation = param.annotation
  198. if annotation is inspect.Parameter.empty:
  199. slot_type = str
  200. elif isinstance(annotation, type):
  201. slot_type = annotation
  202. else:
  203. slot_type = str
  204. # Required bestimmen: kein Default = required
  205. has_default = param.default is not inspect.Parameter.empty
  206. required = not has_default
  207. slots[param_name] = IntentSlot(
  208. name=param_name,
  209. slot_type=slot_type,
  210. required=required,
  211. )
  212. return slots
  213. def get_intent_metadata(func: Callable[..., Any]) -> dict[str, Any] | None:
  214. """
  215. Gibt die Intent-Metadaten einer Funktion zurueck.
  216. Args:
  217. func: Funktion zum Pruefen
  218. Returns:
  219. Metadaten-Dictionary oder None
  220. """
  221. return getattr(func, INTENT_METADATA_ATTR, None)
  222. def is_intent_handler(func: Callable[..., Any]) -> bool:
  223. """
  224. Prueft ob eine Funktion ein Intent-Handler ist.
  225. Args:
  226. func: Funktion zum Pruefen
  227. Returns:
  228. True wenn Intent-Handler
  229. """
  230. return hasattr(func, INTENT_METADATA_ATTR)
  231. def discover_intent_handlers(
  232. obj: object,
  233. ) -> list[tuple[str, Callable[..., Any], dict[str, Any]]]:
  234. """
  235. Entdeckt alle Intent-Handler in einem Objekt.
  236. Durchsucht alle Methoden des Objekts nach @intent Dekoratoren.
  237. Args:
  238. obj: Objekt zum Durchsuchen (z.B. Plugin-Instanz)
  239. Returns:
  240. Liste von (intent_name, handler_func, metadata) Tupeln
  241. """
  242. handlers = []
  243. for attr_name in dir(obj):
  244. if attr_name.startswith("_"):
  245. continue
  246. try:
  247. attr = getattr(obj, attr_name)
  248. except Exception:
  249. continue
  250. if not callable(attr):
  251. continue
  252. metadata = get_intent_metadata(attr)
  253. if metadata is not None:
  254. handlers.append((metadata["name"], attr, metadata))
  255. return handlers