entities.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. # -*- coding: utf-8 -*-
  2. """
  3. Entity-System fuer typisierte Slot-Validierung und -Aufloesung.
  4. Stellt Entity-Definitionen, eine Registry zum Laden aus YAML und
  5. einen Resolver zum Aufloesen von Rohwerten in strukturierte Daten bereit.
  6. Ablauf:
  7. 1. EntityRegistry laedt entities_{lang}.yml
  8. 2. KeywordMatcher prueft Slot-Werte gegen EntityDefinition
  9. 3. EntityResolver loest erkannte Werte in ResolvedEntity auf
  10. Pattern-Syntax:
  11. {slot_name:entity_type} — Slot mit Entity-Typ-Validierung
  12. {slot_name} — Slot ohne Validierung (wie bisher)
  13. """
  14. from __future__ import annotations
  15. import re
  16. from dataclasses import dataclass, field
  17. from datetime import date, datetime, time, timedelta
  18. from pathlib import Path
  19. from typing import Any
  20. from trixy_core.utils.debug import pdebug, pwarn
  21. # ============================================================================
  22. # Datenklassen
  23. # ============================================================================
  24. @dataclass
  25. class EntityDefinition:
  26. """Definition eines Entity-Typs."""
  27. name: str
  28. values: set[str] = field(default_factory=set) # Bekannte Werte (lowercase)
  29. multi_word_values: set[str] = field(default_factory=set) # Mehrwort-Werte ("naechste woche")
  30. open: bool = False # Akzeptiert unbekannte Werte?
  31. regex_patterns: list[re.Pattern[str]] = field(default_factory=list) # Regex fuer dynamische Werte
  32. @dataclass
  33. class ResolvedEntity:
  34. """Aufgeloeste Entity mit Rohwert und strukturiertem Wert."""
  35. raw: str # Original-String ("morgen")
  36. entity_type: str # Entity-Typ ("datum")
  37. value: Any # Aufgeloester Wert (date, int, ...)
  38. formatted: dict[str, str] = field(default_factory=dict) # Format-Varianten
  39. # ============================================================================
  40. # Wochentag-Mapping
  41. # ============================================================================
  42. _WEEKDAY_MAP: dict[str, int] = {
  43. "montag": 0,
  44. "dienstag": 1,
  45. "mittwoch": 2,
  46. "donnerstag": 3,
  47. "freitag": 4,
  48. "samstag": 5,
  49. "sonntag": 6,
  50. }
  51. _WEEKDAY_NAMES: list[str] = [
  52. "Montag", "Dienstag", "Mittwoch", "Donnerstag",
  53. "Freitag", "Samstag", "Sonntag",
  54. ]
  55. _MONTH_NAMES: list[str] = [
  56. "", "Januar", "Februar", "Maerz", "April", "Mai", "Juni",
  57. "Juli", "August", "September", "Oktober", "November", "Dezember",
  58. ]
  59. # Monatsnamen → Monatsnummer
  60. _MONTH_NAME_MAP: dict[str, int] = {
  61. "januar": 1, "februar": 2, "maerz": 3, "märz": 3,
  62. "april": 4, "mai": 5, "juni": 6, "juli": 7,
  63. "august": 8, "september": 9, "oktober": 10,
  64. "november": 11, "dezember": 12,
  65. }
  66. # "halb drei" → Stundenname-Mapping
  67. _HOUR_WORDS: dict[str, int] = {
  68. "eins": 1, "zwei": 2, "drei": 3, "vier": 4, "fuenf": 5, "fünf": 5,
  69. "sechs": 6, "sieben": 7, "acht": 8, "neun": 9, "zehn": 10,
  70. "elf": 11, "zwoelf": 12, "zwölf": 12,
  71. }
  72. # Dauer-Einheiten in Sekunden
  73. _DURATION_UNITS: dict[str, int] = {
  74. "sekunde": 1, "sekunden": 1,
  75. "minute": 60, "minuten": 60,
  76. "stunde": 3600, "stunden": 3600,
  77. }
  78. # ============================================================================
  79. # EntityRegistry
  80. # ============================================================================
  81. class EntityRegistry:
  82. """Laedt und verwaltet Entity-Definitionen aus YAML."""
  83. def __init__(self) -> None:
  84. self._entities: dict[str, EntityDefinition] = {}
  85. def load(self, directory: Path, language: str = "de") -> None:
  86. """Laedt entities_{lang}.yml aus dem angegebenen Verzeichnis."""
  87. yaml_path = directory / f"entities_{language}.yml"
  88. if not yaml_path.exists():
  89. pwarn(f"[EntityRegistry] Entity-Datei nicht gefunden: {yaml_path}")
  90. return
  91. try:
  92. import yaml
  93. except ImportError:
  94. pwarn("[EntityRegistry] PyYAML nicht installiert")
  95. return
  96. try:
  97. with open(yaml_path, "r", encoding="utf-8") as f:
  98. raw = yaml.safe_load(f)
  99. except Exception as e:
  100. pwarn(f"[EntityRegistry] YAML-Fehler in {yaml_path}: {e}")
  101. return
  102. if not isinstance(raw, dict):
  103. pwarn(f"[EntityRegistry] Ungueltiges Format in {yaml_path}")
  104. return
  105. self._entities.clear()
  106. for entity_name, entity_data in raw.items():
  107. if not isinstance(entity_data, dict):
  108. continue
  109. self._entities[entity_name] = _parse_entity_definition(
  110. entity_name, entity_data
  111. )
  112. pdebug(
  113. f"[EntityRegistry] {yaml_path.name}: "
  114. f"{len(self._entities)} Entity-Typen geladen"
  115. )
  116. def load_from_dict(self, data: dict[str, dict[str, Any]]) -> None:
  117. """Laedt Entity-Definitionen aus einem Dict (fuer Tests)."""
  118. self._entities.clear()
  119. for entity_name, entity_data in data.items():
  120. self._entities[entity_name] = _parse_entity_definition(
  121. entity_name, entity_data
  122. )
  123. def get(self, entity_type: str) -> EntityDefinition | None:
  124. """Gibt Entity-Definition zurueck."""
  125. return self._entities.get(entity_type)
  126. def matches(self, value: str, entity_type: str) -> bool:
  127. """Prueft ob ein Wert zu einem Entity-Typ passt."""
  128. entity_def = self._entities.get(entity_type)
  129. if entity_def is None:
  130. return True # Unbekannter Typ → alles akzeptieren
  131. v = value.strip().lower()
  132. # Exakter Match gegen bekannte Werte
  133. if v in entity_def.values:
  134. return True
  135. # Mehrwort-Match
  136. if v in entity_def.multi_word_values:
  137. return True
  138. # Regex-Match
  139. for pattern in entity_def.regex_patterns:
  140. if pattern.fullmatch(v):
  141. return True
  142. # Open-Entity: akzeptiert alles
  143. if entity_def.open:
  144. return True
  145. return False
  146. def match_multi_word(
  147. self, tokens: list[str], pos: int, entity_type: str
  148. ) -> tuple[str, int] | None:
  149. """
  150. Versucht Mehrwort-Match ab Position pos.
  151. Gibt (wert, neue_pos) zurueck oder None.
  152. """
  153. entity_def = self._entities.get(entity_type)
  154. if entity_def is None:
  155. return None
  156. # Laengste Matches zuerst (greedy)
  157. best: tuple[str, int] | None = None
  158. for mw_value in entity_def.multi_word_values:
  159. mw_tokens = mw_value.split()
  160. end = pos + len(mw_tokens)
  161. if end <= len(tokens):
  162. candidate = " ".join(tokens[pos:end]).lower()
  163. if candidate == mw_value:
  164. if best is None or len(mw_tokens) > (best[1] - pos):
  165. best = (mw_value, end)
  166. # Regex auf zusammengesetzten Text pruefen
  167. if best is None:
  168. for length in range(min(4, len(tokens) - pos), 0, -1):
  169. candidate = " ".join(tokens[pos:pos + length]).lower()
  170. for pattern in entity_def.regex_patterns:
  171. if pattern.fullmatch(candidate):
  172. return (candidate, pos + length)
  173. return best
  174. @property
  175. def entity_types(self) -> list[str]:
  176. """Gibt alle registrierten Entity-Typ-Namen zurueck."""
  177. return list(self._entities.keys())
  178. # ============================================================================
  179. # EntityResolver
  180. # ============================================================================
  181. class EntityResolver:
  182. """Loest Entity-Werte in strukturierte Daten auf."""
  183. def __init__(self, registry: EntityRegistry | None = None) -> None:
  184. self._registry = registry
  185. self._resolvers: dict[str, Any] = {
  186. "datum": self._resolve_datum,
  187. "zeit": self._resolve_zeit,
  188. "dauer": self._resolve_dauer,
  189. }
  190. def resolve(self, raw: str, entity_type: str) -> ResolvedEntity:
  191. """Loest einen Rohwert auf. Gibt ResolvedEntity zurueck."""
  192. resolver = self._resolvers.get(entity_type)
  193. if resolver is not None:
  194. try:
  195. return resolver(raw)
  196. except Exception:
  197. pass
  198. return self._resolve_generic(raw, entity_type)
  199. # ========================================================================
  200. # Datum-Resolver
  201. # ========================================================================
  202. def _resolve_datum(self, raw: str) -> ResolvedEntity:
  203. """Loest eine deutsche Datumsangabe auf."""
  204. today = date.today()
  205. s = raw.strip().lower()
  206. d: date | None = None
  207. # Relative Angaben
  208. if s in ("heute", "today"):
  209. d = today
  210. elif s in ("morgen", "tomorrow"):
  211. d = today + timedelta(days=1)
  212. elif s in ("uebermorgen", "übermorgen"):
  213. d = today + timedelta(days=2)
  214. elif s in _WEEKDAY_MAP:
  215. d = _next_weekday(today, _WEEKDAY_MAP[s])
  216. elif s == "am wochenende":
  217. d = _next_weekday(today, 5) # Samstag
  218. elif s == "diese woche":
  219. d = today
  220. elif s.startswith("nächsten ") or s.startswith("naechsten "):
  221. day_name = s.split(" ", 1)[1].strip()
  222. if day_name in _WEEKDAY_MAP:
  223. d = _next_weekday(today, _WEEKDAY_MAP[day_name])
  224. elif s == "nächste woche" or s == "naechste woche":
  225. # Naechster Montag
  226. d = _next_weekday(today, 0)
  227. else:
  228. d = _parse_absolute_date(s)
  229. if d is None:
  230. return self._resolve_generic(raw, "datum")
  231. return ResolvedEntity(
  232. raw=raw,
  233. entity_type="datum",
  234. value=d,
  235. formatted={
  236. "iso": d.isoformat(),
  237. "eu": d.strftime("%d.%m.%Y"),
  238. "tts": _format_date_tts(d),
  239. "api": d.isoformat(),
  240. },
  241. )
  242. # ========================================================================
  243. # Zeit-Resolver
  244. # ========================================================================
  245. def _resolve_zeit(self, raw: str) -> ResolvedEntity:
  246. """Loest eine deutsche Zeitangabe auf."""
  247. s = raw.strip().lower()
  248. t: time | None = None
  249. # Benannte Zeiten
  250. if s == "mittag":
  251. t = time(12, 0)
  252. elif s == "mitternacht":
  253. t = time(0, 0)
  254. elif s == "abend":
  255. t = time(18, 0)
  256. elif s in ("morgens", "morgen früh", "morgen frueh"):
  257. t = time(8, 0)
  258. elif s == "nachmittag":
  259. t = time(15, 0)
  260. else:
  261. parsed = _parse_time_string(s)
  262. if parsed is not None:
  263. h, m = parsed
  264. t = time(h, m)
  265. if t is None:
  266. return self._resolve_generic(raw, "zeit")
  267. return ResolvedEntity(
  268. raw=raw,
  269. entity_type="zeit",
  270. value=t,
  271. formatted={
  272. "iso": t.isoformat(),
  273. "24h": t.strftime("%H:%M"),
  274. "tts": _format_time_tts(t),
  275. },
  276. )
  277. # ========================================================================
  278. # Dauer-Resolver
  279. # ========================================================================
  280. def _resolve_dauer(self, raw: str) -> ResolvedEntity:
  281. """Loest eine deutsche Dauerangabe auf."""
  282. s = raw.strip().lower()
  283. total_seconds: int | None = None
  284. # "eine minute", "eine stunde", "eine halbe stunde"
  285. if s in ("eine minute", "1 minute"):
  286. total_seconds = 60
  287. elif s in ("eine stunde", "1 stunde"):
  288. total_seconds = 3600
  289. elif s in ("eine halbe stunde",):
  290. total_seconds = 1800
  291. else:
  292. # "5 minuten", "30 sekunden", "2 stunden"
  293. m = re.match(r"(\d+)\s+(sekunden?|minuten?|stunden?)", s)
  294. if m:
  295. amount = int(m.group(1))
  296. unit = m.group(2)
  297. multiplier = _DURATION_UNITS.get(unit, 0)
  298. if multiplier:
  299. total_seconds = amount * multiplier
  300. if total_seconds is None:
  301. return self._resolve_generic(raw, "dauer")
  302. minutes = total_seconds // 60
  303. hours = total_seconds // 3600
  304. return ResolvedEntity(
  305. raw=raw,
  306. entity_type="dauer",
  307. value=total_seconds,
  308. formatted={
  309. "seconds": str(total_seconds),
  310. "minutes": str(minutes),
  311. "hours": str(hours),
  312. "tts": _format_duration_tts(total_seconds),
  313. },
  314. )
  315. # ========================================================================
  316. # Generic Resolver (Fallback)
  317. # ========================================================================
  318. def _resolve_generic(self, raw: str, entity_type: str) -> ResolvedEntity:
  319. """Generischer Resolver fuer unbekannte Entity-Typen."""
  320. return ResolvedEntity(
  321. raw=raw,
  322. entity_type=entity_type,
  323. value=raw.strip().lower(),
  324. formatted={"raw": raw.strip()},
  325. )
  326. # ============================================================================
  327. # Hilfsfunktionen
  328. # ============================================================================
  329. def _parse_entity_definition(
  330. name: str, data: dict[str, Any]
  331. ) -> EntityDefinition:
  332. """Parst eine Entity-Definition aus YAML-Daten."""
  333. raw_values = data.get("values", [])
  334. if not isinstance(raw_values, list):
  335. raw_values = []
  336. values: set[str] = set()
  337. multi_word_values: set[str] = set()
  338. for v in raw_values:
  339. v_str = str(v).strip().lower()
  340. if not v_str:
  341. continue
  342. if " " in v_str:
  343. multi_word_values.add(v_str)
  344. else:
  345. values.add(v_str)
  346. # Regex-Patterns kompilieren
  347. regex_patterns: list[re.Pattern[str]] = []
  348. raw_regex = data.get("regex", [])
  349. if isinstance(raw_regex, list):
  350. for r in raw_regex:
  351. try:
  352. regex_patterns.append(re.compile(str(r), re.IGNORECASE))
  353. except re.error:
  354. pwarn(f"[EntityRegistry] Ungueltiger Regex fuer '{name}': {r}")
  355. return EntityDefinition(
  356. name=name,
  357. values=values,
  358. multi_word_values=multi_word_values,
  359. open=bool(data.get("open", False)),
  360. regex_patterns=regex_patterns,
  361. )
  362. def _next_weekday(start: date, weekday: int) -> date:
  363. """Berechnet den naechsten Wochentag ab start (exklusive heute)."""
  364. diff = weekday - start.weekday()
  365. if diff <= 0:
  366. diff += 7
  367. return start + timedelta(days=diff)
  368. def _parse_absolute_date(s: str) -> date | None:
  369. """Parst absolute Datumsformate: '15.03.', '15.03.2026'."""
  370. today = date.today()
  371. # "15.03." oder "15.3."
  372. m = re.match(r"(\d{1,2})\.(\d{1,2})\.$", s)
  373. if m:
  374. day, month = int(m.group(1)), int(m.group(2))
  375. try:
  376. target = date(today.year, month, day)
  377. if target < today:
  378. target = date(today.year + 1, month, day)
  379. return target
  380. except ValueError:
  381. return None
  382. # "15.03.2026"
  383. m = re.match(r"(\d{1,2})\.(\d{1,2})\.(\d{4})$", s)
  384. if m:
  385. day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
  386. try:
  387. return date(year, month, day)
  388. except ValueError:
  389. return None
  390. # "5 mai", "15 januar" — Tag + Monatsname
  391. m = re.match(r"(\d{1,2})\s+(\w+)$", s)
  392. if m:
  393. day_val = int(m.group(1))
  394. month_val = _MONTH_NAME_MAP.get(m.group(2))
  395. if month_val is not None:
  396. try:
  397. target = date(today.year, month_val, day_val)
  398. if target < today:
  399. target = date(today.year + 1, month_val, day_val)
  400. return target
  401. except ValueError:
  402. return None
  403. return None
  404. def _parse_time_string(s: str) -> tuple[int, int] | None:
  405. """Parst deutsche Zeitangaben."""
  406. # "15:00 Uhr" oder "15:00" oder "9:30"
  407. m = re.match(r"(\d{1,2}):(\d{2})\s*(?:uhr)?$", s)
  408. if m:
  409. h, mi = int(m.group(1)), int(m.group(2))
  410. if 0 <= h <= 23 and 0 <= mi <= 59:
  411. return (h, mi)
  412. # "15 Uhr 30"
  413. m = re.match(r"(\d{1,2})\s*uhr\s*(\d{1,2})$", s)
  414. if m:
  415. h, mi = int(m.group(1)), int(m.group(2))
  416. if 0 <= h <= 23 and 0 <= mi <= 59:
  417. return (h, mi)
  418. # "15 Uhr"
  419. m = re.match(r"(\d{1,2})\s*uhr$", s)
  420. if m:
  421. h = int(m.group(1))
  422. if 0 <= h <= 23:
  423. return (h, 0)
  424. # "halb drei" → 2:30
  425. m = re.match(r"halb\s+(\w+)$", s)
  426. if m:
  427. hour_word = m.group(1)
  428. hour_val = _HOUR_WORDS.get(hour_word)
  429. if hour_val is not None:
  430. h = hour_val - 1
  431. if h < 0:
  432. h = 23
  433. return (h, 30)
  434. return None
  435. def _format_date_tts(d: date) -> str:
  436. """Formatiert ein Datum fuer TTS-Ausgabe."""
  437. today = date.today()
  438. diff = (d - today).days
  439. if diff == 0:
  440. return "heute"
  441. if diff == 1:
  442. return "morgen"
  443. if diff == 2:
  444. return "uebermorgen"
  445. if 0 < diff <= 6:
  446. return f"am {_WEEKDAY_NAMES[d.weekday()]}"
  447. day = d.day
  448. month = _MONTH_NAMES[d.month]
  449. return f"am {day}. {month}"
  450. def _format_time_tts(t: time) -> str:
  451. """Formatiert eine Uhrzeit fuer TTS-Ausgabe."""
  452. if t.minute == 0:
  453. return f"{t.hour} Uhr"
  454. return f"{t.hour} Uhr {t.minute:02d}"
  455. def _format_duration_tts(total_seconds: int) -> str:
  456. """Formatiert eine Dauer fuer TTS-Ausgabe."""
  457. if total_seconds < 60:
  458. unit = "Sekunde" if total_seconds == 1 else "Sekunden"
  459. return f"{total_seconds} {unit}"
  460. elif total_seconds < 3600:
  461. minutes = total_seconds // 60
  462. unit = "Minute" if minutes == 1 else "Minuten"
  463. return f"{minutes} {unit}"
  464. else:
  465. hours = total_seconds // 3600
  466. remaining_minutes = (total_seconds % 3600) // 60
  467. unit = "Stunde" if hours == 1 else "Stunden"
  468. if remaining_minutes:
  469. min_unit = "Minute" if remaining_minutes == 1 else "Minuten"
  470. return f"{hours} {unit} {remaining_minutes} {min_unit}"
  471. return f"{hours} {unit}"