Gemini 텍스트 호출로 컷별 댓글 추천 구현
correct.py의 텍스트 호출 패턴을 따라 ai_pick_cuts() 및 헬퍼 함수(_candidate_lines, _cut_lines, _parse_ai)를 추가했다.
- 목차 댓글 필터링(MAX_TIMES 기준)
- 좋아요순 정렬
- 응답 파싱 시 형식 오류는 조용히 버림
- 모든 실패(키 없음, 타임아웃, JSON 깨짐 등)에 {} 반환
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8367219b7f
commit
7cb70cc33a
@ -8,9 +8,14 @@ Gemini 없이 단위 검증이 된다.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from typing import Dict, List
|
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) 과 같은 값.
|
# 카드 1장이 차지하는 기준 시간(초). pipeline._load_comment_cards(min_sec) 과 같은 값.
|
||||||
CARD_SEC = 3.0
|
CARD_SEC = 3.0
|
||||||
@ -52,3 +57,99 @@ def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]],
|
|||||||
used.add(idx)
|
used.add(idx)
|
||||||
out.append(picks)
|
out.append(picks)
|
||||||
return out
|
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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user