h-lab/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java
hehihoho3@gmail.com d451aff8cc 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>
2026-08-03 11:01:58 +09:00

291 lines
11 KiB
Java

package com.hlab.yanalyst.domain.channel;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "channel_videos", indexes = {
@Index(name = "idx_cv_video_id", columnList = "video_id"), // 조회/중복스킵 lookup
@Index(name = "idx_cv_channel_id", columnList = "channel_id"), // 채널별 영상
@Index(name = "idx_cv_interest_status", columnList = "interest_status"), // 큐레이션/칸반 필터·집계
@Index(name = "idx_cv_source", columnList = "source"), // 출처 필터·집계
@Index(name = "idx_cv_category_id", columnList = "category_id"), // 카테고리 필터·집계
@Index(name = "idx_cv_published_at", columnList = "published_at"), // 게시일 정렬·발굴 필터
@Index(name = "idx_cv_views_per_sub_ratio", columnList = "views_per_sub_ratio") // 떡상 배율 정렬(발굴/후보)
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChannelVideo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String videoId;
@Column(nullable = false)
private String title;
@Column(length = 2083)
private String thumbnailUrl;
private LocalDateTime publishedAt;
private Long viewCount;
private Long likeCount;
private String duration; // ISO 8601 duration string
// --- 파생 분석 지표 (수집 시 자동 계산) ---
/** 영상 길이(초). duration(ISO8601)을 파싱해 저장. */
@Column(name = "duration_sec")
private Integer durationSec;
/** Shorts 여부 (65초 이하). */
@Column(name = "is_shorts")
private Boolean isShorts = false;
/** 시간당 조회수 = 조회수 / 업로드 후 경과 시간. "떡상 속도" 지표. */
@Column(name = "views_per_hour", precision = 18, scale = 2)
private BigDecimal viewsPerHour;
/** 구독자 대비 조회수 비율. "구독자 적은데 터진 영상" 발굴 지표. */
@Column(name = "views_per_sub_ratio", precision = 18, scale = 2)
private BigDecimal viewsPerSubRatio;
// --- 출처/원본 채널 정보 (검색 수집 시 Channel 엔티티가 없을 수 있음) ---
/** 수집 경로: CHANNEL(등록 채널 동기화) / SEARCH(조회수 검색 수집). */
@Column(name = "source", length = 20)
private String source = "CHANNEL";
/** 원본 YouTube 채널 ID(문자열). FK Channel 과 별개로 항상 보관. */
@Column(name = "yt_channel_id")
private String ytChannelId;
/** 원본 채널명. */
@Column(name = "channel_title")
private String channelTitle;
/** 수집 시점 채널 구독자 수. */
@Column(name = "subscriber_count")
private Long subscriberCount;
/** 해시태그(쉼표 구분). */
@Column(name = "hashtags", columnDefinition = "TEXT")
private String hashtags;
/**
* 인물 추적으로 걸린 영상이면 그 인물명. 인물 탭은 source 가 아니라 이 값으로 조회하므로,
* 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 두 탭 모두에 나타난다.
*/
@Column(name = "matched_person", length = 100)
private String matchedPerson;
// --- 큐레이션(분류/관리) 필드 ---
/** 분류 카테고리 ID (categories.id 참조, 느슨한 연결). */
@Column(name = "category_id")
private Long categoryId;
/** 관심 영상 북마크. */
@Column(name = "bookmarked")
private Boolean bookmarked = false;
/** 큐레이션 상태: NEW(수집됨) / REVIEWING(검토중) / TARGET(작업대상) / EXCLUDED(제외). */
@Column(name = "interest_status", length = 20)
private String interestStatus = "NEW";
/** 사용자 메모. */
@Column(name = "memo", columnDefinition = "TEXT")
private String memo;
/** 재가공(재작성) 초안 — 원본 스크립트를 바탕으로 수정한 내 버전. */
@Column(name = "rework_text", columnDefinition = "TEXT")
private String reworkText;
@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")
private Channel channel;
@Builder
public ChannelVideo(String videoId, String title, String thumbnailUrl, LocalDateTime publishedAt, Long viewCount, Long likeCount, String duration, Channel channel) {
this.videoId = videoId;
this.title = title;
this.thumbnailUrl = thumbnailUrl;
this.publishedAt = publishedAt;
this.viewCount = viewCount;
this.likeCount = likeCount;
this.duration = duration;
this.channel = channel;
}
public void update(String title, String thumbnailUrl, Long viewCount, Long likeCount) {
this.title = title;
this.thumbnailUrl = thumbnailUrl;
this.viewCount = viewCount;
this.likeCount = likeCount;
}
/** 수집/갱신 시 파생 분석 지표를 일괄 적용한다. */
public void applyMetrics(Integer durationSec, Boolean isShorts, BigDecimal viewsPerHour) {
this.durationSec = durationSec;
this.isShorts = isShorts;
this.viewsPerHour = viewsPerHour;
}
/** 채널 수집 시 원본 채널 정보 + 구독자 대비 비율 적용. */
public void applyChannelInfo(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) {
this.ytChannelId = ytChannelId;
this.channelTitle = channelTitle;
this.subscriberCount = subscriberCount;
this.viewsPerSubRatio = viewsPerSubRatio;
this.source = "CHANNEL";
}
/**
* 피드(소재 원본/경쟁) 수집 시 원본 채널 정보와 출처를 함께 적용한다.
* @param source SOURCE(소재 원본 롱폼) | RIVAL(경쟁 쇼츠)
*/
public void applyFeedInfo(String ytChannelId, String channelTitle, Long subscriberCount,
BigDecimal viewsPerSubRatio, String source) {
this.ytChannelId = ytChannelId;
this.channelTitle = channelTitle;
this.subscriberCount = subscriberCount;
this.viewsPerSubRatio = viewsPerSubRatio;
this.source = source;
}
/** 설명에서 추출한 해시태그(쉼표 구분)를 채운다. */
public void applyHashtags(String hashtags) {
this.hashtags = hashtags;
}
/** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */
public void applyContextSummary(String contextSummary) {
this.contextSummary = contextSummary;
}
/** 인물 추적으로 걸린 인물명을 기록한다. */
public void applyMatchedPerson(String matchedPerson) {
this.matchedPerson = matchedPerson;
}
/**
* 인물 검색으로 새로 발견한 영상을 만든다. 소스 채널 시드에 없는 채널까지 잡히므로
* 출처를 PERSON 으로 둬 수집함/발굴에서는 격리한다.
*/
public static ChannelVideo fromPersonSearch(String videoId, String title, String thumbnailUrl,
LocalDateTime publishedAt, Long viewCount,
String ytChannelId, String channelTitle,
Integer durationSec, BigDecimal viewsPerHour,
String hashtags, String matchedPerson) {
ChannelVideo v = new ChannelVideo();
v.videoId = videoId;
v.title = title;
v.thumbnailUrl = thumbnailUrl;
v.publishedAt = publishedAt;
v.viewCount = viewCount;
v.likeCount = 0L;
v.ytChannelId = ytChannelId;
v.channelTitle = channelTitle;
v.durationSec = durationSec;
v.isShorts = VideoMetrics.isShorts(durationSec);
v.viewsPerHour = viewsPerHour;
v.hashtags = hashtags;
v.matchedPerson = matchedPerson;
v.source = "PERSON";
return v;
}
/** 출처(source)를 바꾸지 않고 채널 정보/비율만 채운다. 백필 시 SEARCH 수집물용. */
public void applyChannelInfoKeepSource(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) {
this.ytChannelId = ytChannelId;
this.channelTitle = channelTitle;
this.subscriberCount = subscriberCount;
this.viewsPerSubRatio = viewsPerSubRatio;
}
/** null 인 큐레이션 필드에 기본값을 채운다(백필용). */
public void applyCurationDefaults() {
if (this.source == null) this.source = "CHANNEL";
if (this.interestStatus == null) this.interestStatus = "NEW";
if (this.bookmarked == null) this.bookmarked = false;
}
/** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */
public static ChannelVideo fromSearch(String videoId, String title, String thumbnailUrl,
LocalDateTime publishedAt, Long viewCount,
String ytChannelId, String channelTitle, Long subscriberCount,
Integer durationSec, BigDecimal viewsPerHour,
BigDecimal viewsPerSubRatio, String hashtags) {
ChannelVideo v = new ChannelVideo();
v.videoId = videoId;
v.title = title;
v.thumbnailUrl = thumbnailUrl;
v.publishedAt = publishedAt;
v.viewCount = viewCount;
v.likeCount = 0L;
v.ytChannelId = ytChannelId;
v.channelTitle = channelTitle;
v.subscriberCount = subscriberCount;
v.durationSec = durationSec;
v.isShorts = VideoMetrics.isShorts(durationSec);
v.viewsPerHour = viewsPerHour;
v.viewsPerSubRatio = viewsPerSubRatio;
v.hashtags = hashtags;
v.source = "SEARCH";
return v;
}
public void assignCategory(Long categoryId) {
this.categoryId = categoryId;
}
public void setBookmarked(Boolean bookmarked) {
this.bookmarked = bookmarked;
}
public void changeInterestStatus(String interestStatus) {
this.interestStatus = interestStatus;
}
public void setMemo(String memo) {
this.memo = memo;
}
public void setReworkText(String reworkText) {
this.reworkText = reworkText;
}
public void setHasScript(Boolean hasScript) {
this.hasScript = hasScript;
}
public boolean isHasScript() {
return this.hasScript != null && this.hasScript;
}
public boolean isBookmarked() {
return this.bookmarked != null && this.bookmarked;
}
}