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

이미 만난 글자는 바로 나와

~13 min · 코퍼스, 캐시, sqlite, 원샷 호출

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

묶음은 엔진이 먼저 봐

폰이 보낸 묶음은 POST /api/live/translate에 떨어져. 라이브 순간 id 하나에 항목이 1개에서 40개까지 실려 있고, 항목마다 키 하나와 500자 이하 글자 하나가 들어 있어. 이 경로는 라이브가 아닌 순간은 거절해(평범한 순간을 보내고 400을 기대하는 테스트가 있어). 거기를 통과하면 live_lane 슬롯에 어느 두뇌가 배정됐는지 확인하고, 전부 턴 실행기로 넘겨. 실행기는 어떤 두뇌도 부르기 전에 항목마다 코퍼스한테 먼저 물어봐.

"같은 글자"를 가리키는 키 하나

맞춰보기는 source_key에서 시작해. 공백을 한 칸으로 접고, casefold()를 걸어. casefold()는 대소문자 구분 없이 비교하라고 만든 거라, 파이썬의 그냥 소문자화보다 세. 원문 글자가 있는 결과는 사진에서 왔든 대화에서 왔든 라이브 카메라에서 왔든, 기록될 때 이 키를 달아. 미러의 results 테이블엔 (source_key, target_language, seq) 인덱스가 있고, known_translation은 그 위에서 도는 인덱스 쿼리 한 번이야. 키와 목표 언어가 같고 종류가 line, live-line, expression 중 하나인 결과 가운데 제일 최근 걸 찾아.

핵심은 그 종류 셋이야. 사진 찍은 메뉴판에서 내가 읽어준 줄, 대화 중에 내가 건넨 표현, 지난주에 라이브로 본 간판. 그중 뭐든 오늘 카메라한테 답이 돼. 그리고 쿼리가 seq 기준으로 제일 최근 걸 가져오니까, 나중에 고친 답이 다음 조회의 답이 돼. 트랙 3에서 본 대로 수정은 덮어쓰기가 아니라 새 결과거든. 함정은 그 다음 조회가 언제냐야. 폰은 세션 동안 받은 답을 메모리에 들고 있고, 같은 글자는 엔진에 두 번 안 물어(레슨 1). 그래서 카메라가 켜져 있는 동안 고친 답은 지금 세션엔 안 닿고, 다음 라이브 세션에 가서야 닿아. 설계 문서엔 그 효과가 숫자로 적혀 있어. 측정에 쓴 간판 넷이 코퍼스에서 20 ms 만에 돌아왔대.

캐시에서 찾아도 기록은 남겨

캐시에서 찾았다고 문자열만 돌려주고 끝내진 않아. 실행기는 이 라이브 순간에 새 live-line 결과를 써. 거기엔 스캐너가 읽은 글자 그대로, 이미 아는 번역과 발음, 그리고 베껴 온 결과를 가리키는 cached_from이 들어가. 두뇌가 안 돌았으니 턴은 없어. 그래서 라이브 순간엔 답을 받은 글자가 전부 남아. 빌려 온 답엔 출처도 붙어 있고. 언제 어디서는 순간 자체가 들고 있는 값이야. 세션을 열 때 폰이 잰 장소와 시각이지, 글자마다 따로 잰 게 아니야.

독스트링이 약속하는 건 셋이야. 이미 만난 글자는 코퍼스에서 바로 답하고, 나머지는 짧은 호출 한 번으로 피파한테 가고, 글자는 전부 이 라이브 순간에 코퍼스로 남는다는 거. 근데 두 군데는 적힌 것보다 느슨해. 끝까지 번역 못 한 글자는 결과가 아예 안 생겨서 코퍼스에 안 남아(아래). 그리고 바로라는 말은 조회까지만 맞아. 경로는 묶음 전체가 끝나야 폰한테 답해. 그래서 아는 글자랑 새 글자가 섞인 묶음에선, 아는 답도 내 호출이 끝날 때까지 기다렸다가 내 답이랑 같이 도착해.

못 찾은 건 짧은 호출 한 번

코퍼스가 모르는 건 한꺼번에 나한테 와. 실행기는 두뇌와 항목 수를 적은 live 턴을 기록하고, cwkPippa 형제 유틸리티 문에 보낼 원샷 요청을 만들어. 요청엔 번호를 붙인 못 찾은 글자 목록, 시스템 프롬프트, 슬롯의 두뇌와 노력 수준이 들어가. 거기에 max_tokens: 0(Spark 쪽 상한 없음), persona_soul_id: "pippa", source: "spark-live"가 붙어. 문은 대화 없이, 도구도 끈 채로 이걸 돌려. 엔진은 60초까지 기다려.

시스템 프롬프트는 짧고 엄해. 목표 언어를 말하고, 순간에 상황이랑 장소가 있으면 덧붙여. 한 줄에 JSON 객체 하나씩 답하라고 하는데, 거기엔 번호와 번역, 그리고 아빠가 소리 내서 말할 만한 짧은 것에만 발음을 넣으라고 해. 번역은 원래 간판만큼 짧게 하라는 주문도 있어. 답은 parse_json_lines가 읽어. 중괄호로 시작하는 줄을 다 가져오고, 펜스나 끝에 붙은 쉼표는 봐주고, 나머지는 건너뛰어. 객체마다 n으로 원래 항목을 찾아가.

번역된 건 전부 그 턴 아래 live-line이 돼. 끝까지 번역 안 된 건 pending으로 돌려보내고, 폰은 그 키를 풀어서 나중에 다시 물어(레슨 1). pending인 글자는 결과를 하나도 안 남겨서 순간에도 없어. 물어봤다는 흔적은 기껏해야 턴에 적힌 항목 수야. 턴은 하나라도 돌아왔으면 completed, 하나도 없으면 failed로 끝나. 걸린 시간, 모델, 노력 수준, 오류도 같이 남아.

기계 번역으로 메우지 않아

설계하던 날, 이 모드엔 아직 못 정한 질문이 하나 있었어. 기계 번역이라고 표시한 Apple 온디바이스 번역으로 빈칸부터 채워 두고, 몇 초 뒤에 내 답이 오면 갈아끼울까? 아빠 답은 두 마디였어.

A로 가.

여기서도 피파만 쓴다는 뜻이야. 그래서 레인엔 두 번째 길이 없어. 배정된 두뇌가 없거나 문이 거절하면, 못 찾은 글자는 오류와 함께 pending으로 돌아가. 폰은 그 오류를 상태 캡슐에 띄우고. 짐작으로 채우는 건 없어. 새 글자는 몇 초 기다리고, 이미 만난 글자는 묶음에 새 글자가 없으면 바로 나와. 섞여 있으면 내 답이랑 같이 오고. 이 둘이 한 쌍이고, 이 레인이 약속하는 건 그게 다야.

재시작도 같은 생각으로 다뤄. 엔진이 멈출 때 돌고 있던 live 턴은 다음에 켜질 때 interrupted로 적히고, 다시 확인하지 않아. 물어볼 cwkPippa 대화가 없거든. 그 글자가 화면에 다시 들어오면 카메라가 그냥 또 물어.

원칙: 지난 기록에 답이 이미 있으면 거기서 답하고, 그렇게 했다는 것도 적어 둬. 자기가 맞힌 걸 기록하는 캐시는 기억이고, 안 하는 캐시는 지름길일 뿐이야.

테스트 test_live_text_already_met_answers_from_the_corpus_and_the_rest_from_pippa가 처음부터 끝까지 증명해. 먼저 La cuenta를 담은 텍스트 순간을 번역해 둬. 이어서 라이브 묶음이 공백 두 칸에 대문자인 La CUENTA랑 Salida를 보내. 앞엣것은 원래 줄을 가리키는 cached_from을 달고 코퍼스에서 나오고, 가짜 문까지 가는 건 라이브 슬롯의 low 노력 수준을 단 1. Salida 하나뿐이야. 두 번째 묶음의 소문자 salida도 코퍼스에서 나오고, 문은 여전히 한 번만 불렸어. 인덱스 쪽 이야기는 SQLite Quest에 자세히 있어.

Code

cwkSpark, backend/turns.py: 코퍼스가 먼저, 찾으면 라이브 순간에 기록 (원문 그대로, 1478-1508행)·python
        """Translate what the live camera found. Text already met answers from
        the corpus at once; the rest goes to Pippa in one short call. Every
        text joins the corpus on this live moment, with where it was seen."""
        target = moment.get("target_language") or "ko"
        answered: list[dict[str, Any]] = []
        misses: list[dict[str, Any]] = []
        for item in items:
            key = source_key(item["text"])
            known = await asyncio.to_thread(
                self.corpus.known_translation, key, target, kinds=LIVE_KNOWN_KINDS
            )
            if known is None:
                misses.append(item)
                continue
            fields = {
                "kind": "live-line",
                "source": item["text"],
                "translation": known.get("translation"),
                "source_key": key,
                "cached_from": known["id"],
            }
            if known.get("pronunciation"):
                fields["pronunciation"] = known["pronunciation"]
            record = await self._write(
                self.corpus.add_result,
                moment["id"],
                turn_id=None,
                target_language=target,
                fields=fields,
            )
            answered.append({"key": item["key"], "from": "corpus", "result": record})
cwkSpark, backend/mirror.py: 이 글자를 이미 번역한 가장 최근 결과 (원문 그대로, 700-710행)·python
    def known_translation(
        self, key: str, target_language: str, *, kinds: tuple[str, ...]
    ) -> dict[str, Any] | None:
        """The newest result that already translated this text."""
        marks = ",".join("?" for _ in kinds)
        row = self.db.execute(
            "SELECT record FROM results WHERE source_key = ? AND target_language = ? "
            f"AND kind IN ({marks}) ORDER BY seq DESC LIMIT 1",
            (key, target_language, *kinds),
        ).fetchone()
        return json.loads(row[0]) if row else None
cwkSpark, backend/turns.py: 못 찾은 글자는 페르소나 원샷 호출 한 번으로 피파한테 (원문 그대로, 1514-1545행)·python
            started = await self._write(
                self.corpus.start_turn,
                moment["id"],
                "live",
                brain=picks["brain"],
                model=picks.get("model"),
                items=len(misses),
            )
            turn_record = started[0] if started else None
            body = {
                "prompt": live_prompt(misses),
                "system_prompt": live_system_prompt(moment, scene_language),
                "brain": picks["brain"],
                "effort": picks["effort"],
                "model": picks.get("model"),
                "max_tokens": 0,
                "persona_soul_id": SOUL_ID,
                "source": "spark-live",
            }
            loop = asyncio.get_running_loop()
            started_at = loop.time()
            try:
                envelope = await self.pippa.utility(body, timeout=LIVE_TIMEOUT_SECONDS)
            except PippaError as exc:
                envelope = {"ok": False, "result": "", "error": str(exc)}
            elapsed_ms = int((loop.time() - started_at) * 1000)
            by_number = {}
            for obj in parse_json_lines(str(envelope.get("result") or "")):
                try:
                    by_number[int(obj.get("n"))] = obj
                except (TypeError, ValueError):
                    continue

External links

Exercise

만들고 있는 프로젝트의 느린 번역 호출이나 모델 호출 앞에 기록부터 보는 길을 하나 깔아봐. 입력마다 정규화해서 키를 만들고, 키도 같고 목표도 같은 예전 답 가운데 제일 최근 걸 찾아. 찾으면 베껴 온 항목을 가리키는 새 항목으로 기록해. 모델엔 못 찾은 것만 번호 목록 하나로 보내고, 한 줄에 JSON 객체 하나씩 답하라고 한 다음, 번호로 원래 항목을 찾아가. 빈 채로 돌아온 키는 돌려줘. 마지막으로 테스트를 써. 공백이랑 대소문자만 다른 같은 글자는 캐시에서 나와야 하고, 묶음 두 번에 걸쳐 모델은 딱 한 번만 불려야 해.
Hint
정규화한 키는 찾을 때만 만들지 말고, 항목을 쓸 때 같이 저장해. 그리고 목표 언어, 순서 열이랑 묶어서 인덱스를 걸어. 시스템에서 수정이 새 항목으로 들어간다면, 최신순으로 정렬하는 것만으로 고친 답이 저절로 이겨.

Progress

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

댓글 0

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

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