曜日の時間表現の解析

# はじめに
文中の時間表現を文脈を考慮して正規化したいです。例えば「2021年4月1日の1日前の午後10時」を「2021-3-31 22:00:00」にする、みたいなことです。応用先としては、対話形式でのスケジュール設定などがあります。


今回はその中でも曜日の時間表現の解析方法を検討します。
具体的には「次の日曜日」「今週の日曜日」「来週の日曜日」などの表現から具体的な日時を特定できるようになりたいです。

# 難しいところ
週の始まりを月曜日とします。「次の日曜日」は基準時刻によって「今週の日曜日」か「来週の日曜日」かが異なります。具体的には基準時刻の曜日が日曜日より前なら今週になり、そうでないなら来週になります。「次」と「今週」「来週」の処理をうまく分ける必要があります。

# ja-timex
ja-timexが使えそうです。
ja-timexは日本語の時間表現をルールベースで解析するライブラリです。
試しに課題となる文章を解析してみます。


```
pip install ja-timex
```

```Python
from ja_timex import TimexParser
import pendulum
from datetime import datetime

words = [
    "次の日曜日",
    "今週の日曜日",
    "来週の日曜日"
]
for word in words:
    print(word)
    now = datetime.now()
    timexes = TimexParser(reference=pendulum.now()).parse(word)

    for timex in timexes:
        print(timex, timex.to_duration(), timex.to_datetime())
    print()
```

```
次の日曜日
<TIMEX3 tid="t0" type="DATE" value="XXXX-WXX-7" text="日曜日"> 0 microseconds None

今週の日曜日
<TIMEX3 tid="t0" type="DURATION" value="P0W" mod="NOW" text="今週"> 0 microseconds 2024-10-30T14:24:17.403706+09:00
<TIMEX3 tid="t1" type="DATE" value="XXXX-WXX-7" text="日曜日"> 0 microseconds None

来週の日曜日
<TIMEX3 tid="t0" type="DURATION" value="P1W" mod="AFTER" text="来週"> 1 week 2024-11-06T14:24:17.404865+09:00
<TIMEX3 tid="t1" type="DATE" value="XXXX-WXX-7" text="日曜日"> 0 microseconds None
```

以下のようなことがわかります。
- 曜日はtype="DATE"でありvalueのハイフンで区切られた2つめの要素が「WXX」、3つめの要素が曜日番号として正規化されます。
- 曜日番号は日曜が7なので、ja-timex上での曜日の定義は1始まりで1が月曜、7が日曜です。
- 曜日のTIMEXクラスはto_datetimeやto_durationに対応していません。
- 「次」は解析できていません。
- 「今週」「来週」はtype="DURATION"です。値が「0/1 week」なのでaddできそうです。

# 実装
ja-timexで使われているdatetimeを拡張したpendulumではnextというメソッドがあり、これによって一番近い未来の曜日を指定できます。よって「次の」はこれで処理できます。
「今週」「来週」については週のdurationを加算したうえで曜日番号を指定すればよいです。

まず「次のX曜日」という時間表現を解析できるように、CustomParserを設定します。

```Python
import re
from typing import List

#from ja_timex.tag import TIMEX
from ja_timex.tagger import BaseTagger
from ja_timex.pattern.place import Pattern
from ja_timex.pattern.abstime import parse_weekday

custom_pattern = [
    Pattern(
        re_pattern="(次の)+(?P<weekday>[月火水木金土日])(曜日|曜)",
        parse_func=parse_weekday,
        option={},
    )
]

class CustomTagger(BaseTagger):
    def __init__(self, patterns: List[Pattern] = custom_pattern) -> None:
        self.patterns = patterns


from ja_timex.timex import TimexParser

timex_parser = TimexParser(custom_tagger=CustomTagger())

print(timex_parser.parse("次の次の水曜日"))
```

```
[<TIMEX3 tid="t0" type="DATE" value="XXXX-WXX-3" text="次の次の水曜日">]
```

いい感じです。一応、「次の」が繰り返されても良いようにしています。

次に、曜日から具体的な日時を予測する関数を作ります。
曜日が「次の」で始まるときはnextメソッドを使い、そうでないときは週番号を維持したままweekday_idを変更します。weekday_idをセットすることはpendulumでは直接はできなさそうだったため、nextを実行したあと、週番号が進んだら1戻す、という処理をしています。

```Python
def parse_datetime(text: str, reference: pendulum.DateTime = None):
    reference = reference or pendulum.now()
    event_datetime = reference
    timex_parser = TimexParser(reference=reference, custom_tagger=CustomTagger())
    timexes = timex_parser.parse(text)
    for timex in timexes:
        if timex.type == "DATE":
            values = timex.value.split("-")
            # weekdayの場合
            if "W" in values[1]:
                weekday_id = int(values[2])
                weekday_id %= 7 # ja-timexでは日曜日が7だがpendulumでは0なので7で割った余りを使う
                # 「次の」の場合
                if timex.text.startswith("次の"):
                    # 「次の」の回数分、次の曜日を取得
                    next_count = len(re.findall("次の", timex.text))
                    for _ in range(next_count):
                        event_datetime = event_datetime.next(weekday_id)
                # それ以外は週を維持したまあ曜日を設定する
                else:                 
                  previous_event_datetime = event_datetime
                  event_datetime = event_datetime.next(weekday_id)
                  if event_datetime.week_of_year != previous_event_datetime.week_of_year:
                      event_datetime = event_datetime.subtract(weeks=1)
            else:
                pass
        elif timex.type == "DURATION":
            if timex.mod == "BEFORE":
                event_datetime -= timex.to_duration()
            elif timex.mod == "AFTER":
                event_datetime += timex.to_duration()
    return event_datetime

print(parse_datetime("再来週の水曜日", reference = pendulum.datetime(2024, 10, 30, tz="Asia/Tokyo")))
```

```
DateTime(2024, 11, 13, 0, 0, 0, tzinfo=Timezone('Asia/Tokyo'))
```

よさそうです。

いくつかテストケースを作って期待値と比較します。

```Python
# parse_datetime関数のテストケースを作成します。
# 基準日時は2024年10月30日（水曜日）です。

reference_date = pendulum.datetime(2024, 10, 30) #水曜日

test_cases = [
    ("次の水曜日", "2024-11-06"),  # 次の水曜日は11月6日
    ("再来週の水曜日", "2024-11-13"),  # 再来週の水曜日は11月13日
    ("次の次の水曜日", "2024-11-13"),  # 次の次の水曜日も11月13日
    ("今週の水曜日", "2024-10-30"),  # 今週の水曜日は10月30日
    ("来週の水曜日", "2024-11-06"),  # 来週の水曜日は11月6日
    ("次の月曜日", "2024-11-04"),  # 次の月曜日は11月4日
    ("次の金曜日", "2024-11-01"),  # 次の金曜日は11月1日
    ("次の次の月曜日", "2024-11-11"),  # 次の次の月曜日は11月11日
    ("次の日曜日", "2024-11-03"),  # 次の日曜日は11月3日
    ("再来週の日曜日", "2024-11-17"),  # 再来週の日曜日は11月17日
]

for text, expected in test_cases:
    result = parse_datetime(text, reference_date).to_date_string()
    print(f"入力: {text}, 結果: {result}, 期待値: {expected}, 一致: {result == expected}")
```

```
入力: 次の水曜日, 結果: 2024-11-06, 期待値: 2024-11-06, 一致: True
入力: 再来週の水曜日, 結果: 2024-11-13, 期待値: 2024-11-13, 一致: True
入力: 次の次の水曜日, 結果: 2024-11-13, 期待値: 2024-11-13, 一致: True
入力: 今週の水曜日, 結果: 2024-10-30, 期待値: 2024-10-30, 一致: True
入力: 来週の水曜日, 結果: 2024-11-06, 期待値: 2024-11-06, 一致: True
入力: 次の月曜日, 結果: 2024-11-04, 期待値: 2024-11-04, 一致: True
入力: 次の金曜日, 結果: 2024-11-01, 期待値: 2024-11-01, 一致: True
入力: 次の次の月曜日, 結果: 2024-11-11, 期待値: 2024-11-11, 一致: True
入力: 次の日曜日, 結果: 2024-11-03, 期待値: 2024-11-03, 一致: True
入力: 再来週の日曜日, 結果: 2024-11-17, 期待値: 2024-11-17, 一致: True
```

いい感じです。

# おわりに
とりあえず「XのY曜日」という形式の文章をうまく解析することができました。
今後の課題はこの処理を通常の時間表現の解析と組み合わせてうまく動くようにすることです。