~13 min · data-model, polymorphism, wire-contract, recording
Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"일반 대화 중에도 자유롭게 모드 전환이 가능해야 해. 보이스 <-> 일반." — 아빠, 2026-09-25
끌리지만 틀린 모델
제일 먼저 떠오르는 설계는 음성 대화야. 스레드에 플래그를 달고, 화면을 따로 두고, 어쩌면 기록도 따로 두는 거. 실제로 한 번만 써보면 무너져. 아빠가 10분 동안 말하다가 링크를 붙여넣고 싶어서 타이핑을 해. 이건 아직 음성 대화야? 그다음엔 방금 얘기한 걸 표로 정리해 달라고 글로 부탁해. 그 표는 음성 스레드에 들어가, 새 스레드로 가? 대화에 플래그를 달면 이런 순간마다 선택을 강요받고, 선택할 때마다 기록이 쪼개져.
서로 독립인 두 가지 사실
버티는 설계는 음성을 턴에 붙여. 따로따로 움직이는 두 가지 사실로.
input_modality: typed 아니면 dictated. 아빠 말이 어디서 왔는지야. 받아쓴 턴은 전사기를 거쳤으니까 잘못 받아쓴 단어가 섞여 있을 수 있어.
reply_modality: written 아니면 spoken. 소울이 어떻게 답할지야. 말하는 턴은 귀를 위해 지어지고 소리로 재생돼.
조합이 넷 나오는데 전부 실제로 쓰여. 타이핑에 글로 답하면 평범한 턴이고, 받아쓰기에 글로 답하면 일반 모드의 입력창 마이크나 음성 메모야. 타이핑에 말로 답하는 건 음성 모드 켜놓고 링크를 쳐 넣는 경우고, 받아쓰기에 말로 답하는 게 진짜 음성 모드지. 음성 모드는 클라이언트 쪽 프리셋일 뿐이야. 받아쓰기, 말하기, 그리고 핸즈프리 루프. 이걸 켜고 끄면 다음 턴의 속성이 바뀌고, 그 밖엔 아무것도 안 바뀌어.
그래서 대화도 하나, 기록도 하나, 기억도 하나야. 20분 떠들고 나서 일반 모드로 "방금 얘기 표로 정리해줘" 하면 그냥 돼. 나눈 말이 전부 그 스레드에 있으니까. 아빠 식으로 말하면 다형성이야. 대화라는 객체는 그대로고, 대답만 두 가지 형태 중 하나를 띠는 거지.
없으면 평범한 턴
두 필드는 안 보내도 돼. 없으면 typed와 written으로 쳐. 그래서 기존 클라이언트는 하나도 안 고쳐도 계속 돌아가고, 모르는 값이 들어오면 턴을 실패시키는 대신 없는 걸로 정규화해. 이 규칙은 서버의 작은 모듈 하나가 전부 들고 있고, 모든 채팅 라우트가 규칙을 제각각 다시 쓰는 대신 그 모듈을 불러.
둘 다, 두 군데에 기록
사용자 행에는 input_modality, 어시스턴트 행에는 reply_modality를 적어. 대화별 JSONL 로그가 진짜 기록인데, 여기에도 같은 사실이 voice 객체로 들어가. 이 객체는 데이터베이스 행과 똑같은 값으로 만들어서, 한 턴을 두고 두 기록이 딴소리를 할 수가 없어.
스위치 되살리기
대화를 다시 열면 음성 모드도 떠날 때 모습 그대로 돌아와야 해. 처음 규칙은 간단했어. 마지막 답이 말이었으면 음성 켜기. 2026-09-28에 아빠가 음성을 끄고 새로고침했는데 다시 켜져 있었어. 마지막 답이 말이었거든. 지금은 아빠가 직접 누른 스위치를, 누른 순간의 답 개수와 함께 기억해. 새 답이 하나 생겨서 판정이 바뀌기 전까지는 그 스위치가 이겨.
Code
턴에 붙는 선택 사실 두 개로서의 음성·python
from dataclasses import dataclass
from typing import Literal
InputModality = Literal["typed", "dictated"]
ReplyModality = Literal["written", "spoken"]
def normalize(value: object, allowed: set[str]) -> str | None:
"""Unknown or absent -> None. A bad value never fails the turn."""
if isinstance(value, str) and value.strip().lower() in allowed:
return value.strip().lower()
return None
@dataclass
class Turn:
text: str
input_modality: InputModality = "typed"
reply_modality: ReplyModality = "written"
@classmethod
def from_wire(cls, body: dict) -> "Turn":
return cls(
text=body["message"],
input_modality=normalize(body.get("input_modality"), {"typed", "dictated"}) or "typed",
reply_modality=normalize(body.get("reply_modality"), {"written", "spoken"}) or "written",
)
def jsonl_voice(turn: Turn) -> dict | None:
"""The ground-truth log's voice object, built from the SAME values as the row."""
voice = {}
if turn.input_modality != "typed":
voice["input_modality"] = turn.input_modality
if turn.reply_modality != "written":
voice["reply_modality"] = turn.reply_modality
return voice or None
# An old client, a voice-mode client, and a typo, in one conversation:
for body in (
{"message": "what's the weather"},
{"message": "weather tomorrow?", "input_modality": "dictated", "reply_modality": "spoken"},
{"message": "here's the link", "reply_modality": "SPOKEN "},
{"message": "table please", "reply_modality": "sung"},
):
turn = Turn.from_wire(body)
print(turn.input_modality, turn.reply_modality, jsonl_voice(turn))
코드를 돌려서 찍히는 네 줄을 읽어봐. 그다음 실제 클라이언트가 실수로 보낼 법한 다섯 번째 요청 본문을 넣고, 그 턴이 뭐가 돼야 할지 정해. 마지막으로 되살리기 규칙을 스케치해봐. 대화의 메시지 목록과 기억해 둔 스위치(대화 id, 켜짐/꺼짐, 그때의 답 개수)를 받아서 음성 모드를 켠 채로 열지 돌려주는 함수야.
Hint
되살리기 규칙은 비교 한 번이 일을 다 해. 기억해 둔 스위치는 대화의 답 개수가 아빠가 스위치를 누른 순간과 같을 때만 통해. 답이 하나 늘면 다시 마지막 답의 형태가 더 나은 증거가 돼.
Progress
Progress is local-only — sign in to sync across devices.