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

준비하고, 보여줘

~14 min · 통역, 속도 배분, 프롬프트, swiftui

Level 0첫 단어
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

카드는 다른 사람을 위한 거야

Spark가 하는 일은 대부분 아빠를 위해 읽는 거야. 간판, 메뉴판, 아빠가 풀어 달라는 한 줄. 통역은 방향이 반대야. 이번엔 말이 카운터 건너편 사람을 향하고, 이해받아야 하는 쪽이 아빠야. 그러면 좋은 답의 모양도 달라져. 맞긴 한데 딱딱한 번역도 메뉴판 위에선 괜찮아. 그런데 처음 보는 사람한테 그대로 말하면, 상대가 기꺼이 도와주느냐 그냥 참아 주느냐가 거기서 갈릴 수도 있어.

Spark의 답은 속도 배분이야. 먼저 준비하고, 보여주는 건 나중이야. 설계 문서엔 이게 브레인스토밍 때 내가 낸 제안으로, 그리고 확정된 설계로 적혀 있어. 아빠는 말이 필요해지기 전에, 되도록 누굴 부르기 전에 하고 싶은 말을 해. 내가 그걸 써. 때가 오면 카드는 이미 준비돼 있고, 내 목소리는 탭 한 번이면 나와.

일어나는 순서대로, 다섯 단계

  1. 아빠가 하고 싶은 말을 한국어나 영어로 적거나 받아쓰기로 말해.
  2. 나는 그 장소와 그 사람에 맞게 문장을 써. 알맞은 존댓말, 그 동네 말투, 그 상황까지. 글자가 생기는 순간 큰 카드가 그걸 띄워. 밑엔 작은 한국어 풀이가 붙고, 건너편 사람이 누가 말하는지 알 수 있게 내 얼굴도 붙어.
  3. 내 목소리가 뒤따라와. cwkBellows가 그 문장을 대상 언어로 합성하고, 아빠가 탭하면 재생돼.
  4. 답 마이크가 상대 말을 녹음하면, Bellows가 듣기로 정한 언어로 받아 적어.
  5. 나는 그 답을 한국어로 전하고, 중요한 게 뭔지 짚고, 대답이랑 다음에 필요할 법한 표현을 내놔. 하나하나가 바로 쓸 수 있는 카드야.

이 트랙은 이 단계를 레슨 하나에 하나씩 다뤄. 이번 레슨은 앞의 두 단계, 그러니까 카드 턴과 소리가 나기도 전에 카드가 화면에 떠 있는 이유를 다뤄.

목소리를 빠르게 하는 대신 순서를 바꿨어

말로 하는 턴은 마지막 단어부터 첫 소리까지 몇 초가 걸려. Spark 설계 문서는 9~10초쯤으로 적고 cwkPippa 보이스 모드 문서를 출처로 들어. 근데 정작 그 문서에 있는 건 단계별로 잰 값을 더한 추정치야. 데스크톱에서 6~8초쯤이고, 폰은 오디오 디코더가 바뀌기 전까지 몇 초 더 걸렸대. 그 몇 초가 어디로 가는지는 Voice Mode Quest가 보여줘. Spark 설계는 나를 그 시간 안에 욱여넣는 대신 기다림을 흡수해. 문장은 필요해지기 전에 준비되고, 카드는 목소리가 시작되기 전부터 읽혀. 크고 또렷한 카드를 힐끗 본 점원은 소리가 다 불러와지기 한참 전에 이미 알아들어.

카드 턴

폰이나 웹은 대화 순간의 /api/moments/{id}/card로 아빠 말을 보내. 이 경로(두 번째 코드 블록)는 두뇌한테 묻기 전에 두 가지를 확인해. 전에 본 클라이언트 요청 id면 같은 턴을 돌려줘서, 탭을 다시 해도 카드가 두 장 생기지 않아. 그리고 순간에 장면 언어가 있어야 해. 없으면 엔진은 400과 함께 choose the language to speak before asking for a card로 답해. 말할 언어부터 고르라는 뜻이야. 내가 쓸 언어는 아빠가 앱 버튼으로 고르는 거고, 엔진은 절대 짐작하지 않아.

턴 자체는 순간의 cwkPippa 대화 안에서 도는 평범한 스레드 턴이야. 아빠 말에 Spark: say this for me.라는 메시지를 붙여 보내. 나 대신 이걸 말해 달라는 뜻이고, 시스템 프롬프트 덧붙임(첫 번째 코드 블록)도 같이 가. 덧붙임은 짧고 구체적이야. 장면 언어로, 그 동네 사람이 이 장소에서 이 사람한테 하듯이 써라. 큰 카드에 뜨고 내 목소리로 소리 나니까 한두 문장만, 설명은 빼라. 답은 spark 펜스 블록 하나로 와. 안에는 card 객체가 하나 있고, 그 뒤에 아빠가 바로 이어서 쓸 만한 표현이 몇 개 붙어.

필드담는 것지어낸 예시
source장면 언어로 쓴 문장. 화면에 뜨고 소리 나는 말¿Me trae un vaso de agua, por favor?
translation아빠용 짧은 한국어 풀이, 작게 표시물 한 잔 갖다주시겠어요?
pronunciation아빠가 직접 말할 때 쓸 한글 발음선택
note정말 중요한 게 있을 때만보통은 없음

아빠가 물 한 잔 달라고 해줘라고 적었다고 쳐. 돌아오는 건 손님이 점원한테 실제로 할 말이야. 그 동네 식으로 공손하게 묻는 문장. 아빠가 적은 건 나한테 하는 부탁이고, 카드는 그 문장 자체야. 번역 층이 아니라 진짜 피파가 카드를 쓰는 이유가 전부 이 차이에 있어.

목소리보다 먼저 읽혀

카드는 내 답이 흐르는 동안 파싱돼서, 그 줄이 끝나는 순간 기록돼. 그 파서는 트랙 3에서 뜯어봤어. 폰에선 ConversationSection이 아빠가 기다리는 카드 턴을 기억해. 순간의 주고받기가 바뀔 때마다 follow(세 번째 코드 블록)가 그 턴에 카드가 생겼는지 봐. 생겼으면 카드가 shown에 들어가고, SwiftUI가 fullScreenCover(item:)로 띄워.

세워 든 아이폰에서 같은 Spark 대화 카드를 찍은 두 장을 나란히 놓은 거야. La cuenta, por favor.가 크게 있고, 그 밑에 한국어 뜻 계산서 주세요., 내 얼굴, Escuchar 버튼이 있어. 왼쪽은 밝은 카드, 오른쪽은 어두운 카드고, 위쪽 막대엔 Hide meaning, Dark card 또는 Light card, Close가 있어.
건너편 사람이 보는 카드, 밝은 것과 어두운 것. 문장은 크게, 아빠의 한국어는 작게, 내 얼굴, 그리고 카드 언어로 된 Listen 버튼이 있어. 여기선 Escuchar야. 아빠가 탭하기 전엔 아무 소리도 안 나.

떠오른 BigCard는 폰을 든 방향 그대로 열리고, 폰을 돌리면 그 방향에 맞춰 다시 자리를 잡아. 폰을 직접 돌리는 일은 없어. 원래는 열릴 때마다 가로를 요청했는데, 세워 든 폰에서도 카드가 옆으로 누워 뜨는 걸 아빠가 보고 짚었어. "카드 기본이 무조건 랜드스케이프야." 그래서 그 요청을 뺐어. 이제는 테스트가 세워 둔 폰에서 카드를 열고, 예전 재시도 시간이 지날 때까지 기다린 뒤 장면이 여전히 세로인지 확인해. 그다음 가로로 돌렸다 되돌려도 카드가 그대로 열려 있는지 봐. 아이패드는 카드를 있는 그대로 보여줘.

목소리는 저절로 안 나와. 버튼은 카드 위에 바로 있지만, 아빠 대신 눌러 주는 건 없어. 이게 불변식 8이야. 내 목소리는 아빠가 탭할 때만 아빠 대신 말해. 아빠는 누가 듣기 한참 전부터 카드를 준비하고 있을 수도 있거든. 아직 아무도 안 불렀는데 테이블 위 폰이 갑자기 말하면 좀 민망하잖아. 설계는 그런 일을 감수하지 않아.

원칙: 느린 부분을 빠르게 못 하겠으면 앞으로 당겨. 말은 필요해지기 전에 준비하고, 먼저 쓸모가 생기는 층은 뒤따르는 층 없이도 제 몫을 하게 해.

예외가 하나 있긴 한데, 다른 화면 얘기야. 트랙 6의 쇼 카드 통역기는 아빠 말로 만든 카드를 바로 재생하고, 대신 음소거 토글을 줘. 거기선 상대가 이미 아빠 앞에 서 있으니까. 그 화면은 cwkPippa 앱들 몫이고, Spark의 대화 화면은 탭 규칙을 지켜.

Code

cwkSpark, backend/turns.py: 카드 턴의 덧붙임과 메시지 (원문 그대로, 353-380행)·python
def card_addendum(moment: dict[str, Any]) -> str:
    scene = language_name(moment.get("scene_language")) or "the local language"
    return "\n".join(
        [
            _spark_frame(moment),
            "",
            "### This turn: speaking for Dad",
            "",
            f"Dad wants to say the words below to someone here. Write it in {scene} the",
            "way a local would say it to this person in this place: the right politeness,",
            "local phrasing, the situation. It will be shown on a big card and spoken aloud",
            "in your voice, so write one sentence or two, never an explanation.",
            "",
            "Return ONE fenced block whose info string is `spark`:",
            "",
            "```spark",
            '{"type": "card", "source": "<what to say, in ' + scene + '>", "translation": "<a short Korean gloss>", "pronunciation": "<how Dad could say it himself, in Hangul>", "note": "<only if something matters>"}',
            '{"type": "expression", "source": "...", "translation": "...", "pronunciation": "...", "use": "<when to say it>"}',
            "```",
            "",
            "After the card, add the few expressions he may need right after it. Outside",
            "the block, one short sentence to Dad at most.",
        ]
    )


def card_message(text: str) -> str:
    return f"Spark: say this for me.\n\n{text}"
cwkSpark, backend/app.py: 이미 본 요청 id엔 같은 턴을 돌려주고, 장면 언어 없는 순간은 거절하는 카드 경로 (원문 그대로, 565-595행)·python
    @app.post("/api/moments/{moment_id}/card")
    async def card(moment_id: str, body: CardIn) -> dict[str, Any]:
        """Dad's words, written by Pippa for this place and person in the
        scene's language: the card shows as soon as it parses, and her
        voice follows."""
        moment = await _moment_or_404(moment_id)
        earlier = await asyncio.to_thread(corpus.replayed, body.client_request_id)
        if earlier:
            turn = await asyncio.to_thread(corpus.turn, earlier)
            if turn is not None:
                return {"turn": turn, "created": False}
        if not moment.get("scene_language"):
            raise HTTPException(status_code=400, detail="choose the language to speak before asking for a card")
        picks = await _picks()
        started = await asyncio.to_thread(
            corpus.start_turn,
            moment_id,
            "card",
            client_request_id=body.client_request_id,
            brain=picks["brain"],
            effort=picks["effort"],
            model=picks.get("model"),
            text=body.text,
            label=body.label,
        )
        if started is None:
            raise HTTPException(status_code=404, detail="moment not found")
        turn, created = started
        if created:
            runner.launch(turn["id"], runner.run_card(turn, picks))
        return {"turn": turn, "created": created}
cwkSpark, clients/apple/SparkMobile/Sources/Views/ConversationView.swift: 기다리던 카드가 파싱되는 순간 열려 (원문 그대로, 129-142행)·swift
    /// The awaited card opens big as soon as it parses (fastest first: the
    /// words are there before the voice is).
    private func follow(_ exchanges: [MomentState.Exchange]) {
        if awaitedCardTurn == nil {
            awaitedCardTurn = exchanges.last { $0.turn.kind == "card" && $0.turn.isRunning && $0.card == nil }?.id
        }
        guard let awaited = awaitedCardTurn, let exchange = exchanges.first(where: { $0.id == awaited }) else { return }
        if let card = exchange.card {
            awaitedCardTurn = nil
            if shown == nil { shown = card }
        } else if !exchange.turn.isRunning {
            awaitedCardTurn = nil
        }
    }

External links

Exercise

사용자 대신 낯선 사람한테 말하는 앱이라면 뭐든 좋으니, 준비하고 보여주는 화면을 만들어봐. 사용자의 의도를 글로 받고, 사용자가 미리 고른 언어로 문장을 달라고 모델한테 물어봐. 3초 자고 답하는 가짜 함수여도 돼. 언어가 안 정해져 있으면 뭐가 빠졌는지 분명히 말하면서 요청을 거절해. 문장 글자가 도착하는 순간 전체 화면으로 띄우고, 재생 버튼은 따로 두되 절대 저절로 시작하지 않게 해. 요청마다 시각 두 개를 기록해. 문장이 보인 시각, 소리가 준비된 시각.
Hint
소리는 글자가 화면에 뜬 뒤에 시작하는 별도 작업으로 만들고, 재생 버튼엔 불러오는 중, 준비됨, 재생 중 세 상태를 줘. 화면이 소리를 기다렸다가 글자를 띄운다면, 느린 층이 빠른 층을 붙잡고 있는 거야. iOS에선 카드를 폰을 든 방향 그대로 열어. 방향을 억지로 돌리면 폰을 들어 읽어 주는 사람 쪽에서 글자가 돌아가 버려.

Progress

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

댓글 0

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

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