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:
co-authored by
Claude Opus 5
parent
845ee89054
commit
20c8535169
@@ -220,9 +220,20 @@ def build(project: str, record: str, figures_dir: str | None = None,
|
||||
|
||||
warnings: list[dict] = []
|
||||
preservation = _preservation(before, rec_abs) if before else {"available": False}
|
||||
|
||||
def _warn(entry: dict) -> None:
|
||||
"""경고에 안정 키를 붙여 담는다.
|
||||
|
||||
판정이 **어느 경고에 붙은 것인지** 정해져야 저장 게이트가 대조할 수 있다.
|
||||
내용이 바뀌면 키도 바뀐다 — 다른 경고에 옛 판정이 붙지 않는다.
|
||||
읽는 쪽은 `scripts/studio-save.py` 의 `_review_gate` 다.
|
||||
"""
|
||||
seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset"))
|
||||
entry["key"] = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
|
||||
warnings.append(entry)
|
||||
if preservation.get("available"):
|
||||
for h in preservation["hedgesDropped"]:
|
||||
warnings.append({
|
||||
_warn({
|
||||
"id": "유보 감소",
|
||||
"detail": f"{h['word']} {h['before']}회 → {h['after']}회",
|
||||
"sentence": h.get("sentence", ""),
|
||||
@@ -230,7 +241,7 @@ def build(project: str, record: str, figures_dir: str | None = None,
|
||||
"덜어 낼 만했던 것인지는 근거를 읽어야 안다",
|
||||
})
|
||||
for f in preservation.get("warnings", []):
|
||||
warnings.append({
|
||||
_warn({
|
||||
"id": f"보호 구간 {f['change']}",
|
||||
"detail": f"{f['kind']} — {f['value'][:80]}",
|
||||
"sentence": f.get("sentence", ""),
|
||||
|
||||
+82
-4
@@ -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
|
||||
|
||||
@@ -304,3 +304,89 @@ class CliTest(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class ReviewGateTest(unittest.TestCase):
|
||||
"""검토로 보낸다는 것은 저장이 막힌다는 뜻이어야 한다.
|
||||
|
||||
막지 않으면 라우팅이 아니라 주석이다. `quality-policy@1` §6 — 「UNKNOWN 은 통과가 아니다」.
|
||||
이 게이트가 없던 동안 검토로 보낸 다섯 건이 그대로 저장됐다.
|
||||
"""
|
||||
|
||||
WARN = [{"id": "유보 감소", "detail": "가능성 2회 → 1회", "key": "aaaa1111"},
|
||||
{"id": "그림이 검토 뒤에 바뀌었다", "detail": "fd5f → 3ba4", "key": "bbbb2222"}]
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.pkg = os.path.join(self.dir, "pkg.json")
|
||||
json.dump({"target": {"sha256": "x"}}, open(self.pkg, "w", encoding="utf-8"))
|
||||
self.book = os.path.join(self.dir, "v.json")
|
||||
|
||||
def _write(self, verdicts, sha=None, keys=None):
|
||||
json.dump({"schemaVersion": 1, "reviewer": "사람",
|
||||
"packageSha256": sha if sha is not None else ss._sha256_file(self.pkg),
|
||||
"verdicts": [{"key": k, "verdict": v, "why": "왜"}
|
||||
for k, v in zip(keys or [w["key"] for w in self.WARN],
|
||||
verdicts)]},
|
||||
open(self.book, "w", encoding="utf-8"), ensure_ascii=False)
|
||||
|
||||
def test_no_warnings_needs_no_verdict_file(self):
|
||||
"""대조군. 정상은 이 게이트에 안 걸린다 — 불필요한 차단을 만들지 않는다."""
|
||||
self.assertEqual([], ss._review_gate([], None, self.pkg))
|
||||
|
||||
def test_warnings_without_a_verdict_file_are_refused(self):
|
||||
"""검토를 안 받은 것과 검토가 통과시킨 것은 같은 결과일 수 없다."""
|
||||
with self.assertRaises(ss.Refused) as e:
|
||||
ss._review_gate(self.WARN, None, self.pkg)
|
||||
self.assertIn("검토를 안 받은 것", str(e.exception))
|
||||
|
||||
def test_all_pass_lets_the_save_through(self):
|
||||
self._write(["PASS", "PASS"])
|
||||
cleared = ss._review_gate(self.WARN, self.book, self.pkg)
|
||||
self.assertEqual(2, len(cleared))
|
||||
self.assertEqual({"PASS"}, {c["verdict"] for c in cleared})
|
||||
|
||||
def test_fail_and_unknown_both_block_with_different_words(self):
|
||||
"""둘 다 막되 문구가 갈린다 — 근거가 모자란 것과 틀렸다고 본 것은 다르다."""
|
||||
self._write(["PASS", "FAIL"])
|
||||
with self.assertRaises(ss.Refused) as fail:
|
||||
ss._review_gate(self.WARN, self.book, self.pkg)
|
||||
self._write(["PASS", "UNKNOWN"])
|
||||
with self.assertRaises(ss.Refused) as unknown:
|
||||
ss._review_gate(self.WARN, self.book, self.pkg)
|
||||
self.assertIn("틀렸다고 봤다", str(fail.exception))
|
||||
self.assertIn("근거가 모자라", str(unknown.exception))
|
||||
self.assertNotEqual(str(fail.exception), str(unknown.exception))
|
||||
|
||||
def test_a_warning_with_no_verdict_is_refused(self):
|
||||
"""빠뜨린 것과 통과시킨 것은 다르다."""
|
||||
self._write(["PASS"], keys=[self.WARN[0]["key"]])
|
||||
with self.assertRaises(ss.Refused) as e:
|
||||
ss._review_gate(self.WARN, self.book, self.pkg)
|
||||
self.assertIn("판정이 안 붙은", str(e.exception))
|
||||
|
||||
def test_a_verdict_for_another_package_is_refused(self):
|
||||
"""묶음이 바뀌면 그 판정을 이 묶음에 쓸 수 없다."""
|
||||
self._write(["PASS", "PASS"], sha="0" * 64)
|
||||
with self.assertRaises(ss.Refused) as e:
|
||||
ss._review_gate(self.WARN, self.book, self.pkg)
|
||||
self.assertIn("다른 묶음", str(e.exception))
|
||||
|
||||
def test_a_missing_verdict_file_is_refused(self):
|
||||
with self.assertRaises(ss.Refused):
|
||||
ss._review_gate(self.WARN, os.path.join(self.dir, "nope.json"), self.pkg)
|
||||
|
||||
def test_an_unknown_verdict_word_is_refused(self):
|
||||
self._write(["PASS", "MAYBE"])
|
||||
with self.assertRaises(ss.Refused):
|
||||
ss._review_gate(self.WARN, self.book, self.pkg)
|
||||
|
||||
def test_the_key_changes_when_the_warning_changes(self):
|
||||
"""판정이 다른 경고에 옛 판정으로 붙지 않아야 한다."""
|
||||
import hashlib
|
||||
def key(entry):
|
||||
seed = "|".join(str(entry.get(k, "")) for k in ("id", "detail", "asset"))
|
||||
return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
|
||||
a = {"id": "유보 감소", "detail": "가능성 2회 → 1회"}
|
||||
b = {"id": "유보 감소", "detail": "가능성 3회 → 1회"}
|
||||
self.assertNotEqual(key(a), key(b))
|
||||
|
||||
Reference in New Issue
Block a user