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
|
||||
|
||||
Reference in New Issue
Block a user