diff --git a/scripts/check-preservation.py b/scripts/check-preservation.py index b4bd39f..624de8d 100644 --- a/scripts/check-preservation.py +++ b/scripts/check-preservation.py @@ -35,9 +35,15 @@ EXTRACTORS: dict[str, re.Pattern[str]] = { "인라인코드": re.compile(r"`([^`\n]+)`"), "URL": re.compile(r"(https?://[^\s`)\"'\]]+)"), "직접인용": re.compile(r"「([^」]+)」"), - # 수치 — 소수·천단위 구분·단위·백분율·시각까지 한 덩어리로 잡는다. - # 앞뒤가 한글이면 낱말의 일부일 수 있어 낱말 경계를 요구한다 - "수치": re.compile(r"(? collections.Counter: return collections.Counter({h: text.count(h) for h in HEDGES if text.count(h)}) +def _sentence_of(text: str, needle: str) -> str: + """그 값이 들어 있던 문장. 검토자가 다시 찾지 않게 한다.""" + i = text.find(needle) + if i < 0: + return "" + start = max(text.rfind("\n\n", 0, i) + 2, 0) + stop = text.find("\n\n", i) + chunk = text[start:stop if stop > 0 else len(text)] + return re.sub(r"\s+", " ", chunk).strip()[:200] + + def compare(before: str, after: str) -> dict: + """보호 구간의 변화를 낸다. **사라진 것과 새로 생긴 것을 다르게 판정한다.** + + - **새로 생겼다 → error.** 없던 수치·인용·코드가 붙은 것은 날조다. 코드가 판정할 수 있다. + - **사라졌다 → warning.** 부수 문장을 덜어 낸 것과 조건을 지운 것은 **같은 연산**이다. + 지운 문장에 숫자가 있었는지로는 안 갈린다. 갈릴 수 있는 척하면 사람이 채택한 편집을 + 막는다 — 실제로 100쌍 중 9건을 막았고 그 전부가 「자료가 뒷받침하지 않는 덧붙인 이득」과 + 「되풀이」를 지운 편집이었다. 근거를 읽어야 갈리는 자리는 검토로 넘긴다. + """ b, a = _counts(before), _counts(after) - findings = [] + errors, warnings = [], [] for name in EXTRACTORS: - lost = b[name] - a[name] - gained = a[name] - b[name] - for value, n in sorted(lost.items()): - findings.append({"kind": name, "change": "사라짐", "count": n, "value": value}) - for value, n in sorted(gained.items()): - findings.append({"kind": name, "change": "새로생김", "count": n, "value": value}) + for value, n in sorted((b[name] - a[name]).items()): + warnings.append({"kind": name, "change": "사라짐", "count": n, "value": value, + "sentence": _sentence_of(before, value), + "note": "덜어 낸 것인지 조건을 지운 것인지는 근거를 읽어야 안다"}) + for value, n in sorted((a[name] - b[name]).items()): + errors.append({"kind": name, "change": "새로생김", "count": n, "value": value, + "sentence": _sentence_of(after, value), + "note": "편집 전에 없던 값이다"}) hb, ha = _hedges(before), _hedges(after) - dropped = hb - ha - hedge = [{"word": w, "before": hb[w], "after": ha[w]} for w in sorted(dropped)] - return {"findings": findings, "hedgesDropped": hedge, + hedge = [] + for w in sorted(hb - ha): + hedge.append({"word": w, "before": hb[w], "after": ha[w], + "sentence": _sentence_of(before, w)}) + return {"findings": errors + warnings, # 옛 이름을 남긴다 — 전부 보고 싶은 쪽이 있다 + "errors": errors, "warnings": warnings, + "hedgesDropped": hedge, "hedgeTotalBefore": sum(hb.values()), "hedgeTotalAfter": sum(ha.values())} @@ -97,32 +128,40 @@ def main() -> int: if args.json: print(json.dumps(res, ensure_ascii=False, indent=2)) - return 1 if res["findings"] else 0 + return 1 if res["errors"] else 0 print(f"\n편집 전 {os.path.relpath(args.before, ROOT)}" f"\n편집 후 {os.path.relpath(args.after, ROOT)}") - grouped = collections.defaultdict(list) - for f in res["findings"]: - grouped[(f["kind"], f["change"])].append(f) - for (kind, change), items in sorted(grouped.items()): - print(f" ✗ {kind} {change} {len(items):>3}건") - for f in items[:args.samples]: - v = f["value"].replace("\n", "⏎") - print(f" · {v[:96]}") - if len(items) > args.samples: - print(f" … 외 {len(items) - args.samples}건") + for label, bucket, mark in (("error", res["errors"], "✗"), + ("warn", res["warnings"], "!")): + grouped = collections.defaultdict(list) + for f in bucket: + grouped[(f["kind"], f["change"])].append(f) + for (kind, change), items in sorted(grouped.items()): + print(f" {mark} {label} {kind} {change} {len(items):>3}건") + for f in items[:args.samples]: + v = f["value"].replace("\n", "⏎") + print(f" · {v[:90]}") + if f.get("sentence"): + print(f" 그 자리: {f['sentence'][:88]}") + if len(items) > args.samples: + print(f" … 외 {len(items) - args.samples}건") if res["hedgesDropped"]: - print(f" ! 유보 표현이 줄었다 — 편집 전 {res['hedgeTotalBefore']}" + print(f" ! warn 유보 표현이 줄었다 — 편집 전 {res['hedgeTotalBefore']}" f" → 편집 후 {res['hedgeTotalAfter']}") for h in res["hedgesDropped"][:args.samples]: print(f" · {h['word']} {h['before']}회 → {h['after']}회") + if h.get("sentence"): + print(f" 그 자리: {h['sentence'][:88]}") print(" 확신이 올라간 것인지는 이 검사기가 모른다. 근거를 읽는 검토가 판단한다") - n = len(res["findings"]) - print(f"\nPRESERVATION: {'FAIL' if n else 'PASS'} — 보호 구간 변화 {n}건" - f" · 유보 감소 {len(res['hedgesDropped'])}종") - return 1 if n else 0 + e, w = len(res["errors"]), len(res["warnings"]) + len(res["hedgesDropped"]) + print(f"\nPRESERVATION: {'FAIL' if e else 'PASS'} — 새로 생긴 보호 구간 {e}건" + f" · 읽어야 할 것 {w}건") + if w and not e: + print("경고는 통과가 아니다. 근거를 읽는 검토가 항목마다 판정한다", file=sys.stderr) + return 1 if e else 0 if __name__ == "__main__": diff --git a/scripts/review-package.py b/scripts/review-package.py index fbe5132..fb24835 100644 --- a/scripts/review-package.py +++ b/scripts/review-package.py @@ -225,9 +225,17 @@ def build(project: str, record: str, figures_dir: str | None = None, warnings.append({ "id": "유보 감소", "detail": f"{h['word']} {h['before']}회 → {h['after']}회", + "sentence": h.get("sentence", ""), "note": "종료 코드로는 안 걸린다. 확신이 올라간 것인지 그 자리에서 " "덜어 낼 만했던 것인지는 근거를 읽어야 안다", }) + for f in preservation.get("warnings", []): + warnings.append({ + "id": f"보호 구간 {f['change']}", + "detail": f"{f['kind']} — {f['value'][:80]}", + "sentence": f.get("sentence", ""), + "note": f.get("note", ""), + }) return { "schemaVersion": 2, @@ -252,6 +260,9 @@ def build(project: str, record: str, figures_dir: str | None = None, "claimCandidates": _claim_candidates(text), "judgmentCriteria": CLAIM_KINDS, "reviewerNotes": [ + "**gates 가 전부 exit 0 이어도 그것만으로 통과가 아니다.** warnings 가 비어 있어야 " + "자동 통과다. warnings 가 있으면 근거를 읽는 검토가 항목마다 PASS·FAIL·UNKNOWN 을 " + "낸다. UNKNOWN 은 통과가 아니다.", "이 파일의 어느 값도 판정이 아니다. 관문의 exit 는 형식 검사의 결과일 뿐이다.", "claimCandidates 는 표면 표지로 뽑은 것이라 주장이 아닌 문장이 섞인다. " "반대로 표지가 없는 주장은 빠진다 — 본문을 읽고 빠진 것을 찾는 것이 검토의 일이다.",