From b1c08908ae2592d9871f4e98d23d10682bfa11fb Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 18:43:19 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BB=B7=EB=B3=84=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=EC=97=90=20=EB=8B=A8=EC=96=B4=20=EA=B2=B9?= =?UTF-8?q?=EC=B9=A8(3=EC=88=9C=EC=9C=84)=C2=B7=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?(4=EC=88=9C=EC=9C=84)=20=ED=8F=B4=EB=B0=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini 가 실패하거나(503 등) 문맥은 맞지만 겹치는 단어가 없어 못 잡는 경우("와인 뱉는 장면" ↔ "싱크대로 달려간 이유")가 각각 달라, 대체가 아니라 ⭐시각 » 🤖AI 뒤에 한 단계 더 얹었다. 자막에서 조사를 뗀 어간까지 키워드로 뽑아 댓글 본문에 부분 문자열로 걸리면 매칭한다("넉살이"→"넉살"이 "넉살님"에 걸리는 게 핵심 케이스). 네트워크 없이 항상 동작해 Gemini 가 죽어도 0건은 안 나온다. 그래도 자리가 남으면 분:초 없는 댓글 중 좋아요 상위로 채운다. Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/recommend.py | 75 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py index 16db89d..c9911a2 100644 --- a/capcut_agent/recommend.py +++ b/capcut_agent/recommend.py @@ -9,6 +9,7 @@ Gemini 없이 단위 검증이 된다. from __future__ import annotations import json +import re import time import urllib.error import urllib.request @@ -21,6 +22,17 @@ from .correct import _gemini_key # 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값. 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]: """컷별 카드 장수 — 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] +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]]], quotas) -> List[List[dict]]: - """컷별 추천 확정 — 타임스탬프 우선, 남는 자리만 AI, 전 컷 통틀어 중복 금지. + """컷별 추천 확정 — ⭐시각 » 🤖AI » 🔤단어겹침 » ➕좋아요 순, 전 컷 통틀어 중복 금지. 타임스탬프가 AI보다 먼저인 이유: 그 컷의 **원본 구간**을 콕 집어 언급한 댓글은 - 근거가 확실하다. 추측(AI)을 이기게 둘 이유가 없다. + 근거가 확실하다. 추측(AI·단어)을 이기게 둘 이유가 없다. + 단어 겹침이 AI 다음인 이유: Gemini 는 문맥까지 보고 고르니 더 정확하지만, 503 등으로 + 실패하거나(`ai_picks=None`) 문맥상 연결을 놓칠 때(예: "와인 뱉는 장면" ↔ "싱크대로 달려간 + 이유"는 겹치는 단어가 없다) 그물을 하나 더 치는 것 — 네트워크 없이도 항상 동작한다. 중복은 앞 컷이 가져간다(뒤 컷은 다음 후보로 밀린다). ai_picks: {컷인덱스: [댓글idx …]} — Gemini 실패 시 None/{} 어느 쪽을 넘겨도 타임스탬프만으로 채운다(`ai_pick_cuts` 는 실패를 None 으로 알린다). - Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}] + Returns: 컷별 [{"idx": int, "why": "ts"|"ai"|"word"|"like"}] """ ai_picks = ai_picks or {} valid = {c["idx"] for c in comments} + no_ts = [c for c in comments if not c.get("times")] used: set = set() out: List[List[dict]] = [] 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: picks.append({"idx": idx, "why": "ai"}) 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) return out