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
70 changes: 13 additions & 57 deletions app/core/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)]
1 change: 1 addition & 0 deletions app/core/error_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions app/schemas/event_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand All @@ -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")
Expand Down
12 changes: 11 additions & 1 deletion app/services/event_preview_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
from uuid import uuid4
from zoneinfo import ZoneInfo

from app.core.error_code import ErrorCode
Expand All @@ -21,15 +22,17 @@ 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()
date_source = parsed_event.date_source or ("SELECTED_DATE" if selected_date else "DEFAULT_TODAY")
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,
Expand All @@ -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 = []

Expand Down
92 changes: 87 additions & 5 deletions app/services/parser_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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] = []

Expand Down Expand Up @@ -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),
),
)
)

Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down
18 changes: 17 additions & 1 deletion app/services/recommendation/recommendation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down
Loading
Loading