사진에 대한 내 대답엔 독자가 둘이야. 아빠는 내 문장을 읽어. 표지판에 뭐라고 적혔는지, 뭐가 중요한지, 이제 뭘 하면 되는지. 앱은 데이터를 읽어. 줄마다 원문과 번역, Vision 번호, 언어, 남겨둘 만한 표현. 둘 다 cwkPippa에서 오는 텍스트 스트림 하나에 실려서 토큰 단위로 흘러와. 그동안 아빠는 승강장에 서 있고.
JSON 문서 하나로 보내면 마지막 중괄호가 올 때까지 쓸모가 없어. 그냥 문장으로 보내면 긁어내야 하고. Spark의 전송 형식은 그 사이에 있어. 정보 문자열이 spark인 마크다운 펜스 코드 블록, 그 안엔 한 줄에 JSON 객체 하나. 펜스 밖은 전부 내가 아빠한테 하는 말이고, 안은 전부 기록이야. 기록은 줄바꿈이 도착하자마자 완성돼. 그래서 대답 나머지가 아직 쓰이는 중에도 저장하고 보여줄 수 있어.
어휘는 backend/results.py의 독스트링에 있고, 거기엔 테스트가 곧 명세라고 적혀 있어. line, expression, card, relay, answer. 다른 타입은 자기 필드를 그대로 지닌 extra로 남아. 어휘가 모르는 걸 내가 알 수도 있고, 모든 건 코퍼스니까. 단, 조건이 하나 있어. 어휘의 텍스트 필드(source, translation, pronunciation, note, use) 중 하나는 들고 있어야 하고, 그 값은 앞뒤 공백을 걷어내도 비어 있으면 안 돼. 비었거나 공백뿐인 필드는 안 쳐. 그런 필드가 하나도 없는 낯선 객체는 버려. 설명 턴은 root를 더하는데, 그건 레슨 6에서 볼 거야.
파서는 한 줄씩
SparkBlockParser.feed는 도착한 텍스트를 버퍼에 붙이고, 완성된 줄마다 _line에 넘겨. 조각은 단어 중간에서도, 펜스 중간에서도, JSON 중간에서도 끊길 수 있어. 그래도 잃는 건 없어. 꼬리는 줄바꿈이 올 때까지 버퍼에서 기다려. 엔진의 첫 파서 테스트가 대답 전체를 일곱 글자씩 먹이는 이유가 이거야.
블록 밖에선 펜스를 여는 줄(백틱 셋에 spark, 대소문자 상관없이)이 파서를 안으로 들여보내고, 나머지 줄은 전부 문장이야. 블록 안에선 이렇게 굴러.
닫는 펜스가 오면 밖으로 나가.
객체 사이 빈 줄은 건너뛰어.
다른 줄은 대기 목록에 붙이고, 그 목록을 JSON으로 읽어봐. 딕셔너리로 읽히면 바로 돌려줘.
아직 안 읽히는데 여는 중괄호로 시작하면 계속 모아. 최대 40줄까지. 그래서 들여쓰기된 객체도 통째로 도착해.
그 밖의 것(JSON이 아닌 줄, 리스트, 끝내 안 닫히는 객체)은 버리고 malformed로 세.
이 너그러움엔 값이 있어. 여는 중괄호로 시작하는 망가진 객체는 뒤에 오는 줄을 붙잡아. 그 줄들이 하나씩 대기 목록에 붙고, 합친 글은 끝내 안 읽혀. 목록이 40줄이 되거나 펜스가 닫힐 때까지 아무것도 안 나와. 그때 목록 전체가 그 안의 멀쩡한 기록까지 통째로 망가진 줄 하나로 세어지고 버려져.
finish는 줄바꿈 없는 마지막 줄까지 읽고, 아직 대기 중인 객체는 망가진 걸로 세. prose 속성은 아직 쓰이는 중인 줄도 포함해서 내 말이 만들어지는 대로 보여. 단, 백틱으로 시작하는 줄은 빼. 펜스가 열리는 중일 수도 있거든. 끝에 붙는 아바타 태그 [emotion:...]는 문장에서 떼어내서 내 말 옆 얼굴이 돼.
객체에서 기록으로
normalize는 객체를 코퍼스가 보관할 모양으로 바꿔. 알려진 종류인데 원문이나 번역이 없으면 아무 말도 안 한 거라 버려. 텍스트 필드는 앞뒤 공백을 다듬어. language와 translation_language는 언어 태그 모양일 때만 남겨. 그래서 es_ES는 es-ES가 되고, Latin American Spanish 같은 표시 이름은 사라져. show는 진짜 불리언일 때만 살아남고, n은 정수가 돼. 원문마다 source_key가 붙어. 공백을 하나로 접고 대소문자를 접은 값이라, 아빠가 같은 글자를 또 만나면 코퍼스가 알아봐.
생기자마자 보여
턴의 _stream 루프는 텍스트 이벤트마다 파서에 먹이고, 나온 건 다음 이벤트를 읽기 전에 기록해. 기록된 결과는 하나하나가 순간 로그의 연산이고, 코퍼스는 그걸 그 순간을 보고 있는 모두한테 server-sent events로 알려. 문장도 나가는데, 그건 기록하지 않는 일회성 이벤트야. 폰에선 사진 밑 목록이 줄마다 한 행씩 늘고, 레슨 2의 오버레이가 상자를 하나씩 채워.
메뉴 사진의 번호 붙은 줄 하나와 그 밑에 붙은 내 풀이. 풀이는 일부러 넣은 거야. 이 칸에 뭐가 있는지, 말이 어디서 왔는지, 적힌 맥주는 알코올 있는 것뿐이니 0,0이 있는지 물어보라는 것까지.
아래 첫 코드 블록은 지어낸 역 표지판에 대해 내가 쓸 법한 모양의 대답이야. 진짜 파서에 일곱 글자씩 넣어보면, 기록 넷이 각자 끝나는 줄바꿈에서 하나씩 나와. 말은 펜스 위의 짧은 두 문장이고, 망가진 줄은 0이야.
턴마다 한 번, 고친 건 새 결과로
스트림은 끊기기도 해. cwkPippa가 대답을 다시 시도하면 stream_reset을 보내고, 파서의 reset이 끊긴 시도의 버퍼와 문장을 버려. 엔진은 리셋이 와도 딱 하나는 지켜. 이 턴이 이미 저장한 걸 result_key로 적어둔 recorded 맵이야. 줄은 Vision 번호로, 어근은 어근과 표현으로, 나머지는 텍스트로 키를 잡아.
그래서 재시도가 1번 줄을 같은 내용으로 다시 쓰면 건너뛰어. 번역이 다르면 새 결과로 기록하고, revises가 처음 걸 가리켜. 덮어쓰는 건 없어. 로그는 덧붙이기만 해 (트랙 2). 엔진 테스트가 딱 이걸 재현해. 1번 줄, 리셋, 다시 1번 줄, 2번 줄, 그리고 번역을 고친 1번 줄. 순간엔 결과가 셋 남고, 세 번째가 첫 번째를 고친 거야.
나머지는 클라이언트 몫이야. 폰의 MomentState.current와 웹의 currentResults가 revises 사슬을 따라가서, 원래 자리에 제일 새 버전을 보여줘. 중간에 죽은 스트림도 도착한 건 지켜. 턴이 어떻게 끝나는지는 어떻게 죽었는지에 달렸어. cwkPippa가 스트림 안에서 오류 이벤트로 끊김을 알리면, 뭐라도 기록한 턴은 완료에 부분 표시가 붙고, 아무것도 없을 때만 실패야. 연결 자체가 끊겨서 클라이언트가 예외를 던지면, 줄을 이미 기록했어도 턴은 실패로 끝나. 그래도 기록된 줄은 순간에 그대로 남아.
원칙: 의미의 단위와 도착의 단위가 같은 전송 형식을 골라. 여기선 그게 한 줄이라서, 줄이 생기자마자 앱이 움직일 수 있어.
Code
지어낸 역 표지판에 대한, 피파 대답 모양의 예시 (이 레슨을 위해 직접 썼고, 실제 파서로 확인했어)·text
cwkSpark, backend/results.py: feed, finish, 그리고 줄 단위 상태 기계 (원문 그대로, 139-188행)·python
def feed(self, text: str) -> list[dict[str, Any]]:
self._buffer += text
found: list[dict[str, Any]] = []
while "\n" in self._buffer:
line, self._buffer = self._buffer.split("\n", 1)
found.extend(self._line(line))
return found
def finish(self) -> list[dict[str, Any]]:
"""The reply ended: read what is left, including an unterminated line."""
found: list[dict[str, Any]] = []
if self._buffer:
line, self._buffer = self._buffer, ""
found.extend(self._line(line))
if self._pending:
self.malformed += 1
self._pending = []
self._in_block = False
return found
def _line(self, line: str) -> list[dict[str, Any]]:
if not self._in_block:
if _fence_opens(line):
self._in_block = True
self.blocks += 1
else:
self.prose_lines.append(line)
return []
if _fence_closes(line):
if self._pending:
self.malformed += 1
self._pending = []
self._in_block = False
return []
if not line.strip() and not self._pending:
return []
self._pending.append(line)
candidate = "\n".join(self._pending).strip()
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
if len(self._pending) >= _MAX_PENDING_LINES or not candidate.startswith("{"):
self.malformed += 1
self._pending = []
return []
self._pending = []
if not isinstance(parsed, dict):
self.malformed += 1
return []
return [parsed]
cwkSpark, backend/turns.py: 결과는 턴마다 한 번, 바뀐 건 개정으로 (원문 그대로, 856-893행)·python
async def _record_results(
self,
turn: dict[str, Any],
moment: dict[str, Any],
objects: list[dict[str, Any]],
recorded: dict[tuple[str, str], dict[str, Any]],
) -> int:
"""Record each new result once per turn. The same result written again
(a retried stream) is skipped; a changed one is kept as a revision."""
count = 0
target = moment.get("target_language") or "ko"
for obj in objects:
fields = normalize(obj)
if fields is None:
continue
key = result_key(fields)
earlier = recorded.get(key)
if earlier is not None:
if content_of(earlier) == content_of(fields):
continue
fields["revises"] = earlier["id"]
if turn.get("target_result"):
fields.setdefault("about", turn["target_result"])
record = await self._write(
self.corpus.add_result,
moment["id"],
turn_id=turn["id"],
target_language=target,
fields=fields,
)
recorded[key] = record
count += 1
if record["kind"] == "card" and self.voice is not None:
# The card is readable now; its voice follows (card first),
# and the turn finishes only once the voice is recorded.
voice = asyncio.ensure_future(self._speak_card(moment, record))
self._turn_work.setdefault(turn["id"], []).append(voice)
return count
네 모델 출력용 스트리밍 파서를 짜봐. 직접 정한 정보 문자열이 붙은 펜스 블록 밖은 문장, 안은 한 줄에 JSON 객체 하나. 준비해둔 대답을 1글자, 7글자, 50글자 조각으로 먹여봐. 매번 같은 객체가 나오는지, 각 객체가 자기 줄바꿈을 실어 온 feed 호출에서 나오는지 확인해. 그러고 나서 reset()을 넣어. 네 기록을 식별하는 방식대로 키를 잡은 턴별 맵을 두고, 재시도한 스트림이 아무것도 두 번 기록하지 않는다는 걸 보여줘.
Hint
상태는 셋이면 돼. 텍스트 버퍼, 지금 블록 안인지, 여러 줄에 걸친 객체를 위한 대기 줄 목록. 그 목록엔 상한을 둬. 재시도 테스트는 텍스트, 리셋, 같은 텍스트 한 번 더, 그리고 바뀐 기록 하나 순서로 짜고, 원본을 가리키는 개정 하나가 나와야 해.
Progress
Progress is local-only — sign in to sync across devices.