Files
Aislo/resources/tester/test_base_data_source_match.py
T
eomsangdonandClaude Opus 5 75a96a6a18 fix(tester): 프룬이 「할 일 아님」 칸을 되돌려 놓던 구멍 막음 · 기계 md 읽기 셋 넓힘(못 보는 자리 38 → 25)
**① 프룬 구멍** — 읽기(`load_known()`)만 「할 일 아님」 칸을 읽게 고쳤고 **쓰기는 안 고쳤음**.
  `--prune` 이 합쳐 읽은 것을 그대로 되쓰면 2026-09-18 에 가른 칸이 다시 갈래로 섞여
  **없는 할 일 36 이 되살아남**. 세 파일 다 「제자리에서 지우기」로 고침.

**② 기계 읽기 넓힘**
  · `| 9020- | 비항SD | | … |` 꼴 **앞자리만 알리는 머리줄** — 값이 없으니 못 보는 자리가 아님(8줄 뺌)
  · 빈 칸·`동력` 낱말·전력(㎾) 주연료를 읽게 함 — 전력 기종 아홉은 원문에 기름 값이 없어
    양쪽이 비는 것이 맞음(12줄 뺌) · 7360-0090 차선 제거기는 원문 휘발유 5.53 을 이제 읽음
  ·  **머리 없는 이어 표**를 세는 자리를 만듦 — 책장 넘김에 밀린 줄을 `|---|` 없이 따로 적어
    표로 안 잡히던 자리. **9020 그래브 준설선 일곱 줄의 손료계수가 양쪽 다 조용히 빠져 있었음**

**③ 드러난 어긋남을 장부에 사유·주인과 함께 올림**
  · 7993-0020 양수기 — md 가 규격을 주연료 칸에 밀어 적음(코덱스: PDF 대조로 휘발유 1.3ℓ 제자리에)
  · 9020 일곱 줄 — 코덱스: 앞 표에 이어 붙일 것(`제8장_건설기계.md` 3617줄 자리)
  · 겸용 연료 둘(3450-0642 · 7992-0001) — 랩탑 메인: `_build_mach.py` 가 갈라 읽게

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

589 lines
27 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"]
# ⚠ 열 자리로 잡지 않는다 — 원문 md 가 고쳐지며 앞에 빈 칸이 늘기도 한다(2026-09-18).
# 둘째 머리줄에서 **이름으로** 칸 자리를 찾는다.
heads = t["rows"][0]
at = {_sq(h): i for i, h in enumerate(heads) if _sq(h)}
series = {}
for row in t["rows"]:
label = _sq(row[0])
series[label] = {
name: (row[i].split() if i < len(row) else [])
for name, i in at.items()
if name != "횟수별"
}
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:
# 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘).
# ⚠ 「할 일 아님」 칸(`_할일_아님`)은 **제자리에서** 지운다. `load_known()` 이 합쳐 읽은 것을
# 그대로 되쓰면 2026-09-18 에 가른 칸이 다시 갈래로 섞여 「없는 할 일」이 되살아난다.
current = current_mismatches(load_sources())
data = json.loads(KNOWN.read_text(encoding="utf-8"))
parked = (data.get("_할일_아님") or {}).get("줄") or {}
def _keep(kind: str, rows: dict) -> dict:
return {k: v for k, v in rows.items() if current[kind].get(k) == _without_why(v)}
for kind in FILES:
before = len(data.get(kind) or {})
data[kind] = _keep(kind, data.get(kind) or {})
print(f"{kind}: {before}{len(data[kind])}")
if kind in parked:
was = len(parked[kind])
parked[kind] = _keep(kind, parked[kind])
if len(parked[kind]) != was:
print(f" 할 일 아님 {kind}: {was}{len(parked[kind])}")
KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")