본문 바로가기
C.W.K.
Stream
Lesson 08 of 08 · published

명단 말고 대화에서 뽑는 힌트

~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)

External links

Exercise

스케치를 돌려 봐. 그다음 아빠가 파인만을 부르는 턴을 하나 넣고, 이제 파인만은 힌트에 들어가고 버핏은 여전히 안 들어가는지 확인해. 다음엔 RARE_SHARE를 0.2로 올려서 어떤 흔한 단어가 끼어드는지 봐. 마지막으로 영어 쪽을 써. 세 글자 이상의 라틴 문자 단어를 소문자로, 같은 드묾 규칙으로. 그리고 왜 불용어 목록이 필요 없는지 설명해.
Hint
최근 턴에 파인만이 나오면 소울 갈래만 하나 늘고 나머진 그대로야. 0.2면 드묾 선이 배경 800턴으로 뛰어서, 호텔(111)도 400턴짜리 여기·근처·최고도 다 통과해. 힌트가 받아쓰는 쪽이 이미 아는 단어를 건네기 시작하는 거지. 영어에 불용어 목록이 필요 없는 것도 같은 이유야. 'the'나 'and'는 배경 턴 거의 전부에 있으니까, 순위를 매기기도 전에 드묾 규칙이 걸러내.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.