From d451aff8cc57a3ce1adeeb48df95a1e94978d0d9 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 3 Aug 2026 11:01:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EB=8B=B5=EC=9E=A5?= =?UTF-8?q?=20=EC=B4=88=EC=95=88=20=E2=80=94=20=EB=AF=B8=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EB=8C=93=EA=B8=80=20=EC=88=98=EC=A7=91=20+=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20=EB=82=B4=EC=9A=A9=20=EA=B8=B0=EB=B0=98=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=202=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 영상에 달린 댓글 중 내가 답 안 한 것만 모아, 영상 내용까지 근거로 답글 초안을 만든다. 조회는 나오는데 구독이 안 붙는 문제(59영상 36만 조회 구독 38)에 직접 닿는 지점이라 여기부터 손댔다. - 미응답 판정은 totalReplyCount 가 아니라 답글 작성자에 내 채널이 있는지로 본다. 남이 대댓글만 단 댓글은 여전히 내가 답해야 하기 때문이다. - 수집 중 내가 단 답글을 발견하면 말투 샘플로 모아둔다. 실측 17개가 학습됐고 초안이 그 톤을 따른다. 8자 미만("ㅋㅋㅋ")은 말투 정보가 없어 제외. - 영상 맥락은 Gemini 에 유튜브 URL 을 직접 준다. 내 영상은 30초 쇼츠라 토큰이 적고 화면에 박힌 자막까지 읽힌다. 영상당 1회만 만들어 캐시한다. - 실제 게시는 OAuth 가 없어 불가 — 초안 복사까지가 끝이다. 실측에서 잡은 결함 두 개를 고쳤다. 1) 요약 생성 때 제목을 안 넘겨서 Gemini 가 출연자 이름을 추측했다. "이채영" 영상을 "이채연"으로 요약했고 그 오류가 답글까지 전파됐다. 제목을 함께 주고 "제목 표기를 글자 그대로" 규칙을 넣어 해결. 캐시가 오염된 경우를 위해 refreshContext 옵션을 두고, '다시 생성'은 요약도 다시 읽게 했다. 2) 영상 요약이 없는데도 규칙에 "[영상 내용]을 근거로"가 남아 없는 섹션을 가리켰다. 요약 유무에 따라 규칙을 갈랐다(테스트가 잡음). 쿼터: 영상당 1 unit — 59개 수집에 59 units. 테스트 11건 추가(미응답 판정·말투 샘플·프롬프트 조립·응답 파싱) — 총 106건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../yanalyst/domain/channel/ChannelVideo.java | 12 + .../domain/reply/CommentReplyController.java | 63 +++ .../domain/reply/CommentReplyService.java | 304 ++++++++++++++ .../yanalyst/domain/reply/ReplyDrafting.java | 147 +++++++ .../domain/reply/ReplyStyleSample.java | 40 ++ .../reply/ReplyStyleSampleRepository.java | 13 + .../yanalyst/domain/reply/VideoComment.java | 116 ++++++ .../domain/reply/VideoCommentRepository.java | 24 ++ .../domain/reply/dto/ReplyCommentDto.java | 30 ++ .../com/hlab/yanalyst/web/WebController.java | 6 + .../resources/templates/layout/sidebar.html | 3 + src/main/resources/templates/reply.html | 392 ++++++++++++++++++ .../domain/reply/ReplyDraftingTest.java | 125 ++++++ 13 files changed, 1275 insertions(+) create mode 100644 src/main/java/com/hlab/yanalyst/domain/reply/CommentReplyController.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/reply/CommentReplyService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/reply/ReplyDrafting.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/reply/ReplyStyleSample.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/reply/ReplyStyleSampleRepository.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/reply/VideoComment.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/reply/VideoCommentRepository.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/reply/dto/ReplyCommentDto.java create mode 100644 src/main/resources/templates/reply.html create mode 100644 src/test/java/com/hlab/yanalyst/domain/reply/ReplyDraftingTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java index 0ddec2c..5458b8c 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java @@ -114,6 +114,13 @@ public class ChannelVideo { @Column(name = "has_script") private Boolean hasScript = false; + /** + * 영상 내용 요약(Gemini가 영상을 직접 보고 만든 것). 댓글 답글 초안의 근거로 쓴다. + * 영상당 1회만 만들고 재사용한다 — 같은 영상 댓글마다 다시 분석하면 낭비다. + */ + @Column(name = "context_summary", columnDefinition = "TEXT") + private String contextSummary; + @com.fasterxml.jackson.annotation.JsonIgnore @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "channel_id") @@ -172,6 +179,11 @@ public class ChannelVideo { this.hashtags = hashtags; } + /** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */ + public void applyContextSummary(String contextSummary) { + this.contextSummary = contextSummary; + } + /** 인물 추적으로 걸린 인물명을 기록한다. */ public void applyMatchedPerson(String matchedPerson) { this.matchedPerson = matchedPerson; diff --git a/src/main/java/com/hlab/yanalyst/domain/reply/CommentReplyController.java b/src/main/java/com/hlab/yanalyst/domain/reply/CommentReplyController.java new file mode 100644 index 0000000..ea4e53a --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/reply/CommentReplyController.java @@ -0,0 +1,63 @@ +package com.hlab.yanalyst.domain.reply; + +import com.hlab.yanalyst.domain.reply.dto.ReplyCommentDto; +import com.hlab.yanalyst.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** 내 채널 댓글 답장 초안 API. 실제 게시는 OAuth 가 없어 불가 — 초안까지만. */ +@RestController +@RequestMapping("/api/replies") +@RequiredArgsConstructor +@Tag(name = "Comment Reply API", description = "내 채널 미응답 댓글 수집 + 답글 초안 생성") +public class CommentReplyController { + + private final CommentReplyService service; + + @GetMapping + @Operation(summary = "답장 대기 목록", description = "status 미지정이면 완료 제외 전체. 좋아요 많은 순.") + public ApiResponse> queue(@RequestParam(required = false) String status) { + return ApiResponse.ok(service.queue(status)); + } + + @GetMapping("/stats") + @Operation(summary = "현황", description = "미응답/초안있음/완료 건수와 학습된 말투 샘플 수") + public ApiResponse> stats() { + return ApiResponse.ok(service.stats()); + } + + @PostMapping("/collect") + @Operation(summary = "댓글 수집", + description = "내 채널 영상 전체를 돌며 내가 답글 안 단 댓글을 모은다. 영상당 1 unit.") + public ApiResponse> collect() { + return ApiResponse.ok(service.collectAll()); + } + + @PostMapping("/{id}/draft") + @Operation(summary = "초안 생성", + description = "영상 내용 + 댓글을 근거로 서로 다른 초안 2개 생성. " + + "refreshContext=true 면 캐시된 영상 요약을 버리고 다시 만든다(요약이 틀렸을 때).") + public ApiResponse draft(@PathVariable Long id, + @RequestParam(defaultValue = "false") boolean refreshContext) { + return ApiResponse.ok(service.generateDrafts(id, refreshContext)); + } + + @PostMapping("/{id}/done") + @Operation(summary = "완료 표시", description = "실제로 답글을 달았을 때 목록에서 내린다") + public ApiResponse done(@PathVariable Long id) { + service.markDone(id); + return ApiResponse.ok(null); + } + + @PostMapping("/{id}/undo") + @Operation(summary = "완료 취소", description = "다시 미응답으로 되돌린다") + public ApiResponse undo(@PathVariable Long id) { + service.markPending(id); + return ApiResponse.ok(null); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/reply/CommentReplyService.java b/src/main/java/com/hlab/yanalyst/domain/reply/CommentReplyService.java new file mode 100644 index 0000000..9e9aafe --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/reply/CommentReplyService.java @@ -0,0 +1,304 @@ +package com.hlab.yanalyst.domain.reply; + +import com.fasterxml.jackson.databind.JsonNode; +import com.hlab.yanalyst.domain.channel.Channel; +import com.hlab.yanalyst.domain.channel.ChannelRepository; +import com.hlab.yanalyst.domain.channel.ChannelVideo; +import com.hlab.yanalyst.domain.channel.ChannelVideoRepository; +import com.hlab.yanalyst.domain.reply.dto.ReplyCommentDto; +import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 내 채널 댓글을 모아 답글 초안을 만든다. + * + *

실제 답글 등록은 OAuth 가 필요해 불가능하다. 여기서는 초안까지만 만들고 복사해서 붙여넣는다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CommentReplyService { + + /** commentThreads.list 1회 추정 쿼터. */ + private static final long COMMENT_QUOTA = 1; + + private final ChannelRepository channelRepository; + private final ChannelVideoRepository channelVideoRepository; + private final VideoCommentRepository commentRepository; + private final ReplyStyleSampleRepository styleRepository; + private final YoutubeQuotaGuard quotaGuard; + private final RestTemplate restTemplate; + + @Value("${youtube.api.key}") + private String youtubeApiKey; + + @Value("${gemini.api-key:}") + private String geminiApiKey; + + @Value("${gemini.model:gemini-2.5-flash}") + private String geminiModel; + + // ---------- 수집 ---------- + + /** 내 채널(OWN) 영상 전체를 돌며 내가 답글 안 단 댓글을 모은다. */ + public Map collectAll() { + List own = channelRepository.findOwnChannels(); + if (own.isEmpty()) { + throw new IllegalStateException("내 채널이 등록돼 있지 않습니다. '내 채널' 화면에서 먼저 등록하세요."); + } + Channel me = own.get(0); + List videos = channelVideoRepository.findByChannelId(me.getId()); + if (videos.isEmpty()) { + throw new IllegalStateException("수집된 내 영상이 없습니다. '내 채널' 화면에서 동기화를 먼저 하세요."); + } + + int scanned = 0, saved = 0, alreadyReplied = 0, styleLearned = 0, skippedByQuota = 0, failed = 0; + + for (ChannelVideo v : videos) { + if (!quotaGuard.tryConsume(COMMENT_QUOTA)) { + skippedByQuota++; + continue; + } + try { + Counts c = collectForVideo(me.getChannelId(), v); + scanned += c.scanned; + saved += c.saved; + alreadyReplied += c.alreadyReplied; + styleLearned += c.styleLearned; + } catch (Exception e) { + failed++; + log.warn("[Reply] 영상 {} 댓글 수집 실패 — 건너뜀: {}", v.getVideoId(), e.getMessage()); + } + } + + Map summary = new LinkedHashMap<>(); + summary.put("videos", videos.size()); + summary.put("scannedComments", scanned); + summary.put("pending", saved); + summary.put("alreadyReplied", alreadyReplied); + summary.put("styleLearned", styleLearned); + summary.put("failed", failed); + summary.put("skippedByQuota", skippedByQuota); + summary.put("quotaRemaining", quotaGuard.remaining()); + log.info("[Reply] 댓글 수집 완료: {}", summary); + return summary; + } + + private record Counts(int scanned, int saved, int alreadyReplied, int styleLearned) {} + + /** 같은 빈에서 호출되므로 @Transactional 프록시가 안 걸린다 — 저장은 모두 명시적으로 한다. */ + private Counts collectForVideo(String myChannelId, ChannelVideo video) { + URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/commentThreads") + .queryParam("part", "snippet,replies") // replies 가 있어야 내가 답했는지 안다 + .queryParam("videoId", video.getVideoId()) + .queryParam("maxResults", 100) + .queryParam("order", "relevance") + .queryParam("textFormat", "plainText") + .queryParam("key", youtubeApiKey) + .build().encode().toUri(); + + JsonNode root = restTemplate.getForObject(uri, JsonNode.class); + if (root == null) return new Counts(0, 0, 0, 0); + + int scanned = 0, saved = 0, replied = 0, learned = 0; + + for (JsonNode thread : root.path("items")) { + scanned++; + JsonNode top = thread.path("snippet").path("topLevelComment"); + JsonNode s = top.path("snippet"); + + // 답글 작성자 수집 + 내 답글이면 말투 샘플로 학습 + List replyAuthors = new ArrayList<>(); + for (JsonNode r : thread.path("replies").path("comments")) { + JsonNode rs = r.path("snippet"); + String authorId = rs.path("authorChannelId").path("value").asText(null); + replyAuthors.add(authorId); + if (myChannelId.equals(authorId)) { + String replyId = r.path("id").asText(null); + String text = rs.path("textOriginal").asText(""); + if (replyId != null && !text.isBlank() && !styleRepository.existsByReplyId(replyId)) { + styleRepository.save(new ReplyStyleSample(replyId, text)); + learned++; + } + } + } + + if (ReplyDrafting.repliedByMe(replyAuthors, myChannelId)) { + replied++; + continue; // 이미 답한 건 대기열에 넣지 않는다 + } + + // 내가 쓴 최상위 댓글(고정댓글 등)은 답장 대상이 아니다 + if (myChannelId.equals(s.path("authorChannelId").path("value").asText(null))) continue; + + String commentId = top.path("id").asText(null); + if (commentId == null || commentId.isBlank()) continue; + + String text = s.path("textOriginal").asText(""); + Long likes = s.path("likeCount").asLong(0); + Integer replyCount = thread.path("snippet").path("totalReplyCount").asInt(0); + LocalDateTime published = parseTime(s.path("publishedAt").asText(null)); + + final int[] delta = {0}; + commentRepository.findByCommentId(commentId) + .ifPresentOrElse(existing -> { + existing.refresh(likes, replyCount); + commentRepository.save(existing); + }, + () -> { + commentRepository.save(new VideoComment( + commentId, video.getVideoId(), video.getTitle(), video.getThumbnailUrl(), + s.path("authorDisplayName").asText(""), + s.path("authorProfileImageUrl").asText(null), + text, likes, published, replyCount)); + delta[0] = 1; + }); + saved += delta[0]; + } + return new Counts(scanned, saved, replied, learned); + } + + private LocalDateTime parseTime(String iso) { + if (iso == null || iso.isBlank()) return null; + try { + return LocalDateTime.parse(iso, DateTimeFormatter.ISO_DATE_TIME); + } catch (Exception e) { + return null; + } + } + + // ---------- 조회 ---------- + + @Transactional(readOnly = true) + public List queue(String status) { + String filter = (status == null || status.isBlank()) ? null : status.trim().toUpperCase(); + List out = new ArrayList<>(); + for (VideoComment c : commentRepository.findQueue(filter)) out.add(ReplyCommentDto.from(c)); + return out; + } + + @Transactional(readOnly = true) + public Map stats() { + Map m = new LinkedHashMap<>(); + m.put("pending", commentRepository.countByStatus(VideoComment.NEW)); + m.put("drafted", commentRepository.countByStatus(VideoComment.DRAFTED)); + m.put("done", commentRepository.countByStatus(VideoComment.DONE)); + m.put("styleSamples", styleRepository.count()); + return m; + } + + // ---------- 초안 생성 ---------- + + /** + * 댓글 1건의 초안 2개를 만든다. 영상 내용 요약이 없으면 먼저 만들어 캐시한다. + * + * @param refreshContext 캐시된 요약이 틀렸을 때 다시 만들게 한다 + */ + @Transactional + public ReplyCommentDto generateDrafts(Long id, boolean refreshContext) { + if (geminiApiKey == null || geminiApiKey.isBlank()) { + throw new IllegalStateException("Gemini API 키가 없습니다. gemini.api-key 를 설정하세요."); + } + VideoComment c = commentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("댓글을 찾을 수 없습니다: " + id)); + + String context = videoContext(c.getVideoId(), c.getVideoTitle(), refreshContext); + List samples = ReplyDrafting.pickStyleSamples( + styleRepository.findAllByOrderByIdDesc(PageRequest.of(0, ReplyDrafting.MAX_STYLE_SAMPLES)) + .stream().map(ReplyStyleSample::getText).toList()); + + String prompt = ReplyDrafting.buildPrompt(c.getVideoTitle(), context, c.getText(), samples); + ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts(callGemini(prompt, null)); + c.applyDrafts(d.a(), d.b()); + commentRepository.save(c); + return ReplyCommentDto.from(c); + } + + /** + * 영상 내용 요약. 없으면 Gemini에 유튜브 URL을 직접 줘서 만들고 저장한다. + * 님 영상은 30초 쇼츠라 토큰이 적고, 화면에 박힌 자막까지 읽힌다. + */ + private String videoContext(String videoId, String videoTitle, boolean force) { + ChannelVideo v = channelVideoRepository.findByVideoId(videoId).orElse(null); + if (v == null) return null; + if (!force && v.getContextSummary() != null && !v.getContextSummary().isBlank()) { + return v.getContextSummary(); + } + + try { + // 제목을 반드시 함께 준다. 제목 없이 영상만 보여주면 출연자 이름을 추측해 틀린다 + // (실측: "이채영" 영상을 "이채연"으로 요약했고, 그 오류가 답글까지 전파됐다). + String prompt = "이 영상의 내용을 한국어로 요약하세요.\n" + + "영상 제목은 \"" + (videoTitle == null ? "" : videoTitle) + "\" 입니다.\n" + + "누가 나오는지, 무슨 상황인지, 화면에 박힌 자막의 핵심 대사를 포함하세요.\n" + + "사람 이름은 제목에 적힌 표기를 그대로 쓰고, 제목에 없으면 화면 자막에 적힌 표기를 그대로 쓰세요. " + + "비슷한 이름으로 바꾸거나 추측해서 쓰지 마세요.\n" + + "댓글에 답할 때 근거로 쓸 것이므로 사실만 적고 추측은 적지 마세요. 5문장 이내."; + String summary = callGemini(prompt, "https://www.youtube.com/watch?v=" + videoId); + if (summary != null && !summary.isBlank()) { + v.applyContextSummary(summary.trim()); + channelVideoRepository.save(v); + return summary.trim(); + } + } catch (Exception e) { + // 요약 실패해도 제목만으로 답글은 쓸 수 있다 — 초안 생성 자체를 막지 않는다 + log.warn("[Reply] 영상 {} 요약 실패(제목만으로 진행): {}", videoId, e.getMessage()); + } + return null; + } + + /** @param youtubeUrl 영상을 함께 보낼 때만 지정. null 이면 텍스트만 보낸다. */ + private String callGemini(String prompt, String youtubeUrl) { + String url = "https://generativelanguage.googleapis.com/v1beta/models/" + + geminiModel + ":generateContent?key=" + geminiApiKey; + + List> parts = new ArrayList<>(); + parts.add(Map.of("text", prompt)); + if (youtubeUrl != null) parts.add(Map.of("file_data", Map.of("file_uri", youtubeUrl))); + + Map body = Map.of("contents", List.of(Map.of("parts", parts))); + JsonNode res = restTemplate.postForObject(url, body, JsonNode.class); + if (res == null) throw new IllegalStateException("Gemini 응답이 비어있습니다."); + if (res.has("error")) { + throw new IllegalStateException("Gemini 오류: " + res.path("error").path("message").asText("")); + } + StringBuilder sb = new StringBuilder(); + for (JsonNode p : res.path("candidates").path(0).path("content").path("parts")) { + sb.append(p.path("text").asText("")); + } + return sb.toString(); + } + + // ---------- 상태 ---------- + + @Transactional + public void markDone(Long id) { + VideoComment c = commentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("댓글을 찾을 수 없습니다: " + id)); + c.markDone(); + commentRepository.save(c); + } + + @Transactional + public void markPending(Long id) { + VideoComment c = commentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("댓글을 찾을 수 없습니다: " + id)); + c.markNew(); + commentRepository.save(c); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/reply/ReplyDrafting.java b/src/main/java/com/hlab/yanalyst/domain/reply/ReplyDrafting.java new file mode 100644 index 0000000..b84e308 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/reply/ReplyDrafting.java @@ -0,0 +1,147 @@ +package com.hlab.yanalyst.domain.reply; + +import java.util.ArrayList; +import java.util.List; + +/** + * 답글 초안 생성의 순수 로직 — 미응답 판정, 말투 샘플 정리, 프롬프트 조립, 응답 파싱. + * + *

네트워크·DB에 의존하지 않아 그대로 테스트한다. + */ +public final class ReplyDrafting { + + /** 말투 학습에 쓸 과거 답글 최대 개수. 너무 많으면 프롬프트만 길어진다. */ + public static final int MAX_STYLE_SAMPLES = 5; + + private ReplyDrafting() {} + + /** + * 내가 이미 답글을 달았는가. + * + *

totalReplyCount 로 판정하면 안 된다 — 남이 대댓글만 단 댓글은 여전히 미응답이다. + * + * @param replyAuthorChannelIds 이 댓글에 달린 답글 작성자들의 채널 ID + * @param myChannelId 내 채널 ID + */ + public static boolean repliedByMe(List replyAuthorChannelIds, String myChannelId) { + if (myChannelId == null || myChannelId.isBlank()) return false; + if (replyAuthorChannelIds == null) return false; + for (String id : replyAuthorChannelIds) { + if (myChannelId.equals(id)) return true; + } + return false; + } + + /** + * 말투 학습용 샘플을 고른다. 너무 짧은 것(ㅋㅋ, 감사합니다)은 말투 정보가 없어 뺀다. + * + * @param myReplies 과거에 내가 단 답글들 + */ + public static List pickStyleSamples(List myReplies) { + List out = new ArrayList<>(); + if (myReplies == null) return out; + for (String r : myReplies) { + if (r == null) continue; + String t = r.trim(); + if (t.length() < 8) continue; // "ㅋㅋㅋ", "감사합니다" 류 제외 + out.add(t); + if (out.size() >= MAX_STYLE_SAMPLES) break; + } + return out; + } + + /** + * Gemini 프롬프트를 조립한다. + * + * @param videoTitle 댓글이 달린 영상 제목 + * @param videoContext 영상 내용 요약(자막·화면자막 기반). 없으면 null + * @param comment 댓글 본문 + * @param styleSamples 내 과거 답글(말투 학습). 비어 있으면 기본 톤 지시를 쓴다 + */ + public static String buildPrompt(String videoTitle, String videoContext, String comment, + List styleSamples) { + StringBuilder sb = new StringBuilder(); + sb.append("너는 예능·드라마 명장면을 잘라 올리는 유튜브 쇼츠 채널의 운영자다. ") + .append("아래 내 영상에 달린 댓글에 답글을 쓴다.\n\n"); + + boolean hasContext = videoContext != null && !videoContext.isBlank(); + + sb.append("[영상 제목]\n").append(nz(videoTitle)).append("\n\n"); + if (hasContext) { + sb.append("[영상 내용]\n").append(videoContext.trim()).append("\n\n"); + } + sb.append("[댓글]\n").append(nz(comment)).append("\n\n"); + + if (styleSamples != null && !styleSamples.isEmpty()) { + sb.append("[내가 예전에 쓴 답글 — 이 말투를 그대로 따라라]\n"); + for (String s : styleSamples) sb.append("- ").append(s).append("\n"); + sb.append("\n"); + } else { + sb.append("[말투]\n") + .append("- 친근한 반말체는 쓰지 말고, 가볍고 유쾌한 존댓말로 쓴다\n") + .append("- 이모지는 최대 1개, 안 써도 된다\n\n"); + } + + sb.append("[규칙]\n") + .append("- 1~2문장, 60자 이내로 짧게. 긴 답글은 읽히지 않는다\n") + .append("- 댓글 내용에 실제로 반응해라. \"시청 감사합니다\" 같은 복붙 답글은 금지\n"); + // 요약이 없는데 "영상 내용을 근거로" 라고 하면 모델에게 없는 섹션을 가리키게 된다 + if (hasContext) { + sb.append("- 영상 내용을 묻는 댓글이면 위 영상 내용을 근거로 정확히 답한다\n"); + } else { + sb.append("- 영상 안에서 무슨 일이 있었는지는 모른다. 내용을 묻는 댓글이면 단정하지 말고 넘어간다\n"); + } + // 실측에서 "이채영"을 "채연"으로 바꿔 쓰는 사고가 났다. 이름은 창작 대상이 아니다. + sb.append("- 사람 이름은 위에 적힌 표기를 글자 그대로 써라. 비슷한 이름으로 바꾸거나 줄이지 마라\n") + .append("- 위에 이름이 안 나왔으면 이름을 쓰지 말고 지칭을 피해라\n") + .append("- 모르는 사실을 지어내지 마라. 확실하지 않으면 단정하지 않는다\n") + .append("- 시비조·악의적인 댓글이면 맞받지 말고 담백하게 넘긴다\n") + .append("- 서로 다른 결의 답글 2개를 준다\n\n") + .append("[출력 형식] 다른 말 없이 아래 JSON만 출력한다\n") + .append("{\"a\": \"첫 번째 답글\", \"b\": \"두 번째 답글\"}"); + + return sb.toString(); + } + + /** 초안 2개. */ + public record Drafts(String a, String b) {} + + /** + * 모델 응답에서 초안 2개를 뽑는다. 코드펜스·앞뒤 잡소리가 섞여도 JSON 부분만 골라낸다. + * + * @throws IllegalStateException 초안을 하나도 못 뽑았을 때 + */ + public static Drafts parseDrafts(String raw) { + if (raw == null || raw.isBlank()) { + throw new IllegalStateException("모델이 빈 응답을 반환했습니다."); + } + String s = raw.trim(); + int start = s.indexOf('{'); + int end = s.lastIndexOf('}'); + if (start >= 0 && end > start) s = s.substring(start, end + 1); + + String a = extract(s, "a"); + String b = extract(s, "b"); + if (a == null && b == null) { + throw new IllegalStateException("응답에서 초안을 찾지 못했습니다: " + raw.substring(0, Math.min(120, raw.length()))); + } + if (a == null) a = b; + if (b == null) b = a; + return new Drafts(a, b); + } + + /** "key": "value" 에서 value 를 꺼낸다(이스케이프된 따옴표 허용). */ + private static String extract(String json, String key) { + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("\"" + key + "\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"") + .matcher(json); + if (!m.find()) return null; + String v = m.group(1) + .replace("\\n", "\n").replace("\\\"", "\"").replace("\\\\", "\\"); + return v.isBlank() ? null : v.trim(); + } + + private static String nz(String s) { + return s == null ? "" : s.trim(); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/reply/ReplyStyleSample.java b/src/main/java/com/hlab/yanalyst/domain/reply/ReplyStyleSample.java new file mode 100644 index 0000000..6829a07 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/reply/ReplyStyleSample.java @@ -0,0 +1,40 @@ +package com.hlab.yanalyst.domain.reply; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * 내가 예전에 직접 단 답글. 초안의 말투를 여기서 학습한다. + * 댓글 수집 중에 내 채널이 작성한 답글을 발견하면 모아둔다. + */ +@Entity +@Table(name = "reply_style_samples", + indexes = @Index(name = "idx_rss_reply_id", columnList = "reply_id", unique = true)) +@Getter +@NoArgsConstructor +public class ReplyStyleSample { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** YouTube 답글 ID — 중복 저장 방지용. */ + @Column(name = "reply_id", nullable = false, unique = true, length = 120) + private String replyId; + + @Column(columnDefinition = "TEXT") + private String text; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + public ReplyStyleSample(String replyId, String text) { + this.replyId = replyId; + this.text = text; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/reply/ReplyStyleSampleRepository.java b/src/main/java/com/hlab/yanalyst/domain/reply/ReplyStyleSampleRepository.java new file mode 100644 index 0000000..29d2064 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/reply/ReplyStyleSampleRepository.java @@ -0,0 +1,13 @@ +package com.hlab.yanalyst.domain.reply; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReplyStyleSampleRepository extends JpaRepository { + boolean existsByReplyId(String replyId); + + /** 최근에 쓴 답글부터. 말투는 최신 것이 지금 톤에 가깝다. */ + List findAllByOrderByIdDesc(Pageable pageable); +} diff --git a/src/main/java/com/hlab/yanalyst/domain/reply/VideoComment.java b/src/main/java/com/hlab/yanalyst/domain/reply/VideoComment.java new file mode 100644 index 0000000..7b9f500 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/reply/VideoComment.java @@ -0,0 +1,116 @@ +package com.hlab.yanalyst.domain.reply; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * 내 채널 영상에 달린 최상위 댓글 중 내가 아직 답글을 달지 않은 것. + * + *

남이 대댓글을 단 건 여전히 미응답이므로, totalReplyCount 가 아니라 + * 답글 작성자에 내 채널이 있는지로 판정한다({@link ReplyDrafting#repliedByMe}). + */ +@Entity +@Table(name = "video_comments", indexes = { + @Index(name = "idx_vc_comment_id", columnList = "comment_id", unique = true), + @Index(name = "idx_vc_status", columnList = "status"), + @Index(name = "idx_vc_video_id", columnList = "video_id") +}) +@Getter +@NoArgsConstructor +public class VideoComment { + + /** 미응답 / 초안있음 / 완료. */ + public static final String NEW = "NEW"; + public static final String DRAFTED = "DRAFTED"; + public static final String DONE = "DONE"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "comment_id", nullable = false, unique = true, length = 120) + private String commentId; + + /** 댓글이 달린 YouTube 영상 ID. */ + @Column(name = "video_id", nullable = false, length = 40) + private String videoId; + + @Column(name = "video_title", length = 500) + private String videoTitle; + + @Column(name = "video_thumbnail_url", length = 2083) + private String videoThumbnailUrl; + + @Column(name = "author_name") + private String authorName; + + @Column(name = "author_image_url", length = 2083) + private String authorImageUrl; + + @Column(columnDefinition = "TEXT") + private String text; + + @Column(name = "like_count") + private Long likeCount; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + /** 다른 사람이 단 대댓글 수(참고용). 내 답글 여부와는 별개다. */ + @Column(name = "reply_count") + private Integer replyCount; + + @Column(nullable = false, length = 20) + private String status = NEW; + + @Column(name = "draft_a", columnDefinition = "TEXT") + private String draftA; + + @Column(name = "draft_b", columnDefinition = "TEXT") + private String draftB; + + @CreationTimestamp + @Column(name = "collected_at", updatable = false) + private LocalDateTime collectedAt; + + public VideoComment(String commentId, String videoId, String videoTitle, String videoThumbnailUrl, + String authorName, String authorImageUrl, String text, Long likeCount, + LocalDateTime publishedAt, Integer replyCount) { + this.commentId = commentId; + this.videoId = videoId; + this.videoTitle = videoTitle; + this.videoThumbnailUrl = videoThumbnailUrl; + this.authorName = authorName; + this.authorImageUrl = authorImageUrl; + this.text = text; + this.likeCount = likeCount; + this.publishedAt = publishedAt; + this.replyCount = replyCount; + this.status = NEW; + } + + /** 수집 때마다 바뀔 수 있는 값만 갱신한다(초안·상태는 보존). */ + public void refresh(Long likeCount, Integer replyCount) { + this.likeCount = likeCount; + this.replyCount = replyCount; + } + + public void applyDrafts(String draftA, String draftB) { + this.draftA = draftA; + this.draftB = draftB; + this.status = DRAFTED; + } + + /** 답글을 실제로 달았다고 표시. */ + public void markDone() { + this.status = DONE; + } + + public void markNew() { + this.status = NEW; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/reply/VideoCommentRepository.java b/src/main/java/com/hlab/yanalyst/domain/reply/VideoCommentRepository.java new file mode 100644 index 0000000..4048246 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/reply/VideoCommentRepository.java @@ -0,0 +1,24 @@ +package com.hlab.yanalyst.domain.reply; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface VideoCommentRepository extends JpaRepository { + + Optional findByCommentId(String commentId); + + /** + * 답장 대기 목록. 상태 필터가 null 이면 완료를 제외한 전체. + * 좋아요 많은 순 → 최신순. 반응 큰 댓글부터 답하는 게 효율이 높다. + */ + @Query("select c from VideoComment c where " + + "(:status is null and c.status <> 'DONE' or c.status = :status) " + + "order by c.likeCount desc nulls last, c.publishedAt desc") + List findQueue(@Param("status") String status); + + long countByStatus(String status); +} diff --git a/src/main/java/com/hlab/yanalyst/domain/reply/dto/ReplyCommentDto.java b/src/main/java/com/hlab/yanalyst/domain/reply/dto/ReplyCommentDto.java new file mode 100644 index 0000000..503dc6a --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/reply/dto/ReplyCommentDto.java @@ -0,0 +1,30 @@ +package com.hlab.yanalyst.domain.reply.dto; + +import com.hlab.yanalyst.domain.reply.VideoComment; + +import java.time.LocalDateTime; + +/** 답장 대기 카드 1건. */ +public record ReplyCommentDto( + Long id, + String commentId, + String videoId, + String videoTitle, + String videoThumbnailUrl, + String authorName, + String authorImageUrl, + String text, + Long likeCount, + LocalDateTime publishedAt, + Integer replyCount, + String status, + String draftA, + String draftB +) { + public static ReplyCommentDto from(VideoComment c) { + return new ReplyCommentDto(c.getId(), c.getCommentId(), c.getVideoId(), c.getVideoTitle(), + c.getVideoThumbnailUrl(), c.getAuthorName(), c.getAuthorImageUrl(), c.getText(), + c.getLikeCount(), c.getPublishedAt(), c.getReplyCount(), c.getStatus(), + c.getDraftA(), c.getDraftB()); + } +} diff --git a/src/main/java/com/hlab/yanalyst/web/WebController.java b/src/main/java/com/hlab/yanalyst/web/WebController.java index 2b7a8fe..f8ce7cf 100644 --- a/src/main/java/com/hlab/yanalyst/web/WebController.java +++ b/src/main/java/com/hlab/yanalyst/web/WebController.java @@ -77,6 +77,12 @@ public class WebController { return "rework"; } + @GetMapping("/reply") + public String reply(Model model) { + model.addAttribute("currentPage", "reply"); + return "reply"; + } + @GetMapping("/comment-cards") public String commentCards(Model model) { model.addAttribute("currentPage", "comment-cards"); diff --git a/src/main/resources/templates/layout/sidebar.html b/src/main/resources/templates/layout/sidebar.html index bf38929..5e6f972 100644 --- a/src/main/resources/templates/layout/sidebar.html +++ b/src/main/resources/templates/layout/sidebar.html @@ -54,6 +54,9 @@ 프로덕션 + + 댓글 답장 + 댓글 카드 diff --git a/src/main/resources/templates/reply.html b/src/main/resources/templates/reply.html new file mode 100644 index 0000000..b8a9d57 --- /dev/null +++ b/src/main/resources/templates/reply.html @@ -0,0 +1,392 @@ + + + + + h-lab - 댓글 답장 + + + +

+ + + +
+
+
+ + +
+
+ +
+ + +
+ + + + +
+ + + diff --git a/src/test/java/com/hlab/yanalyst/domain/reply/ReplyDraftingTest.java b/src/test/java/com/hlab/yanalyst/domain/reply/ReplyDraftingTest.java new file mode 100644 index 0000000..6334a13 --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/reply/ReplyDraftingTest.java @@ -0,0 +1,125 @@ +package com.hlab.yanalyst.domain.reply; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReplyDraftingTest { + + @Test + void 내가_답글을_달았는지는_작성자로_판정한다() { + // 남이 대댓글만 단 댓글은 여전히 미응답 — totalReplyCount 로 판정하면 안 된다 + assertThat(ReplyDrafting.repliedByMe(List.of("OTHER1", "OTHER2"), "ME")).isFalse(); + assertThat(ReplyDrafting.repliedByMe(List.of("OTHER1", "ME"), "ME")).isTrue(); + assertThat(ReplyDrafting.repliedByMe(List.of(), "ME")).isFalse(); + } + + @Test + void 답글판정_null_방어() { + assertThat(ReplyDrafting.repliedByMe(null, "ME")).isFalse(); + assertThat(ReplyDrafting.repliedByMe(List.of("ME"), null)).isFalse(); + assertThat(ReplyDrafting.repliedByMe(List.of("ME"), " ")).isFalse(); + } + + @Test + void 말투샘플은_짧은_답글을_빼고_최대_5개() { + List replies = new ArrayList<>(List.of( + "ㅋㅋㅋ", // 너무 짧음 + "감사합니다", // 너무 짧음 + "이 장면 진짜 레전드죠 저도 편집하면서 웃었어요", + "다음 편도 곧 올라갑니다 기다려주세요!", + "맞아요 그 부분이 제일 웃겼습니다", + "말씀해주신 장면 찾아서 올려볼게요", + "봐주셔서 감사합니다 다음에 또 뵈어요", + "여섯 번째라 잘려야 하는 답글입니다")); + + List picked = ReplyDrafting.pickStyleSamples(replies); + + assertThat(picked).hasSize(5); + assertThat(picked).doesNotContain("ㅋㅋㅋ", "감사합니다"); + assertThat(picked.get(0)).startsWith("이 장면"); + } + + @Test + void 말투샘플_null과_빈입력() { + assertThat(ReplyDrafting.pickStyleSamples(null)).isEmpty(); + assertThat(ReplyDrafting.pickStyleSamples(List.of())).isEmpty(); + } + + @Test + void 프롬프트에_영상내용과_댓글이_들어간다() { + String p = ReplyDrafting.buildPrompt("윤경호 유퀴즈 명장면", "윤경호가 무당 얘기를 한다", + "이거 몇 화인가요?", List.of()); + + assertThat(p).contains("윤경호 유퀴즈 명장면"); + assertThat(p).contains("윤경호가 무당 얘기를 한다"); + assertThat(p).contains("이거 몇 화인가요?"); + assertThat(p).contains("[말투]"); // 샘플 없으면 기본 톤 지시 + } + + @Test + void 말투샘플이_있으면_기본톤_대신_샘플을_넣는다() { + String p = ReplyDrafting.buildPrompt("제목", null, "댓글", List.of("이 장면 진짜 레전드죠")); + + assertThat(p).contains("이 장면 진짜 레전드죠"); + assertThat(p).doesNotContain("[말투]"); + assertThat(p).doesNotContain("[영상 내용]"); // 요약 없으면 섹션 자체를 빼서 헷갈리지 않게 + } + + @Test + void 이름을_바꿔쓰지_말라는_규칙이_들어간다() { + // 실측에서 "이채영"이 "채연"으로 바뀌어 나왔다 — 이름 오류는 그대로 쓰면 사고다 + String p = ReplyDrafting.buildPrompt("이채영 풀세팅", null, "댓글", List.of()); + + assertThat(p).contains("글자 그대로"); + } + + @Test + void 초안_JSON_파싱() { + ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts("{\"a\": \"첫 번째\", \"b\": \"두 번째\"}"); + + assertThat(d.a()).isEqualTo("첫 번째"); + assertThat(d.b()).isEqualTo("두 번째"); + } + + @Test + void 코드펜스와_잡소리가_섞여도_JSON만_골라낸다() { + String raw = "네 알겠습니다!\n```json\n{\"a\": \"답글 하나\", \"b\": \"답글 둘\"}\n```\n도움이 되었길"; + + ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts(raw); + + assertThat(d.a()).isEqualTo("답글 하나"); + assertThat(d.b()).isEqualTo("답글 둘"); + } + + @Test + void 이스케이프된_따옴표와_줄바꿈_처리() { + ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts( + "{\"a\": \"그 \\\"장면\\\" 맞아요\", \"b\": \"줄\\n바꿈\"}"); + + assertThat(d.a()).isEqualTo("그 \"장면\" 맞아요"); + assertThat(d.b()).isEqualTo("줄\n바꿈"); + } + + @Test + void 하나만_나오면_둘_다_그걸로_채운다() { + ReplyDrafting.Drafts d = ReplyDrafting.parseDrafts("{\"a\": \"하나뿐\"}"); + + assertThat(d.a()).isEqualTo("하나뿐"); + assertThat(d.b()).isEqualTo("하나뿐"); + } + + @Test + void 초안을_못뽑으면_예외() { + assertThatThrownBy(() -> ReplyDrafting.parseDrafts("죄송합니다 답변할 수 없습니다")) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> ReplyDrafting.parseDrafts("")) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> ReplyDrafting.parseDrafts(null)) + .isInstanceOf(IllegalStateException.class); + } +}