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/schemas/event_preview.py b/app/schemas/event_preview.py index a576b4b..8a73009 100644 --- a/app/schemas/event_preview.py +++ b/app/schemas/event_preview.py @@ -8,7 +8,9 @@ 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") @@ -20,7 +22,9 @@ 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") date_source: DateSource | None = Field(default=None, alias="dateSource") end_date: str | None = Field(default=None, alias="endDate") diff --git a/app/services/event_preview_service.py b/app/services/event_preview_service.py index bf5279b..b4bccf8 100644 --- a/app/services/event_preview_service.py +++ b/app/services/event_preview_service.py @@ -1,4 +1,5 @@ from datetime import datetime +from uuid import uuid4 from zoneinfo import ZoneInfo from app.core.error_code import ErrorCode @@ -21,7 +22,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() @@ -29,7 +30,9 @@ 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, date_source=date_source, end_date=parsed_event.end_date, @@ -43,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 = [] diff --git a/app/services/parser_service.py b/app/services/parser_service.py index f4cd566..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) @@ -128,11 +129,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 +165,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] = [] @@ -296,7 +297,12 @@ def _extract_date(source_text: str) -> ExtractedValue: ( 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,8 @@ 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 @@ -398,6 +406,80 @@ 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: + 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 _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() + 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/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_event_preview_service.py b/tests/test_event_preview_service.py index 5abbd74..c289617 100644 --- a/tests/test_event_preview_service.py +++ b/tests/test_event_preview_service.py @@ -1,13 +1,16 @@ 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 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.temp_event_id.startswith("tmp_") assert result.event_title == "금요일 3시 팀플 회의" assert result.start_date is not None assert result.date_source == "RELATIVE_EXPRESSION" @@ -19,8 +22,44 @@ def test_preview_event_returns_start_date_and_start_time_with_seconds(): 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_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(eventTitle="내일 오후에 팀플 회의")) + result = preview_event(EventPreviewRequest(draftRevision=1, eventTitle="내일 오후에 팀플 회의")) assert result.start_date is not None assert result.start_time is None @@ -30,7 +69,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 +81,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 +97,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", ) ) @@ -65,19 +106,23 @@ 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(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" assert result.date_source == "EXPLICIT" 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.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 assert payload["dateSource"] == "RELATIVE_EXPRESSION" @@ -86,3 +131,20 @@ def test_preview_event_response_keeps_camel_case_json_contract(): assert payload["toEmbedding"] == ["팀플", "회의"] 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..fb06760 100644 --- a/tests/test_parser_service.py +++ b/tests/test_parser_service.py @@ -92,6 +92,39 @@ 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_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일 부산 여행") 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()