Skip to content

Commit 8ae00a5

Browse files
authored
merging test s3
merging test
2 parents 9a298ed + 449c631 commit 8ae00a5

25 files changed

Lines changed: 743 additions & 96 deletions
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package com.bootsignal.domain.ai.review.entity;
2+
3+
import com.bootsignal.domain.ai.review.dto.ReviewSummaryContent;
4+
import com.bootsignal.global.converter.StringListConverter;
5+
import com.bootsignal.global.entity.BaseEntity;
6+
import jakarta.persistence.Column;
7+
import jakarta.persistence.Convert;
8+
import jakarta.persistence.Entity;
9+
import jakarta.persistence.GeneratedValue;
10+
import jakarta.persistence.GenerationType;
11+
import jakarta.persistence.Id;
12+
import jakarta.persistence.Table;
13+
import java.math.BigDecimal;
14+
import java.time.Instant;
15+
import java.util.List;
16+
import lombok.AccessLevel;
17+
import lombok.Builder;
18+
import lombok.Getter;
19+
import lombok.NoArgsConstructor;
20+
21+
@Entity
22+
@Table(name = "review_summary_cache")
23+
@Getter
24+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
25+
public class ReviewSummaryCache extends BaseEntity {
26+
27+
@Id
28+
@GeneratedValue(strategy = GenerationType.IDENTITY)
29+
private Long id;
30+
31+
@Column(name = "course_id", nullable = false, unique = true)
32+
private Long courseId;
33+
34+
@Column(name = "execution_id", nullable = false, length = 36)
35+
private String executionId;
36+
37+
@Column(name = "review_count", nullable = false)
38+
private int reviewCount;
39+
40+
@Column(name = "latest_crawled_at", nullable = false)
41+
private Instant latestCrawledAt;
42+
43+
@Column(name = "average_rating", precision = 3, scale = 2)
44+
private BigDecimal averageRating;
45+
46+
@Column(name = "course_title", nullable = false, length = 500)
47+
private String courseTitle;
48+
49+
@Column(columnDefinition = "TEXT", nullable = false)
50+
private String summary;
51+
52+
@Convert(converter = StringListConverter.class)
53+
@Column(columnDefinition = "TEXT")
54+
private List<String> strengths;
55+
56+
@Convert(converter = StringListConverter.class)
57+
@Column(columnDefinition = "TEXT")
58+
private List<String> weaknesses;
59+
60+
@Convert(converter = StringListConverter.class)
61+
@Column(name = "recommended_for", columnDefinition = "TEXT")
62+
private List<String> recommendedFor;
63+
64+
@Convert(converter = StringListConverter.class)
65+
@Column(columnDefinition = "TEXT")
66+
private List<String> keywords;
67+
68+
@Builder
69+
private ReviewSummaryCache(Long courseId, String executionId, int reviewCount,
70+
Instant latestCrawledAt, BigDecimal averageRating, String courseTitle,
71+
String summary, List<String> strengths, List<String> weaknesses,
72+
List<String> recommendedFor, List<String> keywords) {
73+
this.courseId = courseId;
74+
this.executionId = executionId;
75+
this.reviewCount = reviewCount;
76+
this.latestCrawledAt = latestCrawledAt;
77+
this.averageRating = averageRating;
78+
this.courseTitle = courseTitle;
79+
this.summary = summary;
80+
this.strengths = strengths;
81+
this.weaknesses = weaknesses;
82+
this.recommendedFor = recommendedFor;
83+
this.keywords = keywords;
84+
}
85+
86+
public void update(String executionId, int reviewCount, Instant latestCrawledAt,
87+
BigDecimal averageRating, String courseTitle, ReviewSummaryContent content) {
88+
this.executionId = executionId;
89+
this.reviewCount = reviewCount;
90+
this.latestCrawledAt = latestCrawledAt;
91+
this.averageRating = averageRating;
92+
this.courseTitle = courseTitle;
93+
this.summary = content.summary();
94+
this.strengths = content.strengths();
95+
this.weaknesses = content.weaknesses();
96+
this.recommendedFor = content.recommendedFor();
97+
this.keywords = content.keywords();
98+
}
99+
100+
public ReviewSummaryContent toContent() {
101+
return new ReviewSummaryContent(summary, strengths, weaknesses, recommendedFor, keywords);
102+
}
103+
104+
public boolean isStale(int currentReviewCount, Instant currentLatestCrawledAt) {
105+
return this.reviewCount != currentReviewCount
106+
|| !this.latestCrawledAt.equals(currentLatestCrawledAt);
107+
}
108+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.bootsignal.domain.ai.review.repository;
2+
3+
import com.bootsignal.domain.ai.review.entity.ReviewSummaryCache;
4+
import java.util.Optional;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
7+
public interface ReviewSummaryCacheRepository extends JpaRepository<ReviewSummaryCache, Long> {
8+
9+
Optional<ReviewSummaryCache> findByCourseId(Long courseId);
10+
}

src/main/java/com/bootsignal/domain/ai/review/service/ReviewSummaryService.java

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,127 @@
77
import com.bootsignal.domain.ai.review.dto.ReviewSummaryContent;
88
import com.bootsignal.domain.ai.review.dto.ReviewSummaryCreateRequest;
99
import com.bootsignal.domain.ai.review.dto.ReviewSummaryResponse;
10+
import com.bootsignal.domain.ai.review.entity.ReviewSummaryCache;
11+
import com.bootsignal.domain.ai.review.repository.ReviewSummaryCacheRepository;
12+
import com.bootsignal.domain.crawled_review.repository.CrawledReviewRepository;
1013
import com.bootsignal.domain.user.entity.User;
1114
import com.bootsignal.domain.user.repository.UserRepository;
1215
import com.bootsignal.global.exception.BootSignalException;
1316
import com.bootsignal.global.exception.ErrorCode;
1417
import com.bootsignal.global.security.SecurityUtil;
1518
import java.math.BigDecimal;
19+
import java.time.Instant;
1620
import java.util.LinkedHashMap;
1721
import java.util.Map;
22+
import java.util.Optional;
23+
import java.util.UUID;
1824
import lombok.RequiredArgsConstructor;
1925
import org.springframework.stereotype.Service;
26+
import org.springframework.transaction.annotation.Transactional;
2027

2128
@Service
2229
@RequiredArgsConstructor
2330
public class ReviewSummaryService {
2431

25-
// 인증된 사용자의 요청을 AI 실행 컨텍스트로 변환해 리뷰 요약 Agent에 위임한다.
2632
private final AgentHarness agentHarness;
2733
private final UserRepository userRepository;
34+
private final ReviewSummaryCacheRepository cacheRepository;
35+
private final CrawledReviewRepository crawledReviewRepository;
2836

37+
@Transactional
2938
public ReviewSummaryResponse createSummary(ReviewSummaryCreateRequest request) {
3039
User user = getAuthenticatedUser();
40+
Long courseId = request.courseId();
41+
42+
long currentCount = countReviews(courseId);
43+
Instant currentLatestCrawledAt = latestCrawledAt(courseId);
44+
45+
Optional<ReviewSummaryCache> cached = cacheRepository.findByCourseId(courseId);
46+
47+
if (cached.isPresent()) {
48+
ReviewSummaryCache cache = cached.get();
49+
if (!cache.isStale((int) currentCount, currentLatestCrawledAt)) {
50+
return toCachedResponse(cache);
51+
}
52+
return regenerateAndUpdate(request, cache, user, (int) currentCount, currentLatestCrawledAt);
53+
}
54+
55+
return generateAndSave(request, user, (int) currentCount, currentLatestCrawledAt);
56+
}
57+
58+
private ReviewSummaryResponse generateAndSave(ReviewSummaryCreateRequest request,
59+
User user, int reviewCount, Instant latestCrawledAt) {
60+
AgentExecutionResult result = executeAgent(request, user);
61+
ReviewSummaryContent content = extractContent(result);
62+
63+
ReviewSummaryCache cache = ReviewSummaryCache.builder()
64+
.courseId(asLong(result.output().get("courseId")))
65+
.executionId(result.executionId().toString())
66+
.reviewCount(reviewCount)
67+
.latestCrawledAt(latestCrawledAt)
68+
.averageRating(asBigDecimal(result.output().get("averageRating")))
69+
.courseTitle(asString(result.output().get("courseTitle")))
70+
.summary(content.summary())
71+
.strengths(content.strengths())
72+
.weaknesses(content.weaknesses())
73+
.recommendedFor(content.recommendedFor())
74+
.keywords(content.keywords())
75+
.build();
76+
cacheRepository.save(cache);
77+
78+
return toResponse(result, content);
79+
}
80+
81+
private ReviewSummaryResponse regenerateAndUpdate(ReviewSummaryCreateRequest request,
82+
ReviewSummaryCache cache, User user, int reviewCount, Instant latestCrawledAt) {
83+
AgentExecutionResult result = executeAgent(request, user);
84+
ReviewSummaryContent content = extractContent(result);
85+
86+
cache.update(
87+
result.executionId().toString(),
88+
reviewCount,
89+
latestCrawledAt,
90+
asBigDecimal(result.output().get("averageRating")),
91+
asString(result.output().get("courseTitle")),
92+
content
93+
);
94+
95+
return toResponse(result, content);
96+
}
97+
98+
private ReviewSummaryResponse toCachedResponse(ReviewSummaryCache cache) {
99+
return ReviewSummaryResponse.from(
100+
UUID.fromString(cache.getExecutionId()),
101+
cache.getCourseId(),
102+
cache.getCourseTitle(),
103+
cache.getReviewCount(),
104+
cache.getAverageRating(),
105+
cache.toContent()
106+
);
107+
}
108+
109+
private AgentExecutionResult executeAgent(ReviewSummaryCreateRequest request, User user) {
31110
AgentExecutionResult result = agentHarness.execute(AgentExecutionContext.of(
32111
AgentType.REVIEW_SUMMARY,
33112
user.getId(),
34113
toInputSummary(request),
35114
toInput(request)
36115
));
37-
38116
if (!result.isSuccess()) {
39117
throw new BootSignalException(ErrorCode.AI_EXECUTION_FAILED, result.errorMessage());
40118
}
119+
return result;
120+
}
41121

122+
private ReviewSummaryContent extractContent(AgentExecutionResult result) {
42123
Object summary = result.output().get("summary");
43124
if (!(summary instanceof ReviewSummaryContent content)) {
44125
throw new BootSignalException(ErrorCode.AI_OUTPUT_INVALID, "수강후기 요약 결과를 찾을 수 없습니다.");
45126
}
127+
return content;
128+
}
129+
130+
private ReviewSummaryResponse toResponse(AgentExecutionResult result, ReviewSummaryContent content) {
46131
return ReviewSummaryResponse.from(
47132
result.executionId(),
48133
asLong(result.output().get("courseId")),
@@ -53,6 +138,18 @@ public ReviewSummaryResponse createSummary(ReviewSummaryCreateRequest request) {
53138
);
54139
}
55140

141+
private long countReviews(Long courseId) {
142+
long count = crawledReviewRepository.countReviewsByCourseId(courseId);
143+
if (count == 0) {
144+
throw new BootSignalException(ErrorCode.AI_INPUT_INVALID, "요약할 고용24 수강후기가 없습니다.");
145+
}
146+
return count;
147+
}
148+
149+
private Instant latestCrawledAt(Long courseId) {
150+
return crawledReviewRepository.findMaxCrawledAtByCourseId(courseId).orElse(Instant.EPOCH);
151+
}
152+
56153
private Map<String, Object> toInput(ReviewSummaryCreateRequest request) {
57154
Map<String, Object> input = new LinkedHashMap<>();
58155
input.put("courseId", request.courseId());

src/main/java/com/bootsignal/domain/crawled_review/repository/CrawledReviewRepository.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import com.bootsignal.domain.crawled_review.entity.CrawledReview;
44
import com.bootsignal.domain.crawled_review.entity.CrawledReviewSource;
5+
import java.time.Instant;
6+
import java.util.List;
7+
import java.util.Optional;
58
import org.springframework.data.domain.Page;
69
import org.springframework.data.domain.Pageable;
710
import org.springframework.data.jpa.repository.JpaRepository;
811
import org.springframework.data.jpa.repository.Query;
912
import org.springframework.data.repository.query.Param;
1013

11-
import java.util.List;
12-
1314
public interface CrawledReviewRepository extends JpaRepository<CrawledReview, Long> {
1415

1516
boolean existsByCourseIdAndExternalReviewId(Long courseId, String externalReviewId);
@@ -25,5 +26,11 @@ SELECT cr.course.id, COUNT(cr), SUM(cr.rating)
2526
GROUP BY cr.course.id
2627
""")
2728
List<Object[]> findCrawledReviewSumsByCourseIds(@Param("courseIds") List<Long> courseIds);
29+
30+
@Query("SELECT COUNT(cr) FROM CrawledReview cr WHERE cr.course.id = :courseId")
31+
long countReviewsByCourseId(@Param("courseId") Long courseId);
32+
33+
@Query("SELECT MAX(cr.crawledAt) FROM CrawledReview cr WHERE cr.course.id = :courseId")
34+
Optional<Instant> findMaxCrawledAtByCourseId(@Param("courseId") Long courseId);
2835
}
2936

0 commit comments

Comments
 (0)