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

카드: 보이되 읽히지 않는

~13 min · cards, markdown, rendering, tts-safety

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"피파 아바타가 디폴트 화면(감정 포함). 보여줄게 필요하면 카드로 표시" — 아빠, 2026-09-25

봐야 하는 것도 있어

말하는 답엔 표도, 코드 조각도, URL도, 비행기 시간표 열두 줄도 들어갈 수 없고, 넣으려고 해서도 안 돼. 그래도 아빠는 대화 도중에 그런 걸 물어봐. 답은 거절하는 것도, 소리 내어 읽는 것도 아니야. 다른 데 두는 거야. 그 다른 데가 카드야. 정보 문자열이 card인 펜스 블록이지. 안에 든 건 평범한 마크다운이고 화면에 카드로 그려져. 그중 어느 것도 소리로 읽히지 않아. 지시는 소울에게 봐야 할 건 카드에 넣고, 그것에 관해 한 문장 말하고, 계속 이야기하라고 해.

문법 하나, 화면 전부

펜스를 고른 건 일부러야. 마크다운 렌더러는 전부 이미 펜스 블록을 한 덩어리로 다루고, 모델은 전부 이미 그걸 쓸 줄 알고, 음성 엔진은 이미 합성 전에 펜스 블록을 걸러내. 그래서 새로 짠 코드는 작았어. 공용 메시지 컴포넌트에 카드 렌더러 하나. 폰의 답 칸이 바로 그 패키지 컴포넌트라서, 웹에 카드가 뜬 날 폰에도 떴어. 음성 화면에선 카드가 소울 얼굴 아래 나타나고, 폰은 카드 안의 마크다운 표를 진짜 격자로 그려.

피파가 말하는 동안의 웹 음성 화면. 보라색 고리 안에서 웃는 얼굴, 'Speaking. Talk or click the face to cut in.'이라는 한 줄, 일시정지 버튼, 그 아래 '보이스 모드는 이렇게 움직여요'라는 제목으로 듣기, 생각하기, 말하기 세 단계를 적은 카드.
피파가 말하는 동안 얼굴 아래 뜬 카드. 목소리는 이걸 절대 안 읽고, 카드는 화면에만 있어.

백틱 네 개 문제

이 레슨이 따로 한 페이지를 차지하는 건 예외 상황 하나 때문이야. 코드 블록을 품어야 하는 카드는 백틱 세 개를 못 써. 안쪽 펜스가 카드를 일찍 닫아버리거든. 마크다운의 답은 더 긴 펜스야. 카드를 백틱 네 개로 열고 닫으면 안의 백틱 세 개짜리 블록은 그냥 내용이 돼. 지시도 정확히 그렇게 말해. 그런데 백틱 세 개짜리 펜스만 아는 흔한 '코드 블록 지우기' 처리는 엉뚱한 펜스끼리 짝을 지어서 가운데를 남겨두고, 그 가운데가 바로 코드야. 예제를 돌려서 이 순진한 처리가 목소리에 뭘 넘기는지 들어봐. "python"이라는 단어와 코드 한 줄이야.

그래서 cwkPippa는 카드를 직접 걷어내. 여는 펜스 길이를 기억했다가 같은 펜스에서만 닫는 패턴으로, 자기가 가진 TTS 라우트 전부가 음성 엔진에 글을 넘기기 전에 이걸 적용해. 클라이언트는 몰라도 돼. 웹 클라이언트는 같은 패턴으로 답을 글 조각과 카드 조각으로 나눠. 양쪽에 똑같이 옮겨 두고, 양쪽 다 테스트해.

URL은 카드에

말하는 턴이 제일 자주 공유하고 싶어 하는 게 링크야. 읽으면 소음이고, 빼면 사라져. 카드 안에 넣으면 화면에서 링크가 되고, 글로 쓴 답에서 링크를 그려주던 응답 부품 장치도 그대로 그걸 그려줄 수 있어. 카드는 그냥 평범한 마크다운이니까.

Code

카드를 아는 걸러내기와 흔한 순진한 걸러내기·python
import re

CARD = re.compile(
    r"^(?P<fence>`{3,})card[ \t]*\n.*?\n(?P=fence)[ \t]*$\n?",
    re.MULTILINE | re.DOTALL,
)
# The common "drop code blocks" regex, which knows only three-backtick fences:
ANY_FENCE = re.compile(r"```.*?```", re.DOTALL)

reply = """Here's the fix, it's one line. I put it on your screen.

````card
```python
retries = 3
```
````

Want me to run the tests after?
[emotion:happy]"""


def speakable(text: str) -> str:
    return CARD.sub("", text)


def splits(text: str) -> list[tuple[str, str]]:
    """What a screen shows: text segments and card segments, in order."""
    out, last = [], 0
    for match in CARD.finditer(text):
        if match.start() > last:
            out.append(("text", text[last:match.start()]))
        body = match.group(0).split("\n", 1)[1].rsplit(match.group("fence"), 1)[0]
        out.append(("card", body.strip()))
        last = match.end()
    if last < len(text):
        out.append(("text", text[last:]))
    return out


print("--- generic fence stripper would speak:")
print(ANY_FENCE.sub("", reply))
print("--- card-aware stripper speaks:")
print(speakable(reply))
print("--- the screen shows:")
for kind, body in splits(reply):
    print(kind, repr(body[:40]))

External links

Exercise

예제를 돌려서 세 출력을 비교해. 그다음 표와 URL을 품은 카드를 하나 더 넣고, 카드 밖에 평범한 백틱 세 개짜리 코드 블록도 하나 넣어. 말하는 턴에서 카드 밖 코드 블록이 어떻게 돼야 할지 정하고, 그 결정대로 speakable()을 고쳐.
Hint
말하는 턴에 카드 밖 코드 블록이 있다는 건 모델이 지시를 어겼다는 뜻이야. 음성 엔진은 원래 평범한 펜스 블록을 걸러내니까 읽히진 않아. 문제는 화면엔 그래도 보여줘야 하느냐야. 보여주는 쪽이 더 친절하고, 고칠 곳은 내용을 소리 없이 숨기는 걸러내기가 아니라 프롬프트야.

Progress

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

댓글 0

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

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