Files
Aislo/resources/tester/test_const_pdf_coverage.py
T
eomsangdonandClaude Opus 5 5de4eeb505 docs(verification): 건설 PDF ↔ md 구멍을 장별 목록으로 — 결손 749 · 허구 547
브레인 B18 ①. 총량만으로는 누가 무엇을 고칠지 몰라 **쪽 · 표 제목 · 줄 글자**를 담아 장별로 나눔.

- `resources/tester/_pdf_gap_report.py` — 값 셈은 커버리지 시험 잣대를 그대로 씀(두 벌 금지)
- 목록 `docs/raw/verification/2026-09-18_건설PDF대조/` (목차 + 장별 43 파일 · 699 줄)
  · 결손 = PDF 에 있는데 md 에 없음 · 허구 = md 에만(대개 붙은 줄 — 같은 자리에서 짝을 이룸)
  · 큰 것: 제13장 플랜트 271 · 제8장 건설기계 122 · 제2장 토목 120 · 제4장 기계설비 51
- 가짜 양성 둘 다듬음
  · 쪽 머리 줄(「제13장 … 723」)·붙은 머리(`173제8장 건설기계`)는 목록에서 가림
    ⚠ **셈에서는 안 뺌** — md 도 머리를 본문에 붙여 적어 한쪽만 빼면 허구가 늘어남(749/547 → 600/929)
  · 표 제목 칸이 자료 줄을 집던 것 — **글자가 둘 이상 있어야 제목**으로 봄
  · 쪽 머리를 먼저 훑어 「장 모름」 474 → 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-18 16:48:56 +09:00

202 lines
8.5 KiB
Python

"""건설 원문 PDF ↔ md **전수 커버리지** — 2026-09-18 브레인 B14(사용자 지시).
왜 — 지금껏 증상이 잡히는 대로 하나씩 찾아 고쳐 느렸다. **구멍 전체 크기를 한 번에** 드러내
목록으로 몰아 고치려고 둔 검사다.
무엇을 재나 — PDF 글자층에서 좌표로 줄을 다시 묶어 **값처럼 생긴 수**를 뽑고, md 표의 수와
양방향으로 맞댄다.
```
결손 PDF 에 있는데 md 에 없음 ← 9020 해상기계처럼 표째 빠진 병
허구 md 에 있는데 PDF 에 없음 ← 잘못 지어 넣은 값
```
⚠ **줄 짝은 안 맞춘다** — PDF 는 칸마다 줄을 바꾸고 md 는 칸을 묶어 적어, 줄끼리 맞대면
멀쩡한 표도 어긋난 것으로 뜬다. **수의 갯수(중복 포함)** 로만 맞댄다.
⚠ **값처럼 생긴 수만 본다** — 소수점이 있거나 세 자리 이상. 쪽번호·차례 번호·항목 번호를
값으로 세면 셈이 노이즈에 묻힌다.
⚠ 차례(`···· 123`) 줄은 뺀다 — 쪽 번호가 값으로 섞인다.
지금 남은 것은 `fixtures/const_pdf_coverage_known.json` 에 **쪽별 셈**으로 적어 둔다.
줄이면 초록, 늘면 빨강 — 새 구멍이 생겼다는 뜻이다.
목록 갱신: `./venv/Scripts/python.exe resources/tester/test_const_pdf_coverage.py --prune`
"""
from __future__ import annotations
import collections
import json
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
PDF = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈.pdf"
MD = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈.md"
KNOWN = Path(__file__).with_name("fixtures") / "const_pdf_coverage_known.json"
_NUM = re.compile(r"\d[\d,]*(?:\.\d+)?")
#: 차례 줄 — 점선 뒤 쪽번호가 값으로 섞인다.
_LEADER = re.compile(r"[·․…\.]{4,}")
#: 쪽 머리 — 「제8장 건설기계 219」. ⚠ **셈에서는 안 뺀다** — md 도 머리를 본문에 붙여 적어
#: 한쪽만 빼면 그만큼 허구로 뜬다(749/547 → 600/929 였다). 목록을 뽑을 때만 가린다.
_PAGE_HEAD = re.compile(r"제\s*\d+\s*장|공통부문|토목부문|건축부문|기계설비부문|유지관리부문")
#: 쪽번호 줄 — 수 하나만 있고 다른 글자가 없다.
_ONLY_NUMBER = re.compile(r"\d[\d,]*(?:\.\d+)?")
#: 한 줄로 묶을 때 세로 오차(포인트).
_ROW_TOLERANCE = 2.5
def _norm(token: str) -> str:
return token.replace(",", "")
def _valuish(token: str) -> bool:
"""값처럼 생긴 수인가 — 소수점이 있거나 세 자리 이상.
⚠ 연도(`2026`·`1999`)는 뺀다 — 「'22년 보완」·발행연도가 값으로 섞인다.
"""
if re.fullmatch(r"(?:19|20)\d\d", token):
return False
return "." in token or len(token) >= 3
def _page_rows(page) -> list[str]:
"""글자 좌표로 **줄을 다시 묶는다** — PDF 글자층은 칸마다 줄을 바꾼다."""
words = sorted(page.get_text("words"), key=lambda w: (round(w[1] / _ROW_TOLERANCE), w[0]))
rows: list[list] = []
current: list = []
top: float | None = None
for word in words:
if top is None or abs(word[1] - top) <= _ROW_TOLERANCE:
current.append(word)
top = word[1] if top is None else top
else:
rows.append(current)
current, top = [word], word[1]
if current:
rows.append(current)
return [" ".join(w[4] for w in sorted(row, key=lambda x: x[0])) for row in rows]
def pdf_numbers() -> tuple[collections.Counter, dict[str, list[int]]]:
"""PDF 값 수의 셈과 **어느 쪽에서 나왔는지**."""
import pymupdf
counts: collections.Counter = collections.Counter()
pages: dict[str, list[int]] = collections.defaultdict(list)
with pymupdf.open(PDF) as doc:
for number, page in enumerate(doc, start=1):
for line in _page_rows(page):
if _LEADER.search(line):
continue
found = [_norm(x) for x in _NUM.findall(line) if _valuish(_norm(x))]
# ⚠ **쪽번호 줄만 뺀다** — 줄에 수 하나뿐이고 다른 글자가 없는 줄이다.
# 값 하나짜리 줄까지 빼면 여러 줄로 갈린 칸(`[80∼95]`)이 통째로 안 세어진다.
if not found or _ONLY_NUMBER.fullmatch(line.strip()):
continue
for token in found:
counts[token] += 1
pages[token].append(number)
return counts, pages
def md_numbers() -> collections.Counter:
"""md 전문의 값 수 — ⚠ **표만 보지 않는다.** md 는 PDF 를 옮겨 적은 글이라 본문·[주]까지
맞대야 「옮겨 적다 빠뜨린 것」이 드러난다(표만 보면 PDF 본문 쪽이 통째로 결손으로 뜬다)."""
counts: collections.Counter = collections.Counter()
for line in MD.read_text(encoding="utf-8").splitlines():
if _LEADER.search(line):
continue
found = [_norm(x) for x in _NUM.findall(line) if _valuish(_norm(x))]
if not found or _ONLY_NUMBER.fullmatch(line.strip()):
continue
counts.update(found)
return counts
def measure() -> dict[str, object]:
"""`{결손 쪽별 셈, 허구 수, 총계}` — 양방향 구멍."""
pdf, pages = pdf_numbers()
md = md_numbers()
missing, invented = pdf - md, md - pdf
per_page: collections.Counter = collections.Counter()
for token, count in missing.items():
for page in pages[token][:count]:
per_page[page] += 1
return {
"결손_쪽별": {str(k): v for k, v in sorted(per_page.items())},
"결손_총": sum(missing.values()),
"허구_총": sum(invented.values()),
"허구_값": {k: v for k, v in sorted(invented.items())},
}
@pytest.fixture(scope="module")
def now() -> dict[str, object]:
return measure()
def _known() -> dict:
return json.loads(KNOWN.read_text(encoding="utf-8"))
def test_결손이_목록보다_늘지_않음(now: dict) -> None:
"""PDF 에 있는데 md 에 없는 값 — **줄면 초록, 늘면 빨강**(새 구멍)."""
known = _known()
grew = [
f" 쪽 {page}: 목록 {known['결손_쪽별'].get(page, 0)} → 지금 {count}"
for page, count in now["결손_쪽별"].items()
if count > known["결손_쪽별"].get(page, 0)
]
assert not grew, "PDF 에만 있는 값이 늘었음 — md 에서 빠진 자리:\n" + "\n".join(grew)
def test_허구가_목록보다_늘지_않음(now: dict) -> None:
"""md 에 있는데 PDF 에 없는 값 — 지어 넣은 자리."""
known = _known()
grew = {
token: count
for token, count in now["허구_값"].items()
if count > known["허구_값"].get(token, 0)
}
assert not grew, f"PDF 에 없는 값이 md 에 늘었음: {grew}"
def test_목록이_원문과_같은_판(now: dict) -> None:
"""목록의 총계가 지금 셈과 같은가 — 줄었으면 `--prune` 으로 목록을 줄일 것."""
known = _known()
assert now["결손_총"] <= known["결손_총"], (
f"결손 {known['결손_총']}{now['결손_총']}" # 늘었다
)
assert now["허구_총"] <= known["허구_총"], f"허구 {known['허구_총']}{now['허구_총']}"
if __name__ == "__main__" and "--report" in sys.argv:
# 구멍 목록 — md 를 고치는 창(코덱스·랩탑서브·안티그래비티)이 볼 자리
pdf_counts, pdf_pages = pdf_numbers()
md_counts = md_numbers()
missing, invented = pdf_counts - md_counts, md_counts - pdf_counts
holes: collections.Counter = collections.Counter()
for value, count in missing.items():
for page in pdf_pages[value][:count]:
holes[page] += 1
print(f"결손 {sum(missing.values())} · 허구 {sum(invented.values())}")
print("구멍 큰 쪽 스물")
for page, count in holes.most_common(20):
print(f" 쪽 {page:4d} {count}")
print("PDF 에 없는 값 열(붙은 줄 잔재가 많다)")
for value, count in invented.most_common(10):
print(f" {value} x{count}")
if __name__ == "__main__" and "--prune" in sys.argv:
got = measure()
before = _known()
KNOWN.write_text(json.dumps(got, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print(
f"결손 {before['결손_총']}{got['결손_총']} · 허구 {before['허구_총']}{got['허구_총']}"
)