diff --git a/scripts/seed-local-scarce.sql b/scripts/seed-local-scarce.sql new file mode 100644 index 0000000..3237b26 --- /dev/null +++ b/scripts/seed-local-scarce.sql @@ -0,0 +1,221 @@ +-- ============================================================================= +-- scripts/seed-local-scarce.sql +-- 데이트 코스 "장소 부족" 시나리오용 시드 (개선 효과 시연용) +-- +-- 장소 구성: FOOD 2 / CAFE 1 / ACTIVITY 1 (총 4개) +-- categorySequence = [FOOD, CAFE, ACTIVITY] 로 3코스를 생성하면 +-- CAFE/ACTIVITY 는 후보가 1개뿐이므로, +-- - (기존 하드 제외 방식) 2번째·3번째 코스에서 CAFE/ACTIVITY 슬롯이 잘렸음(skip). +-- - (개선 소프트 룰) 3코스 모두 CAFE/ACTIVITY 가 채워짐(중복 허용 + 패널티). +-- +-- 사용 순서 (seed-local.sql 과 동일): +-- 1. 앱 구동 (PlaceTaxonomySeedDataInitializer 자동 실행 대기) +-- 2. Swagger: GET /api/v1/auth/dev/master-token → userId 확인 +-- 3. Swagger: POST /api/v1/rooms → publicId 확인 +-- 4. 아래 v_user_id, v_room_pid 값 수정 후 저장 +-- 5. 터미널에서 실행: +-- bash: docker exec -i udidura-postgres psql -U udidura -d udidura < scripts/seed-local-scarce.sql +-- PowerShell: Get-Content scripts/seed-local-scarce.sql | docker exec -i udidura-postgres psql -U udidura -d udidura +-- +-- 멱등 스크립트: ON CONFLICT DO NOTHING 사용, 중복 실행 안전 +-- ⚠️ 풍부 시드(seed-local.sql)와 다른 방(room)에서 쓰는 것을 권장(장소가 합쳐지면 부족 상황이 사라짐). +-- ============================================================================= + +DO $$ +DECLARE + -- ★★★ 아래 두 값을 반드시 수정하세요 ★★★ + v_user_id BIGINT := 0; -- ① GET /api/v1/auth/dev/master-token 응답의 userId + v_room_pid TEXT := 'YOUR-ROOM-PUBLIC-ID'; -- ② POST /api/v1/rooms 응답의 publicId + -- ★★★★★★★★★★★★★★★★★★★★★★★★★★ + + v_room_id BIGINT; + v_food_cat_id BIGINT; + v_cafe_cat_id BIGINT; + v_act_cat_id BIGINT; + + v_tag_korean BIGINT; + v_tag_chinese BIGINT; + v_tag_bakery BIGINT; + v_tag_park BIGINT; + + v_place_id BIGINT; + v_now TIMESTAMPTZ := NOW(); + v_bh_expires TIMESTAMPTZ := '2027-12-31 00:00:00+00'; + v_bh_json TEXT; + + v_rl_food1 BIGINT; + v_rl_food2 BIGINT; + v_rl_cafe1 BIGINT; + v_rl_act1 BIGINT; + +BEGIN + -- 입력 검증 + IF v_user_id = 0 THEN + RAISE EXCEPTION '[seed-scarce] v_user_id를 실제 userId로 수정하세요.'; + END IF; + IF v_room_pid = 'YOUR-ROOM-PUBLIC-ID' THEN + RAISE EXCEPTION '[seed-scarce] v_room_pid를 실제 roomPublicId로 수정하세요.'; + END IF; + + -- 영업시간 JSON: 월~일 10:00-22:00 + v_bh_json := '{"daily_hours":[' + || '{"day":"월","open":"10:00","close":"22:00"},' + || '{"day":"화","open":"10:00","close":"22:00"},' + || '{"day":"수","open":"10:00","close":"22:00"},' + || '{"day":"목","open":"10:00","close":"22:00"},' + || '{"day":"금","open":"10:00","close":"22:00"},' + || '{"day":"토","open":"10:00","close":"22:00"},' + || '{"day":"일","open":"10:00","close":"22:00"}' + || ']}'; + + -- 룸 조회 + SELECT id INTO v_room_id FROM rooms WHERE public_id = v_room_pid; + IF v_room_id IS NULL THEN + RAISE EXCEPTION '[seed-scarce] 룸을 찾을 수 없습니다: %', v_room_pid; + END IF; + + -- 카테고리 ID 조회 + SELECT id INTO v_food_cat_id FROM place_category WHERE code = 'FOOD'; + SELECT id INTO v_cafe_cat_id FROM place_category WHERE code = 'CAFE'; + SELECT id INTO v_act_cat_id FROM place_category WHERE code = 'ACTIVITY'; + + IF v_food_cat_id IS NULL OR v_cafe_cat_id IS NULL OR v_act_cat_id IS NULL THEN + RAISE EXCEPTION '[seed-scarce] place_category를 찾을 수 없습니다. 앱을 먼저 구동해 taxonomy seed를 완료하세요.'; + END IF; + + -- 태그 ID 조회 + SELECT id INTO v_tag_korean FROM place_tag WHERE code = 'KOREAN' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_chinese FROM place_tag WHERE code = 'CHINESE' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_bakery FROM place_tag WHERE code = 'BAKERY' AND category_id = v_cafe_cat_id; + SELECT id INTO v_tag_park FROM place_tag WHERE code = 'PARK' AND category_id = v_act_cat_id; + + -- 룸 멤버 등록 + INSERT INTO room_members (room_id, user_id, pinned, created_at, updated_at) + VALUES (v_room_id, v_user_id, false, v_now, v_now) + ON CONFLICT (room_id, user_id) DO NOTHING; + + -- ========================================================================= + -- 장소 삽입 (4개 — FOOD 2 / CAFE 1 / ACTIVITY 1) + -- ========================================================================= + INSERT INTO places ( + source, external_place_id, kakao_place_id, + name, category_name, category_group_code, + address, road_address, + latitude, longitude, + service_category_id, service_tag_id, + created_at, updated_at + ) VALUES + ('KAKAO', 'scarce_food_001', 'scarce_food_001', + '부족시드 한식당', '음식점 > 한식', 'FD6', + '서울 마포구 서교동 1-1', '서울 마포구 홍익로 1', + 37.553000, 126.921000, + v_food_cat_id, v_tag_korean, v_now, v_now), + + ('KAKAO', 'scarce_food_002', 'scarce_food_002', + '부족시드 중식당', '음식점 > 중식', 'FD6', + '서울 마포구 연남동 2-2', '서울 마포구 동교로 2', + 37.560500, 126.928000, + v_food_cat_id, v_tag_chinese, v_now, v_now), + + ('KAKAO', 'scarce_cafe_001', 'scarce_cafe_001', + '부족시드 베이커리', '카페 > 베이커리', 'CE7', + '서울 마포구 연남동 6-6', '서울 마포구 동교로 6', + 37.559800, 126.927000, + v_cafe_cat_id, v_tag_bakery, v_now, v_now), + + ('KAKAO', 'scarce_act_001', 'scarce_act_001', + '부족시드 공원', '관광명소 > 공원', 'AT4', + '서울 마포구 연남동 11-1', '서울 마포구 경의로 11', + 37.558000, 126.926200, + v_act_cat_id, v_tag_park, v_now, v_now) + + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- ========================================================================= + -- room_places 삽입 + -- ========================================================================= + FOR v_place_id IN + SELECT id FROM places + WHERE kakao_place_id IN ( + 'scarce_food_001', 'scarce_food_002', 'scarce_cafe_001', 'scarce_act_001' + ) + LOOP + INSERT INTO room_places ( + room_id, place_id, created_by_user_id, added_via, + sido_code, sido_name, sigungu_code, sigungu_name, + created_at, updated_at + ) + VALUES ( + v_room_id, v_place_id, v_user_id, 'EXTERNAL_SEARCH', + '11', '서울특별시', '11440', '마포구', + v_now, v_now + ) + ON CONFLICT (room_id, place_id) DO NOTHING; + END LOOP; + + -- ========================================================================= + -- place_business_hours (코스 생성 필터 통과를 위해 필수) + -- ========================================================================= + INSERT INTO place_business_hours ( + kakao_place_id, place_name, + business_hours_json, business_hours_status, + business_hours_fetched_at, business_hours_expires_at, + version, created_at, updated_at + ) + SELECT + p.kakao_place_id, p.name, v_bh_json, 'SUCCEEDED', + v_now, v_bh_expires, 0, v_now, v_now + FROM places p + WHERE p.kakao_place_id IN ( + 'scarce_food_001', 'scarce_food_002', 'scarce_cafe_001', 'scarce_act_001' + ) + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- ========================================================================= + -- POPULAR 코스도 동작하도록 4개 장소 모두 link 부여 (likeCount 차등) + -- ========================================================================= + INSERT INTO links ( + original_url, normalized_url, + link_source_type, dispatch_status, status, + like_count, version, created_at, updated_at + ) VALUES + ('https://www.instagram.com/p/scarce_food1/', 'https://www.instagram.com/p/scarce_food1/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 1000, 0, v_now, v_now), + ('https://www.instagram.com/p/scarce_food2/', 'https://www.instagram.com/p/scarce_food2/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 200, 0, v_now, v_now), + ('https://www.instagram.com/p/scarce_cafe1/', 'https://www.instagram.com/p/scarce_cafe1/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 900, 0, v_now, v_now), + ('https://www.instagram.com/p/scarce_act1/', 'https://www.instagram.com/p/scarce_act1/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 800, 0, v_now, v_now) + ON CONFLICT (normalized_url) DO NOTHING; + + INSERT INTO room_links (room_id, link_id, created_at, updated_at) + SELECT v_room_id, id, v_now, v_now FROM links + WHERE normalized_url IN ( + 'https://www.instagram.com/p/scarce_food1/', + 'https://www.instagram.com/p/scarce_food2/', + 'https://www.instagram.com/p/scarce_cafe1/', + 'https://www.instagram.com/p/scarce_act1/' + ) + ON CONFLICT (room_id, link_id) DO NOTHING; + + SELECT rl.id INTO v_rl_food1 FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/scarce_food1/'; + SELECT rl.id INTO v_rl_food2 FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/scarce_food2/'; + SELECT rl.id INTO v_rl_cafe1 FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/scarce_cafe1/'; + SELECT rl.id INTO v_rl_act1 FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/scarce_act1/'; + + UPDATE room_places SET origin_room_link_id = v_rl_food1 + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'scarce_food_001'); + UPDATE room_places SET origin_room_link_id = v_rl_food2 + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'scarce_food_002'); + UPDATE room_places SET origin_room_link_id = v_rl_cafe1 + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'scarce_cafe_001'); + UPDATE room_places SET origin_room_link_id = v_rl_act1 + WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'scarce_act_001'); + + RAISE NOTICE '[seed-scarce] 완료 — 장소 4개(FOOD2/CAFE1/ACT1), 영업시간 4개, links 4개 추가됨 (room: %)', v_room_pid; +END $$; diff --git a/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java b/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java index 2eff857..6d3b7b6 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/CourseSelector.java @@ -8,10 +8,10 @@ import com.hufs.capstone.backend.place.domain.entity.RoomPlace; import java.time.Instant; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashSet; import java.util.List; -import java.util.Optional; +import java.util.Map; +import java.util.Random; import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -19,28 +19,58 @@ @Component class CourseSelector { + /** + * 점수 차이를 얼마나 강하게 선택 확률에 반영할지(>1 일수록 최고점 우세). "중간 강도". + */ + private static final double SELECTION_SHARPNESS = 2.0; + private final CourseScorer scorer; private final List strategies; + private final Random random; @Autowired CourseSelector(CourseScorer scorer, List strategies) { + this(scorer, strategies, new Random()); + } + + CourseSelector(CourseScorer scorer, List strategies, Random random) { this.scorer = scorer; this.strategies = strategies; + this.random = random; } CourseSelector(CourseScorer scorer) { - this(scorer, List.of( + this(scorer, defaultStrategies(), new Random()); + } + + CourseSelector(CourseScorer scorer, Random random) { + this(scorer, defaultStrategies(), random); + } + + private static List defaultStrategies() { + return List.of( new GeneralCourseRecommendationStrategy(), new TrendyCourseRecommendationStrategy(), new PopularCourseRecommendationStrategy() - )); + ); } + /** + * 코스 하나를 구성한다. + * + *

코스 내부 장소 중복은 금지하지만, 코스 중복은 허용한다. + * 다른 코스에서 이미 사용된 장소({@code usageCounts})에는 점수 패널티를 적용해 + * 다양성을 유도하되 완전히 제외하지는 않는다(소프트 룰). + * 슬롯별 선택은 최고점 고정 선택이 아니라 점수 기반 가중 확률 선택이다. + * + * @param usageCounts 장소(roomPlace id)별 "이전 코스들에서 사용된 횟수". 읽기 전용으로 사용하며 + * 카운트 증가는 호출 측(서비스)이 코스 커밋 후 수행한다. + */ CourseSelectionResult select( CourseMode mode, List slots, AvailablePool pool, - Set globallyUsedIds, + Map usageCounts, Instant startDateTime ) { CourseRecommendationStrategy strategy = strategyFor(mode); @@ -53,37 +83,76 @@ CourseSelectionResult select( for (int i = 0; i < slots.size(); i++) { CategorySlotCommand slot = slots.get(i); final AvailableCandidate prevForLambda = prev; - Optional best = pool.forSlot(slot).stream() - .filter(c -> !globallyUsedIds.contains(c.roomPlace().getId())) + List candidates = pool.forSlot(slot).stream() .filter(c -> !pickedIds.contains(c.roomPlace().getId())) .filter(strategy::isCandidateAllowed) - .max(Comparator.comparingDouble( - c -> scorer.score(c, prevForLambda, mode, ctx, startDateTime) - )); + .toList(); - if (best.isEmpty()) { + if (candidates.isEmpty()) { skipped.add(i); continue; } - AvailableCandidate chosen = best.get(); + AvailableCandidate chosen = weightedPick(candidates, c -> { + double base = scorer.score(c, prevForLambda, mode, ctx, startDateTime); + int usage = usageCounts.getOrDefault(c.roomPlace().getId(), 0); + return Math.pow(base, SELECTION_SHARPNESS) * crossCoursePenalty(usage); + }); + pickedPlaces.add(chosen.roomPlace()); pickedIds.add(chosen.roomPlace().getId()); prev = chosen; } - globallyUsedIds.addAll(pickedIds); return new CourseSelectionResult(pickedPlaces, skipped); } + /** + * 이미 다른 코스에서 사용된 횟수에 따른 점수 패널티 배수. + * 0회: 1.0, 1회: 0.6, 2회: 0.3, 3회: 0.15 … (사용될수록 절반씩 감소) + */ + private static double crossCoursePenalty(int usageCount) { + if (usageCount <= 0) { + return 1.0; + } + return 0.6 * Math.pow(0.5, usageCount - 1); + } + + /** + * 가중치(weight)에 비례하는 확률로 후보 하나를 선택한다(Weighted Random Selection). + * 모든 가중치가 0 이하이면 균등 확률로 선택한다. + */ + private AvailableCandidate weightedPick( + List candidates, + java.util.function.ToDoubleFunction weightFn + ) { + double[] weights = new double[candidates.size()]; + double total = 0.0; + for (int i = 0; i < candidates.size(); i++) { + double w = Math.max(0.0, weightFn.applyAsDouble(candidates.get(i))); + weights[i] = w; + total += w; + } + + if (total <= 0.0) { + return candidates.get(random.nextInt(candidates.size())); + } + + double threshold = random.nextDouble() * total; + double cumulative = 0.0; + for (int i = 0; i < candidates.size(); i++) { + cumulative += weights[i]; + if (threshold < cumulative) { + return candidates.get(i); + } + } + return candidates.get(candidates.size() - 1); + } + private CourseRecommendationStrategy strategyFor(CourseMode mode) { return strategies.stream() .filter(strategy -> strategy.supports(mode)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unsupported course mode: " + mode)); } - - Set newGloballyUsedIds() { - return new HashSet<>(); - } } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java index 9e9ab5e..fa5023e 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseGenerationService.java @@ -19,8 +19,10 @@ import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -40,6 +42,12 @@ public class DateCourseGenerationService { private final DateCoursePlaceRepository dateCoursePlaceRepository; private final ObjectMapper objectMapper; + /** + * 한 배치 안에서 "코스 전체가 동일"하게 나오는 것을 피하기 위한 최대 재추첨 횟수. + * 장소가 부족해 회피가 불가능하면 마지막 후보를 그대로 사용해 생성을 보장한다. + */ + private static final int MAX_REROLL = 5; + @Transactional public DateCourseGenerationResult generate(DateCourseGenerationCommand command, Long userId) { inputValidator.validate(command.sigunguCode(), command.startDateTime(), @@ -55,21 +63,22 @@ public DateCourseGenerationResult generate(DateCourseGenerationCommand command, String batchId = UUID.randomUUID().toString(); String categorySequenceJson = serializeSlots(command.categorySequence()); - Set globallyUsedIds = courseSelector.newGloballyUsedIds(); + Map usageCounts = new HashMap<>(); + Set> batchSignatures = new HashSet<>(); List results = new ArrayList<>(); for (CourseMode mode : List.of(CourseMode.GENERAL, CourseMode.TRENDY, CourseMode.POPULAR)) { - Set candidateUsedIds = new HashSet<>(globallyUsedIds); - CourseSelectionResult selection = courseSelector.select( - mode, command.categorySequence(), pool, candidateUsedIds, command.startDateTime()); - - if (selection.pickedPlaces().isEmpty()) { + CourseSelectionResult selection = selectAvoidingDuplicates( + mode, command, pool, usageCounts, batchSignatures, room.getId()); + if (selection == null) { continue; } - if (duplicatePolicy.existsSavedCourseWithSamePlaces(room.getId(), selection.pickedPlaces())) { - continue; + + List signature = placeIdSignature(selection.pickedPlaces()); + batchSignatures.add(signature); + for (Long placeId : signature) { + usageCounts.merge(placeId, 1, Integer::sum); } - globallyUsedIds = candidateUsedIds; String skippedJson = serializeSkipped(selection.skippedSlotIndices()); DateCourse dateCourse = dateCourseRepository.save(DateCourse.create( @@ -101,6 +110,43 @@ public DateCourseGenerationResult generate(DateCourseGenerationCommand command, return new DateCourseGenerationResult(batchId, results); } + /** + * 한 모드의 코스를 선택하되, 같은 배치 안에서 "코스 전체가 동일"한 결과를 최대 {@link #MAX_REROLL}회 + * 재추첨으로 피한다. 회피에 실패하면(장소 부족) 마지막 유효 후보를 사용해 생성을 보장한다. + * 이미 저장된 코스와 장소·순서가 완전히 같은 후보는 절대 채택하지 않는다. + * + * @return 채택된 선택 결과. 채택 가능한 후보가 없으면(전부 비었거나 전부 저장-중복) {@code null}. + */ + private CourseSelectionResult selectAvoidingDuplicates( + CourseMode mode, + DateCourseGenerationCommand command, + AvailablePool pool, + Map usageCounts, + Set> batchSignatures, + Long roomId + ) { + CourseSelectionResult fallback = null; + for (int attempt = 0; attempt < MAX_REROLL; attempt++) { + CourseSelectionResult selection = courseSelector.select( + mode, command.categorySequence(), pool, usageCounts, command.startDateTime()); + if (selection.pickedPlaces().isEmpty()) { + continue; + } + if (duplicatePolicy.existsSavedCourseWithSamePlaces(roomId, selection.pickedPlaces())) { + continue; + } + fallback = selection; + if (!batchSignatures.contains(placeIdSignature(selection.pickedPlaces()))) { + return selection; + } + } + return fallback; + } + + private static List placeIdSignature(List places) { + return places.stream().map(RoomPlace::getId).toList(); + } + private static DateCourseResult toResult(DateCourse dateCourse, List pickedPlaces, List skipped) { List placeResults = new ArrayList<>(); for (int i = 0; i < pickedPlaces.size(); i++) { diff --git a/src/main/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolver.java b/src/main/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolver.java index cbd3cda..bbac818 100644 --- a/src/main/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolver.java +++ b/src/main/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolver.java @@ -34,6 +34,13 @@ public class PlaceTaxonomyResolver { "사진스튜디오" ); private static final List OVERRIDE_RULES = List.of( + new OverrideRule( + KakaoCategoryGroupPolicy.KAKAO_FOOD, + KakaoCategoryGroupPolicy.SERVICE_CATEGORY_CAFE, + "BAKERY", + List.of(), + List.of("제과", "베이커리") + ), new OverrideRule( KakaoCategoryGroupPolicy.KAKAO_CAFE, KakaoCategoryGroupPolicy.SERVICE_CATEGORY_ACTIVITY, diff --git a/src/test/java/com/hufs/capstone/backend/course/application/CourseDiversityMetricsTest.java b/src/test/java/com/hufs/capstone/backend/course/application/CourseDiversityMetricsTest.java new file mode 100644 index 0000000..7b89800 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/CourseDiversityMetricsTest.java @@ -0,0 +1,143 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.application.dto.AvailableCandidate; +import com.hufs.capstone.backend.course.application.dto.CategorySlotCommand; +import com.hufs.capstone.backend.course.application.dto.CourseSelectionResult; +import com.hufs.capstone.backend.course.domain.enums.CourseMode; +import com.hufs.capstone.backend.place.domain.entity.Place; +import com.hufs.capstone.backend.place.domain.entity.PlaceCategory; +import com.hufs.capstone.backend.place.domain.entity.PlaceTag; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; + +/** + * 발표용 다양성 정량 지표 측정 테스트. + * + *

장소가 "부족한" 풀(FOOD 2 / CAFE 1 / ACTIVITY 1)에서 GENERAL/TRENDY/POPULAR 3코스 배치를 + * 여러 번 생성하며 다음 지표를 산출한다. + *

    + *
  • 생성 성공률: 필요한 모든 슬롯이 채워진(skip 없는) 코스 비율
  • + *
  • 코스 간 중복률: 코스쌍 사이에 공유되는 장소 비율
  • + *
  • 평균 고유 장소 수: 3코스 전체에서 서로 다른 장소 수
  • + *
+ * 개선된 소프트 룰에서는 장소가 부족해도 생성 성공률이 100%여야 한다(코스 간 중복 허용 덕분). + */ +@Slf4j +class CourseDiversityMetricsTest { + + private static final long SEED = 7L; + private static final int BATCHES = 200; + private static final List MODES = + List.of(CourseMode.GENERAL, CourseMode.TRENDY, CourseMode.POPULAR); + private static final List SLOTS = List.of( + new CategorySlotCommand("FOOD", null), + new CategorySlotCommand("CAFE", null), + new CategorySlotCommand("ACTIVITY", null) + ); + + private final CourseScorer scorer = new CourseScorer(); + private final CourseSelector selector = new CourseSelector(scorer, new Random(SEED)); + + @Test + void scarcePoolStillGeneratesAllCoursesWithDiversity() { + int totalCourses = 0; + int fullCourses = 0; + double overlapSum = 0.0; + int overlapPairs = 0; + long uniqueSum = 0; + + for (int batch = 0; batch < BATCHES; batch++) { + AvailablePool pool = scarcePool(); + Map usageCounts = new HashMap<>(); + List> courses = new ArrayList<>(); + + for (CourseMode mode : MODES) { + CourseSelectionResult selection = + selector.select(mode, SLOTS, pool, usageCounts, Instant.now()); + List ids = selection.pickedPlaces().stream().map(RoomPlace::getId).toList(); + courses.add(ids); + for (Long id : ids) { + usageCounts.merge(id, 1, Integer::sum); + } + + totalCourses++; + if (selection.skippedSlotIndices().isEmpty()) { + fullCourses++; + } + } + + // 코스쌍 간 중복률 + for (int i = 0; i < courses.size(); i++) { + for (int j = i + 1; j < courses.size(); j++) { + List a = courses.get(i); + List b = courses.get(j); + if (a.isEmpty() || b.isEmpty()) { + continue; + } + long shared = a.stream().filter(b::contains).count(); + overlapSum += (double) shared / Math.max(a.size(), b.size()); + overlapPairs++; + } + } + + // 배치 전체 고유 장소 수 + Set unique = new HashSet<>(); + courses.forEach(unique::addAll); + uniqueSum += unique.size(); + } + + double successRate = (double) fullCourses / totalCourses; + double overlapRate = overlapPairs == 0 ? 0.0 : overlapSum / overlapPairs; + double avgUnique = (double) uniqueSum / BATCHES; + + log.info("=== 개선 방식(중복 패널티 + 확률 선택) 다양성 지표 (부족 풀: FOOD2/CAFE1/ACT1) ==="); + log.info("배치 수 : {}", BATCHES); + log.info("생성 성공률 : {}%", String.format("%.1f", successRate * 100)); + log.info("코스 간 중복률 : {}%", String.format("%.1f", overlapRate * 100)); + log.info("평균 고유 장소 수 : {}", String.format("%.2f", avgUnique)); + + // 핵심 개선: 장소가 부족해도 모든 코스가 채워진다(하드 제외였다면 뒤 코스가 잘렸을 것). + assertThat(successRate).isEqualTo(1.0); + } + + private static AvailablePool scarcePool() { + List candidates = new ArrayList<>(); + candidates.add(candidate(1L, "FOOD", "KOREAN", 37.5000, 127.0)); + candidates.add(candidate(2L, "FOOD", "CHINESE", 37.5020, 127.0)); + candidates.add(candidate(3L, "CAFE", "BAKERY", 37.5010, 127.0)); + candidates.add(candidate(4L, "ACTIVITY", "PARK", 37.5015, 127.0)); + return new AvailablePool(candidates); + } + + private static AvailableCandidate candidate(Long id, String catCode, String tagCode, double lat, double lng) { + PlaceCategory category = mock(PlaceCategory.class); + when(category.getCode()).thenReturn(catCode); + PlaceTag tag = mock(PlaceTag.class); + when(tag.getCode()).thenReturn(tagCode); + Place place = mock(Place.class); + when(place.getServiceCategory()).thenReturn(category); + when(place.getServiceTag()).thenReturn(tag); + when(place.getLatitude()).thenReturn(BigDecimal.valueOf(lat)); + when(place.getLongitude()).thenReturn(BigDecimal.valueOf(lng)); + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getId()).thenReturn(id); + when(roomPlace.getPlace()).thenReturn(place); + when(roomPlace.getCreatedAt()).thenReturn(Instant.now()); + when(roomPlace.getOriginRoomLink()).thenReturn(mock(com.hufs.capstone.backend.link.domain.entity.RoomLink.class)); + return new AvailableCandidate(roomPlace, null); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java b/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java index 81ba5e2..bc9749a 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/CourseSelectorTest.java @@ -14,24 +14,26 @@ import com.hufs.capstone.backend.place.domain.entity.RoomPlace; import java.math.BigDecimal; import java.time.Instant; -import java.util.HashSet; +import java.util.HashMap; import java.util.List; -import java.util.Set; +import java.util.Map; +import java.util.Random; import org.junit.jupiter.api.Test; class CourseSelectorTest { + private static final long SEED = 42L; + private final CourseScorer scorer = new CourseScorer(); - private final CourseSelector selector = new CourseSelector(scorer); + private final CourseSelector selector = new CourseSelector(scorer, new Random(SEED)); @Test void firstSlotNoPreviousDistanceIgnored() { AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); AvailablePool pool = new AvailablePool(List.of(candidate)); List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); - Set used = new HashSet<>(); - CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, new HashMap<>(), Instant.now()); assertThat(result.pickedPlaces()).hasSize(1); assertThat(result.skippedSlotIndices()).isEmpty(); @@ -43,9 +45,8 @@ void noMatchingCandidateSlotIsSkipped() { AvailablePool pool = new AvailablePool(List.of(candidate)); // slot asks for CAFE but pool only has FOOD List slots = List.of(new CategorySlotCommand("CAFE", "BAKERY")); - Set used = new HashSet<>(); - CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, new HashMap<>(), Instant.now()); assertThat(result.pickedPlaces()).isEmpty(); assertThat(result.skippedSlotIndices()).containsExactly(0); @@ -60,30 +61,30 @@ void wildcardSlotMatchesAllTagsInCategory() { new CategorySlotCommand("CAFE", null), // wildcard new CategorySlotCommand("CAFE", null) // second wildcard slot ); - Set used = new HashSet<>(); - CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, new HashMap<>(), Instant.now()); assertThat(result.pickedPlaces()).hasSize(2); assertThat(result.skippedSlotIndices()).isEmpty(); } @Test - void globallyUsedIdsPreventCrossCourseDuplication() { + void crossCourseDuplicationAllowed() { + // 소프트 룰: 코스 간 장소 중복은 허용된다(하드 제외 제거). AvailableCandidate candidate = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); AvailablePool pool = new AvailablePool(List.of(candidate)); List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); - Set used = new HashSet<>(); - // First course consumes id=1 - CourseSelectionResult first = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); - assertThat(first.pickedPlaces()).hasSize(1); - assertThat(used).contains(1L); + // 첫 코스가 id=1을 사용했다고 가정하고 usageCount를 1로 둔다. + Map usageCounts = new HashMap<>(); + usageCounts.put(1L, 1); + + // 두 번째 코스도 같은 장소를 다시 선택할 수 있어야 한다(제외되지 않음). + CourseSelectionResult second = selector.select(CourseMode.TRENDY, slots, pool, usageCounts, Instant.now()); - // Second course cannot reuse id=1 - CourseSelectionResult second = selector.select(CourseMode.TRENDY, slots, pool, used, Instant.now()); - assertThat(second.pickedPlaces()).isEmpty(); - assertThat(second.skippedSlotIndices()).containsExactly(0); + assertThat(second.pickedPlaces()).hasSize(1); + assertThat(second.pickedPlaces().get(0).getId()).isEqualTo(1L); + assertThat(second.skippedSlotIndices()).isEmpty(); } @Test @@ -95,9 +96,8 @@ void sameCourseDuplicationPrevented() { new CategorySlotCommand("FOOD", "KOREAN"), new CategorySlotCommand("FOOD", "KOREAN") ); - Set used = new HashSet<>(); - CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, used, Instant.now()); + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, new HashMap<>(), Instant.now()); assertThat(result.pickedPlaces()).hasSize(1); assertThat(result.skippedSlotIndices()).containsExactly(1); @@ -110,14 +110,63 @@ void popularCandidateWithoutLinkExcluded() { AvailablePool pool = new AvailablePool(List.of(noLink, withLink)); List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); - Set used = new HashSet<>(); - CourseSelectionResult result = selector.select(CourseMode.POPULAR, slots, pool, used, Instant.now()); + CourseSelectionResult result = selector.select(CourseMode.POPULAR, slots, pool, new HashMap<>(), Instant.now()); assertThat(result.pickedPlaces()).hasSize(1); assertThat(result.pickedPlaces().get(0).getId()).isEqualTo(1L); } + @Test + void penaltyLowersReuseProbability() { + // 동일 좌표/생성시각의 두 후보 A(id=1), B(id=2). 기본 점수는 같다. + // A에만 cross-course 사용 패널티(2회)를 주면 B가 훨씬 자주 선택되어야 한다. + List slots = List.of(new CategorySlotCommand("FOOD", "KOREAN")); + int iterations = 2000; + int bChosen = 0; + for (int i = 0; i < iterations; i++) { + AvailableCandidate a = candidate(1L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); + AvailableCandidate b = candidate(2L, "FOOD", "KOREAN", 37.0, 127.0, Instant.now()); + AvailablePool pool = new AvailablePool(List.of(a, b)); + Map usageCounts = new HashMap<>(); + usageCounts.put(1L, 2); // A는 이미 두 번 사용됨 → 패널티 0.3 + + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, usageCounts, Instant.now()); + if (result.pickedPlaces().get(0).getId() == 2L) { + bChosen++; + } + } + + // 패널티 0.3 vs 1.0 → B 선택 확률 ≈ 1/(1+0.3) ≈ 77%. 넉넉히 65% 이상이면 통과. + assertThat(bChosen).isGreaterThan((int) (iterations * 0.65)); + } + + @Test + void higherScoreSelectedMoreOften() { + // prev(첫 슬롯)에 가까운 후보일수록 거리 점수가 높다. + // 두 번째 슬롯에서 prev에 가까운 near(id=2)가 far(id=3)보다 더 자주 선택되어야 한다. + List slots = List.of( + new CategorySlotCommand("FOOD", "KOREAN"), + new CategorySlotCommand("CAFE", "BAKERY") + ); + int iterations = 2000; + int nearChosen = 0; + for (int i = 0; i < iterations; i++) { + AvailableCandidate anchor = candidate(1L, "FOOD", "KOREAN", 37.5000, 127.0, Instant.now()); + AvailableCandidate near = candidate(2L, "CAFE", "BAKERY", 37.5010, 127.0, Instant.now()); + AvailableCandidate far = candidate(3L, "CAFE", "BAKERY", 37.6000, 127.0, Instant.now()); + AvailablePool pool = new AvailablePool(List.of(anchor, near, far)); + + CourseSelectionResult result = selector.select(CourseMode.GENERAL, slots, pool, new HashMap<>(), Instant.now()); + assertThat(result.pickedPlaces()).hasSize(2); + if (result.pickedPlaces().get(1).getId() == 2L) { + nearChosen++; + } + } + + assertThat(nearChosen).isGreaterThan(iterations / 2); + } + private static AvailableCandidate candidate(Long id, String catCode, String tagCode, double lat, double lng, Instant createdAt) { PlaceCategory category = mock(PlaceCategory.class); diff --git a/src/test/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolverTest.java b/src/test/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolverTest.java index f8f1fc4..f08f834 100644 --- a/src/test/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolverTest.java +++ b/src/test/java/com/hufs/capstone/backend/place/application/PlaceTaxonomyResolverTest.java @@ -86,6 +86,19 @@ void shouldResolveCafeBakeryTagFromEitherConfectioneryOrBakeryKeyword() { assertThat(confectionery.tag().getName()).isEqualTo("제과,베이커리"); } + @Test + void shouldOverrideKakaoFoodBakeryToCafeBakery() { + PlaceTag bakery = tag(20L, cafe, "BAKERY", "제과,베이커리", 1); + PlaceTag coffeeDessert = tag(21L, cafe, "COFFEE_DESSERT", "커피·디저트", 2); + PlaceTag misc = tag(22L, cafe, "MISC", "기타", 9); + when(placeTagRepository.findActiveTaxonomyTags()).thenReturn(List.of(bakery, coffeeDessert, misc)); + + ResolvedPlaceTaxonomy result = resolver.resolve("FD6", "음식점 > 간식 > 제과,베이커리"); + + assertThat(result.category().getCode()).isEqualTo("CAFE"); + assertThat(result.tag().getCode()).isEqualTo("BAKERY"); + } + @Test void shouldResolvePlainCafeAndCoffeeShopToCoffeeDessert() { PlaceTag bakery = tag(20L, cafe, "BAKERY", "제과,베이커리", 1);