feat: 댓글 답장 초안 — 미응답 댓글 수집 + 영상 내용 기반 초안 2개

내 영상에 달린 댓글 중 내가 답 안 한 것만 모아, 영상 내용까지 근거로
답글 초안을 만든다. 조회는 나오는데 구독이 안 붙는 문제(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) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-03 11:01:58 +09:00
parent c0dbd3b23d
commit d451aff8cc
13 changed files with 1275 additions and 0 deletions

View File

@ -114,6 +114,13 @@ public class ChannelVideo {
@Column(name = "has_script") @Column(name = "has_script")
private Boolean hasScript = false; private Boolean hasScript = false;
/**
* 영상 내용 요약(Gemini가 영상을 직접 보고 만든 ). 댓글 답글 초안의 근거로 쓴다.
* 영상당 1회만 만들고 재사용한다 같은 영상 댓글마다 다시 분석하면 낭비다.
*/
@Column(name = "context_summary", columnDefinition = "TEXT")
private String contextSummary;
@com.fasterxml.jackson.annotation.JsonIgnore @com.fasterxml.jackson.annotation.JsonIgnore
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id") @JoinColumn(name = "channel_id")
@ -172,6 +179,11 @@ public class ChannelVideo {
this.hashtags = hashtags; this.hashtags = hashtags;
} }
/** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */
public void applyContextSummary(String contextSummary) {
this.contextSummary = contextSummary;
}
/** 인물 추적으로 걸린 인물명을 기록한다. */ /** 인물 추적으로 걸린 인물명을 기록한다. */
public void applyMatchedPerson(String matchedPerson) { public void applyMatchedPerson(String matchedPerson) {
this.matchedPerson = matchedPerson; this.matchedPerson = matchedPerson;

View File

@ -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<List<ReplyCommentDto>> queue(@RequestParam(required = false) String status) {
return ApiResponse.ok(service.queue(status));
}
@GetMapping("/stats")
@Operation(summary = "현황", description = "미응답/초안있음/완료 건수와 학습된 말투 샘플 수")
public ApiResponse<Map<String, Object>> stats() {
return ApiResponse.ok(service.stats());
}
@PostMapping("/collect")
@Operation(summary = "댓글 수집",
description = "내 채널 영상 전체를 돌며 내가 답글 안 단 댓글을 모은다. 영상당 1 unit.")
public ApiResponse<Map<String, Object>> collect() {
return ApiResponse.ok(service.collectAll());
}
@PostMapping("/{id}/draft")
@Operation(summary = "초안 생성",
description = "영상 내용 + 댓글을 근거로 서로 다른 초안 2개 생성. "
+ "refreshContext=true 면 캐시된 영상 요약을 버리고 다시 만든다(요약이 틀렸을 때).")
public ApiResponse<ReplyCommentDto> draft(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean refreshContext) {
return ApiResponse.ok(service.generateDrafts(id, refreshContext));
}
@PostMapping("/{id}/done")
@Operation(summary = "완료 표시", description = "실제로 답글을 달았을 때 목록에서 내린다")
public ApiResponse<Void> done(@PathVariable Long id) {
service.markDone(id);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/undo")
@Operation(summary = "완료 취소", description = "다시 미응답으로 되돌린다")
public ApiResponse<Void> undo(@PathVariable Long id) {
service.markPending(id);
return ApiResponse.ok(null);
}
}

View File

@ -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;
/**
* 채널 댓글을 모아 답글 초안을 만든다.
*
* <p>실제 답글 등록은 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<String, Object> collectAll() {
List<Channel> own = channelRepository.findOwnChannels();
if (own.isEmpty()) {
throw new IllegalStateException("내 채널이 등록돼 있지 않습니다. '내 채널' 화면에서 먼저 등록하세요.");
}
Channel me = own.get(0);
List<ChannelVideo> 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<String, Object> 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<String> 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<ReplyCommentDto> queue(String status) {
String filter = (status == null || status.isBlank()) ? null : status.trim().toUpperCase();
List<ReplyCommentDto> out = new ArrayList<>();
for (VideoComment c : commentRepository.findQueue(filter)) out.add(ReplyCommentDto.from(c));
return out;
}
@Transactional(readOnly = true)
public Map<String, Object> stats() {
Map<String, Object> 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<String> 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<Map<String, Object>> parts = new ArrayList<>();
parts.add(Map.of("text", prompt));
if (youtubeUrl != null) parts.add(Map.of("file_data", Map.of("file_uri", youtubeUrl)));
Map<String, Object> 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);
}
}

View File

@ -0,0 +1,147 @@
package com.hlab.yanalyst.domain.reply;
import java.util.ArrayList;
import java.util.List;
/**
* 답글 초안 생성의 순수 로직 미응답 판정, 말투 샘플 정리, 프롬프트 조립, 응답 파싱.
*
* <p>네트워크·DB에 의존하지 않아 그대로 테스트한다.
*/
public final class ReplyDrafting {
/** 말투 학습에 쓸 과거 답글 최대 개수. 너무 많으면 프롬프트만 길어진다. */
public static final int MAX_STYLE_SAMPLES = 5;
private ReplyDrafting() {}
/**
* 내가 이미 답글을 달았는가.
*
* <p>totalReplyCount 판정하면 된다 남이 대댓글만 댓글은 여전히 미응답이다.
*
* @param replyAuthorChannelIds 댓글에 달린 답글 작성자들의 채널 ID
* @param myChannelId 채널 ID
*/
public static boolean repliedByMe(List<String> 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<String> pickStyleSamples(List<String> myReplies) {
List<String> 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<String> 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();
}
}

View File

@ -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;
}
}

View File

@ -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<ReplyStyleSample, Long> {
boolean existsByReplyId(String replyId);
/** 최근에 쓴 답글부터. 말투는 최신 것이 지금 톤에 가깝다. */
List<ReplyStyleSample> findAllByOrderByIdDesc(Pageable pageable);
}

View File

@ -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;
/**
* 채널 영상에 달린 최상위 댓글 <b>내가 아직 답글을 달지 않은</b> .
*
* <p>남이 대댓글을 여전히 미응답이므로, 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;
}
}

View File

@ -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<VideoComment, Long> {
Optional<VideoComment> 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<VideoComment> findQueue(@Param("status") String status);
long countByStatus(String status);
}

View File

@ -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());
}
}

View File

@ -77,6 +77,12 @@ public class WebController {
return "rework"; return "rework";
} }
@GetMapping("/reply")
public String reply(Model model) {
model.addAttribute("currentPage", "reply");
return "reply";
}
@GetMapping("/comment-cards") @GetMapping("/comment-cards")
public String commentCards(Model model) { public String commentCards(Model model) {
model.addAttribute("currentPage", "comment-cards"); model.addAttribute("currentPage", "comment-cards");

View File

@ -54,6 +54,9 @@
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'"> <a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'">
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span> <i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">프로덕션</span>
</a> </a>
<a th:href="@{/reply}" class="nav-item" th:classappend="${currentPage == 'reply'} ? 'active'">
<i data-lucide="reply" class="nav-icon"></i><span class="nav-text">댓글 답장</span>
</a>
<a th:href="@{/comment-cards}" class="nav-item" th:classappend="${currentPage == 'comment-cards'} ? 'active'"> <a th:href="@{/comment-cards}" class="nav-item" th:classappend="${currentPage == 'comment-cards'} ? 'active'">
<i data-lucide="message-square-quote" class="nav-icon"></i><span class="nav-text">댓글 카드</span> <i data-lucide="message-square-quote" class="nav-icon"></i><span class="nav-text">댓글 카드</span>
</a> </a>

View File

@ -0,0 +1,392 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 댓글 답장</title>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>댓글 답장</h1>
<p class="sub">내 영상에 달린 댓글 중 아직 답 안 한 것만 모아, 영상 내용까지 보고 초안을 씁니다.</p>
</div>
<div class="actions">
<button class="btn btn-primary" id="collectBtn" onclick="collect()">
<i data-lucide="download" style="width:15px;"></i> 댓글 수집
</button>
</div>
</div>
<!-- 현황 + 필터 -->
<div class="card mb-4">
<div class="toolbar">
<div class="rp-stats" id="stats"></div>
<span class="spacer"></span>
<label class="field" for="fStatus">보기
<select id="fStatus" onchange="load()">
<option value="">답장 대기 (미응답+초안)</option>
<option value="NEW">미응답만</option>
<option value="DRAFTED">초안 있는 것만</option>
<option value="DONE">완료</option>
</select>
</label>
</div>
</div>
<div id="list" class="rp-list"></div>
<div id="state" class="rp-state hidden"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<style>
/* 현황 */
.rp-stats { display:flex; gap:1.25rem; flex-wrap:wrap; }
.rp-stat { display:flex; align-items:baseline; gap:0.35rem; font-size:0.78rem; color:var(--text-3); }
.rp-stat b { font-size:1rem; font-weight:700; color:var(--text); font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
/* 목록 */
.rp-list { display:flex; flex-direction:column; gap:0.85rem; }
.rp-card {
display:grid; grid-template-columns:132px 1fr; gap:1rem;
background:var(--surface); border:1px solid var(--border); border-radius:var(--r);
padding:0.95rem 1.1rem;
transition:border-color .18s ease, box-shadow .18s ease;
}
.rp-card:hover { border-color:var(--border-strong); box-shadow:var(--shadow); }
.rp-card.is-done { opacity:.55; }
.rp-card.is-done:hover { opacity:1; }
@media (max-width: 720px) { .rp-card { grid-template-columns:1fr; } }
/* 영상 */
.rp-vid { min-width:0; }
.rp-vid .th { display:block; aspect-ratio:9/16; max-height:150px; border-radius:var(--r-sm);
overflow:hidden; background:var(--inset); }
.rp-vid .th img { width:100%; height:100%; object-fit:cover; display:block; }
.rp-vid .th:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.rp-vid .t {
font-size:0.72rem; color:var(--text-3); margin-top:0.35rem; line-height:1.4;
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
/* 댓글 */
.rp-body { min-width:0; display:flex; flex-direction:column; gap:0.6rem; }
.rp-who { display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; }
.rp-who img { width:26px; height:26px; border-radius:50%; background:var(--inset); flex-shrink:0; }
.rp-who b { font-size:0.83rem; font-weight:600; }
.rp-who span { font-size:0.72rem; color:var(--text-3); }
.rp-who .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
.rp-text {
font-size:0.9rem; line-height:1.65; color:var(--text);
white-space:pre-wrap; word-break:break-word; max-width:70ch;
}
/* 초안 */
.rp-drafts { display:flex; flex-direction:column; gap:0.5rem; }
.rp-draft {
display:flex; align-items:flex-start; gap:0.6rem;
background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r-sm);
padding:0.6rem 0.7rem;
}
.rp-draft .k {
flex:0 0 auto; font-size:0.68rem; font-weight:700; color:var(--text-3);
border:1px solid var(--border-strong); border-radius:4px; padding:1px 5px; margin-top:2px;
}
.rp-draft .v { flex:1; font-size:0.86rem; line-height:1.6; word-break:break-word; }
.rp-draft button {
flex:0 0 auto; display:inline-flex; align-items:center; justify-content:center; gap:4px;
min-height:34px; padding:0 0.6rem;
background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--r-sm);
color:var(--text-2); font-size:0.76rem; font-weight:600; font-family:inherit; cursor:pointer;
transition:all .18s ease;
}
.rp-draft button:hover { background:var(--hover); color:var(--text); }
.rp-draft button.ok { color:var(--success); border-color:var(--success); }
.rp-actions { display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; }
.rp-actions .btn { min-height:38px; padding:0.5rem 0.85rem; font-size:0.8rem; }
/* 상태 */
.rp-state { padding:3rem 1rem; text-align:center; color:var(--text-2); }
.rp-state .ic {
width:44px; height:44px; margin:0 auto 0.9rem; border-radius:12px;
background:var(--surface-2); color:var(--text-3);
display:flex; align-items:center; justify-content:center;
}
.rp-state .t { font-weight:600; color:var(--text); margin-bottom:0.35rem; }
.rp-state .d { font-size:0.85rem; line-height:1.6; }
.rp-state .a { margin-top:1rem; display:flex; gap:0.5rem; justify-content:center; flex-wrap:wrap; }
.skel { border:1px solid var(--border); border-radius:var(--r); background:var(--surface);
display:grid; grid-template-columns:132px 1fr; gap:1rem; padding:0.95rem 1.1rem; }
.skel .a1 { aspect-ratio:9/16; max-height:150px; border-radius:var(--r-sm); }
.skel .b1 { height:12px; margin-bottom:0.6rem; border-radius:4px; }
.skel .b2 { height:12px; width:70%; border-radius:4px; }
.toast {
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px);
background:var(--text); color:var(--bg);
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600;
box-shadow:var(--shadow-lg); z-index:2000;
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease;
max-width:88vw; text-align:center;
}
.toast.show { opacity:1; transform:translate(-50%, 0); }
.toast.err { background:var(--danger); color:#fff; }
@media (prefers-reduced-motion: reduce) {
.rp-card, .rp-draft button, .toast { transition:none; }
}
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/replies';
let items = [];
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function fmtNum(n){ return n==null ? '0' : Number(n).toLocaleString(); }
function fmtAgo(iso){
if(!iso) return '';
const t = new Date(iso).getTime();
if(isNaN(t)) return '';
const m = Math.max(0, Math.floor((Date.now()-t)/60000));
if(m < 60) return `${m} `;
const h = Math.floor(m/60);
if(h < 24) return `${h}시간 `;
const d = Math.floor(h/24);
return d < 30 ? `${d} ` : new Date(t).toLocaleDateString('ko-KR');
}
let toastTimer = null;
function toast(msg, isErr){
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.toggle('err', !!isErr);
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500);
}
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
return json.data;
}
function showState(icon, title, desc, actions){
document.getElementById('list').innerHTML = '';
const el = document.getElementById('state');
el.classList.remove('hidden');
el.innerHTML = `<div class="ic"><i data-lucide="${icon}"></i></div>
<div class="t">${esc(title)}</div><div class="d">${desc}</div>
${actions ? `<div class="a">${actions}</div>` : ''}`;
if(window.lucide) lucide.createIcons();
}
function hideState(){ document.getElementById('state').classList.add('hidden'); }
function showSkeleton(){
hideState();
const one = `<div class="skel"><div class="a1 skeleton"></div>
<div><div class="b1 skeleton"></div><div class="b2 skeleton"></div></div></div>`;
document.getElementById('list').innerHTML = one.repeat(4);
}
// ---------- 현황 ----------
async function loadStats(){
let s;
try { s = await api(API + '/stats'); } catch(e){ return; }
document.getElementById('stats').innerHTML = `
<div class="rp-stat"><b>${s.pending}</b> 미응답</div>
<div class="rp-stat"><b>${s.drafted}</b> 초안 있음</div>
<div class="rp-stat"><b>${s.done}</b> 완료</div>
<div class="rp-stat" title="내가 예전에 단 답글에서 말투를 학습합니다"><b>${s.styleSamples}</b> 말투 샘플</div>`;
}
// ---------- 목록 ----------
async function load(){
showSkeleton();
const st = document.getElementById('fStatus').value;
try {
items = await api(API + (st ? '?status='+st : '')) || [];
} catch(e){
showState('alert-triangle', '불러오지 못했습니다', esc(e.message),
'<button class="btn btn-secondary" onclick="load()">다시 시도</button>');
return;
}
render();
loadStats();
}
function draftRow(key, label, text, id){
if(!text) return '';
return `<div class="rp-draft">
<span class="k">${label}</span>
<div class="v">${esc(text)}</div>
<button onclick="copyDraft(this, ${id}, '${key}')" aria-label="${label} 초안 복사">
<i data-lucide="copy" style="width:13px;"></i> 복사</button>
</div>`;
}
function cardHtml(c){
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(c.videoId);
const done = c.status === 'DONE';
const hasDraft = !!(c.draftA || c.draftB);
const drafts = hasDraft
? `<div class="rp-drafts">
${draftRow('a','A', c.draftA, c.id)}
${draftRow('b','B', c.draftB, c.id)}
</div>` : '';
const others = c.replyCount > 0
? `<span>· 대댓글 <span class="num">${c.replyCount}</span></span>` : '';
return `<article class="rp-card${done ? ' is-done' : ''}">
<div class="rp-vid">
<a class="th" href="${url}" target="_blank" rel="noopener"
aria-label="${esc(c.videoTitle)} — YouTube 에서 열기">
<img src="${esc(c.videoThumbnailUrl)}" alt="" loading="lazy" width="180" height="320">
</a>
<div class="t">${esc(c.videoTitle)}</div>
</div>
<div class="rp-body">
<div class="rp-who">
<img src="${esc(c.authorImageUrl)}" alt="" loading="lazy">
<b>${esc(c.authorName)}</b>
<span>${fmtAgo(c.publishedAt)}</span>
<span>· 좋아요 <span class="num">${fmtNum(c.likeCount)}</span></span>
${others}
${done ? '<span class="badge badge-success">완료</span>' : ''}
</div>
<div class="rp-text">${esc(c.text)}</div>
${drafts}
<div class="rp-actions">
<button class="btn ${hasDraft ? 'btn-secondary' : 'btn-primary'}"
id="draftBtn-${c.id}" onclick="makeDraft(${c.id}, ${hasDraft})"
title="${hasDraft ? '영상을 다시 읽고 새로 씁니다' : '영상 내용을 근거로 초안 2개를 만듭니다'}">
<i data-lucide="sparkles" style="width:14px;"></i>
${hasDraft ? '다시 생성' : '초안 생성'}
</button>
<a class="btn btn-secondary" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> 영상에서 답글 달기</a>
${done
? `<button class="btn btn-secondary" onclick="undo(${c.id})">
<i data-lucide="rotate-ccw" style="width:14px;"></i> 완료 취소</button>`
: `<button class="btn btn-secondary" onclick="markDone(${c.id})">
<i data-lucide="check" style="width:14px;"></i> 답글 달았음</button>`}
</div>
</div>
</article>`;
}
function render(){
if(items.length === 0){
const st = document.getElementById('fStatus').value;
if(st === 'DONE'){
showState('inbox', '완료한 댓글이 없습니다', '답글을 달고 <b>답글 달았음</b>을 누르면 여기로 옵니다.');
} else {
showState('message-square', '답장할 댓글이 없습니다',
'내 채널이 등록돼 있어야 합니다. 등록·동기화 후 <b>댓글 수집</b>을 누르세요.',
`<a class="btn btn-secondary" href="/my-channel">내 채널로</a>
<button class="btn btn-primary" onclick="collect()">댓글 수집</button>`);
}
return;
}
hideState();
document.getElementById('list').innerHTML = items.map(cardHtml).join('');
if(window.lucide) lucide.createIcons();
}
// ---------- 액션 ----------
async function collect(){
const btn = document.getElementById('collectBtn');
btn.disabled = true;
const orig = btn.innerHTML;
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 수집 중...';
if(window.lucide) lucide.createIcons();
try {
const r = await api(API + '/collect', { method:'POST' });
toast(`영상 ${r.videos}개에서 미응답 ${r.pending}건 · 이미 답한 것 ${r.alreadyReplied}건`);
await load();
} catch(e){
toast(e.message, true);
} finally {
btn.disabled = false; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
}
}
// 다시 생성이면 캐시된 영상 요약도 버리고 다시 읽는다 — 요약이 틀려서 다시 누르는 경우가 많다
async function makeDraft(id, refresh){
const btn = document.getElementById('draftBtn-' + id);
if(btn){
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" style="width:14px;" class="animate-spin"></i> 생성 중...';
if(window.lucide) lucide.createIcons();
}
try {
const updated = await api(`${API}/${id}/draft?refreshContext=${refresh ? 'true' : 'false'}`,
{ method:'POST' });
const i = items.findIndex(v => v.id === id);
if(i >= 0) items[i] = updated;
render();
loadStats();
toast('초안 2개를 만들었습니다');
} catch(e){
toast('생성 실패: ' + e.message, true);
render();
}
}
async function copyDraft(btn, id, key){
const c = items.find(v => v.id === id);
if(!c) return;
const text = key === 'a' ? c.draftA : c.draftB;
try {
await navigator.clipboard.writeText(text);
} catch(e){
// 클립보드 권한이 없을 때의 폴백
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } finally { document.body.removeChild(ta); }
}
const orig = btn.innerHTML;
btn.classList.add('ok');
btn.innerHTML = '<i data-lucide="check" style="width:13px;"></i> 복사됨';
if(window.lucide) lucide.createIcons();
setTimeout(()=>{
btn.classList.remove('ok');
btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
}, 1600);
}
async function markDone(id){
try {
await api(`${API}/${id}/done`, { method:'POST' });
items = items.filter(v => v.id !== id);
render(); loadStats();
toast('완료 처리했습니다');
} catch(e){ toast('실패: ' + e.message, true); }
}
async function undo(id){
try {
await api(`${API}/${id}/undo`, { method:'POST' });
await load();
toast('미응답으로 되돌렸습니다');
} catch(e){ toast('실패: ' + e.message, true); }
}
load();
/*]]>*/
</script>
</div>
</body>
</html>

View File

@ -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<String> replies = new ArrayList<>(List.of(
"ㅋㅋㅋ", // 너무 짧음
"감사합니다", // 너무 짧음
"이 장면 진짜 레전드죠 저도 편집하면서 웃었어요",
"다음 편도 곧 올라갑니다 기다려주세요!",
"맞아요 그 부분이 제일 웃겼습니다",
"말씀해주신 장면 찾아서 올려볼게요",
"봐주셔서 감사합니다 다음에 또 뵈어요",
"여섯 번째라 잘려야 하는 답글입니다"));
List<String> 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);
}
}