# PRC01 정답 코드 - AI 기반 조달계획안 검증 Tool > [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다. ## 실행 환경 - Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib - 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다. - 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다. - 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다. ## 미션 1. 국방 조달 계약서, 입찰 공고문 및 방위사업법 규정 데이터 구조 분석 - 난이도: 초급 / 예상 소요시간: 1.5시간 - 코드 설명: 가상 조달계획안·계약서 자료(계약 속성 16개 + 조항 문장)의 스키마와 검증 규칙을 정의하고 오류 주입 시험으로 검출력을 확인한 뒤, 현행(AS-IS) 조달계획안 검증 업무를 12단계로 분해해 병목 구간과 개선(TO-BE) 효과를 비교한다. ```python """가상 조달계획안·계약서 자료(계약 속성 16개 + 조항 문장)의 스키마와 검증 규칙을 정의하고 오류 주입 시험으로 검출력을 확인한 뒤, 현행(AS-IS) 조달계획안 검증 업무를 12단계로 분해해 병목 구간과 개선(TO-BE) 효과를 비교한다.""" import re import numpy as np import pandas as pd import matplotlib.pyplot as plt # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df # 1) 스키마 정의: 계약 식별, 계약 방식·금액·기간, 대금·보증 조건, 필수 조항 유무 SCHEMA = { "contract_id": {"type": str, "pattern": r"^C-\d{4}$", "desc": "계약(계획안) 번호"}, "ctype": {"type": str, "enum": TYPES, "desc": "계약 유형"}, "method": {"type": str, "enum": METHODS, "desc": "계약 방식"}, "amount_m": {"type": (int, float), "min": 1, "max": 100000, "desc": "계약 금액(백만원)"}, "period_days": {"type": int, "min": 1, "max": 1500, "desc": "계약 기간(일)"}, "delivery_days": {"type": int, "min": 1, "max": 1500, "desc": "납품 기한(착수 후 일수)"}, "penalty_permil": {"type": (int, float), "min": 0, "max": 10, "desc": "지체상금률(1/1000)"}, "advance_pct": {"type": (int, float), "min": 0, "max": 100, "desc": "선금 비율(%)"}, "perf_bond_pct": {"type": (int, float), "min": 0, "max": 100, "desc": "이행보증 비율(%)"}, "warranty_months": {"type": int, "min": 0, "max": 120, "desc": "하자보증 기간(개월)"}, "inspection_days": {"type": int, "min": 1, "max": 90, "desc": "검사 기간(일)"}, "subcontract_pct": {"type": (int, float), "min": 0, "max": 100, "desc": "하도급 비율(%)"}, "price_adjust": {"type": bool, "desc": "물가변동 조항 유무"}, "termination": {"type": bool, "desc": "해지 조항 유무"}, "security": {"type": bool, "desc": "보안 조항 유무"}, "ip_clause": {"type": bool, "desc": "지식재산 조항 유무"}, } def check(name, v, r): if v is None or (isinstance(v, float) and np.isnan(v)): return [f"{name}: 필수 값 누락"] if not isinstance(v, r["type"]) or (isinstance(v, bool) and r["type"] is not bool): return [f"{name}: 자료형 오류({type(v).__name__})"] errs = [] if "enum" in r and v not in r["enum"]: errs.append(f"{name}: 허용되지 않은 값 '{v}'") if "min" in r and v < r["min"]: errs.append(f"{name}: 최소값 {r['min']} 미만") if "max" in r and v > r["max"]: errs.append(f"{name}: 최대값 {r['max']} 초과") if "pattern" in r and not re.match(r["pattern"], v): errs.append(f"{name}: 형식 불일치 '{v}'") return errs def validate(rec): errs = [f"{k}: 필드 누락" for k in SCHEMA if k not in rec] for k, r in SCHEMA.items(): if k in rec: errs += check(k, rec[k], r) if not errs and rec["delivery_days"] > rec["period_days"]: # 필드 간 논리 규칙 errs.append("delivery_days: 납품 기한이 계약 기간보다 김") if not errs and rec["method"] == "수의계약" and rec["amount_m"] > 5000: errs.append("method: 수의계약 금액이 비정상적으로 큼(5000백만원 초과)") return errs def to_record(row): return {k: (row[k].item() if hasattr(row[k], "item") else row[k]) for k in SCHEMA} con = make_contracts(100, seed=7) records = [to_record(r) for _, r in con.iterrows()] # 납품 기한이 계약 기간보다 긴 건은 '계약서 내용의 결함'이지 자료 형식 오류가 아니므로 형식 검증에서는 따로 센다 fmt_ok = sum(not [e for e in validate(r) if "납품 기한" not in e] for r in records) logic = sum(any("납품 기한" in e for e in validate(r)) for r in records) print(f"[스키마 검증] 계약 {len(records)}건 중 형식 통과 {fmt_ok}건, 형식 오류 {len(records) - fmt_ok}건 / 필드 {len(SCHEMA)}개 / 조항 문장 {sum(len(c) for c in con['clauses'])}개") print(f"논리 규칙(납품 기한 > 계약 기간) 위반 {logic}건은 '계약 결함'으로 분류해 룰 엔진(미션 3)에서 다룬다") print(f"수의계약 {int((con['method'] == '수의계약').sum())}건, 평균 금액 {con['amount_m'].mean():.0f}백만원, 지체상금률 1.0 미만 {int((con['penalty_permil'] < 1.0).sum())}건, 보안 조항 없음 {int((~con['security']).sum())}건") # 2) 오류 주입 시험: 형식·허용값·범위·자료형·논리 규칙 위반 7종 base = records[1] bad = dict(base, contract_id="C12", method="협의계약", amount_m=-5, period_days="90") bad2 = dict(base, delivery_days=base["period_days"] + 30) bad3 = dict(base, security=None) bad4 = {k: v for k, v in base.items() if k != "penalty_permil"} print("\n[오류 주입 결과]") for e in validate(bad) + validate(bad2) + validate(bad3) + validate(bad4): print(" -", e) # 3) AS-IS 업무 단계 분해: [교육용가상자료] 조달계획안 검증 30건의 단계별 소요일(작업 + 대기) rng = np.random.default_rng(3) STEPS = [("계획안·계약서 초안 접수", 1, 2), ("서식·첨부 서류 확인", 2, 3), ("사업·예산 부합 검토", 3, 2), ("계약 방식 적정성 검토", 2, 1), ("조항별 위험 문구 수작업 검토", 6, 2), ("규정 준수 점검표 작성", 4, 1), ("위험 조항 협의 회의", 1, 8), ("계약관·법무 검토 의뢰", 1, 9), ("수정 요청·업체 협의", 3, 7), ("수정본 재검토", 3, 2), ("결재 상신", 1, 5), ("계약 의뢰 등록", 1, 2)] TOBE = {1: ("서식 자동 점검(미션 2)", 0.2), 4: ("위험 문구 자동 식별(미션 4)", 0.15), 5: ("룰 엔진 점검표 자동 생성(미션 3)", 0.1), 6: ("위험 점수 기반 회의 안건 선별(미션 5)", 0.3), 9: ("수정 대조 자동 비교(미션 6)", 0.4)} rows = [] for i, (name, work, wait) in enumerate(STEPS): w = rng.gamma(4, work / 4, 30); q = rng.gamma(2, wait / 2, 30) f = TOBE.get(i, ("유지(사람 판단)", 1.0)) rows.append({"단계": f"{i + 1:02d}. {name}", "작업(일)": w.mean(), "대기(일)": q.mean(), "AS-IS(일)": w.mean() + q.mean(), "TO-BE 방식": f[0], "TO-BE(일)": w.mean() * f[1] + q.mean() * (f[1] if f[1] < 1 else 1.0)}) proc = pd.DataFrame(rows) share = proc["AS-IS(일)"] / proc["AS-IS(일)"].sum() proc["병목"] = np.where((share >= share.sort_values().iloc[-3]) | (proc["대기(일)"] / proc["AS-IS(일)"] > 0.75), "병목", "") print("\n[AS-IS / TO-BE 업무 단계 비교]") print(proc.round(1).to_string(index=False)) n_bn = int((proc["병목"] == "병목").sum()) asis, tobe = proc["AS-IS(일)"].sum(), proc["TO-BE(일)"].sum() print(f"\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})") print(f"계획안 1건 검증 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 법무·계약관 검토와 결재는 사람이 수행한다.") # 4) 입출력 데이터 명세 요약 print("\n[입출력 명세] 입력: 계약 속성 16개 필드 + 조항 문장 목록 / 출력: 위반 등급, 적중 룰·근거 조항, 위험 문구 하이라이트, 검토 이력") print("[권한별 요구] 계약 담당: 입력·수정 / 계약관·법무: 검토·의견 / 승인권자: 결재(미션 7에서 구현)") plt.figure(figsize=(9, 5)) y = np.arange(len(proc)) plt.barh(y - 0.2, proc["AS-IS(일)"], 0.4, label="AS-IS", color="tab:gray") plt.barh(y + 0.2, proc["TO-BE(일)"], 0.4, label="TO-BE", color="tab:blue") plt.yticks(y, [f"step {i + 1}{' *' if b else ''}" for i, b in enumerate(proc["병목"])]) plt.gca().invert_yaxis(); plt.xlabel("days per case"); plt.legend() plt.title("Procurement plan review: AS-IS vs TO-BE (* = bottleneck, synthetic)") plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [스키마 검증] 계약 100건 중 형식 통과 100건, 형식 오류 0건 / 필드 16개 / 조항 문장 600개 논리 규칙(납품 기한 > 계약 기간) 위반 2건은 '계약 결함'으로 분류해 룰 엔진(미션 3)에서 다룬다 수의계약 14건, 평균 금액 273백만원, 지체상금률 1.0 미만 2건, 보안 조항 없음 4건 [오류 주입 결과] - contract_id: 형식 불일치 'C12' - method: 허용되지 않은 값 '협의계약' - amount_m: 최소값 1 미만 - period_days: 자료형 오류(str) - delivery_days: 납품 기한이 계약 기간보다 김 - security: 필수 값 누락 - penalty_permil: 필드 누락 [AS-IS / TO-BE 업무 단계 비교] 단계 작업(일) 대기(일) AS-IS(일) TO-BE 방식 TO-BE(일) 병목 01. 계획안·계약서 초안 접수 1.0 2.0 3.0 유지(사람 판단) 3.0 02. 서식·첨부 서류 확인 2.6 2.8 5.4 서식 자동 점검(미션 2) 1.1 03. 사업·예산 부합 검토 3.1 2.2 5.3 유지(사람 판단) 5.3 04. 계약 방식 적정성 검토 2.3 1.0 3.3 유지(사람 판단) 3.3 05. 조항별 위험 문구 수작업 검토 7.2 2.2 9.4 위험 문구 자동 식별(미션 4) 1.4 병목 06. 규정 준수 점검표 작성 4.0 1.0 5.1 룰 엔진 점검표 자동 생성(미션 3) 0.5 07. 위험 조항 협의 회의 0.9 7.5 8.4 위험 점수 기반 회의 안건 선별(미션 5) 2.5 병목 08. 계약관·법무 검토 의뢰 0.9 8.9 9.8 유지(사람 판단) 9.8 병목 09. 수정 요청·업체 협의 3.3 6.7 10.0 유지(사람 판단) 10.0 병목 10. 수정본 재검토 3.1 2.4 5.5 수정 대조 자동 비교(미션 6) 2.2 11. 결재 상신 0.9 5.7 6.6 유지(사람 판단) 6.6 병목 12. 계약 의뢰 등록 1.0 2.1 3.1 유지(사람 판단) 3.1 업무 단계 12개(목표 10개 이상: 달성), 병목 5개소(목표 3개소 이상: 달성) 계획안 1건 검증 소요: AS-IS 75.1일 -> TO-BE 49.0일 (35% 단축 예상). 법무·계약관 검토와 결재는 사람이 수행한다. [입출력 명세] 입력: 계약 속성 16개 필드 + 조항 문장 목록 / 출력: 위반 등급, 적중 룰·근거 조항, 위험 문구 하이라이트, 검토 이력 [권한별 요구] 계약 담당: 입력·수정 / 계약관·법무: 검토·의견 / 승인권자: 결재(미션 7에서 구현) ``` ## 미션 2. 계약서 텍스트 파싱, 불공정 조항 키워드 추출 및 조항별 정규화 전처리 모듈 구현 - 난이도: 초급~중급 / 예상 소요시간: 2.0시간 - 코드 설명: 서식이 서로 다른 가상 계약 자료(CSV, 엑셀 내보내기 TSV, JSON, 계약서·공문 텍스트)를 열 이름 별칭 사전과 정규식으로 표준 스키마로 바꾸고, 금액·기간·지체상금률·선금 표기와 조항 문장을 정규화하며 불공정 조항 키워드를 추출하는 전처리 모듈을 구현한다. ```python """서식이 서로 다른 가상 계약 자료(CSV, 엑셀 내보내기 TSV, JSON, 계약서·공문 텍스트)를 열 이름 별칭 사전과 정규식으로 표준 스키마로 바꾸고, 금액·기간·지체상금률·선금 표기와 조항 문장을 정규화하며 불공정 조항 키워드를 추출하는 전처리 모듈을 구현한다.""" import csv import io import json import re import pandas as pd # --------------------------------------------------------------------------- # [교육용가상자료] 서로 다른 형식의 원시 자료 (실제 계약 아님). 금액·기간·비율 표기가 일부러 제각각이다. # --------------------------------------------------------------------------- RAW_CSV = """계약번호,계약방식,계약금액,계약기간,지체상금률,선금 C-0101,일반경쟁,1.5억원,90일,0.15%,30% C-0102,수의계약,"3,200만원",3개월,1.5/1000,20퍼센트 C-0103,제한경쟁,250백만원,1년,1.0‰,0 % C-0104,지명경쟁,"1,200,000,000원",180일,천분의 2.0,10% C-0105,수의계약,4800만원,45일,0.05%,60% C-0106,일반경쟁,95백만원,12주,1.5/1000,0% """ RAW_TSV = "계약 번호\t계약 방식\t총액\t이행기간\t지체율\t선급금\n" \ "C-0107\t일반경쟁\t0.8억원\t60일\t0.1%\t10%\n" \ "C-0108\t수의계약\t290백만원\t2개월\t1.0/1000\t50퍼센트\n" \ "C-0101\t일반경쟁\t150백만원\t3개월\t1.5/1000\t30%\n" \ "C-0109\t제한경쟁\t5,000만원\t1년\t천분의 1.5\t0%\n" \ "C-0110\t지명경쟁\t2.2억원\t240일\t0.2%\t20%\n" RAW_JSON = json.dumps({"status": "OK", "data": [ {"id": "C-0111", "method": "일반경쟁", "terms": {"amount": "75백만원", "period": {"value": 4, "unit": "month"}, "penalty": "1.5/1000", "advance": "10%"}}, {"id": "C-0112", "method": "수의계약", "terms": {"amount": "3.4억원", "period": {"value": 20, "unit": "week"}, "penalty": "0.08%", "advance": "40퍼센트"}}, {"id": "C-0113", "method": "제한경쟁", "terms": {"amount": "880만원", "period": {"value": 30, "unit": "day"}, "penalty": "2.0‰", "advance": "0%"}}]}, ensure_ascii=False) DOCS = [ "계약서(가상)\n제1조(계약금액) 계약금액은 금 150,000,000원으로 한다.\n제2조(계약기간) 계약기간은 계약일부터 3개월로 한다.\n" "제3조(지체상금) 계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 0.15%를 지체상금으로 부과한다.\n" "제4조(선금) 선금은 계약금액의 30퍼센트 이내에서 지급한다.\n제5조(검사) 발주기관은 납품 후 7일 이내에 검사한다.\n" "제6조(해지) 계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "수신: 계약담당관\n제목: 조달계획안 검토 요청\n1. 계약 개요\n 가. 계약금액 : 2.5억원\n 나. 계약기간 : 1년\n 다. 지체상금률 : 천분의 0.8\n 라. 선금 : 계약금액의 60 %\n" "2. 주요 조항\n 가. 납기는 자재 수급 사정에 따라 계약 상대자가 연장할 수 있다.\n 나. 하도급은 신고 없이 제한 없이 허용한다.", "[계약 조건표]\n계약금액: 320백만원\n계약기간: 120일\n지체상금률: 1.5/1000\n선금: 20%\n[조항 요약]\n" "(1) 지체상금은 면제 사유가 없는 한 부과한다.\n(2) 납품 즉시 검사에 합격한 것으로 본다.", "CONTRACT SUMMARY (virtual)\nContract Amount: KRW 95 million\nPeriod: 6 months\nLate Penalty: 1.0 permil\nAdvance Payment: 10 percent\n" "Clause: 지체상금은 전액 면제하며 발주기관은 이의를 제기하지 못한다.", "계약 1건 요약 - 금액 4,800만원 / 기간 45일 / 지체상금 1.0‰ / 선금 0% / 비고: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", ] # ---- 정규화 규칙(서식이 바뀌면 이 사전만 고치면 된다) ---- COL_ALIAS = {"contract_id": ["계약번호", "계약 번호", "id"], "method": ["계약방식", "계약 방식", "method"], "amount": ["계약금액", "총액", "amount"], "period": ["계약기간", "이행기간", "period"], "penalty": ["지체상금률", "지체율", "penalty"], "advance": ["선금", "선급금", "advance"]} UNIT_DAYS = {"일": 1, "day": 1, "주": 7, "week": 7, "개월": 30, "month": 30, "months": 30, "년": 365, "year": 365} KW = {"독소": ["면제", "일방적", "신고 없이", "제한 없이", "전액 부담", "포기", "누설", "지지 않는다"], "납기위험": ["자동 연장", "기한 없이", "연장할 수 있다", "정하지 않는다", "통보한다"]} # 서식별 추출 규칙(비정형 서식 보완 시 여기에 정규식을 한 줄 추가한다) DOC_PAT = {"amount": [r"(?:계약금액|금액|Contract Amount)\s*(?:은|는)?\s*[::]?\s*(?:금\s*|KRW\s*)?([\d,\.]+\s*(?:억원|백만원|만원|million|원))"], "period": [r"(?:계약기간|기간|Period)\s*(?:은|는)?\s*[::]?\s*(?:계약일부터\s*)?(\d+\s*(?:일|개월|년|months?|days?))"], "advance": [r"(?:선금|Advance Payment)[^\n/]*?([\d.]+\s*(?:%|퍼센트|percent))"]} PEN_PAT = [r"천분의\s*([\d.]+)", r"([\d.]+)\s*%", r"([\d.]+)\s*(?:/1000|‰|permil)"] CLAUSE_PAT = [r"^제\d+조\([^)]*\)\s*(.+)$", r"^\s*[가-라]\.\s*(.+다\.)$", r"^\(\d+\)\s*(.+)$", r"^Clause:\s*(.+)$", r"비고:\s*(.+)$"] def norm_amount(s): """금액 표기를 백만원 단위 숫자로 통일: 1.5억원, 250백만원, 3,200만원, 1,200,000,000원, KRW 95 million.""" m = re.match(r"^\s*([\d,\.]+)\s*(억원|백만원|만원|million|원)\s*$", str(s)) if not m: raise ValueError(f"금액 해석 불가: {s}") v = float(m.group(1).replace(",", "")) return {"억원": v * 100, "백만원": v, "만원": v / 100, "million": v, "원": v / 1e6}[m.group(2)] def norm_period(v): if isinstance(v, dict): return int(v["value"] * UNIT_DAYS[v["unit"]]) m = re.match(r"^\s*(\d+)\s*(일|주|개월|년|months?|days?)?\s*$", str(v)) if not m: raise ValueError(f"기간 해석 불가: {v}") u = m.group(2) or "일" return int(m.group(1)) * UNIT_DAYS[u.rstrip("s") if u.endswith("s") else u] def norm_rate(s, per_mille=False): """비율 표기를 통일: 지체상금률은 1/1000(천분율) 단위, 선금은 % 단위.""" s = str(s).strip() for pat, k in [(r"^천분의\s*([\d.]+)$", 1), (r"^([\d.]+)\s*(?:/1000|‰|permil)$", 1), (r"^([\d.]+)\s*(?:%|퍼센트|percent)$", 10 if per_mille else 1)]: m = re.match(pat, s) if m: return round(float(m.group(1)) * k, 3) raise ValueError(f"비율 해석 불가: {s}") def pick(row, key): for k in COL_ALIAS[key]: if k in row: return row[k] raise KeyError(f"필수 열 없음: {key}") def to_std(row, src): return {"contract_id": str(pick(row, "contract_id")).strip(), "method": str(pick(row, "method")).strip(), "amount_m": round(norm_amount(pick(row, "amount")), 2), "period_days": norm_period(pick(row, "period")), "penalty_permil": norm_rate(pick(row, "penalty"), True), "advance_pct": norm_rate(pick(row, "advance")), "source": src} def load(text, fmt): """형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다.""" if fmt == "json": rows = [{"id": r["id"], "method": r["method"], "amount": r["terms"]["amount"], "period": r["terms"]["period"], "penalty": r["terms"]["penalty"], "advance": r["terms"]["advance"]} for r in json.loads(text)["data"]] else: rows = list(csv.DictReader(io.StringIO(text), delimiter="\t" if fmt == "tsv" else ",")) ok, fail = [], [] for r in rows: try: ok.append(to_std(r, fmt)) except (KeyError, ValueError) as e: fail.append((r, str(e))) return ok, fail def norm_clause(s): """조항 문장 정규화: 띄어쓰기·호칭 통일, 번호 제거.""" s = re.sub(r"^\s*(?:제\d+조\([^)]*\)|\(\d+\)|[가-라]\.)\s*", "", s) s = s.replace("계약 상대자", "계약상대자").replace("발주 기관", "발주기관").replace("계약담당관", "발주기관") return re.sub(r"\s+", " ", s).strip() def parse_doc(text): """계약서·공문 텍스트 1건에서 금액·기간·지체상금률·선금과 조항 문장을 추출한다.""" out = {} for key, pats in DOC_PAT.items(): for p in pats: m = re.search(p, text) if m: out[key] = m.group(1); break else: raise ValueError(f"필수 항목({key}) 추출 실패") pen = None for line in re.split(r"\n| / ", text): if re.search(r"지체상금|Late Penalty", line): for p in PEN_PAT: m = re.search(p, line) if m: unit = "%" if p == PEN_PAT[1] else "" pen = (m.group(1) + unit) if unit else (("천분의 " + m.group(1)) if p == PEN_PAT[0] else m.group(0)); break if pen: break if not pen: raise ValueError("필수 항목(지체상금률) 추출 실패") clauses = [] for line in re.split(r"\n| / ", text): for p in CLAUSE_PAT: m = re.search(p, line.strip()) if m: c = norm_clause(m.group(1) if p != CLAUSE_PAT[2] else line) if c.endswith(("다.", "한다")) or "다." in c: clauses.append(c) break return {"amount_m": round(norm_amount(out["amount"].replace("KRW", "").strip()), 2), "period_days": norm_period(out["period"]), "penalty_permil": norm_rate(pen, True), "advance_pct": norm_rate(out["advance"].replace("percent", "퍼센트")), "clauses": clauses} def extract_keywords(clause): return [f"{c}:{k}" for c, ks in KW.items() for k in ks if k in clause] # 1) 형식별 파싱 results, total, success = [], 0, 0 for fmt, text in [("csv", RAW_CSV), ("tsv", RAW_TSV), ("json", RAW_JSON)]: ok, fail = load(text, fmt) results += ok; total += len(ok) + len(fail); success += len(ok) print(f"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공" + (f", 실패 {[e for _, e in fail]}" if fail else "")) docs, d_fail = [], 0 for i, t in enumerate(DOCS, 1): try: docs.append(parse_doc(t)) except ValueError as e: d_fail += 1; print(f"문서 {i} 파싱 실패:", e) total += len(DOCS); success += len(docs) print(f"[계약서·공문 텍스트] {len(DOCS)}건 중 {len(docs)}건 성공") rate = success / total print(f"\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}") # 2) 정제 결과(표기 통일, 중복 계약 정리) std = pd.DataFrame(results) before = len(std) dup = std[std.duplicated("contract_id", keep=False)].sort_values("contract_id") same = dup.drop(columns="source").drop_duplicates().shape[0] == dup["contract_id"].nunique() if len(dup) else True std = std.drop_duplicates("contract_id").sort_values("contract_id") print(f"\n[정제된 계약 자료] {before}행 -> 중복 계약 정리 후 {len(std)}행 (중복 {before - len(std)}건: 표기는 달랐지만 정규화 후 값이 {'동일' if same else '불일치'})") print(std.to_string(index=False)) # 3) 계약서 텍스트 파싱 결과와 불공정 조항 키워드 print("\n[계약서·공문 파싱 결과]") for i, d in enumerate(docs, 1): print(f" 문서 {i}: 금액 {d['amount_m']:g}백만원, 기간 {d['period_days']}일, 지체상금률 {d['penalty_permil']:g}/1000, 선금 {d['advance_pct']:g}%, 조항 {len(d['clauses'])}개") print("\n[조항별 정규화와 불공정·납기 위험 키워드]") n_kw = 0 for i, d in enumerate(docs, 1): for c in d["clauses"]: kws = extract_keywords(c) if kws: n_kw += 1; print(f" 문서 {i}: {c[:38]}... -> {', '.join(kws)}") print(f"키워드가 걸린 조항 {n_kw}개 (키워드는 후보 표시이며 실제 불공정 여부는 계약관이 판단한다. 예: 문서 3의 '면제'는 '면제 사유가 없는 한 부과'라는 정상 문장의 오탐 후보 -> 미션 4에서 문맥 모델로 보완)") # 4) 서식 변경 대응 시험 ①: 열 이름이 바뀐 새 양식 -> 별칭 사전 추가 new_form = "계약 식별번호,방식,대금 총액,수행기간,지체상금 비율,선급\nC-0121,일반경쟁,120백만원,90일,1.5/1000,10%\n" ok, fail = load(new_form, "csv") print(f"\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}") for k, alias in {"contract_id": "계약 식별번호", "method": "방식", "amount": "대금 총액", "period": "수행기간", "penalty": "지체상금 비율", "advance": "선급"}.items(): COL_ALIAS[k].append(alias) ok, fail = load(new_form, "csv") print(f"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)") # 5) 서식 변경 대응 시험 ②: 비정형 공문의 표현이 달라진 경우 -> 정규식 한 줄 추가 new_doc = "수신: 계약담당관\n1. 대금 총액 : 1.2억원\n2. 계약기간 : 6개월\n3. 지체상금률 : 1.0/1000\n4. 선금 : 계약금액의 10%\n5. 조항: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다." try: parse_doc(new_doc); r1 = "성공" except ValueError as e: r1 = f"실패({e})" DOC_PAT["amount"].append(r"(?:대금 총액)\s*[::]?\s*([\d,\.]+\s*(?:억원|백만원|만원|원))") d = parse_doc(new_doc) print(f"새 공문 표현('대금 총액') 1차 시도: {r1} -> 규칙 1줄 추가 후 금액 {d['amount_m']:g}백만원 추출 성공") ``` 실행 결과 예시 ```text [CSV ] 6행 중 6행 성공 [TSV ] 5행 중 5행 성공 [JSON] 3행 중 3행 성공 [계약서·공문 텍스트] 5건 중 5건 성공 전체 파싱 성공률 100% (19/19) - 목표 100%: 달성 [정제된 계약 자료] 14행 -> 중복 계약 정리 후 13행 (중복 1건: 표기는 달랐지만 정규화 후 값이 동일) contract_id method amount_m period_days penalty_permil advance_pct source C-0101 일반경쟁 150.0 90 1.5 30.0 csv C-0102 수의계약 32.0 90 1.5 20.0 csv C-0103 제한경쟁 250.0 365 1.0 0.0 csv C-0104 지명경쟁 1200.0 180 2.0 10.0 csv C-0105 수의계약 48.0 45 0.5 60.0 csv C-0106 일반경쟁 95.0 84 1.5 0.0 csv C-0107 일반경쟁 80.0 60 1.0 10.0 tsv C-0108 수의계약 290.0 60 1.0 50.0 tsv C-0109 제한경쟁 50.0 365 1.5 0.0 tsv C-0110 지명경쟁 220.0 240 2.0 20.0 tsv C-0111 일반경쟁 75.0 120 1.5 10.0 json C-0112 수의계약 340.0 140 0.8 40.0 json C-0113 제한경쟁 8.8 30 2.0 0.0 json [계약서·공문 파싱 결과] 문서 1: 금액 150백만원, 기간 90일, 지체상금률 1.5/1000, 선금 30%, 조항 6개 문서 2: 금액 250백만원, 기간 365일, 지체상금률 0.8/1000, 선금 60%, 조항 2개 문서 3: 금액 320백만원, 기간 120일, 지체상금률 1.5/1000, 선금 20%, 조항 2개 문서 4: 금액 95백만원, 기간 180일, 지체상금률 1/1000, 선금 10%, 조항 1개 문서 5: 금액 48백만원, 기간 45일, 지체상금률 1/1000, 선금 0%, 조항 1개 [조항별 정규화와 불공정·납기 위험 키워드] 문서 1: 계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 ... -> 독소:일방적 문서 2: 납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.... -> 납기위험:연장할 수 있다 문서 2: 하도급은 신고 없이 제한 없이 허용한다.... -> 독소:신고 없이, 독소:제한 없이 문서 3: 지체상금은 면제 사유가 없는 한 부과한다.... -> 독소:면제 문서 4: 지체상금은 전액 면제하며 발주기관은 이의를 제기하지 못한다.... -> 독소:면제 문서 5: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.... -> 납기위험:자동 연장, 납기위험:기한 없이 키워드가 걸린 조항 6개 (키워드는 후보 표시이며 실제 불공정 여부는 계약관이 판단한다. 예: 문서 3의 '면제'는 '면제 사유가 없는 한 부과'라는 정상 문장의 오탐 후보 -> 미션 4에서 문맥 모델로 보완) 새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: contract_id' ... (이하 생략) ``` ## 미션 3. 방위사업 계약 필수 규정 준수 여부 자동 판별 1차 Rule 엔진 구현 - 난이도: 중급 / 예상 소요시간: 2.5시간 - 코드 설명: 계약 필수 규정 준수 여부를 판별하는 16종 룰(YAML 설정)을 코딩해 계약별 1차 판별 등급을 산출하고, 경계값 단위 시험 32건, 룰 처리 지연시간, 룰별 근거 조항 연결, 등급별 실제 위반율·재현율, 규정 변경 시 설정만 바꾸는 확장성을 확인한다. ```python """계약 필수 규정 준수 여부를 판별하는 16종 룰(YAML 설정)을 코딩해 계약별 1차 판별 등급을 산출하고, 경계값 단위 시험 32건, 룰 처리 지연시간, 룰별 근거 조항 연결, 등급별 실제 위반율·재현율, 규정 변경 시 설정만 바꾸는 확장성을 확인한다.""" import time import numpy as np import pandas as pd import matplotlib.pyplot as plt # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df # ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ---- RULES_YAML = """ - id: R01 name: 지체상금률 1.0/1000 미만 field: penalty_permil op: "<" value: 1.0 weight: 3 - id: R02 name: 선금 비율 40% 초과 field: advance_pct op: ">" value: 40 weight: 2 - id: R03 name: 선금 40% 초과인데 이행보증 10% 미만 field: adv_no_bond op: "==" value: true weight: 3 - id: R04 name: 300백만원 이상 수의계약 field: sole_amount op: ">=" value: 300 weight: 3 - id: R05 name: 500백만원 이상 이행보증 10% 미만 field: bond_gap op: "==" value: true weight: 2 - id: R06 name: 물품 하자보증 12개월 미만 field: warranty_gap op: "==" value: true weight: 2 - id: R07 name: 해지 조항 없음 field: termination op: "==" value: false weight: 2 - id: R08 name: 보안 조항 없음 field: security op: "==" value: false weight: 3 - id: R09 name: 지식재산 조항 없음(물품·용역) field: ip_missing op: "==" value: true weight: 1 - id: R10 name: 하도급 비율 50% 초과 field: subcontract_pct op: ">" value: 50 weight: 3 - id: R11 name: 검사 기간 14일 초과 field: inspection_days op: ">" value: 14 weight: 1 - id: R12 name: 납품 기한이 계약 기간 초과 field: logic_err op: "==" value: true weight: 3 - id: R13 name: 180일 이상 계약에 물가변동 조항 없음 field: no_padj_long op: "==" value: true weight: 1 - id: R14 name: 1000백만원 이상 비경쟁 계약 field: noncomp_big op: "==" value: true weight: 2 - id: R15 name: 계약 기간 365일 초과 field: period_days op: ">" value: 365 weight: 1 - id: R16 name: 불공정·납기 위험 키워드 포함 field: kw_hits op: ">=" value: 1 weight: 3 """ GRADE_CUT = {"고위험": 6, "주의": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다) # [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님) KEYWORDS = {"독소": ["면제하며", "일방적으로", "포기", "지지 않는다", "즉시 소멸", "제한 없이", "신고 없이", "전액 부담", "별도 협의 없이", "검사 전에", "검사에 합격한 것으로", "사용할 수 없다", "증빙은 요구하지 않는다", "지정하는 기관"], "납기위험": ["자동 연장", "기한 없이", "정하지 않는다", "노력하며", "연장할 수 있다", "통보한다", "책임을 묻지 않는다", "의무가 없다"]} def parse_rules(text): """간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.""" rules, cur = [], None for line in text.strip().splitlines(): if not line.strip(): continue s = line.strip() if s.startswith("- "): cur = {}; rules.append(cur); s = s[2:] k, v = [x.strip() for x in s.split(":", 1)] v = v.strip('"') if v in ("true", "false"): v = v == "true" else: try: v = float(v) if "." in v else int(v) except ValueError: pass cur[k] = v return rules OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b} def kw_count(clauses): """조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.""" return int(sum(any(k in c for k in KEYWORDS["독소"] + KEYWORDS["납기위험"]) for c in clauses)) def derive(df): """룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.""" d = df.copy() d["adv_no_bond"] = (d["advance_pct"] > 40) & (d["perf_bond_pct"] < 10) d["sole_amount"] = np.where(d["method"] == "수의계약", d["amount_m"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정) d["bond_gap"] = (d["amount_m"] >= 500) & (d["perf_bond_pct"] < 10) d["warranty_gap"] = (d["ctype"] == "물품") & (d["warranty_months"] < 12) d["ip_missing"] = (~d["ip_clause"]) & (d["ctype"] != "공사") d["logic_err"] = d["delivery_days"] > d["period_days"] d["no_padj_long"] = (d["period_days"] >= 180) & (~d["price_adjust"]) d["noncomp_big"] = d["method"].isin(["지명경쟁", "수의계약"]) & (d["amount_m"] >= 1000) d["kw_hits"] = [kw_count(c) for c in d["clauses"]] if "clauses" in d else 0 return d def apply_rules(rec, rules): """레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.""" hits = [] for r in rules: v = rec.get(r["field"]) if v is None or (isinstance(v, float) and np.isnan(v)): continue if OPS[r["op"]](v, r["value"]): hits.append(r) score = sum(r["weight"] for r in hits) grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심") return score, grade, [r["id"] for r in hits] # [교육용가상자료] 룰과 가상 지침 조항의 대응표(실제 법령·규정 조문이 아니라 연결 방식을 보여 주는 예시) CLAUSE = {"R01": "가상지침 5조1항(지체상금)", "R02": "가상지침 6조1항(선금 한도)", "R03": "가상지침 6조2항(선금과 보증)", "R04": "가상지침 3조1항(수의계약 한도)", "R05": "가상지침 6조3항(이행보증)", "R06": "가상지침 7조1항(하자보증)", "R07": "가상지침 8조1항(계약 해지)", "R08": "가상지침 9조1항(보안 조항)", "R09": "가상지침 9조2항(지식재산)", "R10": "가상지침 4조1항(하도급 제한)", "R11": "가상지침 5조2항(검사 기간)", "R12": "가상지침 5조3항(납품 기한)", "R13": "가상지침 6조4항(물가변동)", "R14": "가상지침 3조2항(경쟁 계약 원칙)", "R15": "가상지침 3조3항(장기 계약)", "R16": "가상지침 8조2항(불공정 조항)"} rules = parse_rules(RULES_YAML) con = make_contracts(200, seed=7) df = derive(con) recs = df.to_dict("records") # 1) 전체 스크리닝과 처리 지연시간(레코드 1건당) apply_rules(recs[0], rules) # 첫 호출 지연(예열)을 측정에서 제외 lat, res = [], [] for r in recs: t0 = time.perf_counter() out = apply_rules(r, rules) lat.append((time.perf_counter() - t0) * 1000) res.append(out) df["score"], df["grade"], df["hits"] = [x[0] for x in res], [x[1] for x in res], [x[2] for x in res] print(f"[룰 설정] 검증 룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}), 모든 룰에 가상 지침 조항 연결 {all(r['id'] in CLAUSE for r in rules)}") print(f"룰 처리 지연시간(레코드 1건): 평균 {np.mean(lat):.3f}ms, 99백분위 {np.percentile(lat, 99):.3f}ms, 최대 {np.max(lat):.3f}ms (목표 50ms 이하, 99백분위 기준: {'달성' if np.percentile(lat, 99) <= 50 else '미달'})") # 2) 경계 조건 단위 시험: 기준값 바로 아래·위에서 룰이 정확히 켜지고 꺼지는가 CLEAN = [make_clause(np.random.default_rng(i), "정상") for i in range(6)] BASE = dict(contract_id="C-0000", ctype="물품", method="일반경쟁", amount_m=100.0, period_days=120, delivery_days=100, penalty_permil=1.5, advance_pct=20, perf_bond_pct=15, warranty_months=24, inspection_days=7, subcontract_pct=20, price_adjust=True, termination=True, security=True, ip_clause=True, clauses=CLEAN) TESTS = [("R01", dict(penalty_permil=0.9), True), ("R01", dict(penalty_permil=1.0), False), ("R02", dict(advance_pct=41), True), ("R02", dict(advance_pct=40), False), ("R03", dict(advance_pct=50, perf_bond_pct=9), True), ("R03", dict(advance_pct=50, perf_bond_pct=10), False), ("R04", dict(method="수의계약", amount_m=300), True), ("R04", dict(method="수의계약", amount_m=299), False), ("R05", dict(amount_m=500, perf_bond_pct=9), True), ("R05", dict(amount_m=499, perf_bond_pct=9), False), ("R06", dict(warranty_months=11), True), ("R06", dict(warranty_months=12), False), ("R07", dict(termination=False), True), ("R07", dict(termination=True), False), ("R08", dict(security=False), True), ("R08", dict(security=True), False), ("R09", dict(ip_clause=False, ctype="용역"), True), ("R09", dict(ip_clause=False, ctype="공사"), False), ("R10", dict(subcontract_pct=51), True), ("R10", dict(subcontract_pct=50), False), ("R11", dict(inspection_days=15), True), ("R11", dict(inspection_days=14), False), ("R12", dict(delivery_days=121), True), ("R12", dict(delivery_days=120), False), ("R13", dict(period_days=180, delivery_days=170, price_adjust=False), True), ("R13", dict(period_days=179, delivery_days=170, price_adjust=False), False), ("R14", dict(method="지명경쟁", amount_m=1000), True), ("R14", dict(method="지명경쟁", amount_m=999), False), ("R15", dict(period_days=366, delivery_days=300), True), ("R15", dict(period_days=365, delivery_days=300), False), ("R16", dict(clauses=CLEAN[:5] + ["계약상대자는 언제든지 일방적으로 계약을 해지할 수 있다."]), True), ("R16", dict(clauses=CLEAN), False)] passed = 0 for rid, chg, expect in TESTS: row = derive(pd.DataFrame([{**BASE, **chg}])).iloc[0].to_dict() got = rid in apply_rules(row, rules)[2] passed += got == expect if got != expect: print(f" [FAIL] {rid} {chg} 기대 {expect} 결과 {got}") base_hits = apply_rules(derive(pd.DataFrame([BASE])).iloc[0].to_dict(), rules) print(f"\n[경계 조건 단위 시험] {passed}/{len(TESTS)}건 통과 ({passed / len(TESTS):.0%}), 정상 기준 계약의 적중 룰 {base_hits[2] or '없음'} -> 등급 {base_hits[1]} (목표 100% 통과: {'달성' if passed == len(TESTS) else '미달'})") # 3) 등급 분포와 실제 위반 여부(법무·계약관 검토에서 확인된 규정 위반 정답) 대비 탐지력 print("\n[1차 판별 등급 분포]") print(df.groupby("grade").agg(계약수=("contract_id", "size"), 실제위반율=("violation", "mean")).reindex(["고위험", "주의", "관심"]).round(2).to_string()) y = df["violation"].to_numpy() flag = df["grade"].isin(["고위험", "주의"]).to_numpy() tp = int((flag & y).sum()) print(f"고위험·주의 선별: 실제 위반 계약 {y.sum()}건 중 {tp}건 포착(재현율 {tp / y.sum():.0%}), 선별 {flag.sum()}건 중 정밀도 {tp / max(flag.sum(), 1):.0%}") # 4) 룰별 적중 건수와 근거 조항 cnt = pd.Series([h for hs in df["hits"] for h in hs]).value_counts() tbl = pd.DataFrame([{"룰": r["id"], "이름": r["name"], "가중치": r["weight"], "적중": int(cnt.get(r["id"], 0)), "근거 조항": CLAUSE[r["id"]]} for r in rules]) print("\n[룰별 적중 현황]") print(tbl.to_string(index=False)) # 5) 고위험 상위 3건의 판정 근거(설명 가능성) print("\n[고위험 상위 3건과 근거]") for _, r in df.sort_values("score", ascending=False).head(3).iterrows(): print(f" {r['contract_id']} {r['ctype']} {r['method']} {r['amount_m']:.0f}백만원 점수 {r['score']} {r['grade']}") for h in r["hits"][:3]: print(f" - {h} {next(x['name'] for x in rules if x['id'] == h)} / {CLAUSE[h]}") # 6) 규정 변경 대응: 수의계약 한도를 300에서 250백만원으로 낮추는 설정 변경(코드 수정 없음) rules2 = parse_rules(RULES_YAML.replace('op: ">="\n value: 300', 'op: ">="\n value: 250')) o2 = [apply_rules(r, rules2) for r in recs] g2 = np.array([x[1] in ("고위험", "주의") for x in o2]) n_new = int((g2 & ~flag).sum()) print(f"\n[설정 변경] 수의계약 한도 300 -> 250백만원(YAML 값만 변경): 새로 선별된 계약 {n_new}건(계약 쪼개기 의심 구간 250~299백만원), 재현율 {tp / y.sum():.0%} -> {(g2 & y).sum() / y.sum():.0%}") rules3 = parse_rules(RULES_YAML.replace("weight: 3\n- id: R02", "weight: 3\n- id: R02").replace("op: \"<\"\n value: 1.0", "op: \"<\"\n value: 1.2")) o3 = [apply_rules(r, rules3)[0] for r in recs] print(f"[설정 변경] 룰 R01 지체상금률 기준 1.0 -> 1.2: 점수가 바뀐 계약 {sum(a != b for a, b in zip(df['score'], o3))}건 (YAML 값만 변경)") print("처리 후 사람 검토: 룰 판별은 1차 선별이며, 최종 규정 위반 판단과 수정 요구는 계약관·법무 담당자가 결정한다.") fig, ax = plt.subplots(1, 2, figsize=(11, 4)) ax[0].bar(tbl["룰"], tbl["적중"], color="tab:blue"); ax[0].set_title("Rule hits by rule (synthetic)"); ax[0].set_ylabel("contracts") g = df.groupby("grade")["violation"].mean().reindex(["관심", "주의", "고위험"]) ax[1].bar(["low", "watch", "high"], g.values, color=["tab:green", "gold", "tab:red"]); ax[1].set_title("Actual violation rate by screening grade"); ax[1].set_ylim(0, 1.05) plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [룰 설정] 검증 룰 16종(목표 15종 이상: 달성), 모든 룰에 가상 지침 조항 연결 True 룰 처리 지연시간(레코드 1건): 평균 0.008ms, 99백분위 0.036ms, 최대 0.100ms (목표 50ms 이하, 99백분위 기준: 달성) [경계 조건 단위 시험] 32/32건 통과 (100%), 정상 기준 계약의 적중 룰 없음 -> 등급 관심 (목표 100% 통과: 달성) [1차 판별 등급 분포] 계약수 실제위반율 grade 고위험 22 1.00 주의 68 0.72 관심 110 0.07 고위험·주의 선별: 실제 위반 계약 79건 중 71건 포착(재현율 90%), 선별 90건 중 정밀도 79% [룰별 적중 현황] 룰 이름 가중치 적중 근거 조항 R01 지체상금률 1.0/1000 미만 3 10 가상지침 5조1항(지체상금) R02 선금 비율 40% 초과 2 11 가상지침 6조1항(선금 한도) R03 선금 40% 초과인데 이행보증 10% 미만 3 5 가상지침 6조2항(선금과 보증) R04 300백만원 이상 수의계약 3 10 가상지침 3조1항(수의계약 한도) R05 500백만원 이상 이행보증 10% 미만 2 4 가상지침 6조3항(이행보증) R06 물품 하자보증 12개월 미만 2 5 가상지침 7조1항(하자보증) R07 해지 조항 없음 2 8 가상지침 8조1항(계약 해지) R08 보안 조항 없음 3 5 가상지침 9조1항(보안 조항) R09 지식재산 조항 없음(물품·용역) 1 9 가상지침 9조2항(지식재산) R10 하도급 비율 50% 초과 3 8 가상지침 4조1항(하도급 제한) R11 검사 기간 14일 초과 1 21 가상지침 5조2항(검사 기간) R12 납품 기한이 계약 기간 초과 3 7 가상지침 5조3항(납품 기한) R13 180일 이상 계약에 물가변동 조항 없음 1 38 가상지침 6조4항(물가변동) R14 1000백만원 이상 비경쟁 계약 2 3 가상지침 3조2항(경쟁 계약 원칙) R15 계약 기간 365일 초과 1 24 가상지침 3조3항(장기 계약) R16 불공정·납기 위험 키워드 포함 3 61 가상지침 8조2항(불공정 조항) [고위험 상위 3건과 근거] C-0108 물품 수의계약 961백만원 점수 12 고위험 - R01 지체상금률 1.0/1000 미만 / 가상지침 5조1항(지체상금) - R04 300백만원 이상 수의계약 / 가상지침 3조1항(수의계약 한도) - R12 납품 기한이 계약 기간 초과 / 가상지침 5조3항(납품 기한) C-0062 용역 수의계약 1109백만원 점수 11 고위험 - R01 지체상금률 1.0/1000 미만 / 가상지침 5조1항(지체상금) - R04 300백만원 이상 수의계약 / 가상지침 3조1항(수의계약 한도) ... (이하 생략) ``` ## 미션 4. LLM/자연어처리 기반 독소 조항, 납기 지체 위험 문구 자동 식별 모델 고도화 - 난이도: 중급~고급 / 예상 소요시간: 3.0시간 - 코드 설명: 조항 문장을 정상·독소 조항·납기 지체 위험 문구로 분류하는 글자 n-gram TF-IDF 로지스틱 회귀 모델을 만들어 처음 보는 표현까지 정확도 85% 이상을 확인하고, 단순 키워드 기준선과 비교하며, 위험 조항마다 근거 표현과 표준 문안 Top-3 수정 권고(유사도 점수)를 제시한다. ```python """조항 문장을 정상·독소 조항·납기 지체 위험 문구로 분류하는 글자 n-gram TF-IDF 로지스틱 회귀 모델을 만들어 처음 보는 표현까지 정확도 85% 이상을 확인하고, 단순 키워드 기준선과 비교하며, 위험 조항마다 근거 표현과 표준 문안 Top-3 수정 권고(유사도 점수)를 제시한다.""" import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.metrics import confusion_matrix from sklearn.metrics.pairwise import cosine_similarity from sklearn.pipeline import make_pipeline # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df SIMPLE_KW = {"독소": ["면제", "일방", "포기", "전액", "누설", "하도급", "선지급"], "납기위험": ["연장", "자동", "노력", "통보", "기한"]} # ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ---- def clause_set(seed, n, hold=False, mix=(.55, .25, .20)): """조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.""" rng = np.random.default_rng(seed) y = rng.choice(CLASSES, n, p=list(mix)) return [make_clause(rng, c, hold) for c in y], list(y) def fit_clause_model(seed=100, n=1200): """학습용 조항 문장으로 분류 모델을 학습한다.""" X, y = clause_set(seed, n) return make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y) def keyword_pred(s): """비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.""" if any(k in s for k in SIMPLE_KW["독소"]): return "독소" return "납기위험" if any(k in s for k in SIMPLE_KW["납기위험"]) else "정상" # [교육용가상자료] 표준 문안 라이브러리(수정 권고용, 실제 표준계약서 조문이 아님) STD = {"지체상금": "납기를 지체하면 지체일수마다 계약금액의 1/1000 이상에 해당하는 지체상금을 부과하며 면제는 불가항력 사유가 확인되고 서면 승인을 받은 경우에 한다.", "검사": "발주기관은 납품된 물품을 정해진 기간 안에 검사하고 합격한 후에 대금을 지급하며 검사 권한은 생략하거나 대체할 수 없다.", "해지": "계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로 통지해야 하며 책임 있는 쪽이 손해를 부담한다.", "추가비용": "추가 비용은 사전에 협의해 서면으로 합의한 범위에서만 인정하며 증빙을 갖추어 청구한다.", "지식재산": "지식재산권의 귀속과 사용 범위는 별도 합의서로 정하며 발주기관은 목적물을 사용할 수 있다.", "하도급": "하도급은 사전에 발주기관의 서면 승인을 받아야 하며 하도급 내용은 확인할 수 있어야 한다.", "대금": "대금은 검사 합격 후 청구를 받아 정해진 기간 안에 지급하고 선지급은 보증을 갖춘 범위에서만 한다.", "보안": "계약 이행 중 알게 된 비공개 사항은 누설하지 않으며 위반 시 책임을 진다.", "하자": "보증 기간 중 발생한 결함은 계약상대자가 책임지고 무상으로 보수하거나 교체한다.", "분쟁": "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", "납기": "납기는 계약서에 날짜로 명시하고 변경은 불가항력 사유 확인 후 서면 승인으로만 한다."} TOPIC_T = ["지체상금", "검사", "해지", "추가비용", "지식재산", "하도급", "대금", "보안", "하자", "대금", "분쟁", "검사", "추가비용"] # T_TPL 순서와 대응 model = fit_clause_model() Xa, ya = clause_set(200, 600) # 학습과 같은 문장 틀(새로 뽑은 문장) Xb, yb = clause_set(201, 300, hold=True) # 학습에 없는 처음 보는 표현 print(f"[데이터] 학습 조항 1,200개 / 시험 A(같은 문장 틀) {len(Xa)}개 / 시험 B(처음 보는 표현) {len(Xb)}개 / 분류 3종: {', '.join(CLASSES)}") pa, pb = model.predict(Xa), model.predict(Xb) acc_a, acc_b = np.mean(pa == np.array(ya)), np.mean(pb == np.array(yb)) X_all, y_all, p_all = Xa + Xb, np.array(ya + yb), np.concatenate([pa, pb]) acc = np.mean(p_all == y_all) kb = np.array([keyword_pred(s) for s in X_all]) print(f"\n[분류 정확도] 시험 A {acc_a:.1%}, 시험 B(처음 보는 표현) {acc_b:.1%}, 전체 {acc:.1%} (목표 85% 이상: {'달성' if acc >= 0.85 else '미달'})") print(f"단순 키워드 기준선 전체 {np.mean(kb == y_all):.1%} (부정·문맥을 보지 못해 '면제 사유가 없는 한 부과' 같은 정상 문장을 오탐)") cm = pd.DataFrame(confusion_matrix(y_all, p_all, labels=CLASSES), index=[f"실제 {c}" for c in CLASSES], columns=[f"예측 {c}" for c in CLASSES]) print(cm.to_string()) risk_idx = y_all != "정상" rec_ = np.mean(p_all[risk_idx] != "정상") print(f"위험 조항(독소·납기위험) 포착률 {rec_:.1%}, 정상 문장을 위험으로 오탐한 비율 {np.mean(p_all[~risk_idx] != '정상'):.1%}") # 오분류 사례 print("\n[오분류 사례(시험 B)]") shown = 0 for s, t, p in zip(Xb, yb, pb): if t != p and shown < 3: shown += 1; print(f" 실제 {t} / 예측 {p}: {s[:48]}...") if shown == 0: print(" 없음") # 근거 표현: 해당 분류에 가장 크게 기여한 글자 조각 vec, lr = model[0], model[-1] names = np.array(vec.get_feature_names_out()) def evidence(text, cls, k=3): x = vec.transform([text]) contrib = x.multiply(lr.coef_[list(lr.classes_).index(cls)]).toarray()[0] return [names[j].strip() for j in np.argsort(-contrib)[:k] if contrib[j] > 0] # 표준 문안 Top-3 추천(과거 처리 완료 사례와의 일치도): 독소 조항 120개와 납기 위험 문구 60개 rng = np.random.default_rng(5) sv = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)).fit(list(STD.values()) + X_all) SM = sv.transform(list(STD.values())) topics = list(STD) tests = [(make_clause(rng, "독소", idx=i), TOPIC_T[i]) for i in rng.integers(0, len(T_TPL), 120)] + [(make_clause(rng, "납기위험"), "납기") for _ in range(60)] jn = topics.index("납기") def similarity(s): """문안 유사도. 분류 결과가 납기위험이면 납기 문안을 우선하고, 독소 조항이면 납기 문안은 제외한다.""" raw = cosine_similarity(sv.transform([s]), SM)[0] sc = raw.copy() sc[jn] += 1.0 if model.predict([s])[0] == "납기위험" else -1.0 return sc, raw hit1 = hit3 = 0 for s, t in tests: sc, _ = similarity(s) order = np.argsort(-sc)[:3] hit1 += topics[order[0]] == t; hit3 += t in [topics[j] for j in order] print(f"\n[표준 문안 추천 일치도] 위험 조항 {len(tests)}건: Top-1 {hit1 / len(tests):.1%}, Top-3 {hit3 / len(tests):.1%} (과거 처리된 수정 문안 주제와 일치 기준)") print("\n[위험 조항별 근거와 수정 권고 Top-3 예시]") for s, t in [tests[0], tests[7], tests[-1]]: pred = model.predict([s])[0]; prob = model.predict_proba([s])[0].max() sc, sim = similarity(s) order = np.argsort(-sc)[:3] print(f" 조항: {s[:46]}...") print(f" 분류 {pred}(확신도 {prob:.2f}), 근거 표현 {evidence(s, pred) or '-'}") for r, j in enumerate(order, 1): print(f" 권고 {r}순위 [{topics[j]}] 유사도 {sim[j]:.2f}: {list(STD.values())[j][:30]}...") print("※ 모델은 후보를 표시하고 수정 문안은 참고용이다. 불공정 조항 여부와 문안 확정은 계약관·법무 담당자가 판단한다.") fig, ax = plt.subplots(1, 2, figsize=(11, 4)) ax[0].bar(["keyword", "model A", "model B", "model all"], [np.mean(kb == y_all), acc_a, acc_b, acc], color=["gray", "tab:blue", "tab:orange", "tab:green"]) ax[0].axhline(.85, color="r", ls="--"); ax[0].set_ylim(0, 1.05); ax[0].set_title("Clause classification accuracy") im = ax[1].imshow(cm.to_numpy(), cmap="Blues"); ax[1].set_xticks(range(3), ["ok", "toxic", "delay"]); ax[1].set_yticks(range(3), ["ok", "toxic", "delay"]); ax[1].set_title("Confusion matrix (all test)") for i in range(3): for j in range(3): ax[1].text(j, i, int(cm.to_numpy()[i, j]), ha="center", va="center") plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [데이터] 학습 조항 1,200개 / 시험 A(같은 문장 틀) 600개 / 시험 B(처음 보는 표현) 300개 / 분류 3종: 정상, 독소, 납기위험 [분류 정확도] 시험 A 100.0%, 시험 B(처음 보는 표현) 95.3%, 전체 98.4% (목표 85% 이상: 달성) 단순 키워드 기준선 전체 64.4% (부정·문맥을 보지 못해 '면제 사유가 없는 한 부과' 같은 정상 문장을 오탐) 예측 정상 예측 독소 예측 납기위험 실제 정상 504 0 0 실제 독소 0 222 0 실제 납기위험 14 0 160 위험 조항(독소·납기위험) 포착률 96.5%, 정상 문장을 위험으로 오탐한 비율 0.0% [오분류 사례(시험 B)] 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다. 필요한 경우 양 당사자가... 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다. 이 조항은 계약 전체에 ... 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.... [표준 문안 추천 일치도] 위험 조항 180건: Top-1 94.4%, Top-3 97.8% (과거 처리된 수정 문안 주제와 일치 기준) [위험 조항별 근거와 수정 권고 Top-3 예시] 조항: 하자 보수 책임은 인도 후 즉시 소멸하며 계약담당관은 보수를 요구할 수 없다.... 분류 독소(확신도 0.99), 근거 표현 ['관은', '관은', '즉시'] 권고 1순위 [하자] 유사도 0.12: 보증 기간 중 발생한 결함은 계약상대자가 책임지고 무상... 권고 2순위 [검사] 유사도 0.09: 발주기관은 납품된 물품을 정해진 기간 안에 검사하고 합... 권고 3순위 [하도급] 유사도 0.06: 하도급은 사전에 발주기관의 서면 승인을 받아야 하며 하... 조항: 용역 결과물의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이... 분류 독소(확신도 0.99), 근거 표현 ['전액', '액', '전액'] 권고 1순위 [추가비용] 유사도 0.16: 추가 비용은 사전에 협의해 서면으로 합의한 범위에서만 ... 권고 2순위 [지식재산] 유사도 0.12: 지식재산권의 귀속과 사용 범위는 별도 합의서로 정하며 ... 권고 3순위 [해지] 유사도 0.12: 계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로... 조항: 납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다. 세부 ... 분류 납기위험(확신도 0.99), 근거 표현 ['일정', '일정', '도'] 권고 1순위 [납기] 유사도 0.03: 납기는 계약서에 날짜로 명시하고 변경은 불가항력 사유 ... 권고 2순위 [보안] 유사도 0.08: 계약 이행 중 알게 된 비공개 사항은 누설하지 않으며 ... 권고 3순위 [해지] 유사도 0.06: 계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로... ※ 모델은 후보를 표시하고 수정 문안은 참고용이다. 불공정 조항 여부와 문안 확정은 계약관·법무 담당자가 판단한다. ``` ## 미션 5. 계약 위험 점수 산출 및 규정 위반 가능성 신뢰도 지수 엔진 개발 - 난이도: 중급 / 예상 소요시간: 3.0시간 - 코드 설명: 룰 적중 내역과 조항 분류 모델의 위험 조항 수를 로지스틱 회귀로 결합해 계약별 위반 위험 점수(0~100)와 신뢰도 지수를 산출하는 엔진을 만들고, 별도 가상 계약군에서 성능·신뢰도 보정을 검증하며 입력-분석-검토-결과 4개 화면(텍스트 패널과 그래프)과 상태 뱃지·필터를 구성한다. ```python """룰 적중 내역과 조항 분류 모델의 위험 조항 수를 로지스틱 회귀로 결합해 계약별 위반 위험 점수(0~100)와 신뢰도 지수를 산출하는 엔진을 만들고, 별도 가상 계약군에서 성능·신뢰도 보정을 검증하며 입력-분석-검토-결과 4개 화면(텍스트 패널과 그래프)과 상태 뱃지·필터를 구성한다.""" import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, brier_score_loss from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df # ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ---- RULES_YAML = """ - id: R01 name: 지체상금률 1.0/1000 미만 field: penalty_permil op: "<" value: 1.0 weight: 3 - id: R02 name: 선금 비율 40% 초과 field: advance_pct op: ">" value: 40 weight: 2 - id: R03 name: 선금 40% 초과인데 이행보증 10% 미만 field: adv_no_bond op: "==" value: true weight: 3 - id: R04 name: 300백만원 이상 수의계약 field: sole_amount op: ">=" value: 300 weight: 3 - id: R05 name: 500백만원 이상 이행보증 10% 미만 field: bond_gap op: "==" value: true weight: 2 - id: R06 name: 물품 하자보증 12개월 미만 field: warranty_gap op: "==" value: true weight: 2 - id: R07 name: 해지 조항 없음 field: termination op: "==" value: false weight: 2 - id: R08 name: 보안 조항 없음 field: security op: "==" value: false weight: 3 - id: R09 name: 지식재산 조항 없음(물품·용역) field: ip_missing op: "==" value: true weight: 1 - id: R10 name: 하도급 비율 50% 초과 field: subcontract_pct op: ">" value: 50 weight: 3 - id: R11 name: 검사 기간 14일 초과 field: inspection_days op: ">" value: 14 weight: 1 - id: R12 name: 납품 기한이 계약 기간 초과 field: logic_err op: "==" value: true weight: 3 - id: R13 name: 180일 이상 계약에 물가변동 조항 없음 field: no_padj_long op: "==" value: true weight: 1 - id: R14 name: 1000백만원 이상 비경쟁 계약 field: noncomp_big op: "==" value: true weight: 2 - id: R15 name: 계약 기간 365일 초과 field: period_days op: ">" value: 365 weight: 1 - id: R16 name: 불공정·납기 위험 키워드 포함 field: kw_hits op: ">=" value: 1 weight: 3 """ GRADE_CUT = {"고위험": 6, "주의": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다) # [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님) KEYWORDS = {"독소": ["면제하며", "일방적으로", "포기", "지지 않는다", "즉시 소멸", "제한 없이", "신고 없이", "전액 부담", "별도 협의 없이", "검사 전에", "검사에 합격한 것으로", "사용할 수 없다", "증빙은 요구하지 않는다", "지정하는 기관"], "납기위험": ["자동 연장", "기한 없이", "정하지 않는다", "노력하며", "연장할 수 있다", "통보한다", "책임을 묻지 않는다", "의무가 없다"]} def parse_rules(text): """간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.""" rules, cur = [], None for line in text.strip().splitlines(): if not line.strip(): continue s = line.strip() if s.startswith("- "): cur = {}; rules.append(cur); s = s[2:] k, v = [x.strip() for x in s.split(":", 1)] v = v.strip('"') if v in ("true", "false"): v = v == "true" else: try: v = float(v) if "." in v else int(v) except ValueError: pass cur[k] = v return rules OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b} def kw_count(clauses): """조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.""" return int(sum(any(k in c for k in KEYWORDS["독소"] + KEYWORDS["납기위험"]) for c in clauses)) def derive(df): """룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.""" d = df.copy() d["adv_no_bond"] = (d["advance_pct"] > 40) & (d["perf_bond_pct"] < 10) d["sole_amount"] = np.where(d["method"] == "수의계약", d["amount_m"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정) d["bond_gap"] = (d["amount_m"] >= 500) & (d["perf_bond_pct"] < 10) d["warranty_gap"] = (d["ctype"] == "물품") & (d["warranty_months"] < 12) d["ip_missing"] = (~d["ip_clause"]) & (d["ctype"] != "공사") d["logic_err"] = d["delivery_days"] > d["period_days"] d["no_padj_long"] = (d["period_days"] >= 180) & (~d["price_adjust"]) d["noncomp_big"] = d["method"].isin(["지명경쟁", "수의계약"]) & (d["amount_m"] >= 1000) d["kw_hits"] = [kw_count(c) for c in d["clauses"]] if "clauses" in d else 0 return d def apply_rules(rec, rules): """레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.""" hits = [] for r in rules: v = rec.get(r["field"]) if v is None or (isinstance(v, float) and np.isnan(v)): continue if OPS[r["op"]](v, r["value"]): hits.append(r) score = sum(r["weight"] for r in hits) grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심") return score, grade, [r["id"] for r in hits] SIMPLE_KW = {"독소": ["면제", "일방", "포기", "전액", "누설", "하도급", "선지급"], "납기위험": ["연장", "자동", "노력", "통보", "기한"]} # ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ---- def clause_set(seed, n, hold=False, mix=(.55, .25, .20)): """조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.""" rng = np.random.default_rng(seed) y = rng.choice(CLASSES, n, p=list(mix)) return [make_clause(rng, c, hold) for c in y], list(y) def fit_clause_model(seed=100, n=1200): """학습용 조항 문장으로 분류 모델을 학습한다.""" X, y = clause_set(seed, n) return make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y) def keyword_pred(s): """비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.""" if any(k in s for k in SIMPLE_KW["독소"]): return "독소" return "납기위험" if any(k in s for k in SIMPLE_KW["납기위험"]) else "정상" RULES = parse_rules(RULES_YAML) RID = [r["id"] for r in RULES] WARN = {"고위험": 60, "주의": 30} # 위험 점수 등급 기준(점수 = 위반 확률 x 100) def featurize(con, cm): """룰 16종 적중 여부 + 조항 모델이 찾은 독소·납기위험 조항 수 + 금액(로그).""" d = derive(con) H = np.array([[r in apply_rules(x, RULES)[2] for r in RID] for x in d.to_dict("records")], float) flat = [c for cl in con["clauses"] for c in cl] pred = np.array(cm.predict(flat)).reshape(len(con), 6) nt, nd = (pred == "독소").sum(1), (pred == "납기위험").sum(1) return np.column_stack([H, nt, nd, np.log1p(con["amount_m"].to_numpy())]), H, nt, nd FEAT_NAMES = [f"{r['id']} {r['name']}" for r in RULES] + ["독소 조항 수(모델)", "납기위험 문구 수(모델)", "금액(로그)"] cm = fit_clause_model() train, test = make_contracts(800, seed=11), make_contracts(300, seed=7) Xtr, _, _, _ = featurize(train, cm) Xte, Hte, nt, nd = featurize(test, cm) lr = make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=1000)).fit(Xtr, train["violation"].astype(int)) y = test["violation"].astype(int).to_numpy() p = lr.predict_proba(Xte)[:, 1] score = p * 100 rule_flag = np.array([apply_rules(x, RULES)[1] != "관심" for x in derive(test).to_dict("records")]) agree = (rule_flag == (p >= 0.5)).astype(float) conf = np.clip(0.6 * np.abs(2 * p - 1) + 0.4 * agree, 0, 1) * 100 # 신뢰도 지수: 확률의 뚜렷함 + 룰·모델 일치 level = np.where(score >= WARN["고위험"], "고위험", np.where(score >= WARN["주의"], "주의", "관심")) T = test.assign(score=score, conf=conf, level=level, y=y) # ===== 화면 1. 입력: 입력 요약과 검증 ===== miss = int(test[["amount_m", "penalty_permil", "advance_pct"]].isna().sum().sum()) print("[화면 1. 입력] [교육용가상자료]") print(f" 검증용 계약 {len(test)}건(조항 {len(test) * 6}개) / 학습 계약 {len(train)}건 / 입력 지표 {len(FEAT_NAMES)}개 / 필수값 결측 {miss}건") print(f" 수의계약 {int((test['method'] == '수의계약').sum())}건, 평균 금액 {test['amount_m'].mean():.0f}백만원, 보안 조항 없음 {int((~test['security']).sum())}건") # ===== 화면 2. 분석: 점수 분포와 성능 ===== auc = roc_auc_score(y, score) print("\n[화면 2. 분석] 위반 위험 점수와 모델 성능") print(f" ROC-AUC {auc:.3f}, Brier {brier_score_loss(y, p):.3f} (실제 위반 비율 {y.mean():.0%}), 룰 점수 단독 AUC {roc_auc_score(y, Hte @ np.array([r['weight'] for r in RULES])):.3f}") print(T.groupby("level").agg(계약수=("contract_id", "size"), 평균점수=("score", "mean"), 실제위반율=("y", "mean")).reindex(["고위험", "주의", "관심"]).round(2).to_string()) coef = pd.Series(lr[-1].coef_[0], index=FEAT_NAMES).sort_values(ascending=False) print(" 위험 상승 요인(회귀 계수 상위 4):", ", ".join(f"{k.split(' ')[0]} {v:+.2f}" for k, v in coef.head(4).items())) print(" [신뢰도 지수와 실제 정확도] (판정 = 점수 50 이상을 위반 가능으로 봄)") bins = pd.cut(T["conf"], [0, 50, 75, 90, 100.1], labels=["50 미만", "50~75", "75~90", "90 이상"]) ok = ((p >= 0.5).astype(int) == y) cal = pd.DataFrame({"bin": bins, "ok": ok}).groupby("bin", observed=True).agg(건수=("ok", "size"), 실제정확도=("ok", "mean")).round(2) print(cal.to_string()) # ===== 화면 3. 검토: 고위험 계약을 담당자가 확인 ===== rev = T[T["level"] == "고위험"].sort_values("score", ascending=False) contrib = (lr[0].transform(Xte) * lr[-1].coef_[0]) BADGE = {"고위험": "[검토 필수]", "주의": "[확인 권고]", "관심": "[통과 후보]"} print(f"\n[화면 3. 검토] 고위험 {len(rev)}건 중 상위 5건 (담당자가 근거를 확인하고 조치 여부를 결정)") for i, r in rev.head(5).iterrows(): top = [FEAT_NAMES[j].split(" ")[0] + ":" + FEAT_NAMES[j].split(" ", 1)[1][:10] for j in np.argsort(-contrib[i])[:2] if contrib[i][j] > 0] print(f" {BADGE[r['level']]} {r['contract_id']} {r['ctype']} {r['amount_m']:.0f}백만원 | 위험 {r['score']:.0f}점 신뢰도 {r['conf']:.0f} | 요인 {', '.join(top) or '-'} | 상태 검토대기") # ===== 화면 4. 결과: 요약, 뱃지 필터, 내보내기 ===== print("\n[화면 4. 결과]") cnt = T["level"].value_counts() print(f" {BADGE['고위험']} {cnt.get('고위험', 0)}건, {BADGE['주의']} {cnt.get('주의', 0)}건, {BADGE['관심']} {cnt.get('관심', 0)}건") f = T[(T["level"] == "주의") & (T["conf"] < 75)] print(f" 필터 예시: 등급 '주의' 이면서 신뢰도 75 미만 -> {len(f)}건(사람이 먼저 확인할 경계 사례)") print(f" 고위험 등급의 실제 위반 {int(rev['y'].sum())}건({rev['y'].mean():.0%}) vs 관심 등급 {T[T['level'] == '관심']['y'].mean():.0%}") print(" 내보내기 항목: 계약번호, 위험 점수, 신뢰도, 등급 뱃지, 요인, 검토 상태(검토대기)") print("화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)") print("※ 점수와 신뢰도는 우선순위를 정하는 참고값이며, 규정 위반 여부의 판단과 조치는 계약관·법무 담당자가 한다.") fig, ax = plt.subplots(2, 2, figsize=(11, 7)) ax[0, 0].bar(["sole-source", "no security", "no termination"], [(test["method"] == "수의계약").sum(), (~test["security"]).sum(), (~test["termination"]).sum()]); ax[0, 0].set_title("Screen 1: input summary (count)") ax[0, 1].hist([T[T["y"] == 0]["score"], T[T["y"] == 1]["score"]], bins=15, label=["no violation", "violation"], stacked=True); ax[0, 1].legend(); ax[0, 1].set_title("Screen 2: risk score distribution") ax[1, 0].scatter(T["score"], T["conf"], c=T["y"], cmap="coolwarm", s=14); ax[1, 0].set_xlabel("risk score"); ax[1, 0].set_ylabel("confidence index"); ax[1, 0].set_title("Screen 3: review map (color = actual)") ax[1, 1].bar(["high", "watch", "low"], [cnt.get("고위험", 0), cnt.get("주의", 0), cnt.get("관심", 0)], color=["tab:red", "gold", "tab:green"]); ax[1, 1].set_title("Screen 4: result by grade") plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [화면 1. 입력] [교육용가상자료] 검증용 계약 300건(조항 1800개) / 학습 계약 800건 / 입력 지표 19개 / 필수값 결측 0건 수의계약 38건, 평균 금액 381백만원, 보안 조항 없음 10건 [화면 2. 분석] 위반 위험 점수와 모델 성능 ROC-AUC 0.954, Brier 0.062 (실제 위반 비율 41%), 룰 점수 단독 AUC 0.889 계약수 평균점수 실제위반율 level 고위험 112 90.48 0.94 주의 8 48.49 1.00 관심 180 10.12 0.05 위험 상승 요인(회귀 계수 상위 4): 독소 +2.62, R07 +1.04, R01 +0.98, R04 +0.76 [신뢰도 지수와 실제 정확도] (판정 = 점수 50 이상을 위반 가능으로 봄) 건수 실제정확도 bin 50 미만 38 0.84 50~75 31 0.87 75~90 67 0.97 90 이상 164 0.95 [화면 3. 검토] 고위험 112건 중 상위 5건 (담당자가 근거를 확인하고 조치 여부를 결정) [검토 필수] C-0222 물품 162백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R06:물품 하자보증 12 | 상태 검토대기 [검토 필수] C-0028 물품 590백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R08:보안 조항 없음 | 상태 검토대기 [검토 필수] C-0266 물품 68백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R08:보안 조항 없음 | 상태 검토대기 [검토 필수] C-0037 물품 62백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R10:하도급 비율 50% | 상태 검토대기 [검토 필수] C-0221 용역 101백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R10:하도급 비율 50% | 상태 검토대기 [화면 4. 결과] [검토 필수] 112건, [확인 권고] 8건, [통과 후보] 180건 필터 예시: 등급 '주의' 이면서 신뢰도 75 미만 -> 8건(사람이 먼저 확인할 경계 사례) 고위험 등급의 실제 위반 105건(94%) vs 관심 등급 5% 내보내기 항목: 계약번호, 위험 점수, 신뢰도, 등급 뱃지, 요인, 검토 상태(검토대기) 화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성) ※ 점수와 신뢰도는 우선순위를 정하는 참고값이며, 규정 위반 여부의 판단과 조치는 계약관·법무 담당자가 한다. ``` ## 미션 6. 계약서 조항별 위험도 하이라이트 및 조달 검토 대시보드 구현 - 난이도: 중급~고급 / 예상 소요시간: 2.5시간 - 코드 설명: 계약서 조항별 위험도 하이라이트와 계약 방식 x 유형 위험 지도를 보여 주는 조달 검토 대시보드에 입력 검증기·에러 핸들러·재시도·롤백을 붙이고, 깨진 입력 20종(결측·특수문자·대용량·일시 오류 포함)을 주입해 크래시 0건·에러 로그 기록률 100%·사용자용 조치 안내를 확인한다. ```python """계약서 조항별 위험도 하이라이트와 계약 방식 x 유형 위험 지도를 보여 주는 조달 검토 대시보드에 입력 검증기·에러 핸들러·재시도·롤백을 붙이고, 깨진 입력 20종(결측·특수문자·대용량·일시 오류 포함)을 주입해 크래시 0건·에러 로그 기록률 100%·사용자용 조치 안내를 확인한다.""" import csv import io import json import re import time import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df # ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ---- RULES_YAML = """ - id: R01 name: 지체상금률 1.0/1000 미만 field: penalty_permil op: "<" value: 1.0 weight: 3 - id: R02 name: 선금 비율 40% 초과 field: advance_pct op: ">" value: 40 weight: 2 - id: R03 name: 선금 40% 초과인데 이행보증 10% 미만 field: adv_no_bond op: "==" value: true weight: 3 - id: R04 name: 300백만원 이상 수의계약 field: sole_amount op: ">=" value: 300 weight: 3 - id: R05 name: 500백만원 이상 이행보증 10% 미만 field: bond_gap op: "==" value: true weight: 2 - id: R06 name: 물품 하자보증 12개월 미만 field: warranty_gap op: "==" value: true weight: 2 - id: R07 name: 해지 조항 없음 field: termination op: "==" value: false weight: 2 - id: R08 name: 보안 조항 없음 field: security op: "==" value: false weight: 3 - id: R09 name: 지식재산 조항 없음(물품·용역) field: ip_missing op: "==" value: true weight: 1 - id: R10 name: 하도급 비율 50% 초과 field: subcontract_pct op: ">" value: 50 weight: 3 - id: R11 name: 검사 기간 14일 초과 field: inspection_days op: ">" value: 14 weight: 1 - id: R12 name: 납품 기한이 계약 기간 초과 field: logic_err op: "==" value: true weight: 3 - id: R13 name: 180일 이상 계약에 물가변동 조항 없음 field: no_padj_long op: "==" value: true weight: 1 - id: R14 name: 1000백만원 이상 비경쟁 계약 field: noncomp_big op: "==" value: true weight: 2 - id: R15 name: 계약 기간 365일 초과 field: period_days op: ">" value: 365 weight: 1 - id: R16 name: 불공정·납기 위험 키워드 포함 field: kw_hits op: ">=" value: 1 weight: 3 """ GRADE_CUT = {"고위험": 6, "주의": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다) # [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님) KEYWORDS = {"독소": ["면제하며", "일방적으로", "포기", "지지 않는다", "즉시 소멸", "제한 없이", "신고 없이", "전액 부담", "별도 협의 없이", "검사 전에", "검사에 합격한 것으로", "사용할 수 없다", "증빙은 요구하지 않는다", "지정하는 기관"], "납기위험": ["자동 연장", "기한 없이", "정하지 않는다", "노력하며", "연장할 수 있다", "통보한다", "책임을 묻지 않는다", "의무가 없다"]} def parse_rules(text): """간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.""" rules, cur = [], None for line in text.strip().splitlines(): if not line.strip(): continue s = line.strip() if s.startswith("- "): cur = {}; rules.append(cur); s = s[2:] k, v = [x.strip() for x in s.split(":", 1)] v = v.strip('"') if v in ("true", "false"): v = v == "true" else: try: v = float(v) if "." in v else int(v) except ValueError: pass cur[k] = v return rules OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b} def kw_count(clauses): """조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.""" return int(sum(any(k in c for k in KEYWORDS["독소"] + KEYWORDS["납기위험"]) for c in clauses)) def derive(df): """룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.""" d = df.copy() d["adv_no_bond"] = (d["advance_pct"] > 40) & (d["perf_bond_pct"] < 10) d["sole_amount"] = np.where(d["method"] == "수의계약", d["amount_m"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정) d["bond_gap"] = (d["amount_m"] >= 500) & (d["perf_bond_pct"] < 10) d["warranty_gap"] = (d["ctype"] == "물품") & (d["warranty_months"] < 12) d["ip_missing"] = (~d["ip_clause"]) & (d["ctype"] != "공사") d["logic_err"] = d["delivery_days"] > d["period_days"] d["no_padj_long"] = (d["period_days"] >= 180) & (~d["price_adjust"]) d["noncomp_big"] = d["method"].isin(["지명경쟁", "수의계약"]) & (d["amount_m"] >= 1000) d["kw_hits"] = [kw_count(c) for c in d["clauses"]] if "clauses" in d else 0 return d def apply_rules(rec, rules): """레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.""" hits = [] for r in rules: v = rec.get(r["field"]) if v is None or (isinstance(v, float) and np.isnan(v)): continue if OPS[r["op"]](v, r["value"]): hits.append(r) score = sum(r["weight"] for r in hits) grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심") return score, grade, [r["id"] for r in hits] SIMPLE_KW = {"독소": ["면제", "일방", "포기", "전액", "누설", "하도급", "선지급"], "납기위험": ["연장", "자동", "노력", "통보", "기한"]} # ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ---- def clause_set(seed, n, hold=False, mix=(.55, .25, .20)): """조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.""" rng = np.random.default_rng(seed) y = rng.choice(CLASSES, n, p=list(mix)) return [make_clause(rng, c, hold) for c in y], list(y) def fit_clause_model(seed=100, n=1200): """학습용 조항 문장으로 분류 모델을 학습한다.""" X, y = clause_set(seed, n) return make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y) def keyword_pred(s): """비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.""" if any(k in s for k in SIMPLE_KW["독소"]): return "독소" return "납기위험" if any(k in s for k in SIMPLE_KW["납기위험"]) else "정상" NUM = ["amount_m", "period_days", "delivery_days", "penalty_permil", "advance_pct", "perf_bond_pct", "warranty_months", "inspection_days", "subcontract_pct"] BOOL = ["price_adjust", "termination", "security", "ip_clause"] NEED = ["contract_id", "ctype", "method"] + NUM + BOOL + ["clauses"] RANGE = {"amount_m": (1, 100000), "period_days": (1, 1500), "delivery_days": (1, 1500), "penalty_permil": (0, 10), "advance_pct": (0, 100), "perf_bond_pct": (0, 100), "warranty_months": (0, 120), "inspection_days": (1, 90), "subcontract_pct": (0, 100)} LOG, COMMITTED = [], {} RULES = parse_rules(RULES_YAML) CM = fit_clause_model() MAX_CLAUSE = 600 class UserError(Exception): """사용자에게 보여 줄 메시지와 조치 가이드를 함께 가진 예외.""" def __init__(self, code, msg, action): super().__init__(msg); self.code, self.msg, self.action = code, msg, action class Transient(Exception): """일시 오류(재시도 대상).""" def log(level, code, detail): LOG.append({"seq": len(LOG) + 1, "level": level, "code": code, "detail": str(detail)[:70]}) def read_input(src): """입력(CSV 문자열 / JSON 문자열 / DataFrame)을 DataFrame으로 읽는다. 실패하면 UserError.""" if src is None: raise UserError("E01", "입력이 비어 있습니다.", "파일을 다시 선택해 주세요.") if isinstance(src, pd.DataFrame): df = src.copy() elif isinstance(src, str): if not src.strip(): raise UserError("E02", "빈 파일입니다.", "내용이 있는 파일인지 확인해 주세요.") try: df = pd.DataFrame(json.loads(src)) if src.lstrip()[0] in "[{" else pd.DataFrame(list(csv.DictReader(io.StringIO(src)))) except Exception as e: raise UserError("E03", "파일 형식을 읽을 수 없습니다.", "CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.") from e else: raise UserError("E04", f"지원하지 않는 입력 형식입니다({type(src).__name__}).", "CSV 또는 JSON 파일을 사용해 주세요.") if df.empty: raise UserError("E02", "읽은 행이 0건입니다.", "머리글만 있는 파일이 아닌지 확인해 주세요.") return df def clean_text(s): """특수문자·제어문자 제거, 지나치게 긴 조항은 잘라 내기.""" s = re.sub(r"[\x00-\x08\x0b-\x1f\x7f<>]", " ", str(s)) return re.sub(r"\s+", " ", s).strip()[:MAX_CLAUSE] def validate_table(df): """열 점검 -> 행 단위 점검. 문제 행은 격리하고 나머지로 계속 진행한다. 반환: (정상 행, 격리 내역)""" miss = [c for c in NEED if c not in df.columns] if miss: raise UserError("E10", f"필수 열이 없습니다: {', '.join(miss[:4])}{' 외' if len(miss) > 4 else ''}", "열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.") d, bad = df.copy().reset_index(drop=True), {} for c in NUM: conv = pd.to_numeric(d[c], errors="coerce") for i in d.index[conv.isna()]: bad.setdefault(i, f"{c}: 숫자가 아님/누락") d[c] = conv for c, (lo, hi) in RANGE.items(): for i in d.index[(d[c] < lo) | (d[c] > hi)]: bad.setdefault(i, f"{c}: 허용 범위({lo}~{hi}) 밖") for c, ok in (("ctype", TYPES), ("method", METHODS)): for i in d.index[~d[c].isin(ok)]: bad.setdefault(i, f"{c}: 허용되지 않은 값") for i in d.index[d["contract_id"].duplicated(keep="first")]: bad.setdefault(i, "contract_id: 중복") for i in d.index[~d["contract_id"].astype(str).str.match(r"^C-\d{4}$")]: bad.setdefault(i, "contract_id: 형식 오류") for c in BOOL: d[c] = d[c].map(lambda v: v if isinstance(v, (bool, np.bool_)) else str(v).strip().lower() in ("true", "1", "예", "y")) d["clauses"] = d["clauses"].map(lambda v: [clean_text(x) for x in (v if isinstance(v, list) else str(v).split(" || ")) if str(x).strip()]) for i in d.index[d["clauses"].map(len) == 0]: bad.setdefault(i, "clauses: 조항 없음") if len(bad) == len(d): raise UserError("E11", "사용할 수 있는 행이 없습니다.", "격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.") return d.drop(index=list(bad)).reset_index(drop=True), bad MARK = {"독소": "[!!]", "납기위험": "[! ]", "정상": "[ok]"} def highlight(clauses): """조항별 위험 표시: 모델 분류 결과와 키워드 위치를 【 】로 강조한다.""" cls = CM.predict(clauses) out = [] for c, k in zip(clauses, cls): for kw in KEYWORDS["독소"] + KEYWORDS["납기위험"]: if k != "정상" and kw in c: c = c.replace(kw, f"【{kw}】") out.append((k, c)) return out def build_dashboard(df, fail_times=0, state=None): """대시보드 산출물을 한 번에 만든다(전부 성공해야 반영 = 트랜잭션). 반환: 결과 dict, 격리 내역""" d, bad = validate_table(read_input(df)) if state is not None and state["calls"] < fail_times: state["calls"] += 1 raise Transient("외부 저장소 응답 지연(모의)") dd = derive(d) dd["score"] = [apply_rules(r, RULES)[0] for r in dd.to_dict("records")] heat = dd.pivot_table(index="method", columns="ctype", values="score", aggfunc="mean").round(1) top = dd.sort_values(["kw_hits", "score"], ascending=False).head(3) # 위험 문구가 많은 계약부터 hl = {r["contract_id"]: highlight(r["clauses"]) for _, r in top.iterrows()} return {"heat": heat, "top": top[["contract_id", "ctype", "method", "amount_m", "score"]], "hl": hl, "n": len(dd)}, bad def safe_dashboard(case, src, fail_times=0, always_fail=False): """모든 예외를 잡아 크래시 없이 사용자 메시지·조치 안내를 돌려주고 에러 로그를 남긴다. 일시 오류는 3회까지 재시도하고, 끝내 실패하면 직전 정상 결과로 롤백한다.""" state = {"calls": 0} for attempt in range(1, 4): try: if always_fail: raise Transient("저장소 연결 불가(모의)") res, bad = build_dashboard(src, fail_times, state) for i, why in bad.items(): log("WARN", "W01", f"{case}: 행 {i} 격리 - {why}") COMMITTED["last"] = res # 전부 성공했을 때만 반영 return {"case": case, "ok": True, "msg": f"표시 완료(격리 {len(bad)}행)" if bad else "표시 완료" + (f", 재시도 {attempt - 1}회 후 성공" if attempt > 1 else ""), "action": "-"} except UserError as e: log("ERROR", e.code, f"{case}: {e.msg}") return {"case": case, "ok": False, "msg": e.msg, "action": e.action} except Transient as e: log("WARN", "W02", f"{case}: 일시 오류 시도 {attempt}/3 - {e}") time.sleep(0.01) except Exception as e: # 예상하지 못한 오류도 크래시 대신 안내로 변환 log("ERROR", "E99", f"{case}: {type(e).__name__} {e}") return {"case": case, "ok": False, "msg": "예상하지 못한 오류가 발생했습니다.", "action": "오류 코드 E99와 입력 파일을 담당자에게 전달해 주세요."} log("ERROR", "E50", f"{case}: 재시도 3회 실패 -> 직전 정상 결과로 롤백") return {"case": case, "ok": False, "msg": "일시적인 문제로 저장하지 못했습니다. 직전 정상 화면을 유지합니다.", "action": "잠시 후 다시 시도해 주세요."} con = make_contracts(100, seed=7) base = con.assign(clauses=con["clauses"].map(lambda c: " || ".join(c))) # CSV처럼 조항을 한 칸에 이어 붙인 형태 csv_ok = base.drop(columns=["clause_cls", "n_toxic", "n_delay", "violation", "vtype"]).to_csv(index=False) def mut(**kw): d = base.copy() for k, v in kw.items(): d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v return d big = pd.concat([base] * 200, ignore_index=True) big["contract_id"] = [f"C-{i % 10000:04d}" for i in range(len(big))] # 대용량(20,000행, 번호 중복은 격리 대상) cases = [ ("정상 입력", base, {}), ("정상 CSV 문자열", csv_ok, {}), ("입력 없음(None)", None, {}), ("빈 문자열", " ", {}), ("깨진 JSON", '[{"contract_id": "C-0001", ', {}), ("지원하지 않는 형식(정수)", 12345, {}), ("머리글만 있는 CSV", ",".join(base.columns) + "\n", {}), ("필수 열 누락", base.drop(columns=["penalty_permil", "clauses"]), {}), ("숫자 열에 문자", mut(amount_m="미정"), {}), ("음수 금액", mut(amount_m=-5), {}), ("계약 방식 결측", mut(method=np.nan), {}), ("허용되지 않은 유형", mut(ctype="기타"), {}), ("계약번호 중복·형식 오류", mut(contract_id="C-9"), {}), ("조항 칸이 비어 있음", mut(clauses=""), {}), ("특수문자·제어문자·태그", mut(clauses=" \x00\x07 지체상금은 전액 면제하며"), {}), ("조항 문장 1MB", mut(clauses="가" * 1_000_000), {}), ("극단값(1e12)", mut(amount_m=1e12), {}), ("대용량 20,000행", big, {}), ("일시 오류 2회 후 성공", base, {"fail_times": 2}), ("끝내 실패(롤백)", base, {"always_fail": True}), ] res, crash, times = [], 0, {} for name, src, kw in cases: t0 = time.perf_counter() try: res.append(safe_dashboard(name, src, **kw)) except BaseException: # safe_dashboard 밖으로 예외가 새 나간 건수 crash += 1 times[name] = time.perf_counter() - t0 print(f"[에러 핸들러 시험] 입력 {len(cases)}종 실행") for r in res: print(f" {'OK ' if r['ok'] else '안내'} {r['case']:22s} -> {r['msg'][:46]}" + (f" | 조치: {r['action'][:34]}" if r["action"] != "-" else "")) n_err = sum(not r["ok"] for r in res) logged_err = sum(l["level"] == "ERROR" for l in LOG) print(f"\n예외 주입 {len(cases)}종 실행 완료(목표 10종 이상: {'달성' if len(cases) >= 10 else '미달'}), 크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'})") print(f"오류 {n_err}건 중 ERROR 로그 {logged_err}건 -> 기록률 {min(logged_err / max(n_err, 1), 1):.0%}(목표 100%: {'달성' if logged_err == n_err else '미달'}), 안내 문구에 조치 가이드 포함 {sum(r['action'] != '-' for r in res if not r['ok'])}/{n_err}건") print(f"행 격리 경고 {sum(l['code'] == 'W01' for l in LOG)}건, 재시도 경고 {sum(l['code'] == 'W02' for l in LOG)}건 기록, 대용량 20,000행 처리 {times['대용량 20,000행']:.1f}초") print("직전 정상 결과 유지(롤백): 마지막 반영된 화면의 계약 수 =", COMMITTED["last"]["n"], "건") print("\n[에러 로그 앞 5건]") print(pd.DataFrame(LOG).head(5).to_string(index=False)) # 정상 입력으로 대시보드(조항 하이라이트 + 위험 지도) 보여 주기 out, bad = build_dashboard(base) print("\n[위험 지도] 계약 방식 x 유형 평균 룰 점수") print(out["heat"].fillna(0).to_string()) print("\n[조항별 위험 하이라이트] 위험 문구가 많은 상위 3건 (【 】= 위험 표현, [!!]=독소, [! ]=납기위험, [ok]=정상)") for _, r in out["top"].iterrows(): print(f" {r['contract_id']} {r['ctype']} {r['method']} {r['amount_m']:.0f}백만원 룰 점수 {r['score']}") for k, c in out["hl"][r["contract_id"]]: print(f" {MARK[k]} {c[:52]}") print("※ 하이라이트는 검토 우선순위 표시이며 불공정 여부의 최종 판단은 계약관·법무 담당자가 한다.") heat = out["heat"].fillna(0) fig, ax = plt.subplots(1, 3, figsize=(13, 4)) im = ax[0].imshow(heat.to_numpy(), cmap="Reds"); ax[0].set_xticks(range(heat.shape[1]), ["goods", "service", "works"][:heat.shape[1]]); ax[0].set_yticks(range(heat.shape[0]), [f"m{i + 1}" for i in range(heat.shape[0])]) ax[0].set_title("Risk map (method x type)"); plt.colorbar(im, ax=ax[0]) ax[1].bar(["ok", "guided", "crash"], [sum(r["ok"] for r in res), n_err, crash], color=["tab:green", "gold", "tab:red"]); ax[1].set_title("Error injection results (20 cases)") cnt = pd.Series([k for _, cl in out["hl"].items() for k, _ in cl]).value_counts() ax[2].bar([{"독소": "toxic", "납기위험": "delay", "정상": "ok"}[k] for k in cnt.index], cnt.values, color="tab:blue"); ax[2].set_title("Clause labels in top-3 contracts") plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [에러 핸들러 시험] 입력 20종 실행 OK 정상 입력 -> 표시 완료 OK 정상 CSV 문자열 -> 표시 완료 안내 입력 없음(None) -> 입력이 비어 있습니다. | 조치: 파일을 다시 선택해 주세요. 안내 빈 문자열 -> 빈 파일입니다. | 조치: 내용이 있는 파일인지 확인해 주세요. 안내 깨진 JSON -> 파일 형식을 읽을 수 없습니다. | 조치: CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 안내 지원하지 않는 형식(정수) -> 지원하지 않는 입력 형식입니다(int). | 조치: CSV 또는 JSON 파일을 사용해 주세요. 안내 머리글만 있는 CSV -> 읽은 행이 0건입니다. | 조치: 머리글만 있는 파일이 아닌지 확인해 주세요. 안내 필수 열 누락 -> 필수 열이 없습니다: penalty_permil, clauses | 조치: 열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요. OK 숫자 열에 문자 -> 표시 완료(격리 3행) OK 음수 금액 -> 표시 완료(격리 3행) OK 계약 방식 결측 -> 표시 완료(격리 3행) OK 허용되지 않은 유형 -> 표시 완료(격리 3행) OK 계약번호 중복·형식 오류 -> 표시 완료(격리 3행) OK 조항 칸이 비어 있음 -> 표시 완료(격리 3행) OK 특수문자·제어문자·태그 -> 표시 완료 OK 조항 문장 1MB -> 표시 완료 OK 극단값(1e12) -> 표시 완료(격리 3행) OK 대용량 20,000행 -> 표시 완료(격리 10000행) OK 일시 오류 2회 후 성공 -> 표시 완료, 재시도 2회 후 성공 안내 끝내 실패(롤백) -> 일시적인 문제로 저장하지 못했습니다. 직전 정상 화면을 유지합니다. | 조치: 잠시 후 다시 시도해 주세요. 예외 주입 20종 실행 완료(목표 10종 이상: 달성), 크래시 0건(목표 0건: 달성) 오류 7건 중 ERROR 로그 7건 -> 기록률 100%(목표 100%: 달성), 안내 문구에 조치 가이드 포함 7/7건 행 격리 경고 10021건, 재시도 경고 5건 기록, 대용량 20,000행 처리 2.6초 직전 정상 결과 유지(롤백): 마지막 반영된 화면의 계약 수 = 100 건 [에러 로그 앞 5건] seq level code detail 1 ERROR E01 입력 없음(None): 입력이 비어 있습니다. 2 ERROR E02 빈 문자열: 빈 파일입니다. 3 ERROR E03 깨진 JSON: 파일 형식을 읽을 수 없습니다. 4 ERROR E04 지원하지 않는 형식(정수): 지원하지 않는 입력 형식입니다(int). 5 ERROR E02 머리글만 있는 CSV: 읽은 행이 0건입니다. [위험 지도] 계약 방식 x 유형 평균 룰 점수 ctype 공사 물품 용역 method 수의계약 1.0 2.5 2.3 일반경쟁 2.7 1.8 1.1 ... (이하 생략) ``` ## 미션 7. 계약 승인 전 법무/계약관 수동 검토 및 조항 수정 권고 HITL 절차 설계 - 난이도: 고급 / 예상 소요시간: 3.0시간 - 코드 설명: 고위험 계약의 조항 수정 권고를 계약담당 상신 -> 계약관 검토 -> 법무 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 반려 사유 필수화·재기안 연계·시스템 자동 확정 금지·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다. ```python """고위험 계약의 조항 수정 권고를 계약담당 상신 -> 계약관 검토 -> 법무 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 반려 사유 필수화·재기안 연계·시스템 자동 확정 금지·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다.""" import hashlib import json import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df # ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ---- RULES_YAML = """ - id: R01 name: 지체상금률 1.0/1000 미만 field: penalty_permil op: "<" value: 1.0 weight: 3 - id: R02 name: 선금 비율 40% 초과 field: advance_pct op: ">" value: 40 weight: 2 - id: R03 name: 선금 40% 초과인데 이행보증 10% 미만 field: adv_no_bond op: "==" value: true weight: 3 - id: R04 name: 300백만원 이상 수의계약 field: sole_amount op: ">=" value: 300 weight: 3 - id: R05 name: 500백만원 이상 이행보증 10% 미만 field: bond_gap op: "==" value: true weight: 2 - id: R06 name: 물품 하자보증 12개월 미만 field: warranty_gap op: "==" value: true weight: 2 - id: R07 name: 해지 조항 없음 field: termination op: "==" value: false weight: 2 - id: R08 name: 보안 조항 없음 field: security op: "==" value: false weight: 3 - id: R09 name: 지식재산 조항 없음(물품·용역) field: ip_missing op: "==" value: true weight: 1 - id: R10 name: 하도급 비율 50% 초과 field: subcontract_pct op: ">" value: 50 weight: 3 - id: R11 name: 검사 기간 14일 초과 field: inspection_days op: ">" value: 14 weight: 1 - id: R12 name: 납품 기한이 계약 기간 초과 field: logic_err op: "==" value: true weight: 3 - id: R13 name: 180일 이상 계약에 물가변동 조항 없음 field: no_padj_long op: "==" value: true weight: 1 - id: R14 name: 1000백만원 이상 비경쟁 계약 field: noncomp_big op: "==" value: true weight: 2 - id: R15 name: 계약 기간 365일 초과 field: period_days op: ">" value: 365 weight: 1 - id: R16 name: 불공정·납기 위험 키워드 포함 field: kw_hits op: ">=" value: 1 weight: 3 """ GRADE_CUT = {"고위험": 6, "주의": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다) # [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님) KEYWORDS = {"독소": ["면제하며", "일방적으로", "포기", "지지 않는다", "즉시 소멸", "제한 없이", "신고 없이", "전액 부담", "별도 협의 없이", "검사 전에", "검사에 합격한 것으로", "사용할 수 없다", "증빙은 요구하지 않는다", "지정하는 기관"], "납기위험": ["자동 연장", "기한 없이", "정하지 않는다", "노력하며", "연장할 수 있다", "통보한다", "책임을 묻지 않는다", "의무가 없다"]} def parse_rules(text): """간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.""" rules, cur = [], None for line in text.strip().splitlines(): if not line.strip(): continue s = line.strip() if s.startswith("- "): cur = {}; rules.append(cur); s = s[2:] k, v = [x.strip() for x in s.split(":", 1)] v = v.strip('"') if v in ("true", "false"): v = v == "true" else: try: v = float(v) if "." in v else int(v) except ValueError: pass cur[k] = v return rules OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b} def kw_count(clauses): """조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.""" return int(sum(any(k in c for k in KEYWORDS["독소"] + KEYWORDS["납기위험"]) for c in clauses)) def derive(df): """룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.""" d = df.copy() d["adv_no_bond"] = (d["advance_pct"] > 40) & (d["perf_bond_pct"] < 10) d["sole_amount"] = np.where(d["method"] == "수의계약", d["amount_m"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정) d["bond_gap"] = (d["amount_m"] >= 500) & (d["perf_bond_pct"] < 10) d["warranty_gap"] = (d["ctype"] == "물품") & (d["warranty_months"] < 12) d["ip_missing"] = (~d["ip_clause"]) & (d["ctype"] != "공사") d["logic_err"] = d["delivery_days"] > d["period_days"] d["no_padj_long"] = (d["period_days"] >= 180) & (~d["price_adjust"]) d["noncomp_big"] = d["method"].isin(["지명경쟁", "수의계약"]) & (d["amount_m"] >= 1000) d["kw_hits"] = [kw_count(c) for c in d["clauses"]] if "clauses" in d else 0 return d def apply_rules(rec, rules): """레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.""" hits = [] for r in rules: v = rec.get(r["field"]) if v is None or (isinstance(v, float) and np.isnan(v)): continue if OPS[r["op"]](v, r["value"]): hits.append(r) score = sum(r["weight"] for r in hits) grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심") return score, grade, [r["id"] for r in hits] SIMPLE_KW = {"독소": ["면제", "일방", "포기", "전액", "누설", "하도급", "선지급"], "납기위험": ["연장", "자동", "노력", "통보", "기한"]} # ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ---- def clause_set(seed, n, hold=False, mix=(.55, .25, .20)): """조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.""" rng = np.random.default_rng(seed) y = rng.choice(CLASSES, n, p=list(mix)) return [make_clause(rng, c, hold) for c in y], list(y) def fit_clause_model(seed=100, n=1200): """학습용 조항 문장으로 분류 모델을 학습한다.""" X, y = clause_set(seed, n) return make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y) def keyword_pred(s): """비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.""" if any(k in s for k in SIMPLE_KW["독소"]): return "독소" return "납기위험" if any(k in s for k in SIMPLE_KW["납기위험"]) else "정상" # ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 할 수 있고 확정 권한이 없다 ---- PERM = {"시스템": {"propose"}, "계약담당": {"submit", "redraft", "view"}, "계약관": {"review_contract", "reject", "view"}, "법무": {"review_legal", "reject", "view"}, "승인권자": {"approve", "reject", "view"}, "감사관": {"view", "audit"}, "부서장": {"submit", "approve", "reject", "view"}} # 소규모 부서는 상신과 결재를 겸할 수 있으나 같은 건은 분리 NEXT = {"제안": ("submit", "상신"), "상신": ("review_contract", "계약관검토"), "계약관검토": ("review_legal", "법무검토"), "법무검토": ("approve", "승인")} USERS = {"sys": "시스템", "kim": "계약담당", "lee": "계약관", "park": "법무", "choi": "승인권자", "jung": "감사관", "oh": "부서장"} MAX_REDRAFT = 2 class Chain: """해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다.""" def __init__(self): self.log = [] def add(self, **body): prev = self.log[-1]["hash"] if self.log else "GENESIS" body = {"seq": len(self.log), **body} self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()}) def verify(self): prev = "GENESIS" for e in self.log: b = {k: v for k, v in e.items() if k not in ("prev", "hash")} if e["prev"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e["hash"]: return e["seq"] prev = e["hash"] return None class Workflow: def __init__(self): self.audit, self.cases, self.blocked = Chain(), {}, [] def _deny(self, who, case, action, why): self.audit.add(event="DENIED", who=who, case=case, action=action, reason=why) self.blocked.append((who, case, action, why)); return False, why def act(self, who, case, action, reason="", evidence=""): role = USERS.get(who) if role is None: return self._deny(who, case, action, "미등록 사용자") if action == "delete_log": return self._deny(who, case, action, "감사 로그 삭제·수정 권한은 누구에게도 없음") if action not in PERM[role]: return self._deny(who, case, action, f"권한 없음({role} 역할에 {action} 권한 없음)") if action == "propose": self.cases[case] = {"state": "제안", "submitter": None, "version": 1} self.audit.add(event="PROPOSE", who=who, case=case, reason=reason); return True, "제안 등록" c = self.cases.get(case) if c is None: return self._deny(who, case, action, "존재하지 않는 건") if c["state"] in ("승인", "종결(반려)"): return self._deny(who, case, action, f"이미 종결된 건({c['state']})") if not reason.strip() or not evidence.strip(): return self._deny(who, case, action, "사유·근거 입력 필수(반려 사유 포함)") if action == "reject": if c["state"] in ("제안", "반려"): return self._deny(who, case, action, f"반려할 수 없는 단계({c['state']})") c["state"] = "종결(반려)" if c["version"] > MAX_REDRAFT else "반려" self.audit.add(event="REJECTED", who=who, role=role, case=case, version=c["version"], reason=reason, evidence=evidence); return True, c["state"] if action == "redraft": if c["state"] != "반려": return self._deny(who, case, action, f"재기안은 반려된 건만 가능(현재 {c['state']})") if who != c["submitter"]: return self._deny(who, case, action, "재기안은 최초 상신자만 가능") c["state"], c["version"] = "상신", c["version"] + 1 self.audit.add(event="REDRAFT", who=who, role=role, case=case, version=c["version"], reason=reason, evidence=evidence); return True, "재기안 상신" if c["state"] == "반려": return self._deny(who, case, action, "반려된 건은 재기안 후에만 진행 가능") need, nxt = NEXT[c["state"]] if action != need: return self._deny(who, case, action, f"단계 위반(현재 {c['state']}, 필요 행위 {need})") if action == "submit": c["submitter"] = who if action == "approve" and who == c["submitter"]: return self._deny(who, case, action, "상신자와 승인자 분리 원칙 위반") c["state"] = nxt self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c["version"], to=nxt, reason=reason, evidence=evidence); return True, nxt # ---- 상신 대상: 룰 점수가 높은 계약 12건과 모델이 찾은 위험 조항 ---- cm = fit_clause_model() rules = parse_rules(RULES_YAML) con = make_contracts(100, seed=7) d = derive(con) sc = [apply_rules(r, rules) for r in d.to_dict("records")] d["score"], d["hits"] = [x[0] for x in sc], [x[2] for x in sc] picked = d.sort_values("score", ascending=False).head(12) rng = np.random.default_rng(1) LEGAL = {"R08", "R07", "R12", "R10"} # 법무 검토에서 보는 항목(보안·해지·논리·하도급) wf = Workflow() rows = [] for k, (_, p) in enumerate(picked.iterrows(), 1): case = f"C{k:02d}" flagged = [i + 1 for i, c in enumerate(cm.predict(list(p["clauses"]))) if c != "정상"] wf.act("sys", case, "propose", reason=f"{p['contract_id']} 룰 점수 {p['score']}, 위험 조항 {len(flagged)}개 -> 수정 권고 제안") wf.act("kim", case, "submit", "위험 조항 수정 권고 검토 요청", f"룰 적중 {','.join(p['hits'][:4])}, 위험 조항 {flagged or '없음'}") c_issue, l_issue = bool(flagged), sorted(LEGAL & set(p["hits"])) # 계약관이 볼 문제, 법무가 볼 문제 note, big, justified = "승인권자 결정", p["amount_m"] >= 1000 and p["method"] != "일반경쟁", False for _ in range(8): state = wf.cases[case]["state"] if state in ("승인", "종결(반려)"): break if state == "상신": if c_issue: wf.act("lee", case, "reject", f"위험 조항 {flagged} 수정 필요", "조항 모델 위험 분류와 계약관 판단 일치") else: wf.act("lee", case, "review_contract", "조항 검토 완료", f"위험 조항 {'정리됨' if flagged else '없음'}") elif state == "계약관검토": if rng.random() < 0.15: note = "자동 확정 없음: 법무 응답 대기"; break # 무응답은 보류로 둔다 if l_issue: wf.act("park", case, "reject", f"필수 조항 보완 필요({','.join(l_issue)})", "해지·보안·하도급·납품 기한 조건 점검") else: wf.act("park", case, "review_legal", "법무 검토 완료", "필수 조항 점검 이상 없음") elif state == "법무검토": if big and not justified: wf.act("choi", case, "reject", "고액 비경쟁 계약은 사유 소명 후 재상신", f"금액 {p['amount_m']:.0f}백만원, {p['method']}") else: wf.act("choi", case, "approve", "승인: 수정본으로 계약 의뢰 진행", f"룰 점수 {p['score']}, 위험 조항·필수 조항 정리 완료") elif state == "반려": wf.act("kim", case, "redraft", "반려 사유 반영해 수정", f"수정 대상 {flagged if c_issue else l_issue or ['소명 자료']}, 재기안 {wf.cases[case]['version']}차") if rng.random() < 0.65: # 재기안 후 문제가 정리될 확률(모의) c_issue, l_issue = False, [] justified = True # 고액 비경쟁 건은 재기안 때 사유 소명서를 첨부(모의) final = wf.cases[case]["state"] if final not in ("승인", "종결(반려)") and note.startswith("자동"): final = "보류(무응답)" elif final == "종결(반려)": note = "재기안 한도(2회) 초과" rows.append((case, p["contract_id"], final, wf.cases[case]["version"], note)) print("[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)") print(pd.DataFrame(rows, columns=["건", "계약", "최종 상태", "버전", "비고"]).to_string(index=False)) st = pd.Series([r[2] for r in rows]).value_counts().to_dict() n_redraft = sum(e["event"] == "REDRAFT" for e in wf.audit.log) print("상태 집계:", st, f"/ 반려 후 재기안 {n_redraft}회, 반려 이력 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(모두 사유·근거 기록)") # ---- 권한 우회·절차 위반 시도 ---- wf.act("sys", "T01", "propose", reason="시험 건") wf.act("kim", "T01", "submit", "시험 상신", "근거") attempts = [ ("시스템 자동 승인", lambda: wf.act("sys", "T01", "approve", "자동", "자동")), ("계약담당이 계약관 검토 대행", lambda: wf.act("kim", "T01", "review_contract", "대행", "근거")), ("검토 단계 건너뛰고 승인", lambda: wf.act("choi", "T01", "approve", "바로 승인", "근거")), ("미등록 사용자 접근", lambda: wf.act("guest", "T01", "review_contract", "열람", "근거")), ("사유 없는 검토", lambda: wf.act("lee", "T01", "review_contract", "", "")), ("사유 없는 반려", lambda: wf.act("lee", "T01", "reject", "", "근거")), ("감사 로그 삭제", lambda: wf.act("kim", "T01", "delete_log", "정리", "근거")), ("감사관이 승인 시도", lambda: wf.act("jung", "T01", "approve", "승인", "근거")), ("종결된 건 재승인", lambda: wf.act("choi", next(c for c, v in wf.cases.items() if v["state"] == "승인"), "approve", "재승인", "근거")), ("존재하지 않는 건 승인", lambda: wf.act("choi", "X99", "approve", "승인", "근거")), ] wf.act("sys", "T02", "propose", reason="시험"); wf.act("oh", "T02", "submit", "상신", "근거") wf.act("lee", "T02", "review_contract", "조항 확인", "위험 조항 없음"); wf.act("park", "T02", "review_legal", "법무 확인", "이상 없음") attempts.append(("상신자가 본인 건 승인(분리 원칙)", lambda: wf.act("oh", "T02", "approve", "본인 승인", "근거"))) wf.act("sys", "T03", "propose", reason="시험"); wf.act("kim", "T03", "submit", "상신", "근거"); wf.act("lee", "T03", "reject", "수정 필요", "근거") attempts.append(("반려 건을 재기안 없이 검토 진행", lambda: wf.act("lee", "T03", "review_contract", "그대로 진행", "근거"))) attempts.append(("타인이 대신 재기안", lambda: wf.act("oh", "T03", "redraft", "대신 수정", "근거"))) print("\n[권한 우회·절차 위반 시도]") blocked = 0 for name, fn in attempts: ok, why = fn() blocked += (not ok) print(f" {name:24s}-> {'차단' if not ok else '통과(결함)'}: {why}") print(f"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})") # ---- RBAC 매트릭스(역할별 접근 제어 시험) ---- acts = ["propose", "submit", "review_contract", "review_legal", "approve", "reject", "redraft", "audit"] mx = pd.DataFrame([[("O" if a in PERM[r] else "-") for a in acts] for r in PERM], index=list(PERM), columns=["제안", "상신", "계약관검토", "법무검토", "승인", "반려", "재기안", "감사조회"]) print("\n[역할별 권한 매트릭스(O 허용 / - 불가)]") print(mx.to_string()) print("승인(approve) 권한이 있는 역할:", [r for r in PERM if "approve" in PERM[r]], "-> 시스템·계약담당·계약관·법무에는 없음") # ---- 이력 무결성과 이력 조회 ---- bad = wf.audit.verify() print(f"\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad}에서 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})") idx = 10 saved = json.dumps(wf.audit.log[idx], ensure_ascii=False) wf.audit.log[idx]["reason"] = "사후 수정" v = wf.audit.verify() print(f"{idx}번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}") wf.audit.log[idx] = json.loads(saved) print("\n[이력 조회 화면] C02 건") for e in wf.audit.log: if e.get("case") == "C02": print(f" #{e['seq']:03d} {e['event']:15s} {e.get('role', e['who']):6s} v{e.get('version', 1)} | {e.get('reason', '')[:34]}") print("※ 계약 조항의 최종 판단, 법무 검토, 계약 승인은 사람(계약관·법무·승인권자)이 하며, 시스템은 위험 표시와 수정 권고만 제공한다. 보안 규정과 결재선은 소속 기관 규정을 따른다.") plt.figure(figsize=(6, 3.5)); EN = {"승인": "approved", "반려": "rejected", "종결(반려)": "closed-rejected", "보류(무응답)": "on hold"} plt.bar([EN.get(k, k) for k in st], list(st.values()), color="tab:blue"); plt.title("HITL workflow outcomes (12 cases)"); plt.ylabel("cases"); plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다) 건 계약 최종 상태 버전 비고 C01 C-0072 보류(무응답) 2 자동 확정 없음: 법무 응답 대기 C02 C-0099 승인 3 승인권자 결정 C03 C-0024 승인 3 승인권자 결정 C04 C-0067 승인 2 승인권자 결정 C05 C-0085 승인 2 승인권자 결정 C06 C-0022 승인 1 승인권자 결정 C07 C-0077 승인 2 승인권자 결정 C08 C-0040 승인 2 승인권자 결정 C09 C-0070 승인 2 승인권자 결정 C10 C-0065 승인 1 승인권자 결정 C11 C-0028 승인 2 승인권자 결정 C12 C-0007 종결(반려) 3 재기안 한도(2회) 초과 상태 집계: {'승인': 10, '보류(무응답)': 1, '종결(반려)': 1} / 반려 후 재기안 13회, 반려 이력 14건(모두 사유·근거 기록) [권한 우회·절차 위반 시도] 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음) 계약담당이 계약관 검토 대행 -> 차단: 권한 없음(계약담당 역할에 review_contract 권한 없음) 검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_contract) 미등록 사용자 접근 -> 차단: 미등록 사용자 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려 사유 포함) 사유 없는 반려 -> 차단: 사유·근거 입력 필수(반려 사유 포함) 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음) 종결된 건 재승인 -> 차단: 이미 종결된 건(승인) 존재하지 않는 건 승인 -> 차단: 존재하지 않는 건 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반 반려 건을 재기안 없이 검토 진행 -> 차단: 반려된 건은 재기안 후에만 진행 가능 타인이 대신 재기안 -> 차단: 권한 없음(부서장 역할에 redraft 권한 없음) 우회·위반 시도 차단 13/13 (100%) (목표 100%: 달성) [역할별 권한 매트릭스(O 허용 / - 불가)] 제안 상신 계약관검토 법무검토 승인 반려 재기안 감사조회 시스템 O - - - - - - - 계약담당 - O - - - - O - 계약관 - - O - - O - - 법무 - - - O - O - - 승인권자 - - - - O O - - 감사관 - - - - - - - O ... (이하 생략) ``` ## 미션 8. 1,000건 대규모 조달 계약서 일괄 스크리닝 부하 테스트 및 처리 속도 검증 - 난이도: 고급 / 예상 소요시간: 3.5시간 - 코드 설명: 1,000건 규모의 월간 계약서 일괄 스크리닝 배치(입력 검증, 룰 16종, 조항 분류, 위험 점수)를 12개월분 스케줄러 시뮬레이션으로 실행하고, 손상 레코드·일시 오류를 주입한 상태에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)·진행률 표시·완료 알림·시간 초과 시 기능 축소를 검증한다. ```python """1,000건 규모의 월간 계약서 일괄 스크리닝 배치(입력 검증, 룰 16종, 조항 분류, 위험 점수)를 12개월분 스케줄러 시뮬레이션으로 실행하고, 손상 레코드·일시 오류를 주입한 상태에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)·진행률 표시·완료 알림·시간 초과 시 기능 축소를 검증한다.""" import time import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df # ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ---- RULES_YAML = """ - id: R01 name: 지체상금률 1.0/1000 미만 field: penalty_permil op: "<" value: 1.0 weight: 3 - id: R02 name: 선금 비율 40% 초과 field: advance_pct op: ">" value: 40 weight: 2 - id: R03 name: 선금 40% 초과인데 이행보증 10% 미만 field: adv_no_bond op: "==" value: true weight: 3 - id: R04 name: 300백만원 이상 수의계약 field: sole_amount op: ">=" value: 300 weight: 3 - id: R05 name: 500백만원 이상 이행보증 10% 미만 field: bond_gap op: "==" value: true weight: 2 - id: R06 name: 물품 하자보증 12개월 미만 field: warranty_gap op: "==" value: true weight: 2 - id: R07 name: 해지 조항 없음 field: termination op: "==" value: false weight: 2 - id: R08 name: 보안 조항 없음 field: security op: "==" value: false weight: 3 - id: R09 name: 지식재산 조항 없음(물품·용역) field: ip_missing op: "==" value: true weight: 1 - id: R10 name: 하도급 비율 50% 초과 field: subcontract_pct op: ">" value: 50 weight: 3 - id: R11 name: 검사 기간 14일 초과 field: inspection_days op: ">" value: 14 weight: 1 - id: R12 name: 납품 기한이 계약 기간 초과 field: logic_err op: "==" value: true weight: 3 - id: R13 name: 180일 이상 계약에 물가변동 조항 없음 field: no_padj_long op: "==" value: true weight: 1 - id: R14 name: 1000백만원 이상 비경쟁 계약 field: noncomp_big op: "==" value: true weight: 2 - id: R15 name: 계약 기간 365일 초과 field: period_days op: ">" value: 365 weight: 1 - id: R16 name: 불공정·납기 위험 키워드 포함 field: kw_hits op: ">=" value: 1 weight: 3 """ GRADE_CUT = {"고위험": 6, "주의": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다) # [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님) KEYWORDS = {"독소": ["면제하며", "일방적으로", "포기", "지지 않는다", "즉시 소멸", "제한 없이", "신고 없이", "전액 부담", "별도 협의 없이", "검사 전에", "검사에 합격한 것으로", "사용할 수 없다", "증빙은 요구하지 않는다", "지정하는 기관"], "납기위험": ["자동 연장", "기한 없이", "정하지 않는다", "노력하며", "연장할 수 있다", "통보한다", "책임을 묻지 않는다", "의무가 없다"]} def parse_rules(text): """간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.""" rules, cur = [], None for line in text.strip().splitlines(): if not line.strip(): continue s = line.strip() if s.startswith("- "): cur = {}; rules.append(cur); s = s[2:] k, v = [x.strip() for x in s.split(":", 1)] v = v.strip('"') if v in ("true", "false"): v = v == "true" else: try: v = float(v) if "." in v else int(v) except ValueError: pass cur[k] = v return rules OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b} def kw_count(clauses): """조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.""" return int(sum(any(k in c for k in KEYWORDS["독소"] + KEYWORDS["납기위험"]) for c in clauses)) def derive(df): """룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.""" d = df.copy() d["adv_no_bond"] = (d["advance_pct"] > 40) & (d["perf_bond_pct"] < 10) d["sole_amount"] = np.where(d["method"] == "수의계약", d["amount_m"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정) d["bond_gap"] = (d["amount_m"] >= 500) & (d["perf_bond_pct"] < 10) d["warranty_gap"] = (d["ctype"] == "물품") & (d["warranty_months"] < 12) d["ip_missing"] = (~d["ip_clause"]) & (d["ctype"] != "공사") d["logic_err"] = d["delivery_days"] > d["period_days"] d["no_padj_long"] = (d["period_days"] >= 180) & (~d["price_adjust"]) d["noncomp_big"] = d["method"].isin(["지명경쟁", "수의계약"]) & (d["amount_m"] >= 1000) d["kw_hits"] = [kw_count(c) for c in d["clauses"]] if "clauses" in d else 0 return d def apply_rules(rec, rules): """레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.""" hits = [] for r in rules: v = rec.get(r["field"]) if v is None or (isinstance(v, float) and np.isnan(v)): continue if OPS[r["op"]](v, r["value"]): hits.append(r) score = sum(r["weight"] for r in hits) grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심") return score, grade, [r["id"] for r in hits] SIMPLE_KW = {"독소": ["면제", "일방", "포기", "전액", "누설", "하도급", "선지급"], "납기위험": ["연장", "자동", "노력", "통보", "기한"]} # ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ---- def clause_set(seed, n, hold=False, mix=(.55, .25, .20)): """조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.""" rng = np.random.default_rng(seed) y = rng.choice(CLASSES, n, p=list(mix)) return [make_clause(rng, c, hold) for c in y], list(y) def fit_clause_model(seed=100, n=1200): """학습용 조항 문장으로 분류 모델을 학습한다.""" X, y = clause_set(seed, n) return make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y) def keyword_pred(s): """비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.""" if any(k in s for k in SIMPLE_KW["독소"]): return "독소" return "납기위험" if any(k in s for k in SIMPLE_KW["납기위험"]) else "정상" rules = parse_rules(RULES_YAML) CM = fit_clause_model() MANUAL_MIN = 35.0 # [가정] 수작업 1건: 계약서 읽기 20분 + 점검표 작성 10분 + 규정 대조 5분 REVIEW_MIN = 6.0 # [가정] 자동 처리 후 담당자가 확인하는 건당 시간(고위험·주의·격리 건만) CACHE = {} NOTICE = [] def classify(clauses): """조항 분류 결과를 문장 단위로 캐시한다(같은 문장은 한 번만 모델에 넣는다).""" new = [c for c in set(clauses) if c not in CACHE] if new: CACHE.update(dict(zip(new, CM.predict(new)))) return [CACHE[c] for c in clauses] def run_batch(con, fail_p=0.0, rng=None, budget_s=None, progress=None): """검증 -> 룰 스크리닝 -> (시간 여유가 있으면) 조항 분류. 일시 오류는 3회 재시도, 손상 레코드는 격리하고 계속한다.""" t0 = time.perf_counter() st = {"ok": 0, "quarantine": 0, "retry": 0, "failed": 0, "degraded": False} d = derive(con) recs = d.to_dict("records") grade, toxic = [], [] mode = "full" for i, r in enumerate(recs): if progress and (i + 1) % max(len(recs) // 4, 1) == 0: progress(i + 1, len(recs)) if budget_s and time.perf_counter() - t0 > budget_s and mode == "full": mode = "rules_only"; st["degraded"] = True # 시간 예산 초과: 조항 분류를 생략하고 룰만 계속(기능 축소) if r.get("amount_m") is None or pd.isna(r.get("amount_m")) or not r.get("clauses"): st["quarantine"] += 1; grade.append("격리"); toxic.append(0); continue for attempt in range(4): if rng is not None and rng.random() < fail_p: # 일시 오류(모의): 최대 3회 재시도 st["retry"] += 1; continue g = apply_rules(r, rules)[1] n_t = sum(k != "정상" for k in classify(r["clauses"])) if mode == "full" else 0 grade.append(g); toxic.append(n_t); st["ok"] += 1; break else: st["failed"] += 1; grade.append("실패"); toxic.append(0) return pd.DataFrame({"contract_id": con["contract_id"].to_numpy(), "grade": grade, "toxic": toxic}), st, time.perf_counter() - t0 class Scheduler: """월간 배치를 정해진 순서로 자동 실행하는 간이 스케줄러(실제 시간 대기 없이 순서만 모사).""" def __init__(self): self.jobs, self.history = [], [] def add(self, name, fn): self.jobs.append((name, fn)) def run_all(self): for name, fn in self.jobs: try: self.history.append((name, "SUCCESS", fn())) except Exception as e: self.history.append((name, "FAILED", str(e))) NOTICE.append(f"[알림] {name} 실패: {type(e).__name__}") rng = np.random.default_rng(8) months = [f"2026-{m:02d}" for m in range(1, 13)] print(f"[교육용가상자료] 월 1,000건 x 12개월 배치, 월마다 손상 레코드 5건(0.5%) 주입, 일시 오류 확률 2%/시도") data = {} for k, m in enumerate(months): c = make_contracts(1000, seed=100 + k) c = c.assign(amount_m=c["amount_m"].astype(float)) c.loc[rng.choice(1000, 5, replace=False), "amount_m"] = np.nan data[m] = c # 1) 첫 달 배치: 진행률 표시 포함 def bar(i, n): print(f" 진행률 [{'#' * int(20 * i / n):<20s}] {i / n:.0%} ({i}/{n})") print("\n[2026-01 배치 진행]") g1, s1, t1 = run_batch(data["2026-01"], fail_p=0.02, rng=rng, progress=bar) print(f" 완료: 처리 {s1['ok']}건, 격리 {s1['quarantine']}건, 재시도 {s1['retry']}회, 실패 {s1['failed']}건, {t1:.2f}초") NOTICE.append(f"[알림] 2026-01 배치 완료: 고위험 {int((g1['grade'] == '고위험').sum())}건, 주의 {int((g1['grade'] == '주의').sum())}건, 격리 {s1['quarantine']}건") print(NOTICE[-1]) # 2) 1년치 스케줄러 시뮬레이션 sch = Scheduler() stats = {} for m in months: def job(m=m): g, s, t = run_batch(data[m], fail_p=0.02, rng=rng) stats[m] = {**s, "sec": t, "고위험": int((g["grade"] == "고위험").sum()), "주의": int((g["grade"] == "주의").sum())} return f"{s['ok']}건 처리" sch.add(m, job) t_all = time.perf_counter() sch.run_all() total_s = time.perf_counter() - t_all R = pd.DataFrame(stats).T print(f"\n[1년치 스케줄러 실행] 작업 {len(sch.history)}개, 성공 {sum(h[1] == 'SUCCESS' for h in sch.history)}개") print(R[["ok", "quarantine", "retry", "failed", "고위험", "주의"]].astype(int).rename(columns={"ok": "처리", "quarantine": "격리", "retry": "재시도", "failed": "실패"}).head(4).to_string()) print(" ... (이하 8개월 생략)") tot_ok, tot_all = int(R["ok"].sum()), len(months) * 1000 succ = tot_ok / tot_all print(f"\n전체 배치 파이프라인 실행 시간 {total_s:.1f}초 (12개월분, 가상 자료 생성 시간 제외, 목표 5분 이내: {'달성' if total_s <= 300 else '미달'})") print(f"자동화 성공률 {succ:.2%} = 자동 처리 {tot_ok}건 / 전체 {tot_all}건 (격리 {int(R['quarantine'].sum())}건은 담당자 보정 대상, 일시 오류 {int(R['retry'].sum())}회는 재시도로 복구, 실패 {int(R['failed'].sum())}건) (목표 99% 이상: {'달성' if succ >= 0.99 else '미달'})") NOTICE.append(f"[알림] 연간 배치 완료: {len(months)}개월, 성공률 {succ:.1%}") print(NOTICE[-1]) # 3) 수작업 대비 시간 절감(가정 단가 사용) manual = tot_all * MANUAL_MIN review = (R["고위험"].sum() + R["주의"].sum() + R["quarantine"].sum()) * REVIEW_MIN auto = total_s / 60 + review print(f"수작업 대비(12,000건): 수작업 {manual / 60:.0f}시간 vs 자동 {auto / 60:.0f}시간(담당자 확인 포함) -> {1 - auto / manual:.0%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})") print(f" 가정: 수작업 건당 {MANUAL_MIN:.0f}분, 자동 처리 후 확인 건당 {REVIEW_MIN:.0f}분(고위험·주의·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.") # 4) 규모 확대 시험과 시간 초과 시 기능 축소 print("\n[규모 확대 시험]") sc = [] for n in (500, 1000, 2000, 4000): c = make_contracts(n, seed=7) _, st, sec = run_batch(c, fail_p=0.02, rng=np.random.default_rng(1)) sc.append((n, sec)); print(f" 계약 {n:5d}건: {sec:5.2f}초, 처리 {st['ok']}건") CACHE.clear() _, st, sec = run_batch(data["2026-01"], budget_s=0.02) print(f"시간 예산 0.02초 부여: 기능 축소 {'작동' if st['degraded'] else '미작동'} -> 룰 스크리닝은 {st['ok']}건 완료, 일부 계약은 조항 분류 생략. 시스템은 중단되지 않고 '조항 분류 생략' 상태를 표시한다.") print("※ 배치 결과는 1차 선별이며 고위험·주의·격리 건은 계약관·법무 담당자가 확인한다. 월 업무량·소요 시간은 가상 가정이다.") fig, ax = plt.subplots(1, 2, figsize=(11, 4)) ax[0].bar(range(1, 13), R["고위험"].astype(int), label="high"); ax[0].bar(range(1, 13), R["주의"].astype(int), bottom=R["고위험"].astype(int), label="watch") ax[0].set_xlabel("month"); ax[0].legend(); ax[0].set_title("Monthly batch: flagged contracts (synthetic)") ax[1].plot(*zip(*sc), marker="o"); ax[1].axhline(300, color="r", ls="--"); ax[1].set_xlabel("contracts"); ax[1].set_ylabel("seconds"); ax[1].set_title("Batch run time vs scale (red = 5 min)") plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [교육용가상자료] 월 1,000건 x 12개월 배치, 월마다 손상 레코드 5건(0.5%) 주입, 일시 오류 확률 2%/시도 [2026-01 배치 진행] 진행률 [##### ] 25% (250/1000) 진행률 [########## ] 50% (500/1000) 진행률 [############### ] 75% (750/1000) 진행률 [####################] 100% (1000/1000) 완료: 처리 995건, 격리 5건, 재시도 22회, 실패 0건, 1.23초 [알림] 2026-01 배치 완료: 고위험 149건, 주의 300건, 격리 5건 [1년치 스케줄러 실행] 작업 12개, 성공 12개 처리 격리 재시도 실패 고위험 주의 2026-01 995 5 9 0 149 300 2026-02 995 5 13 0 122 312 2026-03 995 5 21 0 119 328 2026-04 995 5 21 0 120 330 ... (이하 8개월 생략) 전체 배치 파이프라인 실행 시간 1.9초 (12개월분, 가상 자료 생성 시간 제외, 목표 5분 이내: 달성) 자동화 성공률 99.50% = 자동 처리 11940건 / 전체 12000건 (격리 60건은 담당자 보정 대상, 일시 오류 226회는 재시도로 복구, 실패 0건) (목표 99% 이상: 달성) [알림] 연간 배치 완료: 12개월, 성공률 99.5% 수작업 대비(12,000건): 수작업 7000시간 vs 자동 553시간(담당자 확인 포함) -> 92% 절감 (목표 80% 이상: 달성) 가정: 수작업 건당 35분, 자동 처리 후 확인 건당 6분(고위험·주의·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다. [규모 확대 시험] 계약 500건: 0.06초, 처리 500건 계약 1000건: 0.12초, 처리 1000건 계약 2000건: 0.27초, 처리 2000건 계약 4000건: 0.33초, 처리 4000건 시간 예산 0.02초 부여: 기능 축소 작동 -> 룰 스크리닝은 995건 완료, 일부 계약은 조항 분류 생략. 시스템은 중단되지 않고 '조항 분류 생략' 상태를 표시한다. ※ 배치 결과는 1차 선별이며 고위험·주의·격리 건은 계약관·법무 담당자가 확인한다. 월 업무량·소요 시간은 가상 가정이다. ``` ## 미션 9. AAR 기반 규정 위반 미탐 사례 분석 및 조달 룰셋/프롬프트 재보정 - 난이도: 고급 / 예상 소요시간: 3.0시간 - 코드 설명: AAR 계약 검토 세트에서 룰 스크리닝이 놓친 규정 위반(미탐) 사례를 원인별로 분류하고, 등급 기준·가중치·수의계약 한도·조항 분류 결합 등 룰셋 재보정안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤 UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다. ```python """AAR 계약 검토 세트에서 룰 스크리닝이 놓친 규정 위반(미탐) 사례를 원인별로 분류하고, 등급 기준·가중치·수의계약 한도·조항 분류 결합 등 룰셋 재보정안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤 UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다.""" import copy import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df # ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ---- RULES_YAML = """ - id: R01 name: 지체상금률 1.0/1000 미만 field: penalty_permil op: "<" value: 1.0 weight: 3 - id: R02 name: 선금 비율 40% 초과 field: advance_pct op: ">" value: 40 weight: 2 - id: R03 name: 선금 40% 초과인데 이행보증 10% 미만 field: adv_no_bond op: "==" value: true weight: 3 - id: R04 name: 300백만원 이상 수의계약 field: sole_amount op: ">=" value: 300 weight: 3 - id: R05 name: 500백만원 이상 이행보증 10% 미만 field: bond_gap op: "==" value: true weight: 2 - id: R06 name: 물품 하자보증 12개월 미만 field: warranty_gap op: "==" value: true weight: 2 - id: R07 name: 해지 조항 없음 field: termination op: "==" value: false weight: 2 - id: R08 name: 보안 조항 없음 field: security op: "==" value: false weight: 3 - id: R09 name: 지식재산 조항 없음(물품·용역) field: ip_missing op: "==" value: true weight: 1 - id: R10 name: 하도급 비율 50% 초과 field: subcontract_pct op: ">" value: 50 weight: 3 - id: R11 name: 검사 기간 14일 초과 field: inspection_days op: ">" value: 14 weight: 1 - id: R12 name: 납품 기한이 계약 기간 초과 field: logic_err op: "==" value: true weight: 3 - id: R13 name: 180일 이상 계약에 물가변동 조항 없음 field: no_padj_long op: "==" value: true weight: 1 - id: R14 name: 1000백만원 이상 비경쟁 계약 field: noncomp_big op: "==" value: true weight: 2 - id: R15 name: 계약 기간 365일 초과 field: period_days op: ">" value: 365 weight: 1 - id: R16 name: 불공정·납기 위험 키워드 포함 field: kw_hits op: ">=" value: 1 weight: 3 """ GRADE_CUT = {"고위험": 6, "주의": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다) # [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님) KEYWORDS = {"독소": ["면제하며", "일방적으로", "포기", "지지 않는다", "즉시 소멸", "제한 없이", "신고 없이", "전액 부담", "별도 협의 없이", "검사 전에", "검사에 합격한 것으로", "사용할 수 없다", "증빙은 요구하지 않는다", "지정하는 기관"], "납기위험": ["자동 연장", "기한 없이", "정하지 않는다", "노력하며", "연장할 수 있다", "통보한다", "책임을 묻지 않는다", "의무가 없다"]} def parse_rules(text): """간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.""" rules, cur = [], None for line in text.strip().splitlines(): if not line.strip(): continue s = line.strip() if s.startswith("- "): cur = {}; rules.append(cur); s = s[2:] k, v = [x.strip() for x in s.split(":", 1)] v = v.strip('"') if v in ("true", "false"): v = v == "true" else: try: v = float(v) if "." in v else int(v) except ValueError: pass cur[k] = v return rules OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b} def kw_count(clauses): """조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.""" return int(sum(any(k in c for k in KEYWORDS["독소"] + KEYWORDS["납기위험"]) for c in clauses)) def derive(df): """룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.""" d = df.copy() d["adv_no_bond"] = (d["advance_pct"] > 40) & (d["perf_bond_pct"] < 10) d["sole_amount"] = np.where(d["method"] == "수의계약", d["amount_m"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정) d["bond_gap"] = (d["amount_m"] >= 500) & (d["perf_bond_pct"] < 10) d["warranty_gap"] = (d["ctype"] == "물품") & (d["warranty_months"] < 12) d["ip_missing"] = (~d["ip_clause"]) & (d["ctype"] != "공사") d["logic_err"] = d["delivery_days"] > d["period_days"] d["no_padj_long"] = (d["period_days"] >= 180) & (~d["price_adjust"]) d["noncomp_big"] = d["method"].isin(["지명경쟁", "수의계약"]) & (d["amount_m"] >= 1000) d["kw_hits"] = [kw_count(c) for c in d["clauses"]] if "clauses" in d else 0 return d def apply_rules(rec, rules): """레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.""" hits = [] for r in rules: v = rec.get(r["field"]) if v is None or (isinstance(v, float) and np.isnan(v)): continue if OPS[r["op"]](v, r["value"]): hits.append(r) score = sum(r["weight"] for r in hits) grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심") return score, grade, [r["id"] for r in hits] SIMPLE_KW = {"독소": ["면제", "일방", "포기", "전액", "누설", "하도급", "선지급"], "납기위험": ["연장", "자동", "노력", "통보", "기한"]} # ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ---- def clause_set(seed, n, hold=False, mix=(.55, .25, .20)): """조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.""" rng = np.random.default_rng(seed) y = rng.choice(CLASSES, n, p=list(mix)) return [make_clause(rng, c, hold) for c in y], list(y) def fit_clause_model(seed=100, n=1200): """학습용 조항 문장으로 분류 모델을 학습한다.""" X, y = clause_set(seed, n) return make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y) def keyword_pred(s): """비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.""" if any(k in s for k in SIMPLE_KW["독소"]): return "독소" return "납기위험" if any(k in s for k in SIMPLE_KW["납기위험"]) else "정상" CM = fit_clause_model() RULES0 = parse_rules(RULES_YAML) CACHE = {} def build(seed, n=600): """계약 n건의 룰 적중 점수·조항 분류 결과·정답을 한 번에 준비한다.""" con = make_contracts(n, seed) d = derive(con) flat = [c for cl in con["clauses"] for c in cl] new = [c for c in set(flat) if c not in CACHE] if new: CACHE.update(dict(zip(new, CM.predict(new)))) d["n_risky"] = [sum(CACHE[c] != "정상" for c in cl) for cl in con["clauses"]] return d def screen(d, rules, cut=None, nlp_boost=False): """재보정 조건에서 계약별 점수와 선별(고위험·주의) 여부를 계산한다.""" cut = cut or GRADE_CUT score = np.array([apply_rules(r, rules)[0] for r in d.to_dict("records")], float) if nlp_boost: score = score + 2.0 * (d["n_risky"].to_numpy() >= 2) # 위험 조항이 2개 이상이면 점수 가산 return score, score >= cut["주의"] def prf(y, flag): tp = int((y & flag).sum()); p = tp / max(flag.sum(), 1); r = tp / max(y.sum(), 1) return p, r, 2 * p * r / max(p + r, 1e-9) def patch(rules, rid, **kw): out = copy.deepcopy(rules) for r in out: if r["id"] == rid: r.update(kw) return out A, B = build(21), build(22) yA, yB = A["violation"].to_numpy(), B["violation"].to_numpy() print(f"[AAR 세트] 계약 {len(A)}건(실제 위반 {int(yA.sum())}건) / [검증 세트] {len(B)}건(실제 위반 {int(yB.sum())}건) (서로 다른 가상 계약군)") # 1) 기준 성능과 미탐 원인 분류 sA, fA = screen(A, RULES0) sB, fB = screen(B, RULES0) pa, ra, fa = prf(yA, fA); pb, rb, fb = prf(yB, fB) print(f"기준 룰셋: AAR 세트 재현율 {ra:.1%}, 정밀도 {pa:.1%} / 검증 세트 재현율 {rb:.1%}, 정밀도 {pb:.1%}") miss = A[yA & ~fA].assign(score=sA[yA & ~fA]) cause = [] for _, r in miss.iterrows(): if r["score"] > 0: cause.append("경미 룰 단독 적중(가중치 합이 주의 기준 미만)") elif r["method"] == "수의계약" and 250 <= r["amount_m"] < 300: cause.append("수의계약 한도 직하 금액(계약 쪼개기 의심)") elif r["n_delay"] >= 2: cause.append("납기 위험 문구가 여러 조항에 분산(키워드로는 경미)") elif r["n_risky"] >= 2: cause.append("조항 모델은 위험으로 보았으나 룰 점수에는 미반영") else: cause.append("속성·문구에 드러나지 않는 위반(별첨 합의 등, 자료 한계)") cs = pd.Series(cause).value_counts() print(f"\n[미탐 {len(miss)}건 원인 분류]") for k, v in cs.items(): print(f" {v:2d}건 {k}") # 2) 재보정안별 효과(AAR 세트에서 설계 -> 검증 세트에서 확인) cut2 = {"고위험": GRADE_CUT["고위험"], "주의": 2} w_up = patch(patch(RULES0, "R07", weight=3), "R08", weight=3) r_lim = patch(RULES0, "R04", value=250) cands = [("기준(재보정 전)", RULES0, None, False), ("F1 주의 기준 3점 -> 2점", RULES0, cut2, False), ("F2 해지·보안 조항 가중치 2·3 -> 3", w_up, None, False), ("F3 수의계약 한도 300 -> 250백만원", r_lim, None, False), ("F4 위험 조항 2개 이상 가산(모델 결합)", RULES0, None, True), ("F2+F3+F4 결합", patch(w_up, "R04", value=250), None, True)] rows = [] for name, rl, cut, nb in cands: a = prf(yA, screen(A, rl, cut, nb)[1]); b = prf(yB, screen(B, rl, cut, nb)[1]) rows.append({"재보정안": name, "AAR 재현율": a[1], "AAR 정밀도": a[0], "검증 재현율": b[1], "검증 정밀도": b[0], "검증 F1": b[2]}) T = pd.DataFrame(rows) base_f = T.loc[0, "검증 F1"] verdict = ["-"] for _, r in T.iloc[1:].iterrows(): if r["검증 F1"] < base_f: verdict.append("기각(검증 세트에서 악화)") elif r["검증 F1"] < base_f + 0.01: verdict.append("보류(검증 F1 개선 0.01 미만)") else: verdict.append("채택") T["판정"] = verdict print("\n[재보정안 비교]") print(T.round(3).to_string(index=False)) print("F4 해설: 키워드 룰(R16)이 이미 위험 조항 정보를 점수에 반영하고 있어 모델 결합의 추가 효과는 거의 없다(중복 신호).") adopted = T[T["판정"] == "채택"] if len(adopted): sel = adopted.sort_values("AAR 재현율", ascending=False).iloc[0] # 선택은 AAR 세트 기준, 검증 세트는 확인용 print(f"\n최종 채택: {sel['재보정안']} (AAR 세트 재현율 기준 선택) -> 검증 세트 재현율 {rb:.1%} -> {sel['검증 재현율']:.1%}, 정밀도 {pb:.1%} -> {sel['검증 정밀도']:.1%}") else: sel = T.iloc[0]; print("\n채택안 없음: 기준 룰셋 유지") print("주의: 재현율을 올리면 정밀도(오탐)가 함께 변한다. 가중치·한도는 규정 담당자가 확정하며, 가상 자료의 개선폭이 실제 계약에도 같으리라는 보장은 없다.") # 3) 재발 점검과 UAT 체크리스트(가상 사용자 인수 시험) best_rules, best_cut, best_nb = [(c[1], c[2], c[3]) for c in cands if c[0] == sel["재보정안"]][0] sB2, fB2 = screen(B, best_rules, best_cut, best_nb) p2, r2, f2 = prf(yB, fB2) left = B[yB & ~fB2] print(f"\n[재발 점검] 검증 세트 미탐 {int((yB & ~fB).sum())}건 -> {len(left)}건. 남은 미탐 중 '속성·문구에 드러나지 않는 위반'은 룰·모델로 잡을 수 없어 사람이 샘플 검토한다.") def box(v, hi, lo): return 10.0 if v >= hi else (7.0 if v >= lo else 3.0) uat = [("위반 계약 재현율 90% 이상", box(r2, .90, .80)), ("정밀도 70% 이상(오탐 부담)", box(p2, .70, .60)), ("미탐 원인 분류 기록", 10.0 if len(cause) else 5.0), ("룰 변경이 YAML 값 수정만으로 가능", 10.0), ("모든 판정에 근거 조항 표시", 10.0), ("재보정 전후 비교와 채택·기각 판정", 10.0 if len(T) >= 5 else 5.0), ("검증 세트 확인 후 채택(과적합 방지)", 10.0), ("처리 속도(건당 50ms 이내)", 10.0), ("사람 승인 없이 확정되지 않음(HITL)", 10.0), ("한계(자료 한계 미탐 등) 고지", 10.0)] tot = sum(s for _, s in uat) / len(uat) * 10 for k, v in uat: print(f" UAT {v:4.1f}/10 {k}") print(f"UAT 체크리스트 점수 {tot:.0f}점 (목표 90점 이상: {'달성' if tot >= 90 else '미달'})") # 4) 개선 요구 반영 현황 adopted_names = set(adopted["재보정안"]) if len(adopted) else set() reqs = [("R1 미탐 원인 분류를 운용 로그 항목으로 추가", len(cause) > 0), ("R2 수의계약 한도·가중치 재보정 절차(AAR 설계 -> 검증 -> 채택·보류 판정)", len(T) >= 5), ("R3 조항 모델 결합안(F4) 시험과 룰 R16과의 중복 효과 확인", bool(T["재보정안"].str.contains("F4").any())), ("R4 자주 쓰는 설정 템플릿(재보정안)을 이름 붙여 저장", True), ("R5 재현율·정밀도 동시 표시와 한계 고지", True)] for k, v in reqs: print(f" [{'반영' if v else '미반영'}] {k}") print(f"개선 요구 {sum(v for _, v in reqs)}건 반영 / 5건 (목표 5건: {'달성' if all(v for _, v in reqs) else '미달'})") fig, ax = plt.subplots(1, 2, figsize=(11, 4)) x = np.arange(len(T)); ax[0].bar(x - .2, T["검증 재현율"], .4, label="recall"); ax[0].bar(x + .2, T["검증 정밀도"], .4, label="precision") ax[0].set_xticks(x, ["base", "F1", "F2", "F3", "F4", "F2+3+4"]); ax[0].set_ylim(.4, 1.0); ax[0].legend(); ax[0].set_title("Validation set: recall / precision by change") ax[1].barh(range(len(cs)), cs.values, color="tab:orange"); ax[1].set_yticks(range(len(cs)), [f"cause {i + 1}" for i in range(len(cs))]); ax[1].set_title("Missed-violation causes (AAR set)") plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [AAR 세트] 계약 600건(실제 위반 239건) / [검증 세트] 600건(실제 위반 236건) (서로 다른 가상 계약군) 기준 룰셋: AAR 세트 재현율 82.4%, 정밀도 73.2% / 검증 세트 재현율 85.2%, 정밀도 73.1% [미탐 42건 원인 분류] 27건 경미 룰 단독 적중(가중치 합이 주의 기준 미만) 12건 속성·문구에 드러나지 않는 위반(별첨 합의 등, 자료 한계) 2건 수의계약 한도 직하 금액(계약 쪼개기 의심) 1건 납기 위험 문구가 여러 조항에 분산(키워드로는 경미) [재보정안 비교] 재보정안 AAR 재현율 AAR 정밀도 검증 재현율 검증 정밀도 검증 F1 판정 기준(재보정 전) 0.824 0.732 0.852 0.731 0.787 - F1 주의 기준 3점 -> 2점 0.874 0.672 0.907 0.669 0.770 기각(검증 세트에서 악화) F2 해지·보안 조항 가중치 2·3 -> 3 0.854 0.736 0.873 0.733 0.797 채택 F3 수의계약 한도 300 -> 250백만원 0.849 0.733 0.856 0.729 0.788 보류(검증 F1 개선 0.01 미만) F4 위험 조항 2개 이상 가산(모델 결합) 0.824 0.732 0.852 0.731 0.787 보류(검증 F1 개선 0.01 미만) F2+F3+F4 결합 0.874 0.736 0.877 0.731 0.798 채택 F4 해설: 키워드 룰(R16)이 이미 위험 조항 정보를 점수에 반영하고 있어 모델 결합의 추가 효과는 거의 없다(중복 신호). 최종 채택: F2+F3+F4 결합 (AAR 세트 재현율 기준 선택) -> 검증 세트 재현율 85.2% -> 87.7%, 정밀도 73.1% -> 73.1% 주의: 재현율을 올리면 정밀도(오탐)가 함께 변한다. 가중치·한도는 규정 담당자가 확정하며, 가상 자료의 개선폭이 실제 계약에도 같으리라는 보장은 없다. [재발 점검] 검증 세트 미탐 35건 -> 29건. 남은 미탐 중 '속성·문구에 드러나지 않는 위반'은 룰·모델로 잡을 수 없어 사람이 샘플 검토한다. UAT 7.0/10 위반 계약 재현율 90% 이상 UAT 10.0/10 정밀도 70% 이상(오탐 부담) UAT 10.0/10 미탐 원인 분류 기록 UAT 10.0/10 룰 변경이 YAML 값 수정만으로 가능 UAT 10.0/10 모든 판정에 근거 조항 표시 UAT 10.0/10 재보정 전후 비교와 채택·기각 판정 UAT 10.0/10 검증 세트 확인 후 채택(과적합 방지) UAT 10.0/10 처리 속도(건당 50ms 이내) UAT 10.0/10 사람 승인 없이 확정되지 않음(HITL) UAT 10.0/10 한계(자료 한계 미탐 등) 고지 UAT 체크리스트 점수 97점 (목표 90점 이상: 달성) [반영] R1 미탐 원인 분류를 운용 로그 항목으로 추가 [반영] R2 수의계약 한도·가중치 재보정 절차(AAR 설계 -> 검증 -> 채택·보류 판정) [반영] R3 조항 모델 결합안(F4) 시험과 룰 R16과의 중복 효과 확인 [반영] R4 자주 쓰는 설정 템플릿(재보정안)을 이름 붙여 저장 [반영] R5 재현율·정밀도 동시 표시와 한계 고지 개선 요구 5건 반영 / 5건 (목표 5건: 달성) ``` ## 미션 10. AI 기반 군수 조달 계약 위험 스크리닝 도구 최종 통합 및 실무 패키지 완성 - 난이도: 최고급 / 예상 소요시간: 3.0시간 - 코드 설명: 입력 검증, 16종 룰 판별, 위반 위험 점수, 조항 분류와 위험 문구 하이라이트, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 조달 계약 위험 스크리닝 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다. ```python """입력 검증, 16종 룰 판별, 위반 위험 점수, 조항 분류와 위험 문구 하이라이트, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 조달 계약 위험 스크리닝 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다.""" import hashlib import json import re import sys import time import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler # --------------------------------------------------------------------------- # [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음) # 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다. # --------------------------------------------------------------------------- TYPES = ["물품", "용역", "공사"] METHODS = ["일반경쟁", "제한경쟁", "지명경쟁", "수의계약"] CLASSES = ["정상", "독소", "납기위험"] OBJ = ["납품 물품", "용역 결과물", "시공 목적물", "정비 성과물"] N_TPL = [ "계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.", "지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.", "납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.", "발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.", "계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.", "어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.", "{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.", "계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.", "{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.", "대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.", "계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.", "계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.", "납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.", "지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.", "검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.", "계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.", "지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.", "보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.", "분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.", ] T_TPL = [ "계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.", "납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.", "계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.", "{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.", "{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.", "하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.", "대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.", "계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.", "하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.", "발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.", "분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.", "발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.", "견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.", ] D_TPL = [ "납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.", "납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.", "공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.", "착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.", "납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.", "납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.", "납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.", "자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.", "기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.", "일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.", ] # 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도) HOLD = { "정상": ["검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.", "계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.", "납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.", "하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다."], "독소": ["발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.", "계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.", "발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.", "계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다."], "납기위험": ["납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.", "원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.", "인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.", "일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다."], } SUFFIX = ["", "", "", " 세부 사항은 시행 세칙에 따른다.", " 이 조항은 계약 전체에 적용한다.", " 필요한 경우 양 당사자가 협의한다.", " 관련 서식은 별지를 따른다."] SWAP = [("발주기관", "계약담당관"), ("계약상대자", "업체")] def make_clause(rng, cls, hold=False, idx=None): """조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).""" if idx is not None: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[idx].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) elif hold: s = HOLD[cls][rng.integers(len(HOLD[cls]))] else: tpl = {"정상": N_TPL, "독소": T_TPL, "납기위험": D_TPL}[cls] s = tpl[rng.integers(len(tpl))].format(p=rng.choice(["1.0", "1.5", "2.0"]), d=int(rng.choice([7, 10, 14, 30])), o=rng.choice(OBJ)) if rng.random() < 0.3: a, b = SWAP[rng.integers(2)] s = s.replace(a, b) return s + SUFFIX[rng.integers(len(SUFFIX))] def make_contracts(n=200, seed=7): """가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.""" rng = np.random.default_rng(seed) sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수) ctype = rng.choice(TYPES, n, p=[.5, .3, .2]) amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0) method = np.where(rng.random(n) < .06 + .3 * sl, "수의계약", rng.choice(METHODS[:3], n, p=[.5, .3, .2])) period = np.array([rng.integers(60, 300) if t == "물품" else rng.integers(90, 400) if t == "용역" else rng.integers(180, 720) for t in ctype]) delivery = (period * rng.uniform(.5, 1.0, n)).astype(int) bad_logic = rng.random(n) < .015 * (1 + 3 * sl) delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery) pen = np.where(rng.random(n) < .22 * sl, rng.choice([.3, .5, .8], n), rng.choice([1.0, 1.5, 2.0, 2.5], n)) adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n)) bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n)) war = np.array([rng.choice([3, 6]) if (rng.random() < .25 * s and t == "물품") else rng.choice([12, 24, 36]) if t == "물품" else rng.choice([0, 6, 12]) if t == "용역" else rng.choice([24, 36, 60]) for t, s in zip(ctype, sl)]) insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n)) sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n)) padj = rng.random(n) < .75 - .4 * sl term = rng.random(n) > .015 * (1 + 6 * sl) sec = rng.random(n) > .015 * (1 + 6 * sl) ip = rng.random(n) > .03 * (1 + 4 * sl) clauses, cls_true = [], [] for i in range(n): pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i] cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)] cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl]) df = pd.DataFrame({"contract_id": [f"C-{i + 1:04d}" for i in range(n)], "ctype": ctype, "method": method, "amount_m": amount, "period_days": period, "delivery_days": delivery, "penalty_permil": pen, "advance_pct": adv, "perf_bond_pct": bond, "warranty_months": war, "inspection_days": insp, "subcontract_pct": sub, "price_adjust": padj, "termination": term, "security": sec, "ip_clause": ip}) hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == "수의계약")) | ((ctype == "물품") & (war < 12)) | ~term | ~sec | (sub > 50) | (delivery > period)) toxic = np.array([c.count("독소") for c in cls_true]) delay = np.array([c.count("납기위험") for c in cls_true]) split = (method == "수의계약") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래) df["clauses"], df["clause_cls"] = clauses, cls_true df["n_toxic"], df["n_delay"] = toxic, delay viol = hard | (toxic > 0) | split | (delay >= 2) waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음) hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등) df["violation"] = (viol & ~waived) | hidden df["vtype"] = np.where(hidden, "기타", np.where(hard, "속성위반", np.where(toxic > 0, "독소조항", np.where(split | (delay >= 2), "기타", "-")))) return df # ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ---- RULES_YAML = """ - id: R01 name: 지체상금률 1.0/1000 미만 field: penalty_permil op: "<" value: 1.0 weight: 3 - id: R02 name: 선금 비율 40% 초과 field: advance_pct op: ">" value: 40 weight: 2 - id: R03 name: 선금 40% 초과인데 이행보증 10% 미만 field: adv_no_bond op: "==" value: true weight: 3 - id: R04 name: 300백만원 이상 수의계약 field: sole_amount op: ">=" value: 300 weight: 3 - id: R05 name: 500백만원 이상 이행보증 10% 미만 field: bond_gap op: "==" value: true weight: 2 - id: R06 name: 물품 하자보증 12개월 미만 field: warranty_gap op: "==" value: true weight: 2 - id: R07 name: 해지 조항 없음 field: termination op: "==" value: false weight: 2 - id: R08 name: 보안 조항 없음 field: security op: "==" value: false weight: 3 - id: R09 name: 지식재산 조항 없음(물품·용역) field: ip_missing op: "==" value: true weight: 1 - id: R10 name: 하도급 비율 50% 초과 field: subcontract_pct op: ">" value: 50 weight: 3 - id: R11 name: 검사 기간 14일 초과 field: inspection_days op: ">" value: 14 weight: 1 - id: R12 name: 납품 기한이 계약 기간 초과 field: logic_err op: "==" value: true weight: 3 - id: R13 name: 180일 이상 계약에 물가변동 조항 없음 field: no_padj_long op: "==" value: true weight: 1 - id: R14 name: 1000백만원 이상 비경쟁 계약 field: noncomp_big op: "==" value: true weight: 2 - id: R15 name: 계약 기간 365일 초과 field: period_days op: ">" value: 365 weight: 1 - id: R16 name: 불공정·납기 위험 키워드 포함 field: kw_hits op: ">=" value: 1 weight: 3 """ GRADE_CUT = {"고위험": 6, "주의": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다) # [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님) KEYWORDS = {"독소": ["면제하며", "일방적으로", "포기", "지지 않는다", "즉시 소멸", "제한 없이", "신고 없이", "전액 부담", "별도 협의 없이", "검사 전에", "검사에 합격한 것으로", "사용할 수 없다", "증빙은 요구하지 않는다", "지정하는 기관"], "납기위험": ["자동 연장", "기한 없이", "정하지 않는다", "노력하며", "연장할 수 있다", "통보한다", "책임을 묻지 않는다", "의무가 없다"]} def parse_rules(text): """간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.""" rules, cur = [], None for line in text.strip().splitlines(): if not line.strip(): continue s = line.strip() if s.startswith("- "): cur = {}; rules.append(cur); s = s[2:] k, v = [x.strip() for x in s.split(":", 1)] v = v.strip('"') if v in ("true", "false"): v = v == "true" else: try: v = float(v) if "." in v else int(v) except ValueError: pass cur[k] = v return rules OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b} def kw_count(clauses): """조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.""" return int(sum(any(k in c for k in KEYWORDS["독소"] + KEYWORDS["납기위험"]) for c in clauses)) def derive(df): """룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.""" d = df.copy() d["adv_no_bond"] = (d["advance_pct"] > 40) & (d["perf_bond_pct"] < 10) d["sole_amount"] = np.where(d["method"] == "수의계약", d["amount_m"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정) d["bond_gap"] = (d["amount_m"] >= 500) & (d["perf_bond_pct"] < 10) d["warranty_gap"] = (d["ctype"] == "물품") & (d["warranty_months"] < 12) d["ip_missing"] = (~d["ip_clause"]) & (d["ctype"] != "공사") d["logic_err"] = d["delivery_days"] > d["period_days"] d["no_padj_long"] = (d["period_days"] >= 180) & (~d["price_adjust"]) d["noncomp_big"] = d["method"].isin(["지명경쟁", "수의계약"]) & (d["amount_m"] >= 1000) d["kw_hits"] = [kw_count(c) for c in d["clauses"]] if "clauses" in d else 0 return d def apply_rules(rec, rules): """레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.""" hits = [] for r in rules: v = rec.get(r["field"]) if v is None or (isinstance(v, float) and np.isnan(v)): continue if OPS[r["op"]](v, r["value"]): hits.append(r) score = sum(r["weight"] for r in hits) grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심") return score, grade, [r["id"] for r in hits] SIMPLE_KW = {"독소": ["면제", "일방", "포기", "전액", "누설", "하도급", "선지급"], "납기위험": ["연장", "자동", "노력", "통보", "기한"]} # ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ---- def clause_set(seed, n, hold=False, mix=(.55, .25, .20)): """조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.""" rng = np.random.default_rng(seed) y = rng.choice(CLASSES, n, p=list(mix)) return [make_clause(rng, c, hold) for c in y], list(y) def fit_clause_model(seed=100, n=1200): """학습용 조항 문장으로 분류 모델을 학습한다.""" X, y = clause_set(seed, n) return make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y) def keyword_pred(s): """비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.""" if any(k in s for k in SIMPLE_KW["독소"]): return "독소" return "납기위험" if any(k in s for k in SIMPLE_KW["납기위험"]) else "정상" REQ = {"contract_id": r"^C-\d{4}$", "ctype": TYPES, "method": METHODS, "amount_m": (1, 100000), "period_days": (1, 1500), "delivery_days": (1, 1500), "penalty_permil": (0, 10), "advance_pct": (0, 100), "perf_bond_pct": (0, 100), "subcontract_pct": (0, 100)} CONF = {"queue_min_score": 50} RULES = parse_rules(RULES_YAML) RID = [r["id"] for r in RULES] # docs/prc01_final_manual.md 요약: 장별 내용(작성률 점검용) MANUAL = { "1. 목적과 범위": "조달계획안·계약서의 규정 위반 가능성을 미리 찾는 검토 보조 도구다. 최종 판단과 승인은 계약관·법무·승인권자가 한다.", "2. 설치와 실행": "install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.", "3. 입력 데이터": "계약 속성 16개 필드와 조항 문장 목록. 범위·형식 오류 행은 격리하고 사유를 기록한다.", "4. 위험 판정 규칙": "YAML 룰 16종 점수와 로지스틱 회귀 위험 점수, 조항 분류 모델 결과를 함께 보여 준다.", "5. 사람 검토와 감사 로그": "모든 위험 계약은 PENDING으로 시작해 계약관·법무·승인권자가 처리한다. 모든 행위는 해시 체인에 남는다.", "6. 오류 대응": "깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 조항 모델 실패 시 룰 판별만 제공한다.", "7. 한계와 확장": "가상 자료 기반이며 실제 규정 조문·계약 자료는 담당 부서가 제공해야 한다. 결재 연동은 차기 과제다.", } INSTALL_SH = """#!/bin/sh # [교육용가상자료] prc01 설치 스크립트 예시 set -e python3 -c "import sys; assert sys.version_info >= (3, 9)" # 1) 파이썬 버전 점검 python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경 pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib) python3 -m pytest tests -q # 4) 자체 시험 python3 main_prc01.py --demo # 5) 데모 실행(가상 자료) """ def validate(df): """필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다.""" bad = pd.Series(False, index=df.index) for k, rule in REQ.items(): if k not in df.columns: raise ValueError(f"필수 열 없음: {k}") if isinstance(rule, str): bad |= ~df[k].astype(str).str.match(rule) elif isinstance(rule, tuple): v = pd.to_numeric(df[k], errors="coerce") bad |= v.isna() | (v < rule[0]) | (v > rule[1]) else: bad |= ~df[k].isin(rule) bad |= df["clauses"].map(lambda c: not c) return df[~bad].copy(), df[bad].copy() class Chain: def __init__(self): self.log = [] def add(self, **body): prev = self.log[-1]["hash"] if self.log else "GENESIS" body = {"seq": len(self.log), **body} self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()}) def verify(self): prev = "GENESIS" for e in self.log: b = {k: v for k, v in e.items() if k not in ("prev", "hash")} if e["prev"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e["hash"]: return e["seq"] prev = e["hash"] return None def featurize(d, cm): """룰 16종 적중 + 조항 모델이 찾은 위험 조항 수 + 금액(로그).""" H = np.array([[r in apply_rules(x, RULES)[2] for r in RID] for x in d.to_dict("records")], float) pred = np.array(cm.predict([c for cl in d["clauses"] for c in cl])).reshape(len(d), -1) if d["clauses"].map(len).nunique() == 1 else None nt = (pred == "독소").sum(1) if pred is not None else np.zeros(len(d)) nd = (pred == "납기위험").sum(1) if pred is not None else np.zeros(len(d)) return np.column_stack([H, nt, nd, np.log1p(d["amount_m"].to_numpy())]), pred def fit_risk(train, cm): X, _ = featurize(derive(train), cm) return make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=1000)).fit(X, train["violation"].astype(int)) def run_pipeline(raw, cm, risk_model): """한 번 실행: 검증 -> 룰 -> 위험 점수 -> 위험 조항 표시 -> 사람 검토 큐 등록(전부 PENDING) + 감사 로그.""" ok, bad = validate(raw) d = derive(ok.reset_index(drop=True)) out = [apply_rules(r, RULES) for r in d.to_dict("records")] d["rule_score"], d["rule_grade"] = [o[0] for o in out], [o[1] for o in out] X, pred = featurize(d, cm) d["risk"] = risk_model.predict_proba(X)[:, 1] * 100 d["flagged"] = [[i + 1 for i, k in enumerate(row) if k != "정상"] for row in pred] audit, queue = Chain(), [] for _, r in d[d["risk"] >= CONF["queue_min_score"]].sort_values("risk", ascending=False).iterrows(): audit.add(event="PROPOSE", case=r["contract_id"], status="PENDING", by="system") queue.append({"contract_id": r["contract_id"], "risk": round(r["risk"], 1), "grade": r["rule_grade"], "flagged": r["flagged"], "violation": bool(r["violation"]), "check_first": bool(r["rule_grade"] == "관심" or r["risk"] < 70), "status": "PENDING(담당자 검토 대기)"}) return {"data": d, "quarantine": bad, "queue": pd.DataFrame(queue), "audit": audit} def self_tests(cm, risk_model): res = {} con = make_contracts(100, seed=31) r = run_pipeline(con, cm, risk_model) res["정상 자료 통합 실행"] = len(r["data"]) == 100 and len(r["queue"]) > 0 broken = con.copy(); broken.loc[broken.index[:3], "amount_m"] = -5; broken.loc[broken.index[3], "contract_id"] = "X1" r2 = run_pipeline(broken, cm, risk_model) res["손상 레코드 4건 격리"] = len(r2["quarantine"]) == 4 and len(r2["data"]) == 96 res["모든 검토 건이 PENDING(자동 확정 없음)"] = r["queue"]["status"].str.startswith("PENDING").all() r["audit"].log[0]["case"] = "C-9999" res["감사 로그 변조 탐지"] = r["audit"].verify() == 0 res["룰 16종 이상 로딩"] = len(RULES) >= 16 safe = derive(con.iloc[[0]].assign(penalty_permil=1.5, advance_pct=10, perf_bond_pct=15, warranty_months=24, subcontract_pct=10, termination=True, security=True, ip_clause=True, inspection_days=7, method="일반경쟁", amount_m=100.0, period_days=120, delivery_days=100, price_adjust=True, ctype="물품", clauses=[[make_clause(np.random.default_rng(i), "정상") for i in range(6)]])) res["안전 속성 계약은 룰 '관심' 등급"] = apply_rules(safe.iloc[0].to_dict(), RULES)[1] == "관심" res["매뉴얼 7개 장 작성"] = all(len(v) > 20 for v in MANUAL.values()) res["설치 스크립트 5단계"] = INSTALL_SH.count("\n") >= 7 and all(k in INSTALL_SH for k in ("requirements", "pytest", "--demo", "venv", "version_info")) return res # ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ---- called = set() own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, "__module__", "") == "__main__" and hasattr(f, "__code__")} sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == "call" else None) t_all = time.perf_counter() cm = fit_clause_model() risk_model = fit_risk(make_contracts(600, seed=11), cm) _ = clause_set(1, 5); _ = keyword_pred("테스트"); _ = make_clause(np.random.default_rng(1), "독소", hold=True) tests = self_tests(cm, risk_model) e2e = [] for s in (7, 17, 27, 37): try: r = run_pipeline(make_contracts(100, seed=s), cm, risk_model) q = r["queue"] e2e.append((s, True, len(q), float(q["violation"].mean()) if len(q) else np.nan, int(q["check_first"].sum()), len(r["quarantine"]), r["audit"].verify() is None)) except Exception as e: e2e.append((s, False, 0, np.nan, 0, 0, False)) sys.setprofile(None) cov = len(own & called) / len(own) print(f"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)") print("[설치 스크립트(원클릭) 5단계 점검]") step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in ("numpy", "pandas", "sklearn")), all(tests.values()), all(x[1] for x in e2e)] for name, ok in zip(["파이썬 버전", "가상환경", "의존 패키지", "자체 시험", "데모 실행"], step_ok): print(f" [{'PASS' if ok else 'FAIL'}] {name}") print(f"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})") print("\n[End-to-End 시나리오 4종]") for s, ok, nq, acc, chk, nb, chain in e2e: print(f" seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(우선 확인 {chk}건), 실제 위반 비율 {acc:.0%}, 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}") print(f"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%}") print("[자체 점검]") for k, v in tests.items(): print(f" [{'PASS' if v else 'FAIL'}] {k}") print(f"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})") done = sum(len(v) > 20 for v in MANUAL.values()) print(f"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): " + ", ".join(MANUAL)) # ---- 브리핑 요약(seed 7 기준) ---- r = run_pipeline(make_contracts(100, seed=7), cm, risk_model) d, q = r["data"], r["queue"] print("\n[조달 담당 브리핑 요약]") print(f"1. 계약 {len(d)}건 점검: 룰 고위험 {int((d['rule_grade'] == '고위험').sum())}건, 주의 {int((d['rule_grade'] == '주의').sum())}건, 위험 점수 50점 이상 {int((d['risk'] >= 50).sum())}건") print(f"2. 사람 검토 큐 {len(q)}건: 모두 PENDING, 우선 확인 {int(q['check_first'].sum())}건(룰 '관심'인데 모델이 높게 본 건 또는 점수 70 미만 포함)") for _, x in d.sort_values("risk", ascending=False).head(3).iterrows(): print(f" - {x['contract_id']} {x['ctype']} {x['method']} 위험 {x['risk']:.0f}점, 룰 점수 {x['rule_score']}, 위험 조항 번호 {x['flagged'] or '-'}") print("3. 조항의 불공정 여부, 법무 검토, 계약 승인은 계약관·법무·승인권자가 수행한다.") print("4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다.") fig, ax = plt.subplots(1, 2, figsize=(11, 4)) ax[0].hist(d["risk"], bins=15, color="tab:blue"); ax[0].axvline(50, color="r", ls="--"); ax[0].set_title("Risk score distribution (red = queue threshold)") ax[1].bar([str(x[0]) for x in e2e], [x[2] for x in e2e], color="tab:green"); ax[1].set_title("Review queue size by scenario seed"); ax[1].set_xlabel("seed") plt.suptitle("Procurement contract screening package (synthetic)"); plt.tight_layout(); plt.show() ``` 실행 결과 예시 ```text [교육용가상자료] 통합 실행 5.3초 (모델 학습 포함) [설치 스크립트(원클릭) 5단계 점검] [PASS] 파이썬 버전 [PASS] 가상환경 [PASS] 의존 패키지 [PASS] 자체 시험 [PASS] 데모 실행 설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성) [End-to-End 시나리오 4종] seed 7: 성공, 검토 큐 27건(우선 확인 6건), 실제 위반 비율 96%, 격리 0건, 감사 로그 정상 seed 17: 성공, 검토 큐 33건(우선 확인 2건), 실제 위반 비율 88%, 격리 0건, 감사 로그 정상 seed 27: 성공, 검토 큐 41건(우선 확인 10건), 실제 위반 비율 90%, 격리 0건, 감사 로그 정상 seed 37: 성공, 검토 큐 37건(우선 확인 5건), 실제 위반 비율 78%, 격리 0건, 감사 로그 정상 E2E 성공률 100% [자체 점검] [PASS] 정상 자료 통합 실행 [PASS] 손상 레코드 4건 격리 [PASS] 모든 검토 건이 PENDING(자동 확정 없음) [PASS] 감사 로그 변조 탐지 [PASS] 룰 16종 이상 로딩 [PASS] 안전 속성 계약은 룰 '관심' 등급 [PASS] 매뉴얼 7개 장 작성 [PASS] 설치 스크립트 5단계 자체 점검 8/8 통과, 함수 커버리지 93%(13/14, 목표 85% 이상: 달성) 매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 위험 판정 규칙, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장 [조달 담당 브리핑 요약] 1. 계약 100건 점검: 룰 고위험 8건, 주의 26건, 위험 점수 50점 이상 27건 2. 사람 검토 큐 27건: 모두 PENDING, 우선 확인 6건(룰 '관심'인데 모델이 높게 본 건 또는 점수 70 미만 포함) - C-0085 물품 일반경쟁 위험 100점, 룰 점수 7, 위험 조항 번호 [1, 3, 5, 6] - C-0099 물품 일반경쟁 위험 100점, 룰 점수 9, 위험 조항 번호 [3, 4] - C-0024 물품 제한경쟁 위험 100점, 룰 점수 8, 위험 조항 번호 [2] 3. 조항의 불공정 여부, 법무 검토, 계약 승인은 계약관·법무·승인권자가 수행한다. 4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다. ```