From 5d797bbc7d08aa9cf8f2f54f7c1627b6895664ab Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:10:53 +0900 Subject: [PATCH 01/11] =?UTF-8?q?#354=20feat:=20course=5Fsearch=5Fdocument?= =?UTF-8?q?s=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=EC=A1=B4=20=EA=B3=B5=EC=9C=A0=20=EC=BD=94?= =?UTF-8?q?=EC=8A=A4=20INIT=20=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=82=BD?= =?UTF-8?q?=EC=9E=85=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V12__create_course_search_documents.sql | 14 ++++++++++++++ ...ourse_search_documents_for_existing_courses.sql | 8 ++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/main/resources/db/migration/V12__create_course_search_documents.sql create mode 100644 src/main/resources/db/migration/V13__insert_init_course_search_documents_for_existing_courses.sql diff --git a/src/main/resources/db/migration/V12__create_course_search_documents.sql b/src/main/resources/db/migration/V12__create_course_search_documents.sql new file mode 100644 index 00000000..6638121d --- /dev/null +++ b/src/main/resources/db/migration/V12__create_course_search_documents.sql @@ -0,0 +1,14 @@ +CREATE TABLE course_search_documents +( + course_id BIGINT NOT NULL, + retrieval_text TEXT NOT NULL, + embedding MEDIUMBLOB, + embedding_model VARCHAR(100), + status VARCHAR(20) NOT NULL DEFAULT 'INIT', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + + PRIMARY KEY (course_id), + CONSTRAINT fk_course_search_documents_course + FOREIGN KEY (course_id) REFERENCES courses (id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V13__insert_init_course_search_documents_for_existing_courses.sql b/src/main/resources/db/migration/V13__insert_init_course_search_documents_for_existing_courses.sql new file mode 100644 index 00000000..7911b558 --- /dev/null +++ b/src/main/resources/db/migration/V13__insert_init_course_search_documents_for_existing_courses.sql @@ -0,0 +1,8 @@ +INSERT INTO course_search_documents (course_id, retrieval_text, status) +SELECT c.id, '', 'INIT' +FROM courses c +WHERE c.active = true + AND c.is_shared = true + AND NOT EXISTS ( + SELECT 1 FROM course_search_documents d WHERE d.course_id = c.id + ); From 5557fa7a07ec867653e24cb2318a8c55d41628fa Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:10:58 +0900 Subject: [PATCH 02/11] =?UTF-8?q?#354=20feat:=20CourseSearchDocument=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course/entity/CourseSearchDocument.java | 73 +++++++++++++++++++ .../CourseSearchDocumentRepository.java | 52 +++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/main/java/org/sopt/solply_server/domain/course/entity/CourseSearchDocument.java create mode 100644 src/main/java/org/sopt/solply_server/domain/course/repository/CourseSearchDocumentRepository.java diff --git a/src/main/java/org/sopt/solply_server/domain/course/entity/CourseSearchDocument.java b/src/main/java/org/sopt/solply_server/domain/course/entity/CourseSearchDocument.java new file mode 100644 index 00000000..08b77bdb --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/course/entity/CourseSearchDocument.java @@ -0,0 +1,73 @@ +package org.sopt.solply_server.domain.course.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.solply_server.domain.place.entity.EmbeddingStatus; +import org.sopt.solply_server.global.ai.FloatArrayConverter; +import org.sopt.solply_server.global.entity.BaseTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "course_search_documents") +public class CourseSearchDocument extends BaseTimeEntity { + + @Id + private Long courseId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id") + private Course course; + + @Column(nullable = false, columnDefinition = "TEXT") + private String retrievalText; + + @Convert(converter = FloatArrayConverter.class) + @Column(columnDefinition = "MEDIUMBLOB") + private float[] embedding; + + @Column(length = 100) + private String embeddingModel; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private EmbeddingStatus status; + + public static CourseSearchDocument init(Course course) { + CourseSearchDocument doc = new CourseSearchDocument(); + doc.course = course; + doc.retrievalText = ""; + doc.status = EmbeddingStatus.INIT; + return doc; + } + + public void updateEmbedding(String retrievalText, float[] embedding, String embeddingModel) { + this.retrievalText = retrievalText; + this.embedding = embedding; + this.embeddingModel = embeddingModel; + this.status = EmbeddingStatus.READY; + } + + public void markFailed() { + this.status = EmbeddingStatus.FAILED; + } + + public void markDirty() { + if (this.status != EmbeddingStatus.INIT) { + this.status = EmbeddingStatus.DIRTY; + } + } +} diff --git a/src/main/java/org/sopt/solply_server/domain/course/repository/CourseSearchDocumentRepository.java b/src/main/java/org/sopt/solply_server/domain/course/repository/CourseSearchDocumentRepository.java new file mode 100644 index 00000000..b2bf85aa --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/course/repository/CourseSearchDocumentRepository.java @@ -0,0 +1,52 @@ +package org.sopt.solply_server.domain.course.repository; + +import java.util.Collection; +import java.util.List; +import org.sopt.solply_server.domain.course.entity.CourseSearchDocument; +import org.sopt.solply_server.domain.place.entity.EmbeddingStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CourseSearchDocumentRepository extends JpaRepository { + + @Query(""" + SELECT d FROM CourseSearchDocument d + JOIN FETCH d.course c + JOIN FETCH c.tag + JOIN FETCH c.town + WHERE c.active = true + AND c.isShared = true + AND c.town.id = :townId + AND d.embedding IS NOT NULL + """) + List findActiveSharedByTownIdWithEmbedding(@Param("townId") Long townId); + + @Query(""" + SELECT d FROM CourseSearchDocument d + JOIN FETCH d.course c + JOIN FETCH c.tag + JOIN FETCH c.town + WHERE c.active = true + AND c.isShared = true + AND c.town.parent.id = :parentTownId + AND d.embedding IS NOT NULL + """) + List findActiveSharedByParentTownIdWithEmbedding(@Param("parentTownId") Long parentTownId); + + @Query(""" + SELECT d.courseId FROM CourseSearchDocument d + WHERE d.status IN :statuses + """) + List findCourseIdsByStatusIn(@Param("statuses") Collection statuses); + + @Modifying + @Query(""" + UPDATE CourseSearchDocument d + SET d.status = 'DIRTY', d.updatedAt = CURRENT_TIMESTAMP + WHERE d.status <> 'INIT' + AND d.courseId = :courseId + """) + int markDirtyByCourseId(@Param("courseId") Long courseId); +} From 3df49017e82fcaca37ed1384bcebacd5c41db059 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:11:02 +0900 Subject: [PATCH 03/11] =?UTF-8?q?#354=20feat:=20CourseRetrievalTextBuilder?= =?UTF-8?q?=20=EB=B0=8F=20CourseEmbeddingBatchProcessor=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CourseEmbeddingBatchProcessor.java | 87 +++++++++++++++++++ .../service/CourseRetrievalTextBuilder.java | 45 ++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/main/java/org/sopt/solply_server/domain/course/service/CourseEmbeddingBatchProcessor.java create mode 100644 src/main/java/org/sopt/solply_server/domain/course/service/CourseRetrievalTextBuilder.java diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/CourseEmbeddingBatchProcessor.java b/src/main/java/org/sopt/solply_server/domain/course/service/CourseEmbeddingBatchProcessor.java new file mode 100644 index 00000000..b479a0db --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/course/service/CourseEmbeddingBatchProcessor.java @@ -0,0 +1,87 @@ +package org.sopt.solply_server.domain.course.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.solply_server.domain.course.entity.Course; +import org.sopt.solply_server.domain.course.entity.CourseSearchDocument; +import org.sopt.solply_server.domain.course.repository.CourseRepository; +import org.sopt.solply_server.domain.course.repository.CourseSearchDocumentRepository; +import org.sopt.solply_server.global.ai.EmbeddingService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CourseEmbeddingBatchProcessor { + + private final CourseSearchDocumentRepository courseSearchDocumentRepository; + private final CourseRepository courseRepository; + private final CourseRetrievalTextBuilder retrievalTextBuilder; + private final EmbeddingService embeddingService; + + /** + * 단건 임베딩. 호출자의 트랜잭션 내에서 동작한다. + */ + public void processOne(CourseSearchDocument doc) { + List courses = courseRepository.findByIdInWithAllForRecommendation(List.of(doc.getCourseId())); + if (courses.isEmpty()) { + log.warn("코스를 찾을 수 없습니다 - courseId={}", doc.getCourseId()); + doc.markFailed(); + return; + } + Course course = courses.get(0); + + try { + String retrievalText = retrievalTextBuilder.build(course); + float[] embedding = embeddingService.embed(retrievalText); + doc.updateEmbedding(retrievalText, embedding, embeddingService.getModelName()); + log.info("코스 임베딩 완료 - courseId={}", doc.getCourseId()); + } catch (Exception e) { + doc.markFailed(); + log.warn("코스 임베딩 실패 - courseId={}, error={}", doc.getCourseId(), e.getMessage()); + } + } + + @Transactional + public void processBatch(List courseIds) { + Map courseById = courseRepository.findByIdInWithAllForRecommendation(courseIds) + .stream().collect(Collectors.toMap(Course::getId, c -> c)); + + List docs = courseSearchDocumentRepository.findAllById(courseIds); + + List embeddableDocs = new ArrayList<>(); + List retrievalTexts = new ArrayList<>(); + + for (CourseSearchDocument doc : docs) { + Course course = courseById.get(doc.getCourseId()); + if (course == null) { + log.warn("코스를 찾을 수 없어 건너뜁니다 - courseId={}", doc.getCourseId()); + continue; + } + embeddableDocs.add(doc); + retrievalTexts.add(retrievalTextBuilder.build(course)); + } + + if (embeddableDocs.isEmpty()) return; + + List embeddings = embeddingService.embedAll(retrievalTexts); + + for (int i = 0; i < embeddableDocs.size(); i++) { + embeddableDocs.get(i).updateEmbedding(retrievalTexts.get(i), embeddings.get(i), embeddingService.getModelName()); + } + + log.info("코스 배치 임베딩 완료 - count={}", embeddableDocs.size()); + } + + @Transactional + public void markChunkFailed(List courseIds) { + List docs = courseSearchDocumentRepository.findAllById(courseIds); + docs.forEach(CourseSearchDocument::markFailed); + log.warn("코스 청크 임베딩 실패로 FAILED 처리 - count={}", docs.size()); + } +} diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/CourseRetrievalTextBuilder.java b/src/main/java/org/sopt/solply_server/domain/course/service/CourseRetrievalTextBuilder.java new file mode 100644 index 00000000..6d951654 --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/course/service/CourseRetrievalTextBuilder.java @@ -0,0 +1,45 @@ +package org.sopt.solply_server.domain.course.service; + +import java.util.List; +import java.util.Objects; +import org.sopt.solply_server.domain.course.entity.Course; +import org.sopt.solply_server.domain.course.entity.CoursePlace; +import org.sopt.solply_server.domain.place.entity.Place; +import org.sopt.solply_server.domain.tag.entity.Tag; +import org.springframework.stereotype.Component; + +@Component +public class CourseRetrievalTextBuilder { + + /** + * 코스 임베딩용 retrieval_text 생성. + * 호출자는 course.coursePlaces → place → placeTags 가 이미 로딩된 상태여야 한다. + */ + public String build(Course course) { + String courseName = course.getName(); + String townName = course.getTown().getName(); + String category = course.getTag() != null ? course.getTag().getName() : "기타"; + String introduction = course.getIntroduction(); + int placeCount = course.getCoursePlaces().size(); + String lengthDesc = placeCount <= 3 ? "짧은" : "반나절"; + + List mainTagNames = course.getCoursePlaces().stream() + .map(CoursePlace::getPlace) + .map(place -> place.getMainTag().filter(Tag::isActive).map(Tag::getName).orElse(null)) + .filter(Objects::nonNull) + .distinct() + .toList(); + + String tagList = mainTagNames.isEmpty() ? "다양한 장소" : String.join(", ", mainTagNames); + + StringBuilder sb = new StringBuilder(); + sb.append(courseName).append("은 ").append(townName).append("에 위치한 ").append(category).append(" 코스다.\n"); + if (introduction != null && !introduction.isBlank()) { + sb.append(introduction).append("\n"); + } + sb.append("총 ").append(placeCount).append("개 장소로 구성된 ").append(lengthDesc).append(" 코스다.\n"); + sb.append("주요 장소 유형: ").append(tagList); + + return sb.toString(); + } +} From cf1d271bf28eec38201a88f6794cf36b037973a5 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:17:18 +0900 Subject: [PATCH 04/11] =?UTF-8?q?#354=20feat:=20CourseCreatedEvent=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A6=AC=EC=8A=A4=EB=84=88,=20CourseEmbeddingFacad?= =?UTF-8?q?e=20=EB=B0=B0=EC=B9=98=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/event/CourseCreatedEvent.java | 4 ++ .../event/CourseCreatedEventListener.java | 44 +++++++++++++ .../service/facade/CourseEmbeddingFacade.java | 65 +++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEvent.java create mode 100644 src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEventListener.java create mode 100644 src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseEmbeddingFacade.java diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEvent.java b/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEvent.java new file mode 100644 index 00000000..e91bf6d2 --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEvent.java @@ -0,0 +1,4 @@ +package org.sopt.solply_server.domain.course.service.event; + +public record CourseCreatedEvent(Long courseId) { +} diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEventListener.java b/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEventListener.java new file mode 100644 index 00000000..75790d5a --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEventListener.java @@ -0,0 +1,44 @@ +package org.sopt.solply_server.domain.course.service.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.solply_server.domain.course.entity.Course; +import org.sopt.solply_server.domain.course.entity.CourseSearchDocument; +import org.sopt.solply_server.domain.course.repository.CourseRepository; +import org.sopt.solply_server.domain.course.repository.CourseSearchDocumentRepository; +import org.sopt.solply_server.domain.course.service.CourseEmbeddingBatchProcessor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CourseCreatedEventListener { + + private final CourseRepository courseRepository; + private final CourseSearchDocumentRepository courseSearchDocumentRepository; + private final CourseEmbeddingBatchProcessor batchProcessor; + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CourseCreatedEvent event) { + Long courseId = event.courseId(); + log.info("코스 임베딩 생성 시작 - courseId={}", courseId); + + Course course = courseRepository.findById(courseId).orElse(null); + if (course == null) { + log.warn("코스를 찾을 수 없습니다 - courseId={}", courseId); + return; + } + + CourseSearchDocument doc = courseSearchDocumentRepository.findById(courseId) + .orElseGet(() -> courseSearchDocumentRepository.save(CourseSearchDocument.init(course))); + + batchProcessor.processOne(doc); + } +} diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseEmbeddingFacade.java b/src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseEmbeddingFacade.java new file mode 100644 index 00000000..5db21487 --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseEmbeddingFacade.java @@ -0,0 +1,65 @@ +package org.sopt.solply_server.domain.course.service.facade; + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.solply_server.domain.course.repository.CourseSearchDocumentRepository; +import org.sopt.solply_server.domain.course.service.CourseEmbeddingBatchProcessor; +import org.sopt.solply_server.domain.place.entity.EmbeddingStatus; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CourseEmbeddingFacade { + + /** 한 번에 OpenAI API에 전송할 코스 수. Rate limit 대응 및 부분 실패 격리를 위해 분할한다. */ + private static final int EMBEDDING_CHUNK_SIZE = 50; + + private static final List REEMBEDDING_TARGET_STATUSES = + List.of(EmbeddingStatus.INIT, EmbeddingStatus.DIRTY, EmbeddingStatus.FAILED); + + private final CourseSearchDocumentRepository courseSearchDocumentRepository; + private final CourseEmbeddingBatchProcessor batchProcessor; + + /** + * 매일 새벽 4시 — INIT·DIRTY·FAILED 상태의 코스 문서를 일괄 재임베딩한다. + * 장소 임베딩 배치(03:00)와 겹치지 않도록 04:00으로 설정. + */ + @Scheduled(cron = "0 0 4 * * *") + public void reembedStaleDocuments() { + List targetCourseIds = courseSearchDocumentRepository.findCourseIdsByStatusIn(REEMBEDDING_TARGET_STATUSES); + + log.info("코스 재임베딩 배치 시작 - 대상 수: {}", targetCourseIds.size()); + embedInChunks(targetCourseIds); + log.info("코스 재임베딩 배치 완료"); + } + + /** + * courseId 목록을 EMBEDDING_CHUNK_SIZE 단위로 분할하여 순차 임베딩한다. + * 청크 단위로 트랜잭션이 분리되어 있어, 한 청크가 실패해도 다른 청크에 영향을 주지 않는다. + */ + private void embedInChunks(List courseIds) { + List> chunks = splitIntoChunks(courseIds, EMBEDDING_CHUNK_SIZE); + + for (List chunk : chunks) { + try { + batchProcessor.processBatch(chunk); + } catch (Exception e) { + log.warn("코스 청크 임베딩 실패 — 해당 청크를 FAILED 처리합니다. size={}, error={}", chunk.size(), e.getMessage()); + batchProcessor.markChunkFailed(chunk); + } + } + } + + /** 리스트를 maxSize 크기의 서브리스트 목록으로 분할한다. */ + private List> splitIntoChunks(List list, int maxSize) { + List> chunks = new ArrayList<>(); + for (int start = 0; start < list.size(); start += maxSize) { + chunks.add(list.subList(start, Math.min(start + maxSize, list.size()))); + } + return chunks; + } +} From 04889a2066dc3b31fbfc0b38374cc5b6dd81fdf2 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:24:25 +0900 Subject: [PATCH 05/11] =?UTF-8?q?#354=20feat:=20CourseService=EC=97=90=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=C2=B7DIRTY=20?= =?UTF-8?q?=EB=A7=88=ED=82=B9=20=EC=B6=94=EA=B0=80,=20=EB=B9=84=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EC=BD=94=EC=8A=A4=20=EC=9E=84=EB=B2=A0=EB=94=A9=20?= =?UTF-8?q?=EC=83=9D=EB=9E=B5=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course/repository/CourseRepository.java | 17 +++++++++++++++++ .../domain/course/service/CourseService.java | 14 ++++++++++++++ .../event/CourseCreatedEventListener.java | 9 ++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java b/src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java index b35b3258..6821d259 100644 --- a/src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java +++ b/src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java @@ -82,4 +82,21 @@ List findCourseNamesByBookmarkedCourses(@Param("courseIds") Set co @Param("namePattern") String namePattern); Optional findByIdAndActiveTrue(Long id); + + /** + * 추천 응답 조립용 — course + tag + town + coursePlaces + place + placeTags 를 모두 JOIN FETCH. + * 임베딩 유사도 Top-K 선정 후 상세 데이터를 한 번에 로딩할 때 사용한다. + */ + @Query(""" + SELECT DISTINCT c FROM Course c + JOIN FETCH c.tag + JOIN FETCH c.town + LEFT JOIN FETCH c.coursePlaces cp + LEFT JOIN FETCH cp.place p + LEFT JOIN FETCH p.placeTags pt + LEFT JOIN FETCH pt.tag + WHERE c.id IN :courseIds + AND c.active = true + """) + List findByIdInWithAllForRecommendation(@Param("courseIds") List courseIds); } diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/CourseService.java b/src/main/java/org/sopt/solply_server/domain/course/service/CourseService.java index 7b5ee08a..59c3ed27 100644 --- a/src/main/java/org/sopt/solply_server/domain/course/service/CourseService.java +++ b/src/main/java/org/sopt/solply_server/domain/course/service/CourseService.java @@ -24,12 +24,15 @@ import org.sopt.solply_server.domain.town.entity.Town; import org.sopt.solply_server.domain.town.util.TownValidator; import org.sopt.solply_server.domain.user.entity.User; +import org.sopt.solply_server.domain.course.repository.CourseSearchDocumentRepository; +import org.sopt.solply_server.domain.course.service.event.CourseCreatedEvent; import org.sopt.solply_server.global.exception.BusinessException; import org.sopt.solply_server.global.exception.EntityNotFoundException; import org.sopt.solply_server.global.exception.ErrorCode; import org.sopt.solply_server.global.util.EntityLoader; import org.sopt.solply_server.global.util.TagViewUtils; import org.sopt.solply_server.global.util.s3.ImageUrlProvider; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,6 +57,9 @@ public class CourseService { private final CourseUtils courseUtils; private final EntityLoader entityLoader; + private final CourseSearchDocumentRepository courseSearchDocumentRepository; + private final ApplicationEventPublisher applicationEventPublisher; + private final TownValidator townValidator; private final CoursePlaceValidator coursePlaceValidator; private final TagValidator tagValidator; @@ -161,6 +167,8 @@ public CourseAddPlaceResponse addPlaceToCourse(final Long userId, final Long pla if (originCourse.isCreatedBy(userId)) { // 본인 코스에 장소 추가 coursePlaceService.addPlaceToMyCourse(originCourse, place); + // 장소 구성이 바뀌었으므로 임베딩 문서를 DIRTY 상태로 전환 + courseSearchDocumentRepository.markDirtyByCourseId(originCourse.getId()); } else { // 코스 복제 후 장소 추가 List placesInCourse = originCourse.getPlacesInCourseInfo(); @@ -328,6 +336,9 @@ private void updateCourse(Course course, String courseName, String courseDescrip courseRepository.deleteCoursePlacesByCourseId(course.getId()); coursePlaceService.addPlacesToTargetCourse(course, placeInfosInCourseForOrder, placesToAdd); + + // 코스 내용이 바뀌었으므로 임베딩 문서를 DIRTY 상태로 전환 + courseSearchDocumentRepository.markDirtyByCourseId(course.getId()); } /** @@ -355,6 +366,9 @@ private Course createNewCourse(User user, final String courseName, final String // 북마크 등록 courseBookmarkFacade.createCourseBookmark(user.getId(), newCourse.getId()); + // 임베딩 파이프라인 트리거 (트랜잭션 커밋 후 비동기 실행) + applicationEventPublisher.publishEvent(new CourseCreatedEvent(newCourse.getId())); + return newCourse; } diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEventListener.java b/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEventListener.java index 75790d5a..5bfe4531 100644 --- a/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEventListener.java +++ b/src/main/java/org/sopt/solply_server/domain/course/service/event/CourseCreatedEventListener.java @@ -28,7 +28,6 @@ public class CourseCreatedEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(CourseCreatedEvent event) { Long courseId = event.courseId(); - log.info("코스 임베딩 생성 시작 - courseId={}", courseId); Course course = courseRepository.findById(courseId).orElse(null); if (course == null) { @@ -36,6 +35,14 @@ public void handle(CourseCreatedEvent event) { return; } + // 비공개 코스는 추천 대상이 아니므로 임베딩 생략 + if (!course.isShared()) { + log.debug("비공개 코스는 임베딩을 생략합니다 - courseId={}", courseId); + return; + } + + log.info("코스 임베딩 생성 시작 - courseId={}", courseId); + CourseSearchDocument doc = courseSearchDocumentRepository.findById(courseId) .orElseGet(() -> courseSearchDocumentRepository.save(CourseSearchDocument.init(course))); From f252110f97a35808be0484e6cedf3c4e7e7a4d28 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:26:39 +0900 Subject: [PATCH 06/11] =?UTF-8?q?#354=20feat:=20ReasonGenerationService?= =?UTF-8?q?=EC=97=90=20CourseContext=20=EB=B0=8F=20generateCourseReasons?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/ai/ReasonGenerationService.java | 83 ++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/solply_server/global/ai/ReasonGenerationService.java b/src/main/java/org/sopt/solply_server/global/ai/ReasonGenerationService.java index 44449538..b870e2ca 100644 --- a/src/main/java/org/sopt/solply_server/global/ai/ReasonGenerationService.java +++ b/src/main/java/org/sopt/solply_server/global/ai/ReasonGenerationService.java @@ -41,7 +41,7 @@ public ReasonGenerationService( } // OpenAI json_schema structured output: 루트는 반드시 object여야 하므로 reasons 배열로 래핑 - private static final Map RESPONSE_FORMAT = Map.of( + private static final Map PLACE_RESPONSE_FORMAT = Map.of( "type", "json_schema", "json_schema", Map.of( "name", "place_reasons", @@ -60,13 +60,32 @@ public ReasonGenerationService( ) ); + private static final Map COURSE_RESPONSE_FORMAT = Map.of( + "type", "json_schema", + "json_schema", Map.of( + "name", "course_reasons", + "strict", true, + "schema", Map.of( + "type", "object", + "properties", Map.of( + "reasons", Map.of( + "type", "array", + "items", Map.of("type", "string") + ) + ), + "required", List.of("reasons"), + "additionalProperties", false + ) + ) + ); + public List generateReasons(String query, String userName, List places) { String prompt = buildPrompt(query, userName, places); Map requestBody = Map.of( "model", chatModel, "messages", List.of(Map.of("role", "user", "content", prompt)), - "response_format", RESPONSE_FORMAT + "response_format", PLACE_RESPONSE_FORMAT ); try { @@ -114,9 +133,69 @@ private String buildPlacesInfo(List places) { return sb.toString().trim(); } + public List generateCourseReasons(String query, String userName, List courses) { + String prompt = buildCoursePrompt(query, userName, courses); + + Map requestBody = Map.of( + "model", chatModel, + "messages", List.of(Map.of("role", "user", "content", prompt)), + "response_format", COURSE_RESPONSE_FORMAT + ); + + try { + Map response = restClient.post() + .uri("/chat/completions") + .body(requestBody) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + List> choices = (List>) response.get("choices"); + String content = (String) ((Map) choices.get(0).get("message")).get("content"); + + JsonNode reasons = objectMapper.readTree(content).get("reasons"); + List result = new ArrayList<>(); + reasons.forEach(node -> result.add(node.asText())); + return result; + } catch (Exception e) { + log.warn("코스 추천 이유 생성 실패, 빈 문자열로 대체합니다. error={}", e.getMessage()); + return courses.stream().map(c -> "").toList(); + } + } + + private String buildCoursePrompt(String query, String userName, List courses) { + return """ + 당신은 코스 추천 전문가입니다. + 아래 사용자 질문과 코스 정보를 바탕으로, %s님께 각 코스를 추천하는 이유를 한 문장씩 작성해주세요. + 코스 순서와 개수를 반드시 유지하여 reasons 배열에 담아 응답하세요. + + 사용자 질문: %s + + %s + """.formatted(userName, query, buildCoursesInfo(courses)); + } + + private String buildCoursesInfo(List courses) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < courses.size(); i++) { + CourseContext c = courses.get(i); + sb.append("코스 ").append(i + 1).append("\n"); + sb.append("- 이름: ").append(c.name()).append("\n"); + sb.append("- 동네: ").append(c.townName()).append("\n"); + sb.append("- 코스 정보: ").append(c.retrievalText()).append("\n"); + sb.append("\n"); + } + return sb.toString().trim(); + } + public record PlaceContext( String name, String townName, String retrievalText ) {} + + public record CourseContext( + String name, + String townName, + String retrievalText + ) {} } From 3d3b7f44d165199f30c46f180c0e8d1fbdf25d1e Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:32:43 +0900 Subject: [PATCH 07/11] =?UTF-8?q?#354=20feat:=20CourseEmbeddingRecommendSe?= =?UTF-8?q?rvice=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20DTO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/EmbeddingCourseRecommendRequest.java | 9 + .../recommend/dto/RecommendedCourseDto.java | 13 ++ .../EmbeddingCourseRecommendGetResponse.java | 8 + .../CourseEmbeddingRecommendService.java | 157 ++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 src/main/java/org/sopt/solply_server/domain/recommend/dto/EmbeddingCourseRecommendRequest.java create mode 100644 src/main/java/org/sopt/solply_server/domain/recommend/dto/RecommendedCourseDto.java create mode 100644 src/main/java/org/sopt/solply_server/domain/recommend/dto/response/EmbeddingCourseRecommendGetResponse.java create mode 100644 src/main/java/org/sopt/solply_server/domain/recommend/service/CourseEmbeddingRecommendService.java diff --git a/src/main/java/org/sopt/solply_server/domain/recommend/dto/EmbeddingCourseRecommendRequest.java b/src/main/java/org/sopt/solply_server/domain/recommend/dto/EmbeddingCourseRecommendRequest.java new file mode 100644 index 00000000..de2f9542 --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/recommend/dto/EmbeddingCourseRecommendRequest.java @@ -0,0 +1,9 @@ +package org.sopt.solply_server.domain.recommend.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record EmbeddingCourseRecommendRequest( + @NotBlank String query, + @NotNull Long townId +) {} diff --git a/src/main/java/org/sopt/solply_server/domain/recommend/dto/RecommendedCourseDto.java b/src/main/java/org/sopt/solply_server/domain/recommend/dto/RecommendedCourseDto.java new file mode 100644 index 00000000..e9af340d --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/recommend/dto/RecommendedCourseDto.java @@ -0,0 +1,13 @@ +package org.sopt.solply_server.domain.recommend.dto; + +import java.util.List; + +public record RecommendedCourseDto( + Long courseId, + String courseName, + String thumbnailImageUrl, + String courseTag, + String townName, + String reason, + List placeMainTags +) {} diff --git a/src/main/java/org/sopt/solply_server/domain/recommend/dto/response/EmbeddingCourseRecommendGetResponse.java b/src/main/java/org/sopt/solply_server/domain/recommend/dto/response/EmbeddingCourseRecommendGetResponse.java new file mode 100644 index 00000000..4e019b9c --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/recommend/dto/response/EmbeddingCourseRecommendGetResponse.java @@ -0,0 +1,8 @@ +package org.sopt.solply_server.domain.recommend.dto.response; + +import java.util.List; +import org.sopt.solply_server.domain.recommend.dto.RecommendedCourseDto; + +public record EmbeddingCourseRecommendGetResponse( + List courses +) {} diff --git a/src/main/java/org/sopt/solply_server/domain/recommend/service/CourseEmbeddingRecommendService.java b/src/main/java/org/sopt/solply_server/domain/recommend/service/CourseEmbeddingRecommendService.java new file mode 100644 index 00000000..121ccf24 --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/recommend/service/CourseEmbeddingRecommendService.java @@ -0,0 +1,157 @@ +package org.sopt.solply_server.domain.recommend.service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.OptionalDouble; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.solply_server.domain.course.entity.Course; +import org.sopt.solply_server.domain.course.entity.CoursePlace; +import org.sopt.solply_server.domain.course.entity.CourseSearchDocument; +import org.sopt.solply_server.domain.course.repository.CourseRepository; +import org.sopt.solply_server.domain.course.repository.CourseSearchDocumentRepository; +import org.sopt.solply_server.domain.course.util.CourseUtils; +import org.sopt.solply_server.domain.place.entity.Place; +import org.sopt.solply_server.domain.recommend.dto.RecommendedCourseDto; +import org.sopt.solply_server.domain.recommend.dto.response.EmbeddingCourseRecommendGetResponse; +import org.sopt.solply_server.domain.tag.entity.Tag; +import org.sopt.solply_server.domain.town.entity.Town; +import org.sopt.solply_server.domain.user.entity.User; +import org.sopt.solply_server.global.ai.CosineSimilarityUtil; +import org.sopt.solply_server.global.ai.EmbeddingService; +import org.sopt.solply_server.global.ai.ReasonGenerationService; +import org.sopt.solply_server.global.ai.ReasonGenerationService.CourseContext; +import org.sopt.solply_server.global.util.EntityLoader; +import org.sopt.solply_server.global.util.TagViewUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CourseEmbeddingRecommendService { + + private static final int TOP_K = 3; + + private final CourseSearchDocumentRepository courseSearchDocumentRepository; + private final CourseRepository courseRepository; + private final EmbeddingService embeddingService; + private final ReasonGenerationService reasonGenerationService; + private final CourseUtils courseUtils; + private final EntityLoader entityLoader; + + public EmbeddingCourseRecommendGetResponse recommendByQuery(String query, Long townId, Long userId) { + User user = entityLoader.getUser(userId); + String userName = user.getNickname(); + + float[] queryVector = embeddingService.embed(query); + + // parentTown이 null이면 광역 동네 → 자식 동네들의 공유 코스 포함 조회 + Town town = entityLoader.getActiveTown(townId); + List candidates = town.getParent() == null + ? courseSearchDocumentRepository.findActiveSharedByParentTownIdWithEmbedding(townId) + : courseSearchDocumentRepository.findActiveSharedByTownIdWithEmbedding(townId); + + // 코사인 유사도 계산 후 상위 TOP_K 선정 + List topCourseIds = candidates.stream() + .filter(doc -> { + if (doc.getEmbedding() == null) { + log.warn("임베딩 데이터가 없어 추천 후보에서 제외합니다. courseId={}", doc.getCourseId()); + return false; + } + return true; + }) + .flatMap(doc -> { + OptionalDouble score = CosineSimilarityUtil.calculate(queryVector, doc.getEmbedding()); + if (score.isEmpty()) { + log.warn("임베딩 차원 불일치로 추천 후보에서 제외합니다. courseId={}, queryDim={}, docDim={}", + doc.getCourseId(), queryVector.length, doc.getEmbedding().length); + return java.util.stream.Stream.empty(); + } + return java.util.stream.Stream.of(new ScoredDoc(doc.getCourseId(), score.getAsDouble())); + }) + .sorted(Comparator.comparingDouble(ScoredDoc::score).reversed()) + .limit(TOP_K) + .map(ScoredDoc::courseId) + .toList(); + + if (topCourseIds.isEmpty()) { + return new EmbeddingCourseRecommendGetResponse(List.of()); + } + + // Top-K 코스의 전체 데이터 로딩 (tag, town, coursePlaces, place, placeTags 포함) + List topCourses = courseRepository.findByIdInWithAllForRecommendation(topCourseIds); + + // 유사도 점수 순서 복원 (DB 조회 결과 순서와 topCourseIds 순서가 다를 수 있음) + List orderedCourses = topCourseIds.stream() + .map(id -> topCourses.stream().filter(c -> c.getId().equals(id)).findFirst().orElse(null)) + .filter(Objects::nonNull) + .toList(); + + List courseContexts = orderedCourses.stream() + .map(course -> new CourseContext( + course.getName(), + course.getTown().getName(), + buildRetrievalTextSummary(course) + )) + .toList(); + + List reasons = reasonGenerationService.generateCourseReasons(query, userName, courseContexts); + + List result = new ArrayList<>(); + for (int i = 0; i < orderedCourses.size(); i++) { + Course course = orderedCourses.get(i); + String reason = (i < reasons.size()) ? reasons.get(i) : ""; + result.add(toDto(course, reason)); + } + + return new EmbeddingCourseRecommendGetResponse(result); + } + + private RecommendedCourseDto toDto(Course course, String reason) { + String courseTag = TagViewUtils.getActiveNameOrNull(course.getTag()); + String townName = course.getTown().getName(); + String thumbnailImageUrl = courseUtils.getCourseThumbnailUrl(course); + + List placeMainTags = course.getCoursePlaces().stream() + .map(CoursePlace::getPlace) + .map(place -> place.getMainTag().filter(Tag::isActive).map(Tag::getName).orElse(null)) + .filter(Objects::nonNull) + .toList(); + + return new RecommendedCourseDto( + course.getId(), + course.getName(), + thumbnailImageUrl, + courseTag, + townName, + reason, + placeMainTags + ); + } + + /** + * LLM 프롬프트용 코스 요약 텍스트. + * CourseSearchDocument의 retrievalText 대신 현재 로딩된 course 데이터로 즉석 생성한다. + */ + private String buildRetrievalTextSummary(Course course) { + String category = TagViewUtils.getActiveNameOrNull(course.getTag()); + int placeCount = course.getCoursePlaces().size(); + List mainTagNames = course.getCoursePlaces().stream() + .map(CoursePlace::getPlace) + .map(p -> p.getMainTag().filter(Tag::isActive).map(Tag::getName).orElse(null)) + .filter(Objects::nonNull) + .distinct() + .toList(); + + return String.format("%s 코스, 장소 %d개 (%s)", + category != null ? category : "기타", + placeCount, + mainTagNames.isEmpty() ? "다양한 장소" : String.join(", ", mainTagNames)); + } + + private record ScoredDoc(Long courseId, double score) {} +} From b8aa1c0f3f269d44be4b3566c6ede4f988ef6af6 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:32:48 +0900 Subject: [PATCH 08/11] =?UTF-8?q?#354=20feat:=20POST=20/api/recommend/cour?= =?UTF-8?q?ses/embedding=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recommend/controller/RecommendController.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/main/java/org/sopt/solply_server/domain/recommend/controller/RecommendController.java b/src/main/java/org/sopt/solply_server/domain/recommend/controller/RecommendController.java index 00969f53..afe2105f 100644 --- a/src/main/java/org/sopt/solply_server/domain/recommend/controller/RecommendController.java +++ b/src/main/java/org/sopt/solply_server/domain/recommend/controller/RecommendController.java @@ -6,11 +6,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import org.sopt.solply_server.domain.recommend.dto.EmbeddingCourseRecommendRequest; import org.sopt.solply_server.domain.recommend.dto.EmbeddingPlaceRecommendRequest; import lombok.RequiredArgsConstructor; import org.sopt.solply_server.domain.recommend.dto.response.CourseRecommendGetResponse; +import org.sopt.solply_server.domain.recommend.dto.response.EmbeddingCourseRecommendGetResponse; import org.sopt.solply_server.domain.recommend.dto.response.EmbeddingPlaceRecommendGetResponse; import org.sopt.solply_server.domain.recommend.dto.response.PlaceRecommendationGetResponse; +import org.sopt.solply_server.domain.recommend.service.CourseEmbeddingRecommendService; import org.sopt.solply_server.domain.recommend.service.EmbeddingRecommendService; import org.sopt.solply_server.domain.recommend.service.RecommendService; import org.sopt.solply_server.global.annotation.CurrentUserId; @@ -31,6 +34,7 @@ public class RecommendController { private final RecommendService recommendService; private final EmbeddingRecommendService embeddingRecommendService; + private final CourseEmbeddingRecommendService courseEmbeddingRecommendService; @Operation(summary = "장소 추천 조회", description = "장소 추천을 위한 썸네일 리스트를 조회합니다.") @GetMapping("/places") @@ -54,6 +58,17 @@ public ResponseEntity> rec ); } + @Operation(summary = "자연어 기반 코스 추천", description = "사용자의 자연어 질문과 동네 ID를 기반으로 유사한 공유 코스 상위 3개를 추천합니다.") + @PostMapping("/courses/embedding") + public ResponseEntity> recommendCoursesByEmbedding( + @CurrentUserId Long userId, + @RequestBody @Valid EmbeddingCourseRecommendRequest request) { + return CustomApiResponse.success( + "자연어 기반 코스 추천 조회 성공", + courseEmbeddingRecommendService.recommendByQuery(request.query(), request.townId(), userId) + ); + } + @Operation(summary = "추천 코스 목록 조회", description = "특정 동네의 공유된 코스 목록을 조회합니다.") @GetMapping("/courses") public ResponseEntity> findRecommendCourses( From 7321dd07e56e3e40fc03397327bfbf8d10223ee7 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:35:33 +0900 Subject: [PATCH 09/11] =?UTF-8?q?#354=20feat:=20=EC=BD=94=EC=8A=A4=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20meaning=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20retrieval=5Ftext=EC=97=90=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=9D=98=EB=AF=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CourseRetrievalTextBuilder.java | 18 ++++++++++----- .../CourseEmbeddingRecommendService.java | 8 +++---- .../V14__add_meaning_to_course_tags.sql | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 src/main/resources/db/migration/V14__add_meaning_to_course_tags.sql diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/CourseRetrievalTextBuilder.java b/src/main/java/org/sopt/solply_server/domain/course/service/CourseRetrievalTextBuilder.java index 6d951654..87ba9bd1 100644 --- a/src/main/java/org/sopt/solply_server/domain/course/service/CourseRetrievalTextBuilder.java +++ b/src/main/java/org/sopt/solply_server/domain/course/service/CourseRetrievalTextBuilder.java @@ -4,7 +4,6 @@ import java.util.Objects; import org.sopt.solply_server.domain.course.entity.Course; import org.sopt.solply_server.domain.course.entity.CoursePlace; -import org.sopt.solply_server.domain.place.entity.Place; import org.sopt.solply_server.domain.tag.entity.Tag; import org.springframework.stereotype.Component; @@ -18,27 +17,34 @@ public class CourseRetrievalTextBuilder { public String build(Course course) { String courseName = course.getName(); String townName = course.getTown().getName(); - String category = course.getTag() != null ? course.getTag().getName() : "기타"; + + Tag tag = course.getTag(); + String tagName = tag != null ? tag.getName() : "기타"; + String tagMeaning = (tag != null && tag.getMeaning() != null) ? tag.getMeaning() : null; + String introduction = course.getIntroduction(); int placeCount = course.getCoursePlaces().size(); String lengthDesc = placeCount <= 3 ? "짧은" : "반나절"; - List mainTagNames = course.getCoursePlaces().stream() + List placeMainTagNames = course.getCoursePlaces().stream() .map(CoursePlace::getPlace) .map(place -> place.getMainTag().filter(Tag::isActive).map(Tag::getName).orElse(null)) .filter(Objects::nonNull) .distinct() .toList(); - String tagList = mainTagNames.isEmpty() ? "다양한 장소" : String.join(", ", mainTagNames); + String placeTagList = placeMainTagNames.isEmpty() ? "다양한 장소" : String.join(", ", placeMainTagNames); StringBuilder sb = new StringBuilder(); - sb.append(courseName).append("은 ").append(townName).append("에 위치한 ").append(category).append(" 코스다.\n"); + sb.append(courseName).append("은 ").append(townName).append("에 위치한 ").append(tagName).append(" 코스다.\n"); + if (tagMeaning != null && !tagMeaning.isBlank()) { + sb.append(tagMeaning).append("\n"); + } if (introduction != null && !introduction.isBlank()) { sb.append(introduction).append("\n"); } sb.append("총 ").append(placeCount).append("개 장소로 구성된 ").append(lengthDesc).append(" 코스다.\n"); - sb.append("주요 장소 유형: ").append(tagList); + sb.append("주요 장소 유형: ").append(placeTagList); return sb.toString(); } diff --git a/src/main/java/org/sopt/solply_server/domain/recommend/service/CourseEmbeddingRecommendService.java b/src/main/java/org/sopt/solply_server/domain/recommend/service/CourseEmbeddingRecommendService.java index 121ccf24..18f5917f 100644 --- a/src/main/java/org/sopt/solply_server/domain/recommend/service/CourseEmbeddingRecommendService.java +++ b/src/main/java/org/sopt/solply_server/domain/recommend/service/CourseEmbeddingRecommendService.java @@ -138,9 +138,9 @@ private RecommendedCourseDto toDto(Course course, String reason) { * CourseSearchDocument의 retrievalText 대신 현재 로딩된 course 데이터로 즉석 생성한다. */ private String buildRetrievalTextSummary(Course course) { - String category = TagViewUtils.getActiveNameOrNull(course.getTag()); + String tagName = TagViewUtils.getActiveNameOrNull(course.getTag()); int placeCount = course.getCoursePlaces().size(); - List mainTagNames = course.getCoursePlaces().stream() + List placeMainTagNames = course.getCoursePlaces().stream() .map(CoursePlace::getPlace) .map(p -> p.getMainTag().filter(Tag::isActive).map(Tag::getName).orElse(null)) .filter(Objects::nonNull) @@ -148,9 +148,9 @@ private String buildRetrievalTextSummary(Course course) { .toList(); return String.format("%s 코스, 장소 %d개 (%s)", - category != null ? category : "기타", + tagName != null ? tagName : "기타", placeCount, - mainTagNames.isEmpty() ? "다양한 장소" : String.join(", ", mainTagNames)); + placeMainTagNames.isEmpty() ? "다양한 장소" : String.join(", ", placeMainTagNames)); } private record ScoredDoc(Long courseId, double score) {} diff --git a/src/main/resources/db/migration/V14__add_meaning_to_course_tags.sql b/src/main/resources/db/migration/V14__add_meaning_to_course_tags.sql new file mode 100644 index 00000000..9832bf22 --- /dev/null +++ b/src/main/resources/db/migration/V14__add_meaning_to_course_tags.sql @@ -0,0 +1,22 @@ +-- COURSE 태그 (id 31~34) meaning 추가 +-- retrieval_text 생성 시 코스 카테고리의 맥락으로 활용됨 + +-- id=31: 맛집·디저트 +UPDATE tags +SET meaning = '식사 / 카페 / 디저트 등 먹는 흐름이 핵심인 코스다. 맛집 투어, 카페 투어, 빵지순례, 디저트 코스처럼 먹는 활동을 중심으로 구성된다.' +WHERE id = 31; + +-- id=32: 취향·발견 +UPDATE tags +SET meaning = '특정 취향(책, 쇼핑, 전시 등)을 기반으로 공간을 탐색하고 발견하는 흐름의 코스다. 책방, 소품샵, 빈티지샵, 전시, 공방, 편집샵, 브랜드·컨셉 공간 등 구경과 탐색 중심으로 구성된다.' +WHERE id = 32; + +-- id=33: 산책·힐링 +UPDATE tags +SET meaning = '산책 등 이동을 통해 휴식과 리프레시할 수 있는 코스다. 공원, 산책로, 자연·풍경 중심 동선으로 구성되며 조용한 카페가 보조적으로 포함되기도 한다.' +WHERE id = 33; + +-- id=34: 데일리 +UPDATE tags +SET meaning = '특정 활동 중심이 아닌 동네 대표 스팟을 자연스럽게 섞은 코스다. 특별한 테마 없이 가볍게 즐길 수 있는 무난한 일상 코스다.' +WHERE id = 34; From e270a61e84ebd2225817dcbfd43973d4be6de971 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:38:38 +0900 Subject: [PATCH 10/11] =?UTF-8?q?#354=20feat:=20POST=20/api/admin/courses/?= =?UTF-8?q?search-documents/initialize=20=EC=9D=BC=EA=B4=84=20=EC=9E=84?= =?UTF-8?q?=EB=B2=A0=EB=94=A9=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../course/controller/AdminCourseController.java | 9 +++++++++ .../service/facade/CourseEmbeddingFacade.java | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/main/java/org/sopt/solply_server/domain/admin/course/controller/AdminCourseController.java b/src/main/java/org/sopt/solply_server/domain/admin/course/controller/AdminCourseController.java index 239bc3ff..50d21e46 100644 --- a/src/main/java/org/sopt/solply_server/domain/admin/course/controller/AdminCourseController.java +++ b/src/main/java/org/sopt/solply_server/domain/admin/course/controller/AdminCourseController.java @@ -8,6 +8,7 @@ import org.sopt.solply_server.domain.admin.course.dto.response.AdminCourseUpdateResponse; import org.sopt.solply_server.domain.admin.course.dto.response.AdminCourseUpsertResponse; import org.sopt.solply_server.domain.admin.course.service.AdminCourseService; +import org.sopt.solply_server.domain.course.service.facade.CourseEmbeddingFacade; import org.sopt.solply_server.global.annotation.CurrentUserId; import org.sopt.solply_server.global.dto.CustomApiResponse; import org.springframework.http.ResponseEntity; @@ -32,6 +33,7 @@ @RequestMapping("/api/admin/courses") public class AdminCourseController { private final AdminCourseService adminCourseService; + private final CourseEmbeddingFacade courseEmbeddingFacade; @Operation(summary = "어드민 코스 생성", description = "어드민이 새 코스를 생성합니다.") @PostMapping @@ -88,6 +90,13 @@ public ResponseEntity> deleteCourse( return CustomApiResponse.success("코스 삭제 성공", null); } + @Operation(summary = "코스 검색 문서 일괄 임베딩", description = "INIT·FAILED 상태의 코스 검색 문서를 즉시 임베딩합니다. 테스트 및 초기 데이터 세팅용.") + @PostMapping("/search-documents/initialize") + public ResponseEntity> initializeCourseSearchDocuments() { + courseEmbeddingFacade.initializePendingDocuments(); + return CustomApiResponse.success("코스 검색 문서 임베딩 초기화 완료", null); + } + @Operation(summary = "어드민 코스 상태 수정", description = "어드민이 코스의 활성화 상태를 수정합니다.") @PatchMapping("/{id}/activation") public ResponseEntity> updateCourseStatus( diff --git a/src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseEmbeddingFacade.java b/src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseEmbeddingFacade.java index 5db21487..85185fb3 100644 --- a/src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseEmbeddingFacade.java +++ b/src/main/java/org/sopt/solply_server/domain/course/service/facade/CourseEmbeddingFacade.java @@ -18,12 +18,27 @@ public class CourseEmbeddingFacade { /** 한 번에 OpenAI API에 전송할 코스 수. Rate limit 대응 및 부분 실패 격리를 위해 분할한다. */ private static final int EMBEDDING_CHUNK_SIZE = 50; + private static final List INIT_TARGET_STATUSES = + List.of(EmbeddingStatus.INIT, EmbeddingStatus.FAILED); + private static final List REEMBEDDING_TARGET_STATUSES = List.of(EmbeddingStatus.INIT, EmbeddingStatus.DIRTY, EmbeddingStatus.FAILED); private final CourseSearchDocumentRepository courseSearchDocumentRepository; private final CourseEmbeddingBatchProcessor batchProcessor; + /** + * INIT·FAILED 상태의 코스 문서를 즉시 임베딩한다. + * 수동 트리거용 (어드민 API, 테스트 등). + */ + public void initializePendingDocuments() { + List pendingIds = courseSearchDocumentRepository.findCourseIdsByStatusIn(INIT_TARGET_STATUSES); + + log.info("코스 임베딩 초기화 시작 - 대상 수: {}", pendingIds.size()); + embedInChunks(pendingIds); + log.info("코스 임베딩 초기화 완료"); + } + /** * 매일 새벽 4시 — INIT·DIRTY·FAILED 상태의 코스 문서를 일괄 재임베딩한다. * 장소 임베딩 배치(03:00)와 겹치지 않도록 04:00으로 설정. From 1d2521ba865513a3e3b0711e8bf80d62a8d1a7b3 Mon Sep 17 00:00:00 2001 From: Minkyu Shin Date: Thu, 9 Apr 2026 00:43:21 +0900 Subject: [PATCH 11/11] =?UTF-8?q?#354=20fix:=20MultipleBagFetchException?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0=20=E2=80=94=20placeTags=20JOIN=20FETCH=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20@BatchSize=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/course/repository/CourseRepository.java | 6 ++++-- .../org/sopt/solply_server/domain/place/entity/Place.java | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java b/src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java index 6821d259..af6d584c 100644 --- a/src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java +++ b/src/main/java/org/sopt/solply_server/domain/course/repository/CourseRepository.java @@ -87,14 +87,16 @@ List findCourseNamesByBookmarkedCourses(@Param("courseIds") Set co * 추천 응답 조립용 — course + tag + town + coursePlaces + place + placeTags 를 모두 JOIN FETCH. * 임베딩 유사도 Top-K 선정 후 상세 데이터를 한 번에 로딩할 때 사용한다. */ + /** + * 추천 응답 조립용 — course + tag + town + coursePlaces + place 를 JOIN FETCH. + * place.placeTags는 @BatchSize lazy loading으로 처리한다 (두 컬렉션 동시 JOIN FETCH 시 MultipleBagFetchException 발생). + */ @Query(""" SELECT DISTINCT c FROM Course c JOIN FETCH c.tag JOIN FETCH c.town LEFT JOIN FETCH c.coursePlaces cp LEFT JOIN FETCH cp.place p - LEFT JOIN FETCH p.placeTags pt - LEFT JOIN FETCH pt.tag WHERE c.id IN :courseIds AND c.active = true """) diff --git a/src/main/java/org/sopt/solply_server/domain/place/entity/Place.java b/src/main/java/org/sopt/solply_server/domain/place/entity/Place.java index 184ab866..b898231a 100644 --- a/src/main/java/org/sopt/solply_server/domain/place/entity/Place.java +++ b/src/main/java/org/sopt/solply_server/domain/place/entity/Place.java @@ -61,6 +61,7 @@ public class Place extends BaseTimeEntity { private String address; + @BatchSize(size = 50) @OneToMany(mappedBy = "place", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List placeTags = new ArrayList<>();