Skip to content

Commit 410c55a

Browse files
authored
feat: 게시글 AI 요약 응답 분리 (#2328)
1 parent f59c72e commit 410c55a

10 files changed

Lines changed: 503 additions & 6 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package in.koreatech.koin.domain.community.article.controller;
2+
3+
import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH;
4+
5+
import org.springframework.http.ResponseEntity;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.PathVariable;
8+
import org.springframework.web.bind.annotation.RequestMapping;
9+
import org.springframework.web.bind.annotation.RequestParam;
10+
11+
import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2;
12+
import in.koreatech.koin.global.ipaddress.IpAddress;
13+
import io.swagger.v3.oas.annotations.Operation;
14+
import io.swagger.v3.oas.annotations.Parameter;
15+
import io.swagger.v3.oas.annotations.media.Content;
16+
import io.swagger.v3.oas.annotations.media.Schema;
17+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
18+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
19+
import io.swagger.v3.oas.annotations.tags.Tag;
20+
21+
@Tag(name = "(Normal) Articles V2: 게시글", description = "게시글 정보를 관리한다")
22+
@RequestMapping("/v2/articles")
23+
public interface ArticleApiV2 {
24+
25+
@ApiResponses(
26+
value = {
27+
@ApiResponse(responseCode = "200"),
28+
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
29+
}
30+
)
31+
@Operation(summary = "게시글 단건 조회 V2", description = "게시글 원문과 AI 요약을 분리해 반환한다.")
32+
@GetMapping("/{id}")
33+
ResponseEntity<ArticleResponseV2> getArticleV2(
34+
@RequestParam(required = false) Integer boardId,
35+
@Parameter(in = PATH) @PathVariable("id") Integer articleId,
36+
@IpAddress String ipAddress
37+
);
38+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package in.koreatech.koin.domain.community.article.controller;
2+
3+
import org.springframework.http.ResponseEntity;
4+
import org.springframework.web.bind.annotation.GetMapping;
5+
import org.springframework.web.bind.annotation.PathVariable;
6+
import org.springframework.web.bind.annotation.RequestMapping;
7+
import org.springframework.web.bind.annotation.RequestParam;
8+
import org.springframework.web.bind.annotation.RestController;
9+
10+
import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2;
11+
import in.koreatech.koin.domain.community.article.service.ArticleService;
12+
import in.koreatech.koin.global.ipaddress.IpAddress;
13+
import lombok.RequiredArgsConstructor;
14+
15+
@RestController
16+
@RequiredArgsConstructor
17+
@RequestMapping("/v2/articles")
18+
public class ArticleControllerV2 implements ArticleApiV2 {
19+
20+
private final ArticleService articleService;
21+
22+
@GetMapping("/{id}")
23+
public ResponseEntity<ArticleResponseV2> getArticleV2(
24+
@RequestParam(required = false) Integer boardId,
25+
@PathVariable("id") Integer articleId,
26+
@IpAddress String ipAddress
27+
) {
28+
ArticleResponseV2 foundArticle = articleService.getArticleV2(boardId, articleId, ipAddress);
29+
return ResponseEntity.ok().body(foundArticle);
30+
}
31+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package in.koreatech.koin.domain.community.article.dto;
2+
3+
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
4+
5+
import java.util.Arrays;
6+
import java.util.List;
7+
import java.util.Optional;
8+
9+
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
10+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
11+
12+
import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryIcon;
13+
import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView;
14+
import io.swagger.v3.oas.annotations.media.Schema;
15+
16+
@JsonNaming(SnakeCaseStrategy.class)
17+
public record ArticleAiSummaryResponse(
18+
19+
@Schema(description = "AI 요약 상태", example = "SUCCESS", requiredMode = REQUIRED)
20+
Status status,
21+
22+
@Schema(description = "AI 요약 항목", requiredMode = REQUIRED)
23+
List<InnerArticleAiSummaryItemResponse> items
24+
) {
25+
26+
private static final int MAX_SUMMARY_LINES = 3;
27+
private static final int MAX_SUMMARY_LINE_LENGTH = 220;
28+
29+
public static ArticleAiSummaryResponse from(ArticleSummaryView summaryView) {
30+
List<InnerArticleAiSummaryItemResponse> items = summaryView.summaryLines().stream()
31+
.filter(line -> line != null && !line.isBlank())
32+
.filter(line -> line.length() <= MAX_SUMMARY_LINE_LENGTH)
33+
.map(InnerArticleAiSummaryItemResponse::from)
34+
.flatMap(Optional::stream)
35+
.limit(MAX_SUMMARY_LINES)
36+
.toList();
37+
Status status = summaryView.isSuccess() && items.isEmpty()
38+
? Status.UNAVAILABLE
39+
: Status.valueOf(summaryView.status().name());
40+
return new ArticleAiSummaryResponse(status, items);
41+
}
42+
43+
public enum Status {
44+
SUCCESS,
45+
PENDING,
46+
UNAVAILABLE
47+
}
48+
49+
@JsonNaming(SnakeCaseStrategy.class)
50+
public record InnerArticleAiSummaryItemResponse(
51+
52+
@Schema(description = "요약 항목 아이콘", example = "📅", requiredMode = REQUIRED)
53+
String icon,
54+
55+
@Schema(description = "요약 내용", example = "신청은 5월 20일까지 접수됩니다.", requiredMode = REQUIRED)
56+
String text
57+
) {
58+
59+
private static Optional<InnerArticleAiSummaryItemResponse> from(String summaryLine) {
60+
if (summaryLine == null || summaryLine.isBlank()) {
61+
return Optional.empty();
62+
}
63+
return Arrays.stream(ArticleSummaryIcon.values())
64+
.filter(icon -> summaryLine.startsWith(icon.getEmoji() + " "))
65+
.findFirst()
66+
.map(icon -> new InnerArticleAiSummaryItemResponse(
67+
icon.getEmoji(),
68+
summaryLine.substring((icon.getEmoji() + " ").length()).trim()
69+
))
70+
.filter(item -> !item.text().isBlank());
71+
}
72+
}
73+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package in.koreatech.koin.domain.community.article.dto;
2+
3+
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
4+
5+
import java.time.LocalDate;
6+
import java.time.LocalDateTime;
7+
import java.util.List;
8+
9+
import com.fasterxml.jackson.annotation.JsonFormat;
10+
import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
11+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
12+
13+
import in.koreatech.koin.domain.community.article.model.Article;
14+
import in.koreatech.koin.domain.community.article.model.ArticleAttachment;
15+
import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView;
16+
import io.swagger.v3.oas.annotations.media.Schema;
17+
18+
@JsonNaming(SnakeCaseStrategy.class)
19+
public record ArticleResponseV2(
20+
21+
@Schema(description = "게시글 고유 ID", example = "2", requiredMode = REQUIRED)
22+
Integer id,
23+
24+
@Schema(description = "게시판 고유 ID", example = "4", requiredMode = REQUIRED)
25+
Integer boardId,
26+
27+
@Schema(description = "제목", example = "제목", requiredMode = REQUIRED)
28+
String title,
29+
30+
@Schema(description = "게시글 원문", example = "내용", requiredMode = REQUIRED)
31+
String content,
32+
33+
@Schema(description = "AI 요약", requiredMode = REQUIRED)
34+
ArticleAiSummaryResponse aiSummary,
35+
36+
@Schema(description = "작성자", example = "닉네임", requiredMode = REQUIRED)
37+
String author,
38+
39+
@Schema(description = "조회수", example = "1", requiredMode = REQUIRED)
40+
Integer hit,
41+
42+
@Schema(description = "공지 원본 url", example = "https://portal.koreatech.ac.kr/ctt/bb/bulletin?b=14&ls=20&ln=1&dm=r&p=33248")
43+
String url,
44+
45+
@Schema(description = "첨부 파일")
46+
List<InnerArticleAttachmentResponse> attachments,
47+
48+
@Schema(description = "이전 게시글 ID", example = "1")
49+
Integer prevId,
50+
51+
@Schema(description = "다음 게시글 ID", example = "3")
52+
Integer nextId,
53+
54+
@Schema(description = "등록 일자", example = "2024-08-28", requiredMode = REQUIRED)
55+
@JsonFormat(pattern = "yyyy-MM-dd") LocalDate registeredAt,
56+
57+
@Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED)
58+
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt
59+
) {
60+
61+
public static ArticleResponseV2 from(Article article, String content, ArticleSummaryView summaryView) {
62+
return new ArticleResponseV2(
63+
article.getId(),
64+
article.getBoard().getId(),
65+
article.getTitle(),
66+
content,
67+
ArticleAiSummaryResponse.from(summaryView),
68+
article.getAuthor(),
69+
article.getTotalHit(),
70+
article.getUrl(),
71+
article.getAttachments().stream()
72+
.map(InnerArticleAttachmentResponse::from)
73+
.toList(),
74+
article.getPrevId(),
75+
article.getNextId(),
76+
article.getRegisteredAt(),
77+
article.getUpdatedAt()
78+
);
79+
}
80+
81+
@JsonNaming(SnakeCaseStrategy.class)
82+
private record InnerArticleAttachmentResponse(
83+
84+
@Schema(description = "파일 고유 ID", example = "1", requiredMode = REQUIRED)
85+
Integer id,
86+
87+
@Schema(description = "파일 이름", example = "이미지.png", requiredMode = REQUIRED)
88+
String name,
89+
90+
@Schema(description = "파일 url", requiredMode = REQUIRED)
91+
String url,
92+
93+
@Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED)
94+
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt,
95+
96+
@Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED)
97+
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt
98+
) {
99+
100+
private static InnerArticleAttachmentResponse from(ArticleAttachment attachment) {
101+
return new InnerArticleAttachmentResponse(
102+
attachment.getId(),
103+
attachment.getName(),
104+
attachment.getUrl(),
105+
attachment.getCreatedAt(),
106+
attachment.getUpdatedAt()
107+
);
108+
}
109+
}
110+
}

src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import in.koreatech.koin.common.model.Criteria;
2020
import in.koreatech.koin.domain.community.article.dto.ArticleHotKeywordResponse;
2121
import in.koreatech.koin.domain.community.article.dto.ArticleResponse;
22+
import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2;
2223
import in.koreatech.koin.domain.community.article.dto.ArticlesResponse;
2324
import in.koreatech.koin.domain.community.article.dto.HotArticleItemResponse;
2425
import in.koreatech.koin.domain.community.article.exception.ArticleBoardMisMatchException;
@@ -32,6 +33,7 @@
3233
import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitUserRepository;
3334
import in.koreatech.koin.domain.community.article.repository.redis.HotArticleRepository;
3435
import in.koreatech.koin.domain.community.article.service.summary.ArticleAiSummaryService;
36+
import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView;
3537
import in.koreatech.koin.global.exception.custom.KoinIllegalArgumentException;
3638
import in.koreatech.koin.infrastructure.s3.client.S3Client;
3739
import lombok.RequiredArgsConstructor;
@@ -65,6 +67,25 @@ public class ArticleService {
6567

6668
@Transactional
6769
public ArticleResponse getArticle(Integer boardId, Integer articleId, String publicIp) {
70+
ArticleDetail articleDetail = getArticleDetail(boardId, articleId, publicIp);
71+
String contentWithSummary = articleAiSummaryService.prependSummaryIfReady(
72+
articleDetail.article(),
73+
articleDetail.content()
74+
);
75+
return ArticleResponse.from(articleDetail.article(), contentWithSummary);
76+
}
77+
78+
@Transactional
79+
public ArticleResponseV2 getArticleV2(Integer boardId, Integer articleId, String publicIp) {
80+
ArticleDetail articleDetail = getArticleDetail(boardId, articleId, publicIp);
81+
ArticleSummaryView summaryView = articleAiSummaryService.getSummary(
82+
articleDetail.article(),
83+
articleDetail.content()
84+
);
85+
return ArticleResponseV2.from(articleDetail.article(), articleDetail.content(), summaryView);
86+
}
87+
88+
private ArticleDetail getArticleDetail(Integer boardId, Integer articleId, String publicIp) {
6889
Article article = articleRepository.getById(articleId);
6990
String content = article.getContent();
7091
String contentUrl = content == null ? null : content.trim();
@@ -76,8 +97,7 @@ public ArticleResponse getArticle(Integer boardId, Integer articleId, String pub
7697
articleHitUserRepository.save(ArticleHitUser.of(articleId, publicIp));
7798
}
7899
setPrevNextArticle(boardId, article);
79-
String contentWithSummary = articleAiSummaryService.prependSummaryIfReady(article, content);
80-
return ArticleResponse.from(article, contentWithSummary);
100+
return new ArticleDetail(article, content);
81101
}
82102

83103
public ArticlesResponse getArticles(Integer boardId, Integer page, Integer limit, Integer userId) {
@@ -213,4 +233,10 @@ private Board getBoard(Integer boardId, Article article) {
213233
}
214234
return boardRepository.getById(boardId);
215235
}
236+
237+
private record ArticleDetail(
238+
Article article,
239+
String content
240+
) {
241+
}
216242
}

src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import in.koreatech.koin.domain.community.article.model.ArticleAiSummary;
2323
import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLog;
2424
import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLogType;
25+
import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryStatus;
2526
import in.koreatech.koin.domain.community.article.repository.ArticleAiSummaryLogRepository;
2627
import in.koreatech.koin.domain.community.article.repository.ArticleAiSummaryRepository;
2728
import in.koreatech.koin.domain.community.article.repository.ArticleRepository;
@@ -49,29 +50,45 @@ public class ArticleAiSummaryService {
4950

5051
@Transactional
5152
public String prependSummaryIfReady(Article article, String content) {
52-
if (article.getBoard().getId().equals(LOST_ITEM_BOARD_ID)) {
53+
ArticleSummaryView summaryView = resolveSummary(article, content);
54+
if (!summaryView.isSuccess()) {
5355
return content;
5456
}
57+
return contentRenderer.prependSummary(content, summaryView.summaryLines());
58+
}
59+
60+
@Transactional
61+
public ArticleSummaryView getSummary(Article article, String content) {
62+
return resolveSummary(article, content);
63+
}
64+
65+
private ArticleSummaryView resolveSummary(Article article, String content) {
66+
if (article.getBoard().getId().equals(LOST_ITEM_BOARD_ID)) {
67+
return ArticleSummaryView.unavailable();
68+
}
5569
ArticleSummarySourceSeed seed = ArticleSummarySourceSeed.from(article, content);
5670
String fingerprint = sourceReader.createFingerprint(seed);
5771

5872
Optional<ArticleAiSummary> optionalSummary = articleAiSummaryRepository.findByArticleId(article.getId());
5973
if (optionalSummary.isEmpty()) {
6074
enqueueIfEnabled(article, fingerprint, article.getUpdatedAt());
61-
return content;
75+
return canGenerate() ? ArticleSummaryView.pending() : ArticleSummaryView.unavailable();
6276
}
6377

6478
ArticleAiSummary summary = optionalSummary.get();
6579
if (summary.hasSummaryForSource(fingerprint)) {
6680
if (canGenerate() && !summary.isProcessing() && isStale(summary, fingerprint)) {
6781
summary.prepareWait(fingerprint, article.getUpdatedAt(), properties.getModel(), properties.getPromptVersion());
6882
}
69-
return contentRenderer.prependSummary(content, summary.getSummaryLines());
83+
return ArticleSummaryView.success(summary.getSummaryLines());
7084
}
7185
if (canGenerate() && !summary.isProcessing() && isStale(summary, fingerprint)) {
7286
summary.prepareWait(fingerprint, article.getUpdatedAt(), properties.getModel(), properties.getPromptVersion());
7387
}
74-
return content;
88+
if (canGenerate() && (summary.getStatus() == ArticleAiSummaryStatus.WAIT || summary.isProcessing())) {
89+
return ArticleSummaryView.pending();
90+
}
91+
return ArticleSummaryView.unavailable();
7592
}
7693

7794
@Transactional

0 commit comments

Comments
 (0)