From 1739c6ea594e7527fcca866e74116bb6132a5109 Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Mon, 17 Aug 2026 22:03:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT]=20=EC=9B=94=20=EC=83=9D=EB=9E=B5=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=8C=8C=EC=8B=B1=20=EA=B3=A0=EB=8F=84?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/parser_service.py | 39 ++++++++++++++++++++++++++++++++++ tests/test_parser_service.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/app/services/parser_service.py b/app/services/parser_service.py index 6b053a4..eccbd7d 100644 --- a/app/services/parser_service.py +++ b/app/services/parser_service.py @@ -30,6 +30,15 @@ re.compile(r"(?\d{1,2})/(?P\d{1,2})(?![\d/.-])"), ] +# 종료일의 월이 생략된 범위를 처리합니다: 8월 20일부터 22일까지, 20일 부터 22일까지. +INHERITED_MONTH_DATE_RANGE_PATTERN = re.compile( + r"(?\d{4})년\s*)?" + r"(?:(?P\d{1,2})월\s*)?" + r"(?P\d{1,2})일\s*부터\s*" + r"(?P\d{1,2})일\s*까지" +) + # 오전/오후, 반, 분, 쯤/경이 포함된 시간 표현을 처리합니다. TIME_PATTERN = re.compile( r"(?:(?P오전|오후|저녁|아침|밤|새벽|점심|낮)(?:에)?\s*)?" @@ -171,6 +180,10 @@ 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) + if inherited_month_range.value is not None: + return inherited_month_range + for pattern in DATE_PATTERNS: for match in pattern.finditer(source_text): year = int(match.groupdict().get("year") or today.year) @@ -324,6 +337,32 @@ 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) -> ExtractedValue: + """생략된 월은 시작일의 월 또는 서비스 기준 현재 월로 보완해 날짜 범위를 반환합니다.""" + match = INHERITED_MONTH_DATE_RANGE_PATTERN.search(source_text) + if match is None: + return ExtractedValue(value=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 ExtractedValue(value=None) + + removable_text = match.group(0) + return 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, diff --git a/tests/test_parser_service.py b/tests/test_parser_service.py index fb06760..cec1807 100644 --- a/tests/test_parser_service.py +++ b/tests/test_parser_service.py @@ -59,6 +59,42 @@ 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_relative_date_range_sets_start_and_end_date(monkeypatch): import app.services.parser_service as parser_service From 3ee9c282ac4cba62a5f8bde734060e36411c379f Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Mon, 17 Aug 2026 22:10:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=20=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/parser_service.py | 76 +++++++++++++++++++++++----------- tests/test_parser_service.py | 20 +++++++++ 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/app/services/parser_service.py b/app/services/parser_service.py index eccbd7d..babb42d 100644 --- a/app/services/parser_service.py +++ b/app/services/parser_service.py @@ -181,8 +181,6 @@ def _extract_date(source_text: str, reference_date: date | None = None) -> Extra removable_texts: list[str] = [] inherited_month_range = _extract_inherited_month_date_range(source_text, today) - if inherited_month_range.value is not None: - return inherited_month_range for pattern in DATE_PATTERNS: for match in pattern.finditer(source_text): @@ -320,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] @@ -337,11 +356,14 @@ 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) -> ExtractedValue: +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 ExtractedValue(value=None) + return None year = int(match.group("year") or today.year) month = int(match.group("month") or today.month) @@ -350,16 +372,19 @@ def _extract_inherited_month_date_range(source_text: str, today: date) -> Extrac start_date = date(year, month, int(match.group("start_day"))) end_date = date(year, month, int(match.group("end_day"))) except ValueError: - return ExtractedValue(value=None) + return None removable_text = match.group(0) - return 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, + 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, + ), ) @@ -411,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: @@ -432,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( diff --git a/tests/test_parser_service.py b/tests/test_parser_service.py index cec1807..9cde80a 100644 --- a/tests/test_parser_service.py +++ b/tests/test_parser_service.py @@ -95,6 +95,26 @@ def test_inherited_month_date_range_does_not_roll_inverted_end_to_next_month(): 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