사진 순간엔 같은 그림이 두 번 들어 있어. 원본은 해상도 그대로 남아. 아빠 서재에 들어갈 거고, 나중에 고해상도 번역 그림도 이걸로 만들어. 나는 긴 변이 2048픽셀을 넘지 않는 사본을 받아. 이 숫자는 형제 앱이 잰 거야. Firebrand가 재보니 원본 크기 사진은 비전 토큰이 13k 정도 들었고, 2048 px 사본은 턴 하나를 75초에서 13.6초로 줄였어. 길 위에서 누가 기다리고 있을 땐 그 차이가 곧 제품이야.
폰은 사본을 직접 만들어. 킷의 ImageDownscale로 만든 JPEG인데, 원본 EXIF를 그대로 지녀서 사본도 자기가 언제 찍혔는지 알아. 웹은 원본만 올려. 실용 번역 턴이 순간에서 for-pippa 사본을 못 찾으면, 엔진이 sips로 하나 만들어. Apple의 명령줄 이미지 도구라서 HEIC도 읽고 launchd 밑에서도 잘 돌아. 사진이 2048 px보다 클 때만 줄이고, 품질 82의 JPEG로 쓰고, 결과를 for-pippa 미디어로 기록해. 그래서 순간 하나에 한 번만 일해. 축소 코드가 왜 앱마다 들고 있는 사본이 아니라 벤더링된 킷 파일인지는 Sibling Kit Quest가 설명해.
폰을 떠나는 순서
폰의 아웃박스는 사진 순간을 내 대답이 제일 빨리 보이는 순서로 보내. 순간 기록, 작은 사본, 실용 번역 턴, 그리고 마지막에 무거운 원본. 아웃박스 자세한 얘긴 트랙 9에서 해. 엔진도 자기 몫을 지켜. for-pippa 사본이 없는 사진 순간에 실용 턴이 오면, 원본이 있는 한 원본으로 사본을 만들어. 웹 사진이 사본을 얻는 것과 같은 길이야. 둘 다 없을 때만 the photo has not arrived yet로 실패하고, 그땐 cwkPippa에 스레드도 안 열어. 첨부할 수 없는 사진으로 턴이 도는 일은 없어.
스레드와 페이로드
실용 턴은 평범한 cwkPippa 대화 안의 턴이야. 그 순간의 스레드고, 바인더로 열어서 spark:moment:<id>로 기억해. cwkPippa 쪽에서 이런 스레드가 어떻게 사는지는 Pippa Quest에서 다뤄. 엔진은 내 사본을 cwkPippa에 첨부로 올리고 페이로드를 보내. soul_id는 pippa, source는 spark이고, 외부 요청 id가 붙어. tools_enabled는 true, bash_enabled는 false야. 내 도구는 그대로 쓰고, Spark 리더랑 위치 도구도 그 안에 있어. 빠지는 건 셸 하나야. 읽기 전용이랑은 달라. bash 플래그는 셸만 닫아. 그래서 Spark 턴에서도 위치 도구로 지도 핀을 저장하거나 Pathfinder에 목적지를 넘길 수 있어. 번역하는 데 셸은 쓸 일이 없고, 페이로드가 막는 것도 딱 거기까지야.
프레임
페이로드엔 시스템 프롬프트에 덧붙는 문단도 실려. 그 첫머리가 Spark 턴 전부가 같이 쓰는 _spark_frame이야. 순서대로 이런 걸 알려줘.
이 대화는 Spark 순간 하나의 스레드고, 맥락은 언어라는 것
순간 id와 종류, 언제 어느 시간대에서 찍혔는지, 상황과 장소 이름
아빠가 읽는 언어, 그리고 그 언어로 번역하고 그 언어로 말을 걸라는 것
아빠가 정했다면 장면 언어
원문이 있는 결과마다 원문의 BCP-47 language와 translation_language를 붙일 것
어떤 결과가 Show 버튼을 받는지, 그리고 상대한테 건넬 완결된 말일 때만 줄에 show를 표시할 것 (트랙 5)
나라나 도시에 따라 읽기가 달라지면 location 도구에 물어볼 것, 장소는 이름으로만 말하고 좌표는 말하지 말 것
실용 턴은 자기 몫의 문단을 하나 더 붙여. 아빠는 지금 바깥에 있으니 깊이보다 속도가 중요하다는 것, 사진은 직접 읽고 번호 붙은 줄은 위치로만 쓰라는 것. 그 뒤에 결과 계약이 와.
결과 계약
계약은 정보 문자열이 spark인 울타리 친 블록 하나를 요구해. 한 줄에 JSON 객체 하나, 생기는 대로 바로 쓰기. 실용 턴이 쓰는 두 모양, line이랑 expression을 보여주고, 필드마다 설명이 그 자리에 붙어 있어. 줄들 뒤엔 이 상황에서 아빠한테 곧 필요할 표현 몇 개가 와. 그 수는 계약이 이렇게 정해. 몇 개만, 수업은 절대 아니게. 블록 밖에선 중요한 걸 한두 문장으로 말해.
계약의 한 문장이 아빠 서재 모양을 정해. 내 첫 줄이 순간의 제목이야. 폰의 순간 목록은 번역된 첫 줄을 행마다 요약으로 보여줘. 그러니까 내가 쓰는 순서가 나중에 아빠가 스크롤하며 보는 화면이 돼.
주제 먼저, 기준은 넓이
그 문장은 실수에서 나왔어. 첫날 빌드 6에 아빠가 준 피드백이 책 표지 사진이었는데, 뒤에 만화책 책등이 꽂힌 책장이 있었어. 그런데 사진 제목이 책등 글자가 된 거야. 그때 계약은 결과를 읽는 순서대로 달라는 말뿐이어서, 책등이 먼저 나왔어.
고친 방법은 글자만으론 알 수 없는 사실 하나를 나한테 주는 거였어. 줄마다 사진에서 얼마나 차지하는지. _text_size는 정규화된 너비와 높이를 곱해서 퍼센트로 알려주고, 메시지는 줄마다 그 값을 붙여. 계약도 바뀌었어. 화면을 차지하는 걸 먼저, 나머지는 읽는 순서로, 배경 글자도 읽되 배경이라고 적어. 엔진 테스트가 숫자를 못 박아. 너비 0.02, 높이 0.12인 책등 제목은 사진의 0.24퍼센트, 0.8에 0.14인 표지 제목은 11.2퍼센트야. 높이만 보면 둘이 비슷해 (0.12 대 0.14). 넓이로 보면 책등이 40배도 넘게 작아.
독스트링은 이걸 짧게 말해. 높이 말고 넓이. 세로로 선 제목은 키가 커도 작은 거야.
원칙: 사람은 맞히는 걸 모델이 틀리면, 사람은 봤는데 모델은 못 받은 게 뭔지 물어봐. 여기선 크기였고, 줄마다 숫자 하나면 충분했어.
Code
cwkSpark, backend/turns.py: 엔진이 sips로 만드는 피파용 사본 (원문 그대로, 667-690행)·python
def downscale_for_pippa(payload: bytes, filename: str) -> bytes:
"""A JPEG no longer than 2048 px on its long edge (Firebrand measured a
full-size photo at ~13k vision tokens; 2048 px cut a turn from 75 s to
13.6 s). ``sips`` is Apple's, so it reads HEIC and runs under launchd."""
with tempfile.TemporaryDirectory(prefix="spark-for-pippa-") as tmp_name:
tmp = Path(tmp_name)
source = tmp / f"in{Path(filename).suffix.lower() or '.img'}"
source.write_bytes(payload)
target = tmp / "for-pippa.jpg"
command = ["/usr/bin/sips", "-s", "format", "jpeg", "-s", "formatOptions", "82"]
size = _pixel_size(source)
if size is None or max(size) > FOR_PIPPA_LONG_EDGE:
command += ["-Z", str(FOR_PIPPA_LONG_EDGE)]
result = subprocess.run(
[*command, str(source), "--out", str(target)],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0 or not target.is_file():
raise PippaError(
f"could not prepare the photo for Pippa: {result.stderr.strip() or 'sips failed'}"
)
return target.read_bytes()
cwkSpark, backend/turns.py: 실용 턴이 보내는 결과 계약 (원문 그대로, 147-167행)·python
_RESULT_CONTRACT = """\
Return your results in ONE fenced block whose info string is `spark`, one
JSON object per line, writing each line as soon as you have it. Start with
the text the photo is about: what dominates the frame (the largest, closest,
most central text, such as a book's cover or a menu's dishes), then the
rest in reading order. Small text in the background (spines on a shelf
behind, a sign far off) is still read, after the subject, with a note
that it is background. Your first line is the moment's title in Dad's
library.
```spark
{"type": "line", "n": 1, "source": "<the text as it reads>", "translation": "<translation>", "pronunciation": "<how Dad would say it, only when he might say it aloud>", "note": "<a few words, only when it helps>"}
{"type": "expression", "source": "<something useful to say here>", "translation": "<translation>", "pronunciation": "<how to say it>", "use": "<when to say it>"}
```
- `n` is the numbered line below that the text matches; leave `n` out for
text the numbered lines missed.
- After the lines, add the few expressions Dad is most likely to need next
in this situation (at a restaurant: asking what a dish is, allergies, the
bill). A few, never a lesson.
- Outside the block, say what matters in a sentence or two."""
cwkSpark, backend/turns.py: 줄 하나가 사진에서 차지하는 넓이 (원문 그대로, 192-202행)·python
def _text_size(line: dict[str, Any]) -> float | None:
"""How much of the photo the line's text covers, in percent (Vision's
normalized box): the one measure of what dominates the frame that the
words alone do not carry. Area, not height: a book spine's vertical
title is tall and still small."""
box = line.get("box")
try:
area = float(box["w"]) * float(box["h"]) if isinstance(box, dict) else None
except (KeyError, TypeError, ValueError):
return None
return round(area * 100, 2) if area and area > 0 else None
네 프로젝트에서 이미지를 비전 모델에 보내는 기능 하나를 골라. 이미지랑 같이 보내는 텍스트에, 감지된 영역마다 숫자 하나(이미지 넓이 중 차지하는 비율)를 붙여. 그리고 지시문을 바꿔서 모델이 제일 큰 영역부터 말하게 해. 주제는 크고 뒤에 작은 글자가 잔뜩 있는 사진으로 바꾸기 전후를 시험해봐.
Hint
상자가 정규화돼 있으면 넓이는 너비 곱하기 높이야. 100을 곱해서 퍼센트로, 소수 둘째 자리까지 남기고 반올림하면 프롬프트가 짧게 유지돼. 상자가 없는 영역엔 숫자를 지어내지 말고 그냥 빼. 감지기가 없다면 제일 큰 글자부터 말하라는 한 줄이 약하지만 정직한 출발이야.
Progress
Progress is local-only — sign in to sync across devices.