entities.py 22 KB

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