시각 기반 판별을 컷 1개에서 모든 컷으로 일반화

- is_whole() → is_time_based(): 컷 개수와 무관하게 모든 자막이 비었으면 참
  * 통짜 모드(1개 컷) + 유튜브 구간 탭(2개 이상 자막 없는 구간)을 둘 다 지원
  * 자막 없으면 내용 추천 불가 → 시각 기반으로 배정
- _whole_picks() → _time_based_picks(): 여러 컷 반복 처리
  * used 집합을 컷 사이에 공유해 댓글 중복 금지 (앞 컷 우선)
  * 각 컷의 quota에 맞춰 슬롯 배정, 빈 슬롯은 좋아요 상위로 채움
- build_highlight_cuts() 호출부 업데이트
  * 기존 통짜 모드는 그대로 동작 (1개 컷 + 빈 자막 = 참)
  * 새 규칙이 유튜브 구간 탭으로 확장

검증:
- 자막 판별 조건 (empty/whitespace 포함 처리)
- 컷 간 중복 금지 확인
- 자동 탭 회귀 테스트 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 16:59:33 +09:00
parent 6df1930d06
commit 2525fb5231

View File

@ -184,29 +184,42 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
return _rows_to_picks(rows) return _rows_to_picks(rows)
def is_whole(cuts) -> bool: def is_time_based(cuts) -> bool:
"""통짜 하이라이트인가 — 컷 1개 + 자막 없음(app._whole_hl 이 만드는 모양). """시각 기반으로 배정할 컷 묶음인가 — **모든 컷의 자막이 비었는가**.
통짜는 자막이 없어 내용 추천의 근거가 없다. 대신 타임라인 시각 = 원본 시각이라 자막이 없으면 내용 추천의 근거가 없다. 대신 이런 묶음(통짜 모드·유튜브 구간 )
시각으로 정확히 맞출 있다(스펙 §4.1). 구간을 그대로 이어붙이므로 타임라인 시각 = 원본 시각이 성립해 시각으로 맞출 있다.
1개짜리 통짜는 규칙의 특수 케이스다(스펙 §1).
""" """
return len(cuts) == 1 and not (cuts[0].get("bottom") or "").strip() if not cuts:
return False
return all(not (c.get("bottom") or "").strip() for c in cuts)
def _whole_picks(cut, comments, n: int) -> List[dict]: def _time_based_picks(cuts, comments, quotas) -> List[List[dict]]:
"""통짜 — 슬롯마다 그 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 메움.""" """컷마다 3초 슬롯 배정 — 슬롯 시간대 언급 댓글, 빈 슬롯은 좋아요 상위로 채움.
slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n)
used = {i for i in slots if i is not None} `used` 사이에 공유해 댓글이 컷에 들어가지 않게 한다( 우선).
"""
used: set = set()
no_ts = [c for c in comments if not c.get("times")] no_ts = [c for c in comments if not c.get("times")]
out: List[List[dict]] = []
for i, cut in enumerate(cuts):
n = quotas[i] if i < len(quotas) else 0
slots = match_slots(comments, cut["start"], cut["end"] - cut["start"], n,
exclude=used)
used.update(s for s in slots if s is not None)
fill = iter(top_liked(no_ts, used, n)) fill = iter(top_liked(no_ts, used, n))
out: List[dict] = [] picks: List[dict] = []
for s in slots: for s in slots:
if s is not None: if s is not None:
out.append({"idx": s, "why": "ts"}) picks.append({"idx": s, "why": "ts"})
continue continue
nxt = next(fill, None) nxt = next(fill, None)
if nxt is not None: if nxt is not None:
out.append({"idx": nxt, "why": "like"}) picks.append({"idx": nxt, "why": "like"})
used.add(nxt)
out.append(picks)
return out return out
@ -225,8 +238,8 @@ def build_highlight_cuts(hl, comments, *, key=None):
return [], 0, False return [], 0, False
quotas = quotas_for(cuts) quotas = quotas_for(cuts)
ai_failed = False ai_failed = False
if is_whole(cuts): if is_time_based(cuts):
picks = [_whole_picks(cuts[0], comments, quotas[0])] picks = _time_based_picks(cuts, comments, quotas)
else: else:
ai = ai_pick_cuts(cuts, comments, quotas, key=key) ai = ai_pick_cuts(cuts, comments, quotas, key=key)
ai_failed = ai is None ai_failed = ai is None