Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 — 산출은 랩탑 것으로 다시 짓고 갈래 수 332 로 맞춤(공종 306 → 303 · 갈래 열쇠 겹침 0)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
This commit is contained in:
2026-09-17 18:25:17 +09:00
co-authored by Claude Opus 5
13 changed files with 329 additions and 6032 deletions
@@ -26,6 +26,7 @@
from __future__ import annotations
import difflib
import hashlib
import json
import re
@@ -146,10 +147,16 @@ def renumber_from_body(
return fixed
#: ⚠ [주] 속 **인용** — 「‘13-3. 기초다짐 및 뒤채움’ 항을 적용한다」(13-4-1 [주]④ · 13-5-2 [주]⑤).
#: 「번호. 이름」 꼴이라 제목으로 읽혀 표 셋(F0405·F0406·F0418)이 13-3 에 붙고 없는 갈래 3줄이 섰음
#: (2026-09-17 서브 훑기 · 브레인). 닫는 따옴표 뒤에 「항·을·를」이 오면 인용.
_CITATION_RE = re.compile(r"[’”]\s*(?:항|을|를)")
def _is_section_title(section: str, by_number: dict[str, dict[str, Any]]) -> bool:
"""진짜 절 제목인가 — 「번호. 이름」, 또는 마침표 없이 번호 뒤 글이 목차 절 이름과 같음."""
"""진짜 절 제목인가 — 「번호. 이름」(인용 아님), 또는 마침표 없이 번호 뒤 글이 목차 절 이름과 같음."""
if re.match(r"^\s*\d+(?:-\d+){0,3}\.\s", section):
return True
return not _CITATION_RE.search(section)
number = section_number(section)
node = by_number.get(number) if number else None
if node is None:
@@ -158,6 +165,35 @@ def _is_section_title(section: str, by_number: dict[str, dict[str, Any]]) -> boo
return "".join(tail.split()) == "".join(str(node.get("name") or "").split())
#: 목차 이름과 본문 제목이 **글자만** 다른 자리(「깍기」·「깎기」 · 「㎝」·「cm」)를 같게 봄 — 이름 닮음 잴 때만.
_NAME_GLYPHS = (("", "cm"), ("", "·"), ("", "·"), ("", "~"), ("", "~"), ("", ""))
#: 이름 닮음 아래턱 — 부록 사례(「4-1. 소나무재선충병방제」 ↔ 목차 4-1 수확베기)는 0.09 이하 ·
#: 진짜 제목의 글자 차이(「비탈면 고르기」 ↔ 「비탈면 면고르기」 0.96 · 「면벽」 ↔ 「면벅」 0.5)는 0.34 이상(2026-09-17 전수).
NAME_LIKENESS_FLOOR = 0.3
def _plain(name: str) -> str:
text = "".join(str(name or "").split())
for glyph, same in _NAME_GLYPHS:
text = text.replace(glyph, same)
return text
def title_names_node(section: str, node: dict[str, Any]) -> bool:
"""「번호. 이름」 제목의 이름이 그 번호 목차 줄 이름을 가리키는가 — 번호만 겹친 **딴 문서 표**를 거름.
⚠ 부록 사례 표 일곱(F0455·F0456·F0472~F0476)이 본문 절 번호와 겹쳐 그 절에 붙어
없는 공종 줄(4-1 수확베기 ← 소나무재선충병방제)이 섰음(2026-09-17 브레인 헛단위). 못 붙인 표로 목록에 남김.
"""
found = re.match(r"^\s*\d+(?:-\d+){0,3}\.\s+(.+)$", section)
if found is None:
return True # 마침표 없는 제목은 `_is_section_title` 이 이미 목차 이름과 같은지 봤음
title, name = _plain(found.group(1)), _plain(node.get("name"))
if title.startswith(name) or name.startswith(title):
return True
return difflib.SequenceMatcher(None, title, name).ratio() >= NAME_LIKENESS_FLOOR
def section_number(section: str) -> str | None:
"""`"9-3-1. 인력"` → `"9-3-1"`. 번호가 없으면 `None`."""
m = re.match(r"^\s*(\d+(?:-\d+){0,3})[.\s]", section + " ")
@@ -208,6 +244,8 @@ def build() -> tuple[
number = section_number(section)
chapter = number.split("-")[0] if number else None
node = by_number.get(number) if number else None
if node is not None and not title_names_node(section, node):
node = None # 번호만 겹친 딴 문서 표(부록 사례) — 못 붙인 표 목록으로
entry = table_entry(
table, section, number, chapter, source_lines, node and node["name"], report
)
@@ -123,7 +123,7 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
# ── 제13장 구조물 ───────────────────────────────────────────────
"F0400": ("13-2-3", "reference", "큰돌 직경별 뒷길이 기준(㎝)"),
"F0406": (
"13-3",
"13-4-1", # 2026-09-17 절 바로잡음 — [주]④ 인용을 13-3 제목으로 읽었던 것(표는 13-4-1 메쌓기 아래)
"reference",
"〈돌쌓기의 개수 및 중량의 표준〉 — [주] 인력 돌쌓기·돌붙임 기준",
),
@@ -19,6 +19,14 @@ from __future__ import annotations
#: 공종코드 → (짧은 딱지, 사유 한 줄). 접두사로 맞춘다(갈래가 붙어도 걸리게).
KNOWN_GAPS: dict[str, tuple[str, str]] = {
# ⚠ 2026-09-17 — 찰붙임 장비 표(F0418)가 [주] 인용을 제목으로 읽혀 13-3 헛단위에 붙어 있다가
# 제자리(13-5-2)를 찾으니 **메붙임(F0417)·찰붙임(F0418) 두 표가 같은 갈래 이름(35·55·75cm 이하)으로
# 한 단가에 더해짐**(약 두 배). 지금 이 코드를 쓰는 곳은 0 — 갈래를 메/찰로 가르기 전 쓰지 말 것.
"FP-13-05-02": (
"메·찰 합쳐짐",
"⚠ 이 단가는 **메붙임·찰붙임 두 표가 더해진 값**입니다 — 둘 중 하나만 써야 하는 갈래라 "
"지금 값은 쓰면 안 됩니다(품셈 13-5-2 「1. 메붙임」·「2. 찰붙임」 · 갈래 가르기 대기).",
),
# ⚠ 규준틀 둘은 **B08 인계가 이미 「재료량은 [주]④ 설계수량에 따른다라 미확보」**를 싣는다.
# 그러니 그 말을 되풀이하지 않고 **B09 쪽에서만 아는 것**만 보탠다 —
# ㉠ 지금 선 값이 **인력 품만**이라는 것 ㉡ 손율은 원문에 이미 있다는 것.
+8 -2
View File
@@ -38,8 +38,11 @@ _RATE_EDITABLE = (
"manager_thresholds.default_estimated_amount_krw",
"manager_thresholds.civil_main_work_estimated_amount_krw",
)
#: ⚠ **지금 상태를 먼저** — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 서브 훑기 · 브레인 「없으면 없다고」).
#: 뒤 문장은 프로젝트 복사(요구 14)가 붙으면 참이 되는 약속이라 지우지 않고 「그 길이 붙으면」으로 둠.
_SCOPE_NOTICE = (
"여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜"
"⚠ **아직 어디에도 안 쓰임** — 고친 값은 마스터에만 쌓임 · 프로젝트로 옮기는 길(프로젝트 복사)이 아직 없음."
" 그 길이 붙으면: 여기서 고친 값은 **새로 만드는 프로젝트부터** 쓰임 — 이미 만든 프로젝트 금액은 안 바뀜"
"(프로젝트는 만들 때 뜬 사본을 봄)"
)
_RATE_NOTICE = "법이 정한 값 — 고치면 원본 갱신 때 구간 이름이 바뀌어 고친 값이 주인을 잃기 쉬움(주인 없음으로 남음)"
@@ -346,7 +349,10 @@ def _sorted(rows: list[dict[str, Any]], column: str, desc: bool) -> list[dict[st
#: 돌쌓기 표준경사는 B06 횡단설계가 같은 값으로 기울기를 그림 — 아홉 중 **그림에도 닿는** 하나(브레인).
_SLOPE_NOTICE = "고치면 횡단 도면 모양도 함께 바뀜 — B06 횡단설계가 같은 값으로 벽 기울기를 그림"
_SLOPE_NOTICE = (
"⚠ **B06 횡단설계가 아직 여기서 고친 값을 안 읽음**(원본 값으로 벽 기울기를 그림) — 지금은 고쳐도 횡단 도면이 안 바뀜."
" 읽게 되면: 고치면 횡단 도면 모양도 함께 바뀜"
)
#: 같은 「1:0.3」 이 두 자리에 있음(코덱스 검증 2) — 값은 같으나 **축과 근거가 다름**.
#: masonry_slope = 품셈 13-4-4 [주]⑪ 표준경사(직고 · 메/찰 · 성토/절토 축 · 원문표)
#: masonry_class.face_slope = 교본 7-3 돌흙막이(구조물 **형식**별 기본값 · 판정이 안 되는 자리의 종전값)
@@ -2182,7 +2182,7 @@
"name_ko": "공종에 못 붙인 표",
"summary": "목차 어디에도 못 붙인 품셈 표.",
"shape": "list",
"rows": 15,
"rows": 22,
"columns": [
{
"key": "pum_table_id",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-17T17:44:39+09:00",
"generated_at": "2026-09-17T18:22:17+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "6532b0095457b00882b26cf006903d3ee8d04082b2bc55ccd28c56043c4003f5",
"size_bytes": 1011687
"sha256": "ffa114016725768042a7b59f4daf6ab7188595e8c649208c659dcd9787c5ca5d",
"size_bytes": 925084
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-17T17:44:40+09:00",
"generated_at": "2026-09-17T18:22:18+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_const",
@@ -12,7 +12,7 @@
"files": [
{
"file": "const_work_item_master_2026-01-01.json",
"sha256": "25714237447cacbdbbc2f43af7fc4a4229d2a9a4a5a447298ebd81946d026e60",
"sha256": "9744ac236de8f08582a3aa36b50125aef76acac18094bd06d23d442183fffe0c",
"size_bytes": 3780649
},
{
@@ -4,7 +4,7 @@
"effective_date": "2026-01-01",
"pum_edition": "2026-01-01",
"toc_edition": "2026-01-01",
"generated_at": "2026-09-17T17:44:40+09:00",
"generated_at": "2026-09-17T18:22:18+09:00",
"dataset_version": {
"dataset_id": "pum_const",
"effective_date": "2026-01-01",
File diff suppressed because it is too large Load Diff
@@ -45,7 +45,10 @@ MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
#: 2026-09-17 랩탑 메인 — 목차 오기 둘을 본문 번호로 바로잡아(브레인 지시) **네 줄만** 바뀌어 새로 뜸
#: (옛 f8b8fba1…). 네 줄 모습은 `test_목차_오기는_본문_번호로_바로잡고_열쇠는_그대로` 가 잼 ·
#: 미판정·밑수 목록 파일은 한 글자도 안 바뀜.
BASELINE_WORK_ITEMS_SHA = "be88f730b325c290012e5a2a79ad593ce5a74ffd82464226857191fefe6647e3"
#: 2026-09-17 랩탑 메인 둘째 — 헛단위 뺌(인용을 제목으로 읽은 13-3 표 셋 · 부록 사례 번호 겹침 일곱)으로 다시 뜸
#: (옛 be88f730…). 바뀐 줄은 13-3·4-1·4-2·2-1·2-2·3-1·3-2·3-3·13-4-1·13-5-2 의 표 귀속뿐 —
#: `test_work_item_master_toc` 가 자리를 잼 · 단가표 대조는 그 파일 머리.
BASELINE_WORK_ITEMS_SHA = "2accafe5e366c32eb6e95123642b4330e28537b2199dee851247e996fcd839da"
#: 열쇠 작업이 새로 얹은 칸. 지문을 잴 때만 떼어낸다.
ADDED_FIELDS = (
@@ -273,7 +276,7 @@ def test_갈래에도_불변_열쇠가_있다(master: dict) -> None:
assert re.fullmatch(r"\d{2}", v["variant_key"]), v
keys.append(f"{it['work_item_key']}#{v['variant_key']}")
pairs.append((v["name"], v["name_clean"]))
assert len(keys) == 335 # 갈래 수
assert len(keys) == 332 # 갈래 수
assert len(set(keys)) == len(keys) # 겹침 0
# 한 공종 안에서 번호는 1부터 빠짐없이
for it in items:
@@ -314,7 +317,7 @@ def test_갈래_문자열이_열쇠라_흔들림을_못박음(master: dict) -> N
import collections
keys = [k for it in master["work_items"] for k in (it.get("variant_keys") or [])]
assert len(keys) == 335
assert len(keys) == 332 # 2026-09-17 헛단위 13-3 갈래 3 뺌(335 → 332)
tilde = collections.Counter(ch for k in keys for ch in k if ch in "∼~")
assert tilde == {"": 26, "": 8}, "물결표 쓰임이 바뀜 — 열쇠가 갈렸는지 볼 것"
squeezed = collections.defaultdict(set)
+38 -2
View File
@@ -4,8 +4,12 @@
뒤 줄(「무근진동기 제외」)이 앞 표(F0363 「철근, 펌프카 0-15m」)를 물고, 진짜 표(F0364)는 orphan 으로 사라졌음.
표 번호로는 안 잡힘(번호는 같았음) — **표 절 제목의 이름이 다른 줄 이름과 똑같은데 그 줄이 아닌 데 붙음**이 그 꼴.
⚠ 이름 글자만 조금 다른 자리(목차 「깍기」 · 본문 「깎기」)는 판정하지 않음 — 번호·이름이 딴 줄을 가리킬 때만.
부록 사례 표 7(F0455·F0456·F0472~F0476)은 번호가 본문 절과 겹쳐 그 절에 붙어 있으나 모두 참조형 —
일위대가로 안 읽혀 금액 무관(브레인 전수 판정). 자리 판정은 데스크탑 서브 몫.
2026-09-17 둘째 — **헛단위**(목록에 없는 공종이 섬 · 서브 훑기 · 브레인):
· [주] 인용 「‘13-3. 기초다짐 및 뒤채움’ 항을 적용한다」를 제목으로 읽어 표 셋(F0405·F0406·F0418)이 13-3 에 붙음
· 부록 사례 표 일곱(F0455·F0456·F0472~F0476)이 본문 절 번호와 겹쳐 4-1·4-2 따위에 붙음
⇒ 인용은 제목이 아님 · 번호만 겹치고 이름이 딴판이면 못 붙인 표 목록으로.
단가표 대조(677 제목 전후) — 헛단위 B-FP-13-03 셋이 빠지고 **B-FP-13-05-02 가 메붙임+찰붙임 합으로 약 두 배**
(쓰는 곳 0 · `KnownGaps` 에 「쓰면 안 됨」 표시 · 갈래 가르기 대기). 나머지 단가 한 원도 안 바뀜.
"""
from __future__ import annotations
@@ -62,3 +66,35 @@ def test_사라진_표는_목록으로_남음(master: dict) -> None:
master["stats"]["tables_attached"] + len(master["orphan_tables"])
== master["stats"]["tables_total"]
)
APPENDIX = {"F0455", "F0456", "F0472", "F0473", "F0474", "F0475", "F0476"}
def test_인용은_제목이_아니라_표가_제_절에_붙음(master: dict) -> None:
by_code = {it["work_item_code"]: it for it in master["work_items"]}
tables = lambda code: [t["pum_table_id"] for t in by_code[code]["tables"]] # noqa: E731
assert tables("FP-13-03") == [] # 헛단위 — 표 없는 묶는 마디로
assert {"F0405", "F0406"} <= set(tables("FP-13-04-01"))
assert "F0418" in tables("FP-13-05-02")
def test_부록_사례_표는_번호가_겹쳐도_안_붙고_목록으로(master: dict) -> None:
attached = {t["pum_table_id"] for it in master["work_items"] for t in it["tables"]}
assert not attached & APPENDIX
assert APPENDIX <= {o["pum_table_id"] for o in master["orphan_tables"]}
by_code = {it["work_item_code"]: it for it in master["work_items"]}
assert by_code["FP-04-01"]["tables"] == [] and by_code["FP-04-02"]["tables"] == []
def test_이름_닮음_잣대() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import _is_section_title, title_names_node
assert not _is_section_title("13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", {})
assert _is_section_title(
"2-9. 어린나무가꾸기, (1) 치수림단계 (제거대상 피복도 ‘소’, 가지치기)", {}
)
assert not title_names_node("4-1. 소나무재선충병방제", {"name": "수확베기"})
assert title_names_node("9-19-2. 비탈면 고르기(암절취)", {"name": "비탈면 면고르기(암절취)"})
assert title_names_node("12-13. 면벽", {"name": "면벅"})
assert title_names_node("9-7-1. T=30㎝ 미만", {"name": "T=30cm 미만"})
+3
View File
@@ -85,6 +85,9 @@ def test_돌쌓기_경사는_도면에도_닿는다고_알림(client: TestClient
"""아홉 중 하나만 수량 말고 **그림**에도 닿음 — 화면이 사전을 가지면 문구가 두 벌(브레인)."""
slope = _get(client, "masonry_slope", size=1)["notice"]
assert any("횡단 도면" in line for line in slope) and len(slope) == 2
# ⚠ 지금 상태를 먼저 — 덮개를 읽는 곳이 Z01 밖에 0(2026-09-17 브레인 「없으면 없다고」)
assert any(line.startswith("⚠ **B06 횡단설계가 아직") for line in slope)
assert slope[0].startswith("⚠ **아직 어디에도 안 쓰임**")
assert all(
not any("횡단 도면" in line for line in _get(client, kind, size=1)["notice"])
for kind in NEW_KINDS
+4 -4
View File
@@ -38,7 +38,7 @@ def _get(client: TestClient, kind: str, **params) -> dict:
def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None:
listed = client.get("/api/master-data/base-prices").json()["kinds"]
forest = next(i for i in listed if i["kind"] == "work_item_forest")
assert forest["group"] == "work_item" and forest["rows"] == 627 # 갈래마다 한 줄
assert forest["group"] == "work_item" and forest["rows"] == 620 # 갈래마다 한 줄 · 헛단위 7줄 뺌(2026-09-17)
# 상자 이름은 이름표 것 — 영문 키(work_item)로 뜨던 자리(2026-09-17 브레인 ①)
assert {i["group"]: i["group_label"] for i in listed} == {
"base_price": "기초단가",
@@ -47,12 +47,12 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non
}
table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지
assert table["total"] == 627 and table["stats"] == {"work_items": 365, "units": 627}
assert table["total"] == 620 and table["stats"] == {"work_items": 360, "units": 620}
assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"])
rows = table["rows"] + _get(client, "work_item_forest", size=500, page=2, axis_role="")["rows"]
assert len({r["@id"] for r in rows}) == 627
assert len({r["@id"] for r in rows}) == 620
assert (
len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 365
len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 360
) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1)
by_code = {r["work_item_code"]: r for r in rows}
assert by_code["FP-09-03-02"]["name"] == "토공 토사깍기 기계"