149 lines
7.8 KiB
Python
149 lines
7.8 KiB
Python
"""ASR 진단과 별개로 exact-byte 소유자 청취 승인을 검증하는 로컬 릴리스 gate."""
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import zipfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
APPROVAL = ".agent/RELEASE-APPROVAL-v0.2.5.json"
|
|
ASR = ".agent/evidence-v0.2.5/review-fixes/final-asr.json"
|
|
PRIOR_ASR = ".agent/evidence-v0.2.5/final-asr.json"
|
|
BUNDLE = ".agent/evidence-v0.2.5/release-approval/review-bundle.zip"
|
|
DELIVERED_BUNDLE = "/home/hermes/voice-memos/find-word-v0.2.5-normalized-ambience-review.zip"
|
|
# 사용자에게 전달된 ZIP의 고정 digest가 승인 범위의 신뢰 기준이다.
|
|
# manifest/WAV/승인 JSON을 함께 바꿔도 이 ZIP과 달라지면 승인으로 통과할 수 없다.
|
|
BUNDLE_SHA256 = "bb2e974b652df43db4a1f2a59716836e2c521839c724b34d4b71c1b40c3322e5"
|
|
IDS = ("street", "mall", "theater", "department", "market", "subway", "cafe", "rain")
|
|
REQUIRED_EMPTY = ["mall", "market", "cafe"]
|
|
MANIFEST = "public/ambience/manifest.json"
|
|
NOTICE = "public/ambience/AMBIENCE_NOTICES.md"
|
|
SCOPE = {
|
|
"version": "0.2.5",
|
|
"kind": "exact-byte-ambience-bundle",
|
|
"directory": "public/ambience",
|
|
"files": [*(f"{name}.wav" for name in IDS), "manifest.json", "AMBIENCE_NOTICES.md"],
|
|
}
|
|
|
|
|
|
def require(condition, message):
|
|
"""검증은 최적화 실행에서도 생략되지 않으며 실패 원인을 값 노출 없이 반환한다."""
|
|
if not condition:
|
|
raise ValueError(message)
|
|
|
|
|
|
def sha256(raw):
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def unique_object(pairs):
|
|
"""중복 JSON key의 마지막 값이 앞선 승인/clearance 값을 숨기지 못하게 한다."""
|
|
result = {}
|
|
for key, value in pairs:
|
|
require(key not in result, f"Duplicate JSON key: {key}")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def read_json(path):
|
|
return json.loads(path.read_bytes(), object_pairs_hook=unique_object)
|
|
|
|
|
|
def validate_screening(root, manifest, manifest_hash):
|
|
"""empty 결과뿐 아니라 exact 입력·동일 모델/VAD·screening 한계를 함께 확인한다."""
|
|
screening = read_json(root / ASR)
|
|
prior = read_json(root / PRIOR_ASR)
|
|
require(screening["tool"] == prior["tool"] and screening["model"] == prior["model"],
|
|
"ASR tool/model mismatch")
|
|
for key in ("device", "computeType", "cpuThreads", "numWorkers", "randomSeed",
|
|
"parameters", "vadModelSha256"):
|
|
require(screening["method"][key] == prior["method"][key], f"ASR setting mismatch: {key}")
|
|
require(screening["method"]["manifestSha256"] == manifest_hash, "ASR manifest mismatch")
|
|
require(screening["requiredEmptyEnvironmentIds"] == REQUIRED_EMPTY, "ASR required scope mismatch")
|
|
for key, value in {"screeningOnly": True, "humanListeningAcceptance": False,
|
|
"legalPrivacyClearance": False, "textIsVerifiedTranscript": False}.items():
|
|
require(screening["limitations"][key] is value, f"ASR limitation overclaim: {key}")
|
|
require([row["environmentId"] for row in screening["files"]] == list(IDS), "ASR file scope mismatch")
|
|
empty = True
|
|
for entry, row in zip(manifest["files"], screening["files"], strict=True):
|
|
require(row["file"] == f"public/ambience/{entry['file']}", "ASR file path mismatch")
|
|
for key in ("sha256", "bytes", "sourceSha256", "selectedSegmentStartSeconds"):
|
|
require(row[key] == entry[key], f"ASR asset mismatch: {key}")
|
|
require(isinstance(row["segments"], list), "ASR segments missing")
|
|
if entry["environmentId"] in REQUIRED_EMPTY:
|
|
empty = empty and row["segments"] == [] and row["text"] == "" and row["info"]["duration_after_vad"] == 0
|
|
require(screening["requiredEmptyPassed"] is empty, "ASR aggregate contradicts diagnostic evidence")
|
|
return empty
|
|
|
|
|
|
def validate_owner_approval(root, manifest, manifest_hash):
|
|
"""보고된 Oz 회신을 고정된 전달 ZIP 및 현재 파일 전체와 연결한다. 전자서명 인증은 아니다."""
|
|
approval = read_json(root / APPROVAL)
|
|
expected = {
|
|
"schemaVersion": 1,
|
|
"owner": "Oz",
|
|
"statement": "승인합니다. 이대로 진행하세요",
|
|
"statementSource": "User-reported Oz reply after receipt of the review bundle",
|
|
"timestampUtc": "2026-10-14T11:29:51Z",
|
|
"timestampKst": "2026-10-14T20:29:51+09:00",
|
|
"scope": SCOPE,
|
|
"productOwnerReleaseApproval": True,
|
|
"humanListeningAcceptance": True,
|
|
"legalPrivacyClearance": False,
|
|
"reviewBundle": {"file": BUNDLE, "deliveredPath": DELIVERED_BUNDLE, "sha256": BUNDLE_SHA256},
|
|
"manifest": {"file": MANIFEST, "sha256": manifest_hash},
|
|
"notice": {"file": NOTICE, "sha256": sha256((root / NOTICE).read_bytes())},
|
|
"wavs": [{"environmentId": row["environmentId"], "file": f"public/ambience/{row['file']}",
|
|
"sha256": row["sha256"], "bytes": row["bytes"]} for row in manifest["files"]],
|
|
}
|
|
# 알 수 없는 scope/clearance 필드도 거부한다. bool은 문자열/숫자를 승인값으로 인정하지 않는다.
|
|
require(approval == expected, "Exact owner approval statement/scope/timestamp/hash mismatch")
|
|
for key in ("productOwnerReleaseApproval", "humanListeningAcceptance", "legalPrivacyClearance"):
|
|
require(approval[key] is expected[key], f"Invalid approval flag: {key}")
|
|
archive_bytes = (root / BUNDLE).read_bytes()
|
|
require(sha256(archive_bytes) == BUNDLE_SHA256, "Reviewed bundle hash mismatch")
|
|
with zipfile.ZipFile(root / BUNDLE) as archive:
|
|
names = [*SCOPE["files"], "LISTENING-REVIEW.txt"]
|
|
require(sorted(archive.namelist()) == sorted(names), "Reviewed bundle file scope mismatch")
|
|
# 압축 해제 없이 전수 비교하므로 ZIP path traversal도 발생하지 않는다.
|
|
for name in SCOPE["files"]:
|
|
require(archive.read(name) == (root / "public/ambience" / name).read_bytes(),
|
|
f"Current asset differs from reviewed bundle: {name}")
|
|
|
|
|
|
def validate_release(root=ROOT):
|
|
"""자동 empty-ASR OR 유효한 exact 청취 승인. 존재하는 잘못된 승인은 항상 실패한다."""
|
|
root = Path(root)
|
|
manifest = read_json(root / MANIFEST)
|
|
manifest_hash = sha256((root / MANIFEST).read_bytes())
|
|
require(manifest["version"] == "0.2.5", "Release version mismatch")
|
|
require([row["environmentId"] for row in manifest["files"]] == list(IDS), "Manifest asset scope mismatch")
|
|
require(sorted(path.name for path in (root / "public/ambience").iterdir()) == sorted(SCOPE["files"]),
|
|
"Current asset directory scope mismatch")
|
|
for name, row in zip(IDS, manifest["files"], strict=True):
|
|
require(row["file"] == f"{name}.wav", "Manifest filename mismatch")
|
|
raw = (root / "public/ambience" / row["file"]).read_bytes()
|
|
require(sha256(raw) == row["sha256"] and len(raw) == row["bytes"], "Current WAV hash/bytes mismatch")
|
|
automated = validate_screening(root, manifest, manifest_hash)
|
|
owner = (root / APPROVAL).exists()
|
|
if owner:
|
|
validate_owner_approval(root, manifest, manifest_hash)
|
|
require(automated or owner, "ASR is nonempty and exact owner approval record is absent")
|
|
return {
|
|
"passed": True,
|
|
"acceptancePath": "automated-empty-asr" if automated else "exact-byte-owner-listening-approval",
|
|
"automatedEmptyAsrPassed": automated,
|
|
"productOwnerReleaseApproval": owner,
|
|
"humanListeningAcceptance": owner,
|
|
"legalPrivacyClearance": False,
|
|
"manifestSha256": manifest_hash,
|
|
"reviewBundleSha256": BUNDLE_SHA256 if owner else None,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
print(json.dumps(validate_release(), ensure_ascii=False, indent=2))
|
|
except (ValueError, KeyError, TypeError, OSError, zipfile.BadZipFile) as error:
|
|
print(json.dumps({"passed": False, "reason": str(error)}, ensure_ascii=False))
|
|
raise SystemExit(1) from error
|