Skip to content

Commit 6b30e8c

Browse files
wjdtjddns98claude
andauthored
feat(script): 비트 수 3 고정 — 완료율 A/B 종료 (#127)
PO 지시(2026-07-31): "3비트로 고정하자, 4비트 너무 김". - _BEAT_COUNT_VARIANTS를 [3]으로 줄여 3/4 A/B를 끝냈다(4 복귀는 리스트에 4 추가). - _BEAT_COUNT 기본값도 3으로 정렬. - 4비트 전제 테스트를 3비트 기준으로 정렬(_valid_body3 도입, 규칙별 케이스 인덱스 재배치, 라이브/dry_run 비트 수 핀 갱신). 편당 클립 4→3개라 Veo 비용도 ~$0.34 절감된다. 글자수 하드룰(33~46자)은 PO 지시 범위 밖이라 건드리지 않았다. 다만 run28 실측에서 script.hard_rule_gave_up(tries=3)이 났다 — 모델이 이 범위를 3회 안에 못 맞춘 사례가 실재하므로 잘림·품질 이슈가 재발하면 이 범위가 후속 후보다. 참고: SCRIPT_SYSTEM_PROMPT = build_script_system_prompt(4) 모듈 상수는 테스트 핀 용도로만 남아 있고, 실사용 경로는 build_script_system_prompt(pick_beat_count(...))라 3비트로 나간다(PR #121도 이 상수는 그대로 뒀다). ruff + pytest 504 passed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 63ab8f4 commit 6b30e8c

2 files changed

Lines changed: 33 additions & 38 deletions

File tree

nutti/integrations/ai_text.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,14 @@ def pick_episode_format(key: str, avoid: str = "") -> str:
143143

144144

145145
# 완료율 A/B(2026-07-21 PO): 2026 쇼츠 알고리즘은 완료율·루프 재생을 최우선한다 —
146-
# 32초(4비트)는 완주 부담이 커서 24초(3비트) 단축판을 편별 50/50로 실측 비교한다.
147-
# 승자가 정해지면 이 리스트를 한 값으로 줄이면 된다(A/B 종료).
148-
_BEAT_COUNT_VARIANTS = [3, 4]
146+
# 32초(4비트)는 완주 부담이 커서 24초(3비트) 단축판을 편별 50/50로 실측 비교했다.
147+
# A/B 종료(2026-07-31 PO "3비트로 고정하자, 4비트 너무 김") — 3비트(≈27초) 고정.
148+
# 4비트로 되돌리려면 이 리스트에 4를 다시 넣으면 된다.
149+
_BEAT_COUNT_VARIANTS = [3]
149150

150151

151152
def pick_beat_count(key: str) -> int:
152-
"""문자열(주제) CRC32로 편별 비트 수(3/4)를 결정한다 — 완료율 A/B 단일 소스."""
153+
"""문자열(주제) CRC32로 편별 비트 수를 결정한다 — 비트 수 단일 소스."""
153154
return _BEAT_COUNT_VARIANTS[zlib.crc32(f"beats:{key}".encode()) % len(_BEAT_COUNT_VARIANTS)]
154155

155156
# ==================== PO 수정 구역 (주제·메타데이터 SEO) ====================
@@ -458,7 +459,7 @@ def _split_into_beats(text: str, n: int = 4) -> list[str]:
458459
# 프롬프트 지시는 모델이 "참고사항"으로 취급해 간헐적으로 어긴다(실측: 의성어·발음
459460
# 리스크 단어 잔존). 아래 규칙은 코드 레벨로 강제하고, 위반 시 위반 사유를 붙여
460461
# 자동 재생성한다. 목록은 실측 축적 — 새 사례가 나오면 여기에 추가.
461-
_BEAT_COUNT = 4
462+
_BEAT_COUNT = 3
462463
# 지시상 38~44자지만 하드룰은 완충(재생성 무한루프 방지). 이 범위 밖만 반려.
463464
# 2026-07-10 PO 지시로 상한을 타이트하게(40~46→38~44) — 발화가 8초를 덜 채울수록
464465
# 비트 경계 유사도 매칭 여유(발화 끝~클립 끝)가 커져 스티칭이 더 매끄럽다(실측).

tests/test_ai_text.py

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ def test_generate_script_dry_run():
5050
assert script.fact_checked is True
5151

5252

53-
def test_generate_script_dry_run_fills_four_beats():
54-
"""dry_run 대본은 영상 비트 4개(훅·핵심·팁·마무리)로 분할돼 채워진다."""
53+
def test_generate_script_dry_run_fills_beats():
54+
"""dry_run 대본은 영상 비트 3개(훅·핵심/팁·마무리)로 분할돼 채워진다."""
5555
script = _client().generate_script("강아지 닭가슴살 간식 적정량")
56-
assert len(script.beats) == 4
56+
assert len(script.beats) == 3
5757
assert all(b.strip() for b in script.beats)
5858

5959

@@ -103,13 +103,11 @@ def test_script_system_prompt_pins_strong_hook():
103103
assert "먹으면서 말하는" in SCRIPT_SYSTEM_PROMPT
104104

105105

106-
def test_pick_beat_count_deterministic_and_both_variants():
107-
"""완료율 A/B: 주제 해시로 3/4비트가 결정적으로 갈리고, 두 변형이 모두 나온다."""
106+
def test_pick_beat_count_is_three():
107+
"""비트 수는 3 고정(2026-07-31 PO "3비트로 고정하자, 4비트 너무 김")."""
108108
from nutti.integrations.ai_text import pick_beat_count
109109

110-
assert pick_beat_count("고구마") == pick_beat_count("고구마")
111-
seen = {pick_beat_count(f"주제-{i}") for i in range(100)}
112-
assert seen == {3, 4}
110+
assert {pick_beat_count(f"주제-{i}") for i in range(100)} == {3}
113111

114112

115113
def test_build_script_system_prompt_three_beats():
@@ -146,15 +144,10 @@ def test_generate_script_dry_run_beats_follow_ab():
146144

147145

148146
def test_split_beats_uses_topic_beat_count():
149-
"""REVISE 재분할도 주제 해시 비트 수를 따른다(대본 생성과 단일 소스)."""
150-
from nutti.integrations.ai_text import pick_beat_count
151-
147+
"""REVISE 재분할도 pick_beat_count를 따른다(대본 생성과 단일 소스)."""
152148
client = _client()
153149
body = "1문장이야.\n2문장이야.\n3문장이야.\n4문장이야."
154-
topic3 = next(f"주제-{i}" for i in range(100) if pick_beat_count(f"주제-{i}") == 3)
155-
topic4 = next(f"주제-{i}" for i in range(100) if pick_beat_count(f"주제-{i}") == 4)
156-
assert len(client.split_beats(body, topic3)) == 3
157-
assert len(client.split_beats(body, topic4)) == 4
150+
assert len(client.split_beats(body, "주제-0")) == 3
158151

159152

160153
def test_vlog_format_rule_has_fail_twist():
@@ -405,18 +398,24 @@ def _valid_body() -> str:
405398
return "\n".join(lines)
406399

407400

401+
def _valid_body3() -> str:
402+
"""운영 기본인 3비트(훅·핵심·CTA) 통과 본문 — _valid_body에서 설명 1줄 제거."""
403+
lines = _valid_body().splitlines()
404+
return "\n".join([lines[0], lines[1], lines[3]])
405+
406+
408407
def test_validate_script_body_passes_clean_script():
409408
"""하드룰 전부 통과하는 대본은 위반 0건."""
410409
from nutti.integrations.ai_text import validate_script_body
411410

412-
assert validate_script_body(_valid_body()) == []
411+
assert validate_script_body(_valid_body3()) == []
413412

414413

415414
def test_validate_script_body_catches_each_rule():
416415
"""규칙별 검출: 비트 수·글자수·의성어·발음 리스크·브랜드명·마지막 느낌표."""
417416
from nutti.integrations.ai_text import validate_script_body
418417

419-
base = _valid_body().splitlines()
418+
base = _valid_body3().splitlines()
420419

421420
def swapped(idx: int, line: str) -> str:
422421
lines = base[:]
@@ -427,11 +426,11 @@ def swapped(idx: int, line: str) -> str:
427426
cases = [
428427
(0, "강아지가 콜록콜록 기침하면 열에 아홉은 놓치는 위험 신호가 있어요", "의성어"),
429428
(1, "짧은 대사", "38~44자"),
430-
(2, "귀진드기 감염은 초기에 잡아야 해요 가려움 신호를 놓치지 마세요 꼭", "발음"),
431-
(3, "Nutti 계산기로 우리 아이 맞춤 급여량을 오늘 바로 확인해 보세요", "브랜드"),
432-
(3, "프로필 링크의 간식 계산기로 우리 아이 맞춤 급여량을 확인하세요!", "느낌표"),
429+
(1, "귀진드기 감염은 초기에 잡아야 해요 가려움 신호를 놓치지 마세요 꼭", "발음"),
430+
(2, "Nutti 계산기로 우리 아이 맞춤 급여량을 오늘 바로 확인해 보세요", "브랜드"),
431+
(2, "프로필 링크의 간식 계산기로 우리 아이 맞춤 급여량을 확인하세요!", "느낌표"),
433432
]
434-
assert any("4줄" in v for v in validate_script_body("\n".join(base[:3])))
433+
assert any("3줄" in v for v in validate_script_body("\n".join(base[:2])))
435434
for idx, line, keyword in cases:
436435
violations = validate_script_body(swapped(idx, line))
437436
assert any(keyword in v for v in violations), (keyword, violations)
@@ -441,7 +440,8 @@ def test_generate_script_regenerates_on_hard_rule_violation(monkeypatch):
441440
"""하드룰 위반 대본 → 위반 사유를 붙여 자동 재생성, 통과본으로 확정."""
442441
settings = Settings(NUTTI_DRY_RUN=False, ANTHROPIC_API_KEY="", NUTTI_ENV="test")
443442
client = AITextClient(settings) # _client=None → 폴백(_llm_text) 경로
444-
bodies = iter(["강아지가 콜록콜록 짧은 대사", _valid_body()])
443+
good = _valid_body3()
444+
bodies = iter(["강아지가 콜록콜록 짧은 대사", good])
445445
prompts: list[str] = []
446446

447447
def fake_llm(full, **_kw):
@@ -451,17 +451,11 @@ def fake_llm(full, **_kw):
451451
monkeypatch.setattr(client, "_llm_text", fake_llm)
452452
# 간식 선정도 _llm_text를 쓰므로 페이크 iterator를 소모하지 않게 고정값으로 대체.
453453
monkeypatch.setattr(client, "suggest_food", lambda _t: ("고구마 스틱", "sweet potato"))
454-
# _valid_body()는 4줄이므로 4비트 버킷 주제로 고정(A/B 도입 후 3비트 주제면 반려됨).
455-
from nutti.integrations.ai_text import pick_beat_count
456-
457-
topic = next(
458-
t for t in (f"간식 적정량-{i}" for i in range(50)) if pick_beat_count(t) == 4
459-
)
460-
script = client.generate_script(topic)
454+
script = client.generate_script("간식 적정량")
461455
assert len(prompts) == 2 # 1회 위반 → 1회 재생성
462456
assert "하드룰 위반" in prompts[1] and "의성어" in prompts[1] # 위반 사유가 피드백으로
463-
assert script.body == _valid_body()
464-
assert len(script.beats) == 4
457+
assert script.body == good
458+
assert len(script.beats) == 3
465459

466460

467461
def test_generate_script_gives_up_after_max_tries(monkeypatch):
@@ -493,9 +487,9 @@ def test_fact_check_parse_failure_fails_safe():
493487

494488
def test_generate_script_live_populates_beats():
495489
"""라이브 Anthropic 경로도 beats를 채운다(되돌리면 영상이 8초 단일컷으로 퇴화 → 회귀 핀)."""
496-
msg = _Msg([_Block("text", text="훅 문장\n핵심 문장\n팁 문장\n마무리 문장")])
490+
msg = _Msg([_Block("text", text="훅 문장\n핵심 문장\n마무리 문장")])
497491
script = _live_client(msg).generate_script("강아지 간식")
498-
assert len(script.beats) == 4
492+
assert len(script.beats) == 3
499493
assert script.beats[0] == "훅 문장"
500494
assert script.beats[-1] == "마무리 문장"
501495
# 안전 불변식: 생성 단계는 fact_checked=False여야 한다(오직 fact_check_script만 승격).

0 commit comments

Comments
 (0)