# -*- coding: utf-8 -*- """ Trixy Mathe-Plugin. Verarbeitet mathematische Ausdruecke in natuerlicher Sprache. Unterstuetzt Grundrechenarten, Potenzen, Wurzeln, Trigonometrie, Konstanten und verschachtelte Ausdruecke. Beispiele: "Was ist 1 + 1" "Was ist die Quadratwurzel aus 9" "Berechne 5 hoch 3" "Was ist pi mal 2" "Was ist die Quersumme von 12345" "Was ist 30 Prozent von 250" "Rechne 100 Dollar in Euro um" """ from __future__ import annotations import math import re from typing import Any from trixy_core.plugins.trixy_plugin import TrixyPlugin from trixy_core.utils.debug import pinfo, pdebug from trixy_core.nlp.decorators import intent from trixy_core.nlp.handler import IntentResult, IntentReceivedData # ============================================================================ # Woerter → Zahlen / Operatoren # ============================================================================ WORD_NUMBERS: dict[str, float] = { "null": 0, "eins": 1, "zwei": 2, "drei": 3, "vier": 4, "fünf": 5, "fuenf": 5, "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, "zehn": 10, "elf": 11, "zwölf": 12, "zwoelf": 12, "dreizehn": 13, "vierzehn": 14, "fünfzehn": 15, "fuenfzehn": 15, "sechzehn": 16, "siebzehn": 17, "achtzehn": 18, "neunzehn": 19, "zwanzig": 20, "dreißig": 30, "dreissig": 30, "vierzig": 40, "fünfzig": 50, "fuenfzig": 50, "sechzig": 60, "siebzig": 70, "achtzig": 80, "neunzig": 90, "hundert": 100, "tausend": 1000, "million": 1_000_000, "milliarde": 1_000_000_000, "eine": 1, "ein": 1, "pi": math.pi, "e": math.e, "halbe": 0.5, "halb": 0.5, "viertel": 0.25, "drittel": 1/3, } OPERATOR_WORDS: dict[str, str] = { "plus": "+", "und": "+", "minus": "-", "weniger": "-", "mal": "*", "multipliziert": "*", "multipliziert mit": "*", "geteilt": "/", "geteilt durch": "/", "durch": "/", "dividiert": "/", "hoch": "**", "potenz": "**", "modulo": "%", "mod": "%", "rest": "%", } CONSTANTS: dict[str, float] = { "pi": math.pi, "e": math.e, "tau": math.tau, "phi": (1 + math.sqrt(5)) / 2, # Goldener Schnitt } # ============================================================================ # Natuerlichsprachiger Mathe-Parser # ============================================================================ class MathParser: """Parst mathematische Ausdruecke aus natuerlicher Sprache.""" def parse_and_evaluate(self, text: str) -> tuple[float, str]: """Parst und berechnet einen mathematischen Ausdruck. Args: text: Natuerlichsprachiger Ausdruck Returns: (ergebnis, beschreibung_des_ausdruecks) Raises: ValueError: Wenn der Ausdruck nicht geparst werden kann """ text = text.strip().lower() # Wenn "wobei" vorkommt → zusammengesetzter Ausdruck, direkt als Expression if "wobei" in text: expr = self._text_to_expression(text) if expr: value = self._safe_eval(expr) return (value, expr) # Spezialfunktionen pruefen result = self._try_special_functions(text) if result is not None: return result # Text in mathematischen Ausdruck umwandeln expr = self._text_to_expression(text) if not expr: raise ValueError("Konnte keinen mathematischen Ausdruck erkennen") # Sicher auswerten value = self._safe_eval(expr) return (value, expr) def _try_special_functions(self, text: str) -> tuple[float, str] | None: """Prueft auf Spezialfunktionen (Wurzel, Quersumme, Prozent, etc.).""" # n-te Wurzel (VOR Quadratwurzel pruefen!) m = re.search(r"(\d+)(?:te|\.)\s*wurzel\s+(?:aus\s+|von\s+)?(.+)", text) if m: n = int(m.group(1)) inner = self._parse_number_expr(m.group(2)) result = inner ** (1 / n) return (result, f"{n}. Wurzel aus {inner}") # Quadratwurzel (nach n-ter Wurzel!) m = re.search(r"(?:quadrat)?wurzel\s+(?:aus\s+|von\s+)?(.+)", text) if m: inner = self._parse_number_expr(m.group(1)) result = math.sqrt(inner) return (result, f"√({inner})") # Quersumme m = re.search(r"quersumme\s+(?:aus\s+|von\s+)?(\d+)", text) if m: digits = m.group(1) result = sum(int(d) for d in digits) return (float(result), f"Quersumme({digits})") # Fakultaet m = re.search(r"fakult(?:ä|ae)t\s+(?:von\s+)?(\d+)", text) if m: n = int(m.group(1)) if n > 170: raise ValueError("Fakultaet zu gross (max 170)") result = math.factorial(n) return (float(result), f"{n}!") # Prozent von m = re.search(r"(\d+(?:[.,]\d+)?)\s*prozent\s+von\s+(.+)", text) if m: pct = float(m.group(1).replace(",", ".")) base = self._parse_number_expr(m.group(2)) result = base * pct / 100 return (result, f"{pct}% von {base}") # Sinus/Cosinus/Tangens for func_name, func in [ ("sinus", math.sin), ("sin", math.sin), ("cosinus", math.cos), ("cos", math.cos), ("tangens", math.tan), ("tan", math.tan), ]: m = re.search(rf"{func_name}\s+(?:von\s+)?(.+)", text) if m: inner = self._parse_number_expr(m.group(1)) # Grad in Radiant rad = math.radians(inner) result = func(rad) return (result, f"{func_name}({inner}°)") # Logarithmus m = re.search(r"(?:natuerlicher\s+)?logarithmus\s+(?:von\s+)?(.+)", text) if m: inner = self._parse_number_expr(m.group(1)) result = math.log(inner) return (result, f"ln({inner})") m = re.search(r"log(?:arithmus)?\s+(?:basis\s+)?(\d+)\s+(?:von\s+)?(.+)", text) if m: base = int(m.group(1)) inner = self._parse_number_expr(m.group(2)) result = math.log(inner, base) return (result, f"log{base}({inner})") # Absoluter Betrag m = re.search(r"(?:absoluter?\s+)?betrag\s+(?:von\s+)?(.+)", text) if m: inner = self._parse_number_expr(m.group(1)) result = abs(inner) return (result, f"|{inner}|") # Runden m = re.search(r"runde\s+(.+?)(?:\s+auf\s+(\d+)\s+(?:stellen|dezimal))?$", text) if m: inner = self._parse_number_expr(m.group(1)) digits = int(m.group(2)) if m.group(2) else 0 result = round(inner, digits) return (result, f"round({inner}, {digits})") return None def _parse_number_expr(self, text: str) -> float: """Parst eine Zahl oder einen einfachen Ausdruck aus Text.""" text = text.strip().rstrip(".") # Konstanten for name, val in CONSTANTS.items(): if text == name: return val # Wort-Zahlen if text in WORD_NUMBERS: return WORD_NUMBERS[text] # Direkte Zahl try: return float(text.replace(",", ".")) except ValueError: pass # Ausdruck parsen und auswerten expr = self._text_to_expression(text) if expr: return self._safe_eval(expr) raise ValueError(f"Kann '{text}' nicht als Zahl interpretieren") def _text_to_expression(self, text: str) -> str: """Wandelt natuerlichsprachigen Text in einen math. Ausdruck um.""" text = text.strip() # Klammern umwandeln text = text.replace("in klammer", "(").replace("klammer zu", ")") text = text.replace("klammer auf", "(").replace("klammer", "(") # "wobei X gleich Y ist" aufloesen variables: dict[str, float] = {} wobei_match = re.search(r"wobei\s+(\w+)\s+(?:gleich\s+|=\s*)?(.+?)(?:\s+ist)?$", text) if wobei_match: var_name = wobei_match.group(1).lower() var_expr_text = wobei_match.group(2).strip() text = text[:wobei_match.start()].strip() # Spezialfunktion fuer Variable pruefen try: special = self._try_special_functions(var_expr_text) if special: variables[var_name] = special[0] else: variables[var_name] = self._parse_number_expr(var_expr_text) except ValueError: pass # Zusammengesetzte Zahlwoerter aufloesen (z.B. "dreihundert" → 300) text = self._expand_compound_numbers(text) tokens = text.split() expr_parts: list[str] = [] i = 0 while i < len(tokens): token = tokens[i] # Mathematische Symbole direkt uebernehmen if token in ("+", "-", "*", "/", "**", "%", "(", ")"): expr_parts.append(token) i += 1 continue # Mehrteilige Operatoren if i + 1 < len(tokens): two_word = f"{token} {tokens[i + 1]}" if two_word in OPERATOR_WORDS: expr_parts.append(OPERATOR_WORDS[two_word]) i += 2 continue # Einzelne Operatoren if token in OPERATOR_WORDS: expr_parts.append(OPERATOR_WORDS[token]) i += 1 continue # Variablen (aus "wobei") if token in variables: expr_parts.append(str(variables[token])) i += 1 continue # Konstanten if token in CONSTANTS: # Implizites * wenn direkt nach einer Zahl if expr_parts and expr_parts[-1] not in ("+", "-", "*", "/", "**", "%", "("): expr_parts.append("*") expr_parts.append(str(CONSTANTS[token])) i += 1 continue # Wort-Zahlen if token in WORD_NUMBERS: val = WORD_NUMBERS[token] # "halb von X" → 0.5 * X if token in ("halb", "halbe", "viertel", "drittel"): expr_parts.append(str(val)) expr_parts.append("*") i += 1 continue expr_parts.append(str(val)) i += 1 continue # Zahlen (mit Komma als Dezimaltrenner) cleaned = token.replace(",", ".").rstrip(".") try: float(cleaned) expr_parts.append(cleaned) i += 1 continue except ValueError: pass # "zum quadrat" / "zum kubus" if token == "zum" and i + 1 < len(tokens): next_token = tokens[i + 1] if next_token == "quadrat": expr_parts.append("** 2") i += 2 continue elif next_token in ("kubus", "kubik"): expr_parts.append("** 3") i += 2 continue # Ignoriere Fuellwoerter if token in ("ist", "was", "berechne", "rechne", "wie", "viel", "das", "ergebnis", "von", "gleich", "sind", "wieviel", "die", "der", "den", "dem", "ergibt", "und"): i += 1 continue i += 1 result = " ".join(expr_parts) # Nicht-geschlossene Klammern automatisch schliessen open_count = result.count("(") - result.count(")") if open_count > 0: result += " )" * open_count return result def _expand_compound_numbers(self, text: str) -> str: """Loest zusammengesetzte Zahlwoerter auf. z.B. "dreihundert" → "300", "zweitausend" → "2000" """ multipliers = { "hundert": 100, "tausend": 1000, "million": 1_000_000, "millionen": 1_000_000, "milliarde": 1_000_000_000, "milliarden": 1_000_000_000, } prefixes = { "ein": 1, "zwei": 2, "drei": 3, "vier": 4, "fünf": 5, "fuenf": 5, "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, "zehn": 10, "elf": 11, "zwölf": 12, "zwoelf": 12, "zwanzig": 20, "dreißig": 30, "dreissig": 30, "vierzig": 40, "fünfzig": 50, "fuenfzig": 50, "sechzig": 60, "siebzig": 70, "achtzig": 80, "neunzig": 90, } for mult_word, mult_val in multipliers.items(): for pre_word, pre_val in prefixes.items(): compound = pre_word + mult_word if compound in text: text = text.replace(compound, str(pre_val * mult_val)) return text def _safe_eval(self, expr: str) -> float: """Wertet einen mathematischen Ausdruck sicher aus. Nur Zahlen, Operatoren und Klammern erlaubt — kein exec/import. """ expr = expr.strip() if not expr: raise ValueError("Leerer Ausdruck") # Validierung: nur erlaubte Zeichen allowed = set("0123456789.+-*/%()**eE ") if not all(c in allowed for c in expr): raise ValueError(f"Unerlaubte Zeichen im Ausdruck: {expr}") # Keine gefaehrlichen Ausdruecke if any(kw in expr for kw in ("import", "__", "exec", "eval", "open")): raise ValueError("Unerlaubter Ausdruck") try: # Nur mathematische Ausdruecke erlauben result = eval(expr, {"__builtins__": {}}, {}) if isinstance(result, (int, float)): return float(result) raise ValueError(f"Unerwartetes Ergebnis: {type(result)}") except ZeroDivisionError: raise ValueError("Division durch Null") except SyntaxError: raise ValueError(f"Syntaxfehler im Ausdruck: {expr}") except Exception as e: raise ValueError(f"Auswertungsfehler: {e}") # ============================================================================ # Ergebnis-Formatierung # ============================================================================ def format_result(value: float) -> str: """Formatiert ein Ergebnis fuer TTS-Ausgabe.""" if value == float("inf") or value == float("-inf"): return "unendlich" if math.isnan(value): return "undefiniert" # Ganzzahl? if value == int(value) and abs(value) < 1e15: return str(int(value)) # Rundung fuer schoene Ausgabe if abs(value) < 0.01: return f"{value:.6f}".rstrip("0").rstrip(".") elif abs(value) < 1000: return f"{value:.4f}".rstrip("0").rstrip(".") else: return f"{value:.2f}".rstrip("0").rstrip(".") # ============================================================================ # Plugin # ============================================================================ class MathPlugin(TrixyPlugin): """Mathematische Berechnungen per Sprachsteuerung.""" NAME = "math_calc" VERSION = "1.0.0" DESCRIPTION = "Mathematische Berechnungen und Umrechnungen" AUTHOR = "Trixy" def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._parser = MathParser() async def on_load(self) -> None: pinfo("[Mathe] Plugin geladen") async def on_unload(self) -> None: pass @intent( "math_calculate", description="Mathematische Berechnung durchfuehren", patterns=[ "was ist {ausdruck}", "was ergibt {ausdruck}", "was sind {ausdruck}", "berechne {ausdruck}", "rechne {ausdruck}", "wie viel ist {ausdruck}", "wieviel ist {ausdruck}", "{ausdruck} berechnen", ], ) async def handle_calculate(self, data: IntentReceivedData) -> IntentResult: """Berechnet einen mathematischen Ausdruck.""" ausdruck = data.get_slot("ausdruck", "") if not ausdruck: ausdruck = data.original_text if not ausdruck: return IntentResult.failure("missing_expression", "Was soll ich berechnen?") try: value, expr = self._parser.parse_and_evaluate(ausdruck) result_str = format_result(value) # TTS-freundliche Ausgabe # Punkt durch "Komma" ersetzen fuer deutsche Aussprache tts_result = result_str.replace(".", " Komma ") return IntentResult.success_with_response( f"Das Ergebnis ist {tts_result}." ) except ValueError as e: return IntentResult.failure("calc_error", str(e)) except Exception as e: return IntentResult.failure("error", f"Berechnungsfehler: {e}") # Plugin-Export Plugin = MathPlugin