From 3fef2eae490bd9ba21ae2020ac313a0031dcd97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=98=88=EB=A6=BC?= Date: Sun, 9 Aug 2026 19:41:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20draftRevision=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=B6=94=EC=B2=9C=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=A4=91=EB=8B=A8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/deps.py | 70 ++++--------------- app/core/error_code.py | 1 + .../recommendation/recommendation_service.py | 18 ++++- .../recommendation/revision_guard_service.py | 51 ++++++++++++++ tests/test_recommendation_service.py | 53 ++++++++++++++ 5 files changed, 135 insertions(+), 58 deletions(-) create mode 100644 app/services/recommendation/revision_guard_service.py create mode 100644 tests/test_recommendation_service.py diff --git a/app/core/deps.py b/app/core/deps.py index 3b437f1..966e0af 100644 --- a/app/core/deps.py +++ b/app/core/deps.py @@ -14,6 +14,8 @@ from app.services.recommendation.refinement_service import RecommendationRefinementService from app.services.recommendation.temporal_validation_service import TemporalValidationService from app.services.recommendation.suggestion_compose_service import SuggestionCompositionService +from app.services.recommendation.revision_guard_service import RevisionGuardService +from app.core.valkey_client import valkey_client def get_neo4j_client() -> Neo4jClient: @@ -88,75 +90,29 @@ def get_suggestion_compose_service() -> SuggestionCompositionService: SuggestionCompositionServiceDep = Annotated[SuggestionCompositionService, Depends(get_suggestion_compose_service)] +def get_revision_guard_service() -> RevisionGuardService: + return RevisionGuardService(client=valkey_client) + + +RevisionGuardServiceDep = Annotated[RevisionGuardService, Depends(get_revision_guard_service)] + + def get_recommendation_service( schedule_context_service: ScheduleContextServiceDep, candidate_search_service: CandidateSearchServiceDep, refinement_service: RecommendationRefinementServiceDep, temporal_validation_service: TemporalValidationServiceDep, - suggestion_compose_service: SuggestionCompositionServiceDep + suggestion_compose_service: SuggestionCompositionServiceDep, + revision_guard_service: RevisionGuardServiceDep, ) -> RecommendationService: return RecommendationService( schedule_context_service=schedule_context_service, candidate_search_service=candidate_search_service, refinement_service=refinement_service, temporal_validation_service=temporal_validation_service, - suggestion_compose_service=suggestion_compose_service + suggestion_compose_service=suggestion_compose_service, + revision_guard_service=revision_guard_service, ) RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)] - - - -# 참고용 입니다!!!! 이런 코드가 있으면 좋을 것 같다는 의견!! 입니다! -# TODO: ScheduleContextRepo 의존성 주입 (일정 맥락 조회 구현 후 활성화) -# def get_schedule_context_repo(client: Neo4jClientDep) -> ScheduleContextRepo: -# from app.graph.repositories.schedule_context_repo import ScheduleContextRepo -# -# return ScheduleContextRepo(client.driver) -# -# ScheduleContextRepoDep = Annotated[ScheduleContextRepo, Depends(get_schedule_context_repo)] - - -# TODO: ParserService 의존성 주입 (C101/C102 자연어 일정 1차 파싱 구현 후 활성화) -# def get_parser_service() -> ParserService: -# from app.services.parser_service import ParserService -# -# return ParserService() -# -# ParserServiceDep = Annotated[ParserService, Depends(get_parser_service)] - - -# TODO: ScheduleContextService 의존성 주입 (Neo4j 맥락 분석 구현 후 활성화) -# def get_schedule_context_service( -# repo: ScheduleContextRepoDep, -# ) -> ScheduleContextService: -# from app.services.schedule_context_service import ScheduleContextService -# -# return ScheduleContextService(repo=repo) -# -# ScheduleContextServiceDep = Annotated[ScheduleContextService, Depends(get_schedule_context_service)] - - -# TODO: RecommendationService 의존성 주입 (parser → graph → llm → recommender 파이프라인 구현 후 활성화) -# def get_recommendation_service( -# parser_service: ParserServiceDep, -# recommendation_repo: RecommendationRepoDep, -# ) -> RecommendationService: -# from app.services.recommendation_service import RecommendationService -# -# return RecommendationService( -# parser_service=parser_service, -# recommendation_repo=recommendation_repo, -# ) -# -# RecommendationServiceDep = Annotated[RecommendationService, Depends(get_recommendation_service)] - - -# TODO: LLMService 의존성 주입 (Upstage LLM 연동 구현 후 활성화) -# def get_llm_service() -> LLMService: -# from app.services.llm_service import LLMService -# -# return LLMService() -# -# LLMServiceDep = Annotated[LLMService, Depends(get_llm_service)] diff --git a/app/core/error_code.py b/app/core/error_code.py index b5d72f3..c629bde 100644 --- a/app/core/error_code.py +++ b/app/core/error_code.py @@ -20,6 +20,7 @@ class ErrorCode(Enum): NEO4J_503 = (HTTPStatus.SERVICE_UNAVAILABLE, "Neo4j 연결을 사용할 수 없습니다.") LLM_503 = (HTTPStatus.SERVICE_UNAVAILABLE, "LLM 연동을 사용할 수 없습니다.") + STALE_DRAFT_REVISION_409 = (HTTPStatus.CONFLICT, "최신 일정 입력이 존재하여 이전 추천 요청을 중단했습니다.") def __init__(self, status: HTTPStatus, message: str): diff --git a/app/services/recommendation/recommendation_service.py b/app/services/recommendation/recommendation_service.py index 5bf652f..c0ca481 100644 --- a/app/services/recommendation/recommendation_service.py +++ b/app/services/recommendation/recommendation_service.py @@ -12,6 +12,7 @@ from app.schemas.recommendation.temporal import TemporalValidationResult from app.services.recommendation.temporal_validation_service import TemporalValidationService from app.services.recommendation.suggestion_compose_service import SuggestionCompositionService +from app.services.recommendation.revision_guard_service import RevisionGuardService logger = logging.getLogger("uvicorn.error") @@ -24,13 +25,21 @@ def __init__( candidate_search_service: CandidateSearchService, refinement_service: RecommendationRefinementService, temporal_validation_service: TemporalValidationService, - suggestion_compose_service: SuggestionCompositionService + suggestion_compose_service: SuggestionCompositionService, + revision_guard_service: RevisionGuardService, ) -> None: self.schedule_context_service = schedule_context_service self.candidate_search_service = candidate_search_service self.refinement_service = refinement_service self.temporal_validation_service = temporal_validation_service self.suggestion_compose_service = suggestion_compose_service + self.revision_guard_service = revision_guard_service + + def _ensure_current_revision(self, request: RecommendationRequest) -> None: + self.revision_guard_service.ensure_current( + temp_event_id=request.temp_event_id, + draft_revision=request.draft_revision, + ) def run_pipeline( self, @@ -76,8 +85,11 @@ def _run_pipeline( | TemporalValidationResult | RecommendationResponse ): + self._ensure_current_revision(request) + # D101: 일정 맥락 구조화 context = self.schedule_context_service.structure_context(request) + self._ensure_current_revision(request) if stop_after_step == PipelineStep.CONTEXT: return context @@ -96,6 +108,7 @@ def _run_pipeline( # D102: Neo4j 추천 후보 조회 candidate = self.candidate_search_service.search(context) + self._ensure_current_revision(request) if stop_after_step == PipelineStep.CANDIDATES: return candidate @@ -120,6 +133,7 @@ def _run_pipeline( request=request, candidate_result=candidate, ) + self._ensure_current_revision(request) if stop_after_step == PipelineStep.REFINED_ITEMS: return refined_result @@ -142,6 +156,7 @@ def _run_pipeline( temporal_result = self.temporal_validation_service.temporal_validate( refinement_result=refined_result, ) + self._ensure_current_revision(request) if stop_after_step == PipelineStep.VALIDATED_ITEMS: return temporal_result @@ -151,6 +166,7 @@ def _run_pipeline( recommendation_result = self.suggestion_compose_service.compose( temporal_result=temporal_result ) + self._ensure_current_revision(request) if stop_after_step is not None: raise NotImplementedError( diff --git a/app/services/recommendation/revision_guard_service.py b/app/services/recommendation/revision_guard_service.py new file mode 100644 index 0000000..2654c85 --- /dev/null +++ b/app/services/recommendation/revision_guard_service.py @@ -0,0 +1,51 @@ +import logging + +from redis.exceptions import RedisError + +from app.core.error_code import ErrorCode +from app.core.exceptions import BusinessException +from app.core.valkey_client import ValkeyClient + + +logger = logging.getLogger("uvicorn.error") + +LATEST_REVISION_KEY_PREFIX = "tryna:recommendation:latest-revision" + + +class RevisionGuardService: + + def __init__(self, client: ValkeyClient) -> None: + self._valkey_client = client + + @staticmethod + def build_key(temp_event_id: str) -> str: + return f"{LATEST_REVISION_KEY_PREFIX}:{temp_event_id}" + + def ensure_current(self, temp_event_id: str, draft_revision: int) -> None: + client = self._valkey_client.client + if client is None: + return + + try: + stored_revision = client.get(self.build_key(temp_event_id)) + if stored_revision is None: + return + latest_revision = int(stored_revision) + except (RedisError, TypeError, ValueError): + logger.warning( + "Draft revision lookup failed; continuing recommendation pipeline: " + "tempEventId=%s", + temp_event_id, + exc_info=True, + ) + return + + if draft_revision < latest_revision: + logger.info( + "Stale recommendation request stopped: " + "tempEventId=%s, requestRevision=%s, latestRevision=%s", + temp_event_id, + draft_revision, + latest_revision, + ) + raise BusinessException(ErrorCode.STALE_DRAFT_REVISION_409) diff --git a/tests/test_recommendation_service.py b/tests/test_recommendation_service.py new file mode 100644 index 0000000..6690271 --- /dev/null +++ b/tests/test_recommendation_service.py @@ -0,0 +1,53 @@ +from unittest.mock import Mock + +import pytest + +from app.core.error_code import ErrorCode +from app.core.exceptions import BusinessException +from app.services.recommendation.recommendation_service import RecommendationService + + +def _recommendation_service(revision_guard_service: Mock) -> RecommendationService: + return RecommendationService( + schedule_context_service=Mock(), + candidate_search_service=Mock(), + refinement_service=Mock(), + temporal_validation_service=Mock(), + suggestion_compose_service=Mock(), + revision_guard_service=revision_guard_service, + ) + + +def _request() -> Mock: + return Mock(temp_event_id="event-123", draft_revision=2) + + +def test_stale_request_is_stopped_before_d101(): + guard = Mock() + guard.ensure_current.side_effect = BusinessException( + ErrorCode.STALE_DRAFT_REVISION_409 + ) + service = _recommendation_service(guard) + + with pytest.raises(BusinessException): + service.run_pipeline(_request()) + + service.schedule_context_service.structure_context.assert_not_called() + + +def test_request_that_becomes_stale_after_d101_does_not_run_d102(): + guard = Mock() + guard.ensure_current.side_effect = [ + None, + BusinessException(ErrorCode.STALE_DRAFT_REVISION_409), + ] + service = _recommendation_service(guard) + service.schedule_context_service.structure_context.return_value = Mock( + embedding_status="READY" + ) + + with pytest.raises(BusinessException): + service.run_pipeline(_request()) + + service.schedule_context_service.structure_context.assert_called_once() + service.candidate_search_service.search.assert_not_called() From e52f13f32bbbe96c53cb0902436352a4962c9e5d Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Mon, 10 Aug 2026 13:42:13 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[FEAT]=20=EC=9D=BC=EC=A0=95=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20draftRevision=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/event_preview.py | 6 ++++- app/services/event_preview_service.py | 5 ++++- ...esktopTRYNAbrain.venvScriptsActivate.ps1)" | 22 +++++++++++++++++++ tests/test_event_preview_service.py | 17 +++++++++----- 4 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 "s -ExecutionPolicy RemoteSigned) ; (& c\357\200\272UserskimtaDesktopTRYNAbrain.venvScriptsActivate.ps1)" diff --git a/app/schemas/event_preview.py b/app/schemas/event_preview.py index a576b4b..83c3ede 100644 --- a/app/schemas/event_preview.py +++ b/app/schemas/event_preview.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date from pydantic import BaseModel, ConfigDict, Field @@ -9,6 +9,7 @@ class EventPreviewRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) event_title: str = Field(alias="eventTitle") + draft_revision: int = Field(alias="draftRevision", ge=0) selected_date: date | None = Field(default=None, alias="selectedDate") @@ -21,6 +22,7 @@ class EventPreviewResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) event_title: str = Field(alias="eventTitle") + draft_revision: int = Field(alias="draftRevision") start_date: str | None = Field(default=None, alias="startDate") date_source: DateSource | None = Field(default=None, alias="dateSource") end_date: str | None = Field(default=None, alias="endDate") @@ -31,3 +33,5 @@ class EventPreviewResponse(BaseModel): is_all_day_candidate: bool = Field(alias="isAllDayCandidate") needs_confirmation: bool = Field(alias="needsConfirmation") warnings: list[EventPreviewWarning] = Field(default_factory=list) + + diff --git a/app/services/event_preview_service.py b/app/services/event_preview_service.py index bf5279b..6ccc1e8 100644 --- a/app/services/event_preview_service.py +++ b/app/services/event_preview_service.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime from zoneinfo import ZoneInfo from app.core.error_code import ErrorCode @@ -30,6 +30,7 @@ def preview_event(request: EventPreviewRequest) -> EventPreviewResponse: return EventPreviewResponse( event_title=parsed_event.source_text, + draft_revision=request.draft_revision, start_date=start_date, date_source=date_source, end_date=parsed_event.end_date, @@ -82,3 +83,5 @@ def _format_time_with_seconds(time_candidate: str | None) -> str | None: return f"{hour.zfill(2)}:{minute.zfill(2)}:{second.zfill(2)}" return None + + diff --git "a/s -ExecutionPolicy RemoteSigned) ; (& c\357\200\272UserskimtaDesktopTRYNAbrain.venvScriptsActivate.ps1)" "b/s -ExecutionPolicy RemoteSigned) ; (& c\357\200\272UserskimtaDesktopTRYNAbrain.venvScriptsActivate.ps1)" new file mode 100644 index 0000000..57abded --- /dev/null +++ "b/s -ExecutionPolicy RemoteSigned) ; (& c\357\200\272UserskimtaDesktopTRYNAbrain.venvScriptsActivate.ps1)" @@ -0,0 +1,22 @@ + chore/init-fastapi + develop + docs/parser-service-comments + feat/event-date-range-parser + feat/event-date-relative-expressions + feat/event-parse-preview + feat/event-parser-kiwi + feat/event-parser-rule-based + feat/event-parser-time-range + feat/event-parser-week-alias + feat/event-preview-date-source + feat/event-preview-date-time-fields + feat/event-preview-embedding + feat/event-preview-embedding-tokens + feat/event-preview-selected-date + feat/relative-week-default-weekday + fix/parser-service-seoul-date +* fix/time-range-end-period-inference + main + refactor/event-parser-start-end + refactor/event-preview-schema-snake-case + refactor/event-title-contract diff --git a/tests/test_event_preview_service.py b/tests/test_event_preview_service.py index 5abbd74..2d90f40 100644 --- a/tests/test_event_preview_service.py +++ b/tests/test_event_preview_service.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime from zoneinfo import ZoneInfo from app.schemas.event_preview import EventPreviewRequest @@ -6,7 +6,7 @@ def test_preview_event_returns_start_date_and_start_time_with_seconds(): - result = preview_event(EventPreviewRequest(eventTitle="금요일 3시 팀플 회의")) + result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="금요일 3시 팀플 회의")) assert result.event_title == "금요일 3시 팀플 회의" assert result.start_date is not None @@ -20,7 +20,7 @@ def test_preview_event_returns_start_date_and_start_time_with_seconds(): def test_preview_event_keeps_ambiguous_time_out_of_start_time(): - result = preview_event(EventPreviewRequest(eventTitle="내일 오후에 팀플 회의")) + result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="내일 오후에 팀플 회의")) assert result.start_date is not None assert result.start_time is None @@ -30,7 +30,7 @@ def test_preview_event_keeps_ambiguous_time_out_of_start_time(): def test_preview_event_defaults_missing_date_to_today(): - result = preview_event(EventPreviewRequest(eventTitle="팀플 회의")) + result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="팀플 회의")) assert result.start_date == datetime.now(ZoneInfo("Asia/Seoul")).date().isoformat() assert result.date_source == "DEFAULT_TODAY" @@ -42,6 +42,7 @@ def test_preview_event_uses_selected_date_when_source_has_no_date(): result = preview_event( EventPreviewRequest( eventTitle="팀플 회의", + draftRevision=7, selectedDate="2026-08-10", ) ) @@ -57,6 +58,7 @@ def test_preview_event_source_date_has_priority_over_selected_date(): result = preview_event( EventPreviewRequest( eventTitle="2026년 8월 22일 부산 전시회", + draftRevision=8, selectedDate="2026-08-10", ) ) @@ -67,7 +69,7 @@ def test_preview_event_source_date_has_priority_over_selected_date(): def test_preview_event_returns_explicit_date_source_for_absolute_date(): - result = preview_event(EventPreviewRequest(eventTitle="2026년 8월 22일 부산 전시회")) + result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="2026년 8월 22일 부산 전시회")) payload = result.model_dump(by_alias=True) assert result.start_date == "2026-08-22" @@ -75,9 +77,11 @@ def test_preview_event_returns_explicit_date_source_for_absolute_date(): assert payload["dateSource"] == "EXPLICIT" def test_preview_event_response_keeps_camel_case_json_contract(): - result = preview_event(EventPreviewRequest(eventTitle="금요일 3시 팀플 회의")) + result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="금요일 3시 팀플 회의")) payload = result.model_dump(by_alias=True) + assert result.draft_revision == 1 + assert payload["draftRevision"] == 1 assert payload["eventTitle"] == "금요일 3시 팀플 회의" assert payload["startDate"] is not None assert payload["dateSource"] == "RELATIVE_EXPRESSION" @@ -86,3 +90,4 @@ def test_preview_event_response_keeps_camel_case_json_contract(): assert payload["toEmbedding"] == ["팀플", "회의"] assert payload["isAllDayCandidate"] is False assert payload["needsConfirmation"] is False + From 4094da083e1fb1b97f80f3a9f6bd07edec6cc1c8 Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Mon, 10 Aug 2026 13:49:56 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[TEST]=20draftRevision=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EA=B0=92=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...esktopTRYNAbrain.venvScriptsActivate.ps1)" | 22 ------------------- tests/test_event_preview_service.py | 14 +++++++++++- 2 files changed, 13 insertions(+), 23 deletions(-) delete mode 100644 "s -ExecutionPolicy RemoteSigned) ; (& c\357\200\272UserskimtaDesktopTRYNAbrain.venvScriptsActivate.ps1)" diff --git "a/s -ExecutionPolicy RemoteSigned) ; (& c\357\200\272UserskimtaDesktopTRYNAbrain.venvScriptsActivate.ps1)" "b/s -ExecutionPolicy RemoteSigned) ; (& c\357\200\272UserskimtaDesktopTRYNAbrain.venvScriptsActivate.ps1)" deleted file mode 100644 index 57abded..0000000 --- "a/s -ExecutionPolicy RemoteSigned) ; (& c\357\200\272UserskimtaDesktopTRYNAbrain.venvScriptsActivate.ps1)" +++ /dev/null @@ -1,22 +0,0 @@ - chore/init-fastapi - develop - docs/parser-service-comments - feat/event-date-range-parser - feat/event-date-relative-expressions - feat/event-parse-preview - feat/event-parser-kiwi - feat/event-parser-rule-based - feat/event-parser-time-range - feat/event-parser-week-alias - feat/event-preview-date-source - feat/event-preview-date-time-fields - feat/event-preview-embedding - feat/event-preview-embedding-tokens - feat/event-preview-selected-date - feat/relative-week-default-weekday - fix/parser-service-seoul-date -* fix/time-range-end-period-inference - main - refactor/event-parser-start-end - refactor/event-preview-schema-snake-case - refactor/event-title-contract diff --git a/tests/test_event_preview_service.py b/tests/test_event_preview_service.py index 2d90f40..99d0fda 100644 --- a/tests/test_event_preview_service.py +++ b/tests/test_event_preview_service.py @@ -1,6 +1,8 @@ -from datetime import datetime +from datetime import datetime from zoneinfo import ZoneInfo +import pytest + from app.schemas.event_preview import EventPreviewRequest from app.services.event_preview_service import preview_event @@ -18,6 +20,16 @@ def test_preview_event_returns_start_date_and_start_time_with_seconds(): assert result.to_embedding == ["팀플", "회의"] assert result.is_all_day_candidate is False +def test_event_preview_request_accepts_zero_draft_revision(): + request = EventPreviewRequest(draftRevision=0, eventTitle="팀플 회의") + + assert request.draft_revision == 0 + + +def test_event_preview_request_rejects_negative_draft_revision(): + with pytest.raises(ValueError): + EventPreviewRequest(draftRevision=-1, eventTitle="팀플 회의") + def test_preview_event_keeps_ambiguous_time_out_of_start_time(): result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="내일 오후에 팀플 회의")) From 394516864c8e45726a32089ae14d6f28c03f4327 Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Mon, 10 Aug 2026 16:41:30 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[FEAT]=20=EC=9D=BC=EC=A0=95=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20tempEnventId=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/event_preview.py | 6 ++--- app/services/event_preview_service.py | 13 ++++++++--- tests/test_event_preview_service.py | 32 +++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/app/schemas/event_preview.py b/app/schemas/event_preview.py index 83c3ede..8a73009 100644 --- a/app/schemas/event_preview.py +++ b/app/schemas/event_preview.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date from pydantic import BaseModel, ConfigDict, Field @@ -8,6 +8,7 @@ class EventPreviewRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) + temp_event_id: str | None = Field(default=None, alias="tempEventId") event_title: str = Field(alias="eventTitle") draft_revision: int = Field(alias="draftRevision", ge=0) selected_date: date | None = Field(default=None, alias="selectedDate") @@ -21,6 +22,7 @@ class EventPreviewWarning(BaseModel): class EventPreviewResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) + temp_event_id: str = Field(alias="tempEventId") event_title: str = Field(alias="eventTitle") draft_revision: int = Field(alias="draftRevision") start_date: str | None = Field(default=None, alias="startDate") @@ -33,5 +35,3 @@ class EventPreviewResponse(BaseModel): is_all_day_candidate: bool = Field(alias="isAllDayCandidate") needs_confirmation: bool = Field(alias="needsConfirmation") warnings: list[EventPreviewWarning] = Field(default_factory=list) - - diff --git a/app/services/event_preview_service.py b/app/services/event_preview_service.py index 6ccc1e8..b785f4a 100644 --- a/app/services/event_preview_service.py +++ b/app/services/event_preview_service.py @@ -1,4 +1,5 @@ -from datetime import datetime +from datetime import datetime +from uuid import uuid4 from zoneinfo import ZoneInfo from app.core.error_code import ErrorCode @@ -29,6 +30,7 @@ def preview_event(request: EventPreviewRequest) -> EventPreviewResponse: start_time = _format_time_with_seconds(parsed_event.start_time) return EventPreviewResponse( + temp_event_id=_resolve_temp_event_id(request.temp_event_id), event_title=parsed_event.source_text, draft_revision=request.draft_revision, start_date=start_date, @@ -44,6 +46,13 @@ def preview_event(request: EventPreviewRequest) -> EventPreviewResponse: ) +def _resolve_temp_event_id(temp_event_id: str | None) -> str: + if temp_event_id and temp_event_id.strip(): + return temp_event_id.strip() + + return f"tmp_{uuid4()}" + + def _build_warnings(parsed_event: ParsedEvent) -> list[EventPreviewWarning]: warnings = [] @@ -83,5 +92,3 @@ def _format_time_with_seconds(time_candidate: str | None) -> str | None: return f"{hour.zfill(2)}:{minute.zfill(2)}:{second.zfill(2)}" return None - - diff --git a/tests/test_event_preview_service.py b/tests/test_event_preview_service.py index 99d0fda..b752a78 100644 --- a/tests/test_event_preview_service.py +++ b/tests/test_event_preview_service.py @@ -10,6 +10,7 @@ def test_preview_event_returns_start_date_and_start_time_with_seconds(): result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="금요일 3시 팀플 회의")) + assert result.temp_event_id.startswith("tmp_") assert result.event_title == "금요일 3시 팀플 회의" assert result.start_date is not None assert result.date_source == "RELATIVE_EXPRESSION" @@ -20,6 +21,7 @@ def test_preview_event_returns_start_date_and_start_time_with_seconds(): assert result.to_embedding == ["팀플", "회의"] assert result.is_all_day_candidate is False + def test_event_preview_request_accepts_zero_draft_revision(): request = EventPreviewRequest(draftRevision=0, eventTitle="팀플 회의") @@ -31,6 +33,31 @@ def test_event_preview_request_rejects_negative_draft_revision(): EventPreviewRequest(draftRevision=-1, eventTitle="팀플 회의") +def test_preview_event_reuses_existing_temp_event_id(): + result = preview_event( + EventPreviewRequest( + tempEventId="tmp_existing-event-id", + draftRevision=2, + eventTitle="금요일 3시 팀플 회의", + ) + ) + + assert result.temp_event_id == "tmp_existing-event-id" + + +def test_preview_event_creates_temp_event_id_when_blank(): + result = preview_event( + EventPreviewRequest( + tempEventId=" ", + draftRevision=2, + eventTitle="금요일 3시 팀플 회의", + ) + ) + + assert result.temp_event_id.startswith("tmp_") + assert result.temp_event_id.strip() == result.temp_event_id + + def test_preview_event_keeps_ambiguous_time_out_of_start_time(): result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="내일 오후에 팀플 회의")) @@ -79,7 +106,6 @@ def test_preview_event_source_date_has_priority_over_selected_date(): assert result.date_source == "EXPLICIT" - def test_preview_event_returns_explicit_date_source_for_absolute_date(): result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="2026년 8월 22일 부산 전시회")) payload = result.model_dump(by_alias=True) @@ -88,11 +114,14 @@ def test_preview_event_returns_explicit_date_source_for_absolute_date(): assert result.date_source == "EXPLICIT" assert payload["dateSource"] == "EXPLICIT" + def test_preview_event_response_keeps_camel_case_json_contract(): result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="금요일 3시 팀플 회의")) payload = result.model_dump(by_alias=True) + assert result.temp_event_id.startswith("tmp_") assert result.draft_revision == 1 + assert payload["tempEventId"].startswith("tmp_") assert payload["draftRevision"] == 1 assert payload["eventTitle"] == "금요일 3시 팀플 회의" assert payload["startDate"] is not None @@ -102,4 +131,3 @@ def test_preview_event_response_keeps_camel_case_json_contract(): assert payload["toEmbedding"] == ["팀플", "회의"] assert payload["isAllDayCandidate"] is False assert payload["needsConfirmation"] is False - From 25bc88ab9c7302824b28a60a19cb442b35be2b0d Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Tue, 11 Aug 2026 16:22:07 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[FIX]=20=EC=9A=94=EC=9D=BC=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EC=A2=85=EB=A3=8C=EC=9D=BC=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/event_preview_service.py | 6 ++-- app/services/parser_service.py | 43 ++++++++++++++++++++++++--- tests/test_event_preview_service.py | 16 ++++++++++ tests/test_parser_service.py | 17 +++++++++++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/app/services/event_preview_service.py b/app/services/event_preview_service.py index 6ccc1e8..8d9090b 100644 --- a/app/services/event_preview_service.py +++ b/app/services/event_preview_service.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime from zoneinfo import ZoneInfo from app.core.error_code import ErrorCode @@ -21,7 +21,7 @@ def preview_event(request: EventPreviewRequest) -> EventPreviewResponse: if not event_title: raise BusinessException(ErrorCode.COMMON_400) - parsed_event = parse_event_text(event_title) + parsed_event = parse_event_text(event_title, reference_date=request.selected_date) warnings = _build_warnings(parsed_event) selected_date = request.selected_date.isoformat() if request.selected_date else None start_date = parsed_event.start_date or selected_date or datetime.now(ASIA_SEOUL).date().isoformat() @@ -83,5 +83,3 @@ def _format_time_with_seconds(time_candidate: str | None) -> str | None: return f"{hour.zfill(2)}:{minute.zfill(2)}:{second.zfill(2)}" return None - - diff --git a/app/services/parser_service.py b/app/services/parser_service.py index f4cd566..163281e 100644 --- a/app/services/parser_service.py +++ b/app/services/parser_service.py @@ -128,11 +128,11 @@ def _today_in_service_timezone() -> date: return datetime.now(SERVICE_TIMEZONE).date() -def parse_event_text(source_text: str) -> ParsedEvent: +def parse_event_text(source_text: str, reference_date: date | None = None) -> ParsedEvent: """사용자 원문을 날짜/시간/장소 후보와 임베딩 키워드로 변환합니다.""" normalized_text = _normalize_spaces(source_text) - extracted_date = _extract_date(normalized_text) + extracted_date = _extract_date(normalized_text, reference_date=reference_date) extracted_time = _extract_time(normalized_text) extracted_place = _extract_place( source_text=normalized_text, @@ -164,9 +164,9 @@ def parse_event_text(source_text: str) -> ParsedEvent: ) -def _extract_date(source_text: str) -> ExtractedValue: +def _extract_date(source_text: str, reference_date: date | None = None) -> ExtractedValue: """절대 날짜, 상대 날짜, 요일 표현 중 원문에서 가장 먼저 나온 날짜 후보를 반환합니다.""" - today = _today_in_service_timezone() + today = reference_date or _today_in_service_timezone() candidates: list[tuple[int, int, ExtractedValue]] = [] removable_texts: list[str] = [] @@ -380,6 +380,7 @@ def _extract_date_range( if not _is_date_range_connector(between, until_match): continue + end_date = _roll_weekday_range_end_forward(start_date, end_date) if _is_inverted_date_range(start_date, end_date): continue @@ -398,6 +399,40 @@ def _extract_date_range( return ExtractedValue(value=None) +def _roll_weekday_range_end_forward(start_date: ExtractedValue, end_date: ExtractedValue) -> ExtractedValue: + """요일 범위의 종료 요일이 시작일보다 앞서면 시작일 이후의 같은 요일로 보정합니다.""" + if not start_date.value or not end_date.value or not end_date.text: + return end_date + + weekday = _weekday_from_bare_text(end_date.text) + if weekday is None: + return end_date + + start = date.fromisoformat(start_date.value) + end = date.fromisoformat(end_date.value) + if end >= start: + return end_date + + rolled_end = _next_weekday(start + timedelta(days=1), weekday) + return ExtractedValue( + value=rolled_end.isoformat(), + text=end_date.text, + removable_texts=end_date.removable_texts, + date_source=end_date.date_source, + is_past=end_date.is_past, + is_ambiguous=end_date.is_ambiguous, + ) + + +def _weekday_from_bare_text(text: str) -> int | None: + """'금요일' 또는 '금요일에'처럼 주차 수식어가 없는 요일 표현만 요일 번호로 변환합니다.""" + normalized_text = text.strip() + if normalized_text.endswith("에"): + normalized_text = normalized_text[:-1] + + return WEEKDAY_INDEX.get(normalized_text) + + def _is_date_range_connector(between: str, until_match: re.Match[str] | None) -> bool: """두 날짜 후보 사이가 범위 연결 표현인지 확인합니다.""" if not until_match: diff --git a/tests/test_event_preview_service.py b/tests/test_event_preview_service.py index 99d0fda..1565e75 100644 --- a/tests/test_event_preview_service.py +++ b/tests/test_event_preview_service.py @@ -103,3 +103,19 @@ def test_preview_event_response_keeps_camel_case_json_contract(): assert payload["isAllDayCandidate"] is False assert payload["needsConfirmation"] is False + +def test_preview_event_uses_selected_date_as_weekday_reference(): + result = preview_event( + EventPreviewRequest( + eventTitle="월요일부터 금요일까지 제주도 여행", + draftRevision=9, + selectedDate="2026-08-11", + ) + ) + payload = result.model_dump(by_alias=True) + + assert result.start_date == "2026-08-17" + assert result.end_date == "2026-08-21" + assert result.date_source == "RELATIVE_EXPRESSION" + assert payload["startDate"] == "2026-08-17" + assert payload["endDate"] == "2026-08-21" diff --git a/tests/test_parser_service.py b/tests/test_parser_service.py index 29c631f..f61f558 100644 --- a/tests/test_parser_service.py +++ b/tests/test_parser_service.py @@ -92,6 +92,23 @@ def test_weekday_date_range_sets_start_and_end_date(monkeypatch): assert result.date_source == "RELATIVE_EXPRESSION" assert result.to_embedding == ["MT"] +def test_weekday_date_range_rolls_end_forward_when_start_moves_to_next_week(monkeypatch): + import app.services.parser_service as parser_service + + monkeypatch.setattr( + parser_service, + "_today_in_service_timezone", + lambda: date(2026, 8, 11), + ) + + result = parser_service.parse_event_text("월요일부터 금요일까지 제주도 여행") + + assert result.start_date == "2026-08-17" + assert result.end_date == "2026-08-21" + assert result.date_source == "RELATIVE_EXPRESSION" + assert result.to_embedding == ["제주도", "여행"] + + def test_dates_without_range_connector_do_not_set_end_date(): result = parse_event_text("8월 22일 8월 24일 부산 여행") From 79234182b1de5ac724059fcfb13f3c52e0a3baf5 Mon Sep 17 00:00:00 2001 From: taehyeon1998 Date: Tue, 11 Aug 2026 18:02:10 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[FIX]=20=EC=A3=BC=EC=B0=A8=20=EC=88=98?= =?UTF-8?q?=EC=8B=9D=EC=96=B4=20=EC=9A=94=EC=9D=BC=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/parser_service.py | 49 +++++++++++++++++++++++++++++++++- tests/test_parser_service.py | 16 +++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/app/services/parser_service.py b/app/services/parser_service.py index 163281e..6b053a4 100644 --- a/app/services/parser_service.py +++ b/app/services/parser_service.py @@ -108,6 +108,7 @@ class ExtractedValue: date_source: DateSource | None = None is_past: bool = False is_ambiguous: bool = False + is_bare_weekday: bool = False def __post_init__(self) -> None: _validate_date_source(self.date_source) @@ -296,7 +297,12 @@ def _extract_date(source_text: str, reference_date: date | None = None) -> Extra ( weekday_index, weekday_index + len(removable_text), - ExtractedValue(value=parsed_date.isoformat(), text=removable_text, date_source="RELATIVE_EXPRESSION"), + ExtractedValue( + value=parsed_date.isoformat(), + text=removable_text, + date_source="RELATIVE_EXPRESSION", + is_bare_weekday=not _is_qualified_weekday_match(source_text, weekday_index), + ), ) ) @@ -380,6 +386,7 @@ def _extract_date_range( if not _is_date_range_connector(between, until_match): continue + start_date = _align_bare_weekday_start_to_qualified_week_end(start_date, end_date) end_date = _roll_weekday_range_end_forward(start_date, end_date) if _is_inverted_date_range(start_date, end_date): continue @@ -399,6 +406,36 @@ def _extract_date_range( return ExtractedValue(value=None) +def _align_bare_weekday_start_to_qualified_week_end( + start_date: ExtractedValue, + end_date: ExtractedValue, +) -> ExtractedValue: + """이번주로 한정된 종료 요일 앞의 순수 시작 요일은 같은 주 기준으로 보정합니다.""" + if not start_date.is_bare_weekday or not start_date.value or not end_date.value or not end_date.text: + return start_date + + if not _is_this_week_qualified_weekday(end_date.text): + return start_date + + weekday = _weekday_from_bare_text(start_date.text or "") + if weekday is None: + return start_date + + end = date.fromisoformat(end_date.value) + same_week_start = end - timedelta(days=end.weekday()) + timedelta(days=weekday) + if same_week_start > end: + return start_date + + return ExtractedValue( + value=same_week_start.isoformat(), + text=start_date.text, + removable_texts=start_date.removable_texts, + date_source=start_date.date_source, + is_past=start_date.is_past, + is_ambiguous=start_date.is_ambiguous, + is_bare_weekday=start_date.is_bare_weekday, + ) + def _roll_weekday_range_end_forward(start_date: ExtractedValue, end_date: ExtractedValue) -> ExtractedValue: """요일 범위의 종료 요일이 시작일보다 앞서면 시작일 이후의 같은 요일로 보정합니다.""" if not start_date.value or not end_date.value or not end_date.text: @@ -424,6 +461,16 @@ def _roll_weekday_range_end_forward(start_date: ExtractedValue, end_date: Extrac ) +def _is_qualified_weekday_match(source_text: str, weekday_index: int) -> bool: + """주차 수식어 뒤에 붙은 요일을 순수 요일 후보에서 제외합니다.""" + prefix = source_text[:weekday_index] + return re.search(r"(?:이번|요번|다음|담|다다음)\s*(?:주)?\s*$", prefix) is not None + + +def _is_this_week_qualified_weekday(text: str) -> bool: + """이번주/요번주로 한정된 요일 표현인지 확인합니다.""" + return re.match(r"(?:이번|요번)\s*주\s+", text.strip()) is not None + def _weekday_from_bare_text(text: str) -> int | None: """'금요일' 또는 '금요일에'처럼 주차 수식어가 없는 요일 표현만 요일 번호로 변환합니다.""" normalized_text = text.strip() diff --git a/tests/test_parser_service.py b/tests/test_parser_service.py index f61f558..fb06760 100644 --- a/tests/test_parser_service.py +++ b/tests/test_parser_service.py @@ -109,6 +109,22 @@ def test_weekday_date_range_rolls_end_forward_when_start_moves_to_next_week(monk assert result.to_embedding == ["제주도", "여행"] +def test_qualified_this_week_end_does_not_roll_to_next_week(monkeypatch): + import app.services.parser_service as parser_service + + monkeypatch.setattr( + parser_service, + "_today_in_service_timezone", + lambda: date(2026, 8, 11), + ) + + result = parser_service.parse_event_text("월요일부터 이번주 금요일까지 제주도 여행") + + assert result.start_date == "2026-08-10" + assert result.end_date == "2026-08-14" + assert result.date_source == "RELATIVE_EXPRESSION" + assert result.to_embedding == ["제주도", "여행"] + def test_dates_without_range_connector_do_not_set_end_date(): result = parse_event_text("8월 22일 8월 24일 부산 여행")