文脈を考慮した時間表現の正規化（ja-timex）

# はじめに
文中の時間表現を文脈を考慮して正規化したいです。例えば「2021年4月1日の1日前の午後10時」を「2021-3-31 22:00:00」みたいなことをしたり、基準日を2024-12-24 15:15:15としたとき、「明日の午前7時」を「2024-12-25 07:00:00」に変換したり、みたいなことをしたいです。

応用先としては、対話形式でのスケジュール設定などがあります。

# ja-timex
ja-timexという、日本語の時間表現を解析するライブラリが使えそうです。

試してみます。

```
pip install ja-timex
```

```Python
from ja_timex import TimexParser
import pendulum
from datetime import datetime
now = datetime.now()
timexes = TimexParser(reference=pendulum.now()).parse("2024年3月11日の3日前の明日の午後七時")

for timex in timexes:
    print(timex)
```

```
<TIMEX3 tid="t0" type="DATE" value="2024-03-11" text="2024年3月11日">
<TIMEX3 tid="t1" type="DURATION" value="P3D" mod="BEFORE" text="3日前">
<TIMEX3 tid="t2" type="DURATION" value="P1D" mod="AFTER" text="明日">
<TIMEX3 tid="t3" type="TIME" value="T19-XX-XX" text="午後7時">
```

出力を見ると「3日前」「明日」などの相対的な時間表現はtype="DURATION"、「2024年3月11日」「午後7時」などの絶対的な時間表現はtype="DATE"やtype="TIME"となるようです。

# 実装
ja-timexは表層的な時間表現を解析するライブラリのため、文脈を踏まえて文章全体が表す絶対的な日時はプログラム側で計算する必要があります。
スケジュール設定的な用途を考えると、時間表現は文頭から順番に更新されることがほとんどと思われますので、ja-timexで取得された時間表現を順にみていって日時を更新する処理を書いてみます。

```Python
def parse_remind_time(text: str, now: datetime = None) -> datetime:
    now = now or pendulum.now()
    # type="TIME"の場合、referenceがないとto_datetimeでNoneが返るのでreferenceを指定する
    timexes = TimexParser(reference=now).parse(text)
    for timex in timexes:
        # DURATIONは相対的な日時のため、timedeltaに変換してmodの値に応じてnowに加減算する
        if timex.type == "DURATION":
            if timex.mod == "BEFORE":
                now -= timex.to_duration()
            elif timex.mod == "AFTER":
                now += timex.to_duration()
        # DATEは絶対的な日時のため、datetimeに変換して、対応する値をnowに代入する
        elif timex.type == "DATE":
            year_str, month_str, day_str = timex.value[1:].split("-")
            timexdatetime = timex.to_datetime()
            if year_str != "XX":
                now = now.set(year = timexdatetime.year)
            if month_str != "XX":
                now = now.set(month = timexdatetime.month)
            if day_str != "XX":
                now = now.set(day = timexdatetime.day)
        # TIMEは絶対的な時刻のため、datetimeに変換して、対応する値をnowに代入する
        elif timex.type == "TIME":
            hour_str, minute_str, second_str = timex.value[1:].split("-")
            timexdatetime = timex.to_datetime()
            # pm7時->to_datetimeでhour=7、のようになるので、pm_prefix or pm_suffixがある場合は12を加える
            # pm19時のような表現は（微妙だが)19時として扱うほうが妥当な場合が多そうなので、hourが12以下の場合のみ12を加える
            if timex.parsed.get('pm_prefix') or timex.parsed.get('pm_suffix'):
                if type(timex.parsed.get('clock_hour')) is str and timex.parsed.get('clock_hour').isdigit() and int(timex.parsed.get('clock_hour')) <= 12:
                    timexdatetime = timexdatetime.add(hours=12)
            
            # 表層で指定された時刻があれば代入する
            if hour_str != "XX":
                now = now.set(hour = timexdatetime.hour)
            if minute_str != "XX":
                now = now.set(minute = timexdatetime.minute)
            if second_str != "XX":
                now = now.set(second = timexdatetime.second)
            
            # hourのみ指定されたら「ちょうど」と解釈する。例：「7時」は7:00:00と解釈する
            if hour_str != "XX" and minute_str == "XX" and second_str == "XX":
                now = now.set(minute = 0)
                now = now.set(second = 0)
            # minuteのみ指定されたら「ちょうど」と解釈する。例：「15分」はXX:15:00と解釈する
            elif minute_str != "XX" and second_str == "XX":
                now = now.set(second = 0)
    return now
```

適当に作ったサンプルでテストしてみます。

```Python
reference = pendulum.datetime(2024, 12, 24, 15, 15, 15) #火曜日
testcases = [
  ("5時", "2024-12-24 17:00:00"), # 近い時刻の未来なのでPM
  ("明日の7時", "2024-12-25 07:00:00"), #近い時刻の未来なのでAM
  ("明日の午後7時", "2024-12-25 19:00:00"),
  ("今日の26時", "2024-12-25 02:00:00"), # 26時=翌AM2時
  ("来年の5月", "2025-05-24 15:15:15"),
  ("来週", "2024-12-31 15:15:15"),
  ("来週水曜日", "2025-01-01 15:15:15"), # 基準日が火曜なので
  ("朝９時", "2024-12-25 09:00:00"), # 朝なのでAM、近い未来なので翌日
  ("夕方６時", "2024-12-24 18:00:00"), # 夕方なのでPM
  ("1ヶ月後", "2025-01-24 15:15:15"),
  ("1年３ヶ月後", "2026-01-24 15:15:15"),
  ("1日の午後7時", "2025-01-01 19:00:00"), # 「1日」は直近の未来の日付と解釈し、翌月1日としてあつかう
  ("10秒後", "2024-12-24 15:15:25"),
]
for text, expected in testcases:
  try:
    result = parse_remind_time(text, reference).strftime("%Y-%m-%d %H:%M:%S")
    print(f"入力: {text}, 結果: {result}, 期待値: {expected}, 一致: {result == expected}")
  except Exception as e:
    print(f"入力: {text}, エラー: {e}")
```

```
入力: 5時, 結果: 2024-12-24 05:00:00, 期待値: 2024-12-24 17:00:00, 一致: False
入力: 明日の7時, 結果: 2024-12-25 07:00:00, 期待値: 2024-12-25 07:00:00, 一致: True
入力: 明日の午後7時, 結果: 2024-12-25 19:00:00, 期待値: 2024-12-25 19:00:00, 一致: True
入力: 今日の26時, 結果: 2024-12-24 02:00:00, 期待値: 2024-12-25 02:00:00, 一致: False
入力: 来年の5月, 結果: 2025-05-24 15:15:15, 期待値: 2025-05-24 15:15:15, 一致: True
入力: 来週, 結果: 2024-12-31 15:15:15, 期待値: 2024-12-31 15:15:15, 一致: True
入力: 来週水曜日, エラー: 'NoneType' object has no attribute 'month'
入力: 朝９時, 結果: 2024-12-24 09:00:00, 期待値: 2024-12-25 09:00:00, 一致: False
入力: 夕方６時, 結果: 2024-12-24 06:00:00, 期待値: 2024-12-24 18:00:00, 一致: False
入力: 1ヶ月後, 結果: 2025-01-24 15:15:15, 期待値: 2025-01-24 15:15:15, 一致: True
入力: 1年３ヶ月後, 結果: 2024-12-24 15:15:15, 期待値: 2026-01-24 15:15:15, 一致: False
入力: 1日の午後7時, 結果: 2024-12-01 19:00:00, 期待値: 2025-01-01 19:00:00, 一致: False
入力: 10秒後, 結果: 2024-12-24 15:15:25, 期待値: 2024-12-24 15:15:25, 一致: True
入力: 25年の5月, 結果: 2024-05-24 15:15:15, 期待値: 2025-05-24 15:15:15, 一致: False
```

## 成功するケース
- 「10秒後」など日付をまたがないレベルでの相対的な時刻指定
- 「明日の午後7時」などam,pmの曖昧性のない日時指定
## 失敗するケース
- 「5時」など午前午後の曖昧性が解消できないケース
  - 「リマインダなら直近の未来」などヒューリスティクスに基づいて決めつける、もしくは、ユーザに聞き返すなどの対応が必要
- 「夕方」などam, pmの特殊ケース
  - 「朝」や「夜」はいけそう。ユースケースにおうじて辞書を追加する必要がある。
- 「X年Xヶ月後」という相対的な指定
  - 「X年後」「Xヶ月後」はいけるが「X年Xヶ月後」というパターンが辞書になさそう。
- 曜日
  - 曜日自体の解析はja-timexできているのでルールの追加だけでなんとかなりそう。
## その他考える必要があること
- 省略時の初期値
  - 年月日時分秒のそれぞれについて省略時の初期値を設定しておくと良さそう。現在時刻を引き継ぐケースと「0」など固定値にすべきケースがあり、自分以外の指定の有無によって分岐をする必要がありそう。
  - 自分以外の指定の有無も考慮する場合、単一の時間表現トークンのみで処理が完結しないので、文章全体で何が指定されていて指定されていないかを考える必要がありそう。

# おわりに
現状でもある程度使えはするので、見つかった課題の改善はアプリケーション作成時に必要が生じたらでよさそうです。
一旦ここまでにします。