diff --git a/build.gradle b/build.gradle index fb0b7163..5c92d2e8 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-aop' annotationProcessor 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/docs/monitor/ASYNC_MONITORING_STRATEGY.md b/docs/monitor/ASYNC_MONITORING_STRATEGY.md new file mode 100644 index 00000000..4ac8d55d --- /dev/null +++ b/docs/monitor/ASYNC_MONITORING_STRATEGY.md @@ -0,0 +1,92 @@ +# 비동기 작업(Async) 모니터링 전략 문서 + +## 1. 개요 및 최종 결정 +본 프로젝트는 **Java 21 가상 스레드(Virtual Thread)** 환경에서 비동기 작업(`@Async`)의 성능(Latency)과 부하(Concurrency)를 효과적으로 추적하기 위해 **Pure Custom AOP** 전략을 최종 채택했습니다. + +이 문서는 최종 결정에 이르기까지 검토했던 다양한 대안들과 각각의 트레이드오프 분석 결과를 기록합니다. + +--- + +## 2. 전략 수립 과정 및 트레이드오프 분석 (Trade-off Analysis) + +우리는 최적의 모니터링 전략을 찾기 위해 다음 5가지 옵션을 단계별로 검토했습니다. + +### Option 1: TaskExecutor 래핑 (Base Strategy) +Spring의 `Executor` 빈을 `ExecutorServiceMetrics.monitor()`로 감싸는 방식. +- **장점**: 설정이 매우 간단함 (`AsyncConfig`만 수정). 스레드 풀 전체의 Active Count 파악 가능. +- **단점**: **"식별 불가능"**. 모든 비동기 작업이 하나의 지표로 합쳐짐. "시스템이 느리다"는 알 수 있지만 "이메일 발송이 느린지, 퀴즈 생성이 느린지"는 알 수 없음. +- **결론**: 상세 모니터링 요구사항 충족 불가로 기각. + +### Option 2: @Timed 어노테이션 사용 +개별 비동기 메서드에 `@Timed`를 부착하는 방식. +- **장점**: Spring 표준 기능. 원하는 메서드만 골라서 정밀 측정 가능. +- **단점**: **"휴먼 에러 & 번거로움"**. 개발자가 일일이 붙여야 하며, 실수로 누락 시 모니터링 공백 발생. +- **결론**: 유지보수 효율성이 떨어져 기각. + +### Option 3: 멀티 Executor 전략 (Bulkhead Pattern) +기능별로 Executor를 물리적으로 분리하는 방식 (예: `mailExecutor`, `quizExecutor`). +- **장점 (가상 스레드 환경)**: 가상 스레드는 비용이 거의 "0"이므로, Executor를 수십 개 만들어도 리소스 낭비가 없음. 완벽한 격리(Bulkhead) 가능. +- **단점**: **"개발 복잡도 증가"**. 개발자가 `@Async("quizExecutor")`처럼 빈 이름을 항상 명시해야 함. 실수로 이름을 안 적으면 Default Executor로 작업이 몰려 격리 효과가 사라짐. +- **결론**: 강력하지만, 모든 비동기 작업에 이름을 지정하는 것은 생산성 저하 우려가 있어 보류. + +### Option 4: Hybrid 전략 (래핑 + Custom AOP) +초기에 유력했던 방식. Executor 래핑으로 '전체 부하'를 보고, AOP로 '기능별 응답속도'를 보는 조합. +- **장점**: 인프라 관점(풀 상태)과 앱 관점(메서드 속도) 모두 커버 가능. +- **단점**: **"정보의 파편화 & 중복"**. Active Count를 보려면 Wrapper 지표를, Latency를 보려면 AOP 지표를 봐야 함. + +### Option 5: Pure Custom AOP (Final Choice) +**"선택과 집중" 전략**. 불확실한 Active Count 측정보다는 **"확실한 응답 속도(Latency)"** 모니터링에 집중하는 방식. +- **변경 사유**: 초기에는 `LongTaskTimer`로 활성 작업 수를 측정하려 했으나, 0.3초 미만의 짧은 작업들은 프로메테우스의 수집 주기(15초) 사이에서 누락되어 유의미한 데이터가 나오지 않음. +- **결과**: 과감하게 복잡성을 줄이고, `Timer`를 이용한 **"기능별 실행 시간(Latency)"** 추적에 올인. + +--- + +## 3. 최종 아키텍처: Pure Custom AOP + +### 3.1 동작 원리 +`AsyncMetricsAspect`가 `@Async` 어노테이션이 붙은 모든 메서드를 가로챕니다(Interception). + +1. **Before Execution**: + - `Timer.start()`: 실행 시간 측정 시작. +2. **Proceed**: 실제 비동기 비즈니스 로직 실행. + - **CompletableFuture 감지**: 만약 리턴 타입이 `CompletableFuture`라면, 메서드가 즉시 리턴되더라도 타이머를 멈추지 않음. + - **Callback 등록**: `whenComplete`를 통해 "미래에 작업이 끝나는 시점"에 콜백을 예약. +3. **After Execution (Finally)**: + - **일반 메서드**: 즉시 `Timer.stop()`. + - **Future 메서드**: 비동기 작업이 실제로 완료(성공/실패)된 시점에 콜백이 실행되며 `Timer.stop()`. 스레드 블로킹 없이 정확한 소요 시간 측정. + +### 3.2 수집 지표 (Metrics) +| 메트릭 이름 | 소스 | 설명 | 주요 태그 | +| :--- | :--- | :--- | :--- | +| `async.execution` | `Timer` | **Latency**. 작업 처리 소요 시간. 성능 저하 판단 기준. | `class`, `method`, `exception` | + +--- + +## 4. 알려진 한계 및 엣지 케이스 (Edge Cases) + +이 전략을 운영할 때 주의해야 할 사항들입니다. + +### 4.1 Self-Invocation (내부 호출) +- **현상**: 같은 클래스 내에서 `this.asyncMethod()` 호출 시 AOP가 동작하지 않음 (Proxy 우회). +- **영향**: 비동기로 실행되지도 않고(동기 실행), 모니터링도 누락됨. +- **해결**: 비동기 메서드는 반드시 외부 Bean에서 호출하거나, Self-Injection 기법 사용. + +### 4.2 태그 카디널리티 (Cardinality Explosion) +- **주의**: 메트릭 태그(`tag`)에는 `userId`, `requestId` 처럼 무제한으로 늘어나는 값을 절대 넣으면 안 됨. +- **원칙**: `ClassName`, `MethodName`, `ExceptionType` 같이 **종류가 유한한(Bounded)** 값만 태그로 사용. + +### 4.3 예외 발생 시 통계 왜곡 +- **현상**: 로직 에러로 0.001초 만에 예외가 터지면, p95 평균 실행 시간이 비정상적으로 짧아져 보임. +- **해결**: `async.execution` 지표를 볼 때 `exception="none"` 태그를 필터링하여 **성공한 요청의 Latency만** 보는 것을 권장. + +### 4.4 좀비 작업 방지 (Timeout 필수) +- **위험**: 만약 `CompletableFuture`가 영원히 완료되지 않으면(무한 대기), `Timer.Sample` 객체도 메모리에 영원히 남습니다 (Memory Leak). +- **해결**: 모든 비동기 로직에는 반드시 **TimeLimit(Timeout)**이 걸려 있어야 합니다. 외부 API 호출 시 타임아웃 설정을 잊지 마세요. + - 예: `.orTimeout(1, TimeUnit.MINUTES)` +- **동작 방식**: 타임아웃 발생 시 `TimeoutException`으로 기록되며 타이머도 정상 종료됩니다. + +--- + +## 5. 결론 +우리는 **"개발자 생산성(자동화)"**과 **"관측 가능성(상세 지표)"**의 균형을 위해 Pure Custom AOP를 선택했습니다. +가상 스레드의 장점을 살려 리소스 걱정 없이 비동기 처리를 하되, AOP를 통해 블랙박스가 되지 않도록 모든 작업 현황을 투명하게 모니터링합니다. diff --git a/docs/monitor/GRAFANA_GUIDE.md b/docs/monitor/GRAFANA_GUIDE.md new file mode 100644 index 00000000..b5ce9f80 --- /dev/null +++ b/docs/monitor/GRAFANA_GUIDE.md @@ -0,0 +1,62 @@ +# Grafana 비동기(Async) 모니터링 대시보드 설정 가이드 + +`비동기 Custom AOP` 전략에 따라 수집되는 메트릭을 그라파나에서 시각화하는 방법입니다. + +> **Tip**: 쿼리 편집기 우측 상단의 **'Code'** 버튼을 누르면 아래 PromQL을 직접 복사/붙여넣기 할 수 있습니다 (가장 빠름). +![alt text](image.png) + +--- + + + +## 2. 응답 시간 분포 (점 그래프) +개별 작업들의 분포를 점(Point)으로 표현하여 패턴을 파악하기 좋습니다 (API 요청 그래프와 유사). + +- **패널 타입**: Time series +- **제목**: Async Task Duration Distribution +- **PromQL 쿼리**: + ```promql + async_execution_seconds + ``` +- **[Builder 설정법]** + 1. **Metric**: `async_execution_seconds` 선택 + 2. **Label filters**: (비워둠) + 3. **Operations**: (없음) + 4. **Graph styles (우측 패널 옵션)**: + - **Line interpolation**: `Off` (선 끄기) + - **Show points**: `Always` (점 켜기) + - **Point size**: `5` ~ `6` (잘 보이게 키움) + +--- + +## 3. 응답 시간 (Latency) - p95 +상위 5% 느린 요청들의 응답 속도입니다. + +- **패널 타입**: Time series +- **제목**: Async Latency (p95) +- **PromQL 쿼리**: + ```promql + avg by (method) (async_execution_seconds{quantile="0.95"}) + ``` +- **[Builder 설정법]** + 1. **Metric**: `async_execution_seconds` 선택 + 2. **Label filters**: `quantile` `=` `0.95` 추가 + 3. **Operations**: `+ Operation` 클릭 -> `Aggregations` -> `Avg` 선택 + 4. **Grouping**: `Avg` 옵션에서 `by` -> `method` 선택 + +--- + +## 4. 응답 시간 (Latency) - p99 +상위 1% 느린 요청들의 응답 속도입니다 (튀는 놈 잡기). + +- **패널 타입**: Time series +- **제목**: Async Latency (p99) +- **PromQL 쿼리**: + ```promql + avg by (method) (async_execution_seconds{quantile="0.99"}) + ``` +- **[Builder 설정법]** + 1. **Metric**: `async_execution_seconds` 선택 + 2. **Label filters**: `quantile` `=` `0.99` 추가 + 3. **Operations**: `+ Operation` 클릭 -> `Aggregations` -> `Avg` 선택 + 4. **Grouping**: `Avg` 옵션에서 `by` -> `method` 선택 diff --git a/docs/monitor/image.png b/docs/monitor/image.png new file mode 100644 index 00000000..f5e9f9f2 Binary files /dev/null and b/docs/monitor/image.png differ diff --git a/src/main/java/book/book/challenge/repository/ReadingChallengeRepository.java b/src/main/java/book/book/challenge/repository/ReadingChallengeRepository.java index ed3e39b8..b966bc85 100644 --- a/src/main/java/book/book/challenge/repository/ReadingChallengeRepository.java +++ b/src/main/java/book/book/challenge/repository/ReadingChallengeRepository.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface ReadingChallengeRepository extends JpaRepository, ReadingChallengeRepositoryCustom { @@ -22,6 +23,9 @@ default List findAllChallenges(Member member) { List findByMemberOrderByCreatedDateDesc(Member member); + @Query("SELECT rc FROM ReadingChallenge rc JOIN FETCH rc.book WHERE rc.member.id = :memberId ORDER BY rc.createdDate DESC") + List findAllWithBookByMemberId(Long memberId); + default ReadingChallenge findOngoingChallenge(Member member, Book book) { return findByMemberAndBookAndCompletedFalseAndAbandonedFalse(member, book) .orElseThrow(() -> new CustomException(ErrorCode.CHALLENGE_NOT_FOUND)); diff --git a/src/main/java/book/book/challenge/repository/ReadingDiaryRepository.java b/src/main/java/book/book/challenge/repository/ReadingDiaryRepository.java index 01e839b2..5412bbfb 100644 --- a/src/main/java/book/book/challenge/repository/ReadingDiaryRepository.java +++ b/src/main/java/book/book/challenge/repository/ReadingDiaryRepository.java @@ -6,6 +6,7 @@ import book.book.member.entity.Member; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface ReadingDiaryRepository extends JpaRepository, ReadingDiaryRepositoryCustom { @@ -20,5 +21,8 @@ default ReadingDiary findByIdOrElseThrow(Long id) { List findAllByMemberId(Long memberId); + @Query("SELECT rd FROM ReadingDiary rd JOIN FETCH rd.book WHERE rd.member.id = :memberId ORDER BY rd.createdDate DESC") + List findTop5ByMemberIdOrderByCreatedDateDescWithBook(Long memberId); + List findByBookId(Long bookId); } diff --git a/src/main/java/book/book/challenge/service/ReadingDiaryService.java b/src/main/java/book/book/challenge/service/ReadingDiaryService.java index 1b80436d..6a5bbffc 100644 --- a/src/main/java/book/book/challenge/service/ReadingDiaryService.java +++ b/src/main/java/book/book/challenge/service/ReadingDiaryService.java @@ -49,12 +49,15 @@ public class ReadingDiaryService { private final NaverBlogPostQualityRepository naverBlogPostQualityRepository; private final ApplicationEventPublisher eventPublisher; - /** - * 맴버의 독서일지 조회 - */ - @Transactional + @Transactional(readOnly = true) + public List getRecentDiariesForAnalysis(Long memberId) { + return readingDiaryRepository.findTop5ByMemberIdOrderByCreatedDateDescWithBook(memberId); + } + + // 맴버의 독서일지 조회 + @Transactional(readOnly = true) public CursorPageResponse getDiariesThumbnailByMember(Long requesterId, Long memberId, - Long cursorId, int pageSize) { + Long cursorId, int pageSize) { Member member = memberRepository.findByIdOrElseThrow(memberId); // requesterId가 memberId와 같으면 비공개 포함, 다르면 공개만 @@ -67,7 +70,7 @@ public CursorPageResponse getDiariesThumbnailByMem @Transactional public CursorPageResponse getLatestDiariesFeedByMember(Long requesterId, Long memberId, - Long cursor, int size) { + Long cursor, int size) { boolean includePrivate = requesterId != null && requesterId.equals(memberId); List diaries = readingDiaryRepository.findLatestDiaryFeedsByMember(requesterId, @@ -85,14 +88,15 @@ public CursorPageResponse getLatestDiariesFeedByMember( */ @Transactional public CursorPageResponse getFollowingDiariesFeed(Long requesterId, Long cursor, - int size) { + int size) { List diaries = readingDiaryRepository.findLatestDiaryFeedsByFollowing( requesterId, cursor, size + 1); // TODO: fallback, 팔로잉 피드 없을 시 최근 피드를 보여준다(임시용), 고도화 필요 -// if(diaries.isEmpty()) { -// diaries = readingDiaryRepository.findLatestDiaryFeeds(requesterId, cursor, size + 1); -// } + // if(diaries.isEmpty()) { + // diaries = readingDiaryRepository.findLatestDiaryFeeds(requesterId, cursor, + // size + 1); + // } diaries = (List) diaryDetailCombiner.combine(diaries); onImpressed(requesterId, diaries); @@ -101,22 +105,23 @@ public CursorPageResponse getFollowingDiariesFeed(Long /** * 책별 모두의 독서일지 조회 - * 책과 관련된 모두의 독서일지는 최신성 반응이 사용자가 둔감하게 반응하므로 레디스에 저장하지 않고 지난 날 기록인 DB DiaryStatistic을 사용 + * 책과 관련된 모두의 독서일지는 최신성 반응이 사용자가 둔감하게 반응하므로 레디스에 저장하지 않고 지난 날 기록인 DB + * DiaryStatistic을 사용 */ @Transactional(readOnly = true) public CursorPageResponse getRelatedLatestDiaryThumbnailsByBook( Long bookId, Long cursorId, int pageSize) { - List thumbnails = - readingDiaryRepository.findRelatedLatestDiaryThumbnailsByBook(bookId, cursorId, pageSize + 1); + List thumbnails = readingDiaryRepository + .findRelatedLatestDiaryThumbnailsByBook(bookId, cursorId, pageSize + 1); return CursorPageResponse.of(thumbnails, pageSize, DiaryResponse.DiaryThumbnail::diaryId); } @Transactional(readOnly = true) public DualCursorPageResponse getRelatedPopularDiaryThumbnailsByBook( Long bookId, Long cursorId, Double cursorScore, int pageSize) { - List thumbnails = - readingDiaryRepository.findRelatedPopularDiaryThumbnailsByBook(bookId, cursorId, cursorScore, - pageSize + 1); + List thumbnails = readingDiaryRepository.findRelatedPopularDiaryThumbnailsByBook( + bookId, cursorId, cursorScore, + pageSize + 1); return DualCursorPageResponse.of(thumbnails, pageSize, RelatedDiaryThumbnailByBook::diaryId, RelatedDiaryThumbnailByBook::score); @@ -135,7 +140,6 @@ public CursorPageResponse getLatestDiaryFeedsByBook( return CursorPageResponse.of(diaries, size, DiaryResponse.DiaryFeed::getDiaryId); } - @Transactional(readOnly = true) public DualCursorPageResponse getPopularDiaryFeedsByBook( Long requesterId, Long bookId, Long cursorId, double cursorScore, int size) { diff --git a/src/main/java/book/book/challenge/service/V2ChallengeService.java b/src/main/java/book/book/challenge/service/V2ChallengeService.java index f62bb99a..53666509 100644 --- a/src/main/java/book/book/challenge/service/V2ChallengeService.java +++ b/src/main/java/book/book/challenge/service/V2ChallengeService.java @@ -220,6 +220,11 @@ public Map getInProgressChallengeMap(Long memberId, List bo book -> ongoingBookIds.contains(book.getId()))); } + @Transactional(readOnly = true) + public List getAllChallengesForAnalysis(Long memberId) { + return challengeRepository.findAllWithBookByMemberId(memberId); + } + @Transactional(readOnly = true) public ChallengeSucessDetail getSucessDetail(Long challengeId) { ReadingChallenge readingChallenge = challengeRepository.findByIdOrElseThrow(challengeId); diff --git a/src/main/java/book/book/common/monitoring/AsyncMetricsAspect.java b/src/main/java/book/book/common/monitoring/AsyncMetricsAspect.java new file mode 100644 index 00000000..9d616603 --- /dev/null +++ b/src/main/java/book/book/common/monitoring/AsyncMetricsAspect.java @@ -0,0 +1,58 @@ +package book.book.common.monitoring; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class AsyncMetricsAspect { + + private final MeterRegistry meterRegistry; + + @Around("@annotation(org.springframework.scheduling.annotation.Async)") + public Object measureAsyncExecution(ProceedingJoinPoint joinPoint) throws Throwable { + Timer.Sample timerSample = Timer.start(meterRegistry); + + Object result; + try { + result = joinPoint.proceed(); + } catch (Throwable ex) { + // 메서드 실행 자체가 실패한 경우 (즉시 예외 발생) + recordExecutionTime(timerSample, joinPoint, ex); + throw ex; + } + + // 리턴 타입이 CompletableFuture인 경우: 비동기 작업이 진짜 끝날 때까지 기다림 + if (result instanceof java.util.concurrent.CompletableFuture) { + ((java.util.concurrent.CompletableFuture) result).whenComplete((res, ex) -> { + recordExecutionTime(timerSample, joinPoint, ex); + }); + } else { + // void 이거나 다른 리턴 타입인 경우: 메서드 종료 시점 측정 (기존 방식) + recordExecutionTime(timerSample, joinPoint, null); + } + + return result; + } + + private void recordExecutionTime(Timer.Sample timerSample, ProceedingJoinPoint joinPoint, Throwable ex) { + String exceptionClass = "none"; + if (ex != null) { + exceptionClass = ex.getClass().getSimpleName(); + } + + timerSample.stop(Timer.builder("async.execution") + .tag("class", joinPoint.getSignature().getDeclaringTypeName()) + .tag("method", joinPoint.getSignature().getName()) + .tag("exception", exceptionClass) + .description("Execution time of @Async methods") + .publishPercentiles(0.95, 0.99) + .register(meterRegistry)); + } +} diff --git a/src/main/java/book/book/motive/api/KnowledgeFusionController.java b/src/main/java/book/book/motive/api/KnowledgeFusionController.java new file mode 100644 index 00000000..b93cbb4f --- /dev/null +++ b/src/main/java/book/book/motive/api/KnowledgeFusionController.java @@ -0,0 +1,29 @@ +package book.book.motive.api; + +import book.book.common.response.ResponseForm; +import book.book.motive.dto.InsightResponse; +import book.book.motive.service.KnowledgeFusionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "동기부여 (Motive)", description = "(정순원을 위해 만들어봄)사용자 동기부여 및 인사이트 관련 API") +@RestController +@RequestMapping("/api/v1/motive") +@RequiredArgsConstructor +public class KnowledgeFusionController { + + private final KnowledgeFusionService knowledgeFusionService; + + @Operation(summary = "맞춤형 인사이트 생성", description = "사용자의 독서 활동(일지, 챌린지, 퀴즈)을 분석하여 맞춤형 마인드셋과 액션 플랜을 제공합니다.") + @PostMapping("/insight") + public ResponseForm getPersonalizedInsight( + @AuthenticationPrincipal Long memberId) { + InsightResponse response = knowledgeFusionService.getPersonalizedInsight(memberId); + return ResponseForm.ok(response); + } +} diff --git a/src/main/java/book/book/motive/dto/InsightResponse.java b/src/main/java/book/book/motive/dto/InsightResponse.java new file mode 100644 index 00000000..0f077c08 --- /dev/null +++ b/src/main/java/book/book/motive/dto/InsightResponse.java @@ -0,0 +1,18 @@ +package book.book.motive.dto; + +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class InsightResponse { + private String mindset; + private List actionPlan; + + public InsightResponse(String mindset, List actionPlan) { + this.mindset = mindset; + this.actionPlan = actionPlan; + } +} diff --git a/src/main/java/book/book/motive/service/KnowledgeFusionService.java b/src/main/java/book/book/motive/service/KnowledgeFusionService.java new file mode 100644 index 00000000..62c986a4 --- /dev/null +++ b/src/main/java/book/book/motive/service/KnowledgeFusionService.java @@ -0,0 +1,160 @@ +package book.book.motive.service; + +import book.book.challenge.domain.ReadingChallenge; +import book.book.challenge.domain.ReadingDiary; +import book.book.challenge.service.ReadingDiaryService; +import book.book.challenge.service.V2ChallengeService; +import book.book.motive.dto.InsightResponse; +import book.book.quiz.dto.QuizLog; +import book.book.quiz.dto.external.GeminiRequest; +import book.book.quiz.external.gemini.GeminiSdkClient; +import book.book.quiz.service.QuizService; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import com.google.genai.types.Schema; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KnowledgeFusionService { + + private final ReadingDiaryService readingDiaryService; + private final V2ChallengeService v2ChallengeService; + private final QuizService quizService; + private final GeminiSdkClient geminiSdkClient; + + public InsightResponse getPersonalizedInsight(Long memberId) { + // 1. Fetch User Data (read-only transactions within these methods) + List recentDiaries = readingDiaryService.getRecentDiariesForAnalysis(memberId); + + // Fetch all challenges with book details + List challenges = v2ChallengeService.getAllChallengesForAnalysis(memberId).stream() + .limit(10) // Limit to 10 most recent to avoid token overflow + .toList(); + + // Fetch quiz logs with question/answer/explanation + List recentQuizLogs = quizService.getRecentQuizLogsForAnalysis(memberId); + + // 2. Construct Prompt + String prompt = constructPrompt(recentDiaries, challenges, recentQuizLogs); + + // 3. Call Gemini (No Transaction) + // GeminiSdkClient requires a GeminiRequest object + + // Define Schema for InsightResponse + Schema schema = Schema.builder() + .type("object") + .properties(Map.of( + "mindset", Schema.builder().type("string").build(), + "actionPlan", + Schema.builder().type("array").items(Schema.builder().type("string").build()).build())) + .required(List.of("mindset", "actionPlan")) + .build(); + + // Define System Instruction + String systemPrompt = """ + 당신은 '지식 통합 설계자(Knowledge Synthesis Architect)'입니다. + 사용자의 데이터를 분석할 때 다음 **'7가지 생각 도구'**를 자유자재로 구사하여 **'4단계 지식 창조 메커니즘'**을 수행하십시오. + + [필수 생각 도구 (7 Cognitive Tools)] + 1. 확산적 사고 (Divergent Thinking) + 2. 수렴적 사고 (Convergent Thinking) + 3. 비판적 사고 (Critical Thinking) + 4. 문제 재구성 (Problem Reframing) + 5. 관점 전환 (Perspective Shift) + 6. 유추적 사고 (Analogical Thinking) + 7. 지식 통합 (Knowledge Integration) + + [사고 과정 (4-Step Knowledge Creation Flow)] + 1. **기반 다지기 (Grounded Reality)**: (도구: 비판적 사고, 문제 재구성) 사용자의 구체적인 독서/퀴즈 데이터에서 실질적인 문제를 발견합니다. + 2. **근본적 해체 (Critical Deconstruction)**: (도구: 관점 전환, 확산적 사고) "왜?"라는 질문을 통해 기존 지식의 구조와 가정을 낱낱이 분해합니다. + 3. **창의적 합성 (Creative Synthesis)**: (도구: 지식 통합, 수렴적 사고) 분해된 파편을 재조립하여 새로운 기술적/논리적 해결책을 설계합니다. + 4. **경계 너머로의 도약 (Rhizomatic Expansion)**: (도구: 유추적 사고) 이를 인문/철학/예술적 개념과 연결하여 '삶의 지혜'로 확장합니다. + + 결과물은 단순한 조언이 아니라, 이 치열한 사고 과정을 거쳐 도달한 **'새로운 지식의 결정체'**여야 합니다. + """; + Content systemInstruction = Content.fromParts(Part.fromText(systemPrompt)); + + GeminiRequest request = GeminiRequest + .builder() + .prompt(prompt) + .systemInstruction(systemInstruction) + .schema(schema) + .responseType(InsightResponse.class) + .contextForLogging("KnowledgeFusion") + .build(); + + return geminiSdkClient.generateContent(request); + } + + private String constructPrompt(List diaries, List challenges, + List quizLogs) { + StringBuilder sb = new StringBuilder(); + sb.append("Analyze the following user reading history and provide personalized insights:\n\n"); + + sb.append("### 1. User's Thoughts (Reading Diaries)\n"); + sb.append("This section reveals the user's personal reflections and current intellectual interests.\n"); + if (diaries.isEmpty()) { + sb.append("- None\n"); + } else { + for (ReadingDiary diary : diaries) { + sb.append(String.format("- Book: %s\n Thought: %s\n", diary.getBook().getTitle(), diary.getContent())); + } + } + + sb.append("\n### 2. Reading Context (Challenges)\n"); + sb.append("This section shows the books the user is currently reading or has completed.\n"); + if (challenges.isEmpty()) { + sb.append("- None\n"); + } else { + for (ReadingChallenge challenge : challenges) { + String description = challenge.getBook().getDescription(); + if (description != null && description.length() > 150) { + description = description.substring(0, 150) + "..."; + } + sb.append(String.format("- Book: %s\n Summary: %s\n", + challenge.getBook().getTitle(), + description)); + } + } + + sb.append("\n### 3. Knowledge Engagement (Quiz Content)\n"); + sb.append( + "This section lists specific concepts and knowledge points the user has recently encountered. Focus on the 'Explanation' to understand the depth of knowledge.\n"); + if (quizLogs.isEmpty()) { + sb.append("- None\n"); + } else { + for (QuizLog log : quizLogs) { + sb.append(String.format("- Topic: %s\n Key Concept: %s\n", + log.getQuestion(), + log.getExplanation())); + } + } + + sb.append("\n### Instructions\n"); + sb.append( + "Synthesize a 'Mindset' and an 'Action Plan' by strictly following the **4-Stage Knowledge Creation Mechanism** described in the system instruction.\n"); + sb.append( + "1. **Mindset**: Diagnose the user's state by traversing from 'Technical Reality' to 'Philosophical Expansion' in one sentence.\n"); + sb.append( + "2. **Action Plan**: Provide 3 Insights. Each item MUST be the result of the 4-stage process. Structure each item as follows:\n"); + sb.append(" - **Step 1 (Reality)**: Identify the specific data point (Book/Quiz) you are addressing.\n"); + sb.append( + " - **Step 2 & 3 (Deconstruction & Synthesis)**: Briefly explain how you deconstructed the concept and synthesized a new solution.\n"); + sb.append( + " - **Step 4 (Expansion - THE INSIGHT)**: Present the final wisdom connecting this to a Philosophical/Humanistic concept (Rhizome, Existentialism, etc.).\n"); + sb.append(" - **Action**: A concrete, imperative command derived from this expansion.\n"); + sb.append("\n**Response Format Information**:\n"); + sb.append("- The response MUST be in Korean (Hangul).\n"); + sb.append( + "- The response MUST be valid JSON matching the Schema: { \"mindset\": string, \"actionPlan\": [string] }\n"); + + return sb.toString(); + } + +} diff --git a/src/main/java/book/book/quiz/dto/QuizLog.java b/src/main/java/book/book/quiz/dto/QuizLog.java new file mode 100644 index 00000000..a40b9de0 --- /dev/null +++ b/src/main/java/book/book/quiz/dto/QuizLog.java @@ -0,0 +1,17 @@ +package book.book.quiz.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class QuizLog { + private String question; + private String answer; + private String explanation; +} diff --git a/src/main/java/book/book/quiz/repository/QuizAttemptRepository.java b/src/main/java/book/book/quiz/repository/QuizAttemptRepository.java index 3d514f64..ab334bdc 100644 --- a/src/main/java/book/book/quiz/repository/QuizAttemptRepository.java +++ b/src/main/java/book/book/quiz/repository/QuizAttemptRepository.java @@ -15,4 +15,6 @@ public interface QuizAttemptRepository extends JpaRepository void deleteByQuizIdIn(List quizIds); int countByChallengeIdAndIsCorrectTrue(Long challengeId); + + List findTop10ByMemberIdOrderByAttemptedAtDesc(Long memberId); } diff --git a/src/main/java/book/book/quiz/repository/QuizRepository.java b/src/main/java/book/book/quiz/repository/QuizRepository.java index cafc54a3..6bdfc7cc 100644 --- a/src/main/java/book/book/quiz/repository/QuizRepository.java +++ b/src/main/java/book/book/quiz/repository/QuizRepository.java @@ -16,5 +16,8 @@ public interface QuizRepository extends JpaRepository { @Query("SELECT DISTINCT q FROM Quiz q JOIN FETCH q.chapter JOIN FETCH q.choices WHERE q.chapter.id IN :chapterIds") List findAllByChapterIdInWithChoices(List chapterIds); + @Query("SELECT q FROM Quiz q JOIN FETCH q.choices WHERE q.id IN :quizIds") + List findAllByIdInWithChoices(List quizIds); + boolean existsByChapterId(Long id); } diff --git a/src/main/java/book/book/quiz/service/QuizService.java b/src/main/java/book/book/quiz/service/QuizService.java index 8787dc5a..08f6777c 100644 --- a/src/main/java/book/book/quiz/service/QuizService.java +++ b/src/main/java/book/book/quiz/service/QuizService.java @@ -1,12 +1,14 @@ package book.book.quiz.service; -import book.book.book.entity.Book; import book.book.book.entity.Chapter; import book.book.book.repository.BookRepository; import book.book.book.repository.ChapterRepository; import book.book.common.CustomException; import book.book.common.ErrorCode; import book.book.quiz.domain.Quiz; +import book.book.quiz.domain.QuizAttempt; +import book.book.quiz.domain.QuizChoice; +import book.book.quiz.dto.QuizLog; import book.book.quiz.dto.response.QuizResponses; import book.book.quiz.dto.response.QuizResponses.ChapterResponse; import book.book.quiz.repository.QuizAttemptRepository; @@ -111,4 +113,43 @@ private void deleteQuizEntities(List quizIds) { public int solvedCount(Long challengeId) { return quizAttemptRepository.countByChallengeIdAndIsCorrectTrue(challengeId); } + + @Transactional(readOnly = true) + public List getRecentQuizLogsForAnalysis(Long memberId) { + List attempts = quizAttemptRepository.findTop10ByMemberIdOrderByAttemptedAtDesc(memberId); + if (attempts.isEmpty()) { + return List.of(); + } + + List quizIds = attempts.stream() + .map(QuizAttempt::getQuizId) + .toList(); + + Map quizMap = quizRepository.findAllByIdInWithChoices(quizIds).stream() + .collect(Collectors.toMap(Quiz::getId, quiz -> quiz)); + + return attempts.stream() + .filter(attempt -> quizMap.containsKey(attempt.getQuizId())) + .map(attempt -> { + Quiz quiz = quizMap.get(attempt.getQuizId()); + String correctChoiceText = quiz.getChoices().stream() + .filter(QuizChoice::getIsCorrect) + .findFirst() + .map(QuizChoice::getChoiceText) + .orElse("Unknown"); + + String explanation = quiz.getChoices().stream() + .filter(c -> c.getExplanation() != null && !c.getExplanation().isEmpty()) + .findFirst() + .map(QuizChoice::getExplanation) + .orElse("No explanation"); + + return QuizLog.builder() + .question(quiz.getQuestion()) + .answer(correctChoiceText) + .explanation(explanation) + .build(); + }) + .toList(); + } }