Files
Aislo/resources/tester/test_base_data_source_match.py
T
eomsangdonandClaude Opus 5 d379acc59b chore(tester): ㉮ 36 을 「할 일 아님」 칸으로 빼고 갈래 나눔 · ㉯ 에 주인 다 적음
**① ㉮ 를 갈래에서 아주 뺌** — 장부에 섞여 있어 바퀴마다 「어긋남」으로 다시 세어지던 것.
  `_할일_아님` 칸으로 옮김(공종축 34 + 기초값 2) · **지우지는 않음**(나중에 사유를 보아야 함)
  갈래 — 거절 자리 11 · 그 밖 18 · 원문 부재 4 · 부록 예시 1
  ⚠ **세는 곳에서만 빼고 비교에는 넣음** — `load_known()` 이 그 칸도 읽게 함.
    안 그러면 옮긴 줄이 다시 「새로 어긋남」으로 빨개짐
  시험으로 못박음 — 칸이 있나 · 34줄인가 · 줄마다 **왜 할 일이 아닌지** 사유가 있나 · 갈래가 넷 중 하나인가

**② ㉯ 남의 몫에 주인 다 적음** — 갈래 없던 기계 줄 65 에도 적어 미정 0
  랩탑 메인 87(md 읽기 22 + 기계 원천 벌 재추출 65) · 랩탑 서브 1(F0333 PDF 대조)

**③ 남은 건수 — 실제 할 일만**
  ① 산림 공종 축 **0** (미확보 79 = 원문 부재 69 + 미판독 10, 열은 다 거절 자리) · 미귀속 표 22 는 ㉰ 에
  ② 건설 공종 축 **1,053** (형태 미판정 891 + 밑수 미판독 162) — 뿌리는 「판정이 산림 표로만
     맞춰진 것」이라 랩탑 메인 몫
  ③ 확인 대기 **100** = ㉯ 92(다 남의 몫) + ㉰ 8 · 할 일 아님으로 뺀 것 36
  ④ 사용자 판단 **8** — F0046·F0068 「1인」 밑수 · F0076 반쪽 「1km」 · 부록 14-2 표 셋 ·
     F0405 증가율표 성격 · 이형철근 범위 6~7
  ⇒ **내가 지금 할 수 있는 것은 0** — 다 남의 차례이거나 사용자 판단

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
2026-09-18 06:19:53 +09:00

572 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""기초데이터 열(품셈 근거) ↔ 원문 맞대기 — 2026-09-18 브레인 일감(랩탑 메인).
기초값 열다섯 중 **품셈 원문에서 옮겨 적은 열**을 원문 표와 칸 단위로 맞댐. 나머지 다섯
(노임·기계·자재·유가·요율)은 `test_base_prices_source_match.py` 몫.
- 원문 = 산림 품셈 md(`test_work_item_master_source_match.MD`) · 건설 품셈 제8장 md(불도저 제원)
- 어긋남은 `fixtures/base_data_source_known.json` 에 — 목록 밖 새 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강
(고친 뒤 `python resources/tester/test_base_data_source_match.py --prune`, 지우기만 함)
- ⚠ **못 재는 칸은 못 잰다고 적음** — `NOT_CHECKED` 에 까닭과 함께. 자료에 새 칸이 생기면 빨강
(재든지 못 잰다고 적든지 하게).
- 표 읽기는 옆 시험의 `md_tables` 한 벌 — 빌더 코드는 안 씀.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import pytest
from test_work_item_master_source_match import MD, ROOT, _without_why, md_tables
KNOWN = Path(__file__).resolve().parent / "fixtures/base_data_source_known.json"
CONST_MACHINE_MD = (
ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제8장_건설기계.md"
)
FILES = {
"coef": "data_cost_input_value/coef_2026.json",
"material_surcharge": "data_material_surcharge/material_surcharge_2026-01-01.json",
"formwork_reuse": "data_formwork/formwork_reuse_2026-01-01.json",
"rebar_complexity": "data_rebar/rebar_complexity_2026-01-01.json",
"timber_structure_class": "data_timber/timber_structure_class_2026-01-01.json",
"masonry_slope": "data_masonry/masonry_slope_2026-01-01.json",
"masonry_back_length": "data_masonry/masonry_back_length_2026-01-01.json",
"stone_kind": "data_masonry/stone_kind_2026-01-01.json",
"masonry_class": "data_masonry/masonry_class_2026-01-01.json",
"machine_productivity": "data_machine_productivity/machine_productivity_2026-01-01.json",
}
#: 값이 아니라 글·방침·출처 칸 — 맞댈 값이 없음.
TEXT_FIELDS = {
"schema_version", "dataset_id", "effective_date", "generated_at", "note", "why", "source",
"sources", "policy", "not_here", "no_folding", "option_key", "option_note", "original_tables",
"not_found", "candidates_pending_user", "observed_practice", "pending_user",
} # fmt: skip
#: 재는 칸.
CHECKED = {
"coef": {"variables"},
"material_surcharge": {"rates_pct"},
"formwork_reuse": {"reuse_by_class", "reuse_ratio_pct", "euroform_type"},
"rebar_complexity": {"classes"},
"timber_structure_class": {"classes"},
"masonry_slope": {"steps_m", "table"},
"masonry_back_length": {"steps_m", "table_cm"},
"stone_kind": {"kinds", "wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2", "back_lengths_cm"},
"masonry_class": {"back_length", "boulder_diameter", "bond"},
"machine_productivity": {"variables"},
}
#: ⚠ 못 재는 칸 — 까닭.
NOT_CHECKED = {
"formwork_reuse": {
"type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)",
"shoring": "강관동바리 대상 메모 — 값 없음",
},
"rebar_complexity": {
"form_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)",
"price_hint_krw_per_ton": "B09 가 자재 단가로 셈한 참고값 — 원문에 없음",
},
"timber_structure_class": {
"basis_unit": "밑수 풀이 글(「목재 채적 ㎥」) — 원문은 「㎥당」만 적음",
"type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)",
},
"stone_kind": {
"backfill_ratio_of_back_length": "교본 7-3 값(1/3·1/2) — 품셈 원문이 아님(교본 원본 대조 몫)",
"fallback": "건설품셈 [참고자료] 돌쌓기 규격별 소요량 — 원문 md 가 이 저장소에 없음",
},
"masonry_class": {
"face_slope": "교본 7-3 기준(지식DB 돌쌓기.md 요약) — 품셈 원문이 아님",
},
}
# ── 원문 읽기 ────────────────────────────────────────────────────────────
def _sq(text: str) -> str:
return re.sub(r"\s+", "", str(text))
def _num(text: str) -> float | None:
"""`0.30` · `1:0.30` → 수. `-`·빈칸 → `None`."""
t = _sq(text).split(":")[-1].replace(",", "")
return float(t) if re.fullmatch(r"\d+(\.\d+)?", t) else None
def _range(text: str) -> tuple[float | None, float | None]:
"""`1.702.00` · `2535` · `6-7` · `75이상` → (아래, 위)."""
t = _sq(text)
if m := re.fullmatch(r"(\d+(?:\.\d+)?)이상", t):
return float(m.group(1)), None
parts = re.split(r"[∼~~-]", t)
if len(parts) == 2 and all(re.fullmatch(r"\d+(\.\d+)?", p) for p in parts):
return float(parts[0]), float(parts[1])
return None, None
def _numbers(text: str) -> set[float]:
return {float(x) for x in re.findall(r"\d+(?:\.\d+)?", text)}
def _section(lines: list[str], heading: str) -> str:
"""`### {heading}` 부터 다음 같은 급 제목 앞까지 글."""
start = next(i for i, x in enumerate(lines) if x.startswith(f"### {heading}"))
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("### ")), len(lines))
return "\n".join(lines[start:end])
def _f(value) -> float | None:
return None if value is None else float(value)
# ── 열마다 맞대기 — {열쇠: {"md": 원문, "data": 자료}} ─────────────────────
def _coef(doc: dict, md: dict) -> dict:
out = {}
rows = md["tables"]["F0004"]["rows"]
for col, var in ((1, "coef_soil_L"), (2, "coef_soil_C")):
records = doc["variables"][var]["records"]
if len(records) != len(rows):
out[f"{var}/줄 수"] = {"md": len(rows), "data": len(records)}
for i, (row, rec) in enumerate(zip(rows, records)):
name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(row[0]))
lo, hi = _range(row[col])
md_val = [name, lo, hi, None if lo is not None else _sq(row[col])]
data_name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(rec["soil_type"]))
data_val = [
data_name,
_f(rec.get("min")),
_f(rec.get("max")),
rec.get("rule") and _sq(rec["rule"]),
]
if md_val != data_val:
out[f"{var}/{i + 1}"] = {"md": md_val, "data": data_val}
# 원문 보관 표 — 원문 표와 글자까지 같아야 함
by_line = {t["line"]: t for t in md["tables"].values()}
for var in ("surcharge_labor", "surcharge_mat"):
for t in doc["variables"][var]["tables"]:
m = by_line.get(t["line"])
if m is None or [m["headers"], m["rows"]] != [t["headers"], t["rows"]]:
out[f"{var}/L{t['line']}"] = {
"md": m and [m["headers"], m["rows"]],
"data": [t["headers"], t["rows"]],
}
tool = doc["variables"]["rate_tool"]
if not {tool["min_percent"], tool["max_percent"]} <= _numbers(_section(md["lines"], "1-2-6.")):
out["rate_tool"] = {
"md": "1-2-6 글에 없음",
"data": [tool["min_percent"], tool["max_percent"]],
}
return out
#: 자재 → 원문 칸 자리 (표, 줄, 칸, 줄에 있어야 할 글). 줄이 밀리면 글 확인에서 걸림.
SURCHARGE_CELLS = {
"시멘트": [("F0008", 0, 1, "시멘트"), ("F0008", 0, 2, "시멘트")],
"잔골재": [("F0008", 1, 1, "잔골재"), ("F0008", 1, 2, "잔골재")],
"채움재": [("F0008", 1, 1, "채움재"), ("F0008", 1, 2, "채움재")],
"굵은골재": [("F0008", 2, 1, "굵은골재"), ("F0008", 2, 2, "굵은골재")],
"모래": [("F0009", 0, 1, "모래")],
"부순돌": [("F0009", 1, 1, "부순돌")],
"자갈": [("F0009", 1, 1, "자갈")],
"점질토": [("F0009", 3, 1, "점질토")],
"이형철근": [("F0011", 1, 1, "이형철근"), ("F0011", 2, 1, "주철근")],
"원형철근": [("F0011", 0, 1, "원형철근")],
"강판": [("F0011", 3, 1, "강판")],
"각재": [("F0012", 0, 2, "각재")],
"판재": [("F0012", 1, 1, "판재")],
"레미콘": [("F0012", 5, 2, "레디믹스트"), ("F0012", 6, 1, "철근구조물")],
"흄관": [("F0012", 16, 1, "원심력철근콘크리트관")],
"떼": [("F0012", 10, 1, "떼")],
"초화류": [("F0012", 10, 1, "초화류")],
"사방용 수목": [("F0012", 9, 1, "사방용수목")],
"원석": [("F0012", 8, 1, "마름돌용")],
}
def _material_surcharge(doc: dict, md: dict) -> dict:
out = {}
for rec in doc["rates_pct"]:
cells = SURCHARGE_CELLS.get(rec["material"])
data_val = [rec.get("rate"), rec.get("alt_rate")]
if cells is None:
out[rec["material"]] = {"md": "원문 자리 모름", "data": data_val}
continue
md_val = []
for table, r, c, label in cells:
row = md["tables"][table]["rows"][r]
md_val.append(row[c] if label in _sq("".join(row)) else f"줄 밀림({label})")
md_val += [None] * (2 - len(md_val))
norm = [_num(v) if v and _num(v) is not None else v for v in md_val]
if norm != [_f(v) for v in data_val]:
out[rec["material"]] = {"md": md_val, "data": data_val}
return out
def _formwork_reuse(doc: dict, md: dict) -> dict:
out = {}
rows = md["tables"]["F0040"]["rows"]
for rec, row in zip(doc["reuse_by_class"], rows):
if [int(_num(row[0][:-1])), rec["class"] in row[1]] != [rec["reuse_count"], True]:
out[f"reuse_by_class/{rec['class']}"] = {
"md": row,
"data": [rec["reuse_count"], rec["class"]],
}
t = md["tables"]["F0336"]
heads = t["rows"][0] # 횟수별 | 재료별(%) | 노무비(%)
series = {}
for row in t["rows"]:
label = _sq(row[0])
series[label] = {heads[1]: row[4].split(), heads[2]: row[5].split()}
ratio = doc["reuse_ratio_pct"]
# ⚠ 원문은 **「합 판」 한 줄에 두 열**(재료별% · 노무비%)을 적고, 「각 재」 줄엔 비율이 없다.
# 앞서 각재 자리에 노무비 열이 들어가 있었다(2026-09-18 바로잡음) — 열을 이름으로 맞댄다.
합판 = {h: [float(x) for x in v] for h, v in series.get("합판", {}).items() if v}
for key, head in (("plywood_material_pct", "재료별(%)"), ("plywood_labor_pct", "노무비(%)")):
data_val = [ratio[key][str(n)] for n in range(1, 7)]
if 합판.get(head) != data_val:
out[f"reuse_ratio_pct/{key}"] = {"md": 합판.get(head), "data": data_val}
# 각재는 **비어 있어야** 한다 — 다른 열을 갖다 쓰면 안 된다
각재 = {h: v for h, v in series.get("각재", {}).items() if v}
if 각재 or "timber" in ratio:
out["reuse_ratio_pct/timber"] = {
"md": 각재 or "비율 칸 비어 있음",
"data": ratio.get("timber", "없음(원문에 없어 비워 둠)"),
}
area = md["tables"]["F0395"]["rows"]
md_area = dict(zip([_sq(x) for x in area[0][:3]], [_num(x) for x in area[1][3:6]]))
for cls in doc["euroform_type"]["classes"]:
if md_area.get(cls["key"]) != cls["daily_area_m2"]:
out[f"euroform_type/{cls['key']}"] = {
"md": md_area.get(cls["key"]),
"data": cls["daily_area_m2"],
}
return out
def _rebar_complexity(doc: dict, md: dict) -> dict:
"""예시마다 12-3 [주]① 글에 있나.
ponytail: 글 포함만 봄 — 원문에 있는 낱말(「교량」)을 엉뚱한 갈래에 보태도 통과. 갈래별 문장 쪼개기는 틀릴 때.
"""
note = _sq(md["notes_12_3"])
out = {}
for cls in doc["classes"]:
missing = [ex for ex in cls["examples"] if _sq(ex) not in note]
if missing:
out[f"classes/{cls['key']}"] = {"md": "12-3 [주]① 에 없는 예시", "data": missing}
return out
def _timber_structure_class(doc: dict, md: dict) -> dict:
rows, found, group = md["tables"]["F0440"]["rows"], {}, ""
for row in rows:
cells = [_sq(c) for c in row]
if cells[0].endswith("구조"):
group, cells = cells[0], cells[1:]
grade = "" if _num(cells[0]) is not None else cells.pop(0)
found[f"{group} {grade}".strip()] = [_num(cells[0]), _num(cells[1])]
out = {}
data = {c["key"]: [c["carpenter"], c["laborer"]] for c in doc["classes"]}
for key in sorted(set(found) | set(data)):
if found.get(key) != data.get(key):
out[f"classes/{key}"] = {"md": found.get(key), "data": data.get(key)}
return out
def _steps(cells: list[str]) -> list[float]:
return [
float(_sq(c).lstrip("∼~~")) for c in cells if re.fullmatch(r"[∼~~]\d+(\.\d+)?", _sq(c))
]
def _masonry_slope(doc: dict, md: dict) -> dict:
t = md["tables"]["F0413"]
found, group = {}, ""
for row in t["rows"]:
cells = [c for c in row]
if _sq(cells[0]) in ("메쌓기", "찰쌓기"):
group, cells = _sq(cells[0]), cells[1:]
found[f"{group}/{_sq(cells[0])}"] = [_num(c) for c in cells[1:] if _sq(c)]
data = {f"{g}/{f}": v for g, faces in doc["table"].items() for f, v in faces.items()}
out = {
k: {"md": found.get(k), "data": data.get(k)}
for k in sorted(set(found) | set(data))
if found.get(k) != data.get(k)
}
if _steps(t["headers"]) != doc["steps_m"]:
out["steps_m"] = {"md": _steps(t["headers"]), "data": doc["steps_m"]}
return out
def _masonry_back_length(doc: dict, md: dict) -> dict:
t = md["tables"]["F0412"]
labels = [re.sub(r"\(.*", "", x) for x in t["rows"][1][0].split()] # 메쌓기(㎝) 찰쌓기(㎝)
found = {label: [] for label in labels}
for cell in t["rows"][1][1:6]:
for label, part in zip(labels, cell.split()):
lo, hi = _range(part)
found[label].append([lo, hi])
out = {}
for label in sorted(set(found) | set(doc["table_cm"])):
data = [[_f(a), _f(b)] for a, b in doc["table_cm"].get(label, [])]
if found.get(label) != data:
out[f"table_cm/{label}"] = {"md": found.get(label), "data": doc["table_cm"].get(label)}
if _steps(t["rows"][0]) != doc["steps_m"]:
out["steps_m"] = {"md": _steps(t["rows"][0]), "data": doc["steps_m"]}
return out
def _stone_grid(table: dict) -> dict[str, dict[str, float | None]]:
"""뭉친 칸 표(`야면석(㎥) 깬잡석(㎥)` | `0.06 0.09`) → {돌: {뒷길이: 값}}."""
lengths = [re.sub(r"[^\d]", "", h) for h in table["headers"][1:] if re.search(r"\d+㎝", h)]
grid = {}
for row in table["rows"]:
names = [re.sub(r"\(.*", "", n) for n in re.findall(r"[가-힣]+\s*[가-힣]*\(㎥\)", row[0])]
names = [_sq(n) for n in names]
for name in names:
grid[name] = {}
for length, cell in zip(lengths, row[1:]):
for name, part in zip(names, cell.split()):
grid[name][length] = _num(part)
return grid
def _stone_kind(doc: dict, md: dict) -> dict:
out = {}
first = {"야면석·호박돌": "야면석", "깬잡석": "깬잡석", "깬돌": "깬돌", "견치돌": "견치돌"}
for field, table in (("wedge_stone_m3_per_m2", "F0408"), ("fill_concrete_m3_per_m2", "F0410")):
grid = _stone_grid(md["tables"][table])
fill_rows = {
"깬돌": "깬돌",
"견치돌": "견치돌",
"깬잡석": "깬잡석",
"야면석·호박돌": "야면석",
}
for kind in doc["kinds"]:
name = first[kind] if field.startswith("wedge") else fill_rows[kind]
data = {k: _f(v) for k, v in doc[field][kind].items()}
if grid.get(name) != data:
out[f"{field}/{kind}"] = {"md": grid.get(name), "data": doc[field][kind]}
if field.startswith("fill") and grid.get("야면석") != grid.get("호박돌"):
out[f"{field}/호박돌"] = {"md": grid.get("호박돌"), "data": "야면석과 한 줄로 묶음"}
lengths = [int(re.sub(r"[^\d]", "", h)) for h in md["tables"]["F0408"]["headers"][1:]]
if lengths != doc["back_lengths_cm"]:
out["back_lengths_cm"] = {"md": lengths, "data": doc["back_lengths_cm"]}
if [first[k] for k in doc["kinds"]] != list(_stone_grid(md["tables"]["F0408"])):
out["kinds"] = {"md": list(_stone_grid(md["tables"]["F0408"])), "data": doc["kinds"]}
return out
def _masonry_class(doc: dict, md: dict) -> dict:
out = {}
back = [
int(_numbers(c).pop()) for c in md["tables"]["F0407"]["rows"][0] if _sq(c).endswith("이하")
]
if back != [c["max_cm"] for c in doc["back_length"]["classes"]]:
out["back_length"] = {
"md": back,
"data": [c["max_cm"] for c in doc["back_length"]["classes"]],
}
boulder = []
for cell in md["tables"]["F0419"]["rows"][0]:
if nums := re.findall(r"\d+", cell):
boulder.append(f"{nums[0]}~{nums[1]}")
if boulder != doc["boulder_diameter"]["classes"]:
out["boulder_diameter"] = {"md": boulder, "data": doc["boulder_diameter"]["classes"]}
for name, code in doc["bond"]["codes"].items():
number = code.removeprefix("FP-").replace("-0", "-").lstrip("0")
if not any(x.strip() == f"### {number}. {name}" for x in md["lines"]):
out[f"bond/{name}"] = {"md": f"### {number}. {name} 제목 없음", "data": code}
return out
def _machine_productivity(doc: dict, md: dict) -> dict:
out, var = {}, doc["variables"]
speed = {} # (궤도, 톤, 단) → [전진, 후진]
for track, table in zip(("무한궤도", "타이어"), md["dozer_tables"]):
gears = [_sq(c) for c in table["rows"][0]]
fwd = [i for i, h in enumerate(table["headers"]) if "전진" in h][0]
rev = [i for i, h in enumerate(table["headers"]) if "후진" in h][0]
for row in table["rows"][1:]:
ton = re.match(r"\d+", row[0]).group()
for g in range(1, rev - fwd + 1):
f, r = (
_num(row[fwd + g - 1]),
_num(row[rev + g - 1]) if rev + g - 1 < len(row) else None,
)
if f is not None and r is not None:
speed[(track, ton, g)] = [f, r]
assert gears[fwd] == "1단" or gears[fwd].startswith("1")
data = {
(r["track"], r["tonnage_ton"], r["gear"]): [
float(r["forward_m_per_min"]),
float(r["reverse_m_per_min"]),
]
for r in var["dozer_speed"]["records"]
}
for key in sorted(set(speed) | set(data)):
if speed.get(key) != data.get(key):
out["dozer_speed/" + "/".join(map(str, key))] = {
"md": speed.get(key),
"data": data.get(key),
}
# 삽날 용량 — 원문 md 가 표를 한 줄로 뭉갬(`0.51.11.5…`) · 톤 차례는 속도 표 둘에서 옴
tons = sorted({int(k[1]) for k in speed} | {int(k[1]) for k in data})
line = next(x for x in md["const_lines"] if "q" in x and "무한궤도" in x and "타 이 어" in x)
blades = {}
for track, part in zip(
("무한궤도", "타이어"), re.split(r"타\s*이\s*어", line.split("무한궤도", 1)[1])
):
for ton, token in zip(tons, re.findall(r"\d\.\d|-", part)):
if token != "-":
blades[(track, str(ton))] = float(token)
data = {
(r["track"], r["tonnage_ton"]): float(r["blade_m3"]) for r in var["dozer_blade"]["records"]
}
for key in sorted(set(blades) | set(data)):
if blades.get(key) != data.get(key):
out["dozer_blade/" + "/".join(key)] = {"md": blades.get(key), "data": data.get(key)}
# 덤프 — 원문이 표가 아니라 식 글(`t3=1.1`). **기호 자리**에 적힌 수와 맞댐
haul = _section(md["lines"], "10-12-1.")
for r in var["dump_haul"]["records"]:
found = _symbol_values(haul, DUMP_HAUL_SYMBOLS[r["key"]])
if float(r["value"]) not in found:
out[f"dump_haul/{r['key']}"] = {"md": sorted(found), "data": r["value"]}
for i, r in enumerate(var["dump_material"]["records"], 1):
text = _section(md["lines"], f"10-12-{i}.")
for field, pattern in DUMP_MATERIAL_SYMBOLS.items():
found = _symbol_values(text, pattern)
if float(r[field]) not in found:
out[f"dump_material/{r['label']}/{field}"] = {"md": sorted(found), "data": r[field]}
return out
#: 덤프 밑값 → 10-12-1 글의 기호 자리.
DUMP_HAUL_SYMBOLS = {
"truck_ton": r"덤프트럭\((\d+)ton\)",
"bucket_m3": r"q0=([\d.]+)",
"loader_cycle_sec": r"㎝s=(\d+)",
"speed_loaded_kmh": r"V1\(적재\)\s*\|\s*([\d.]+)",
"speed_empty_kmh": r"V2\(공차\)\s*\|\s*([\d.]+)",
"unload_min": r"t3=([\d.]+)",
"wait_min": r"t4=([\d.]+)",
"cover_min": r"t5=([\d.]+)",
"loading_cycle_sec": r"㎝=(\d+)\(180°\)",
}
#: 재료별 값 → 그 재료 절(10-12-1·2·3) 글의 기호 자리. `E=` 는 `Es=`·`E0=` 와 가름.
DUMP_MATERIAL_SYMBOLS = {
"unit_weight_ton_per_m3": r"γt=([\d.]+)",
"loose_factor": r"f=1/([\d.]+?)=?[,\s]",
"bucket_factor": r"(?<![A-Za-z0-9])K=([\d.]+)",
"loader_efficiency": r"Es=([\d.]+)",
"truck_efficiency": r"(?<![A-Za-z0-9])E=([\d.]+)",
"loading_efficiency": r"E0=([\d.]+)",
}
def _symbol_values(text: str, pattern: str) -> set[float]:
return {float(m.rstrip(".")) for m in re.findall(pattern, text)}
CHECKS = {
"coef": _coef,
"material_surcharge": _material_surcharge,
"formwork_reuse": _formwork_reuse,
"rebar_complexity": _rebar_complexity,
"timber_structure_class": _timber_structure_class,
"masonry_slope": _masonry_slope,
"masonry_back_length": _masonry_back_length,
"stone_kind": _stone_kind,
"masonry_class": _masonry_class,
"machine_productivity": _machine_productivity,
}
# ── 맞대기 ──────────────────────────────────────────────────────────────
def load_sources() -> dict:
lines = MD.read_text(encoding="utf-8").splitlines()
tables = {t["id"]: t for t in md_tables(lines)}
const_lines = CONST_MACHINE_MD.read_text(encoding="utf-8").splitlines()
dozer = [t for t in md_tables(const_lines) if "전진속도" in "".join(t["headers"])][:2]
notes_12_3 = []
for x in lines[tables["F0335"]["end"] :]:
if x.strip().startswith("[주]"):
notes_12_3.append(x)
break
return {
"md": {
"lines": lines,
"tables": tables,
"const_lines": const_lines,
"dozer_tables": dozer,
"notes_12_3": " ".join(notes_12_3),
},
"docs": {
k: json.loads((ROOT / "resources" / p).read_text(encoding="utf-8"))
for k, p in FILES.items()
},
}
def current_mismatches(src: dict) -> dict[str, dict]:
return {kind: check(src["docs"][kind], src["md"]) for kind, check in CHECKS.items()}
def load_known() -> dict[str, dict]:
"""알려진 어긋남 목록. **「할 일 아님」 칸(`_할일_아님`)도 함께 읽는다.**
⚠ 왜 합쳐 읽나 — ㉮(원문에 없음·거절 자리·판정으로 닫힘)는 **바퀴마다 다시 세어지지 않게**
갈래에서 빼 별도 칸에 둔다(2026-09-18 브레인). 그렇다고 비교에서까지 빠지면 그 줄이 다시
「새로 어긋남」으로 빨개진다 — 그래서 **세는 곳에서만 빼고 비교에는 넣는다.**
"""
data = json.loads(KNOWN.read_text(encoding="utf-8"))
for kind, rows in ((data.get("_할일_아님") or {}).get("줄") or {}).items():
data.setdefault(kind, {}).update(rows)
return data
@pytest.fixture(scope="module")
def src() -> dict:
return load_sources()
@pytest.mark.parametrize("kind", list(FILES))
def test_칸마다_재거나_못잰다고_적힘(src: dict, kind: str) -> None:
"""자료의 칸이 셋 중 하나 — 재는 칸 · 못 재는 칸(까닭) · 글 칸. 새 칸이 생기면 빨강."""
fields = set(src["docs"][kind]) - TEXT_FIELDS
declared = CHECKED[kind] | set(NOT_CHECKED.get(kind, {}))
assert fields == declared, (
f"{kind} — 안 적힌 칸 {sorted(fields - declared)} · 없는 칸 {sorted(declared - fields)}"
)
@pytest.mark.parametrize("kind", list(FILES))
def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None:
"""알려진 목록과 똑같아야 함 — 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강(`why` 는 뺌)."""
now = current_mismatches(src)[kind]
known = {k: _without_why(v) for k, v in load_known()[kind].items()}
msgs = [
f" 새로 어긋남 {k}: {json.dumps(now[k], ensure_ascii=False)}"
for k in sorted(set(now) - set(known))
]
msgs += [
f" 모습 바뀜 {k}: 목록 {json.dumps(known[k], ensure_ascii=False)}\n 지금 {json.dumps(now[k], ensure_ascii=False)}"
for k in sorted(set(now) & set(known))
if now[k] != known[k]
]
msgs += [f" 고쳐짐 {k} — 목록에서 지울 것(--prune)" for k in sorted(set(known) - set(now))]
assert not msgs, f"{kind} {len(msgs)}건 — 원문 ↔ 자료:\n" + "\n".join(msgs)
if __name__ == "__main__" and "--prune" in sys.argv:
# 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘).
current = current_mismatches(load_sources())
data = load_known()
for kind in FILES:
before = len(data[kind])
data[kind] = {
k: v for k, v in data[kind].items() if current[kind].get(k) == _without_why(v)
}
print(f"{kind}: {before}{len(data[kind])}")
KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")