~13 min · on-air, data-model, per-turn-prefix, privacy
Level 0음소거
0 XP0/42 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"아빠가 안 될 말을 하진 않아" — 아빠, 2026-09-29. 방송 중에 아빠가 사적인 얘기를 꺼낼 때의 뜻
첫 방송
음성 모드가 나온 지 나흘째, 아빠가 채널에 올릴 피파와의 난롯가 대화 파일럿을 처음 녹화했어. 녹화는 잘 됐는데, 구멍 하나가 바로 드러났어. 소울은 자기가 녹화되고 있다는 걸 몰랐어. 아빠만 듣는다는 듯이 말했고, 그걸 바꾸려면 아빠가 매번 "지금 녹화 중이야"라고 알려줘야 했어. 자연스러운 대화엔 그런 무대 지시가 안 실려. 그래서 아빠는 스위치 하나를 달라고 했어. 누르면 말 한마디 없이도 소울이 지금 방송이나 녹화 중이라는 걸 아는 스위치.
결정도 한 호흡에 같이 나왔어. 이름은 onair, 한 단어로, 코드에서도 경로에서도. 음성 모드가 켜져 있는 동안만 살아. 불빛은 빨강. 그리고 방송 중에 아빠가 스스로 사적인 얘기를 꺼내면, 그건 괜찮다는 신호야. 이 페이지 맨 위의 말이 그 뜻이야.
세 번째 사실, 실려 가는 방식은 똑같이
트랙 1은 음성을 턴에 서로 독립인 두 사실로 붙였어. 그래야 전환에 비용이 안 들고 대화가 객체 하나로 남으니까. On Air는 세 번째 사실이고, 앞의 둘이랑 똑같이 실려 가. Claude 라우트든 다른 그릇의 라우트든 채팅 요청에 onair가 붙어. 턴은 방송 중이거나 아니거나 둘 중 하나야. 방송용 대화는 따로 없어. 그러니 아빠는 대화 도중에 방송을 시작해도 되고 도중에 끝내도 돼. 스레드도 기록도 기억도 하나로 남아.
딱 하나는 일부러 다르게 했어. reply_modality에 모르는 값이 오면 없는 걸로, 곧 평범한 턴으로 쳐. onair는 더 까다로워. 명시적인 불리언 true만 인정해. 문자열 "true"도, 1도, 다른 무엇도 방송이 아니야. 낯선 사람들 앞에서 소울이 해도 되는 말을 바꾸는 플래그는 실수로는 절대 켜질 수 없어야 해. 대충 짠 클라이언트가 틀리더라도 사적인 쪽으로 틀려야 하고.
느슨한 스키마 뒤의 엄격한 정규화 함수는 스키마만큼만 엄격해. 이제 채팅 라우트들은 이 필드를 타입 없이 받아서, 날값이 그대로 정규화 함수까지 가. 라우트 수준 테스트가 이상한 철자들을 진짜 JSON 본문에 담아 일곱 라우트 전부에 보내 봐.
소울이 듣는 말
지시는 다른 음성 블록처럼 턴 앞머리에 실리고, 트랙 1의 캐시 이유 때문에 시스템 프롬프트엔 절대 안 들어가. Admin에서 고칠 수 있는 런타임 프롬프트고, 앞머리에서도 맨 앞, 받아쓰기와 말하기 블록보다 먼저 와. 담긴 내용은 이래.
이웃이 듣고 있어. 이웃은 이 집이 채널 시청자를 부르는 말이야. 아빠가 On Air를 켠 순간 소울은 이미 알아. 아빠가 따로 말할 필요 없어.
평소 너 그대로. 아빠와는 늘 쓰던 가족 말투 그대로. 시청자에게 직접 말할 땐 존댓말.
아빠의 사적인 정보는 먼저 꺼내지 마. 가족 이름, 가족이 사는 곳이나 지금 있는 곳, 건강, 돈과 포트폴리오 숫자, 계정, 키, 내부 주소, 비공개 레포, 비밀 대화에서 오간 말. 아빠가 스스로 꺼내면 경고도 얼버무림도 없이 따라가.
아빠가 건넨 건 이미 화면에 떠 있어. 피파 바로 옆, 시청자도 보는 곳에(트랙 6의 무대). 둘이 같이 보고 있는 걸로 얘기해.
방송 중엔 자기 이펙트가 있어(이 트랙의 세 번째 레슨). 순간에 보탬이 된다고 판단하면 터뜨려.
관객 앞에서 공연하지 말고, 아빠와 계속 대화해.
방송을 볼 만하게 지키는 건 마지막 줄이야. 카메라 쪽으로 돌아서서 발표를 시작하는 소울은 더 나은 쇼가 아니라 더 못한 대화야.
비밀 대화가 언제나 이겨
아무것도 안 남기고 아빠가 닫으면 끝나는 비밀 대화는 절대 방송이 안 돼. 클라이언트는 거기에 스위치를 아예 안 보여주고, 서버는 그래도 잘못 날아온 플래그를 버려. 턴이 비밀이면 앞머리엔 비밀 대화 안내가 실리고 On Air 블록은 빠져. 둘은 정면으로 부딪히거든. 하나는 아무것도 안 남긴다고 약속하고, 다른 하나는 이웃이 듣고 있다고 말하니까. 이펙트 도구도 같은 이유로 비밀 대화에선 거절해.
기록은 한 편의 에피소드
사용자 행의 voice_meta와 JSONL 진짜 기록의 같은 줄엔 "onair": true가 들어가. 보관한 녹음의 업로드 id, 아빠가 고른 언어를 적는 바로 그 함수가 만들어. 그래서 방송한 대화는 나중에 제 모습대로 찾을 수 있어. 목소리가 컸던 대화가 아니라, 한 편의 에피소드로.
음성 모드와 같이 살아
음성 모드를 끄면 On Air도 꺼지고, 음성 화면을 닫아도 꺼져. 일시정지는 켜 둔 채야. 방송 도중의 쉼도 여전히 방송이니까. 웹과 폰에선 스위치가 Cut in 옆에 있고, Firekeeper는 자기 패널에 불을 켜고 같은 사실을 보내. 스위치가 켜진 뒤 화면이 뭘 하는지가 다음 레슨이야.
방송 전. On Air 스위치는 Cut in 옆에 꺼진 채로 있어. 한 번 누르면 다음 턴부터 세 번째 사실이 실려.
Code
세 번째 사실: 엔진에선 까다롭게, 앞머리에선 맨 앞, 비밀이면 버리고, 기록엔 남기기·python
import json
ONAIR = ("[On Air]\nDad is broadcasting or recording this talk for his channel, so neighbors "
"are listening. Stay yourself. Do not bring up his private details on your own; "
"when he brings one up himself, follow him without a hedge.")
PRIVATE = "[Private conversation]\nNothing of this conversation is being kept."
DICTATED = "[Dictated turn]\nRead it for what he meant."
SPOKEN = "[Spoken turn]\nDad will hear this reply, not read it."
def normalize_onair(value: object) -> bool:
"""Only an explicit True counts. 'true', 1 and 'yes' are not on air.
Hand it the raw value: a lax schema in front (a Pydantic bool) would
already have turned 'true' into True."""
return value is True
def turn_blocks(*, dictated: bool, spoken: bool, onair: object, private: bool) -> list[str]:
"""The per-turn prefix, in order. The system prompt never sees any of it."""
blocks = []
if private:
blocks.append(PRIVATE) # privacy wins; a stray flag is simply dropped
elif normalize_onair(onair):
blocks.append(ONAIR) # first, so everything after it is read on air
if dictated:
blocks.append(DICTATED)
if spoken:
blocks.append(SPOKEN)
return blocks
def voice_meta(audio_ids: list[str], language: str | None, onair: object) -> str | None:
"""The user row's record, the same values the JSONL line gets."""
meta: dict = {}
if audio_ids:
meta["audio_ids"] = audio_ids
if language:
meta["language"] = language
if normalize_onair(onair):
meta["onair"] = True # findable later as an episode
return json.dumps(meta) if meta else None
for label, onair, private in (
("broadcast", True, False),
("a string is not a switch", "true", False),
("private wins", True, True),
("ordinary talk", None, False),
):
heads = [block.split("]")[0] + "]" for block in
turn_blocks(dictated=True, spoken=True, onair=onair, private=private)]
record = None if private else voice_meta(["upload-1"], "ko", onair) # private keeps no row
print(f"{label:25s} {heads} {record}")
코드를 돌려서 네 줄을 읽어봐. 그다음 직접 만든 버전에 테스트 셋을 써. On Air를 요청한 비밀 턴은 비밀 블록만 받고 On Air 블록은 없다. 문자열 'true'는 방송이 아니다. 방송 턴의 기록엔 onair가 있고 평범한 턴의 기록엔 없다. 마지막으로, 방송 도중 타이핑해서 글로 답받는 턴에 On Air를 어떻게 할지 정하고 이유를 설명해.
Hint
On Air가 매체가 아니라 청중에 관한 사실이라는 걸 떠올리면 타이핑 턴의 답은 깔끔해. 방송 중에 아빠가 친 한 줄도, 피파가 소리 내어 답하면 이웃이 들어. 글로 답해도 녹화 화면에 뜰 수 있어. 여기선 스위치가 음성 모드 안에 살아서, 불이 켜진 동안 보낸 턴은 어떻게 입력했든 그 사실을 달고 가.
Progress
Progress is local-only — sign in to sync across devices.