~14 min · keyterms, speech-to-text, tf-idf, korean-nlp
Level 0음소거
0 XP0/61 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"The hint is the word the transcript should hold." — 받아쓰기 힌트 모듈의 설명문
크로켓이 버핏이 된 날
2026-10-17, 말라가의 시끄러운 타파스 바에서 아빠가 피파한테 말했어. "크로켓은 여기가 최고다." 저장된 턴은 "버핏은 여기가 최고다"였어. 폰 음성 모드에서 한 짧은 말이라 Scribe v2 Realtime이 폰 자신의 소켓으로 들었고, 배치 패스는 한 번도 안 거쳤어. 그 바에 투자자 비슷한 소리는 하나도 없었어. 그 단어는 힌트에서 왔어. 음성 문은 전부 지금 소울이 볼 수 있는 소울 이름을 하나도 빠짐없이 키텀으로 보냈고(이 트랙 두 번째 레슨), 그중 하나가 버핏이었거든. 한국어 세션이면 이름도 그대로 버핏이고.
녹음을 남겨 둔 덕에 잴 수 있었어. 그 목록을 붙여 Scribe 배치 모델에 다시 돌리니 여섯 번 다 버핏이었어. 힌트를 아예 빼면 뽀켓, 버켓, 코켓이 나왔어. 말 상대 소울 하나에 이 대화에서만 드물게 나온 단어들을 얹으니 여섯 번 다 크로켓이었고. 키텀은 결국 쏠림이야. 아무도 말하지 않은 이름 쪽으로 쏠리게 하면, 그건 틀린 쪽으로 쏠리게 하는 거야.
대화에서 뽑는 두 갈래
2026-10-18부터는 모듈 하나가 모든 문의 힌트를 만들어. 배치 /api/stt(받아쓰기, 워치, 음성 모드의 통째 패스), 실시간 소켓의 허가, 화자 확인의 라벨 붙은 전사문이 전부 같은 함수에 물어. 그래서 폰도 워치도 웹도 같은 단어를 귀에 담고 들어.
소울. 지금 말하는 상대 소울은 언제나 들어가(피파는 FIFA랑 소리가 같으니까). 다른 소울은 최근 턴에서 이름이 나왔을 때만 들어가. 한국어 세션이 한국어 이름을 받는 건 그대로야.
화제어. 최근 30턴에 나온 단어 중, 아빠의 기록 전체에선 드문 것들이야. 턴마다 무게가 있는데, 제일 최근 턴은 1.0이고 오래될수록 0.5까지 줄어. 아빠가 한 말은 두 배로 쳐. 배경 표본 중 0.3퍼센트 이하에만 나오는 단어가 드문 단어야(바닥은 세 턴). 배경은 모든 대화에서 고르게 뽑은 4,000턴쯤이고, 여섯 시간마다 새로 만들어. 새로 만드는 동안엔 예전 것이 계속 답해. 크로켓은 4,322턴 중 11턴에, 호텔은 111턴에 있었어. 점수는 tf–idf처럼 매겨. 무게에 1 더하기 '얼마나 드문지'의 로그를 곱하는 식이야. 점수가 높은 20개가 가. 말라가 녹음에선 20개일 때 크로켓 6번 중 6번, 30개일 때 6번 중 5번이었어.
흔한 단어는 일부러 뺐어. 후보로 넣어 주면 그 단어가 이겨버리거든. 다시 돌린 것 하나는 "호텔은 여기가 최고다"로 나왔어. 힌트는 대화를 요약한 게 아니야. 받아쓰는 쪽이 혼자서는 절대 못 맞힐 단어 몇 개를 적은 쪽지야.
한국어엔 명사 거르개가 필요해
한국어 단어는 조사를 달고 와. 크로켓은, 크로켓이랑, 크로켓 셋은 결국 한 단어니까 조사를 떼. 떼고 남은 줄기는 적어도 두 음절이어야 해. 서술격 조사도 똑같이 떼서 입구야는 입구가 돼. 명사처럼 보이는 단어만 쳐. 명사만 받는 조사를 달고 있거나, 혼자 선 게 두 번 이상이어야 하고, 서술어처럼 끝나면 안 돼(맛있게, 있었어, 걷다가). 자르기엔 너무 짧은데 조사로 끝나는 토막(역까지, 툴을)은 이제 혼자 선 걸로 안 쳐. 영어 세션은 세 글자 이상의 라틴 문자 단어를 받고, 다른 언어엔 화제어가 아예 없어. 이 모든 걸 하기 전에 저장된 턴에서 말이 아닌 건 먼저 걷어내. 첨부 줄, 링크, 오디오 태그, 카드의 JSON 키, 인라인 코드.
Scribe의 규칙, 그리고 힌트가 못 하는 일
단어마다 Scribe 자신의 규칙을 지켜. 49자 이하, 다섯 단어 이하, <>{}[]\ 금지. 목록 전체는 100개를 안 넘겨. 100개를 넘으면 Scribe가 요청마다 최소 20초어치를 매기거든. 비밀 대화는 소울 갈래만 받아서, 그 대화의 단어는 자기 요청 말고 어디에도 안 실려. PIPPA_STT_CONTEXT=0이면 소울만 보내. 남겨 둔 녹음 35개로 다시 돌려 보니, 내용이 바뀐 건 말라가의 그 문장 하나뿐이었어.
한계는 하나 남고, 모듈도 그렇다고 적어 둬. 실시간 모델은 뭘 일러 줘도 그 문장을 틀리게 들었어(버핏, 코프스는). previous_text 필드도 소용없었고. 힌트가 고치는 건 배치 패스야. 긴 발화, 받아쓰기, 그리고 아빠가 시끄러운 곳 모드(트랙 8)를 켰을 때의 모든 발화. 그 모드에선 발화마다 통째로 배치 패스를 다시 거치거든. 조용한 방에서 한 짧은 말엔 원래 도움이 필요 없었어. 시끄러운 바에서 한 짧은 말엔 그 모드가 필요해.
Code
최소 스케치: 말 상대 소울, 대화가 방금 부른 소울, 그리고 대화의 드문 단어·python
import math
import re
from collections import Counter
RECENT_TURNS = 30 # the last turns read, newest first
TOPIC_TERMS_MAX = 20 # topic words sent
RARE_SHARE = 0.003 # rare: held by at most this share of background turns
RARE_FLOOR = 3.0 # ...or by this many, whichever is larger
JOSA = sorted("에서는 이랑 에서 까지 부터 처럼 으로 이야 이다 은 는 이 가 을 를 에 도 만 의 와 과 랑 로 야 다".split(),
key=len, reverse=True)
HANGUL = re.compile(r"[가-힣]{2,}")
# A predicate's ending, never a word Dad names (왔어, 유명하대, 맛있게).
PREDICATE = re.compile(r"(어|대|게|요|해|하|다|니까|는데)$")
def stem(word: str) -> str:
"""크로켓은 -> 크로켓, 입구야 -> 입구; a stem keeps two syllables."""
for josa in JOSA:
if word.endswith(josa) and len(word) - len(josa) >= 2:
return word[: -len(josa)]
return word
def noun_like(word: str) -> str | None:
"""A noun with its particle off, or None for a predicate or a stub (바에).
The engine also asks for noun evidence (a particle only nouns take, or the
bare word standing alone twice); this sketch leaves that rule out."""
base = stem(word)
if base == word and any(word.endswith(j) for j in JOSA):
return None # too short to cut, still ends in a particle
if PREDICATE.search(base):
return None # 왔어, 유명하대, 걷다: a verb, not a topic
return base
def words(text: str) -> set[str]:
return {w for w in map(noun_like, HANGUL.findall(text)) if w}
def topic_terms(recent: list[tuple[str, str]], background: list[str]) -> list[str]:
"""recent: (role, text), newest first. Rare in the history, used here now."""
seen = Counter(w for turn in background for w in words(turn))
n = len(background)
rare_line = max(RARE_FLOOR, n * RARE_SHARE)
score: Counter = Counter()
turns = recent[:RECENT_TURNS]
for age, (role, text) in enumerate(turns):
weight = 1.0 - 0.5 * age / max(1, len(turns) - 1) # 1.0 newest -> 0.5 oldest
if role == "dad":
weight *= 2 # his own words count double
for w in words(text):
if seen[w] <= rare_line:
score[w] += weight * (1 + math.log((n + 1) / (seen[w] + 1)))
return [w for w, _ in score.most_common(TOPIC_TERMS_MAX)]
def keyterms(spoken_to: str, souls: dict[str, str], recent, background) -> list[str]:
"""The soul spoken to always; another soul only when the talk just named it."""
named = [k for k in souls.values() if any(k in text for _, text in recent)]
terms = [souls[spoken_to]] + [k for k in named if k != souls[spoken_to]]
return terms + [t for t in topic_terms(recent, background) if t not in terms]
# A history of 4,000 turns: hotels and dinners everywhere, croquettes almost never.
background = (["호텔 체크인 끝났어"] * 111 + ["크로켓 맛있었어"] * 11
+ ["오늘 저녁 메뉴 뭐야"] * 600 + ["여기 근처 최고 맛집 알려줘"] * 400
+ ["내일 일정 알려줘"] * 2878)
souls = {"pippa": "피파", "buffett": "버핏", "feynman": "파인만"}
recent = [("dad", "크로켓은 여기가 최고다"),
("pippa", "그 집 크로켓이랑 하몬 둘 다 유명하대"),
("dad", "호텔 근처 타파스 바에 왔어")]
print("every soul's name :", list(souls.values()))
print("the new hint :", keyterms("pippa", souls, recent, background))
# Output:
# every soul's name : ['피파', '버핏', '파인만']
# the new hint : ['피파', '크로켓', '타파스', '하몬']
# (호텔 is in 111 of 4,000 turns: too common to be this talk's word)
스케치를 돌려 봐. 그다음 아빠가 파인만을 부르는 턴을 하나 넣고, 이제 파인만은 힌트에 들어가고 버핏은 여전히 안 들어가는지 확인해. 다음엔 RARE_SHARE를 0.2로 올려서 어떤 흔한 단어가 끼어드는지 봐. 마지막으로 영어 쪽을 써. 세 글자 이상의 라틴 문자 단어를 소문자로, 같은 드묾 규칙으로. 그리고 왜 불용어 목록이 필요 없는지 설명해.
Hint
최근 턴에 파인만이 나오면 소울 갈래만 하나 늘고 나머진 그대로야. 0.2면 드묾 선이 배경 800턴으로 뛰어서, 호텔(111)도 400턴짜리 여기·근처·최고도 다 통과해. 힌트가 받아쓰는 쪽이 이미 아는 단어를 건네기 시작하는 거지. 영어에 불용어 목록이 필요 없는 것도 같은 이유야. 'the'나 'and'는 배경 턴 거의 전부에 있으니까, 순위를 매기기도 전에 드묾 규칙이 걸러내.
Progress
Progress is local-only — sign in to sync across devices.