diff --git a/schedule/preview_service.py b/schedule/preview_service.py index 9947481..9429bfc 100644 --- a/schedule/preview_service.py +++ b/schedule/preview_service.py @@ -27,7 +27,7 @@ SelectedAnswer, ) from schedule.persistence import db_enabled, load_preview, mark_preview_consumed, save_preview -from schedule.service import FixedEventSpec +from schedule.service import FixedEventSpec, load_candidate_places_by_ids DEFAULT_TIME_ZONE = "Asia/Seoul" DEFAULT_DAY_START = time(10, 0) @@ -409,6 +409,29 @@ def validate_place_limits(request: SchedulePreviewCreateRequest, trip_days: int) unique_place_ids.update(event.place_id for event in request.fixed_events) if len(unique_place_ids) > trip_days * MAX_STOPS_PER_DAY: raise HTTPException(status_code=400, detail="Too many must-visit places for trip length") + validate_places_exist(sorted(unique_place_ids)) + + +def validate_places_exist(place_ids: list[int]) -> None: + """존재하지 않는 장소 id 를 Preview 단계에서 걸러낸다. + + 예전에는 개수만 검사해, 없는 id 를 보내도 Preview 가 READY 로 통과하고 + 일정 생성에서야 실패했다. API_SPEC 의 Preview 검증 항목에도 "필수 방문 장소 + 수와 존재 여부"로 명시돼 있다. + """ + if not place_ids: + return + found = load_candidate_places_by_ids(place_ids) + if not found: + # DB 를 읽지 못하는 환경에서는 검증을 건너뛴다. 여기서 막으면 조회 실패가 + # 사용자 입력 오류로 둔갑한다. + return + missing = [place_id for place_id in place_ids if place_id not in found] + if missing: + raise HTTPException( + status_code=400, + detail=f"placeIds do not exist: {missing}", + ) def interpret_prompt(custom_prompt: str | None) -> InterpretedPrompt: diff --git a/schedule/service.py b/schedule/service.py index d1a60ba..1da4995 100644 --- a/schedule/service.py +++ b/schedule/service.py @@ -184,26 +184,7 @@ def resolve_db_dsn() -> tuple[str | None, str | None, str | None]: return None, None, None -def load_candidate_places_from_db() -> list[CandidatePlace]: - dsn, username, password = resolve_db_dsn() - if not dsn: - return [] - try: - import psycopg - except ModuleNotFoundError: - return [] - - connect_kwargs: dict[str, object] = { - "conninfo": dsn, - "autocommit": True, - } - parsed_dsn = urlsplit(dsn) - if username and parsed_dsn.username is None: - connect_kwargs["user"] = username - if password and parsed_dsn.password is None: - connect_kwargs["password"] = password - - query = """ +CANDIDATE_SELECT = """ SELECT id, external_content_id, @@ -220,17 +201,10 @@ def load_candidate_places_from_db() -> list[CandidatePlace]: WHERE name IS NOT NULL AND longitude IS NOT NULL AND latitude IS NOT NULL - ORDER BY id - """ - try: - with psycopg.connect(**connect_kwargs) as connection: - with connection.cursor() as cursor: - cursor.execute(query) - rows = cursor.fetchall() - except Exception: - logger.exception("failed to load candidate places from database; falling back") - return [] +""" + +def rows_to_candidates(rows) -> list[CandidatePlace]: candidates: list[CandidatePlace] = [] for row in rows: raw_category_label = str(row[4]) if row[4] is not None else "" @@ -249,6 +223,75 @@ def load_candidate_places_from_db() -> list[CandidatePlace]: return candidates +def load_candidate_places_by_ids(place_ids: list[int]) -> dict[int, CandidatePlace]: + """주어진 id 의 장소를 DB 에서 직접 읽는다. + + 후보 풀(CANDIDATE_POOL)은 모듈 로드 시 한 번만 채워지므로, 그 뒤에 등록된 + 장소는 들어 있지 않다. 사용자가 방금 등록한 장소를 필수 방문지로 지정하면 + 후보 풀에서 찾을 수 없어 일정 생성이 실패했다. 그런 id 만 여기서 조회한다. + """ + if not place_ids: + return {} + dsn, username, password = resolve_db_dsn() + if not dsn: + return {} + try: + import psycopg + except ModuleNotFoundError: + return {} + + connect_kwargs: dict[str, object] = {"conninfo": dsn, "autocommit": True} + parsed_dsn = urlsplit(dsn) + if username and parsed_dsn.username is None: + connect_kwargs["user"] = username + if password and parsed_dsn.password is None: + connect_kwargs["password"] = password + + try: + with psycopg.connect(**connect_kwargs) as connection: + with connection.cursor() as cursor: + cursor.execute(CANDIDATE_SELECT + " AND id = ANY(%s)\n", (place_ids,)) + rows = cursor.fetchall() + except Exception: + logger.exception("failed to load places by id from database. placeIds=%s", place_ids) + return {} + + return {candidate.id: candidate for candidate in rows_to_candidates(rows)} + + +def load_candidate_places_from_db() -> list[CandidatePlace]: + dsn, username, password = resolve_db_dsn() + if not dsn: + return [] + try: + import psycopg + except ModuleNotFoundError: + return [] + + connect_kwargs: dict[str, object] = { + "conninfo": dsn, + "autocommit": True, + } + parsed_dsn = urlsplit(dsn) + if username and parsed_dsn.username is None: + connect_kwargs["user"] = username + if password and parsed_dsn.password is None: + connect_kwargs["password"] = password + + query = CANDIDATE_SELECT + "\n ORDER BY id\n" + try: + with psycopg.connect(**connect_kwargs) as connection: + with connection.cursor() as cursor: + cursor.execute(query) + rows = cursor.fetchall() + except Exception: + logger.exception("failed to load candidate places from database; falling back") + return [] + + candidates = rows_to_candidates(rows) + return candidates + + def normalize_category_label(raw_label: str, content_type_id: str) -> str: normalized = raw_label.strip() if not normalized: @@ -452,11 +495,7 @@ def update_schedule(schedule_id: UUID, request: ScheduleUpdateRequest) -> Schedu candidate_stop( order=patch_stop.order, stay_minutes=patch_stop.stay_minutes, - candidate=fallback_candidate_for_unknown_id( - patch_stop.place_id, - planned_day_from_schedule_day(days_by_no[patch_stop.day_no]), - request_context_from_schedule(existing), - ), + candidate=resolve_place_or_400(patch_stop.place_id), selection_reasons=["update_requested_place"], ) ) @@ -1337,6 +1376,22 @@ def selection_reasons( return reasons +def resolve_place_or_400(place_id: int) -> CandidatePlace: + """장소 id 하나를 후보 풀 또는 DB 에서 해석한다. + + 후보 풀은 모듈 로드 시 한 번만 채워지므로 그 뒤 등록된 장소는 들어 있지 않다. + 존재하지 않는 장소로 일정을 만들면 저장 단계에서 반드시 실패하므로, + 가짜 장소를 지어내지 않고 400 으로 거절한다. + """ + for candidate in CANDIDATE_POOL: + if candidate.id == place_id: + return candidate + resolved = load_candidate_places_by_ids([place_id]).get(place_id) + if resolved is None: + raise HTTPException(status_code=400, detail=f"placeId {place_id} does not exist") + return resolved + + def choose_candidate_places( planned_day: PlannedDay, request: ScheduleCreateRequest, @@ -1347,10 +1402,21 @@ def choose_candidate_places( seen_ids: set[int] = set() selected_experience_types: list[str] = [] selected_semantic_groups: list[str] = [] + # 후보 풀은 모듈 로드 시 한 번만 채워지므로 그 뒤 등록된 장소는 들어 있지 않다. + # 풀에 없는 id 만 DB 에서 한 번에 읽어온다. + pool_by_id = {item.id: item for item in CANDIDATE_POOL} + missing_ids = [pid for pid in must_visit_ids if pid not in pool_by_id] + resolved_from_db = load_candidate_places_by_ids(missing_ids) + for place_id in must_visit_ids: - candidate = next((item for item in CANDIDATE_POOL if item.id == place_id), None) + candidate = pool_by_id.get(place_id) or resolved_from_db.get(place_id) if candidate is None: - candidate = fallback_candidate_for_unknown_id(place_id, planned_day, request) + # 존재하지 않는 장소로 일정을 만들면 저장 단계에서 반드시 실패한다. + # 가짜 장소를 지어내지 않고 요청을 거절한다. + raise HTTPException( + status_code=400, + detail=f"mustVisitPlaceId {place_id} does not exist", + ) resolved.append(candidate) seen_ids.add(candidate.id) profile = classify_place(candidate.name, candidate.category_label, candidate.content_type_id) @@ -1432,22 +1498,6 @@ def low_mobility_profile(request: ScheduleCreateRequest) -> bool: ) -def fallback_candidate_for_unknown_id( - place_id: int, - planned_day: PlannedDay, - request: ScheduleCreateRequest, -) -> CandidatePlace: - theme_answer_id = primary_theme_answer_id(request) - category_code, category_label = THEME_CATEGORY_LABELS.get(theme_answer_id or "", (None, "미확인")) - longitude, latitude = interpolate_point( - planned_day.start_location.longitude, - planned_day.start_location.latitude, - planned_day.end_location.longitude, - planned_day.end_location.latitude, - 1, - 2, - ) - def trim_or_extend_stops_to_feasible_window( planned_day: PlannedDay,