knowledge(품셈md): 도구 check · pages · status + 잠금 시험
- check: ① 수(갯수까지) ② 글자(공백 뗌) 양방향 대조 · 어긋난 자리를 쪽·줄로 출력 · 머리 기계검사 칸에 적음 - md 쪽은 머리 · 주석 · 그림 · 표 문법 · 제목·인용 표시 · 첨자 표시를 빼고, 막음표(\) 뒤 글자는 원문으로 셈 - 확정 파일은 통과할 때 확정지문(LF 로 센 sha256)을 한 번만 적음 — 있는 지문은 안 덮음 - pages: 쪽 그림 150dpi → tmp/pum_pages(git 밖) · status: 장별 상태 표 - test_pum_md_locked.py: 확정 파일 지문·①② 0 · 머리 칸 다 있음 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -14,6 +14,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import difflib
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -22,7 +25,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[4] # 저장소 뿌리
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from resources.tester.test_const_pdf_coverage import _page_rows # noqa: E402
|
||||
from resources.tester.test_const_pdf_coverage import _NUM, _page_rows # noqa: E402
|
||||
|
||||
COST = ROOT / "resources/knowledge/original/원가계산"
|
||||
|
||||
@@ -299,10 +302,237 @@ def plan(book_name: str) -> list[Path]:
|
||||
return written
|
||||
|
||||
|
||||
# ── check ──────────────────────────────────────────────────────────────
|
||||
#: 쪽 번호 줄(「- 123 -」) — 절 글자에서 뺌
|
||||
_PAGE_NO = re.compile(r"^-\s*\d+\s*-$")
|
||||
#: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님)
|
||||
_COMMENT = re.compile(r"<!--.*?-->", re.S)
|
||||
_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
|
||||
_TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
|
||||
#: 막음표 — 원문 글자가 md 문법으로 읽히지 않게 앞에 `\` 를 붙인 것(`\*` `\-` `\#` `\>` `\|` …)
|
||||
_ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~^])")
|
||||
_HOLD = "\x00"
|
||||
|
||||
|
||||
def book_of(path: Path) -> dict:
|
||||
path = path.resolve()
|
||||
for book in BOOKS.values():
|
||||
if path.is_relative_to(book["out"].resolve()):
|
||||
return book
|
||||
raise ValueError(f"본문/ 아래 파일이 아님: {path}")
|
||||
|
||||
|
||||
def split_head(text: str) -> tuple[str, str]:
|
||||
"""(머리, 몸) — 머리는 첫 `---` 부터 다음 `---` 까지."""
|
||||
lines = text.split("\n")
|
||||
end = lines.index("---", 1)
|
||||
return "\n".join(lines[: end + 1]), "\n".join(lines[end + 1 :])
|
||||
|
||||
|
||||
def md_lines(body: str) -> list[tuple[int, str]]:
|
||||
"""몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터."""
|
||||
body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body)
|
||||
out = []
|
||||
for number, line in enumerate(body.split("\n"), start=1):
|
||||
if _TABLE_RULE.match(line):
|
||||
continue
|
||||
line = _IMAGE.sub("", line).replace("<br>", " ")
|
||||
held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴
|
||||
line = _ESCAPED.sub(_HOLD, line)
|
||||
line = re.sub(r"^\s*#+\s", "", line) # 제목
|
||||
line = re.sub(r"^\s*(>\s*)+", "", line) # 인용
|
||||
line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`)
|
||||
for ch in held:
|
||||
line = line.replace(_HOLD, ch, 1)
|
||||
out.append((number, line))
|
||||
return out
|
||||
|
||||
|
||||
def pdf_lines(doc, head: dict[str, str]) -> list[tuple[int, str]]:
|
||||
"""그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 쪽 번호 줄 뺌."""
|
||||
first, _, last = head["PDF쪽"].partition("~")
|
||||
first, last = int(first), int(last or first)
|
||||
start_page, start_row = locate(doc, head["시작표지"], first, slack=0)
|
||||
end_page, end_row = last + 1, 0
|
||||
if head["끝표지"] and head["끝표지"] != "끝":
|
||||
try:
|
||||
end_page, end_row = locate(doc, head["끝표지"], last, slack=1)
|
||||
except ValueError:
|
||||
pass
|
||||
out = []
|
||||
for number in range(start_page, min(end_page, doc.page_count) + 1):
|
||||
for index, row in enumerate(_page_rows(doc[number - 1])):
|
||||
if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row):
|
||||
continue
|
||||
if not _PAGE_NO.match(row.strip()):
|
||||
out.append((number, row))
|
||||
return out
|
||||
|
||||
|
||||
def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]:
|
||||
"""공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄)."""
|
||||
text, where = [], []
|
||||
for at, line in lines:
|
||||
for ch in re.sub(r"\s", "", line):
|
||||
text.append(ch)
|
||||
where.append(at)
|
||||
return "".join(text), where
|
||||
|
||||
|
||||
def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[str, list[int]]]:
|
||||
counts, where = collections.Counter(), collections.defaultdict(list)
|
||||
for at, line in lines:
|
||||
for token in _NUM.findall(line):
|
||||
counts[token] += 1
|
||||
where[token].append(at)
|
||||
return counts, where
|
||||
|
||||
|
||||
def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict:
|
||||
"""① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향."""
|
||||
pdf_n, pdf_at = _numbers(pdf)
|
||||
md_n, md_at = _numbers(md)
|
||||
pdf_t, pdf_where = _tight(pdf)
|
||||
md_t, md_where = _tight(md)
|
||||
missing_c = collections.Counter(pdf_t) - collections.Counter(md_t)
|
||||
extra_c = collections.Counter(md_t) - collections.Counter(pdf_t)
|
||||
spots = []
|
||||
if missing_c or extra_c: # 자리 — 차례대로 맞대 어긋난 토막(갯수 차가 난 글자가 든 것만)
|
||||
matcher = difflib.SequenceMatcher(None, pdf_t, md_t, autojunk=False)
|
||||
for op, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
gone, added = pdf_t[i1:i2], md_t[j1:j2]
|
||||
if op == "equal" or not (set(gone) & set(missing_c) or set(added) & set(extra_c)):
|
||||
continue
|
||||
spots.append(
|
||||
{
|
||||
"pdf": pdf_where[min(i1, len(pdf_where) - 1)] if pdf_where else None,
|
||||
"md": md_where[min(j1, len(md_where) - 1)] if md_where else None,
|
||||
"context": pdf_t[max(0, i1 - 8) : i1],
|
||||
"pdf_only": gone,
|
||||
"md_only": added,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"num_missing": {k: (v, pdf_at[k][:v]) for k, v in (pdf_n - md_n).items()},
|
||||
"num_extra": {k: (v, md_at[k][:v]) for k, v in (md_n - pdf_n).items()},
|
||||
"char_missing": missing_c,
|
||||
"char_extra": extra_c,
|
||||
"spots": spots,
|
||||
}
|
||||
|
||||
|
||||
def verdict(result: dict) -> str:
|
||||
counts = (
|
||||
sum(v for v, _ in result["num_missing"].values()),
|
||||
sum(v for v, _ in result["num_extra"].values()),
|
||||
sum(result["char_missing"].values()),
|
||||
sum(result["char_extra"].values()),
|
||||
)
|
||||
word = "통과" if not any(counts) else "불통"
|
||||
return (
|
||||
f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}"
|
||||
)
|
||||
|
||||
|
||||
def fingerprint(text: str) -> str:
|
||||
"""확정지문 — 줄끝을 LF 로 고쳐 셈(git 이 CRLF 로 풀어도 같게) · 도구가 적는 두 칸(기계검사·확정지문)은 뺌."""
|
||||
lines = text.replace("\r\n", "\n").split("\n")
|
||||
kept = [ln for ln in lines if not ln.startswith(("기계검사:", "확정지문:"))]
|
||||
return hashlib.sha256("\n".join(kept).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def set_head(text: str, field: str, value: str) -> str:
|
||||
head, body = split_head(text)
|
||||
head = re.sub(rf"(?m)^{field}:.*$", f"{field}: {value}".rstrip(), head, count=1)
|
||||
return head + "\n" + body
|
||||
|
||||
|
||||
def check_file(path: Path) -> tuple[dict, str]:
|
||||
"""(대조 결과, 한 줄 판정) — 파일은 안 고침."""
|
||||
import pymupdf
|
||||
|
||||
_, body = split_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"))
|
||||
with pymupdf.open(book_of(path)["pdf"]) as doc:
|
||||
result = compare(pdf_lines(doc, read_head(path)), md_lines(body))
|
||||
return result, verdict(result)
|
||||
|
||||
|
||||
def check(path: Path) -> int:
|
||||
"""검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음.
|
||||
|
||||
⚠ 있는 확정지문은 안 고침 — 확정 뒤 바뀐 파일을 지문을 다시 적어 덮으면 시험이 못 잡음.
|
||||
"""
|
||||
result, line = check_file(path)
|
||||
print(f"{path.name}: {line}")
|
||||
for token, (count, at) in sorted(result["num_missing"].items()):
|
||||
print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)")
|
||||
for token, (count, at) in sorted(result["num_extra"].items()):
|
||||
print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)")
|
||||
for spot in result["spots"][:40]:
|
||||
print(
|
||||
f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}"
|
||||
f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]"
|
||||
)
|
||||
text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line)
|
||||
head = read_head(path)
|
||||
if head.get("상태") == "확정" and line.startswith("통과") and not head.get("확정지문"):
|
||||
text = set_head(text, "확정지문", fingerprint(text))
|
||||
path.write_text(text, encoding="utf-8", newline="\n")
|
||||
return 0 if line.startswith("통과") else 1
|
||||
|
||||
|
||||
# ── pages · status ─────────────────────────────────────────────────────
|
||||
PAGES_DIR = ROOT / "tmp/pum_pages"
|
||||
|
||||
|
||||
def pages(book_name: str, span: str = "") -> list[Path]:
|
||||
"""쪽 그림 150dpi PNG — `tmp/pum_pages/<책>/p0135.png`(git 밖)."""
|
||||
import pymupdf
|
||||
|
||||
out = PAGES_DIR / book_name
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
made = []
|
||||
with pymupdf.open(BOOKS[book_name]["pdf"]) as doc:
|
||||
first, _, last = (span or f"1~{doc.page_count}").replace("-", "~").partition("~")
|
||||
for number in range(int(first), int(last or first) + 1):
|
||||
path = out / f"p{number:04d}.png"
|
||||
doc[number - 1].get_pixmap(dpi=150).save(path)
|
||||
made.append(path)
|
||||
return made
|
||||
|
||||
|
||||
def status() -> str:
|
||||
"""장별 상태 표(md) — 머리 「상태」 칸을 모음."""
|
||||
rows = [
|
||||
"| 책 | 장 | " + " | ".join(STATES) + " | 계 |",
|
||||
"|---|---|" + "---|" * (len(STATES) + 1),
|
||||
]
|
||||
for name, book in BOOKS.items():
|
||||
by_chapter: dict[str, collections.Counter] = {}
|
||||
for path in sorted(book["out"].rglob("*.md")):
|
||||
chapter = path.relative_to(book["out"]).parts[0]
|
||||
by_chapter.setdefault(chapter, collections.Counter())[
|
||||
read_head(path).get("상태", "")
|
||||
] += 1
|
||||
for chapter, counts in by_chapter.items():
|
||||
cells = " | ".join(str(counts[s]) for s in STATES)
|
||||
rows.append(f"| {name} | {chapter} | {cells} | {sum(counts.values())} |")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) >= 2 and argv[0] == "plan":
|
||||
made = plan(argv[1])
|
||||
print(f"새 파일 {len(made)}")
|
||||
command, args = (argv[0], argv[1:]) if argv else ("", [])
|
||||
if command == "plan" and args:
|
||||
print(f"새 파일 {len(plan(args[0]))}")
|
||||
return 0
|
||||
if command == "pages" and args:
|
||||
made = pages(args[0], args[1] if len(args) > 1 else "")
|
||||
print(f"쪽 그림 {len(made)} → {PAGES_DIR / args[0]}")
|
||||
return 0
|
||||
if command == "check" and args:
|
||||
return max(check(Path(a)) for a in args)
|
||||
if command == "status":
|
||||
print(status())
|
||||
return 0
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""품셈 md 잠금 — 상태 `확정` 파일은 확정지문이 맞고 기계검사 ①② 가 0 (규칙 6장).
|
||||
|
||||
`확정` 뒤 파일이 바뀌면 빨강. 고치려면 상태를 `작성중` 으로 되돌리고 ①②③ 다시.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
|
||||
|
||||
import pum_md_tool as tool # noqa: E402
|
||||
|
||||
LOCKED = [
|
||||
path
|
||||
for book in tool.BOOKS.values()
|
||||
for path in sorted(book["out"].rglob("*.md"))
|
||||
if tool.read_head(path).get("상태") == "확정"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", LOCKED, ids=lambda p: p.name)
|
||||
def test_locked_file_unchanged_and_clean(path):
|
||||
head = tool.read_head(path)
|
||||
assert head["확정지문"], "확정인데 지문이 없음 — check 를 돌려 적을 것"
|
||||
assert tool.fingerprint(path.read_text(encoding="utf-8")) == head["확정지문"], (
|
||||
"확정 뒤 파일이 바뀜 — 상태를 작성중으로 되돌리고 ①②③ 다시"
|
||||
)
|
||||
pytest.importorskip("pymupdf")
|
||||
_, line = tool.check_file(path)
|
||||
assert line.startswith("통과"), line
|
||||
|
||||
|
||||
def test_heads_are_well_formed():
|
||||
"""모든 새 md 머리 — 칸이 다 있고 상태가 넷 중 하나."""
|
||||
for book in tool.BOOKS.values():
|
||||
for path in book["out"].rglob("*.md"):
|
||||
head = tool.read_head(path)
|
||||
assert set(tool.HEAD_KEYS) <= set(head), path
|
||||
assert head["상태"] in tool.STATES, f"{path}: {head['상태']}"
|
||||
@@ -78,3 +78,46 @@ def test_forest_headings_follow_page_spacing():
|
||||
assert tool.find_heading(doc, "제1장", 13)[2] == "제1장 적용기준"
|
||||
page, row, _ = tool.find_heading(doc, "9-6", 136)
|
||||
assert (page, row) == (136, 0) # 쪽 맨 위 — 앞 절은 135 쪽에서 끝남
|
||||
|
||||
|
||||
def test_md_lines_drop_markup_keep_escaped_text():
|
||||
body = "\n".join(
|
||||
[
|
||||
"## 9-5. 발파암",
|
||||
"",
|
||||
"(단위: ㎥당)",
|
||||
"",
|
||||
"| 구분 | 수량 |",
|
||||
"|---|---|",
|
||||
"| 보통인부 | 0.50 |",
|
||||
r"| a\|b | 1,000<br>2 |",
|
||||
"<!-- 병합: 1열 2~4행 「인력」 -->",
|
||||
"> 【예시】 10^-7",
|
||||
r"\- 원문 줄표",
|
||||
"",
|
||||
]
|
||||
)
|
||||
text = " ".join(line for _, line in tool.md_lines(body))
|
||||
assert "#" not in text and "---" not in text and "<br>" not in text and "병합" not in text
|
||||
assert "a|b" in text # 막음표 뗀 원문 글자
|
||||
assert "- 원문 줄표" in text and "그림" not in text and "pic" not in text
|
||||
assert "10 -7" in text # 첨자 표시 ^ 는 뺌
|
||||
|
||||
|
||||
def test_compare_counts_numbers_and_chars_both_ways():
|
||||
pdf = [(135, "보통인부 0.50 1,000"), (135, "비고")]
|
||||
same = tool.compare(pdf, tool.md_lines("| 보통인부 | 0.50 | 1,000 |\n비고"))
|
||||
assert tool.verdict(same).startswith("통과")
|
||||
bad = tool.compare(pdf, [(1, "보통인부 0.5 1000 비고 가")])
|
||||
assert set(bad["num_missing"]) == {"0.50", "1,000"} # 자릿수·쉼표 바꾸면 결손
|
||||
assert set(bad["num_extra"]) == {"0.5", "1000"}
|
||||
assert bad["char_extra"]["가"] == 1 and bad["char_missing"][","] == 1
|
||||
assert tool.verdict(bad).startswith("불통") and bad["spots"]
|
||||
|
||||
|
||||
def test_fingerprint_ignores_line_endings_and_tool_fields():
|
||||
text = "---\n상태: 확정\n기계검사: 통과\n확정지문:\n---\n본문\n"
|
||||
same = text.replace("\n", "\r\n").replace("기계검사: 통과", "기계검사: 다시")
|
||||
assert tool.fingerprint(text) == tool.fingerprint(same)
|
||||
assert tool.fingerprint(text) != tool.fingerprint(text.replace("본문", "본문!"))
|
||||
assert "확정지문: abc" in tool.set_head(text, "확정지문", "abc")
|
||||
|
||||
Reference in New Issue
Block a user