From 7cb70cc33ad53bad2200839ccda52b267688cbfa Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 4 Aug 2026 11:56:08 +0900 Subject: [PATCH] =?UTF-8?q?Gemini=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=A1=9C=20=EC=BB=B7=EB=B3=84=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=B6=94=EC=B2=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit correct.py의 텍스트 호출 패턴을 따라 ai_pick_cuts() 및 헬퍼 함수(_candidate_lines, _cut_lines, _parse_ai)를 추가했다. - 목차 댓글 필터링(MAX_TIMES 기준) - 좋아요순 정렬 - 응답 파싱 시 형식 오류는 조용히 버림 - 모든 실패(키 없음, 타임아웃, JSON 깨짐 등)에 {} 반환 Co-Authored-By: Claude Opus 5 (1M context) --- capcut_agent/recommend.py | 103 +++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/capcut_agent/recommend.py b/capcut_agent/recommend.py index bdecba7..798d8c2 100644 --- a/capcut_agent/recommend.py +++ b/capcut_agent/recommend.py @@ -8,9 +8,14 @@ Gemini 없이 단위 검증이 된다. """ from __future__ import annotations +import json +import urllib.error +import urllib.request from typing import Dict, List -from .comments import match_ranges +from . import prompts +from .comments import MAX_TIMES, match_ranges +from .correct import _gemini_key # 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값. CARD_SEC = 3.0 @@ -52,3 +57,99 @@ def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]], used.add(idx) out.append(picks) return out + + +# Gemini에 넘길 댓글 후보 수. 더 늘려도 채택률이 안 오르고 토큰만 먹는다. +AI_CANDIDATES = 150 +TEXT_CAP = 200 # 댓글 본문 절단 길이 +AI_MODEL = "gemini-2.5-flash" # correct.py 와 같은 무료 티어 모델 +_ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/" + "{model}:generateContent?key={key}") + +_SCHEMA = {"type": "array", "items": {"type": "object", "properties": { + "cut": {"type": "integer"}, + "picks": {"type": "array", "items": {"type": "integer"}}}, + "required": ["cut", "picks"]}} + + +def _candidate_lines(comments, limit: int = AI_CANDIDATES) -> str: + """Gemini에 줄 댓글 후보 — 목차 댓글 제외, 좋아요순 상위 limit, 본문 절단.""" + usable = [c for c in comments if len(c.get("times") or []) <= MAX_TIMES] + usable.sort(key=lambda c: -c.get("likeCount", 0)) + out = [] + for c in usable[:limit]: + text = " ".join(str(c.get("text") or "").split())[:TEXT_CAP] + out.append(f"{c['idx']}. 👍{c.get('likeCount', 0)} / {text}") + return "\n".join(out) + + +def _cut_lines(cuts) -> str: + """컷 목록 — 번호·길이·자막. 자막이 추천의 유일한 근거다.""" + out = [] + for i, c in enumerate(cuts): + bottom = " ".join(str(c.get("bottom") or "").split()) or "(자막 없음)" + effect = " ".join(str(c.get("effect") or "").split()) + line = f"{i}. ({c['end'] - c['start']:.1f}초) {bottom}" + if effect: + line += f" [효과자막: {effect}]" + out.append(line) + return "\n".join(out) + + +def _parse_ai(raw: str) -> Dict[int, List[int]]: + """Gemini 응답 → {컷인덱스: [댓글idx …]}. 형식이 어긋난 항목은 조용히 버린다.""" + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + if not isinstance(data, list): + return {} + out: Dict[int, List[int]] = {} + for row in data: + if not isinstance(row, dict): + continue + cut, picks = row.get("cut"), row.get("picks") + if not isinstance(cut, int) or isinstance(cut, bool): + continue + if not isinstance(picks, list): + continue + idxs = [p for p in picks if isinstance(p, int) and not isinstance(p, bool)] + if idxs: + out[cut] = idxs + return out + + +def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL, + key=None, timeout: float = 90.0) -> Dict[int, List[int]]: + """컷 자막으로 컷별 추천을 받는다. **실패하면 예외 없이 {}** — 호출부가 폴백한다. + + plan.py._call() 은 parts[0] 에 영상 fileData 가 항상 들어가 재사용할 수 없다. + correct.py 의 텍스트 전용 호출 패턴(responseSchema + 실패 시 안전 반환)을 따른다. + """ + key = key if key is not None else _gemini_key() + if not key or not cuts or not comments: + return {} + k = max(quotas) + 2 if quotas else 3 # 갈아끼울 여유분 + try: + prompt = prompts.load_recommend().format( + cuts=_cut_lines(cuts), comments=_candidate_lines(comments), k=k) + except (KeyError, IndexError, OSError): # 사용자가 프롬프트를 깨뜨린 경우 + return {} + body = { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.3, + "responseMimeType": "application/json", + "responseSchema": _SCHEMA}, + } + req = urllib.request.Request( + _ENDPOINT.format(model=model, key=key), + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + raw = data["candidates"][0]["content"]["parts"][0]["text"] + except (urllib.error.URLError, KeyError, IndexError, TimeoutError, + json.JSONDecodeError, OSError): + return {} # 429 포함 — 폴백은 호출부 몫 + return _parse_ai(raw)