4524 lines
282 KiB
Markdown
4524 lines
282 KiB
Markdown
# 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="<script>alert(1)</script> \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. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다.
|
||
```
|