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
This commit is contained in:
2026-09-18 14:35:24 +09:00
co-authored by Claude Opus 5
parent 7786316ef5
commit 1660ca48ff
7 changed files with 1050 additions and 112 deletions
+41 -11
View File
@@ -254,24 +254,24 @@ def parse_fuel(
specs = split_values(row[spec_col], len(codes)) if spec_col is not None and spec_col < len(row) else None # fmt: skip
for n, code in enumerate(codes):
raw = values["fuel_rate_l_per_hour"][n]
# ⚠ 연료가 둘인 칸(`73.7+휘발유54.5`)은 한쪽만 적으면 틀림 — 안 싣고 셈만 남김
if _num(raw) is None or "+" in raw:
fuels, power = split_fuels(raw)
if not fuels:
# ⚠ 주연료가 수가 아닌 기종(전기 `㎾` · `-`)은 **연료 목록에 안 실음** — ℓ/hr 자료임.
skipped.append({"machine_code": code, "what": raw.strip()[:20]})
continue
record = {
"machine_code": code,
"machine_name": names.get(code[:4], ""),
# 표시 없는 것은 경유([주]① ㉰) · `휘발유0.7` · `중유487.2`
"fuel_type": (
"gasoline"
if re.search(r"휘발유|가솔린", raw)
else "heavy_oil"
if "중유" in raw
else "diesel"
),
"fuel_rate_l_per_hour": _num(raw),
"fuel_type": fuels[0][0],
"fuel_rate_l_per_hour": fuels[0][1],
}
# ⚠ **겸용 연료는 둘 다 싣는다** — 한쪽만 적으면 그만큼 경비가 빈다
# (`73.7+휘발유54.5` 현장가열표층재생기 · `1.87㎾ 휘발유1.3` 모르타르 믹서).
if len(fuels) > 1:
record["secondary_fuel_type"] = fuels[1][0]
record["secondary_fuel_rate_l_per_hour"] = fuels[1][1]
if power is not None:
record["electric_power_kw"] = power
for field in ("misc_material_percent_of_fuel", "operator_person_per_day"):
value = _num(values[field][n]) if field in values else None
if value is not None:
@@ -282,6 +282,36 @@ def parse_fuel(
return records, bad, source_tables, skipped
#: 겸용 연료 칸을 토막낸다 — `73.7+휘발유54.5` · `1.87㎾ 휘발유1.3` · `중유487.2`.
#: ⚠ 표시가 없으면 경유다([주]① ㉰).
_FUEL_PIECE_RE = re.compile(r"(휘발유|가솔린|중유|경유)?\s*([\d,]+(?:\.\d+)?)\s*(㎾|kW|kw)?")
def split_fuels(raw: str) -> tuple[list[tuple[str, float]], float | None]:
"""연료 칸 → (`[(연료갈래, /hr), …]`, 전력 ㎾).
한 칸에 연료가 둘인 기종이 있다 — 그 둘을 **다 싣는다**. 수가 없으면 빈 목록(전기·`-`).
"""
fuels: list[tuple[str, float]] = []
power: float | None = None
for mark, number, kw in _FUEL_PIECE_RE.findall(str(raw or "")):
value = _num(number)
if value is None:
continue
if kw: # 전력은 연료(ℓ/hr)가 아니라 곁값으로 적어 둔다
power = value
continue
kind = (
"gasoline"
if mark in ("휘발유", "가솔린")
else "heavy_oil"
if mark == "중유"
else "diesel"
)
fuels.append((kind, value))
return fuels, power
def extract(old_doc: dict, data: bytes) -> tuple[dict, dict]:
"""옛 벌 + 8장 md → (새 벌, 알림). 사람 판단 칸은 옛 벌에서 이어받음."""
lines = data.decode("utf-8").splitlines()
@@ -2,7 +2,7 @@
"schema_version": "1.0",
"dataset_id": "mach_base",
"effective_date": "2026-01-01",
"generated_at": "2026-09-18T13:19:25+09:00",
"generated_at": "2026-09-18T14:10:46+09:00",
"sources": [
{
"path": "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제8장_건설기계.md",
@@ -14132,6 +14132,17 @@
"operator_person_per_day": 1,
"specification": "400"
},
{
"machine_code": "3450-0642",
"machine_name": "현장가열 표층재생기",
"fuel_type": "diesel",
"fuel_rate_l_per_hour": 73.7,
"secondary_fuel_type": "gasoline",
"secondary_fuel_rate_l_per_hour": 54.5,
"misc_material_percent_of_fuel": 20,
"operator_person_per_day": 7,
"specification": "479"
},
{
"machine_code": "3530-0015",
"machine_name": "스테이빌라이저(안정기)",
@@ -15085,6 +15096,15 @@
"operator_person_per_day": 1,
"specification": "11.19"
},
{
"machine_code": "7992-0001",
"machine_name": "모르타르 믹서",
"fuel_type": "gasoline",
"fuel_rate_l_per_hour": 1.3,
"electric_power_kw": 1.87,
"misc_material_percent_of_fuel": 2,
"specification": "0.3㎥"
},
{
"machine_code": "7993-0020",
"machine_name": "양수기",
@@ -19124,6 +19144,6 @@
"unparsed_price_rows": 0,
"unparsed_loss_rows": 10,
"unparsed_fuel_rows": 0,
"fuel_not_litre_rows": 30
"fuel_not_litre_rows": 28
}
}
@@ -1977,7 +1977,7 @@
"merged_columns": 178,
"files": 41,
"tables": 101,
"columns": 543,
"columns": 546,
"value_groups": 207,
"unknown": 0,
"value_groups_by_kind": {
@@ -4813,7 +4813,7 @@
"name_ko": "기계 연료소모량·운전경비",
"summary": "기계마다 한 시간에 쓰는 연료량과 잡재료비 비율, 운전원 수.",
"shape": "list",
"rows": 274,
"rows": 276,
"columns": [
{
"key": "machine_code",
@@ -4862,6 +4862,23 @@
"name_ko": "읽은 방법",
"unit": "",
"visible": false
},
{
"key": "secondary_fuel_type",
"name_ko": "겸용 연료 종류",
"unit": "",
"note": "원문이 한 칸에 연료를 둘 적은 기종 — 현장가열 표층재생기 `73.7+휘발유54.5`."
},
{
"key": "secondary_fuel_rate_l_per_hour",
"name_ko": "겸용 시간당 연료량",
"unit": "L"
},
{
"key": "electric_power_kw",
"name_ko": "전력",
"unit": "㎾",
"note": "연료(ℓ/hr)가 아니라 곁값 — 모르타르 믹서 `1.87㎾ 휘발유1.3`."
}
]
},
@@ -19,11 +19,15 @@
"owner": "랩탑 메인",
"owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림"
},
"셈 연료 원문에 없는 자료 줄": {
"source": 0,
"data": 1,
"why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020 — 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감"
},
"셈 연료 코드 수": {
"source": 272,
"data": 274,
"why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적은 줄 하나(추출기는 읽고 이 시험 읽기는 못 따라감) + 원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020(사람이 맞춘 값을 이어받음) — 둘 다 값은 원문 그대로",
"class": "㉯ 남이 고쳐야 풀림"
"source": 275,
"data": 276,
"why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감"
},
"손료 8201-0100 machine_name": {
"source": "3D GNSS 머신 가이던스(굴착기용)",
@@ -32,6 +36,18 @@
"class": "㉯ 남이 고쳐야 풀림",
"owner": "랩탑 메인",
"owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림"
},
"연료 7993-0020": {
"source": null,
"data": {
"machine_code": "7993-0020",
"machine_name": "양수기",
"fuel_type": "gasoline",
"fuel_rate_l_per_hour": 1.3,
"parse_method": "verified_source_row_alignment",
"specification": "1.49㎾"
},
"why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020 — 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감"
}
},
"material": {},
@@ -245,26 +261,6 @@
"owner": "랩탑 메인",
"owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림"
},
"연료 못 읽은 줄 7360-0090": {
"what": "원문 표 토막 수가 안 맞음",
"why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적음 — 이 시험 읽기가 못 따라가는 자리(추출기는 읽음)",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 못 읽은 줄 7993-0020": {
"what": "원문 표 토막 수가 안 맞음",
"why": "8-4 양수기 줄이 `| 7993-0020 | 양수기 | 1.49㎾ | 1.49㎾ | - | - |` — 원문이 규격을 연료 칸에 밀어 적음 · 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음",
"class": "㉯ 남이 고쳐야 풀림",
"owner": "랩탑 메인",
"owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림"
},
"연료 표 3450-0642": {
"codes": [
"3450-0642",
"3450-0642"
],
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 5801-0045": {
"codes": [
"5801-0045",
@@ -273,22 +269,6 @@
"why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 5805-0002": {
"codes": [
"5805-0002",
"5805-0002"
],
"why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 5805-0003": {
"codes": [
"5805-0003",
"5805-0003"
],
"why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 7202-0008": {
"codes": [
"7202-0008",
@@ -305,54 +285,6 @@
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 7360-0090": {
"codes": [
"7360-0090",
"7360-0090"
],
"why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적음 — 이 시험 읽기가 못 따라가는 자리(추출기는 읽음)",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 7991-0050": {
"codes": [
"7991-0050",
"7991-0050"
],
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 7991-0100": {
"codes": [
"7991-0100",
"7991-0100"
],
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 7991-0500": {
"codes": [
"7991-0500",
"7991-0500"
],
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 7992-0001": {
"codes": [
"7992-0001",
"7992-0001"
],
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
"class": "㉯ 남이 고쳐야 풀림"
},
"연료 표 7993-0020": {
"codes": [
"7993-0020",
"7993-0020"
],
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
"class": "㉯ 남이 고쳐야 풀림"
},
"운전사 기종별 판단": {
"what": 121,
"why": "기종→운전사 구분(explicit_mappings)은 사람 판단 — 원문 8-1-3 5호는 기종 이름 글만 있음",
@@ -0,0 +1,723 @@
{
"결손_쪽별": {
"11": 1,
"12": 1,
"16": 1,
"18": 2,
"19": 1,
"20": 2,
"21": 1,
"22": 1,
"23": 1,
"27": 2,
"28": 2,
"29": 1,
"30": 1,
"31": 2,
"32": 1,
"33": 2,
"35": 1,
"36": 2,
"37": 3,
"38": 2,
"39": 1,
"40": 1,
"41": 2,
"42": 1,
"46": 1,
"47": 1,
"49": 1,
"50": 1,
"60": 1,
"62": 2,
"64": 17,
"65": 2,
"66": 6,
"67": 3,
"68": 2,
"71": 2,
"72": 5,
"73": 10,
"74": 4,
"75": 10,
"79": 1,
"81": 1,
"87": 1,
"89": 2,
"91": 2,
"92": 5,
"93": 6,
"95": 11,
"96": 1,
"97": 2,
"98": 1,
"100": 4,
"101": 15,
"102": 3,
"103": 1,
"104": 3,
"105": 1,
"106": 4,
"107": 2,
"109": 4,
"110": 5,
"111": 6,
"112": 1,
"113": 3,
"114": 5,
"115": 2,
"116": 7,
"117": 4,
"118": 1,
"119": 2,
"120": 1,
"121": 9,
"122": 2,
"123": 2,
"124": 4,
"126": 1,
"127": 3,
"130": 1,
"134": 1,
"136": 1,
"138": 1,
"139": 1,
"140": 6,
"141": 5,
"142": 1,
"145": 1,
"149": 1,
"150": 4,
"151": 3,
"155": 1,
"157": 1,
"158": 2,
"159": 1,
"160": 1,
"161": 1,
"162": 1,
"165": 2,
"167": 1,
"168": 2,
"169": 1,
"170": 2,
"172": 1,
"174": 1,
"175": 2,
"177": 1,
"178": 1,
"179": 2,
"180": 1,
"181": 6,
"182": 4,
"183": 2,
"185": 1,
"187": 1,
"188": 2,
"190": 2,
"192": 3,
"193": 2,
"195": 1,
"196": 1,
"197": 1,
"198": 1,
"204": 5,
"205": 1,
"207": 1,
"208": 2,
"211": 1,
"213": 1,
"218": 1,
"220": 1,
"221": 1,
"223": 1,
"224": 1,
"225": 3,
"226": 2,
"227": 1,
"228": 3,
"229": 1,
"230": 1,
"231": 3,
"233": 1,
"235": 1,
"236": 2,
"238": 1,
"239": 1,
"241": 1,
"242": 4,
"243": 2,
"244": 9,
"245": 3,
"246": 4,
"248": 1,
"249": 2,
"253": 2,
"254": 2,
"257": 2,
"262": 1,
"263": 4,
"264": 1,
"265": 4,
"267": 1,
"268": 3,
"270": 1,
"271": 2,
"274": 1,
"275": 26,
"276": 2,
"277": 1,
"282": 2,
"284": 2,
"285": 14,
"286": 10,
"287": 33,
"288": 2,
"289": 6,
"290": 8,
"291": 4,
"292": 4,
"293": 3,
"294": 1,
"295": 6,
"297": 6,
"298": 14,
"299": 1,
"300": 2,
"301": 1,
"302": 1,
"308": 2,
"310": 7,
"311": 1,
"313": 1,
"315": 1,
"316": 1,
"317": 1,
"323": 2,
"324": 1,
"326": 12,
"327": 21,
"328": 5,
"331": 2,
"334": 1,
"335": 2,
"336": 1,
"338": 1,
"342": 3,
"353": 1,
"356": 1,
"357": 1,
"374": 1,
"377": 3,
"381": 1,
"382": 1,
"389": 1,
"405": 1,
"413": 11,
"421": 1,
"424": 4,
"427": 1,
"429": 2,
"431": 1,
"432": 1,
"433": 1,
"435": 1,
"436": 1,
"439": 1,
"441": 1,
"452": 2,
"487": 4,
"508": 1,
"533": 4,
"541": 3,
"542": 1,
"545": 1,
"579": 1,
"601": 1,
"625": 1,
"632": 1,
"663": 1,
"683": 1,
"701": 1,
"704": 2,
"718": 1,
"723": 1,
"733": 3,
"750": 1,
"770": 1,
"779": 1,
"780": 1,
"781": 1,
"794": 1,
"796": 1,
"806": 1,
"820": 1,
"822": 2,
"824": 2,
"832": 1,
"833": 1,
"834": 4,
"839": 1,
"840": 1,
"843": 11,
"852": 1,
"863": 1,
"866": 1,
"868": 1,
"877": 1,
"879": 1,
"880": 1,
"882": 1,
"883": 1,
"884": 1,
"885": 1,
"886": 1,
"887": 1,
"892": 1,
"897": 1,
"898": 1,
"899": 1,
"900": 1,
"905": 1,
"908": 1,
"915": 1,
"920": 1,
"921": 1,
"925": 1,
"931": 1,
"937": 1,
"940": 1,
"941": 1,
"943": 1,
"945": 1,
"947": 1,
"949": 1,
"950": 1,
"953": 1,
"959": 1,
"960": 1,
"961": 1,
"964": 1,
"969": 1,
"973": 1,
"980": 1
},
"결손_총": 749,
"허구_총": 547,
"허구_값": {
"0.0020": 1,
"0.0040": 1,
"0.0080": 1,
"0.00840": 1,
"0.010": 3,
"0.01070": 1,
"0.020": 2,
"0.04": 1,
"0.0460": 1,
"0.050": 5,
"0.0508": 1,
"0.0690": 1,
"0.070": 1,
"0.071": 1,
"0.07530": 1,
"0.099": 1,
"0.100": 2,
"0.110": 1,
"0.125": 1,
"0.1520": 1,
"0.300": 1,
"0.310": 1,
"0.312": 1,
"0.335": 7,
"0.413": 1,
"0.432": 1,
"0.4371": 1,
"0.484": 1,
"0.493": 1,
"0.51": 1,
"0.55": 2,
"0.60": 2,
"0.63": 1,
"0.649": 1,
"0.679": 1,
"0.70": 3,
"0.80": 2,
"0.8112": 1,
"0.814": 1,
"0.82": 1,
"0.855": 1,
"0.909": 1,
"0.92": 1,
"00.91": 1,
"000.90": 2,
"00017800.90": 4,
"00020.23": 1,
"00028337686601": 1,
"0002833768660100040.43": 1,
"0002833768660100060.63": 1,
"0002833768660100070.73": 1,
"0002833768660100080.83": 1,
"0002833768660100101.03": 1,
"0008900.90": 13,
"001020": 1,
"00223.0016420": 1,
"00818.16": 1,
"01.0": 1,
"01.01": 2,
"01.5": 1,
"010.01": 1,
"010.010": 1,
"010.02": 2,
"010.05": 1,
"010274.68": 1,
"0140.010": 1,
"02.2": 1,
"020.02": 1,
"020.020": 6,
"020.10": 1,
"022016270008900.90": 1,
"0241500.2260": 1,
"0327": 1,
"0332000.3160": 1,
"03730.174": 1,
"042": 1,
"043.1": 1,
"04532": 1,
"050.010": 1,
"050.020": 1,
"050.05": 4,
"050.050": 9,
"050.300": 1,
"0561.1203": 1,
"060650.0730": 1,
"070.07": 1,
"070.070": 1,
"070.10": 1,
"07467.465": 1,
"075800.1000": 1,
"08.8": 1,
"080": 1,
"1.01": 1,
"1.02": 1,
"1.10431": 1,
"1.129": 1,
"1.15": 1,
"1.150490": 1,
"1.17": 1,
"1.209": 1,
"1.24": 1,
"1.308": 1,
"1.31": 1,
"1.33": 2,
"1.369": 1,
"1.39": 1,
"1.44": 1,
"1.56": 1,
"1.629": 1,
"1.75": 2,
"1.8110": 1,
"1.85": 1,
"10.0": 1,
"10.100": 1,
"100": 11,
"100.020": 1,
"100.05": 1,
"100.070": 1,
"100.100": 1,
"100.20": 1,
"100.50": 1,
"1000.1360": 1,
"10001": 3,
"10003.533331": 1,
"10004.54443": 1,
"1001096": 1,
"1007": 1,
"105": 1,
"107": 1,
"11.52": 1,
"110": 3,
"115": 1,
"120": 1,
"120032.52": 1,
"120043.5333300": 1,
"12211611110610296": 1,
"125": 1,
"1250.1810": 1,
"1255006742299": 1,
"12550067422990202": 1,
"12550067422990204": 1,
"12550067422990302": 1,
"12550067422990402": 1,
"126.1145": 1,
"128": 1,
"134.940": 1,
"1380": 1,
"1400012500.90": 1,
"15.0": 1,
"150": 2,
"150.075": 1,
"1504": 1,
"1521220520019318718217520176171165160154148": 1,
"156": 1,
"1560035": 1,
"15600506.0037320": 1,
"15600727.5053720": 1,
"156016012.501": 1,
"156018016.001": 1,
"156020025.001": 1,
"160.48": 1,
"1600": 2,
"1610.3181": 1,
"166": 2,
"169": 1,
"17.18": 1,
"1705": 1,
"171": 1,
"1722": 1,
"173": 2,
"176": 1,
"18.5": 1,
"18002000220024002600280030000": 1,
"180020002200240026002800300010219": 1,
"180174169163157152": 1,
"186.58": 1,
"1942000017800.90": 1,
"2.0411": 1,
"2.05": 1,
"2.3112": 1,
"2.40": 1,
"2.45": 1,
"2.86": 1,
"2.96": 1,
"20.950": 1,
"200": 8,
"200.050": 1,
"200.100": 1,
"200.200": 1,
"20145139135130124118114": 1,
"20180175170165160155150": 1,
"203": 1,
"203197190": 1,
"21.101": 1,
"21.87": 1,
"21.98": 1,
"210": 1,
"2100": 1,
"214209": 1,
"2168.4191": 1,
"22222233333": 1,
"224.08": 1,
"2300": 1,
"232": 2,
"240": 1,
"250": 5,
"250.028": 1,
"2500": 1,
"250001": 1,
"25001": 1,
"250300350400450500": 1,
"2510": 2,
"26.330": 1,
"260": 1,
"261": 1,
"271.0": 1,
"271.2": 1,
"2867146822682": 1,
"290": 1,
"299.98": 1,
"3.01": 1,
"3.06": 1,
"3.1014": 1,
"3.21": 1,
"3.2516": 1,
"3.27": 1,
"3.40": 1,
"3.61": 1,
"3.67": 1,
"3.81": 1,
"300": 5,
"300.30": 1,
"300.300": 1,
"300523": 1,
"3012111611110610195904066": 1,
"3015515114614113613212640128124119113109104": 1,
"31.101": 1,
"320": 1,
"320034003600380040004200440010": 1,
"325.531": 1,
"3432000017800.90": 1,
"3473413342328322315309303296290": 1,
"350.6": 1,
"3507": 2,
"360": 1,
"361355": 1,
"377370": 1,
"382.6": 1,
"386.3": 1,
"4.01": 1,
"40.11": 5,
"400": 7,
"400500600700": 1,
"4095908681": 1,
"413": 1,
"419": 2,
"423": 1,
"43.049": 1,
"437.344": 1,
"440": 1,
"4450": 1,
"447": 2,
"4545": 1,
"461": 2,
"47.89": 1,
"471121": 1,
"474236": 1,
"4912000017800.90": 1,
"5.0026120": 1,
"5.01": 1,
"5.02": 1,
"5.36": 1,
"5.7118": 1,
"50.11": 3,
"50.257": 1,
"500.056": 1,
"500.300": 1,
"50001": 1,
"5008.57": 1,
"5008336933026": 1,
"501": 2,
"5010197": 1,
"51.72": 1,
"51100": 1,
"5149.2": 1,
"5150": 1,
"52.52": 1,
"5207.7": 1,
"525": 2,
"528027526826225525024410248242235230223218": 1,
"550": 1,
"5500": 1,
"555": 1,
"5600": 1,
"576.56": 1,
"6.01": 1,
"6.02": 1,
"6.0722": 1,
"6.52": 1,
"6.62": 1,
"600": 6,
"612.715": 1,
"615": 1,
"615751": 1,
"6363636363636363636363": 1,
"637": 1,
"6500": 1,
"657.665": 1,
"670": 1,
"6790": 1,
"7.12": 1,
"7.22": 1,
"7.4": 1,
"7.522": 1,
"7.62": 1,
"7.8": 1,
"7.93": 1,
"7.96": 1,
"70.16435004721": 1,
"700": 3,
"70054.5444": 1,
"7006.565": 1,
"7065": 1,
"710121519283233": 1,
"750.14503753311": 7,
"761": 1,
"76413": 1,
"76813": 1,
"77413": 1,
"77613": 1,
"77713": 1,
"78.36": 1,
"78313": 1,
"78413": 1,
"78713": 1,
"78913": 1,
"79613": 1,
"7975": 1,
"8.43": 1,
"80.15": 1,
"800": 3,
"8001000708": 1,
"8052": 1,
"80713": 1,
"81.0": 1,
"81.02": 1,
"81013": 1,
"81213": 1,
"82113": 1,
"82313": 1,
"82413": 1,
"82513": 1,
"82613": 1,
"82713": 1,
"82813": 1,
"82913": 1,
"83013": 1,
"83113": 1,
"83413": 1,
"83613": 1,
"838.148": 1,
"84013": 1,
"84113": 1,
"84213": 1,
"84313": 1,
"84413": 1,
"84913": 1,
"850.13": 6,
"85013": 1,
"85213": 1,
"8581": 1,
"8591": 1,
"8631": 1,
"8641": 1,
"8651": 1,
"8691": 1,
"87.17": 1,
"874.689": 1,
"8752": 1,
"8802": 1,
"8812": 1,
"8842": 1,
"8852": 1,
"8872": 1,
"8892": 1,
"8912": 1,
"8932": 1,
"8942": 1,
"8962": 1,
"8972": 1,
"9.0": 1,
"9.23": 1,
"9.8": 1,
"90.951": 1,
"900": 2,
"900.80": 1,
"9012": 1,
"9032": 1,
"9042": 1,
"9052": 1,
"9060": 5,
"9072": 1,
"9082": 1,
"9133": 1,
"916": 1,
"9173": 1,
"9183": 1,
"9214": 1,
"9224": 1,
"9244": 1,
"938983": 1,
"951.01": 1,
"967.176": 1,
"982": 1
}
}
@@ -187,18 +187,31 @@ def _codes(cell: str, prefix: str | None) -> tuple[list[str] | None, str | None]
LOSS_COLS = {"economic_life_hours": "내용시간", "annual_standard_hours": "가동시간", "depreciation_ratio": "상각비율", "maintenance_ratio": "정비비율", "annual_management_ratio": "관리비율"} # fmt: skip
LOSS_COEF = ("depreciation_coefficient_1e_minus_7", "maintenance_coefficient_1e_minus_7", "management_coefficient_1e_minus_7", "source_coefficient_1e_minus_7") # fmt: skip
FUEL_FIELDS = ("fuel_type", "fuel_rate_l_per_hour", "misc_material_percent_of_fuel", "operator_person_per_day") # fmt: skip
FUEL_FIELDS = ("fuel_type", "fuel_rate_l_per_hour", "misc_material_percent_of_fuel", "operator_person_per_day", "secondary_fuel_type", "secondary_fuel_rate_l_per_hour", "electric_power_kw") # fmt: skip
#: 연료 토막 — `9.0` 경유([주]① ㉰) · `휘발유0.7` · `중유487.2` · 겸용 `73.7+휘발유54.5` ·
#: 전력과 겸용 `1.87㎾ 휘발유1.3`. ⚠ 꼴이 다르면 ValueError — 지어 읽지 않는다.
_FUEL_TOKEN_RE = re.compile(r"(휘발유|중유|경유)?(\d+(?:\.\d+)?)(㎾)?")
def _fuel(tok: str) -> tuple:
"""주연료 토막 — `9.0` 경유([주]① ㉰: 표시 없는 것은 경유) · `휘발유0.7` · `중유487.2` · `-` 없음."""
"""`(갈래, /hr, 겸용 갈래, 겸용 ℓ/hr, 전력㎾)`. 값이 없으면 자리마다 `None`."""
if tok == "-":
return None, None
m = re.fullmatch(r"(휘발유|중유)?(\d+(?:\.\d+)?)", tok)
if m is None:
return None, None, None, None, None
rest, fuels, power = tok, [], None
for m in _FUEL_TOKEN_RE.finditer(tok):
rest = rest.replace(m.group(0), "", 1)
if m.group(3): # 전력은 ℓ/hr 가 아니다 — 곁값
power = float(m.group(2))
continue
kind = {"휘발유": "gasoline", "중유": "heavy_oil", "경유": "diesel", None: "diesel"}[m.group(1)] # fmt: skip
fuels.append((kind, float(m.group(2))))
if rest.replace("+", "").strip() or not (fuels or power):
raise ValueError(tok)
kind = {"휘발유": "gasoline", "중유": "heavy_oil", None: "diesel"}[m.group(1)]
return kind, float(m.group(2))
first = fuels[0] if fuels else (None, None)
second = fuels[1] if len(fuels) > 1 else (None, None)
return first[0], first[1], second[0], second[1], power
def read_machine(path: Path) -> dict:
@@ -268,11 +281,16 @@ def read_machine(path: Path) -> dict:
continue
for r in t["rows"]:
codes, prefix = _codes(r[0], prefix)
cols = [r[3].split(), r[4].split(), r[5].split()]
# ⚠ 코드가 하나인 줄은 **칸 통째로** 한 값이다 — `1.87㎾ 휘발유1.3` 처럼 값 안에 공백이 온다.
cols = [[c.strip()] if len(codes) == 1 else c.split() for c in (r[3], r[4], r[5])]
try:
if not codes or any(len(c) != len(codes) for c in cols):
raise ValueError(r[0])
parsed = [(*_fuel(f), _num(m), _num(o)) for f, m, o in zip(*cols)]
parsed = [
(ft[0], ft[1], _num(m), _num(o), ft[2], ft[3], ft[4])
for f, m, o in zip(*cols)
for ft in (_fuel(f),)
]
except ValueError:
out["skipped"][f"연료 표 {codes[0] if codes else _t(r[0])[:24]}"] = {
"codes": codes and [codes[0], codes[-1]]
+198
View File
@@ -0,0 +1,198 @@
"""건설 원문 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['허구_총']}"
)