컷별 댓글 추천에 단어 겹침(3순위)·좋아요(4순위) 폴백 추가

Gemini 가 실패하거나(503 등) 문맥은 맞지만 겹치는 단어가 없어 못 잡는
경우("와인 뱉는 장면" ↔ "싱크대로 달려간 이유")가 각각 달라, 대체가 아니라
시각 » 🤖AI 뒤에 한 단계 더 얹었다. 자막에서 조사를 뗀 어간까지 키워드로
뽑아 댓글 본문에 부분 문자열로 걸리면 매칭한다("넉살이"→"넉살"이 "넉살님"에
걸리는 게 핵심 케이스). 네트워크 없이 항상 동작해 Gemini 가 죽어도 0건은
안 나온다. 그래도 자리가 남으면 분:초 없는 댓글 중 좋아요 상위로 채운다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 18:43:19 +09:00
parent 9ea70d01a7
commit b1c08908ae

View File

@ -9,6 +9,7 @@ Gemini 없이 단위 검증이 된다.
from __future__ import annotations from __future__ import annotations
import json import json
import re
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
@ -21,6 +22,17 @@ from .correct import _gemini_key
# 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값. # 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값.
CARD_SEC = 3.0 CARD_SEC = 3.0
# ── 단어 겹침 매칭(3순위) — Gemini 가 실패해도(503 등) 네트워크 없이 항상 채워지는 폴백 ──
# 흔해서 아무 컷에나 걸리는 일반 단어. 걸리면 오탐이 늘어날 뿐이라 미리 뺀다.
STOP = {"진짜", "너무", "정말", "그냥", "이거", "저거", "우리", "사람", "이번", "그거", "완전", "진심",
"이렇게", "그렇게", "하는", "했다", "있는", "없는", "보고", "보는", "같아", "같은", "이건", "저건",
"근데", "그리고", "하지만", "합니다", "입니다"}
# 한글 2글자 이상 / 알파벳 3글자 이상 / 숫자 2글자 이상만 키워드 후보로 본다(1글자는 아무 데나 걸린다).
TOK = re.compile(r"[가-힣]{2,}|[A-Za-z]{3,}|\d{2,}")
# 긴 조사부터 검사해야 짧은 조사가 먼저 걸려 어간이 덜 잘리는 일이 없다.
JOSA = ("이야", "에서", "으로", "까지", "부터", "라고", "이고", "", "", "", "", "", "",
"", "", "", "", "", "", "")
def quotas_for(cuts) -> List[int]: def quotas_for(cuts) -> List[int]:
"""컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장. """컷별 카드 장수 — max(1, floor(컷길이 / CARD_SEC)). 짧은 컷도 최소 1장.
@ -33,20 +45,67 @@ def quotas_for(cuts) -> List[int]:
return [max(1, int((c["end"] - c["start"]) // CARD_SEC)) for c in cuts] return [max(1, int((c["end"] - c["start"]) // CARD_SEC)) for c in cuts]
def _cut_keywords(text: str) -> List[str]:
"""자막 한 줄 → 댓글 매칭용 키워드(원본 토큰 + 조사 뗀 어간 후보).
"넉살이"(자막) "넉살님"(댓글) 걸리게 하려면 조사를 떼야 한다. 결과가
2글자 미만이면 버린다(1글자 어간은 아무 댓글에나 걸려 오탐만 늘린다).
"""
out: List[str] = []
seen = set()
for tok in TOK.findall(text or ""):
if tok not in STOP and tok not in seen:
seen.add(tok)
out.append(tok)
for josa in JOSA:
if tok.endswith(josa) and len(tok) - len(josa) >= 2:
stem = tok[:-len(josa)]
if stem not in STOP and stem not in seen:
seen.add(stem)
out.append(stem)
break
return out
def _word_match_ranked(cut, comments, used: set) -> List[int]:
"""단어 겹침 순위(3순위) — 자막 키워드가 댓글 본문에 부분 문자열로 들어 있는 개수.
부분 문자열로 보는 이유: 조사·어미가 댓글 쪽에 붙어 있어도(: "넉살" "넉살님")
흡수하려는 . 점수 0 제외, 점수 내림차순 좋아요 내림차순으로 정렬한다.
"""
keywords = _cut_keywords(cut.get("bottom") or "")
if not keywords:
return []
scored = []
for c in comments:
if c["idx"] in used:
continue
text = str(c.get("text") or "")
score = sum(1 for kw in keywords if kw in text)
if score > 0:
scored.append((score, c.get("likeCount", 0), c["idx"]))
scored.sort(key=lambda t: (-t[0], -t[1]))
return [idx for _, _, idx in scored]
def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]], def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]],
quotas) -> List[List[dict]]: quotas) -> List[List[dict]]:
"""컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지. """컷별 추천 확정 — ⭐시각 » 🤖AI » 🔤단어겹침 » ➕좋아요 순, 전 컷 통틀어 중복 금지.
타임스탬프가 AI보다 먼저인 이유: 컷의 **원본 구간** 집어 언급한 댓글은 타임스탬프가 AI보다 먼저인 이유: 컷의 **원본 구간** 집어 언급한 댓글은
근거가 확실하다. 추측(AI) 이기게 이유가 없다. 근거가 확실하다. 추측(AI·단어) 이기게 이유가 없다.
단어 겹침이 AI 다음인 이유: Gemini 문맥까지 보고 고르니 정확하지만, 503 등으로
실패하거나(`ai_picks=None`) 문맥상 연결을 놓칠 (: "와인 뱉는 장면" "싱크대로 달려간
이유"는 겹치는 단어가 없다) 그물을 하나 더 치는 것 — 네트워크 없이도 항상 동작한다.
중복은 컷이 가져간다( 컷은 다음 후보로 밀린다). 중복은 컷이 가져간다( 컷은 다음 후보로 밀린다).
ai_picks: {컷인덱스: [댓글idx ]} Gemini 실패 None/{} 어느 쪽을 넘겨도 ai_picks: {컷인덱스: [댓글idx ]} Gemini 실패 None/{} 어느 쪽을 넘겨도
타임스탬프만으로 채운다(`ai_pick_cuts` 실패를 None 으로 알린다). 타임스탬프만으로 채운다(`ai_pick_cuts` 실패를 None 으로 알린다).
Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}] Returns: 컷별 [{"idx": int, "why": "ts"|"ai"|"word"|"like"}]
""" """
ai_picks = ai_picks or {} ai_picks = ai_picks or {}
valid = {c["idx"] for c in comments} valid = {c["idx"] for c in comments}
no_ts = [c for c in comments if not c.get("times")]
used: set = set() used: set = set()
out: List[List[dict]] = [] out: List[List[dict]] = []
for i, cut in enumerate(cuts): for i, cut in enumerate(cuts):
@ -64,6 +123,16 @@ def build_cut_picks(cuts, comments, ai_picks: Optional[Dict[int, List[int]]],
if idx in valid and idx not in used: if idx in valid and idx not in used:
picks.append({"idx": idx, "why": "ai"}) picks.append({"idx": idx, "why": "ai"})
used.add(idx) used.add(idx)
if len(picks) < quota:
for idx in _word_match_ranked(cut, comments, used):
if len(picks) >= quota:
break
picks.append({"idx": idx, "why": "word"})
used.add(idx)
if len(picks) < quota:
for idx in top_liked(no_ts, used, quota - len(picks)):
picks.append({"idx": idx, "why": "like"})
used.add(idx)
out.append(picks) out.append(picks)
return out return out