feat(studio-save): 검토로 보낸다는 것은 저장이 막힌다는 뜻이어야 한다

시도 1 이 「8건이 PASS 로 나가지 않는다」를 세웠는데 코드 3건에 대해서만 성립했다. 경고는
실렸는데 그것을 읽고 막는 코드가 없었다. 검토로 라우팅한다는 것이 실제로는 경고를 붙여서
통과시키는 것이었다. 막지 않으면 라우팅이 아니라 주석이다.

경고가 있으면 항목마다 판정을 받고 전부 PASS 일 때만 저장이 나간다. 판정 파일이 없으면
거절한다 — 검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다. 판정이 안 붙은
경고가 있어도 거절한다 — 빠뜨린 것과 통과시킨 것은 다르다.

FAIL 과 UNKNOWN 은 둘 다 막되 문구가 갈린다. 근거가 모자라 판정을 못 한 것과 근거를 읽고
틀렸다고 본 것은 다음에 할 일이 다르다.

판정이 어느 경고에 붙은 것인지 정하려고 경고에 키를 붙였고, 그 키를 읽는 코드를 같은
변경에 넣었다. 내용이 바뀌면 키도 바뀌어 옛 판정이 다른 경고에 붙지 않는다.

경고가 없으면 판정 파일 없이 그대로 나간다. 정상은 이 게이트에 안 걸린다 — 채택 편집
100쌍에서 91쌍이 판정 없이 나가고 9쌍이 판정을 받아야 한다. 판정을 받아야 한다는 것은
막힌다는 뜻이 아니다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
This commit is contained in:
DongHyeonka
2026-09-10 15:53:26 +09:00
co-authored by Claude Opus 5
parent 845ee89054
commit 20c8535169
3 changed files with 181 additions and 6 deletions
+82 -4
View File
@@ -143,6 +143,10 @@ def inspect_assets(package: dict) -> tuple[list[dict], list[dict]]:
"detail": "data-figure-kind=\"illustrative\" — 실제 흐름이 아니라 개념 설명이다",
"note": "막지 않는다. 밝힌 대로 읽히는지만 검토가 본다",
})
for entry in warnings:
# 묶음의 경고와 같은 방식으로 키를 붙인다. 저장 게이트가 판정을 이 키로 찾는다
seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset"))
entry["key"] = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
return errors, warnings
@@ -241,6 +245,75 @@ def build_input(record_path: str) -> dict:
return doc
VERDICTS = ("PASS", "FAIL", "UNKNOWN")
def _review_gate(warnings: list[dict], verdict_path: str | None,
package_path: str) -> list[dict]:
"""경고가 있으면 **검토 판정을 받고 전부 PASS 일 때만** 저장이 나간다.
검토로 라우팅한다는 것은 **저장이 막힌다**는 뜻이어야 한다. 막지 않으면 라우팅이 아니라
주석이다. `quality-policy@1` §6 — 「UNKNOWN 은 통과가 아니다」.
이 함수가 없던 동안 검토로 보낸 다섯 건이 그대로 저장됐다. 경고는 실렸는데 읽는 쪽이
없었다 — **싣는 것과 막는 것은 다른 일이다.**
경고가 없으면 판정 파일이 없어도 그대로 나간다. 정상은 이 게이트에 안 걸린다.
"""
if not warnings:
return []
if not verdict_path:
raise Refused(
f"이 묶음에 검토가 필요한 것이 {len(warnings)}건 있다. --verdicts 로 판정 파일을 준다\n "
+ "\n ".join(f"[{w.get('key')}] {w['id']}{str(w.get('detail'))[:70]}"
for w in warnings)
+ "\n검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다")
if not os.path.isfile(verdict_path):
raise Refused(f"그런 판정 파일이 없다: {verdict_path}")
try:
book = json.load(open(verdict_path, encoding="utf-8"))
except (OSError, ValueError) as e:
raise Refused(f"판정 파일을 읽지 못했다: {e}")
want = book.get("packageSha256")
now = _sha256_file(package_path)
if want and want != now:
raise Refused(
"판정이 다른 묶음에 붙어 있다 — 그 판정을 이 묶음에 쓸 수 없다\n"
f" 판정이 본 묶음 {want}\n 지금 묶음 {now}")
by_key = {v.get("key"): v for v in book.get("verdicts") or []}
missing, failed, unknown, passed = [], [], [], []
for w in warnings:
v = by_key.get(w.get("key"))
if v is None:
missing.append(w)
continue
verdict = str(v.get("verdict", "")).upper()
if verdict not in VERDICTS:
raise Refused(f"판정은 {' · '.join(VERDICTS)} 중 하나다: {verdict!r}")
{"PASS": passed, "FAIL": failed, "UNKNOWN": unknown}[verdict].append((w, v))
if missing:
raise Refused(
"판정이 안 붙은 경고가 있다 — 빠뜨린 것과 통과시킨 것은 다르다\n "
+ "\n ".join(f"[{w.get('key')}] {w['id']}" for w in missing))
if failed:
raise Refused(
"검토가 **근거를 읽고 틀렸다고 봤다.** 고치기 전에는 저장하지 않는다\n "
+ "\n ".join(f"[{w.get('key')}] {w['id']}{v.get('why', '')[:70]}"
for w, v in failed))
if unknown:
raise Refused(
"검토가 **근거가 모자라 판정을 못 했다.** UNKNOWN 은 통과가 아니다 — "
"근거를 채우고 다시 본다\n "
+ "\n ".join(f"[{w.get('key')}] {w['id']}{v.get('why', '')[:70]}"
for w, v in unknown))
return [{"key": w.get("key"), "id": w["id"], "verdict": "PASS",
"why": v.get("why", ""), "reviewer": book.get("reviewer")}
for w, v in passed]
def approved(record_path: str, package_path: str) -> dict:
"""검토를 지난 최종본만 통과시킨다.
@@ -400,6 +473,7 @@ def main() -> int:
ap.add_argument("--document-id", help="이미 있는 문서. 없으면 만들기 계획을 낸다")
ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version")
ap.add_argument("-o", "--out", help="계획을 적을 파일")
ap.add_argument("--verdicts", help="경고마다 PASS/FAIL/UNKNOWN 을 적은 검토 판정 파일")
ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)")
args = ap.parse_args()
@@ -412,6 +486,8 @@ def main() -> int:
if asset_errors:
raise Refused("그림 허용 정책에 걸린다 — 저장 단계에서 막는다\n " +
"\n ".join(f"{e['asset']}: {e['detail']}" for e in asset_errors))
all_warnings = list(pkg.get("warnings") or []) + asset_warnings
cleared = _review_gate(all_warnings, args.verdicts, args.package)
doc = build_input(args.record)
steps = plan_requests(args.record, doc, args.document_id, args.expected_version)
@@ -432,7 +508,8 @@ def main() -> int:
"approvedAgainst": pkg["target"]["sha256"],
"checkerVersions": pkg.get("checkerVersions"),
"requests": steps,
"warnings": asset_warnings,
"warnings": all_warnings,
"reviewCleared": cleared,
"notes": [
"이 파일의 요청을 아직 보내지 않았다. mode 가 plan 이다.",
"게시·게시취소 경로는 이 어댑터가 만들지 않는다. 계획에 들어 있으면 거절한다.",
@@ -448,10 +525,11 @@ def main() -> int:
open(args.out, "w", encoding="utf-8").write(text)
ops = " · ".join(f"{s['op']} {s['method']}" for s in steps)
print(f"{args.out}{ops} · 그림 {len(pkg.get('assets') or [])}"
f"· 경고 {len(asset_warnings)}"
f"· 경고 {len(all_warnings)}(검토 통과 {len(cleared)}) "
f"· 무인 저장 {'' if UNATTENDED_SAVE_ENABLED else ''}")
if asset_warnings:
print("경고는 통과가 아니다. 근거를 읽는 검토가 항목마다 판정한다", file=sys.stderr)
if cleared:
print(f"검토가 통과시킨 경고 {len(cleared)}건 — 판정 파일 {args.verdicts}",
file=sys.stderr)
else:
print(text, end="")
return 0