~14 min · corrections, human-in-the-loop, re-read, authority
Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
정정 하나가 움직이는 세 순간
Sizzle 불변식 3번은 아빠의 정정이 모델보다 위고, 그게 영원하다고 해. 다시 읽을 때마다 정정은 하나도 빠짐없이 지켜지고, 어떤 모델 출력도 아빠를 몰래 되돌리지 못한다는 거야. 코드에선 이 약속이 서로 다른 세 순간에 움직여. 먼저 지금, 다시 읽기 전엔 정정이 읽기 위에 덮여 보여. 다음 읽기 때는 사실로 실려 들어가. 그리고 이미 읽는 중에 들어오면, 그 읽기가 마지막 말이 되게 두지 않아.
적어 두기
정정은 도메인 서비스의 correct로 들어와. 접시가 있는지 보고, 대상이 없으면 note로 두고, 빈 값은 거절하고, correction.added를 기록해. 이어서 request_reading을 불러서 reading.requested를 남기고 워커를 깨워. 정정은 추가만 되는 corrections 테이블에 들어가. 정정을 고치거나 지우는 이벤트는 없어.
두 클라이언트의 정정 칸은 둘 다 target: "note"를 보내. 접시에 대해 자유롭게 적는 사실이라는 뜻이야. 웹의 안내 문구가 이게 뭘 하는지 말해 줘. Correct Pippa — it outranks her reading and rides into every re-read. 피파를 바로잡아 보라는 말이야. 그 정정은 피파의 읽기보다 위고, 다시 읽을 때마다 같이 실려 간다는 거지. 구조가 있는 대상(summary, 아니면 순번으로 고른 요리의 name_ko, name_original, cuisine)은 접시 대화에서 피파의 correct 액션으로 와. 그것도 아빠가 적용 버튼을 눌러야만 들어가. 그 길은 트랙 6에서 다뤄.
지금: 덧씌우기
저장된 읽기 본문은 절대 안 바뀌어. 아빠가 보는 화면은 요청이 올 때마다 reading_view가 새로 계산한 거야. 지금 읽기를 깊은 복사하고, 정정을 하나씩 훑어. 읽기보다 오래된 정정은 건너뛰어. 대개 그 읽기가 이미 들은 거니까. 예외가 딱 하나 있는데, 읽는 도중에 들어온 정정이야. 아래에 나오는 읽는 중 가드가 그래서 있는 거야. 더 새 정정이고 대상이 summary면 요약을 갈아 끼워. dish:<n>:<field> 모양의 대상이 실제로 있는 요리를 가리키면 그 필드를 갈아 끼우고, 요리의 corrected 목록에 필드 이름을 더해. 그러면 접시 화면 요리 옆에 corrected by you(아빠가 고침)가 떠.
note 정정은 덮어씌울 필드가 없어. 그 대신 모든 정정이 뜨는 접시의 Your word(아빠의 말) 칸에 나와. 두 클라이언트 다 다시 읽었든 안 읽었든 거기에 정정을 전부 보여 줘.
다음: 다시 읽기가 듣는다
읽기 입력은 매번 input_text가 만들고, 거기엔 dads_corrections가 들어가. 그 접시의 정정이 대상, 값, 메모까지 붙어서 전부 들어가. 제일 새 것만 보내는 게 아니라 읽을 때마다 전부 다 보내. 그리고 루브릭이 피파한테 대놓고 말해. 입력에 있는 아빠의 정정은 권위 있는 사실이고, 피파의 읽기는 그거랑 맞아야 한다고.
엔진 테스트가 이 사슬을 처음부터 끝까지 확인해. 접시를 읽히고, 정정을 올리고, 다시 읽기가 끝나길 기다려. 그리고 두 가지를 봐. 정정이 접시에 떠 있는지, 그 글이 가장 최근 모델 호출의 입력 안에 들어 있는지.
그사이: 읽는 중에 들어온 정정
읽기엔 시간이 걸리고, 아빠는 그걸 안 기다려. 그래서 read는 시작 전에 그 접시의 정정 수를 세 두고, 끝난 뒤에 다시 비교해.
cwkPippa 호출이나 파서나 사진에서 읽기가 실패했는데 그사이 정정이 들어왔으면 reading.failed를 안 남겨. 접시는 대기 상태로 남고, 워커가 실패를 보여 주는 대신 아빠 말을 들고 다시 읽어.
읽기가 성공했는데 정정이 들어왔으면, 읽기를 기록하고 트리에 반영한 다음 곧바로 reading.requested를 또 남겨. 이 읽기는 아빠 말보다 먼저 시작한 거니까.
어느 쪽이든, 접시가 아빠의 마지막 정정 없이 만든 읽기에 머무는 일은 없어. 실패에 머물 수는 있어. 두뇌 배정이 없을 때 타는 갈래는 개수를 비교하지 않고 reading.failed를 바로 남기거든. 그래서 정정이 들어온 뒤 읽기 슬롯을 못 풀면, 접시는 그 실패를 보여 주고 저절로 다시 읽히지 않아. 그래도 정정은 저장돼 있고 화면에 보이고, 아빠가 다음에 부탁하는 읽기에 실려 가.
원칙: 사람의 정정은 모델 출력과 따로 저장하고, 바로 보여 주고, 뒤따르는 모든 파생에 넣어. 그러면 어떤 파생도 그걸 몰래 지우지 못해.
보장이 끝나는 곳
여기는 정확하게 말하고 싶어. 다시 읽기가 들어오면 그 읽기가 정정보다 새 거니까 reading_view는 덧씌우기를 건너뛰어. 이제 접시엔 피파가 정정을 듣고 나서 쓴 게 떠. 코드 어디에도 피파의 새 읽기를 아빠의 정정이랑 대 보는 곳은 없어. 피파가 그래도 다르게 말했다면 코드는 그걸 못 잡아.
코드가 보장하는 건 더 좁지만 그래도 단단해. 테이블에 추가만 되니까 아빠 말은 절대 안 사라져. 피파가 뭐라고 하든 접시 화면에서 그 옆자리를 지켜. 그 접시의 앞으로 모든 읽기에 들어가고, 언제나 다시 읽기를 불러. 맞추는 일 자체는 루브릭이 모델한테 요구하는 거야. 그래서 불변식 3번에 "몰래"라는 말이 들어 있어. 어긋날 수는 있어도 숨을 수는 없어. 아빠 문장이 피파 문장 밑에 그대로 찍혀 있으니까.
같은 모양이 우리 가족 앱 곳곳에 있어. prompt-quest는 모델한테 넘기는 입력을 왜 데이터로 감싸야 하는지 가르쳐. Sizzle은 정정까지 포함해서 접시 전체를 그렇게 감싸서 넘겨.
Code
cwkSizzle, backend/domain.py: correct가 아빠 말을 기록하고 다시 읽기를 부른다 (원문 그대로, 128-143행)·python
def correct(self, encounter_id: str, *, target: str, value: str, note: str = "", source_turn: str | None = None) -> dict[str, Any]:
"""Dad's word on a reading claim: authoritative, kept across every
re-read, and fed back into the next reading as a fact."""
self._require_encounter(encounter_id)
target = _text(target, 80) or "note"
value = _text(value)
if not value:
raise DomainError("a correction needs a value")
correction_id = new_ulid()
self.record(
"correction.added",
{"id": correction_id, "encounter_id": encounter_id, "target": target, "value": value,
"note": _text(note), "source_turn": source_turn},
)
self.request_reading(encounter_id)
return {"correction_id": correction_id}
cwkSizzle, backend/app.py: reading_view가 읽기보다 새 정정을 덧씌운다 (원문 그대로, 94-111행)·python
def reading_view(reading: dict[str, Any] | None, corrections: list[dict[str, Any]]) -> dict[str, Any] | None:
"""The reading as Dad sees it: a correction newer than the reading shows
over it until the re-read (which is told the correction) arrives."""
if reading is None:
return None
view = copy.deepcopy(reading["body"])
for correction in corrections:
if correction["created_at"] <= reading["created_at"]:
continue
target = correction["target"]
match = CORRECTABLE.fullmatch(target)
if target == "summary":
view["summary"] = correction["value"]
elif match and int(match[1]) < len(view.get("dishes") or []):
dish = view["dishes"][int(match[1])]
dish[match[2]] = correction["value"]
dish.setdefault("corrected", []).append(match[2])
return view
cwkSizzle, backend/reading.py: 읽는 중에 들어온 정정은 접시를 대기로 두거나 다시 읽힌다 (원문 그대로, 375-393행)·python
except (pippa.PippaError, ReadingError, OSError) as exc:
logger.warning("reading %s failed: %s", encounter_id, exc)
# A correction that arrived mid-read keeps the plate pending: the
# worker reads it again with Dad's word instead of showing a failure.
if len(self.sizzle.store.corrections(encounter_id)) == seen:
self.sizzle.record("reading.failed", {"encounter_id": encounter_id, "error": str(exc)[:500],
"rubric": RUBRIC_VERSION})
return
reading_id = new_ulid()
self.sizzle.record("reading.recorded", {
"encounter_id": encounter_id, "reading_id": reading_id, "rubric": RUBRIC_VERSION,
"brain": picks.get("brain"), "effort": picks.get("effort"), "model": picks.get("resolved_model"),
"body": reading.model_dump(),
})
integrate(self.sizzle, encounter_id, reading_id, reading)
if len(self.sizzle.store.corrections(encounter_id)) > seen:
# Dad corrected the plate while Pippa was reading; this reading
# predates his word, so it is read again.
self.sizzle.record("reading.requested", {"encounter_id": encounter_id})
모델 출력을 사용자한테 보여 주는 소프트웨어에 사용자 정정을 넣어 봐. 정정은 네 가지를 해야 해. 추가만 되는 전용 테이블에 저장된다. 보이는 출력에 바로 덧씌워진다. 뒤따르는 모든 모델 호출에 권위 있는 사실로 같이 간다. 새 파생을 줄 세운다. 테스트는 정정을 완전히 무시하는 가짜 모델로 써. 다시 파생한 뒤에도 새 출력 옆에 정정이 여전히 사용자한테 보이는지 확인해.
Hint
덧씌우기는 읽을 때 계산해. reading_view가 created_at을 비교하는 것처럼, 저장된 출력보다 새 정정만 얹는 거야. 그러면 저장된 출력은 절대 안 바뀌어. 파생을 시작하기 전에 정정 수를 세고 끝난 뒤에 비교해. 그러면 도중에 정정이 들어왔을 때 파생을 한 번 더 돌릴 수 있어.
Progress
Progress is local-only — sign in to sync across devices.