main.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. # -*- coding: utf-8 -*-
  2. """
  3. Trixy Mathe-Plugin.
  4. Verarbeitet mathematische Ausdruecke in natuerlicher Sprache.
  5. Unterstuetzt Grundrechenarten, Potenzen, Wurzeln, Trigonometrie,
  6. Konstanten und verschachtelte Ausdruecke.
  7. Beispiele:
  8. "Was ist 1 + 1"
  9. "Was ist die Quadratwurzel aus 9"
  10. "Berechne 5 hoch 3"
  11. "Was ist pi mal 2"
  12. "Was ist die Quersumme von 12345"
  13. "Was ist 30 Prozent von 250"
  14. "Rechne 100 Dollar in Euro um"
  15. """
  16. from __future__ import annotations
  17. import math
  18. import re
  19. from typing import Any
  20. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  21. from trixy_core.utils.debug import pinfo, pdebug
  22. from trixy_core.nlp.decorators import intent
  23. from trixy_core.nlp.handler import IntentResult, IntentReceivedData
  24. # ============================================================================
  25. # Woerter → Zahlen / Operatoren
  26. # ============================================================================
  27. WORD_NUMBERS: dict[str, float] = {
  28. "null": 0, "eins": 1, "zwei": 2, "drei": 3, "vier": 4,
  29. "fünf": 5, "fuenf": 5, "sechs": 6, "sieben": 7, "acht": 8,
  30. "neun": 9, "zehn": 10, "elf": 11, "zwölf": 12, "zwoelf": 12,
  31. "dreizehn": 13, "vierzehn": 14, "fünfzehn": 15, "fuenfzehn": 15,
  32. "sechzehn": 16, "siebzehn": 17, "achtzehn": 18, "neunzehn": 19,
  33. "zwanzig": 20, "dreißig": 30, "dreissig": 30, "vierzig": 40,
  34. "fünfzig": 50, "fuenfzig": 50, "sechzig": 60, "siebzig": 70,
  35. "achtzig": 80, "neunzig": 90, "hundert": 100, "tausend": 1000,
  36. "million": 1_000_000, "milliarde": 1_000_000_000,
  37. "eine": 1, "ein": 1,
  38. "pi": math.pi, "e": math.e,
  39. "halbe": 0.5, "halb": 0.5, "viertel": 0.25, "drittel": 1/3,
  40. }
  41. OPERATOR_WORDS: dict[str, str] = {
  42. "plus": "+", "und": "+",
  43. "minus": "-", "weniger": "-",
  44. "mal": "*", "multipliziert": "*", "multipliziert mit": "*",
  45. "geteilt": "/", "geteilt durch": "/", "durch": "/", "dividiert": "/",
  46. "hoch": "**", "potenz": "**",
  47. "modulo": "%", "mod": "%", "rest": "%",
  48. }
  49. CONSTANTS: dict[str, float] = {
  50. "pi": math.pi,
  51. "e": math.e,
  52. "tau": math.tau,
  53. "phi": (1 + math.sqrt(5)) / 2, # Goldener Schnitt
  54. }
  55. # ============================================================================
  56. # Natuerlichsprachiger Mathe-Parser
  57. # ============================================================================
  58. class MathParser:
  59. """Parst mathematische Ausdruecke aus natuerlicher Sprache."""
  60. def parse_and_evaluate(self, text: str) -> tuple[float, str]:
  61. """Parst und berechnet einen mathematischen Ausdruck.
  62. Args:
  63. text: Natuerlichsprachiger Ausdruck
  64. Returns:
  65. (ergebnis, beschreibung_des_ausdruecks)
  66. Raises:
  67. ValueError: Wenn der Ausdruck nicht geparst werden kann
  68. """
  69. text = text.strip().lower()
  70. # Wenn "wobei" vorkommt → zusammengesetzter Ausdruck, direkt als Expression
  71. if "wobei" in text:
  72. expr = self._text_to_expression(text)
  73. if expr:
  74. value = self._safe_eval(expr)
  75. return (value, expr)
  76. # Spezialfunktionen pruefen
  77. result = self._try_special_functions(text)
  78. if result is not None:
  79. return result
  80. # Text in mathematischen Ausdruck umwandeln
  81. expr = self._text_to_expression(text)
  82. if not expr:
  83. raise ValueError("Konnte keinen mathematischen Ausdruck erkennen")
  84. # Sicher auswerten
  85. value = self._safe_eval(expr)
  86. return (value, expr)
  87. def _try_special_functions(self, text: str) -> tuple[float, str] | None:
  88. """Prueft auf Spezialfunktionen (Wurzel, Quersumme, Prozent, etc.)."""
  89. # n-te Wurzel (VOR Quadratwurzel pruefen!)
  90. m = re.search(r"(\d+)(?:te|\.)\s*wurzel\s+(?:aus\s+|von\s+)?(.+)", text)
  91. if m:
  92. n = int(m.group(1))
  93. inner = self._parse_number_expr(m.group(2))
  94. result = inner ** (1 / n)
  95. return (result, f"{n}. Wurzel aus {inner}")
  96. # Quadratwurzel (nach n-ter Wurzel!)
  97. m = re.search(r"(?:quadrat)?wurzel\s+(?:aus\s+|von\s+)?(.+)", text)
  98. if m:
  99. inner = self._parse_number_expr(m.group(1))
  100. result = math.sqrt(inner)
  101. return (result, f"√({inner})")
  102. # Quersumme
  103. m = re.search(r"quersumme\s+(?:aus\s+|von\s+)?(\d+)", text)
  104. if m:
  105. digits = m.group(1)
  106. result = sum(int(d) for d in digits)
  107. return (float(result), f"Quersumme({digits})")
  108. # Fakultaet
  109. m = re.search(r"fakult(?:ä|ae)t\s+(?:von\s+)?(\d+)", text)
  110. if m:
  111. n = int(m.group(1))
  112. if n > 170:
  113. raise ValueError("Fakultaet zu gross (max 170)")
  114. result = math.factorial(n)
  115. return (float(result), f"{n}!")
  116. # Prozent von
  117. m = re.search(r"(\d+(?:[.,]\d+)?)\s*prozent\s+von\s+(.+)", text)
  118. if m:
  119. pct = float(m.group(1).replace(",", "."))
  120. base = self._parse_number_expr(m.group(2))
  121. result = base * pct / 100
  122. return (result, f"{pct}% von {base}")
  123. # Sinus/Cosinus/Tangens
  124. for func_name, func in [
  125. ("sinus", math.sin), ("sin", math.sin),
  126. ("cosinus", math.cos), ("cos", math.cos),
  127. ("tangens", math.tan), ("tan", math.tan),
  128. ]:
  129. m = re.search(rf"{func_name}\s+(?:von\s+)?(.+)", text)
  130. if m:
  131. inner = self._parse_number_expr(m.group(1))
  132. # Grad in Radiant
  133. rad = math.radians(inner)
  134. result = func(rad)
  135. return (result, f"{func_name}({inner}°)")
  136. # Logarithmus
  137. m = re.search(r"(?:natuerlicher\s+)?logarithmus\s+(?:von\s+)?(.+)", text)
  138. if m:
  139. inner = self._parse_number_expr(m.group(1))
  140. result = math.log(inner)
  141. return (result, f"ln({inner})")
  142. m = re.search(r"log(?:arithmus)?\s+(?:basis\s+)?(\d+)\s+(?:von\s+)?(.+)", text)
  143. if m:
  144. base = int(m.group(1))
  145. inner = self._parse_number_expr(m.group(2))
  146. result = math.log(inner, base)
  147. return (result, f"log{base}({inner})")
  148. # Absoluter Betrag
  149. m = re.search(r"(?:absoluter?\s+)?betrag\s+(?:von\s+)?(.+)", text)
  150. if m:
  151. inner = self._parse_number_expr(m.group(1))
  152. result = abs(inner)
  153. return (result, f"|{inner}|")
  154. # Runden
  155. m = re.search(r"runde\s+(.+?)(?:\s+auf\s+(\d+)\s+(?:stellen|dezimal))?$", text)
  156. if m:
  157. inner = self._parse_number_expr(m.group(1))
  158. digits = int(m.group(2)) if m.group(2) else 0
  159. result = round(inner, digits)
  160. return (result, f"round({inner}, {digits})")
  161. return None
  162. def _parse_number_expr(self, text: str) -> float:
  163. """Parst eine Zahl oder einen einfachen Ausdruck aus Text."""
  164. text = text.strip().rstrip(".")
  165. # Konstanten
  166. for name, val in CONSTANTS.items():
  167. if text == name:
  168. return val
  169. # Wort-Zahlen
  170. if text in WORD_NUMBERS:
  171. return WORD_NUMBERS[text]
  172. # Direkte Zahl
  173. try:
  174. return float(text.replace(",", "."))
  175. except ValueError:
  176. pass
  177. # Ausdruck parsen und auswerten
  178. expr = self._text_to_expression(text)
  179. if expr:
  180. return self._safe_eval(expr)
  181. raise ValueError(f"Kann '{text}' nicht als Zahl interpretieren")
  182. def _text_to_expression(self, text: str) -> str:
  183. """Wandelt natuerlichsprachigen Text in einen math. Ausdruck um."""
  184. text = text.strip()
  185. # Klammern umwandeln
  186. text = text.replace("in klammer", "(").replace("klammer zu", ")")
  187. text = text.replace("klammer auf", "(").replace("klammer", "(")
  188. # "wobei X gleich Y ist" aufloesen
  189. variables: dict[str, float] = {}
  190. wobei_match = re.search(r"wobei\s+(\w+)\s+(?:gleich\s+|=\s*)?(.+?)(?:\s+ist)?$", text)
  191. if wobei_match:
  192. var_name = wobei_match.group(1).lower()
  193. var_expr_text = wobei_match.group(2).strip()
  194. text = text[:wobei_match.start()].strip()
  195. # Spezialfunktion fuer Variable pruefen
  196. try:
  197. special = self._try_special_functions(var_expr_text)
  198. if special:
  199. variables[var_name] = special[0]
  200. else:
  201. variables[var_name] = self._parse_number_expr(var_expr_text)
  202. except ValueError:
  203. pass
  204. # Zusammengesetzte Zahlwoerter aufloesen (z.B. "dreihundert" → 300)
  205. text = self._expand_compound_numbers(text)
  206. tokens = text.split()
  207. expr_parts: list[str] = []
  208. i = 0
  209. while i < len(tokens):
  210. token = tokens[i]
  211. # Mathematische Symbole direkt uebernehmen
  212. if token in ("+", "-", "*", "/", "**", "%", "(", ")"):
  213. expr_parts.append(token)
  214. i += 1
  215. continue
  216. # Mehrteilige Operatoren
  217. if i + 1 < len(tokens):
  218. two_word = f"{token} {tokens[i + 1]}"
  219. if two_word in OPERATOR_WORDS:
  220. expr_parts.append(OPERATOR_WORDS[two_word])
  221. i += 2
  222. continue
  223. # Einzelne Operatoren
  224. if token in OPERATOR_WORDS:
  225. expr_parts.append(OPERATOR_WORDS[token])
  226. i += 1
  227. continue
  228. # Variablen (aus "wobei")
  229. if token in variables:
  230. expr_parts.append(str(variables[token]))
  231. i += 1
  232. continue
  233. # Konstanten
  234. if token in CONSTANTS:
  235. # Implizites * wenn direkt nach einer Zahl
  236. if expr_parts and expr_parts[-1] not in ("+", "-", "*", "/", "**", "%", "("):
  237. expr_parts.append("*")
  238. expr_parts.append(str(CONSTANTS[token]))
  239. i += 1
  240. continue
  241. # Wort-Zahlen
  242. if token in WORD_NUMBERS:
  243. val = WORD_NUMBERS[token]
  244. # "halb von X" → 0.5 * X
  245. if token in ("halb", "halbe", "viertel", "drittel"):
  246. expr_parts.append(str(val))
  247. expr_parts.append("*")
  248. i += 1
  249. continue
  250. expr_parts.append(str(val))
  251. i += 1
  252. continue
  253. # Zahlen (mit Komma als Dezimaltrenner)
  254. cleaned = token.replace(",", ".").rstrip(".")
  255. try:
  256. float(cleaned)
  257. expr_parts.append(cleaned)
  258. i += 1
  259. continue
  260. except ValueError:
  261. pass
  262. # "zum quadrat" / "zum kubus"
  263. if token == "zum" and i + 1 < len(tokens):
  264. next_token = tokens[i + 1]
  265. if next_token == "quadrat":
  266. expr_parts.append("** 2")
  267. i += 2
  268. continue
  269. elif next_token in ("kubus", "kubik"):
  270. expr_parts.append("** 3")
  271. i += 2
  272. continue
  273. # Ignoriere Fuellwoerter
  274. if token in ("ist", "was", "berechne", "rechne", "wie", "viel",
  275. "das", "ergebnis", "von", "gleich", "sind", "wieviel",
  276. "die", "der", "den", "dem", "ergibt", "und"):
  277. i += 1
  278. continue
  279. i += 1
  280. result = " ".join(expr_parts)
  281. # Nicht-geschlossene Klammern automatisch schliessen
  282. open_count = result.count("(") - result.count(")")
  283. if open_count > 0:
  284. result += " )" * open_count
  285. return result
  286. def _expand_compound_numbers(self, text: str) -> str:
  287. """Loest zusammengesetzte Zahlwoerter auf.
  288. z.B. "dreihundert" → "300", "zweitausend" → "2000"
  289. """
  290. multipliers = {
  291. "hundert": 100, "tausend": 1000,
  292. "million": 1_000_000, "millionen": 1_000_000,
  293. "milliarde": 1_000_000_000, "milliarden": 1_000_000_000,
  294. }
  295. prefixes = {
  296. "ein": 1, "zwei": 2, "drei": 3, "vier": 4, "fünf": 5, "fuenf": 5,
  297. "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, "zehn": 10,
  298. "elf": 11, "zwölf": 12, "zwoelf": 12, "zwanzig": 20,
  299. "dreißig": 30, "dreissig": 30, "vierzig": 40, "fünfzig": 50,
  300. "fuenfzig": 50, "sechzig": 60, "siebzig": 70, "achtzig": 80,
  301. "neunzig": 90,
  302. }
  303. for mult_word, mult_val in multipliers.items():
  304. for pre_word, pre_val in prefixes.items():
  305. compound = pre_word + mult_word
  306. if compound in text:
  307. text = text.replace(compound, str(pre_val * mult_val))
  308. return text
  309. def _safe_eval(self, expr: str) -> float:
  310. """Wertet einen mathematischen Ausdruck sicher aus.
  311. Nur Zahlen, Operatoren und Klammern erlaubt — kein exec/import.
  312. """
  313. expr = expr.strip()
  314. if not expr:
  315. raise ValueError("Leerer Ausdruck")
  316. # Validierung: nur erlaubte Zeichen
  317. allowed = set("0123456789.+-*/%()**eE ")
  318. if not all(c in allowed for c in expr):
  319. raise ValueError(f"Unerlaubte Zeichen im Ausdruck: {expr}")
  320. # Keine gefaehrlichen Ausdruecke
  321. if any(kw in expr for kw in ("import", "__", "exec", "eval", "open")):
  322. raise ValueError("Unerlaubter Ausdruck")
  323. try:
  324. # Nur mathematische Ausdruecke erlauben
  325. result = eval(expr, {"__builtins__": {}}, {})
  326. if isinstance(result, (int, float)):
  327. return float(result)
  328. raise ValueError(f"Unerwartetes Ergebnis: {type(result)}")
  329. except ZeroDivisionError:
  330. raise ValueError("Division durch Null")
  331. except SyntaxError:
  332. raise ValueError(f"Syntaxfehler im Ausdruck: {expr}")
  333. except Exception as e:
  334. raise ValueError(f"Auswertungsfehler: {e}")
  335. # ============================================================================
  336. # Ergebnis-Formatierung
  337. # ============================================================================
  338. def format_result(value: float) -> str:
  339. """Formatiert ein Ergebnis fuer TTS-Ausgabe."""
  340. if value == float("inf") or value == float("-inf"):
  341. return "unendlich"
  342. if math.isnan(value):
  343. return "undefiniert"
  344. # Ganzzahl?
  345. if value == int(value) and abs(value) < 1e15:
  346. return str(int(value))
  347. # Rundung fuer schoene Ausgabe
  348. if abs(value) < 0.01:
  349. return f"{value:.6f}".rstrip("0").rstrip(".")
  350. elif abs(value) < 1000:
  351. return f"{value:.4f}".rstrip("0").rstrip(".")
  352. else:
  353. return f"{value:.2f}".rstrip("0").rstrip(".")
  354. # ============================================================================
  355. # Plugin
  356. # ============================================================================
  357. class MathPlugin(TrixyPlugin):
  358. """Mathematische Berechnungen per Sprachsteuerung."""
  359. NAME = "math_calc"
  360. VERSION = "1.0.0"
  361. DESCRIPTION = "Mathematische Berechnungen und Umrechnungen"
  362. AUTHOR = "Trixy"
  363. def __init__(self, **kwargs) -> None:
  364. super().__init__(**kwargs)
  365. self._parser = MathParser()
  366. async def on_load(self) -> None:
  367. pinfo("[Mathe] Plugin geladen")
  368. async def on_unload(self) -> None:
  369. pass
  370. @intent(
  371. "math_calculate",
  372. description="Mathematische Berechnung durchfuehren",
  373. patterns=[
  374. "was ist {ausdruck}",
  375. "was ergibt {ausdruck}",
  376. "was sind {ausdruck}",
  377. "berechne {ausdruck}",
  378. "rechne {ausdruck}",
  379. "wie viel ist {ausdruck}",
  380. "wieviel ist {ausdruck}",
  381. "{ausdruck} berechnen",
  382. ],
  383. )
  384. async def handle_calculate(self, data: IntentReceivedData) -> IntentResult:
  385. """Berechnet einen mathematischen Ausdruck."""
  386. ausdruck = data.get_slot("ausdruck", "")
  387. if not ausdruck:
  388. ausdruck = data.original_text
  389. if not ausdruck:
  390. return IntentResult.failure("missing_expression", "Was soll ich berechnen?")
  391. try:
  392. value, expr = self._parser.parse_and_evaluate(ausdruck)
  393. result_str = format_result(value)
  394. # TTS-freundliche Ausgabe
  395. # Punkt durch "Komma" ersetzen fuer deutsche Aussprache
  396. tts_result = result_str.replace(".", " Komma ")
  397. return IntentResult.success_with_response(
  398. f"Das Ergebnis ist {tts_result}."
  399. )
  400. except ValueError as e:
  401. return IntentResult.failure("calc_error", str(e))
  402. except Exception as e:
  403. return IntentResult.failure("error", f"Berechnungsfehler: {e}")
  404. # Plugin-Export
  405. Plugin = MathPlugin