time_parser.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. # -*- coding: utf-8 -*-
  2. """
  3. Einfacher deutscher Zeitparser fuer Erinnerungen.
  4. Parst natuerlichsprachliche Zeitangaben wie:
  5. - "morgen", "uebermorgen"
  6. - "morgen um 14 Uhr"
  7. - "in 2 Stunden", "in 30 Minuten"
  8. - "naechsten Montag"
  9. - "heute Abend", "heute Nachmittag"
  10. - "2 Uhr" (ambiguous: VM/NM Rueckfrage)
  11. - "14 Uhr" (eindeutig)
  12. """
  13. import re
  14. from dataclasses import dataclass
  15. from datetime import date, datetime, timedelta
  16. @dataclass
  17. class ParsedTime:
  18. """Ergebnis des Zeitparsers."""
  19. dt: datetime | None = None
  20. ambiguous: bool = False
  21. option_a: str = "" # z.B. "2 Uhr morgens"
  22. option_b: str = "" # z.B. "14 Uhr nachmittags"
  23. error: str = ""
  24. def parse_german_time(text: str, default_time: str = "09:00") -> ParsedTime:
  25. """
  26. Parst einen deutschen Zeittext in ein datetime.
  27. Args:
  28. text: Natuerlichsprachliche Zeitangabe
  29. default_time: Standard-Uhrzeit wenn keine angegeben (HH:MM)
  30. Returns:
  31. ParsedTime mit geparster Zeit oder Fehler
  32. """
  33. text_lower = text.lower().strip()
  34. now = datetime.now()
  35. default_hour, default_minute = _parse_hm(default_time)
  36. # "in X Stunden/Minuten" — relative Zeit
  37. relative = _parse_relative(text_lower, now)
  38. if relative:
  39. return relative
  40. # Tag bestimmen
  41. target_day = _parse_day(text_lower, now)
  42. # Uhrzeit bestimmen
  43. time_result = _parse_time_of_day(text_lower, now)
  44. if target_day is None and time_result is None:
  45. return ParsedTime(error=f"Zeitangabe '{text}' nicht erkannt")
  46. if target_day is None:
  47. # Nur Uhrzeit angegeben → heute oder morgen
  48. target_day = now.date()
  49. if time_result and not time_result.ambiguous and time_result.dt:
  50. # Wenn die Zeit schon vorbei ist → morgen
  51. candidate = datetime.combine(target_day, time_result.dt.time())
  52. if candidate <= now:
  53. target_day = target_day + timedelta(days=1)
  54. if time_result is None:
  55. # Nur Tag → Default-Uhrzeit
  56. dt = datetime.combine(target_day, datetime.min.time().replace(
  57. hour=default_hour, minute=default_minute,
  58. ))
  59. return ParsedTime(dt=dt)
  60. if time_result.ambiguous:
  61. # Tag + ambige Uhrzeit
  62. time_result.option_a = f"{time_result.option_a}"
  63. time_result.option_b = f"{time_result.option_b}"
  64. return time_result
  65. if time_result.dt:
  66. dt = datetime.combine(target_day, time_result.dt.time())
  67. return ParsedTime(dt=dt)
  68. return ParsedTime(error=f"Zeitangabe '{text}' nicht erkannt")
  69. def _parse_hm(time_str: str) -> tuple[int, int]:
  70. """Parst HH:MM String."""
  71. parts = time_str.split(":")
  72. return int(parts[0]), int(parts[1]) if len(parts) > 1 else 0
  73. def _parse_relative(text: str, now: datetime) -> ParsedTime | None:
  74. """Parst relative Zeitangaben wie 'in 2 Stunden'."""
  75. # "in X Stunden"
  76. match = re.search(r"in\s+(\d+)\s+stunde[n]?", text)
  77. if match:
  78. hours = int(match.group(1))
  79. return ParsedTime(dt=now + timedelta(hours=hours))
  80. # "in einer Stunde"
  81. if re.search(r"in\s+einer?\s+stunde", text):
  82. return ParsedTime(dt=now + timedelta(hours=1))
  83. # "in X Minuten"
  84. match = re.search(r"in\s+(\d+)\s+minute[n]?", text)
  85. if match:
  86. minutes = int(match.group(1))
  87. return ParsedTime(dt=now + timedelta(minutes=minutes))
  88. # "in einer halben Stunde"
  89. if re.search(r"in\s+einer?\s+halben\s+stunde", text):
  90. return ParsedTime(dt=now + timedelta(minutes=30))
  91. return None
  92. def _parse_day(text: str, now: datetime) -> date | None:
  93. """Parst den Tag aus dem Text."""
  94. today = now.date()
  95. if "heute" in text:
  96. return today
  97. if "morgen" in text and "uebermorgen" not in text and "übermorgen" not in text:
  98. return today + timedelta(days=1)
  99. if "uebermorgen" in text or "übermorgen" in text:
  100. return today + timedelta(days=2)
  101. # "naechsten/nächsten Montag" etc.
  102. wochentage = {
  103. "montag": 0, "dienstag": 1, "mittwoch": 2, "donnerstag": 3,
  104. "freitag": 4, "samstag": 5, "sonntag": 6,
  105. }
  106. for tag, weekday in wochentage.items():
  107. if tag in text:
  108. days_ahead = (weekday - today.weekday()) % 7
  109. if days_ahead == 0:
  110. days_ahead = 7 # Naechste Woche
  111. if "naechsten" in text or "nächsten" in text or "kommenden" in text:
  112. if days_ahead <= 0:
  113. days_ahead += 7
  114. return today + timedelta(days=days_ahead)
  115. return None
  116. def _parse_time_of_day(text: str, now: datetime) -> ParsedTime | None:
  117. """Parst die Uhrzeit aus dem Text."""
  118. # "heute Abend", "heute Nachmittag", "heute Morgen"
  119. if "abend" in text or "abends" in text:
  120. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(hour=19)))
  121. if "nachmittag" in text or "nachmittags" in text:
  122. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(hour=15)))
  123. if re.search(r"\bmorgens?\b", text) and "morgen" not in text.replace("morgens", ""):
  124. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(hour=8)))
  125. if "mittag" in text and "nachmittag" not in text:
  126. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(hour=12)))
  127. # "um HH:MM [Uhr]"
  128. match = re.search(r"(?:um\s+)?(\d{1,2})[:\.](\d{2})\s*(?:uhr)?", text)
  129. if match:
  130. hour = int(match.group(1))
  131. minute = int(match.group(2))
  132. if 0 <= hour <= 23 and 0 <= minute <= 59:
  133. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(
  134. hour=hour, minute=minute,
  135. )))
  136. # "um X Uhr" oder "X Uhr"
  137. match = re.search(r"(?:um\s+)?(\d{1,2})\s*uhr", text)
  138. if match:
  139. hour = int(match.group(1))
  140. if 13 <= hour <= 23:
  141. # Eindeutig Nachmittag/Abend
  142. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(hour=hour)))
  143. if 0 <= hour <= 12:
  144. if hour <= 6:
  145. # 1-6 Uhr: wahrscheinlich Nachmittag → trotzdem Rueckfrage
  146. return ParsedTime(
  147. ambiguous=True,
  148. option_a=f"{hour} Uhr morgens",
  149. option_b=f"{hour + 12} Uhr nachmittags",
  150. )
  151. if hour >= 7 and hour <= 12:
  152. # 7-12 Uhr: wahrscheinlich Vormittag, aber Rueckfrage bei unklarem Kontext
  153. # Wenn "morgens" oder "vormittag" im Text → eindeutig
  154. if "morgens" in text or "vormittag" in text or "frueh" in text or "früh" in text:
  155. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(hour=hour)))
  156. if "nachmittag" in text or "abend" in text:
  157. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(
  158. hour=hour + 12 if hour != 12 else 12,
  159. )))
  160. # 7-11 Uhr ohne Kontext → vormittag (hohe Wahrscheinlichkeit)
  161. if 7 <= hour <= 11:
  162. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(hour=hour)))
  163. # 12 Uhr → eindeutig Mittag
  164. if hour == 12:
  165. return ParsedTime(dt=datetime.combine(now.date(), datetime.min.time().replace(hour=12)))
  166. return None