브레인 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
202 lines
8.5 KiB
Python
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['허구_총']}"
|
|
)
|