| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492 |
- # -*- 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
|