| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670 |
- # -*- coding: utf-8 -*-
- """
- Entity-System fuer typisierte Slot-Validierung und -Aufloesung.
- Stellt Entity-Definitionen, eine Registry zum Laden aus YAML und
- einen Resolver zum Aufloesen von Rohwerten in strukturierte Daten bereit.
- Ablauf:
- 1. EntityRegistry laedt entities_{lang}.yml
- 2. KeywordMatcher prueft Slot-Werte gegen EntityDefinition
- 3. EntityResolver loest erkannte Werte in ResolvedEntity auf
- Pattern-Syntax:
- {slot_name:entity_type} — Slot mit Entity-Typ-Validierung
- {slot_name} — Slot ohne Validierung (wie bisher)
- """
- from __future__ import annotations
- import json
- import re
- from dataclasses import dataclass, field
- from datetime import date, datetime, time, timedelta
- from pathlib import Path
- from typing import Any
- from trixy_core.utils.debug import pdebug, pwarn
- # ============================================================================
- # Datenklassen
- # ============================================================================
- @dataclass
- class EntityDefinition:
- """Definition eines Entity-Typs."""
- name: str
- values: set[str] = field(default_factory=set) # Bekannte Werte (lowercase)
- multi_word_values: set[str] = field(default_factory=set) # Mehrwort-Werte ("naechste woche")
- open: bool = False # Akzeptiert unbekannte Werte?
- regex_patterns: list[re.Pattern[str]] = field(default_factory=list) # Regex fuer dynamische Werte
- @dataclass
- class ResolvedEntity:
- """Aufgeloeste Entity mit Rohwert und strukturiertem Wert."""
- raw: str # Original-String ("morgen")
- entity_type: str # Entity-Typ ("datum")
- value: Any # Aufgeloester Wert (date, int, ...)
- formatted: dict[str, str] = field(default_factory=dict) # Format-Varianten
- # ============================================================================
- # Wochentag-Mapping
- # ============================================================================
- _WEEKDAY_MAP: dict[str, int] = {
- "montag": 0,
- "dienstag": 1,
- "mittwoch": 2,
- "donnerstag": 3,
- "freitag": 4,
- "samstag": 5,
- "sonntag": 6,
- }
- _WEEKDAY_NAMES: list[str] = [
- "Montag", "Dienstag", "Mittwoch", "Donnerstag",
- "Freitag", "Samstag", "Sonntag",
- ]
- _MONTH_NAMES: list[str] = [
- "", "Januar", "Februar", "Maerz", "April", "Mai", "Juni",
- "Juli", "August", "September", "Oktober", "November", "Dezember",
- ]
- # Monatsnamen → Monatsnummer
- _MONTH_NAME_MAP: dict[str, int] = {
- "januar": 1, "februar": 2, "maerz": 3, "märz": 3,
- "april": 4, "mai": 5, "juni": 6, "juli": 7,
- "august": 8, "september": 9, "oktober": 10,
- "november": 11, "dezember": 12,
- }
- # "halb drei" → Stundenname-Mapping
- _HOUR_WORDS: dict[str, int] = {
- "eins": 1, "zwei": 2, "drei": 3, "vier": 4, "fuenf": 5, "fünf": 5,
- "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, "zehn": 10,
- "elf": 11, "zwoelf": 12, "zwölf": 12,
- }
- # Dauer-Einheiten in Sekunden
- _DURATION_UNITS: dict[str, int] = {
- "sekunde": 1, "sekunden": 1,
- "minute": 60, "minuten": 60,
- "stunde": 3600, "stunden": 3600,
- }
- # ============================================================================
- # EntityRegistry
- # ============================================================================
- class EntityRegistry:
- """Laedt und verwaltet Entity-Definitionen aus YAML/JSON-Dateien.
- Unterstuetzte Verzeichnisstrukturen:
- entities/{lang}/*.yml — Globale Entities
- entities/{lang}/*.json — Globale Entities (JSON-Alternative)
- plugins/*/entities/{lang}/*.yml — Plugin-Entities
- plugins/*/entities/{lang}/*.json — Plugin-Entities
- Beim Laden werden Entities aus mehreren Dateien zusammengefuehrt.
- Gleiche Entity-Namen werden gemergt (Werte werden vereinigt).
- """
- def __init__(self) -> None:
- self._entities: dict[str, EntityDefinition] = {}
- def load(self, directory: Path, language: str = "de") -> None:
- """Laedt Entities aus einem Verzeichnis.
- Unterstuetzt zwei Strukturen:
- 1. Neu: directory/{lang}/*.yml|*.json (Multi-Datei)
- 2. Legacy: directory/entities_{lang}.yml (Einzeldatei)
- Args:
- directory: Basis-Verzeichnis (z.B. ./entities/ oder trixy_core/nlp/)
- language: Sprachcode (z.B. "de", "en")
- """
- lang_dir = directory / language
- if lang_dir.is_dir():
- # Neue Struktur: entities/de/*.yml + *.json
- self._load_directory(lang_dir)
- else:
- # Legacy-Fallback: entities_de.yml
- legacy_path = directory / f"entities_{language}.yml"
- if legacy_path.exists():
- self._load_file(legacy_path)
- else:
- pwarn(f"[EntityRegistry] Kein Entity-Verzeichnis gefunden: {lang_dir}")
- def load_plugin_entities(self, plugins_dir: Path, language: str = "de") -> None:
- """Laedt Entities aus allen Plugin-Verzeichnissen.
- Sucht nach: plugins/*/entities/{lang}/*.yml|*.json
- Args:
- plugins_dir: Plugins-Basisverzeichnis (z.B. ./plugins/)
- language: Sprachcode
- """
- if not plugins_dir.is_dir():
- return
- count_before = len(self._entities)
- for plugin_dir in sorted(plugins_dir.iterdir()):
- entity_dir = plugin_dir / "entities" / language
- if entity_dir.is_dir():
- self._load_directory(entity_dir, source=plugin_dir.name)
- loaded = len(self._entities) - count_before
- if loaded > 0:
- pdebug(f"[EntityRegistry] {loaded} Entity-Typen aus Plugins geladen")
- def _load_directory(self, lang_dir: Path, source: str = "") -> None:
- """Laedt alle .yml und .json Dateien aus einem Verzeichnis."""
- files = sorted(lang_dir.glob("*.yml")) + sorted(lang_dir.glob("*.json"))
- if not files:
- return
- prefix = f"plugin:{source}" if source else str(lang_dir.parent.name)
- for filepath in files:
- self._load_file(filepath, source=prefix)
- def _load_file(self, filepath: Path, source: str = "") -> None:
- """Laedt eine einzelne YAML- oder JSON-Datei und mergt Entities."""
- raw = _read_entity_file(filepath)
- if raw is None:
- return
- count = 0
- for entity_name, entity_data in raw.items():
- if not isinstance(entity_data, dict):
- continue
- new_def = _parse_entity_definition(entity_name, entity_data)
- if entity_name in self._entities:
- # Merge: Werte vereinigen
- existing = self._entities[entity_name]
- existing.values |= new_def.values
- existing.multi_word_values |= new_def.multi_word_values
- existing.regex_patterns.extend(new_def.regex_patterns)
- if new_def.open:
- existing.open = True
- else:
- self._entities[entity_name] = new_def
- count += 1
- label = source or filepath.name
- pdebug(f"[EntityRegistry] {label}/{filepath.name}: {count} Entity-Typen geladen")
- def load_from_dict(self, data: dict[str, dict[str, Any]]) -> None:
- """Laedt Entity-Definitionen aus einem Dict (fuer Tests)."""
- self._entities.clear()
- for entity_name, entity_data in data.items():
- self._entities[entity_name] = _parse_entity_definition(
- entity_name, entity_data
- )
- def add_values(self, entity_type: str, values: list[str]) -> None:
- """Fuegt Werte zu einem bestehenden Entity-Typ hinzu.
- Erstellt den Entity-Typ wenn er nicht existiert (als open=True).
- Nützlich fuer Plugins die zur Laufzeit Werte registrieren.
- Args:
- entity_type: Entity-Typ-Name (z.B. "raum")
- values: Liste von Werten
- """
- entity_def = self._entities.get(entity_type)
- if entity_def is None:
- entity_def = EntityDefinition(name=entity_type, open=True)
- self._entities[entity_type] = entity_def
- for v in values:
- v_str = v.strip().lower()
- if not v_str:
- continue
- if " " in v_str:
- entity_def.multi_word_values.add(v_str)
- else:
- entity_def.values.add(v_str)
- def get(self, entity_type: str) -> EntityDefinition | None:
- """Gibt Entity-Definition zurueck."""
- return self._entities.get(entity_type)
- def matches(self, value: str, entity_type: str) -> bool:
- """Prueft ob ein Wert zu einem Entity-Typ passt."""
- entity_def = self._entities.get(entity_type)
- if entity_def is None:
- return True # Unbekannter Typ → alles akzeptieren
- v = value.strip().lower()
- # Exakter Match gegen bekannte Werte
- if v in entity_def.values:
- return True
- # Mehrwort-Match
- if v in entity_def.multi_word_values:
- return True
- # Regex-Match
- for pattern in entity_def.regex_patterns:
- if pattern.fullmatch(v):
- return True
- # Open-Entity: akzeptiert alles
- if entity_def.open:
- return True
- return False
- def match_multi_word(
- self, tokens: list[str], pos: int, entity_type: str
- ) -> tuple[str, int] | None:
- """
- Versucht Mehrwort-Match ab Position pos.
- Gibt (wert, neue_pos) zurueck oder None.
- """
- entity_def = self._entities.get(entity_type)
- if entity_def is None:
- return None
- # Laengste Matches zuerst (greedy)
- best: tuple[str, int] | None = None
- for mw_value in entity_def.multi_word_values:
- mw_tokens = mw_value.split()
- end = pos + len(mw_tokens)
- if end <= len(tokens):
- candidate = " ".join(tokens[pos:end]).lower()
- if candidate == mw_value:
- if best is None or len(mw_tokens) > (best[1] - pos):
- best = (mw_value, end)
- # Regex auf zusammengesetzten Text pruefen
- if best is None:
- for length in range(min(4, len(tokens) - pos), 0, -1):
- candidate = " ".join(tokens[pos:pos + length]).lower()
- for pattern in entity_def.regex_patterns:
- if pattern.fullmatch(candidate):
- return (candidate, pos + length)
- return best
- @property
- def entity_types(self) -> list[str]:
- """Gibt alle registrierten Entity-Typ-Namen zurueck."""
- return list(self._entities.keys())
- # ============================================================================
- # EntityResolver
- # ============================================================================
- class EntityResolver:
- """Loest Entity-Werte in strukturierte Daten auf."""
- def __init__(self, registry: EntityRegistry | None = None) -> None:
- self._registry = registry
- self._resolvers: dict[str, Any] = {
- "datum": self._resolve_datum,
- "zeit": self._resolve_zeit,
- "dauer": self._resolve_dauer,
- }
- def resolve(self, raw: str, entity_type: str) -> ResolvedEntity:
- """Loest einen Rohwert auf. Gibt ResolvedEntity zurueck."""
- resolver = self._resolvers.get(entity_type)
- if resolver is not None:
- try:
- return resolver(raw)
- except Exception:
- pass
- return self._resolve_generic(raw, entity_type)
- # ========================================================================
- # Datum-Resolver
- # ========================================================================
- def _resolve_datum(self, raw: str) -> ResolvedEntity:
- """Loest eine deutsche Datumsangabe auf."""
- today = date.today()
- s = raw.strip().lower()
- d: date | None = None
- # Relative Angaben
- if s in ("heute", "today"):
- d = today
- elif s in ("morgen", "tomorrow"):
- d = today + timedelta(days=1)
- elif s in ("uebermorgen", "übermorgen"):
- d = today + timedelta(days=2)
- elif s in _WEEKDAY_MAP:
- d = _next_weekday(today, _WEEKDAY_MAP[s])
- elif s == "am wochenende":
- d = _next_weekday(today, 5) # Samstag
- elif s == "diese woche":
- d = today
- elif s.startswith("nächsten ") or s.startswith("naechsten "):
- day_name = s.split(" ", 1)[1].strip()
- if day_name in _WEEKDAY_MAP:
- d = _next_weekday(today, _WEEKDAY_MAP[day_name])
- elif s == "nächste woche" or s == "naechste woche":
- # Naechster Montag
- d = _next_weekday(today, 0)
- else:
- d = _parse_absolute_date(s)
- if d is None:
- return self._resolve_generic(raw, "datum")
- return ResolvedEntity(
- raw=raw,
- entity_type="datum",
- value=d,
- formatted={
- "iso": d.isoformat(),
- "eu": d.strftime("%d.%m.%Y"),
- "tts": _format_date_tts(d),
- "api": d.isoformat(),
- },
- )
- # ========================================================================
- # Zeit-Resolver
- # ========================================================================
- def _resolve_zeit(self, raw: str) -> ResolvedEntity:
- """Loest eine deutsche Zeitangabe auf."""
- s = raw.strip().lower()
- t: time | None = None
- # Benannte Zeiten
- if s == "mittag":
- t = time(12, 0)
- elif s == "mitternacht":
- t = time(0, 0)
- elif s == "abend":
- t = time(18, 0)
- elif s in ("morgens", "morgen früh", "morgen frueh"):
- t = time(8, 0)
- elif s == "nachmittag":
- t = time(15, 0)
- else:
- parsed = _parse_time_string(s)
- if parsed is not None:
- h, m = parsed
- t = time(h, m)
- if t is None:
- return self._resolve_generic(raw, "zeit")
- return ResolvedEntity(
- raw=raw,
- entity_type="zeit",
- value=t,
- formatted={
- "iso": t.isoformat(),
- "24h": t.strftime("%H:%M"),
- "tts": _format_time_tts(t),
- },
- )
- # ========================================================================
- # Dauer-Resolver
- # ========================================================================
- def _resolve_dauer(self, raw: str) -> ResolvedEntity:
- """Loest eine deutsche Dauerangabe auf."""
- s = raw.strip().lower()
- total_seconds: int | None = None
- # "eine minute", "eine stunde", "eine halbe stunde"
- if s in ("eine minute", "1 minute"):
- total_seconds = 60
- elif s in ("eine stunde", "1 stunde"):
- total_seconds = 3600
- elif s in ("eine halbe stunde",):
- total_seconds = 1800
- else:
- # "5 minuten", "30 sekunden", "2 stunden"
- m = re.match(r"(\d+)\s+(sekunden?|minuten?|stunden?)", s)
- if m:
- amount = int(m.group(1))
- unit = m.group(2)
- multiplier = _DURATION_UNITS.get(unit, 0)
- if multiplier:
- total_seconds = amount * multiplier
- if total_seconds is None:
- return self._resolve_generic(raw, "dauer")
- minutes = total_seconds // 60
- hours = total_seconds // 3600
- return ResolvedEntity(
- raw=raw,
- entity_type="dauer",
- value=total_seconds,
- formatted={
- "seconds": str(total_seconds),
- "minutes": str(minutes),
- "hours": str(hours),
- "tts": _format_duration_tts(total_seconds),
- },
- )
- # ========================================================================
- # Generic Resolver (Fallback)
- # ========================================================================
- def _resolve_generic(self, raw: str, entity_type: str) -> ResolvedEntity:
- """Generischer Resolver fuer unbekannte Entity-Typen."""
- return ResolvedEntity(
- raw=raw,
- entity_type=entity_type,
- value=raw.strip().lower(),
- formatted={"raw": raw.strip()},
- )
- # ============================================================================
- # Hilfsfunktionen
- # ============================================================================
- def _read_entity_file(filepath: Path) -> dict[str, Any] | None:
- """Liest eine YAML- oder JSON-Entity-Datei und gibt das Dict zurueck."""
- try:
- with open(filepath, "r", encoding="utf-8") as f:
- if filepath.suffix == ".json":
- raw = json.load(f)
- else:
- try:
- import yaml
- except ImportError:
- pwarn("[EntityRegistry] PyYAML nicht installiert")
- return None
- raw = yaml.safe_load(f)
- except Exception as e:
- pwarn(f"[EntityRegistry] Fehler beim Lesen von {filepath}: {e}")
- return None
- if not isinstance(raw, dict):
- pwarn(f"[EntityRegistry] Ungueltiges Format in {filepath}")
- return None
- return raw
- def _parse_entity_definition(
- name: str, data: dict[str, Any]
- ) -> EntityDefinition:
- """Parst eine Entity-Definition aus YAML-Daten."""
- raw_values = data.get("values", [])
- if not isinstance(raw_values, list):
- raw_values = []
- values: set[str] = set()
- multi_word_values: set[str] = set()
- for v in raw_values:
- v_str = str(v).strip().lower()
- if not v_str:
- continue
- if " " in v_str:
- multi_word_values.add(v_str)
- else:
- values.add(v_str)
- # Regex-Patterns kompilieren
- regex_patterns: list[re.Pattern[str]] = []
- raw_regex = data.get("regex", [])
- if isinstance(raw_regex, list):
- for r in raw_regex:
- try:
- regex_patterns.append(re.compile(str(r), re.IGNORECASE))
- except re.error:
- pwarn(f"[EntityRegistry] Ungueltiger Regex fuer '{name}': {r}")
- return EntityDefinition(
- name=name,
- values=values,
- multi_word_values=multi_word_values,
- open=bool(data.get("open", False)),
- regex_patterns=regex_patterns,
- )
- def _next_weekday(start: date, weekday: int) -> date:
- """Berechnet den naechsten Wochentag ab start (exklusive heute)."""
- diff = weekday - start.weekday()
- if diff <= 0:
- diff += 7
- return start + timedelta(days=diff)
- def _parse_absolute_date(s: str) -> date | None:
- """Parst absolute Datumsformate: '15.03.', '15.03.2026'."""
- today = date.today()
- # "15.03." oder "15.3."
- m = re.match(r"(\d{1,2})\.(\d{1,2})\.$", s)
- if m:
- day, month = int(m.group(1)), int(m.group(2))
- try:
- target = date(today.year, month, day)
- if target < today:
- target = date(today.year + 1, month, day)
- return target
- except ValueError:
- return None
- # "15.03.2026"
- m = re.match(r"(\d{1,2})\.(\d{1,2})\.(\d{4})$", s)
- if m:
- day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
- try:
- return date(year, month, day)
- except ValueError:
- return None
- # "5 mai", "15 januar" — Tag + Monatsname
- m = re.match(r"(\d{1,2})\s+(\w+)$", s)
- if m:
- day_val = int(m.group(1))
- month_val = _MONTH_NAME_MAP.get(m.group(2))
- if month_val is not None:
- try:
- target = date(today.year, month_val, day_val)
- if target < today:
- target = date(today.year + 1, month_val, day_val)
- return target
- except ValueError:
- return None
- return None
- def _parse_time_string(s: str) -> tuple[int, int] | None:
- """Parst deutsche Zeitangaben."""
- # "15:00 Uhr" oder "15:00" oder "9:30"
- m = re.match(r"(\d{1,2}):(\d{2})\s*(?:uhr)?$", s)
- if m:
- h, mi = int(m.group(1)), int(m.group(2))
- if 0 <= h <= 23 and 0 <= mi <= 59:
- return (h, mi)
- # "15 Uhr 30"
- m = re.match(r"(\d{1,2})\s*uhr\s*(\d{1,2})$", s)
- if m:
- h, mi = int(m.group(1)), int(m.group(2))
- if 0 <= h <= 23 and 0 <= mi <= 59:
- return (h, mi)
- # "15 Uhr"
- m = re.match(r"(\d{1,2})\s*uhr$", s)
- if m:
- h = int(m.group(1))
- if 0 <= h <= 23:
- return (h, 0)
- # "halb drei" → 2:30
- m = re.match(r"halb\s+(\w+)$", s)
- if m:
- hour_word = m.group(1)
- hour_val = _HOUR_WORDS.get(hour_word)
- if hour_val is not None:
- h = hour_val - 1
- if h < 0:
- h = 23
- return (h, 30)
- return None
- def _format_date_tts(d: date) -> str:
- """Formatiert ein Datum fuer TTS-Ausgabe."""
- today = date.today()
- diff = (d - today).days
- if diff == 0:
- return "heute"
- if diff == 1:
- return "morgen"
- if diff == 2:
- return "uebermorgen"
- if 0 < diff <= 6:
- return f"am {_WEEKDAY_NAMES[d.weekday()]}"
- day = d.day
- month = _MONTH_NAMES[d.month]
- return f"am {day}. {month}"
- def _format_time_tts(t: time) -> str:
- """Formatiert eine Uhrzeit fuer TTS-Ausgabe."""
- if t.minute == 0:
- return f"{t.hour} Uhr"
- return f"{t.hour} Uhr {t.minute:02d}"
- def _format_duration_tts(total_seconds: int) -> str:
- """Formatiert eine Dauer fuer TTS-Ausgabe."""
- if total_seconds < 60:
- unit = "Sekunde" if total_seconds == 1 else "Sekunden"
- return f"{total_seconds} {unit}"
- elif total_seconds < 3600:
- minutes = total_seconds // 60
- unit = "Minute" if minutes == 1 else "Minuten"
- return f"{minutes} {unit}"
- else:
- hours = total_seconds // 3600
- remaining_minutes = (total_seconds % 3600) // 60
- unit = "Stunde" if hours == 1 else "Stunden"
- if remaining_minutes:
- min_unit = "Minute" if remaining_minutes == 1 else "Minuten"
- return f"{hours} {unit} {remaining_minutes} {min_unit}"
- return f"{hours} {unit}"
|