Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 79 additions & 12 deletions app/services/parser_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@
re.compile(r"(?<![\d/.-])(?P<month>\d{1,2})/(?P<day>\d{1,2})(?![\d/.-])"),
]

# 종료일의 월이 생략된 범위를 처리합니다: 8월 20일부터 22일까지, 20일 부터 22일까지.
INHERITED_MONTH_DATE_RANGE_PATTERN = re.compile(
r"(?<!\d)"
r"(?:(?P<year>\d{4})년\s*)?"
r"(?:(?P<month>\d{1,2})월\s*)?"
r"(?P<start_day>\d{1,2})일\s*부터\s*"
r"(?P<end_day>\d{1,2})일\s*까지"
)

# 오전/오후, 반, 분, 쯤/경이 포함된 시간 표현을 처리합니다.
TIME_PATTERN = re.compile(
r"(?:(?P<period>오전|오후|저녁|아침|밤|새벽|점심|낮)(?:에)?\s*)?"
Expand Down Expand Up @@ -171,6 +180,8 @@ def _extract_date(source_text: str, reference_date: date | None = None) -> Extra
candidates: list[tuple[int, int, ExtractedValue]] = []
removable_texts: list[str] = []

inherited_month_range = _extract_inherited_month_date_range(source_text, today)

for pattern in DATE_PATTERNS:
for match in pattern.finditer(source_text):
year = int(match.groupdict().get("year") or today.year)
Expand Down Expand Up @@ -307,8 +318,29 @@ def _extract_date(source_text: str, reference_date: date | None = None) -> Extra
)

date_range = _extract_date_range(source_text, candidates, removable_texts)
if date_range.value is not None:
return date_range
range_candidates = [
candidate
for candidate in (date_range, inherited_month_range)
if candidate is not None
]
if range_candidates:
_, selected_range = min(range_candidates, key=lambda candidate: candidate[0])
range_removable_texts = [
removable_text
for _, candidate in range_candidates
for removable_text in candidate.removable_texts
]
return ExtractedValue(
value=selected_range.value,
end_value=selected_range.end_value,
text=selected_range.text,
removable_texts=_dedupe_longest_first(
[*removable_texts, *range_removable_texts]
),
date_source=selected_range.date_source,
is_past=selected_range.is_past,
is_ambiguous=selected_range.is_ambiguous,
)

if candidates:
selected = min(candidates, key=lambda candidate: candidate[0])[2]
Expand All @@ -324,6 +356,38 @@ def _extract_date(source_text: str, reference_date: date | None = None) -> Extra
return ExtractedValue(value=None)


def _extract_inherited_month_date_range(
source_text: str,
today: date,
) -> tuple[int, ExtractedValue] | None:
"""생략된 월은 시작일의 월 또는 서비스 기준 현재 월로 보완해 날짜 범위를 반환합니다."""
match = INHERITED_MONTH_DATE_RANGE_PATTERN.search(source_text)
if match is None:
return None

year = int(match.group("year") or today.year)
month = int(match.group("month") or today.month)

try:
start_date = date(year, month, int(match.group("start_day")))
end_date = date(year, month, int(match.group("end_day")))
except ValueError:
return None

removable_text = match.group(0)
return (
match.start(),
ExtractedValue(
value=start_date.isoformat(),
end_value=end_date.isoformat() if end_date >= start_date else None,
text=removable_text,
removable_texts=[removable_text],
date_source="EXPLICIT",
is_past=start_date < today,
),
)


def _add_relative_week_candidates_without_weekday(
source_text: str,
today: date,
Expand Down Expand Up @@ -372,7 +436,7 @@ def _extract_date_range(
source_text: str,
candidates: list[tuple[int, int, ExtractedValue]],
removable_texts: list[str],
) -> ExtractedValue:
) -> tuple[int, ExtractedValue] | None:
"""A부터 B까지 형태의 날짜 범위를 시작일/종료일 후보로 분리합니다."""
sorted_candidates = sorted(candidates, key=lambda candidate: candidate[0])
for start_index, start_end, start_date in sorted_candidates:
Expand All @@ -393,17 +457,20 @@ def _extract_date_range(

removable_text = source_text[start_index : end_end + until_match.end()]
removable_texts.append(removable_text)
return ExtractedValue(
value=start_date.value,
end_value=end_date.value,
text=removable_text,
removable_texts=_dedupe_longest_first(removable_texts),
date_source=_combine_date_source(start_date.date_source, end_date.date_source),
is_past=start_date.is_past,
is_ambiguous=start_date.is_ambiguous or end_date.is_ambiguous,
return (
start_index,
ExtractedValue(
value=start_date.value,
end_value=end_date.value,
text=removable_text,
removable_texts=_dedupe_longest_first(removable_texts),
date_source=_combine_date_source(start_date.date_source, end_date.date_source),
is_past=start_date.is_past,
is_ambiguous=start_date.is_ambiguous or end_date.is_ambiguous,
),
)

return ExtractedValue(value=None)
return None


def _align_bare_weekday_start_to_qualified_week_end(
Expand Down
56 changes: 56 additions & 0 deletions tests/test_parser_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,62 @@ def test_explicit_date_range_sets_start_and_end_date():
assert result.to_embedding == ["부산", "여행"]


def test_date_range_inherits_start_month_when_end_month_is_omitted():
result = parse_event_text(
"8월 20일부터 22일까지 부산 여행",
reference_date=date(2026, 8, 17),
)

assert result.start_date == "2026-08-20"
assert result.end_date == "2026-08-22"
assert result.date_source == "EXPLICIT"
assert result.to_embedding == ["부산", "여행"]


def test_date_range_uses_reference_month_when_both_months_are_omitted():
result = parse_event_text(
"20일 부터 22일까지 부산 여행",
reference_date=date(2026, 8, 17),
)

assert result.start_date == "2026-08-20"
assert result.end_date == "2026-08-22"
assert result.date_source == "EXPLICIT"
assert result.to_embedding == ["부산", "여행"]


def test_inherited_month_date_range_does_not_roll_inverted_end_to_next_month():
result = parse_event_text(
"8월 30일부터 2일까지 부산 여행",
reference_date=date(2026, 8, 17),
)

assert result.start_date == "2026-08-30"
assert result.end_date is None
assert result.date_source == "EXPLICIT"
assert result.to_embedding == ["부산", "여행"]


def test_date_range_selects_earliest_standard_range_before_inherited_range():
result = parse_event_text(
"8월 18일부터 8월 19일까지 출장 후 8월 20일부터 22일까지 여행",
reference_date=date(2026, 8, 17),
)

assert result.start_date == "2026-08-18"
assert result.end_date == "2026-08-19"


def test_date_range_selects_earliest_inherited_range_before_standard_range():
result = parse_event_text(
"8월 20일부터 22일까지 여행 후 8월 24일부터 8월 25일까지 출장",
reference_date=date(2026, 8, 17),
)

assert result.start_date == "2026-08-20"
assert result.end_date == "2026-08-22"


def test_relative_date_range_sets_start_and_end_date(monkeypatch):
import app.services.parser_service as parser_service

Expand Down
Loading