Files
Aislo/resources/tester/test_const_pdf_coverage.py
T
eomsangdonandClaude Opus 5 1660ca48ff feat(tester): 건설 PDF ↔ md 전수 커버리지 검사 + 겸용 연료 둘 갈라 읽기
브레인 B08·B14.

- **PDF ↔ md 커버리지**(`test_const_pdf_coverage.py`) — PDF 글자층을 좌표로 줄로 다시 묶어
  값 수를 뽑고 md 전문과 양방향으로 맞댐. 지금 **결손 749**(PDF 에 있는데 md 에 없음) ·
  **허구 547**(md 에만 — 대부분 붙은 줄 잔재 `0008900.90` 꼴)
  · 쪽별 셈을 목록에 두어 **늘면 빨강**(새 구멍) · `--report` 로 구멍 큰 쪽 스물을 뽑음
  · ⚠ 줄 짝은 안 맞춤(PDF 는 칸마다 줄바꿈) · 값처럼 생긴 수만(소수점·세 자리 이상, 연도 뺌) ·
    차례 줄·쪽번호 줄은 뺌
- **겸용 연료 둘**(`_build_mach.py`) — 3450-0642 현장가열 표층재생기 `73.7+휘발유54.5` ·
  7992-0001 모르타르 믹서 `1.87㎾ 휘발유1.3`
  · 둘째 연료는 `secondary_fuel_*`, 전력은 `electric_power_kw`(연료가 아니라 곁값)
  · 연료 줄 274 → 276 · 대조 시험도 같은 꼴을 읽게 넓혀 **기계 못 보는 자리 38 → 27**
  · 이름표에 새 열 셋 + 줄 수 갱신
- 전체 시험 2,442 통과

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

199 lines
8.1 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,}")
#: 쪽번호 줄 — 수 하나만 있고 다른 글자가 없다.
_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['허구_총']}"
)