Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8

This commit is contained in:
2026-10-16 00:01:11 +09:00
9 changed files with 637 additions and 297 deletions
+27 -10
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import sys
import time
import warnings
from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -24,6 +25,7 @@ from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # noqa: E402
warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl")
DOCS = ROOT / "resources/knowledge/original/실무문서"
FIXTURE = Path(__file__).resolve().parent / "fixtures/m02_hopyo_golden.json"
FILES = {
@@ -84,15 +86,24 @@ def read(tag: str) -> dict[str, Any] | None:
return book
def _key(line: dict[str, Any]) -> tuple:
def _keys(line: dict[str, Any]) -> list[tuple]:
"""줄 → 짝 열쇠 후보 — (이름 · 규격 · 단위 · 이음) · 이음 = 단산 번 · 줄 코드 · 없음."""
ref = line.get("참조") or {}
no = ref.get("번") if ref.get("종류") == "단산" else None
code = str(ref.get("코드") or line.get("코드") or "")
return line.get("이름"), line.get("규격") or "", line.get("단위") or "", no, code[:6]
head = (line.get("이름"), line.get("규격") or "", line.get("단위") or "")
code = str(ref.get("코드") or line.get("코드") or "")[:6]
out = [(*head, ("단산", ref["번"]))] if ref.get("종류") == "단산" else []
return out + ([(*head, ("코드", code))] if code else []) + [(*head, None)]
def _lab_key(lab: dict[str, Any]) -> tuple:
return lab["이름"], lab["규격"], lab["단위"], lab.get("단산"), str(lab.get("코드") or "")[:6]
head = (lab["이름"], lab["규격"], lab["단위"])
if lab.get("단산") is not None:
return (*head, ("단산", lab["단산"]))
return (*head, ("코드", str(lab["코드"])[:6]) if lab.get("코드") else None)
def _find(want: dict[tuple, dict], line: dict[str, Any]) -> dict | None:
return next((want[k] for k in _keys(line) if k in want), None)
def line_of(lid: str) -> tuple[dict | None, dict[str, Any]]:
@@ -102,9 +113,7 @@ def line_of(lid: str) -> tuple[dict | None, dict[str, Any]]:
blocks = {int(b["번"]): b for b in book.get("단산") or []}
for h in book["호표"]:
for line in h["줄"]:
if _key(line) == _lab_key(lab) or (
not line.get("참조") and _key(line)[:3] == _lab_key(lab)[:3]
):
if _find({_lab_key(lab): lab}, line):
ref = line.get("참조") or {}
return (blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None), line
raise KeyError(lid)
@@ -123,7 +132,6 @@ def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]:
continue
out["읽음"].append(tag)
want = {_lab_key(lab): lab for lab in labs if lab["파일"] == tag}
loose = {k[:3]: lab for k, lab in want.items()}
blocks = {int(b["번"]): b for b in book.get("단산") or []}
t0 = time.time()
for h in book["호표"]:
@@ -132,7 +140,7 @@ def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]:
ref = line.get("참조") or {}
if ref.get("종류") == "일위" and ref.get("번"):
refs.append(int(ref["번"]))
lab = want.get(_key(line)) or (loose.get(_key(line)[:3]) if not ref else None)
lab = _find(want, line)
if lab is None:
continue
ids.append(lab["id"])
@@ -141,6 +149,15 @@ def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]:
block = blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None
out["줄"][lab["id"]] = {"lab": lab, "셈": verdict.block(block, line, got)}
out["호표"][f"{tag}|{h['번']}"] = (list(dict.fromkeys(ids)), refs)
by_code = {str(b.get("코드") or "")[:6]: b for b in blocks.values() if b.get("코드")}
for lab in want.values(): # 호표 줄이 안 부르는 블록(실험 C6 = 블록마다) — 블록 머리로 줄
block = blocks.get(lab.get("단산") or -1) or by_code.get(str(lab.get("코드"))[:6])
if lab["id"] in out["줄"] or block is None:
continue
basis = {"출처": block.get("출처") or [], "직종": block.get("직종") or []}
line = {**{k: lab[k] for k in ("이름", "규격", "단위")}, "갈래": "단가산출"}
line.update(참조={"종류": "단산", "번": block["번"]}, 근거=basis)
out["줄"][lab["id"]] = {"lab": lab, "셈": verdict.block(block, line, got)}
out["시간"][tag] = round(time.time() - t0, 1)
return out
+4 -4
View File
@@ -60,8 +60,8 @@ def test_출처_글():
def _top(book: dict, no: int) -> list[dict]:
got = hm.match_line(_line(book, no), [], hm.units_of(book))
assert got["상태"] == "후보" and got["들어감"] is False # 자동 확정 없음
got = hm.b_order(_line(book, no)) # 338 — 이름 · 근거 차례(숫자 판정의 덤)
assert got["상태"] == "후보" # 자동 확정 없음
return got["후보"]
@@ -83,7 +83,7 @@ def test_근거_후보(north):
assert "GC000143" in [c["로직"] for c in _top(north, 64)[:2]]
assert _top(north, 49)[0]["로직"] == "GC000085"
soil = next(x for x in north["호표"][2]["줄"] if x["이름"].startswith("잔토처리"))
names = [c["이름"] for c in hm.match_line(soil, [], {})["후보"]]
names = [c["이름"] for c in hm.b_order(soil)["후보"]]
assert not any("철골재" in n for n in names)
assert master_hash() == before
@@ -106,7 +106,7 @@ def test_근거_없는_줄_그대로(north):
assert bare
for line in bare:
plain = {k: v for k, v in line.items() if k != "근거"}
assert hm.match_line(line, [], {}) == hm.match_line(plain, [], {})
assert hm.b_order(line) == hm.b_order(plain)
def test_길_응답_근거_칸(client):
+1 -1
View File
@@ -46,7 +46,7 @@ def test_딱_맞음이_같음을_이김(got):
def test_판정_셋(got):
a = _block("N000", got)
assert a["판정"]["상태"] == "맞음" and "소량감률 50" in a["판정"]["글"]
assert a["후보"][0]["판정"] == "맞음" and a["후보"][0]["근거"]["까닭"] == "조항 같음"
assert a["후보"][0]["판정"] == "맞음" and a["후보"][0]["근거"]["까닭"].startswith("조항 같음")
b = _block("N008", got)
assert b["판정"]["상태"] == "다른 점" and b["차례"][0] == "GC000083"
assert "특별인부 엑셀 0.3542 · 우리 0.3704" in b["판정"]["꼬리표"]
+82
View File
@@ -0,0 +1,82 @@
"""338-1 골든(느림 · 2~4 분) — 정답 표(사람 136 · 새 파일 51)를 실물 엑셀 여덟 파일(읽기만)로 맞춰
설계 7-3 기준 수치 · master_data 원본 그대로 · 셈 무게. 우리 지문 캐시가 없으면 처음 ≈ 40 초 더."""
import time
import pytest
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from resources.tester import helper_m02_hopyo_golden as golden
from resources.tester.test_m02_hopyo_excel import master_hash
LABELED = ("북부청임도", "북부청사방", "동부청영월")
@pytest.fixture(scope="module")
def run():
before = master_hash()
if not ours.ready():
ours.build()
t0 = time.time()
res = golden.evaluate()
return {"결과": res, "시간": time.time() - t0, "원본": before}
@pytest.fixture(scope="module")
def no_seeds():
keep = hm._seeds
hm._seeds = lambda: {}
hm._targets.cache_clear()
try:
yield golden.evaluate(LABELED)
finally:
hm._seeds = keep
hm._targets.cache_clear()
def test_모든_파일_모든_줄(run):
res = run["결과"]
assert not res["못읽음"] and len(res["읽음"]) == 8
assert {lab["id"] for lab in golden.labels()} == set(res["줄"])
def test_사람_126_순위(run):
got = golden.metrics(run["결과"], {"사람"})
assert got["줄"] == 126
assert got["1순위"] >= 116 # 92%
assert got["다섯 안"] >= 122 # 96%
def test_북부청_92_조합_전(run):
rows = [r for r in run["결과"]["줄"].values() if r["lab"]["파일"] in LABELED[:2]]
have = [r for r in rows if r["lab"]["정답"]]
ok = sum((r["셈"].get("지문차례") or [None])[0] in r["lab"]["정답"] for r in have)
assert len(have) == 92 and ok >= 81 # 88%
def test_판정_맞음_다른_점_옵션(run):
got = golden.metrics(run["결과"], {"사람"})
assert got["맞음"] >= 55 and got["맞음 틀림"] <= 2
assert got["다른 점 틀림"] <= 2
assert got["옵션까지"] >= 50
def test_새_파일_맞음_틀림(run):
got = golden.metrics(run["결과"], {"자동", "씨앗", "자동+씨앗"})
assert got["줄"] == 51 and got["맞음 틀림"] <= 3
def test_조합_맞음(run):
got = golden.metrics(run["결과"], {"사람"})
assert got["조합 호표"] == 83 and got["조합 맞음"] >= 60 # 72%
def test_씨앗_끔_1순위(no_seeds):
got = golden.metrics(no_seeds, {"사람"})
assert got["줄"] == 126 and got["1순위"] >= 111 # 88%
def test_원본_그대로_셈_무게(run):
assert master_hash() == run["원본"]
assert run["결과"]["시간"]["북부청임도"] <= 300 # 북부청 임도 맞춤 셈 ≤ 5 분
+98 -58
View File
@@ -2,9 +2,13 @@
맞춤 차례 · 기억 우선 · 길(보기 · 미리 채움 · 확정) · master_data 원본 그대로. 층은 임시 폴더.
"""
import shutil
import pytest
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당을 같이 씀
@@ -86,88 +90,124 @@ def test_규격에서_입력값():
assert values == {} and unread == ["뒷길이"] # 고르기 밖 수 = 빈 값 + 표시
# ── 맞춤 차례 ────────────────────────────────────────────────────────
# ── 맞춤 응답(338 · 설계 3-3) ─────────────────────────────────────────
@pytest.fixture(autouse=True)
def calc(tmp_path, monkeypatch):
"""맞춤셈 캐시 = 임시 폴더(우리 지문 사본) · 뒤 셈은 시험이 바로 돌림(`run_job`)."""
if not ours.ready():
ours.build()
cache = tmp_path / "hopyo_cache"
cache.mkdir()
shutil.copy(ours.path_of(), cache / ours.path_of().name)
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache))
monkeypatch.setattr(fit, "ensure", fit.status)
def _hopyo(client, no, layer="personal"):
res = client.get(f"{URL.format(layer)}/HP-000001/{no}")
assert res.status_code == 200, res.text
return {one["이름"]: one["맞춤"] for one in res.json()["호표"]["줄"]}
def test_이름_같음_후보_호표_퍼센트(client):
def _ready(client, layer="personal"):
assert _post(client, layer).status_code == 200
folder = layers.personal_dir(7, 42) if layer == "personal" else client.system
before = _hopyo(client, 1, layer)["레미콘타설(장비사용)"]
assert before["상태"] == "후보" and "셈 중" in before["꼬리표"] # 셈 전 = 이름 · 근거 후보
fit.run_job(folder, "HP-000001")
return folder
def test_새_상태_체크_제안(client):
before = master_hash()
assert _post(client).status_code == 200
_ready(client)
first = _hopyo(client, 1)
remicon = first["레미콘타설(장비사용)"]
assert remicon["상태"] == "맞음" and remicon["체크"] is True and "들어감" not in remicon
target = remicon["대상"][0]
assert target["로직"] == "GC000198" and target["입력값"]["소량감률"] == 50
assert target["출처"]["소량감률"] == "블록" and target["배"] == 1
assert remicon["품"]["같음"]["콘크리트공"] and remicon["품"]["엑셀"]["인력"]["콘크리트공"]
plywood = first["합판거푸집"]
assert plywood["상태"] == "같음" and plywood["대상"]["로직"] == "GF000160"
assert plywood["입력값"] == {"횟수": "4회사용시"} and plywood["들어감"] is True
remicon = first["레미콘(25-21-120)"]
assert remicon["상태"] == "후보" and remicon["들어감"] is False
assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저
assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"}
mat = first["레미콘(25-21-120)"]
assert mat["상태"] == "후보" and mat["체크"] is False
assert all(c["규격"] == "25-21-120" for c in mat["후보"][:3]) # 규격 같은 자재 먼저
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
assert dig["상태"] == "후보" and len(dig["후보"]) == 5
assert dig["후보"][0]["이름"] == "굴착(인력/토사)" # 330-2 근거(건설 3-2-1 · 특별인부) 1순위
assert any(c["이름"].startswith("구조물터파기") for c in dig["후보"]) # 이름 1순위 자리
assert dig["상태"] == "다른 점" and dig["체크"] is False
assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5
assert "특별인부 엑셀 0.3542 · 우리 0.3704" in dig["꼬리표"]
ref = _hopyo(client, 45)["고화매트재료비"]
assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"]
assert ref["상태"] == "호표" and ref["체크"] is False and ref["글"] == "제46호표 먼저 확인"
assert ref["대상"][0]["호표"] == 46 and ref["대상"][0]["단위"] == "㎡"
pct = _hopyo(client, 46)["잡재료비"]
assert pct["상태"] == "%" and pct["들어감"] is False
assert pct["상태"] == "%" and pct["체크"] is False
whole = client.get(f"{URL.format('personal')}/HP-000001").json()
assert whole["맞춤"][1] == 415 and 0 < whole["맞춤"][0] < 415
assert whole["맞춤"][1] == 415 and 0 < whole["맞춤"][0] < 415 and whole["셈"]["끝"]
assert next(h for h in whole["호표"] if h["번"] == 61)["맞춤"] == [0, 0]
one = client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"]
assert one["셈"]["끝"] and one["만든"] is None and one["맞춤"][0] >= 2
assert master_hash() == before
def test_미리_채움_확정_기억_우선(client):
assert _post(client).status_code == 200
url = f"{URL.format('personal')}/HP-000001/3/guess"
got = client.post(url, json={"차례": 1, "대상": {"로직": "GF000101"}}).json()
assert got["환산"] == {"배": 1, "갈래": "같음"} and got["대상"]["단위"] == "㎥"
assert client.post(url, json={"차례": 1, "대상": {"로직": "XX"}}).status_code == 400
key = _hopyo(client, 3)["구조물터파기(인력100%)"]["열쇠"]
assert key == "구조물터파기(인력100%)|토사|m3"
body = {"줄": {key: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}}}
assert client.post("/api/m02/layers/personal/hopyo-memory", json=body).status_code == 200
dig = _hopyo(client, 3)["구조물터파기(인력100%)"]
assert (dig["상태"], dig["대상"]["로직"], dig["입력값"], dig["들어감"]) == (
"기억",
"GF000101",
{"지역": "서울"},
True,
def _ledger(client, rows, layer="personal"):
res = client.put(f"/api/m02/layers/{layer}/hopyo-ledger", json={"줄": rows})
assert res.status_code == 200, res.text
def test_별칭_체크_규칙(client):
_ready(client)
dig = "구조물터파기(인력100%)|토사|m3"
remicon = "레미콘타설(장비사용)|철근구조물-소량타설|m3"
_ledger(
client,
{
dig: {"갈래": "짝", "짝": [{"로직": "GC000083", "배": 1}]},
remicon: {"갈래": "짝", "짝": [{"로직": "GC000199", "배": 1}]},
"합판거푸집|6회|m2": {"갈래": "짝", "짝": [{"로직": "GC000211", "배": 1}]},
"면목|구멍|개": {"갈래": "우리에 없음"},
},
)
# 이름 같음보다 기억이 앞섬 · 빼기
plywood = _hopyo(client, 1)["합판거푸집"]["열쇠"]
out = {"줄": {plywood: {"빼기": True}}}
assert client.post("/api/m02/layers/personal/hopyo-memory", json=out).status_code == 200
assert _hopyo(client, 1)["합판거푸집"]["상태"] == "빼기"
clear = {"줄": {plywood: None}}
assert client.post("/api/m02/layers/personal/hopyo-memory", json=clear).status_code == 200
assert _hopyo(client, 1)["합판거푸집"]["상태"] == "같음"
got = _hopyo(client, 3)["구조물터파기(인력100%)"]
assert got["상태"] == "별칭" and got["체크"] is True and got["대상"][0]["출처"] == {}
assert got["별칭"]["층"] == "personal" and not got["별칭"]["근거다름"]
first = _hopyo(client, 1)
other = first["레미콘타설(장비사용)"] # 숫자 판정 맞음 = GC000198 → 체크 안 함
assert other["상태"] == "별칭" and other["체크"] is False
assert "숫자 판정 맞음 = GC000198" in other["꼬리표"]
loose = first["합판거푸집"] # 규격 4회 ↔ 장부 6회 — 느슨 열쇠
assert loose["상태"] == "별칭" and loose["별칭"]["규격다름"] and loose["체크"] is False
_ledger(client, {dig: {"갈래": "짝", "짝": [{"로직": "GF000101", "배": 1}]}})
off = _hopyo(client, 3)["구조물터파기(인력100%)"] # 산림 로직 ↔ 엑셀 건설 3-2-1
assert off["별칭"]["근거다름"] and off["체크"] is False and "근거 다름" in off["꼬리표"]
def test_확정_검사와_위_층_기억(client):
bad = [
{"로직": "GC000422", "환산": 1}, # 돈 아닌 로직
{"로직": "GF000101", "환산": 0},
{"로직": "GF000101", "자재": "MT004258", "환산": 1},
{"환산": 1},
]
for one in bad:
res = client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {"a|b|c": one}})
assert res.status_code == 400, one
assert client.post("/api/m02/layers/system/hopyo-memory", json={"줄": {}}).status_code == 403
def test_장부_뺌_위_층(client):
client.who.update(role="SYSTEM_ADMIN")
key = "구조물터파기(인력100%)|토사|m3"
body = {"줄": {key: {"로직": "GF000103", "환산": 1}}}
assert client.post("/api/m02/layers/system/hopyo-memory", json=body).status_code == 200
_ledger(client, {key: {"갈래": "안 씀"}}, "system")
client.who.update(role="USER")
assert _post(client).status_code == 200
assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000103" # 위 층
mine = {"줄": {key: {"로직": "GF000101", "환산": 1}}}
assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200
assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저
_ready(client)
got = _hopyo(client, 3)["구조물터파기(인력100%)"]
assert got["상태"] == "뺌" and got["뺌"] == "안 씀" and got["별칭"]["층"] == "system"
assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7)
def test_미리_셈_목록_낱개(client):
folder = _ready(client)
url = f"{URL.format('personal')}/HP-000001/1/guess"
one = client.post(url, json={"차례": 1, "대상": {"로직": "GC000198"}})
assert one.status_code == 200, one.text
assert one.json()["대상"][0]["환산"] == {"배": 1, "갈래": "같음"}
assert client.post(url, json={"차례": 1, "대상": {"로직": "XX"}}).status_code == 400
body = [{"로직": "GC000198", "입력값": {"타설방법": "장비사용타설", "소량감률": 50}}]
got = hm.guess(folder, "HP-000001", 1, 1, body)
assert got["대상"][0]["출처"]["소량감률"] == "고름"
assert got["품"]["같음"]["콘크리트공"] and got["품"]["엑셀"]["인력"]["콘크리트공"]
plain = hm.guess(folder, "HP-000001", 1, 1, [{"로직": "GC000198", "입력값": {"소량감률": 0}}])
assert not plain["품"]["같음"]["콘크리트공"] # 옵션을 바꾸면 우리 품이 바뀜
def test_로직_추가_고르기_줄은_이_층과_위_층(client):
client.who.update(role="SYSTEM_ADMIN")
assert _post(client, "system").status_code == 200
@@ -197,7 +237,7 @@ def test_뜻_낱말이_안_겹치면_후보_아님():
assert hm.candidates(soil, "로직") == [] # 「처리」 두 글자만 겹침(소금처리 · 바탕처리)
stone = {"이름": "임도표지석(받침,글새김포함)", "규격": "", "단위": "㎥", "갈래": "자재"}
assert hm.candidates(stone, "자재") == [] # 속도표지판 · 도로표지용페인트
got = hm.match_line(soil, [], {})
assert got["상태"] == "없음" and got["후보"] == []
got = hm.b_order(soil)
assert got["상태"] == "없음" and got["top"] == []
fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"}
assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침