글자가 같아도 사진이 다른 문단 아래에 들어가면 원고의 의미가 달라질 수 있다. 북극성의 ArticlePlan과 실제 파일 연결 함수를 읽고, 순서 있는 원고를 별도 자료로 검토하는 예제를 만들었다. 합성 관찰 자료만 비교하며 업로드나 서버 저장을 수행하지 않는다.
본문을 한 문자열로 줄이기 전에 순서를 남긴다
계획은 version과 blocks로 받는다. 블록은 text·image·video이며 매체에는 파일 이름과 SHA-256을 넣는다. 영상은 제목도 필요하다. 처음과 끝은 문단이고 적어도 하나의 매체가 있어야 하며 연속 문단은 합쳐서 표현한다. 일반 텍스트 글을 거절하려는 서비스 전체 규칙이 아니라 이 매체 포함 예제의 계약이다.
예제는3–50블록과 선택 자료20개, 텍스트 총량 한도를 둔다. 이는 검토 코드의 입력 경계이며 발행 주제나 글 수를 제한하는 기준이 아니다. 원래 계약과 비슷한 형태를 독립적으로 작성했지만 원래 TypeScript의 모든 문자열 길이 계산과 직렬화까지 동일하게 재현하지는 않았다.
파일 이름과 바이트가 함께 맞아야 연결한다
compile_plan은 선택된 파일 메타데이터에서 이름과 hash가 모두 같은 항목을 찾는다. 같은 이름이라도 바이트가 다르면 구별하고 같은 이름·hash의 후보가 둘이면 모호하다고 거절한다. 중복 handle도 받지 않는다. 뒤쪽 사진 하나가 없으면 전체 계획의 연결을 돌려주지 않아 일부 성공을 완성된 준비처럼 보이지 않게 한다.
여기서 handle은 합성 문자열이다. 운영체제 경로나 실제 업로드 권한이 아니다. 예제는 파일을 열지 않고 전달된 메타데이터만 비교한다. 기록 생성기는 작은 합성 바이트의 hash를 실제 계산하지만, 사용자가 넣은 임의의 hash가 실제 파일에서 왔는지 인증하는 기능까지 제공하지 않는다.
계획 지문은 바뀐 원고를 같은 것으로 읽지 않게 한다
plan_digest는 키를 정렬한 JSON 전체를 UTF-8로 만들어 hash한다. 문단 내용, 매체 순서와 바이트 hash, 영상 제목이 들어간다. 키 나열 순서만 바뀐 같은 사전은 같게 본다. 원래 프로그램의 JSON 직렬화와 같은 지문이라고 부르지 않고 이 예제만의 비교 규칙으로 명시했다.
review는 기록한 expected_digest와 현재 계획에서 다시 계산한 값을 비교한다. 계획을 바꾸고 예전 지문만 남기면 PLAN_CHANGED다. 이것은 신뢰할 수 있는 작성자를 인증하거나 문장을 비밀로 만드는 장치가 아니다. hash와 새 계획을 함께 바꿀 수 있는 호출자를 막는 권한 체계도 아니다.
코드와 구현 살펴보기
독립 합성 전체 구현
"""Independent synthetic ordered-plan review. No upload, browser or server proof."""
import hashlib
import json
import re
from copy import deepcopy
def fail(code):
raise ValueError(code)
def shape(value, fields, code):
if not isinstance(value, dict) or set(value) != set(fields):
fail(code)
def name(value):
if not isinstance(value, str) or not value.strip() or len(value) > 240 or any(c in value for c in "/\\\r\n\x00"):
fail("FILENAME")
def digest(value):
if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{64}", value):
fail("DIGEST")
def identity(value):
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", value):
fail("IDENTITY")
def validate(raw):
shape(raw, {"version", "blocks"}, "PLAN_SHAPE")
if type(raw["version"]) is not int or raw["version"] != 1:
fail("VERSION")
blocks = raw["blocks"]
if not isinstance(blocks, list) or not 3 <= len(blocks) <= 50:
fail("BLOCK_COUNT")
text_size = 0
for index, block in enumerate(blocks):
if not isinstance(block, dict):
fail("BLOCK_SHAPE")
kind = block.get("kind")
if kind == "text":
shape(block, {"kind", "text"}, "TEXT_SHAPE")
text = block["text"]
if not isinstance(text, str) or not text.strip() or "\r" in text or len(text) > 20000:
fail("TEXT")
if index and blocks[index-1].get("kind") == "text":
fail("ADJACENT_TEXT")
text_size += len(text) + 2
elif kind in {"image", "video"}:
shape(block, {"kind", "fileName", "sha256"} | ({"title"} if kind == "video" else set()), "MEDIA_SHAPE")
name(block["fileName"]); digest(block["sha256"])
if kind == "video" and (not isinstance(block["title"], str) or not block["title"].strip() or len(block["title"]) > 100):
fail("VIDEO_TITLE")
else:
fail("KIND")
if blocks[0]["kind"] != "text" or blocks[-1]["kind"] != "text" or not any(b["kind"] != "text" for b in blocks):
fail("RICH_LAYOUT")
if text_size > 20000:
fail("TEXT_LIMIT")
return deepcopy(raw)
def plan_digest(plan):
value = validate(plan)
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def compile_plan(raw, selected):
plan = validate(raw)
if not isinstance(selected, list) or not 1 <= len(selected) <= 20:
fail("SELECTED_COUNT")
handles = set()
for file in selected:
shape(file, {"handle", "name", "sha256"}, "SELECTED_SHAPE")
identity(file["handle"]); name(file["name"]); digest(file["sha256"])
if file["handle"] in handles:
fail("DUPLICATE_HANDLE")
handles.add(file["handle"])
bindings = []
for index, block in enumerate(plan["blocks"]):
if block["kind"] == "text":
continue
matches = [f for f in selected if f["name"] == block["fileName"] and f["sha256"] == block["sha256"]]
if len(matches) != 1:
fail("FILE_NOT_UNIQUE")
bindings.append({"index": index, "handle": matches[0]["handle"]})
return {"plan": plan, "digest": plan_digest(plan), "bindings": bindings}
def review(compiled, observed, *, expected_digest):
# Recompute identity. Bindings are inert diagnostic output, not service receipts.
shape(compiled, {"plan", "digest", "bindings"}, "COMPILED_SHAPE")
actual_digest = plan_digest(compiled["plan"])
digest(expected_digest)
if compiled["digest"] != actual_digest or expected_digest != actual_digest:
fail("PLAN_CHANGED")
if not isinstance(observed, list) or len(observed) > 50:
fail("OBSERVATION")
ids = set()
for block in observed:
if not isinstance(block, dict):
fail("OBSERVED_SHAPE")
if block.get("kind") == "text":
shape(block, {"kind", "text"}, "OBSERVED_SHAPE")
if not isinstance(block["text"], str):
fail("OBSERVED_TEXT")
elif block.get("kind") in {"image", "video"}:
shape(block, {"kind", "fileName", "sha256", "id", "ready"}, "OBSERVED_SHAPE")
name(block["fileName"]); digest(block["sha256"]); identity(block["id"])
if type(block["ready"]) is not bool:
fail("OBSERVED_READY")
if block["id"] in ids:
fail("DUPLICATE_COMPONENT")
ids.add(block["id"])
else:
fail("OBSERVED_KIND")
differences = []
expected = compiled["plan"]["blocks"]
if len(expected) != len(observed):
differences.append({"code": "COUNT", "expected": len(expected), "actual": len(observed)})
for index, (wanted, found) in enumerate(zip(expected, observed)):
codes = []
if wanted["kind"] != found["kind"]:
codes.append("KIND")
elif wanted["kind"] == "text":
if wanted["text"] != found["text"]:
codes.append("TEXT")
else:
if wanted["fileName"] != found["fileName"]:
codes.append("FILENAME")
if wanted["sha256"] != found["sha256"]:
codes.append("MEDIA_BYTES")
if not found["ready"]:
codes.append("NOT_READY")
differences.extend({"index": index, "code": code} for code in codes)
return {"matches": not differences, "differences": differences,
"evidence": "caller-declared synthetic layout only", "publication_verified": False}
차이를 알려 주되 실제 발행 성공으로 쓰지 않는다
관찰 배열은 같은 순서의 text 또는 매체 자료다. 개수·유형·문단 원문·매체 이름과 hash·ready를 비교하고 차이가 생긴 index와 코드만 돌려준다. 오류 요약에 문단 원문을 다시 복사하지 않는다. 매체 component ID가 중복돼도 거절한다. 영상 제목은 계획 지문에 들어가지만 관찰에는 제목 필드가 없어 실제 제목 일치는 검증하지 않는다.
전부 맞아도 publication_verified는 False다. 관찰의 hash와 ready가 호출자 선언이기 때문이다. 선택 파일을 서버가 받았는지, 편집기가 같은 순서로 저장했는지 확인하려면 신뢰할 관찰기와 실제 서비스 조회가 필요하다. 이 예제의 matches를 업무 완료의 다른 이름으로 사용하지 않는다.
30개 시험과 다섯 기록이 보여 주는 차이
시험은 경로 이름과 잘못된 hash, 빈 문단과 연속 문단, 선택 자료의 모호함을 거절하는지 확인한다. 같은 이름의 다른 바이트, 순서가 뒤바뀐 매체, 내용이 바뀐 문단과 준비되지 않은 관찰을 각각 구별한다. 원고 지문 변경과 호출자 자료의 원본 보존도 본다. 입력은 모두 합성 값이다.
원본30개 시험을 실행했고 ZIP 추출본에서도 같은30개 시험을 수행한다. 다섯 기록은 이 함수들을 실제 로컬에서 호출해 생성했다. 아래 선택기는 그 기록을 읽는다. 원래 브라우저나 업로더, 원격 게시를 시험한 것은 아니다. 구조 비교를 통과한 사실과 실제 매체 저장 확인의 근거를 분명히 나눴다.
다른 편집기에 붙일 때 비교 자료부터 정한다
코드·시험·예제·설명을 수정하고 재사용할 수 있다. 영속 원장과 중단 재개, 자동 재전송 기능은 없다. compile의 bindings는 진단 자료이며 review가 실제 handle 사용을 인증하지 않는다. 실제 편집기에 연결하려면 관찰한 component와 선택한 파일의 관계를 신뢰할 수 있게 만드는 일이 먼저다.
다른 회사의 글쓰기 화면에서는 매체 종류나 문단 규칙도 달라질 수 있다. 예제의 숫자와 모양을 그대로 강제하기보다 원고에서 보존해야 하는 의미를 정해야 한다. 나는 문장이 맞다는 확인에서 한 걸음 더 나아가 어떤 사진이 어디에 놓였는지도 비교할 수 있는 작은 출발점으로 공유한다.