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); + } +}