같은 사진이 서로 다른 현장 폴더에 있다고 해서 어느 쪽이 맞는지 바로 알 수는 없었다. 사진 정리와 공개 코드를 읽으며, 중복 확인을 현장 소속의 확인으로 바꾸지 않으려던 기준을 다시 정리했다.

같은 파일을 찾는 일과 현장을 정하는 일

사진 정리 지침을 다시 읽다가 중요한 경계를 찾았다. 서로 다른 현장 폴더에서 같은 원본이 발견되면 공개를 보류하되, 그중 어느 현장을 정본으로 정하지 않는다는 기준이다. 바이트나 바로 선 픽셀의 해시가 같다는 것은 중복을 찾는 근거이고, 실제 시공 장소를 확정하는 근거는 따로 필요하다고 적혀 있었다.

사진이 두 폴더에 있으면 하나를 지워 정돈하고 싶어진다. 하지만 먼저 발견한 폴더, 더 최근 이름의 폴더, 사진이 더 많은 폴더를 소속 기준으로 삼으면 정리한 흔적만 남고 실제 장소의 근거는 사라질 수 있다. 나는 이 작업에서 중복을 줄이는 효율보다 잘못된 현장의 사례로 소개하지 않는 경계를 먼저 읽었다.

이번에는 사진 원본을 새로 열어 어느 현장이 맞는지 판정하지 않았다. 보관된 작업 대화의 과거 최종 기록과 현재 지침·선별·공개 코드·시험 소스를 필요한 범위로 확인했다. 옛 기록에는 완료한 사진과 인물 반사 등으로 보류한 사진, 아직 활성화하지 않은 보정 초안이 함께 남아 있었다. 그 기록을 현재 전체 검수 완료로 옮기지 않았다.

같다는 신호에 없는 정보를 더하지 않았다

같은 바이트 해시는 동일하게 저장된 자료를 묶는 데 쓸 수 있다. 방향을 정리한 픽셀의 일치도 원래 지침에서는 중복 저장을 살피는 신호다. 어느 폴더가 실제 촬영 장소를 정확하게 적었는지는 이 비교의 출력에 들어 있지 않다. 해시 값만으로 장소를 골라 주면 코드가 확인하지 않은 정보를 더하는 셈이다.

소속 확인에는 원래 수집 경위나 실제 출처, 해당 사진에 대한 사용자 확인 같은 별도 근거가 필요하다. 그 근거가 없으면 서로 다른 현장 목록을 유지한 채 보류할 수 있다. 모든 사진이 잘못됐다고 단정하거나 두 현장을 합치는 것도 다른 판단이다. 일부 혼입의 근거와 전체 현장 정체성을 같은 것으로 취급하지 않는다.

이 원칙을 공유 예제에 옮길 때도 해시 문자열은 관측으로 제공받는 입력이라고 명시했다. 새 함수는 실제 파일을 읽거나 픽셀을 비교하지 않는다. 다른 현장 ID가 같은 선언된 바이트 해시를 쓰면 검토할 신호를 만들고, 어느 쪽이 맞는지 자동으로 결정하지 않는다.

보류는 원본의 단계와 소속을 고치는 명령이 아니었다

현재 선별 코드는 원본에서 고른 사진 목록을 유지하며 사진별 publication_holds를 읽는다. 지원하는 보류 상태와 근거 파일 표기가 있어야 공개 보류를 붙인다. 보류 때문에 시공후를 시공전으로 바꾸거나 현장 ID를 다른 값으로 옮기는 방식은 아니다. 검수한 단계와 아직 확인해야 할 공개 조건이 서로 다른 기록이다.

대표 후보와 본문 갤러리, 전후 비교, 공개 사진 데이터가 이 보류를 함께 읽는다. 대표가 보류되면 쓸 수 있는 다른 시공후 후보를 찾고, 사진을 전부 보류한 경우에도 개별 현장 항목은 남길 수 있다. 기존 시험 소스에도 원본 목록·단계·현장 소속을 남기면서 공개 참조에서 제외하는 조건이 있다. 이번에 그 원래 시험을 실행한 것은 아니다.

단순히 화면에서 이미지를 감췄다는 결과와 파일 접근을 막았다는 결과는 다르다. 이번에 재사용한 갤러리 모듈도 공개에 쓸 ID를 계산하며 원격 파일의 접근 제어와 이미 배포한 캐시 회수를 수행하지 않는다. 실제 서비스에서 공개 파일을 회수해야 한다면 그 범위는 별도 운영 절차로 확인해야 한다.

출처 확인이 끝나도 다른 보류는 남긴다

원래 선별 코드에는 출처 외에도 인물 반사, 보정, 공간 분류, 정리 상태, 설치 완료 확인과 관련한 보류 종류가 있다. 따라서 현장 소속을 확인했다는 이유만으로 사진의 전체 보류 값을 거짓으로 바꾸면 다른 검토 조건까지 사라질 수 있다. 해결한 사유와 아직 남은 사유를 구분해야 한다.

새 예제는 출처 보류만 계산한다. 확인 기록은 사진 ID, 확인한 현장 ID, 근거 ID를 받는다. 확인한 현장이 현재 기록의 현장과 다르면 그 사진은 계속 보류하고 자동 이동하지 않는다. 현재 현장과 같을 때도 다른 관측 사진의 보류를 함께 풀지 않는다. 문자열이 들어왔다는 사실을 진짜 출처 확인으로 인증하지도 않는다.

기존 갤러리에 연결할 때는 현재의 다른 보류와 출처 보류를 합쳐 held에 전달한다. 아래 연결 예시의 base 사진에서 held는 현재 남은 비출처 보류를 뜻한다. 지난번의 모든 보류를 합친 결과를 원본처럼 다시 넣는 방식은 아니다. 실제 적용에는 사유별 현재 기록을 읽어 이 값을 구성하는 절차가 필요하다.

코드와 구현 살펴보기

기존 모듈과 연결하는 최소 합성 호출

from dataclasses import replace
from projection import Photo, project
from source_gate import source_gate
import hashlib

same = hashlib.sha256(b'invented duplicate bytes').hexdigest()
observations = [
    {'id': 'a-after', 'case_id': 'case-a', 'byte_sha256': same},
    {'id': 'b-after', 'case_id': 'case-b', 'byte_sha256': same},
]
base = [Photo(r['id'], r['case_id'], 'room-one', 'living',
              'after', 1600, 900, True, False) for r in observations]
# base.held represents CURRENT non-source holds, not a previous aggregate result.
decisions = {r['id']: r for r in source_gate(observations, [])}
adjusted = [replace(p, held=p.held or decisions[p.id]['source_identity_held'])
            for p in base]
result = project(case_id='case-a', photos=adjusted, pairs=[],
                 gallery_order=['a-after'], preferred_cover='a-after')
assert result['case_id'] == 'case-a'
assert result['public_photo_ids'] == []
print(result)

기존 갤러리 모듈 앞에 작은 검토 단계를 붙였다

새 함수는 최대 64개의 관측과 확인 기록을 받고, 정확한 필드와 중복 ID, 해시 형식을 검사한다. ID는 실제 경로나 업체 이름을 쓰지 않는 작은 식별자다. 해시 충돌 신호가 없다는 결과도 소속을 검증했다거나 공개해도 된다는 뜻으로 표시하지 않는다. authenticates_source와 grants_publication은 항상 false다.

아래 전체 코드는 독립 합성 구현이며 원래 사진 선별기를 수정한 결과가 아니다. 기존 공개 ZIP의 projection.py 옆에 source_gate.py로 저장해 연결할 수 있다. 이 새 함수는 본문에 공개한 추가 코드이고, 기존 ZIP에 들어 있거나 기존 패키지를 새 버전으로 바꾼 것은 아니다.

연결 뒤에도 사진 검수와 동일 구도 검수는 기존 갤러리 모듈의 조건으로 남는다. 소속 확인만 끝났다고 reviewed나 same_view_reviewed를 참으로 만들지 않는다. 한 단계에서 얻은 근거가 다른 단계의 검수를 대신하게 하지 않는 구조다.

코드와 구현 살펴보기

독립 합성 source_gate.py · 전체 구현

"""Review declared fingerprints and source decisions; never infer actual case attribution."""
from collections import defaultdict
from copy import deepcopy
import re


def source_gate(observations, resolutions):
    def rows(value):
        if type(value) is not list or len(value) > 64:
            raise ValueError('BOUNDED_RECORD_LIST')
        return value

    def token(value):
        if type(value) is not str or not re.fullmatch(r'[a-z][a-z0-9_-]{0,47}', value):
            raise ValueError('OPAQUE_IDENTIFIER')

    source, groups = {}, defaultdict(set)
    for row in rows(observations):
        if type(row) is not dict or set(row) != {'id', 'case_id', 'byte_sha256'}:
            raise ValueError('EXACT_OBSERVATION_FIELDS')
        token(row['id']); token(row['case_id'])
        if row['id'] in source:
            raise ValueError('DUPLICATE_OBSERVATION')
        if type(row['byte_sha256']) is not str or not re.fullmatch('[0-9a-f]{64}', row['byte_sha256']):
            raise ValueError('DECLARED_SHA256')
        source[row['id']] = row
        groups[row['byte_sha256']].add(row['case_id'])

    decisions = {}
    for row in rows(resolutions):
        if type(row) is not dict or set(row) != {'id', 'confirmed_case_id', 'evidence_id'}:
            raise ValueError('EXACT_RESOLUTION_FIELDS')
        for value in row.values():
            token(value)
        if row['id'] not in source:
            raise ValueError('UNKNOWN_OBSERVATION')
        if row['id'] in decisions:
            raise ValueError('DUPLICATE_RESOLUTION')
        decisions[row['id']] = row

    result = []
    for row in source.values():
        decision = decisions.get(row['id'])
        cross_case = len(groups[row['byte_sha256']]) > 1
        if decision and decision['confirmed_case_id'] != row['case_id']:
            reason, held = 'different_case_declared_no_auto_move', True
        elif decision:
            reason, held = 'source_resolution_declared', False
        else:
            reason, held = ('cross_case_digest_needs_review', True) if cross_case else ('no_cross_case_digest_conflict', False)
        result.append({
            'id': row['id'], 'case_id': row['case_id'],
            'source_identity_held': held, 'reason': reason,
            'cross_case_digest': cross_case,
            'evidence_id': decision['evidence_id'] if decision else None,
            'authenticates_source': False, 'reads_or_moves_files': False,
            'grants_publication': False,
        })
    return deepcopy(result)

실제로 실행한 것은 합성 메타데이터였다

기존 모듈의 공개 ZIP 6,435바이트와 SHA를 확인하고 추출한 뒤 포함된 19개 시험을 실행했다. 여기에 새 출처 검토 함수와 갤러리 연결을 다루는 시험 24개를 별도로 실행했다. 동일 해시의 두 현장, 한쪽만 확인한 경우, 다른 현장으로 확인한 경우와 다른 보류가 남은 경우를 포함한다. 실제 사진이나 원래 프로그램의 검수 성공 수가 아니다.

다섯 합성 실행 기록에서 출처 미확인 사진이 대표 후보와 비교에서 빠지고, 다른 공개 가능 사진이 갤러리에 남는지 읽었다. 한 현장의 모든 사진이 보류돼도 그 현장 ID와 준비 상태가 남았다. 소속 확인이 끝나도 다른 보류나 미검수 표시 때문에 사진이 계속 제외되는 경우도 확인했다. 아래에는 그중 미확인 사례의 실제 합성 출력을 실었다.

내가 공개하고 싶은 노하우는 중복을 빨리 지우는 요령보다, 자동 비교가 아는 사실과 사람이 이어 확인할 사실을 연결하는 방법이다. 같은 사진이라는 신호를 어디의 시공 사례라는 주장으로 바꾸기 전에 멈추고, 원본과 서로 다른 현장을 그대로 남길 수 있어야 한다. 파일 정리와 공개 설명을 함께 책임지는 홈페이지는 그 경계를 계속 읽을 수 있어야 한다.

코드와 구현 살펴보기

실제 합성 실행 기록 · 미확인 사례

{
  "source_decisions": [
    {
      "id": "a-before",
      "case_id": "case-a",
      "source_identity_held": false,
      "reason": "no_cross_case_digest_conflict",
      "cross_case_digest": false,
      "evidence_id": null,
      "authenticates_source": false,
      "reads_or_moves_files": false,
      "grants_publication": false
    },
    {
      "id": "a-after",
      "case_id": "case-a",
      "source_identity_held": true,
      "reason": "cross_case_digest_needs_review",
      "cross_case_digest": true,
      "evidence_id": null,
      "authenticates_source": false,
      "reads_or_moves_files": false,
      "grants_publication": false
    },
    {
      "id": "a-safe",
      "case_id": "case-a",
      "source_identity_held": false,
      "reason": "no_cross_case_digest_conflict",
      "cross_case_digest": false,
      "evidence_id": null,
      "authenticates_source": false,
      "reads_or_moves_files": false,
      "grants_publication": false
    },
    {
      "id": "b-after",
      "case_id": "case-b",
      "source_identity_held": true,
      "reason": "cross_case_digest_needs_review",
      "cross_case_digest": true,
      "evidence_id": null,
      "authenticates_source": false,
      "reads_or_moves_files": false,
      "grants_publication": false
    }
  ],
  "projection": {
    "case_id": "case-a",
    "gallery_ids": [
      "a-safe"
    ],
    "pair_ids": [],
    "comparison_ids": [],
    "public_photo_ids": [
      "a-safe"
    ],
    "cover_id": "a-safe",
    "empty_state": null
  }
}
기존 갤러리 ZIP · 새 출처 함수는 위 본문 참조
기존 공개본 6,435바이트 · source_gate.py 미포함