Gemini 추천 실패를 화면에 알린다 (조용히 삼키지 않음)

ai_pick_cuts 가 429·타임아웃·JSON 파싱 실패를 전부 {} 로 삼켜서,
build_highlight_cuts 는 여전히 타임스탬프만으로 채운 cuts 를 돌려주고
app.py 의 `if cuts:` 가 참이 되어 except 가 절대 타지 않았다.
스펙 §7이 요구한 "⚠ 추천 실패 → 기존 방식" 경고가 화면에 안 떴다.

- ai_pick_cuts: 실패는 None, 성공은 dict(빈 dict 포함).
  키가 없는 경우는 실패가 아니다 — 키 없이 쓰는 것도 정상 사용이라 {} 를 준다.
  실패로 치는 건 HTTP·타임아웃·응답 파싱·프롬프트 치환 오류뿐.
- 응답 파싱을 _rows_to_picks() 로 떼어내 '깨진 응답'(None)과
  '어울리는 게 없어 빈 배열'({})을 구분한다. _parse_ai() 계약은 그대로 둔다.
- build_highlight_cuts → (cuts, need, ai_failed) 3-튜플.
  통짜 모드는 Gemini 를 안 부르므로 ai_failed=False.
- build_cut_picks 는 ai_picks=None 이어도 안전(`ai_picks or {}`).
- app.py: ai_failed 면 warnings 에 "AI 추천 실패 — 타임스탬프만으로 배정" 한 줄.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 14:09:19 +09:00
parent 6f90efd2bc
commit 1e43530933
2 changed files with 51 additions and 17 deletions

View File

@ -9,9 +9,8 @@ Gemini 없이 단위 검증이 된다.
from __future__ import annotations from __future__ import annotations
import json import json
import urllib.error
import urllib.request import urllib.request
from typing import Dict, List from typing import Dict, List, Optional
from . import prompts from . import prompts
from .comments import MAX_TIMES, match_ranges, match_slots, top_liked from .comments import MAX_TIMES, match_ranges, match_slots, top_liked
@ -26,7 +25,7 @@ def quotas_for(cuts) -> List[int]:
return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts] return [max(1, round((c["end"] - c["start"]) / CARD_SEC)) for c in cuts]
def build_cut_picks(cuts, comments, ai_picks: 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, 전 컷 통틀어 중복 금지.
@ -34,9 +33,11 @@ def build_cut_picks(cuts, comments, ai_picks: Dict[int, List[int]],
근거가 확실하다. 추측(AI) 이기게 이유가 없다. 근거가 확실하다. 추측(AI) 이기게 이유가 없다.
중복은 컷이 가져간다( 컷은 다음 후보로 밀린다). 중복은 컷이 가져간다( 컷은 다음 후보로 밀린다).
ai_picks: {컷인덱스: [댓글idx ]} Gemini 실패 {} 넘기면 타임스탬프만으로 채운다. ai_picks: {컷인덱스: [댓글idx ]} Gemini 실패 None/{} 어느 쪽을 넘겨도
타임스탬프만으로 채운다(`ai_pick_cuts` 실패를 None 으로 알린다).
Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}] Returns: 컷별 [{"idx": int, "why": "ts"|"ai"}]
""" """
ai_picks = ai_picks or {}
valid = {c["idx"] for c in comments} valid = {c["idx"] for c in comments}
used: set = set() used: set = set()
out: List[List[dict]] = [] out: List[List[dict]] = []
@ -97,13 +98,22 @@ def _cut_lines(cuts) -> str:
def _parse_ai(raw: str) -> Dict[int, List[int]]: def _parse_ai(raw: str) -> Dict[int, List[int]]:
"""Gemini 응답 → {컷인덱스: [댓글idx …]}. 형식이 어긋난 항목은 조용히 버린다.""" """Gemini 응답(문자열) → {컷인덱스: [댓글idx …]}. 형식이 어긋나면 {}.
여기서는 '깨진 응답' '아무것도 안 고름' {} . 둘을 구분해야 하는
호출부(`ai_pick_cuts`) json.loads 직접 하고 `_rows_to_picks()` 쓴다.
"""
try: try:
data = json.loads(raw) data = json.loads(raw)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return {} return {}
if not isinstance(data, list): if not isinstance(data, list):
return {} return {}
return _rows_to_picks(data)
def _rows_to_picks(data: list) -> Dict[int, List[int]]:
"""파싱된 배열 → {컷인덱스: [댓글idx …]}. 형식이 어긋난 항목은 조용히 버린다."""
out: Dict[int, List[int]] = {} out: Dict[int, List[int]] = {}
for row in data: for row in data:
if not isinstance(row, dict): if not isinstance(row, dict):
@ -120,21 +130,28 @@ def _parse_ai(raw: str) -> Dict[int, List[int]]:
def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL, def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
key=None, timeout: float = 90.0) -> Dict[int, List[int]]: key=None, timeout: float = 90.0) -> Optional[Dict[int, List[int]]]:
"""컷 자막으로 컷별 추천을 받는다. **실패하면 예외 없이 {}** — 호출부가 폴백한다. """컷 자막으로 컷별 추천을 받는다. **예외를 올리지 않는다** — 호출부가 폴백한다.
Returns:
- dict 성공( dict 성공: "어울리는 게 없다" 정상 답이다). 키가 없어도
{} 없이 쓰는 것도 정상 사용이라 실패로 치지 않는다.
- None **실패**. HTTP·타임아웃·응답 파싱·프롬프트 치환 오류.
화면에 "AI 추천 실패" 경고를 띄우려면 둘을 구분해야 한다
(실패해도 타임스탬프 배정은 그대로 돌아가서 겉보기엔 멀쩡하다).
plan.py._call() parts[0] 영상 fileData 항상 들어가 재사용할 없다. plan.py._call() parts[0] 영상 fileData 항상 들어가 재사용할 없다.
correct.py 텍스트 전용 호출 패턴(responseSchema + 실패 안전 반환) 따른다. correct.py 텍스트 전용 호출 패턴(responseSchema + 실패 안전 반환) 따른다.
""" """
key = key if key is not None else _gemini_key() key = key if key is not None else _gemini_key()
if not key or not cuts or not comments: if not key or not cuts or not comments:
return {} return {} # 부를 이유가 없다 — 실패가 아니다
k = max(quotas) + 2 if quotas else 3 # 갈아끼울 여유분 k = max(quotas) + 2 if quotas else 3 # 갈아끼울 여유분
try: try:
prompt = prompts.load_recommend().format( prompt = prompts.load_recommend().format(
cuts=_cut_lines(cuts), comments=_candidate_lines(comments), k=k) cuts=_cut_lines(cuts), comments=_candidate_lines(comments), k=k)
except (KeyError, IndexError, ValueError, OSError): # 사용자가 프롬프트를 깨뜨린 경우 except (KeyError, IndexError, ValueError, OSError): # 사용자가 프롬프트를 깨뜨린 경우
return {} return None
body = { body = {
"contents": [{"parts": [{"text": prompt}]}], "contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.3, "generationConfig": {"temperature": 0.3,
@ -151,8 +168,14 @@ def ai_pick_cuts(cuts, comments, quotas, *, model: str = AI_MODEL,
raw = data["candidates"][0]["content"]["parts"][0]["text"] raw = data["candidates"][0]["content"]["parts"][0]["text"]
except (KeyError, IndexError, json.JSONDecodeError, ValueError, except (KeyError, IndexError, json.JSONDecodeError, ValueError,
OSError): # URLError, TimeoutError are OSError subclasses; ValueError covers UnicodeDecodeError OSError): # URLError, TimeoutError are OSError subclasses; ValueError covers UnicodeDecodeError
return {} # 429 포함 — 폴백은 호출부 몫 return None # 429 포함 — 폴백은 호출부 몫
return _parse_ai(raw) try:
rows = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return None # 응답이 JSON 이 아니다 = 실패
if not isinstance(rows, list):
return None # 스키마와 다른 모양 = 실패
return _rows_to_picks(rows)
def is_whole(cuts) -> bool: def is_whole(cuts) -> bool:
@ -182,22 +205,27 @@ def _whole_picks(cut, comments, n: int) -> List[dict]:
def build_highlight_cuts(hl, comments, *, key=None): def build_highlight_cuts(hl, comments, *, key=None):
"""하이라이트 하나 → (cuts[], need). 모드는 컷 모양으로 판별한다. """하이라이트 하나 → (cuts[], need, ai_failed). 모드는 컷 모양으로 판별한다.
Returns: ([{"i","sec","bottom","quota","picks"}], need) Returns: ([{"i","sec","bottom","quota","picks"}], need, ai_failed)
need = Σ quota 기존 int(total//3) 대체한다( 경계에 맞추는 쪽이 맞다). need = Σ quota 기존 int(total//3) 대체한다( 경계에 맞추는 쪽이 맞다).
Gemini 실패는 여기서 흡수된다(ai_pick_cuts {} 준다) 예외를 올리지 않는다. ai_failed = Gemini 호출이 **실패**했는가(429·타임아웃·파싱). 통짜 모드는 Gemini
부르므로 항상 False. 호출부는 값으로만 경고를 띄운다 실패해도 cuts
타임스탬프만으로 채워져 비지 않으므로, cuts 비었는지로는 실패를 없다.
Gemini 실패는 여기서 흡수된다(ai_pick_cuts None 준다) 예외를 올리지 않는다.
""" """
cuts = (hl.get("paste") or {}).get("cuts") or [] cuts = (hl.get("paste") or {}).get("cuts") or []
if not cuts: if not cuts:
return [], 0 return [], 0, False
quotas = quotas_for(cuts) quotas = quotas_for(cuts)
ai_failed = False
if is_whole(cuts): if is_whole(cuts):
picks = [_whole_picks(cuts[0], comments, quotas[0])] picks = [_whole_picks(cuts[0], comments, quotas[0])]
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
picks = build_cut_picks(cuts, comments, ai, quotas) picks = build_cut_picks(cuts, comments, ai, quotas)
out = [{"i": i, "sec": round(c["end"] - c["start"], 1), out = [{"i": i, "sec": round(c["end"] - c["start"], 1),
"bottom": c.get("bottom") or "", "quota": quotas[i], "picks": picks[i]} "bottom": c.get("bottom") or "", "quota": quotas[i], "picks": picks[i]}
for i, c in enumerate(cuts)] for i, c in enumerate(cuts)]
return out, sum(quotas) return out, sum(quotas), ai_failed

View File

@ -525,6 +525,7 @@ async def auto_stream(aid: str) -> StreamingResponse:
# 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다. # 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다.
# 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은 # 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은
# 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게. # 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게.
#
no_ts = [c for c in comments if not c["times"]] no_ts = [c for c in comments if not c["times"]]
for h in highlights: for h in highlights:
if "paste" not in h: if "paste" not in h:
@ -535,10 +536,15 @@ async def auto_stream(aid: str) -> StreamingResponse:
# 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다. # 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다.
# Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다. # Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다.
try: try:
cuts, need = await asyncio.to_thread( cuts, need, ai_failed = await asyncio.to_thread(
recommend.build_highlight_cuts, h, comments) recommend.build_highlight_cuts, h, comments)
if cuts: if cuts:
h["cuts"], h["need"] = cuts, need h["cuts"], h["need"] = cuts, need
if ai_failed:
# cuts 는 타임스탬프만으로 채워져 비지 않는다 — 이 신호가 없으면
# 429·타임아웃이 조용히 삼켜져 아무도 모른다.
warnings.append(
f"ID {h.get('id')} AI 추천 실패 — 타임스탬프만으로 배정했습니다")
except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다 except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다
warnings.append( warnings.append(
f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 " f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 "