5101 lines
373 KiB
Plaintext
5101 lines
373 KiB
Plaintext
{
|
||
"cells": [
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"# 🎯 [PRC01] AI 기반 조달계획안 검증 Tool\n",
|
||
"### Google Colab 실습 환경 (조달·계약·국산화 / Level 3)\n",
|
||
"\n",
|
||
"[](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/PRC01/PRC01_solution.ipynb)\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n",
|
||
"## 📌 1. 프로젝트 개요 및 배경\n",
|
||
"\n",
|
||
"| 항목 | 내용 |\n",
|
||
"| :--- | :--- |\n",
|
||
"| **과정 분류** | 조달·계약·국산화 (Level 3) |\n",
|
||
"| **학습 소요 시간** | 10개 미션 (5일 / 총 27.0시간) |\n",
|
||
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
|
||
"\n",
|
||
"### 📖 프로젝트 목표\n",
|
||
"조달계획안과 계약서는 지체상금률, 선금, 보증, 하도급, 필수 조항 같은 항목이 규정에 맞는지 사람이 한 건씩 읽고 대조해야 해서 검토가 늦어지고 불공정 조항이나 납기 지체 위험 문구를 놓치기 쉽다. 본 프로젝트는 조달계획안과 계약서의 오류와 위험을 검증하는 도구를 만드는 과제로, 조달 업무의 병목과 위험 신호 기준, 조항 수정 대안, 계약 의뢰 근거와 변경 이력, 업무 효과 측정까지 한 흐름으로 구현한다. 교육용 가상 계약 자료로 계약 데이터 스키마 정의, 서식별 자료 정제와 조항 정규화, 룰 기반 규정 준수 판별, 자연어처리 기반 독소·납기 위험 문구 식별, 위험 점수와 신뢰도 엔진, 조항 하이라이트 대시보드와 오류 처리, 계약관·법무 검토 HITL 워크플로우, 1,000건 일괄 스크리닝 부하 시험, AAR 룰셋 재보정, 통합 패키지까지 단계별로 만든다. 최종 목표는 위험 계약을 근거와 함께 먼저 보여 주어 검토 시간을 줄이되, 규정 위반 여부의 판단과 계약 승인은 계약관·법무·승인권자가 하도록 지원하는 도구를 완성하는 것이다.\n",
|
||
"\n",
|
||
"> **⚠️ [교육용 가상 데이터셋 원칙]**\n",
|
||
"> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n",
|
||
"> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n",
|
||
"\n",
|
||
"---\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"# ============================================================================\n",
|
||
"# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n",
|
||
"# ============================================================================\n",
|
||
"!pip install -q numpy pandas matplotlib scipy scikit-learn\n",
|
||
"\n",
|
||
"import os\n",
|
||
"import sys\n",
|
||
"import time\n",
|
||
"import math\n",
|
||
"import json\n",
|
||
"import re\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"import warnings\n",
|
||
"warnings.filterwarnings('ignore')\n",
|
||
"\n",
|
||
"# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n",
|
||
"plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n",
|
||
"plt.rcParams['axes.unicode_minus'] = False\n",
|
||
"\n",
|
||
"print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 01. 국방 조달 계약서, 입찰 공고문 및 방위사업법 규정 데이터 구조 분석\n",
|
||
"**소요시간**: 약 1.5시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"국방 조달 계약서, 입찰 공고문 및 방위사업법 규정 데이터 구조를 분석한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **조달계획안과 계약서(계약 방식·금액·기간·대금·보증 조건, 필수 조항 유무)의 데이터 구조와 입출력 명세를 정의하고, 현행(AS-IS) 조달계획안 검증 업무를 단계로 나누어 병목을 찾는다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **계약 식별, 계약 방식·금액·기간, 지체상금률·선금·이행보증·하자보증, 하도급 비율, 필수 조항 유무를 묶어 필드 16개의 스키마와 검증 규칙 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `docs/prc01_as_is_to_be_spec.md` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 현행 업무(AS-IS)와 개선 업무(TO-BE) 비교의 명확성\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 업무 단계 분해 10단계 이상, 병목 구간 3개소 이상 도출\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: 업무 정의서 및 입출력 데이터 명세 작성 완료\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 실무 프로세스와의 일치성 검증\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"가상 조달계획안·계약서 자료(계약 속성 16개 + 조항 문장)의 스키마와 검증 규칙을 정의하고 오류 주입 시험으로 검출력을 확인한 뒤, 현행(AS-IS) 조달계획안 검증 업무를 12단계로 분해해 병목 구간과 개선(TO-BE) 효과를 비교한다.\"\"\"\n",
|
||
"import re\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"# 1) 스키마 정의: 계약 식별, 계약 방식·금액·기간, 대금·보증 조건, 필수 조항 유무\n",
|
||
"SCHEMA = {\n",
|
||
" \"contract_id\": {\"type\": str, \"pattern\": r\"^C-\\d{4}$\", \"desc\": \"계약(계획안) 번호\"},\n",
|
||
" \"ctype\": {\"type\": str, \"enum\": TYPES, \"desc\": \"계약 유형\"},\n",
|
||
" \"method\": {\"type\": str, \"enum\": METHODS, \"desc\": \"계약 방식\"},\n",
|
||
" \"amount_m\": {\"type\": (int, float), \"min\": 1, \"max\": 100000, \"desc\": \"계약 금액(백만원)\"},\n",
|
||
" \"period_days\": {\"type\": int, \"min\": 1, \"max\": 1500, \"desc\": \"계약 기간(일)\"},\n",
|
||
" \"delivery_days\": {\"type\": int, \"min\": 1, \"max\": 1500, \"desc\": \"납품 기한(착수 후 일수)\"},\n",
|
||
" \"penalty_permil\": {\"type\": (int, float), \"min\": 0, \"max\": 10, \"desc\": \"지체상금률(1/1000)\"},\n",
|
||
" \"advance_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"선금 비율(%)\"},\n",
|
||
" \"perf_bond_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"이행보증 비율(%)\"},\n",
|
||
" \"warranty_months\": {\"type\": int, \"min\": 0, \"max\": 120, \"desc\": \"하자보증 기간(개월)\"},\n",
|
||
" \"inspection_days\": {\"type\": int, \"min\": 1, \"max\": 90, \"desc\": \"검사 기간(일)\"},\n",
|
||
" \"subcontract_pct\": {\"type\": (int, float), \"min\": 0, \"max\": 100, \"desc\": \"하도급 비율(%)\"},\n",
|
||
" \"price_adjust\": {\"type\": bool, \"desc\": \"물가변동 조항 유무\"},\n",
|
||
" \"termination\": {\"type\": bool, \"desc\": \"해지 조항 유무\"},\n",
|
||
" \"security\": {\"type\": bool, \"desc\": \"보안 조항 유무\"},\n",
|
||
" \"ip_clause\": {\"type\": bool, \"desc\": \"지식재산 조항 유무\"},\n",
|
||
"}\n",
|
||
"\n",
|
||
"\n",
|
||
"def check(name, v, r):\n",
|
||
" if v is None or (isinstance(v, float) and np.isnan(v)):\n",
|
||
" return [f\"{name}: 필수 값 누락\"]\n",
|
||
" if not isinstance(v, r[\"type\"]) or (isinstance(v, bool) and r[\"type\"] is not bool):\n",
|
||
" return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n",
|
||
" errs = []\n",
|
||
" if \"enum\" in r and v not in r[\"enum\"]:\n",
|
||
" errs.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n",
|
||
" if \"min\" in r and v < r[\"min\"]:\n",
|
||
" errs.append(f\"{name}: 최소값 {r['min']} 미만\")\n",
|
||
" if \"max\" in r and v > r[\"max\"]:\n",
|
||
" errs.append(f\"{name}: 최대값 {r['max']} 초과\")\n",
|
||
" if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n",
|
||
" errs.append(f\"{name}: 형식 불일치 '{v}'\")\n",
|
||
" return errs\n",
|
||
"\n",
|
||
"\n",
|
||
"def validate(rec):\n",
|
||
" errs = [f\"{k}: 필드 누락\" for k in SCHEMA if k not in rec]\n",
|
||
" for k, r in SCHEMA.items():\n",
|
||
" if k in rec:\n",
|
||
" errs += check(k, rec[k], r)\n",
|
||
" if not errs and rec[\"delivery_days\"] > rec[\"period_days\"]: # 필드 간 논리 규칙\n",
|
||
" errs.append(\"delivery_days: 납품 기한이 계약 기간보다 김\")\n",
|
||
" if not errs and rec[\"method\"] == \"수의계약\" and rec[\"amount_m\"] > 5000:\n",
|
||
" errs.append(\"method: 수의계약 금액이 비정상적으로 큼(5000백만원 초과)\")\n",
|
||
" return errs\n",
|
||
"\n",
|
||
"\n",
|
||
"def to_record(row):\n",
|
||
" return {k: (row[k].item() if hasattr(row[k], \"item\") else row[k]) for k in SCHEMA}\n",
|
||
"\n",
|
||
"\n",
|
||
"con = make_contracts(100, seed=7)\n",
|
||
"records = [to_record(r) for _, r in con.iterrows()]\n",
|
||
"# 납품 기한이 계약 기간보다 긴 건은 '계약서 내용의 결함'이지 자료 형식 오류가 아니므로 형식 검증에서는 따로 센다\n",
|
||
"fmt_ok = sum(not [e for e in validate(r) if \"납품 기한\" not in e] for r in records)\n",
|
||
"logic = sum(any(\"납품 기한\" in e for e in validate(r)) for r in records)\n",
|
||
"print(f\"[스키마 검증] 계약 {len(records)}건 중 형식 통과 {fmt_ok}건, 형식 오류 {len(records) - fmt_ok}건 / 필드 {len(SCHEMA)}개 / 조항 문장 {sum(len(c) for c in con['clauses'])}개\")\n",
|
||
"print(f\"논리 규칙(납품 기한 > 계약 기간) 위반 {logic}건은 '계약 결함'으로 분류해 룰 엔진(미션 3)에서 다룬다\")\n",
|
||
"print(f\"수의계약 {int((con['method'] == '수의계약').sum())}건, 평균 금액 {con['amount_m'].mean():.0f}백만원, 지체상금률 1.0 미만 {int((con['penalty_permil'] < 1.0).sum())}건, 보안 조항 없음 {int((~con['security']).sum())}건\")\n",
|
||
"\n",
|
||
"# 2) 오류 주입 시험: 형식·허용값·범위·자료형·논리 규칙 위반 7종\n",
|
||
"base = records[1]\n",
|
||
"bad = dict(base, contract_id=\"C12\", method=\"협의계약\", amount_m=-5, period_days=\"90\")\n",
|
||
"bad2 = dict(base, delivery_days=base[\"period_days\"] + 30)\n",
|
||
"bad3 = dict(base, security=None)\n",
|
||
"bad4 = {k: v for k, v in base.items() if k != \"penalty_permil\"}\n",
|
||
"print(\"\\n[오류 주입 결과]\")\n",
|
||
"for e in validate(bad) + validate(bad2) + validate(bad3) + validate(bad4):\n",
|
||
" print(\" -\", e)\n",
|
||
"\n",
|
||
"# 3) AS-IS 업무 단계 분해: [교육용가상자료] 조달계획안 검증 30건의 단계별 소요일(작업 + 대기)\n",
|
||
"rng = np.random.default_rng(3)\n",
|
||
"STEPS = [(\"계획안·계약서 초안 접수\", 1, 2), (\"서식·첨부 서류 확인\", 2, 3), (\"사업·예산 부합 검토\", 3, 2),\n",
|
||
" (\"계약 방식 적정성 검토\", 2, 1), (\"조항별 위험 문구 수작업 검토\", 6, 2), (\"규정 준수 점검표 작성\", 4, 1),\n",
|
||
" (\"위험 조항 협의 회의\", 1, 8), (\"계약관·법무 검토 의뢰\", 1, 9), (\"수정 요청·업체 협의\", 3, 7),\n",
|
||
" (\"수정본 재검토\", 3, 2), (\"결재 상신\", 1, 5), (\"계약 의뢰 등록\", 1, 2)]\n",
|
||
"TOBE = {1: (\"서식 자동 점검(미션 2)\", 0.2), 4: (\"위험 문구 자동 식별(미션 4)\", 0.15), 5: (\"룰 엔진 점검표 자동 생성(미션 3)\", 0.1),\n",
|
||
" 6: (\"위험 점수 기반 회의 안건 선별(미션 5)\", 0.3), 9: (\"수정 대조 자동 비교(미션 6)\", 0.4)}\n",
|
||
"rows = []\n",
|
||
"for i, (name, work, wait) in enumerate(STEPS):\n",
|
||
" w = rng.gamma(4, work / 4, 30); q = rng.gamma(2, wait / 2, 30)\n",
|
||
" f = TOBE.get(i, (\"유지(사람 판단)\", 1.0))\n",
|
||
" rows.append({\"단계\": f\"{i + 1:02d}. {name}\", \"작업(일)\": w.mean(), \"대기(일)\": q.mean(), \"AS-IS(일)\": w.mean() + q.mean(),\n",
|
||
" \"TO-BE 방식\": f[0], \"TO-BE(일)\": w.mean() * f[1] + q.mean() * (f[1] if f[1] < 1 else 1.0)})\n",
|
||
"proc = pd.DataFrame(rows)\n",
|
||
"share = proc[\"AS-IS(일)\"] / proc[\"AS-IS(일)\"].sum()\n",
|
||
"proc[\"병목\"] = np.where((share >= share.sort_values().iloc[-3]) | (proc[\"대기(일)\"] / proc[\"AS-IS(일)\"] > 0.75), \"병목\", \"\")\n",
|
||
"print(\"\\n[AS-IS / TO-BE 업무 단계 비교]\")\n",
|
||
"print(proc.round(1).to_string(index=False))\n",
|
||
"n_bn = int((proc[\"병목\"] == \"병목\").sum())\n",
|
||
"asis, tobe = proc[\"AS-IS(일)\"].sum(), proc[\"TO-BE(일)\"].sum()\n",
|
||
"print(f\"\\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})\")\n",
|
||
"print(f\"계획안 1건 검증 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 법무·계약관 검토와 결재는 사람이 수행한다.\")\n",
|
||
"\n",
|
||
"# 4) 입출력 데이터 명세 요약\n",
|
||
"print(\"\\n[입출력 명세] 입력: 계약 속성 16개 필드 + 조항 문장 목록 / 출력: 위반 등급, 적중 룰·근거 조항, 위험 문구 하이라이트, 검토 이력\")\n",
|
||
"print(\"[권한별 요구] 계약 담당: 입력·수정 / 계약관·법무: 검토·의견 / 승인권자: 결재(미션 7에서 구현)\")\n",
|
||
"\n",
|
||
"plt.figure(figsize=(9, 5))\n",
|
||
"y = np.arange(len(proc))\n",
|
||
"plt.barh(y - 0.2, proc[\"AS-IS(일)\"], 0.4, label=\"AS-IS\", color=\"tab:gray\")\n",
|
||
"plt.barh(y + 0.2, proc[\"TO-BE(일)\"], 0.4, label=\"TO-BE\", color=\"tab:blue\")\n",
|
||
"plt.yticks(y, [f\"step {i + 1}{' *' if b else ''}\" for i, b in enumerate(proc[\"병목\"])])\n",
|
||
"plt.gca().invert_yaxis(); plt.xlabel(\"days per case\"); plt.legend()\n",
|
||
"plt.title(\"Procurement plan review: AS-IS vs TO-BE (* = bottleneck, synthetic)\")\n",
|
||
"plt.tight_layout(); plt.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[스키마 검증] 계약 100건 중 형식 통과 100건, 형식 오류 0건 / 필드 16개 / 조항 문장 600개\n",
|
||
"논리 규칙(납품 기한 > 계약 기간) 위반 2건은 '계약 결함'으로 분류해 룰 엔진(미션 3)에서 다룬다\n",
|
||
"수의계약 14건, 평균 금액 273백만원, 지체상금률 1.0 미만 2건, 보안 조항 없음 4건\n",
|
||
"\n",
|
||
"[오류 주입 결과]\n",
|
||
" - contract_id: 형식 불일치 'C12'\n",
|
||
" - method: 허용되지 않은 값 '협의계약'\n",
|
||
" - amount_m: 최소값 1 미만\n",
|
||
" - period_days: 자료형 오류(str)\n",
|
||
" - delivery_days: 납품 기한이 계약 기간보다 김\n",
|
||
" - security: 필수 값 누락\n",
|
||
" - penalty_permil: 필드 누락\n",
|
||
"\n",
|
||
"[AS-IS / TO-BE 업무 단계 비교]\n",
|
||
" 단계 작업(일) 대기(일) AS-IS(일) TO-BE 방식 TO-BE(일) 병목\n",
|
||
" 01. 계획안·계약서 초안 접수 1.0 2.0 3.0 유지(사람 판단) 3.0 \n",
|
||
" 02. 서식·첨부 서류 확인 2.6 2.8 5.4 서식 자동 점검(미션 2) 1.1 \n",
|
||
" 03. 사업·예산 부합 검토 3.1 2.2 5.3 유지(사람 판단) 5.3 \n",
|
||
" 04. 계약 방식 적정성 검토 2.3 1.0 3.3 유지(사람 판단) 3.3 \n",
|
||
"05. 조항별 위험 문구 수작업 검토 7.2 2.2 9.4 위험 문구 자동 식별(미션 4) 1.4 병목\n",
|
||
" 06. 규정 준수 점검표 작성 4.0 1.0 5.1 룰 엔진 점검표 자동 생성(미션 3) 0.5 \n",
|
||
" 07. 위험 조항 협의 회의 0.9 7.5 8.4 위험 점수 기반 회의 안건 선별(미션 5) 2.5 병목\n",
|
||
" 08. 계약관·법무 검토 의뢰 0.9 8.9 9.8 유지(사람 판단) 9.8 병목\n",
|
||
" 09. 수정 요청·업체 협의 3.3 6.7 10.0 유지(사람 판단) 10.0 병목\n",
|
||
" 10. 수정본 재검토 3.1 2.4 5.5 수정 대조 자동 비교(미션 6) 2.2 \n",
|
||
" 11. 결재 상신 0.9 5.7 6.6 유지(사람 판단) 6.6 병목\n",
|
||
" 12. 계약 의뢰 등록 1.0 2.1 3.1 유지(사람 판단) 3.1 \n",
|
||
"\n",
|
||
"업무 단계 12개(목표 10개 이상: 달성), 병목 5개소(목표 3개소 이상: 달성)\n",
|
||
"계획안 1건 검증 소요: AS-IS 75.1일 -> TO-BE 49.0일 (35% 단축 예상). 법무·계약관 검토와 결재는 사람이 수행한다.\n",
|
||
"\n",
|
||
"[입출력 명세] 입력: 계약 속성 16개 필드 + 조항 문장 목록 / 출력: 위반 등급, 적중 룰·근거 조항, 위험 문구 하이라이트, 검토 이력\n",
|
||
"[권한별 요구] 계약 담당: 입력·수정 / 계약관·법무: 검토·의견 / 승인권자: 결재(미션 7에서 구현)\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 02. 계약서 텍스트 파싱, 불공정 조항 키워드 추출 및 조항별 정규화 전처리 모듈 구현\n",
|
||
"**소요시간**: 약 2.0시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"계약서 텍스트 파싱, 불공정 조항 키워드 추출 및 정규화 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **서식이 다른 계약 자료(CSV, 엑셀 내보내기, JSON, 계약서·공문 텍스트)를 하나의 표준 스키마로 바꾸고 불공정 조항 키워드를 추출하는 정규화 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **열 이름 별칭 사전으로 CSV, 탭 구분(엑셀 내보내기), JSON 세 형식을 표준 스키마로 바꾸고, 금액(억원·백만원·만원·원), 기간(일·주·개월·년), 지체상금률(%, 1/1000, 천분의), 선금 표기를 통일 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `src/connectors/prc01_data_loader.py 및 스키마 정의서` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 국방 서식/양식 변경에 대한 유연한 확장성\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 지원 파일 포맷(CSV, Excel, JSON 등) 파싱 성공률 100%\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: 데이터 로더 및 스키마 유효성 검사 모듈 완성\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 다양한 형태의 샘플 군수 문서 대상 파싱 테스트\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"서식이 서로 다른 가상 계약 자료(CSV, 엑셀 내보내기 TSV, JSON, 계약서·공문 텍스트)를 열 이름 별칭 사전과 정규식으로 표준 스키마로 바꾸고, 금액·기간·지체상금률·선금 표기와 조항 문장을 정규화하며 불공정 조항 키워드를 추출하는 전처리 모듈을 구현한다.\"\"\"\n",
|
||
"import csv\n",
|
||
"import io\n",
|
||
"import json\n",
|
||
"import re\n",
|
||
"import pandas as pd\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 서로 다른 형식의 원시 자료 (실제 계약 아님). 금액·기간·비율 표기가 일부러 제각각이다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"RAW_CSV = \"\"\"계약번호,계약방식,계약금액,계약기간,지체상금률,선금\n",
|
||
"C-0101,일반경쟁,1.5억원,90일,0.15%,30%\n",
|
||
"C-0102,수의계약,\"3,200만원\",3개월,1.5/1000,20퍼센트\n",
|
||
"C-0103,제한경쟁,250백만원,1년,1.0‰,0 %\n",
|
||
"C-0104,지명경쟁,\"1,200,000,000원\",180일,천분의 2.0,10%\n",
|
||
"C-0105,수의계약,4800만원,45일,0.05%,60%\n",
|
||
"C-0106,일반경쟁,95백만원,12주,1.5/1000,0%\n",
|
||
"\"\"\"\n",
|
||
"RAW_TSV = \"계약 번호\\t계약 방식\\t총액\\t이행기간\\t지체율\\t선급금\\n\" \\\n",
|
||
" \"C-0107\\t일반경쟁\\t0.8억원\\t60일\\t0.1%\\t10%\\n\" \\\n",
|
||
" \"C-0108\\t수의계약\\t290백만원\\t2개월\\t1.0/1000\\t50퍼센트\\n\" \\\n",
|
||
" \"C-0101\\t일반경쟁\\t150백만원\\t3개월\\t1.5/1000\\t30%\\n\" \\\n",
|
||
" \"C-0109\\t제한경쟁\\t5,000만원\\t1년\\t천분의 1.5\\t0%\\n\" \\\n",
|
||
" \"C-0110\\t지명경쟁\\t2.2억원\\t240일\\t0.2%\\t20%\\n\"\n",
|
||
"RAW_JSON = json.dumps({\"status\": \"OK\", \"data\": [\n",
|
||
" {\"id\": \"C-0111\", \"method\": \"일반경쟁\", \"terms\": {\"amount\": \"75백만원\", \"period\": {\"value\": 4, \"unit\": \"month\"}, \"penalty\": \"1.5/1000\", \"advance\": \"10%\"}},\n",
|
||
" {\"id\": \"C-0112\", \"method\": \"수의계약\", \"terms\": {\"amount\": \"3.4억원\", \"period\": {\"value\": 20, \"unit\": \"week\"}, \"penalty\": \"0.08%\", \"advance\": \"40퍼센트\"}},\n",
|
||
" {\"id\": \"C-0113\", \"method\": \"제한경쟁\", \"terms\": {\"amount\": \"880만원\", \"period\": {\"value\": 30, \"unit\": \"day\"}, \"penalty\": \"2.0‰\", \"advance\": \"0%\"}}]},\n",
|
||
" ensure_ascii=False)\n",
|
||
"DOCS = [\n",
|
||
" \"계약서(가상)\\n제1조(계약금액) 계약금액은 금 150,000,000원으로 한다.\\n제2조(계약기간) 계약기간은 계약일부터 3개월로 한다.\\n\"\n",
|
||
" \"제3조(지체상금) 계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 0.15%를 지체상금으로 부과한다.\\n\"\n",
|
||
" \"제4조(선금) 선금은 계약금액의 30퍼센트 이내에서 지급한다.\\n제5조(검사) 발주기관은 납품 후 7일 이내에 검사한다.\\n\"\n",
|
||
" \"제6조(해지) 계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"수신: 계약담당관\\n제목: 조달계획안 검토 요청\\n1. 계약 개요\\n 가. 계약금액 : 2.5억원\\n 나. 계약기간 : 1년\\n 다. 지체상금률 : 천분의 0.8\\n 라. 선금 : 계약금액의 60 %\\n\"\n",
|
||
" \"2. 주요 조항\\n 가. 납기는 자재 수급 사정에 따라 계약 상대자가 연장할 수 있다.\\n 나. 하도급은 신고 없이 제한 없이 허용한다.\",\n",
|
||
" \"[계약 조건표]\\n계약금액: 320백만원\\n계약기간: 120일\\n지체상금률: 1.5/1000\\n선금: 20%\\n[조항 요약]\\n\"\n",
|
||
" \"(1) 지체상금은 면제 사유가 없는 한 부과한다.\\n(2) 납품 즉시 검사에 합격한 것으로 본다.\",\n",
|
||
" \"CONTRACT SUMMARY (virtual)\\nContract Amount: KRW 95 million\\nPeriod: 6 months\\nLate Penalty: 1.0 permil\\nAdvance Payment: 10 percent\\n\"\n",
|
||
" \"Clause: 지체상금은 전액 면제하며 발주기관은 이의를 제기하지 못한다.\",\n",
|
||
" \"계약 1건 요약 - 금액 4,800만원 / 기간 45일 / 지체상금 1.0‰ / 선금 0% / 비고: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
"]\n",
|
||
"\n",
|
||
"# ---- 정규화 규칙(서식이 바뀌면 이 사전만 고치면 된다) ----\n",
|
||
"COL_ALIAS = {\"contract_id\": [\"계약번호\", \"계약 번호\", \"id\"], \"method\": [\"계약방식\", \"계약 방식\", \"method\"], \"amount\": [\"계약금액\", \"총액\", \"amount\"],\n",
|
||
" \"period\": [\"계약기간\", \"이행기간\", \"period\"], \"penalty\": [\"지체상금률\", \"지체율\", \"penalty\"], \"advance\": [\"선금\", \"선급금\", \"advance\"]}\n",
|
||
"UNIT_DAYS = {\"일\": 1, \"day\": 1, \"주\": 7, \"week\": 7, \"개월\": 30, \"month\": 30, \"months\": 30, \"년\": 365, \"year\": 365}\n",
|
||
"KW = {\"독소\": [\"면제\", \"일방적\", \"신고 없이\", \"제한 없이\", \"전액 부담\", \"포기\", \"누설\", \"지지 않는다\"], \"납기위험\": [\"자동 연장\", \"기한 없이\", \"연장할 수 있다\", \"정하지 않는다\", \"통보한다\"]}\n",
|
||
"# 서식별 추출 규칙(비정형 서식 보완 시 여기에 정규식을 한 줄 추가한다)\n",
|
||
"DOC_PAT = {\"amount\": [r\"(?:계약금액|금액|Contract Amount)\\s*(?:은|는)?\\s*[::]?\\s*(?:금\\s*|KRW\\s*)?([\\d,\\.]+\\s*(?:억원|백만원|만원|million|원))\"],\n",
|
||
" \"period\": [r\"(?:계약기간|기간|Period)\\s*(?:은|는)?\\s*[::]?\\s*(?:계약일부터\\s*)?(\\d+\\s*(?:일|개월|년|months?|days?))\"],\n",
|
||
" \"advance\": [r\"(?:선금|Advance Payment)[^\\n/]*?([\\d.]+\\s*(?:%|퍼센트|percent))\"]}\n",
|
||
"PEN_PAT = [r\"천분의\\s*([\\d.]+)\", r\"([\\d.]+)\\s*%\", r\"([\\d.]+)\\s*(?:/1000|‰|permil)\"]\n",
|
||
"CLAUSE_PAT = [r\"^제\\d+조\\([^)]*\\)\\s*(.+)$\", r\"^\\s*[가-라]\\.\\s*(.+다\\.)$\", r\"^\\(\\d+\\)\\s*(.+)$\", r\"^Clause:\\s*(.+)$\", r\"비고:\\s*(.+)$\"]\n",
|
||
"\n",
|
||
"\n",
|
||
"def norm_amount(s):\n",
|
||
" \"\"\"금액 표기를 백만원 단위 숫자로 통일: 1.5억원, 250백만원, 3,200만원, 1,200,000,000원, KRW 95 million.\"\"\"\n",
|
||
" m = re.match(r\"^\\s*([\\d,\\.]+)\\s*(억원|백만원|만원|million|원)\\s*$\", str(s))\n",
|
||
" if not m:\n",
|
||
" raise ValueError(f\"금액 해석 불가: {s}\")\n",
|
||
" v = float(m.group(1).replace(\",\", \"\"))\n",
|
||
" return {\"억원\": v * 100, \"백만원\": v, \"만원\": v / 100, \"million\": v, \"원\": v / 1e6}[m.group(2)]\n",
|
||
"\n",
|
||
"\n",
|
||
"def norm_period(v):\n",
|
||
" if isinstance(v, dict):\n",
|
||
" return int(v[\"value\"] * UNIT_DAYS[v[\"unit\"]])\n",
|
||
" m = re.match(r\"^\\s*(\\d+)\\s*(일|주|개월|년|months?|days?)?\\s*$\", str(v))\n",
|
||
" if not m:\n",
|
||
" raise ValueError(f\"기간 해석 불가: {v}\")\n",
|
||
" u = m.group(2) or \"일\"\n",
|
||
" return int(m.group(1)) * UNIT_DAYS[u.rstrip(\"s\") if u.endswith(\"s\") else u]\n",
|
||
"\n",
|
||
"\n",
|
||
"def norm_rate(s, per_mille=False):\n",
|
||
" \"\"\"비율 표기를 통일: 지체상금률은 1/1000(천분율) 단위, 선금은 % 단위.\"\"\"\n",
|
||
" s = str(s).strip()\n",
|
||
" for pat, k in [(r\"^천분의\\s*([\\d.]+)$\", 1), (r\"^([\\d.]+)\\s*(?:/1000|‰|permil)$\", 1), (r\"^([\\d.]+)\\s*(?:%|퍼센트|percent)$\", 10 if per_mille else 1)]:\n",
|
||
" m = re.match(pat, s)\n",
|
||
" if m:\n",
|
||
" return round(float(m.group(1)) * k, 3)\n",
|
||
" raise ValueError(f\"비율 해석 불가: {s}\")\n",
|
||
"\n",
|
||
"\n",
|
||
"def pick(row, key):\n",
|
||
" for k in COL_ALIAS[key]:\n",
|
||
" if k in row:\n",
|
||
" return row[k]\n",
|
||
" raise KeyError(f\"필수 열 없음: {key}\")\n",
|
||
"\n",
|
||
"\n",
|
||
"def to_std(row, src):\n",
|
||
" return {\"contract_id\": str(pick(row, \"contract_id\")).strip(), \"method\": str(pick(row, \"method\")).strip(), \"amount_m\": round(norm_amount(pick(row, \"amount\")), 2),\n",
|
||
" \"period_days\": norm_period(pick(row, \"period\")), \"penalty_permil\": norm_rate(pick(row, \"penalty\"), True), \"advance_pct\": norm_rate(pick(row, \"advance\")), \"source\": src}\n",
|
||
"\n",
|
||
"\n",
|
||
"def load(text, fmt):\n",
|
||
" \"\"\"형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다.\"\"\"\n",
|
||
" if fmt == \"json\":\n",
|
||
" rows = [{\"id\": r[\"id\"], \"method\": r[\"method\"], \"amount\": r[\"terms\"][\"amount\"], \"period\": r[\"terms\"][\"period\"], \"penalty\": r[\"terms\"][\"penalty\"],\n",
|
||
" \"advance\": r[\"terms\"][\"advance\"]} for r in json.loads(text)[\"data\"]]\n",
|
||
" else:\n",
|
||
" rows = list(csv.DictReader(io.StringIO(text), delimiter=\"\\t\" if fmt == \"tsv\" else \",\"))\n",
|
||
" ok, fail = [], []\n",
|
||
" for r in rows:\n",
|
||
" try:\n",
|
||
" ok.append(to_std(r, fmt))\n",
|
||
" except (KeyError, ValueError) as e:\n",
|
||
" fail.append((r, str(e)))\n",
|
||
" return ok, fail\n",
|
||
"\n",
|
||
"\n",
|
||
"def norm_clause(s):\n",
|
||
" \"\"\"조항 문장 정규화: 띄어쓰기·호칭 통일, 번호 제거.\"\"\"\n",
|
||
" s = re.sub(r\"^\\s*(?:제\\d+조\\([^)]*\\)|\\(\\d+\\)|[가-라]\\.)\\s*\", \"\", s)\n",
|
||
" s = s.replace(\"계약 상대자\", \"계약상대자\").replace(\"발주 기관\", \"발주기관\").replace(\"계약담당관\", \"발주기관\")\n",
|
||
" return re.sub(r\"\\s+\", \" \", s).strip()\n",
|
||
"\n",
|
||
"\n",
|
||
"def parse_doc(text):\n",
|
||
" \"\"\"계약서·공문 텍스트 1건에서 금액·기간·지체상금률·선금과 조항 문장을 추출한다.\"\"\"\n",
|
||
" out = {}\n",
|
||
" for key, pats in DOC_PAT.items():\n",
|
||
" for p in pats:\n",
|
||
" m = re.search(p, text)\n",
|
||
" if m:\n",
|
||
" out[key] = m.group(1); break\n",
|
||
" else:\n",
|
||
" raise ValueError(f\"필수 항목({key}) 추출 실패\")\n",
|
||
" pen = None\n",
|
||
" for line in re.split(r\"\\n| / \", text):\n",
|
||
" if re.search(r\"지체상금|Late Penalty\", line):\n",
|
||
" for p in PEN_PAT:\n",
|
||
" m = re.search(p, line)\n",
|
||
" if m:\n",
|
||
" unit = \"%\" if p == PEN_PAT[1] else \"\"\n",
|
||
" pen = (m.group(1) + unit) if unit else ((\"천분의 \" + m.group(1)) if p == PEN_PAT[0] else m.group(0)); break\n",
|
||
" if pen:\n",
|
||
" break\n",
|
||
" if not pen:\n",
|
||
" raise ValueError(\"필수 항목(지체상금률) 추출 실패\")\n",
|
||
" clauses = []\n",
|
||
" for line in re.split(r\"\\n| / \", text):\n",
|
||
" for p in CLAUSE_PAT:\n",
|
||
" m = re.search(p, line.strip())\n",
|
||
" if m:\n",
|
||
" c = norm_clause(m.group(1) if p != CLAUSE_PAT[2] else line)\n",
|
||
" if c.endswith((\"다.\", \"한다\")) or \"다.\" in c:\n",
|
||
" clauses.append(c)\n",
|
||
" break\n",
|
||
" return {\"amount_m\": round(norm_amount(out[\"amount\"].replace(\"KRW\", \"\").strip()), 2), \"period_days\": norm_period(out[\"period\"]),\n",
|
||
" \"penalty_permil\": norm_rate(pen, True), \"advance_pct\": norm_rate(out[\"advance\"].replace(\"percent\", \"퍼센트\")), \"clauses\": clauses}\n",
|
||
"\n",
|
||
"\n",
|
||
"def extract_keywords(clause):\n",
|
||
" return [f\"{c}:{k}\" for c, ks in KW.items() for k in ks if k in clause]\n",
|
||
"\n",
|
||
"\n",
|
||
"# 1) 형식별 파싱\n",
|
||
"results, total, success = [], 0, 0\n",
|
||
"for fmt, text in [(\"csv\", RAW_CSV), (\"tsv\", RAW_TSV), (\"json\", RAW_JSON)]:\n",
|
||
" ok, fail = load(text, fmt)\n",
|
||
" results += ok; total += len(ok) + len(fail); success += len(ok)\n",
|
||
" print(f\"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공\" + (f\", 실패 {[e for _, e in fail]}\" if fail else \"\"))\n",
|
||
"docs, d_fail = [], 0\n",
|
||
"for i, t in enumerate(DOCS, 1):\n",
|
||
" try:\n",
|
||
" docs.append(parse_doc(t))\n",
|
||
" except ValueError as e:\n",
|
||
" d_fail += 1; print(f\"문서 {i} 파싱 실패:\", e)\n",
|
||
"total += len(DOCS); success += len(docs)\n",
|
||
"print(f\"[계약서·공문 텍스트] {len(DOCS)}건 중 {len(docs)}건 성공\")\n",
|
||
"rate = success / total\n",
|
||
"print(f\"\\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}\")\n",
|
||
"\n",
|
||
"# 2) 정제 결과(표기 통일, 중복 계약 정리)\n",
|
||
"std = pd.DataFrame(results)\n",
|
||
"before = len(std)\n",
|
||
"dup = std[std.duplicated(\"contract_id\", keep=False)].sort_values(\"contract_id\")\n",
|
||
"same = dup.drop(columns=\"source\").drop_duplicates().shape[0] == dup[\"contract_id\"].nunique() if len(dup) else True\n",
|
||
"std = std.drop_duplicates(\"contract_id\").sort_values(\"contract_id\")\n",
|
||
"print(f\"\\n[정제된 계약 자료] {before}행 -> 중복 계약 정리 후 {len(std)}행 (중복 {before - len(std)}건: 표기는 달랐지만 정규화 후 값이 {'동일' if same else '불일치'})\")\n",
|
||
"print(std.to_string(index=False))\n",
|
||
"\n",
|
||
"# 3) 계약서 텍스트 파싱 결과와 불공정 조항 키워드\n",
|
||
"print(\"\\n[계약서·공문 파싱 결과]\")\n",
|
||
"for i, d in enumerate(docs, 1):\n",
|
||
" print(f\" 문서 {i}: 금액 {d['amount_m']:g}백만원, 기간 {d['period_days']}일, 지체상금률 {d['penalty_permil']:g}/1000, 선금 {d['advance_pct']:g}%, 조항 {len(d['clauses'])}개\")\n",
|
||
"print(\"\\n[조항별 정규화와 불공정·납기 위험 키워드]\")\n",
|
||
"n_kw = 0\n",
|
||
"for i, d in enumerate(docs, 1):\n",
|
||
" for c in d[\"clauses\"]:\n",
|
||
" kws = extract_keywords(c)\n",
|
||
" if kws:\n",
|
||
" n_kw += 1; print(f\" 문서 {i}: {c[:38]}... -> {', '.join(kws)}\")\n",
|
||
"print(f\"키워드가 걸린 조항 {n_kw}개 (키워드는 후보 표시이며 실제 불공정 여부는 계약관이 판단한다. 예: 문서 3의 '면제'는 '면제 사유가 없는 한 부과'라는 정상 문장의 오탐 후보 -> 미션 4에서 문맥 모델로 보완)\")\n",
|
||
"\n",
|
||
"# 4) 서식 변경 대응 시험 ①: 열 이름이 바뀐 새 양식 -> 별칭 사전 추가\n",
|
||
"new_form = \"계약 식별번호,방식,대금 총액,수행기간,지체상금 비율,선급\\nC-0121,일반경쟁,120백만원,90일,1.5/1000,10%\\n\"\n",
|
||
"ok, fail = load(new_form, \"csv\")\n",
|
||
"print(f\"\\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}\")\n",
|
||
"for k, alias in {\"contract_id\": \"계약 식별번호\", \"method\": \"방식\", \"amount\": \"대금 총액\", \"period\": \"수행기간\", \"penalty\": \"지체상금 비율\", \"advance\": \"선급\"}.items():\n",
|
||
" COL_ALIAS[k].append(alias)\n",
|
||
"ok, fail = load(new_form, \"csv\")\n",
|
||
"print(f\"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)\")\n",
|
||
"\n",
|
||
"# 5) 서식 변경 대응 시험 ②: 비정형 공문의 표현이 달라진 경우 -> 정규식 한 줄 추가\n",
|
||
"new_doc = \"수신: 계약담당관\\n1. 대금 총액 : 1.2억원\\n2. 계약기간 : 6개월\\n3. 지체상금률 : 1.0/1000\\n4. 선금 : 계약금액의 10%\\n5. 조항: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\"\n",
|
||
"try:\n",
|
||
" parse_doc(new_doc); r1 = \"성공\"\n",
|
||
"except ValueError as e:\n",
|
||
" r1 = f\"실패({e})\"\n",
|
||
"DOC_PAT[\"amount\"].append(r\"(?:대금 총액)\\s*[::]?\\s*([\\d,\\.]+\\s*(?:억원|백만원|만원|원))\")\n",
|
||
"d = parse_doc(new_doc)\n",
|
||
"print(f\"새 공문 표현('대금 총액') 1차 시도: {r1} -> 규칙 1줄 추가 후 금액 {d['amount_m']:g}백만원 추출 성공\")\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[CSV ] 6행 중 6행 성공\n",
|
||
"[TSV ] 5행 중 5행 성공\n",
|
||
"[JSON] 3행 중 3행 성공\n",
|
||
"[계약서·공문 텍스트] 5건 중 5건 성공\n",
|
||
"\n",
|
||
"전체 파싱 성공률 100% (19/19) - 목표 100%: 달성\n",
|
||
"\n",
|
||
"[정제된 계약 자료] 14행 -> 중복 계약 정리 후 13행 (중복 1건: 표기는 달랐지만 정규화 후 값이 동일)\n",
|
||
"contract_id method amount_m period_days penalty_permil advance_pct source\n",
|
||
" C-0101 일반경쟁 150.0 90 1.5 30.0 csv\n",
|
||
" C-0102 수의계약 32.0 90 1.5 20.0 csv\n",
|
||
" C-0103 제한경쟁 250.0 365 1.0 0.0 csv\n",
|
||
" C-0104 지명경쟁 1200.0 180 2.0 10.0 csv\n",
|
||
" C-0105 수의계약 48.0 45 0.5 60.0 csv\n",
|
||
" C-0106 일반경쟁 95.0 84 1.5 0.0 csv\n",
|
||
" C-0107 일반경쟁 80.0 60 1.0 10.0 tsv\n",
|
||
" C-0108 수의계약 290.0 60 1.0 50.0 tsv\n",
|
||
" C-0109 제한경쟁 50.0 365 1.5 0.0 tsv\n",
|
||
" C-0110 지명경쟁 220.0 240 2.0 20.0 tsv\n",
|
||
" C-0111 일반경쟁 75.0 120 1.5 10.0 json\n",
|
||
" C-0112 수의계약 340.0 140 0.8 40.0 json\n",
|
||
" C-0113 제한경쟁 8.8 30 2.0 0.0 json\n",
|
||
"\n",
|
||
"[계약서·공문 파싱 결과]\n",
|
||
" 문서 1: 금액 150백만원, 기간 90일, 지체상금률 1.5/1000, 선금 30%, 조항 6개\n",
|
||
" 문서 2: 금액 250백만원, 기간 365일, 지체상금률 0.8/1000, 선금 60%, 조항 2개\n",
|
||
" 문서 3: 금액 320백만원, 기간 120일, 지체상금률 1.5/1000, 선금 20%, 조항 2개\n",
|
||
" 문서 4: 금액 95백만원, 기간 180일, 지체상금률 1/1000, 선금 10%, 조항 1개\n",
|
||
" 문서 5: 금액 48백만원, 기간 45일, 지체상금률 1/1000, 선금 0%, 조항 1개\n",
|
||
"\n",
|
||
"[조항별 정규화와 불공정·납기 위험 키워드]\n",
|
||
" 문서 1: 계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 ... -> 독소:일방적\n",
|
||
" 문서 2: 납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.... -> 납기위험:연장할 수 있다\n",
|
||
" 문서 2: 하도급은 신고 없이 제한 없이 허용한다.... -> 독소:신고 없이, 독소:제한 없이\n",
|
||
" 문서 3: 지체상금은 면제 사유가 없는 한 부과한다.... -> 독소:면제\n",
|
||
" 문서 4: 지체상금은 전액 면제하며 발주기관은 이의를 제기하지 못한다.... -> 독소:면제\n",
|
||
" 문서 5: 납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.... -> 납기위험:자동 연장, 납기위험:기한 없이\n",
|
||
"키워드가 걸린 조항 6개 (키워드는 후보 표시이며 실제 불공정 여부는 계약관이 판단한다. 예: 문서 3의 '면제'는 '면제 사유가 없는 한 부과'라는 정상 문장의 오탐 후보 -> 미션 4에서 문맥 모델로 보완)\n",
|
||
"\n",
|
||
"새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: contract_id'\n",
|
||
"... (이하 생략)\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 03. 방위사업 계약 필수 규정 준수 여부 자동 판별 1차 Rule 엔진 구현\n",
|
||
"**소요시간**: 약 2.5시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"방위사업 계약 필수 규정 준수 여부 판별 Rule 엔진을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **방위사업 계약 필수 규정(가상 지침)의 준수 여부를 판별하는 룰 15종 이상을 YAML 설정으로 코딩하고 계약별 1차 판별 등급을 산출한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **지체상금률 하한, 선금 한도와 보증, 수의계약 한도, 이행보증, 하자보증, 해지·보안·지식재산 조항 유무, 하도급 비율, 검사 기간, 납품 기한 논리, 물가변동 조항, 장기 계약, 불공정 키워드 등 16종 룰을 가중치 합으로 고위험·주의·관심 등급에 연결 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `src/engine/prc01_rule_engine.py` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 군 규정 및 지침 조항과의 정확한 일치성\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 업무 검증 룰 15종 이상 코딩, 룰 처리 지연시간 50ms 이하\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: 룰 엔진 단위 테스트 100% 통과\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 경계 조건(Boundary condition)에 대한 룰 판정 검증\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"계약 필수 규정 준수 여부를 판별하는 16종 룰(YAML 설정)을 코딩해 계약별 1차 판별 등급을 산출하고, 경계값 단위 시험 32건, 룰 처리 지연시간, 룰별 근거 조항 연결, 등급별 실제 위반율·재현율, 규정 변경 시 설정만 바꾸는 확장성을 확인한다.\"\"\"\n",
|
||
"import time\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n",
|
||
"RULES_YAML = \"\"\"\n",
|
||
"- id: R01\n",
|
||
" name: 지체상금률 1.0/1000 미만\n",
|
||
" field: penalty_permil\n",
|
||
" op: \"<\"\n",
|
||
" value: 1.0\n",
|
||
" weight: 3\n",
|
||
"- id: R02\n",
|
||
" name: 선금 비율 40% 초과\n",
|
||
" field: advance_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 40\n",
|
||
" weight: 2\n",
|
||
"- id: R03\n",
|
||
" name: 선금 40% 초과인데 이행보증 10% 미만\n",
|
||
" field: adv_no_bond\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R04\n",
|
||
" name: 300백만원 이상 수의계약\n",
|
||
" field: sole_amount\n",
|
||
" op: \">=\"\n",
|
||
" value: 300\n",
|
||
" weight: 3\n",
|
||
"- id: R05\n",
|
||
" name: 500백만원 이상 이행보증 10% 미만\n",
|
||
" field: bond_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R06\n",
|
||
" name: 물품 하자보증 12개월 미만\n",
|
||
" field: warranty_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R07\n",
|
||
" name: 해지 조항 없음\n",
|
||
" field: termination\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 2\n",
|
||
"- id: R08\n",
|
||
" name: 보안 조항 없음\n",
|
||
" field: security\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 3\n",
|
||
"- id: R09\n",
|
||
" name: 지식재산 조항 없음(물품·용역)\n",
|
||
" field: ip_missing\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R10\n",
|
||
" name: 하도급 비율 50% 초과\n",
|
||
" field: subcontract_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 50\n",
|
||
" weight: 3\n",
|
||
"- id: R11\n",
|
||
" name: 검사 기간 14일 초과\n",
|
||
" field: inspection_days\n",
|
||
" op: \">\"\n",
|
||
" value: 14\n",
|
||
" weight: 1\n",
|
||
"- id: R12\n",
|
||
" name: 납품 기한이 계약 기간 초과\n",
|
||
" field: logic_err\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R13\n",
|
||
" name: 180일 이상 계약에 물가변동 조항 없음\n",
|
||
" field: no_padj_long\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R14\n",
|
||
" name: 1000백만원 이상 비경쟁 계약\n",
|
||
" field: noncomp_big\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R15\n",
|
||
" name: 계약 기간 365일 초과\n",
|
||
" field: period_days\n",
|
||
" op: \">\"\n",
|
||
" value: 365\n",
|
||
" weight: 1\n",
|
||
"- id: R16\n",
|
||
" name: 불공정·납기 위험 키워드 포함\n",
|
||
" field: kw_hits\n",
|
||
" op: \">=\"\n",
|
||
" value: 1\n",
|
||
" weight: 3\n",
|
||
"\"\"\"\n",
|
||
"GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n",
|
||
"# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n",
|
||
"KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n",
|
||
" \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n",
|
||
"\n",
|
||
"\n",
|
||
"def parse_rules(text):\n",
|
||
" \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n",
|
||
" rules, cur = [], None\n",
|
||
" for line in text.strip().splitlines():\n",
|
||
" if not line.strip():\n",
|
||
" continue\n",
|
||
" s = line.strip()\n",
|
||
" if s.startswith(\"- \"):\n",
|
||
" cur = {}; rules.append(cur); s = s[2:]\n",
|
||
" k, v = [x.strip() for x in s.split(\":\", 1)]\n",
|
||
" v = v.strip('\"')\n",
|
||
" if v in (\"true\", \"false\"):\n",
|
||
" v = v == \"true\"\n",
|
||
" else:\n",
|
||
" try:\n",
|
||
" v = float(v) if \".\" in v else int(v)\n",
|
||
" except ValueError:\n",
|
||
" pass\n",
|
||
" cur[k] = v\n",
|
||
" return rules\n",
|
||
"\n",
|
||
"\n",
|
||
"OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n",
|
||
" \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n",
|
||
"\n",
|
||
"\n",
|
||
"def kw_count(clauses):\n",
|
||
" \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n",
|
||
" return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n",
|
||
"\n",
|
||
"\n",
|
||
"def derive(df):\n",
|
||
" \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n",
|
||
" d = df.copy()\n",
|
||
" d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n",
|
||
" d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n",
|
||
" d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n",
|
||
" d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n",
|
||
" d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n",
|
||
" d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n",
|
||
" d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"def apply_rules(rec, rules):\n",
|
||
" \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n",
|
||
" hits = []\n",
|
||
" for r in rules:\n",
|
||
" v = rec.get(r[\"field\"])\n",
|
||
" if v is None or (isinstance(v, float) and np.isnan(v)):\n",
|
||
" continue\n",
|
||
" if OPS[r[\"op\"]](v, r[\"value\"]):\n",
|
||
" hits.append(r)\n",
|
||
" score = sum(r[\"weight\"] for r in hits)\n",
|
||
" grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n",
|
||
" return score, grade, [r[\"id\"] for r in hits]\n",
|
||
"\n",
|
||
"# [교육용가상자료] 룰과 가상 지침 조항의 대응표(실제 법령·규정 조문이 아니라 연결 방식을 보여 주는 예시)\n",
|
||
"CLAUSE = {\"R01\": \"가상지침 5조1항(지체상금)\", \"R02\": \"가상지침 6조1항(선금 한도)\", \"R03\": \"가상지침 6조2항(선금과 보증)\", \"R04\": \"가상지침 3조1항(수의계약 한도)\",\n",
|
||
" \"R05\": \"가상지침 6조3항(이행보증)\", \"R06\": \"가상지침 7조1항(하자보증)\", \"R07\": \"가상지침 8조1항(계약 해지)\", \"R08\": \"가상지침 9조1항(보안 조항)\",\n",
|
||
" \"R09\": \"가상지침 9조2항(지식재산)\", \"R10\": \"가상지침 4조1항(하도급 제한)\", \"R11\": \"가상지침 5조2항(검사 기간)\", \"R12\": \"가상지침 5조3항(납품 기한)\",\n",
|
||
" \"R13\": \"가상지침 6조4항(물가변동)\", \"R14\": \"가상지침 3조2항(경쟁 계약 원칙)\", \"R15\": \"가상지침 3조3항(장기 계약)\", \"R16\": \"가상지침 8조2항(불공정 조항)\"}\n",
|
||
"\n",
|
||
"rules = parse_rules(RULES_YAML)\n",
|
||
"con = make_contracts(200, seed=7)\n",
|
||
"df = derive(con)\n",
|
||
"recs = df.to_dict(\"records\")\n",
|
||
"\n",
|
||
"# 1) 전체 스크리닝과 처리 지연시간(레코드 1건당)\n",
|
||
"apply_rules(recs[0], rules) # 첫 호출 지연(예열)을 측정에서 제외\n",
|
||
"lat, res = [], []\n",
|
||
"for r in recs:\n",
|
||
" t0 = time.perf_counter()\n",
|
||
" out = apply_rules(r, rules)\n",
|
||
" lat.append((time.perf_counter() - t0) * 1000)\n",
|
||
" res.append(out)\n",
|
||
"df[\"score\"], df[\"grade\"], df[\"hits\"] = [x[0] for x in res], [x[1] for x in res], [x[2] for x in res]\n",
|
||
"print(f\"[룰 설정] 검증 룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}), 모든 룰에 가상 지침 조항 연결 {all(r['id'] in CLAUSE for r in rules)}\")\n",
|
||
"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 '미달'})\")\n",
|
||
"\n",
|
||
"# 2) 경계 조건 단위 시험: 기준값 바로 아래·위에서 룰이 정확히 켜지고 꺼지는가\n",
|
||
"CLEAN = [make_clause(np.random.default_rng(i), \"정상\") for i in range(6)]\n",
|
||
"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,\n",
|
||
" warranty_months=24, inspection_days=7, subcontract_pct=20, price_adjust=True, termination=True, security=True, ip_clause=True, clauses=CLEAN)\n",
|
||
"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),\n",
|
||
" (\"R03\", dict(advance_pct=50, perf_bond_pct=9), True), (\"R03\", dict(advance_pct=50, perf_bond_pct=10), False),\n",
|
||
" (\"R04\", dict(method=\"수의계약\", amount_m=300), True), (\"R04\", dict(method=\"수의계약\", amount_m=299), False),\n",
|
||
" (\"R05\", dict(amount_m=500, perf_bond_pct=9), True), (\"R05\", dict(amount_m=499, perf_bond_pct=9), False),\n",
|
||
" (\"R06\", dict(warranty_months=11), True), (\"R06\", dict(warranty_months=12), False), (\"R07\", dict(termination=False), True), (\"R07\", dict(termination=True), False),\n",
|
||
" (\"R08\", dict(security=False), True), (\"R08\", dict(security=True), False), (\"R09\", dict(ip_clause=False, ctype=\"용역\"), True), (\"R09\", dict(ip_clause=False, ctype=\"공사\"), False),\n",
|
||
" (\"R10\", dict(subcontract_pct=51), True), (\"R10\", dict(subcontract_pct=50), False), (\"R11\", dict(inspection_days=15), True), (\"R11\", dict(inspection_days=14), False),\n",
|
||
" (\"R12\", dict(delivery_days=121), True), (\"R12\", dict(delivery_days=120), False), (\"R13\", dict(period_days=180, delivery_days=170, price_adjust=False), True),\n",
|
||
" (\"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),\n",
|
||
" (\"R15\", dict(period_days=366, delivery_days=300), True), (\"R15\", dict(period_days=365, delivery_days=300), False),\n",
|
||
" (\"R16\", dict(clauses=CLEAN[:5] + [\"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있다.\"]), True), (\"R16\", dict(clauses=CLEAN), False)]\n",
|
||
"passed = 0\n",
|
||
"for rid, chg, expect in TESTS:\n",
|
||
" row = derive(pd.DataFrame([{**BASE, **chg}])).iloc[0].to_dict()\n",
|
||
" got = rid in apply_rules(row, rules)[2]\n",
|
||
" passed += got == expect\n",
|
||
" if got != expect:\n",
|
||
" print(f\" [FAIL] {rid} {chg} 기대 {expect} 결과 {got}\")\n",
|
||
"base_hits = apply_rules(derive(pd.DataFrame([BASE])).iloc[0].to_dict(), rules)\n",
|
||
"print(f\"\\n[경계 조건 단위 시험] {passed}/{len(TESTS)}건 통과 ({passed / len(TESTS):.0%}), 정상 기준 계약의 적중 룰 {base_hits[2] or '없음'} -> 등급 {base_hits[1]} (목표 100% 통과: {'달성' if passed == len(TESTS) else '미달'})\")\n",
|
||
"\n",
|
||
"# 3) 등급 분포와 실제 위반 여부(법무·계약관 검토에서 확인된 규정 위반 정답) 대비 탐지력\n",
|
||
"print(\"\\n[1차 판별 등급 분포]\")\n",
|
||
"print(df.groupby(\"grade\").agg(계약수=(\"contract_id\", \"size\"), 실제위반율=(\"violation\", \"mean\")).reindex([\"고위험\", \"주의\", \"관심\"]).round(2).to_string())\n",
|
||
"y = df[\"violation\"].to_numpy()\n",
|
||
"flag = df[\"grade\"].isin([\"고위험\", \"주의\"]).to_numpy()\n",
|
||
"tp = int((flag & y).sum())\n",
|
||
"print(f\"고위험·주의 선별: 실제 위반 계약 {y.sum()}건 중 {tp}건 포착(재현율 {tp / y.sum():.0%}), 선별 {flag.sum()}건 중 정밀도 {tp / max(flag.sum(), 1):.0%}\")\n",
|
||
"\n",
|
||
"# 4) 룰별 적중 건수와 근거 조항\n",
|
||
"cnt = pd.Series([h for hs in df[\"hits\"] for h in hs]).value_counts()\n",
|
||
"tbl = pd.DataFrame([{\"룰\": r[\"id\"], \"이름\": r[\"name\"], \"가중치\": r[\"weight\"], \"적중\": int(cnt.get(r[\"id\"], 0)), \"근거 조항\": CLAUSE[r[\"id\"]]} for r in rules])\n",
|
||
"print(\"\\n[룰별 적중 현황]\")\n",
|
||
"print(tbl.to_string(index=False))\n",
|
||
"\n",
|
||
"# 5) 고위험 상위 3건의 판정 근거(설명 가능성)\n",
|
||
"print(\"\\n[고위험 상위 3건과 근거]\")\n",
|
||
"for _, r in df.sort_values(\"score\", ascending=False).head(3).iterrows():\n",
|
||
" print(f\" {r['contract_id']} {r['ctype']} {r['method']} {r['amount_m']:.0f}백만원 점수 {r['score']} {r['grade']}\")\n",
|
||
" for h in r[\"hits\"][:3]:\n",
|
||
" print(f\" - {h} {next(x['name'] for x in rules if x['id'] == h)} / {CLAUSE[h]}\")\n",
|
||
"\n",
|
||
"# 6) 규정 변경 대응: 수의계약 한도를 300에서 250백만원으로 낮추는 설정 변경(코드 수정 없음)\n",
|
||
"rules2 = parse_rules(RULES_YAML.replace('op: \">=\"\\n value: 300', 'op: \">=\"\\n value: 250'))\n",
|
||
"o2 = [apply_rules(r, rules2) for r in recs]\n",
|
||
"g2 = np.array([x[1] in (\"고위험\", \"주의\") for x in o2])\n",
|
||
"n_new = int((g2 & ~flag).sum())\n",
|
||
"print(f\"\\n[설정 변경] 수의계약 한도 300 -> 250백만원(YAML 값만 변경): 새로 선별된 계약 {n_new}건(계약 쪼개기 의심 구간 250~299백만원), 재현율 {tp / y.sum():.0%} -> {(g2 & y).sum() / y.sum():.0%}\")\n",
|
||
"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\"))\n",
|
||
"o3 = [apply_rules(r, rules3)[0] for r in recs]\n",
|
||
"print(f\"[설정 변경] 룰 R01 지체상금률 기준 1.0 -> 1.2: 점수가 바뀐 계약 {sum(a != b for a, b in zip(df['score'], o3))}건 (YAML 값만 변경)\")\n",
|
||
"print(\"처리 후 사람 검토: 룰 판별은 1차 선별이며, 최종 규정 위반 판단과 수정 요구는 계약관·법무 담당자가 결정한다.\")\n",
|
||
"\n",
|
||
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
|
||
"ax[0].bar(tbl[\"룰\"], tbl[\"적중\"], color=\"tab:blue\"); ax[0].set_title(\"Rule hits by rule (synthetic)\"); ax[0].set_ylabel(\"contracts\")\n",
|
||
"g = df.groupby(\"grade\")[\"violation\"].mean().reindex([\"관심\", \"주의\", \"고위험\"])\n",
|
||
"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)\n",
|
||
"plt.tight_layout(); plt.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[룰 설정] 검증 룰 16종(목표 15종 이상: 달성), 모든 룰에 가상 지침 조항 연결 True\n",
|
||
"룰 처리 지연시간(레코드 1건): 평균 0.008ms, 99백분위 0.036ms, 최대 0.100ms (목표 50ms 이하, 99백분위 기준: 달성)\n",
|
||
"\n",
|
||
"[경계 조건 단위 시험] 32/32건 통과 (100%), 정상 기준 계약의 적중 룰 없음 -> 등급 관심 (목표 100% 통과: 달성)\n",
|
||
"\n",
|
||
"[1차 판별 등급 분포]\n",
|
||
" 계약수 실제위반율\n",
|
||
"grade \n",
|
||
"고위험 22 1.00\n",
|
||
"주의 68 0.72\n",
|
||
"관심 110 0.07\n",
|
||
"고위험·주의 선별: 실제 위반 계약 79건 중 71건 포착(재현율 90%), 선별 90건 중 정밀도 79%\n",
|
||
"\n",
|
||
"[룰별 적중 현황]\n",
|
||
" 룰 이름 가중치 적중 근거 조항\n",
|
||
"R01 지체상금률 1.0/1000 미만 3 10 가상지침 5조1항(지체상금)\n",
|
||
"R02 선금 비율 40% 초과 2 11 가상지침 6조1항(선금 한도)\n",
|
||
"R03 선금 40% 초과인데 이행보증 10% 미만 3 5 가상지침 6조2항(선금과 보증)\n",
|
||
"R04 300백만원 이상 수의계약 3 10 가상지침 3조1항(수의계약 한도)\n",
|
||
"R05 500백만원 이상 이행보증 10% 미만 2 4 가상지침 6조3항(이행보증)\n",
|
||
"R06 물품 하자보증 12개월 미만 2 5 가상지침 7조1항(하자보증)\n",
|
||
"R07 해지 조항 없음 2 8 가상지침 8조1항(계약 해지)\n",
|
||
"R08 보안 조항 없음 3 5 가상지침 9조1항(보안 조항)\n",
|
||
"R09 지식재산 조항 없음(물품·용역) 1 9 가상지침 9조2항(지식재산)\n",
|
||
"R10 하도급 비율 50% 초과 3 8 가상지침 4조1항(하도급 제한)\n",
|
||
"R11 검사 기간 14일 초과 1 21 가상지침 5조2항(검사 기간)\n",
|
||
"R12 납품 기한이 계약 기간 초과 3 7 가상지침 5조3항(납품 기한)\n",
|
||
"R13 180일 이상 계약에 물가변동 조항 없음 1 38 가상지침 6조4항(물가변동)\n",
|
||
"R14 1000백만원 이상 비경쟁 계약 2 3 가상지침 3조2항(경쟁 계약 원칙)\n",
|
||
"R15 계약 기간 365일 초과 1 24 가상지침 3조3항(장기 계약)\n",
|
||
"R16 불공정·납기 위험 키워드 포함 3 61 가상지침 8조2항(불공정 조항)\n",
|
||
"\n",
|
||
"[고위험 상위 3건과 근거]\n",
|
||
" C-0108 물품 수의계약 961백만원 점수 12 고위험\n",
|
||
" - R01 지체상금률 1.0/1000 미만 / 가상지침 5조1항(지체상금)\n",
|
||
" - R04 300백만원 이상 수의계약 / 가상지침 3조1항(수의계약 한도)\n",
|
||
" - R12 납품 기한이 계약 기간 초과 / 가상지침 5조3항(납품 기한)\n",
|
||
" C-0062 용역 수의계약 1109백만원 점수 11 고위험\n",
|
||
" - R01 지체상금률 1.0/1000 미만 / 가상지침 5조1항(지체상금)\n",
|
||
" - R04 300백만원 이상 수의계약 / 가상지침 3조1항(수의계약 한도)\n",
|
||
"... (이하 생략)\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 04. LLM/자연어처리 기반 독소 조항, 납기 지체 위험 문구 자동 식별 모델 고도화\n",
|
||
"**소요시간**: 약 3.0시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"자연어처리 기반 독소 조항 및 납기 지체 위험 문구 식별 모델을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **자연어처리(글자 n-gram TF-IDF와 로지스틱 회귀)로 독소 조항과 납기 지체 위험 문구를 식별하는 모델을 만들고 수정 권고 문안을 제시한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **조항 문장을 정상·독소·납기위험 세 종류로 분류하는 모델을 학습하고, 학습에 쓴 문장 틀과 처음 보는 표현 두 시험 세트로 정확도를 계산해 단순 키워드 기준선과 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `src/ai/prc01_intelligence_module.py` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 추천 사유의 합리성 및 실무 수용성\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 추천/분류 정확도 85% 이상 달성\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: AI 추론 모듈 구현 및 벤치마크 테스트 완료\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 과거 승인/처리 완료 데이터와의 추천 일치도 검증\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"조항 문장을 정상·독소 조항·납기 지체 위험 문구로 분류하는 글자 n-gram TF-IDF 로지스틱 회귀 모델을 만들어 처음 보는 표현까지 정확도 85% 이상을 확인하고, 단순 키워드 기준선과 비교하며, 위험 조항마다 근거 표현과 표준 문안 Top-3 수정 권고(유사도 점수)를 제시한다.\"\"\"\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
|
||
"from sklearn.linear_model import LogisticRegression\n",
|
||
"from sklearn.metrics import confusion_matrix\n",
|
||
"from sklearn.metrics.pairwise import cosine_similarity\n",
|
||
"from sklearn.pipeline import make_pipeline\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n",
|
||
"\n",
|
||
"# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n",
|
||
"def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n",
|
||
" \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" y = rng.choice(CLASSES, n, p=list(mix))\n",
|
||
" return [make_clause(rng, c, hold) for c in y], list(y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def fit_clause_model(seed=100, n=1200):\n",
|
||
" \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n",
|
||
" X, y = clause_set(seed, n)\n",
|
||
" return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def keyword_pred(s):\n",
|
||
" \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n",
|
||
" if any(k in s for k in SIMPLE_KW[\"독소\"]):\n",
|
||
" return \"독소\"\n",
|
||
" return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n",
|
||
"\n",
|
||
"# [교육용가상자료] 표준 문안 라이브러리(수정 권고용, 실제 표준계약서 조문이 아님)\n",
|
||
"STD = {\"지체상금\": \"납기를 지체하면 지체일수마다 계약금액의 1/1000 이상에 해당하는 지체상금을 부과하며 면제는 불가항력 사유가 확인되고 서면 승인을 받은 경우에 한다.\",\n",
|
||
" \"검사\": \"발주기관은 납품된 물품을 정해진 기간 안에 검사하고 합격한 후에 대금을 지급하며 검사 권한은 생략하거나 대체할 수 없다.\",\n",
|
||
" \"해지\": \"계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로 통지해야 하며 책임 있는 쪽이 손해를 부담한다.\",\n",
|
||
" \"추가비용\": \"추가 비용은 사전에 협의해 서면으로 합의한 범위에서만 인정하며 증빙을 갖추어 청구한다.\",\n",
|
||
" \"지식재산\": \"지식재산권의 귀속과 사용 범위는 별도 합의서로 정하며 발주기관은 목적물을 사용할 수 있다.\",\n",
|
||
" \"하도급\": \"하도급은 사전에 발주기관의 서면 승인을 받아야 하며 하도급 내용은 확인할 수 있어야 한다.\",\n",
|
||
" \"대금\": \"대금은 검사 합격 후 청구를 받아 정해진 기간 안에 지급하고 선지급은 보증을 갖춘 범위에서만 한다.\",\n",
|
||
" \"보안\": \"계약 이행 중 알게 된 비공개 사항은 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"하자\": \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"분쟁\": \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
" \"납기\": \"납기는 계약서에 날짜로 명시하고 변경은 불가항력 사유 확인 후 서면 승인으로만 한다.\"}\n",
|
||
"TOPIC_T = [\"지체상금\", \"검사\", \"해지\", \"추가비용\", \"지식재산\", \"하도급\", \"대금\", \"보안\", \"하자\", \"대금\", \"분쟁\", \"검사\", \"추가비용\"] # T_TPL 순서와 대응\n",
|
||
"\n",
|
||
"model = fit_clause_model()\n",
|
||
"Xa, ya = clause_set(200, 600) # 학습과 같은 문장 틀(새로 뽑은 문장)\n",
|
||
"Xb, yb = clause_set(201, 300, hold=True) # 학습에 없는 처음 보는 표현\n",
|
||
"print(f\"[데이터] 학습 조항 1,200개 / 시험 A(같은 문장 틀) {len(Xa)}개 / 시험 B(처음 보는 표현) {len(Xb)}개 / 분류 3종: {', '.join(CLASSES)}\")\n",
|
||
"\n",
|
||
"pa, pb = model.predict(Xa), model.predict(Xb)\n",
|
||
"acc_a, acc_b = np.mean(pa == np.array(ya)), np.mean(pb == np.array(yb))\n",
|
||
"X_all, y_all, p_all = Xa + Xb, np.array(ya + yb), np.concatenate([pa, pb])\n",
|
||
"acc = np.mean(p_all == y_all)\n",
|
||
"kb = np.array([keyword_pred(s) for s in X_all])\n",
|
||
"print(f\"\\n[분류 정확도] 시험 A {acc_a:.1%}, 시험 B(처음 보는 표현) {acc_b:.1%}, 전체 {acc:.1%} (목표 85% 이상: {'달성' if acc >= 0.85 else '미달'})\")\n",
|
||
"print(f\"단순 키워드 기준선 전체 {np.mean(kb == y_all):.1%} (부정·문맥을 보지 못해 '면제 사유가 없는 한 부과' 같은 정상 문장을 오탐)\")\n",
|
||
"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])\n",
|
||
"print(cm.to_string())\n",
|
||
"risk_idx = y_all != \"정상\"\n",
|
||
"rec_ = np.mean(p_all[risk_idx] != \"정상\")\n",
|
||
"print(f\"위험 조항(독소·납기위험) 포착률 {rec_:.1%}, 정상 문장을 위험으로 오탐한 비율 {np.mean(p_all[~risk_idx] != '정상'):.1%}\")\n",
|
||
"\n",
|
||
"# 오분류 사례\n",
|
||
"print(\"\\n[오분류 사례(시험 B)]\")\n",
|
||
"shown = 0\n",
|
||
"for s, t, p in zip(Xb, yb, pb):\n",
|
||
" if t != p and shown < 3:\n",
|
||
" shown += 1; print(f\" 실제 {t} / 예측 {p}: {s[:48]}...\")\n",
|
||
"if shown == 0:\n",
|
||
" print(\" 없음\")\n",
|
||
"\n",
|
||
"# 근거 표현: 해당 분류에 가장 크게 기여한 글자 조각\n",
|
||
"vec, lr = model[0], model[-1]\n",
|
||
"names = np.array(vec.get_feature_names_out())\n",
|
||
"\n",
|
||
"\n",
|
||
"def evidence(text, cls, k=3):\n",
|
||
" x = vec.transform([text])\n",
|
||
" contrib = x.multiply(lr.coef_[list(lr.classes_).index(cls)]).toarray()[0]\n",
|
||
" return [names[j].strip() for j in np.argsort(-contrib)[:k] if contrib[j] > 0]\n",
|
||
"\n",
|
||
"\n",
|
||
"# 표준 문안 Top-3 추천(과거 처리 완료 사례와의 일치도): 독소 조항 120개와 납기 위험 문구 60개\n",
|
||
"rng = np.random.default_rng(5)\n",
|
||
"sv = TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 3)).fit(list(STD.values()) + X_all)\n",
|
||
"SM = sv.transform(list(STD.values()))\n",
|
||
"topics = list(STD)\n",
|
||
"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)]\n",
|
||
"jn = topics.index(\"납기\")\n",
|
||
"\n",
|
||
"\n",
|
||
"def similarity(s):\n",
|
||
" \"\"\"문안 유사도. 분류 결과가 납기위험이면 납기 문안을 우선하고, 독소 조항이면 납기 문안은 제외한다.\"\"\"\n",
|
||
" raw = cosine_similarity(sv.transform([s]), SM)[0]\n",
|
||
" sc = raw.copy()\n",
|
||
" sc[jn] += 1.0 if model.predict([s])[0] == \"납기위험\" else -1.0\n",
|
||
" return sc, raw\n",
|
||
"\n",
|
||
"\n",
|
||
"hit1 = hit3 = 0\n",
|
||
"for s, t in tests:\n",
|
||
" sc, _ = similarity(s)\n",
|
||
" order = np.argsort(-sc)[:3]\n",
|
||
" hit1 += topics[order[0]] == t; hit3 += t in [topics[j] for j in order]\n",
|
||
"print(f\"\\n[표준 문안 추천 일치도] 위험 조항 {len(tests)}건: Top-1 {hit1 / len(tests):.1%}, Top-3 {hit3 / len(tests):.1%} (과거 처리된 수정 문안 주제와 일치 기준)\")\n",
|
||
"print(\"\\n[위험 조항별 근거와 수정 권고 Top-3 예시]\")\n",
|
||
"for s, t in [tests[0], tests[7], tests[-1]]:\n",
|
||
" pred = model.predict([s])[0]; prob = model.predict_proba([s])[0].max()\n",
|
||
" sc, sim = similarity(s)\n",
|
||
" order = np.argsort(-sc)[:3]\n",
|
||
" print(f\" 조항: {s[:46]}...\")\n",
|
||
" print(f\" 분류 {pred}(확신도 {prob:.2f}), 근거 표현 {evidence(s, pred) or '-'}\")\n",
|
||
" for r, j in enumerate(order, 1):\n",
|
||
" print(f\" 권고 {r}순위 [{topics[j]}] 유사도 {sim[j]:.2f}: {list(STD.values())[j][:30]}...\")\n",
|
||
"print(\"※ 모델은 후보를 표시하고 수정 문안은 참고용이다. 불공정 조항 여부와 문안 확정은 계약관·법무 담당자가 판단한다.\")\n",
|
||
"\n",
|
||
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
|
||
"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\"])\n",
|
||
"ax[0].axhline(.85, color=\"r\", ls=\"--\"); ax[0].set_ylim(0, 1.05); ax[0].set_title(\"Clause classification accuracy\")\n",
|
||
"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)\")\n",
|
||
"for i in range(3):\n",
|
||
" for j in range(3):\n",
|
||
" ax[1].text(j, i, int(cm.to_numpy()[i, j]), ha=\"center\", va=\"center\")\n",
|
||
"plt.tight_layout(); plt.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[데이터] 학습 조항 1,200개 / 시험 A(같은 문장 틀) 600개 / 시험 B(처음 보는 표현) 300개 / 분류 3종: 정상, 독소, 납기위험\n",
|
||
"\n",
|
||
"[분류 정확도] 시험 A 100.0%, 시험 B(처음 보는 표현) 95.3%, 전체 98.4% (목표 85% 이상: 달성)\n",
|
||
"단순 키워드 기준선 전체 64.4% (부정·문맥을 보지 못해 '면제 사유가 없는 한 부과' 같은 정상 문장을 오탐)\n",
|
||
" 예측 정상 예측 독소 예측 납기위험\n",
|
||
"실제 정상 504 0 0\n",
|
||
"실제 독소 0 222 0\n",
|
||
"실제 납기위험 14 0 160\n",
|
||
"위험 조항(독소·납기위험) 포착률 96.5%, 정상 문장을 위험으로 오탐한 비율 0.0%\n",
|
||
"\n",
|
||
"[오분류 사례(시험 B)]\n",
|
||
" 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다. 필요한 경우 양 당사자가...\n",
|
||
" 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다. 이 조항은 계약 전체에 ...\n",
|
||
" 실제 납기위험 / 예측 정상: 일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다....\n",
|
||
"\n",
|
||
"[표준 문안 추천 일치도] 위험 조항 180건: Top-1 94.4%, Top-3 97.8% (과거 처리된 수정 문안 주제와 일치 기준)\n",
|
||
"\n",
|
||
"[위험 조항별 근거와 수정 권고 Top-3 예시]\n",
|
||
" 조항: 하자 보수 책임은 인도 후 즉시 소멸하며 계약담당관은 보수를 요구할 수 없다....\n",
|
||
" 분류 독소(확신도 0.99), 근거 표현 ['관은', '관은', '즉시']\n",
|
||
" 권고 1순위 [하자] 유사도 0.12: 보증 기간 중 발생한 결함은 계약상대자가 책임지고 무상...\n",
|
||
" 권고 2순위 [검사] 유사도 0.09: 발주기관은 납품된 물품을 정해진 기간 안에 검사하고 합...\n",
|
||
" 권고 3순위 [하도급] 유사도 0.06: 하도급은 사전에 발주기관의 서면 승인을 받아야 하며 하...\n",
|
||
" 조항: 용역 결과물의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이...\n",
|
||
" 분류 독소(확신도 0.99), 근거 표현 ['전액', '액', '전액']\n",
|
||
" 권고 1순위 [추가비용] 유사도 0.16: 추가 비용은 사전에 협의해 서면으로 합의한 범위에서만 ...\n",
|
||
" 권고 2순위 [지식재산] 유사도 0.12: 지식재산권의 귀속과 사용 범위는 별도 합의서로 정하며 ...\n",
|
||
" 권고 3순위 [해지] 유사도 0.12: 계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로...\n",
|
||
" 조항: 납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다. 세부 ...\n",
|
||
" 분류 납기위험(확신도 0.99), 근거 표현 ['일정', '일정', '도']\n",
|
||
" 권고 1순위 [납기] 유사도 0.03: 납기는 계약서에 날짜로 명시하고 변경은 불가항력 사유 ...\n",
|
||
" 권고 2순위 [보안] 유사도 0.08: 계약 이행 중 알게 된 비공개 사항은 누설하지 않으며 ...\n",
|
||
" 권고 3순위 [해지] 유사도 0.06: 계약 해지는 사유를 명시하고 정해진 기간 전에 서면으로...\n",
|
||
"※ 모델은 후보를 표시하고 수정 문안은 참고용이다. 불공정 조항 여부와 문안 확정은 계약관·법무 담당자가 판단한다.\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 05. 계약 위험 점수 산출 및 규정 위반 가능성 신뢰도 지수 엔진 개발\n",
|
||
"**소요시간**: 약 3.0시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"계약 위험 점수 산출 및 규정 위반 가능성 신뢰도 엔진을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **룰 적중 내역과 조항 분류 결과를 결합해 계약 위험 점수와 규정 위반 가능성 신뢰도 지수를 산출하고 입력-분석-검토-결과 4개 화면을 구성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **룰 16종 적중 여부, 모델이 찾은 독소·납기위험 조항 수, 금액을 로지스틱 회귀로 결합해 0~100점의 위험 점수를 만들고, 점수의 뚜렷함과 룰·모델 일치 여부로 신뢰도 지수 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `src/ui/prc01_app.py` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 군 실무자 친화적 디자인 및 조작 편의성\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 핵심 화면 4종(입력-분석-검토-결과) 구현 완료\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: UI 프로토타입 로컬 서버 구동 및 전체 페이지 네비게이션 확인\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 사용성 평가(UX Walkthrough) 및 인터랙션 테스트\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"룰 적중 내역과 조항 분류 모델의 위험 조항 수를 로지스틱 회귀로 결합해 계약별 위반 위험 점수(0~100)와 신뢰도 지수를 산출하는 엔진을 만들고, 별도 가상 계약군에서 성능·신뢰도 보정을 검증하며 입력-분석-검토-결과 4개 화면(텍스트 패널과 그래프)과 상태 뱃지·필터를 구성한다.\"\"\"\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
|
||
"from sklearn.linear_model import LogisticRegression\n",
|
||
"from sklearn.metrics import roc_auc_score, brier_score_loss\n",
|
||
"from sklearn.pipeline import make_pipeline\n",
|
||
"from sklearn.preprocessing import StandardScaler\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n",
|
||
"RULES_YAML = \"\"\"\n",
|
||
"- id: R01\n",
|
||
" name: 지체상금률 1.0/1000 미만\n",
|
||
" field: penalty_permil\n",
|
||
" op: \"<\"\n",
|
||
" value: 1.0\n",
|
||
" weight: 3\n",
|
||
"- id: R02\n",
|
||
" name: 선금 비율 40% 초과\n",
|
||
" field: advance_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 40\n",
|
||
" weight: 2\n",
|
||
"- id: R03\n",
|
||
" name: 선금 40% 초과인데 이행보증 10% 미만\n",
|
||
" field: adv_no_bond\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R04\n",
|
||
" name: 300백만원 이상 수의계약\n",
|
||
" field: sole_amount\n",
|
||
" op: \">=\"\n",
|
||
" value: 300\n",
|
||
" weight: 3\n",
|
||
"- id: R05\n",
|
||
" name: 500백만원 이상 이행보증 10% 미만\n",
|
||
" field: bond_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R06\n",
|
||
" name: 물품 하자보증 12개월 미만\n",
|
||
" field: warranty_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R07\n",
|
||
" name: 해지 조항 없음\n",
|
||
" field: termination\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 2\n",
|
||
"- id: R08\n",
|
||
" name: 보안 조항 없음\n",
|
||
" field: security\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 3\n",
|
||
"- id: R09\n",
|
||
" name: 지식재산 조항 없음(물품·용역)\n",
|
||
" field: ip_missing\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R10\n",
|
||
" name: 하도급 비율 50% 초과\n",
|
||
" field: subcontract_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 50\n",
|
||
" weight: 3\n",
|
||
"- id: R11\n",
|
||
" name: 검사 기간 14일 초과\n",
|
||
" field: inspection_days\n",
|
||
" op: \">\"\n",
|
||
" value: 14\n",
|
||
" weight: 1\n",
|
||
"- id: R12\n",
|
||
" name: 납품 기한이 계약 기간 초과\n",
|
||
" field: logic_err\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R13\n",
|
||
" name: 180일 이상 계약에 물가변동 조항 없음\n",
|
||
" field: no_padj_long\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R14\n",
|
||
" name: 1000백만원 이상 비경쟁 계약\n",
|
||
" field: noncomp_big\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R15\n",
|
||
" name: 계약 기간 365일 초과\n",
|
||
" field: period_days\n",
|
||
" op: \">\"\n",
|
||
" value: 365\n",
|
||
" weight: 1\n",
|
||
"- id: R16\n",
|
||
" name: 불공정·납기 위험 키워드 포함\n",
|
||
" field: kw_hits\n",
|
||
" op: \">=\"\n",
|
||
" value: 1\n",
|
||
" weight: 3\n",
|
||
"\"\"\"\n",
|
||
"GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n",
|
||
"# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n",
|
||
"KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n",
|
||
" \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n",
|
||
"\n",
|
||
"\n",
|
||
"def parse_rules(text):\n",
|
||
" \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n",
|
||
" rules, cur = [], None\n",
|
||
" for line in text.strip().splitlines():\n",
|
||
" if not line.strip():\n",
|
||
" continue\n",
|
||
" s = line.strip()\n",
|
||
" if s.startswith(\"- \"):\n",
|
||
" cur = {}; rules.append(cur); s = s[2:]\n",
|
||
" k, v = [x.strip() for x in s.split(\":\", 1)]\n",
|
||
" v = v.strip('\"')\n",
|
||
" if v in (\"true\", \"false\"):\n",
|
||
" v = v == \"true\"\n",
|
||
" else:\n",
|
||
" try:\n",
|
||
" v = float(v) if \".\" in v else int(v)\n",
|
||
" except ValueError:\n",
|
||
" pass\n",
|
||
" cur[k] = v\n",
|
||
" return rules\n",
|
||
"\n",
|
||
"\n",
|
||
"OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n",
|
||
" \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n",
|
||
"\n",
|
||
"\n",
|
||
"def kw_count(clauses):\n",
|
||
" \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n",
|
||
" return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n",
|
||
"\n",
|
||
"\n",
|
||
"def derive(df):\n",
|
||
" \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n",
|
||
" d = df.copy()\n",
|
||
" d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n",
|
||
" d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n",
|
||
" d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n",
|
||
" d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n",
|
||
" d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n",
|
||
" d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n",
|
||
" d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"def apply_rules(rec, rules):\n",
|
||
" \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n",
|
||
" hits = []\n",
|
||
" for r in rules:\n",
|
||
" v = rec.get(r[\"field\"])\n",
|
||
" if v is None or (isinstance(v, float) and np.isnan(v)):\n",
|
||
" continue\n",
|
||
" if OPS[r[\"op\"]](v, r[\"value\"]):\n",
|
||
" hits.append(r)\n",
|
||
" score = sum(r[\"weight\"] for r in hits)\n",
|
||
" grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n",
|
||
" return score, grade, [r[\"id\"] for r in hits]\n",
|
||
"\n",
|
||
"SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n",
|
||
"\n",
|
||
"# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n",
|
||
"def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n",
|
||
" \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" y = rng.choice(CLASSES, n, p=list(mix))\n",
|
||
" return [make_clause(rng, c, hold) for c in y], list(y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def fit_clause_model(seed=100, n=1200):\n",
|
||
" \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n",
|
||
" X, y = clause_set(seed, n)\n",
|
||
" return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def keyword_pred(s):\n",
|
||
" \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n",
|
||
" if any(k in s for k in SIMPLE_KW[\"독소\"]):\n",
|
||
" return \"독소\"\n",
|
||
" return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n",
|
||
"\n",
|
||
"RULES = parse_rules(RULES_YAML)\n",
|
||
"RID = [r[\"id\"] for r in RULES]\n",
|
||
"WARN = {\"고위험\": 60, \"주의\": 30} # 위험 점수 등급 기준(점수 = 위반 확률 x 100)\n",
|
||
"\n",
|
||
"\n",
|
||
"def featurize(con, cm):\n",
|
||
" \"\"\"룰 16종 적중 여부 + 조항 모델이 찾은 독소·납기위험 조항 수 + 금액(로그).\"\"\"\n",
|
||
" d = derive(con)\n",
|
||
" H = np.array([[r in apply_rules(x, RULES)[2] for r in RID] for x in d.to_dict(\"records\")], float)\n",
|
||
" flat = [c for cl in con[\"clauses\"] for c in cl]\n",
|
||
" pred = np.array(cm.predict(flat)).reshape(len(con), 6)\n",
|
||
" nt, nd = (pred == \"독소\").sum(1), (pred == \"납기위험\").sum(1)\n",
|
||
" return np.column_stack([H, nt, nd, np.log1p(con[\"amount_m\"].to_numpy())]), H, nt, nd\n",
|
||
"\n",
|
||
"\n",
|
||
"FEAT_NAMES = [f\"{r['id']} {r['name']}\" for r in RULES] + [\"독소 조항 수(모델)\", \"납기위험 문구 수(모델)\", \"금액(로그)\"]\n",
|
||
"cm = fit_clause_model()\n",
|
||
"train, test = make_contracts(800, seed=11), make_contracts(300, seed=7)\n",
|
||
"Xtr, _, _, _ = featurize(train, cm)\n",
|
||
"Xte, Hte, nt, nd = featurize(test, cm)\n",
|
||
"lr = make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=1000)).fit(Xtr, train[\"violation\"].astype(int))\n",
|
||
"y = test[\"violation\"].astype(int).to_numpy()\n",
|
||
"p = lr.predict_proba(Xte)[:, 1]\n",
|
||
"score = p * 100\n",
|
||
"rule_flag = np.array([apply_rules(x, RULES)[1] != \"관심\" for x in derive(test).to_dict(\"records\")])\n",
|
||
"agree = (rule_flag == (p >= 0.5)).astype(float)\n",
|
||
"conf = np.clip(0.6 * np.abs(2 * p - 1) + 0.4 * agree, 0, 1) * 100 # 신뢰도 지수: 확률의 뚜렷함 + 룰·모델 일치\n",
|
||
"level = np.where(score >= WARN[\"고위험\"], \"고위험\", np.where(score >= WARN[\"주의\"], \"주의\", \"관심\"))\n",
|
||
"T = test.assign(score=score, conf=conf, level=level, y=y)\n",
|
||
"\n",
|
||
"# ===== 화면 1. 입력: 입력 요약과 검증 =====\n",
|
||
"miss = int(test[[\"amount_m\", \"penalty_permil\", \"advance_pct\"]].isna().sum().sum())\n",
|
||
"print(\"[화면 1. 입력] [교육용가상자료]\")\n",
|
||
"print(f\" 검증용 계약 {len(test)}건(조항 {len(test) * 6}개) / 학습 계약 {len(train)}건 / 입력 지표 {len(FEAT_NAMES)}개 / 필수값 결측 {miss}건\")\n",
|
||
"print(f\" 수의계약 {int((test['method'] == '수의계약').sum())}건, 평균 금액 {test['amount_m'].mean():.0f}백만원, 보안 조항 없음 {int((~test['security']).sum())}건\")\n",
|
||
"\n",
|
||
"# ===== 화면 2. 분석: 점수 분포와 성능 =====\n",
|
||
"auc = roc_auc_score(y, score)\n",
|
||
"print(\"\\n[화면 2. 분석] 위반 위험 점수와 모델 성능\")\n",
|
||
"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}\")\n",
|
||
"print(T.groupby(\"level\").agg(계약수=(\"contract_id\", \"size\"), 평균점수=(\"score\", \"mean\"), 실제위반율=(\"y\", \"mean\")).reindex([\"고위험\", \"주의\", \"관심\"]).round(2).to_string())\n",
|
||
"coef = pd.Series(lr[-1].coef_[0], index=FEAT_NAMES).sort_values(ascending=False)\n",
|
||
"print(\" 위험 상승 요인(회귀 계수 상위 4):\", \", \".join(f\"{k.split(' ')[0]} {v:+.2f}\" for k, v in coef.head(4).items()))\n",
|
||
"print(\" [신뢰도 지수와 실제 정확도] (판정 = 점수 50 이상을 위반 가능으로 봄)\")\n",
|
||
"bins = pd.cut(T[\"conf\"], [0, 50, 75, 90, 100.1], labels=[\"50 미만\", \"50~75\", \"75~90\", \"90 이상\"])\n",
|
||
"ok = ((p >= 0.5).astype(int) == y)\n",
|
||
"cal = pd.DataFrame({\"bin\": bins, \"ok\": ok}).groupby(\"bin\", observed=True).agg(건수=(\"ok\", \"size\"), 실제정확도=(\"ok\", \"mean\")).round(2)\n",
|
||
"print(cal.to_string())\n",
|
||
"\n",
|
||
"# ===== 화면 3. 검토: 고위험 계약을 담당자가 확인 =====\n",
|
||
"rev = T[T[\"level\"] == \"고위험\"].sort_values(\"score\", ascending=False)\n",
|
||
"contrib = (lr[0].transform(Xte) * lr[-1].coef_[0])\n",
|
||
"BADGE = {\"고위험\": \"[검토 필수]\", \"주의\": \"[확인 권고]\", \"관심\": \"[통과 후보]\"}\n",
|
||
"print(f\"\\n[화면 3. 검토] 고위험 {len(rev)}건 중 상위 5건 (담당자가 근거를 확인하고 조치 여부를 결정)\")\n",
|
||
"for i, r in rev.head(5).iterrows():\n",
|
||
" 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]\n",
|
||
" print(f\" {BADGE[r['level']]} {r['contract_id']} {r['ctype']} {r['amount_m']:.0f}백만원 | 위험 {r['score']:.0f}점 신뢰도 {r['conf']:.0f} | 요인 {', '.join(top) or '-'} | 상태 검토대기\")\n",
|
||
"\n",
|
||
"# ===== 화면 4. 결과: 요약, 뱃지 필터, 내보내기 =====\n",
|
||
"print(\"\\n[화면 4. 결과]\")\n",
|
||
"cnt = T[\"level\"].value_counts()\n",
|
||
"print(f\" {BADGE['고위험']} {cnt.get('고위험', 0)}건, {BADGE['주의']} {cnt.get('주의', 0)}건, {BADGE['관심']} {cnt.get('관심', 0)}건\")\n",
|
||
"f = T[(T[\"level\"] == \"주의\") & (T[\"conf\"] < 75)]\n",
|
||
"print(f\" 필터 예시: 등급 '주의' 이면서 신뢰도 75 미만 -> {len(f)}건(사람이 먼저 확인할 경계 사례)\")\n",
|
||
"print(f\" 고위험 등급의 실제 위반 {int(rev['y'].sum())}건({rev['y'].mean():.0%}) vs 관심 등급 {T[T['level'] == '관심']['y'].mean():.0%}\")\n",
|
||
"print(\" 내보내기 항목: 계약번호, 위험 점수, 신뢰도, 등급 뱃지, 요인, 검토 상태(검토대기)\")\n",
|
||
"print(\"화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)\")\n",
|
||
"print(\"※ 점수와 신뢰도는 우선순위를 정하는 참고값이며, 규정 위반 여부의 판단과 조치는 계약관·법무 담당자가 한다.\")\n",
|
||
"\n",
|
||
"fig, ax = plt.subplots(2, 2, figsize=(11, 7))\n",
|
||
"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)\")\n",
|
||
"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\")\n",
|
||
"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)\")\n",
|
||
"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\")\n",
|
||
"plt.tight_layout(); plt.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[화면 1. 입력] [교육용가상자료]\n",
|
||
" 검증용 계약 300건(조항 1800개) / 학습 계약 800건 / 입력 지표 19개 / 필수값 결측 0건\n",
|
||
" 수의계약 38건, 평균 금액 381백만원, 보안 조항 없음 10건\n",
|
||
"\n",
|
||
"[화면 2. 분석] 위반 위험 점수와 모델 성능\n",
|
||
" ROC-AUC 0.954, Brier 0.062 (실제 위반 비율 41%), 룰 점수 단독 AUC 0.889\n",
|
||
" 계약수 평균점수 실제위반율\n",
|
||
"level \n",
|
||
"고위험 112 90.48 0.94\n",
|
||
"주의 8 48.49 1.00\n",
|
||
"관심 180 10.12 0.05\n",
|
||
" 위험 상승 요인(회귀 계수 상위 4): 독소 +2.62, R07 +1.04, R01 +0.98, R04 +0.76\n",
|
||
" [신뢰도 지수와 실제 정확도] (판정 = 점수 50 이상을 위반 가능으로 봄)\n",
|
||
" 건수 실제정확도\n",
|
||
"bin \n",
|
||
"50 미만 38 0.84\n",
|
||
"50~75 31 0.87\n",
|
||
"75~90 67 0.97\n",
|
||
"90 이상 164 0.95\n",
|
||
"\n",
|
||
"[화면 3. 검토] 고위험 112건 중 상위 5건 (담당자가 근거를 확인하고 조치 여부를 결정)\n",
|
||
" [검토 필수] C-0222 물품 162백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R06:물품 하자보증 12 | 상태 검토대기\n",
|
||
" [검토 필수] C-0028 물품 590백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R08:보안 조항 없음 | 상태 검토대기\n",
|
||
" [검토 필수] C-0266 물품 68백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R08:보안 조항 없음 | 상태 검토대기\n",
|
||
" [검토 필수] C-0037 물품 62백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R10:하도급 비율 50% | 상태 검토대기\n",
|
||
" [검토 필수] C-0221 용역 101백만원 | 위험 100점 신뢰도 100 | 요인 독소:조항 수(모델), R10:하도급 비율 50% | 상태 검토대기\n",
|
||
"\n",
|
||
"[화면 4. 결과]\n",
|
||
" [검토 필수] 112건, [확인 권고] 8건, [통과 후보] 180건\n",
|
||
" 필터 예시: 등급 '주의' 이면서 신뢰도 75 미만 -> 8건(사람이 먼저 확인할 경계 사례)\n",
|
||
" 고위험 등급의 실제 위반 105건(94%) vs 관심 등급 5%\n",
|
||
" 내보내기 항목: 계약번호, 위험 점수, 신뢰도, 등급 뱃지, 요인, 검토 상태(검토대기)\n",
|
||
"화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)\n",
|
||
"※ 점수와 신뢰도는 우선순위를 정하는 참고값이며, 규정 위반 여부의 판단과 조치는 계약관·법무 담당자가 한다.\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 06. 계약서 조항별 위험도 하이라이트 및 조달 검토 대시보드 구현\n",
|
||
"**소요시간**: 약 2.5시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"계약서 조항별 위험도 하이라이트 및 조달 검토 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **계약서 조항별 위험도 하이라이트와 조달 검토 대시보드를 구현하고 입력 검증기·에러 핸들러로 예외 상황에서도 크래시 없이 동작하게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **계약 방식 x 유형 위험 지도와 위험 문구가 많은 계약의 조항별 하이라이트(독소·납기위험 표시, 위험 표현 강조)를 보여 주는 대시보드 작성하여 원인을 분석하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `src/utils/prc01_validator.py 및 에러 핸들러` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 사용자에게 알기 쉬운 에러 메시지 및 조치 가이드 제공\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 예외 상황 발생 시 크래시(Crash) 0건, 에러 로그 기록률 100%\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: 예외 주입 테스트 10종 통과 및 감사 로그 파일 생성\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 비정상 데이터(결측, 특수문자, 대용량) 스트레스 테스트\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"계약서 조항별 위험도 하이라이트와 계약 방식 x 유형 위험 지도를 보여 주는 조달 검토 대시보드에 입력 검증기·에러 핸들러·재시도·롤백을 붙이고, 깨진 입력 20종(결측·특수문자·대용량·일시 오류 포함)을 주입해 크래시 0건·에러 로그 기록률 100%·사용자용 조치 안내를 확인한다.\"\"\"\n",
|
||
"import csv\n",
|
||
"import io\n",
|
||
"import json\n",
|
||
"import re\n",
|
||
"import time\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
|
||
"from sklearn.linear_model import LogisticRegression\n",
|
||
"from sklearn.pipeline import make_pipeline\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n",
|
||
"RULES_YAML = \"\"\"\n",
|
||
"- id: R01\n",
|
||
" name: 지체상금률 1.0/1000 미만\n",
|
||
" field: penalty_permil\n",
|
||
" op: \"<\"\n",
|
||
" value: 1.0\n",
|
||
" weight: 3\n",
|
||
"- id: R02\n",
|
||
" name: 선금 비율 40% 초과\n",
|
||
" field: advance_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 40\n",
|
||
" weight: 2\n",
|
||
"- id: R03\n",
|
||
" name: 선금 40% 초과인데 이행보증 10% 미만\n",
|
||
" field: adv_no_bond\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R04\n",
|
||
" name: 300백만원 이상 수의계약\n",
|
||
" field: sole_amount\n",
|
||
" op: \">=\"\n",
|
||
" value: 300\n",
|
||
" weight: 3\n",
|
||
"- id: R05\n",
|
||
" name: 500백만원 이상 이행보증 10% 미만\n",
|
||
" field: bond_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R06\n",
|
||
" name: 물품 하자보증 12개월 미만\n",
|
||
" field: warranty_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R07\n",
|
||
" name: 해지 조항 없음\n",
|
||
" field: termination\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 2\n",
|
||
"- id: R08\n",
|
||
" name: 보안 조항 없음\n",
|
||
" field: security\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 3\n",
|
||
"- id: R09\n",
|
||
" name: 지식재산 조항 없음(물품·용역)\n",
|
||
" field: ip_missing\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R10\n",
|
||
" name: 하도급 비율 50% 초과\n",
|
||
" field: subcontract_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 50\n",
|
||
" weight: 3\n",
|
||
"- id: R11\n",
|
||
" name: 검사 기간 14일 초과\n",
|
||
" field: inspection_days\n",
|
||
" op: \">\"\n",
|
||
" value: 14\n",
|
||
" weight: 1\n",
|
||
"- id: R12\n",
|
||
" name: 납품 기한이 계약 기간 초과\n",
|
||
" field: logic_err\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R13\n",
|
||
" name: 180일 이상 계약에 물가변동 조항 없음\n",
|
||
" field: no_padj_long\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R14\n",
|
||
" name: 1000백만원 이상 비경쟁 계약\n",
|
||
" field: noncomp_big\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R15\n",
|
||
" name: 계약 기간 365일 초과\n",
|
||
" field: period_days\n",
|
||
" op: \">\"\n",
|
||
" value: 365\n",
|
||
" weight: 1\n",
|
||
"- id: R16\n",
|
||
" name: 불공정·납기 위험 키워드 포함\n",
|
||
" field: kw_hits\n",
|
||
" op: \">=\"\n",
|
||
" value: 1\n",
|
||
" weight: 3\n",
|
||
"\"\"\"\n",
|
||
"GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n",
|
||
"# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n",
|
||
"KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n",
|
||
" \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n",
|
||
"\n",
|
||
"\n",
|
||
"def parse_rules(text):\n",
|
||
" \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n",
|
||
" rules, cur = [], None\n",
|
||
" for line in text.strip().splitlines():\n",
|
||
" if not line.strip():\n",
|
||
" continue\n",
|
||
" s = line.strip()\n",
|
||
" if s.startswith(\"- \"):\n",
|
||
" cur = {}; rules.append(cur); s = s[2:]\n",
|
||
" k, v = [x.strip() for x in s.split(\":\", 1)]\n",
|
||
" v = v.strip('\"')\n",
|
||
" if v in (\"true\", \"false\"):\n",
|
||
" v = v == \"true\"\n",
|
||
" else:\n",
|
||
" try:\n",
|
||
" v = float(v) if \".\" in v else int(v)\n",
|
||
" except ValueError:\n",
|
||
" pass\n",
|
||
" cur[k] = v\n",
|
||
" return rules\n",
|
||
"\n",
|
||
"\n",
|
||
"OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n",
|
||
" \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n",
|
||
"\n",
|
||
"\n",
|
||
"def kw_count(clauses):\n",
|
||
" \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n",
|
||
" return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n",
|
||
"\n",
|
||
"\n",
|
||
"def derive(df):\n",
|
||
" \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n",
|
||
" d = df.copy()\n",
|
||
" d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n",
|
||
" d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n",
|
||
" d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n",
|
||
" d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n",
|
||
" d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n",
|
||
" d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n",
|
||
" d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"def apply_rules(rec, rules):\n",
|
||
" \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n",
|
||
" hits = []\n",
|
||
" for r in rules:\n",
|
||
" v = rec.get(r[\"field\"])\n",
|
||
" if v is None or (isinstance(v, float) and np.isnan(v)):\n",
|
||
" continue\n",
|
||
" if OPS[r[\"op\"]](v, r[\"value\"]):\n",
|
||
" hits.append(r)\n",
|
||
" score = sum(r[\"weight\"] for r in hits)\n",
|
||
" grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n",
|
||
" return score, grade, [r[\"id\"] for r in hits]\n",
|
||
"\n",
|
||
"SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n",
|
||
"\n",
|
||
"# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n",
|
||
"def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n",
|
||
" \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" y = rng.choice(CLASSES, n, p=list(mix))\n",
|
||
" return [make_clause(rng, c, hold) for c in y], list(y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def fit_clause_model(seed=100, n=1200):\n",
|
||
" \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n",
|
||
" X, y = clause_set(seed, n)\n",
|
||
" return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def keyword_pred(s):\n",
|
||
" \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n",
|
||
" if any(k in s for k in SIMPLE_KW[\"독소\"]):\n",
|
||
" return \"독소\"\n",
|
||
" return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n",
|
||
"\n",
|
||
"NUM = [\"amount_m\", \"period_days\", \"delivery_days\", \"penalty_permil\", \"advance_pct\", \"perf_bond_pct\", \"warranty_months\", \"inspection_days\", \"subcontract_pct\"]\n",
|
||
"BOOL = [\"price_adjust\", \"termination\", \"security\", \"ip_clause\"]\n",
|
||
"NEED = [\"contract_id\", \"ctype\", \"method\"] + NUM + BOOL + [\"clauses\"]\n",
|
||
"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),\n",
|
||
" \"warranty_months\": (0, 120), \"inspection_days\": (1, 90), \"subcontract_pct\": (0, 100)}\n",
|
||
"LOG, COMMITTED = [], {}\n",
|
||
"RULES = parse_rules(RULES_YAML)\n",
|
||
"CM = fit_clause_model()\n",
|
||
"MAX_CLAUSE = 600\n",
|
||
"\n",
|
||
"\n",
|
||
"class UserError(Exception):\n",
|
||
" \"\"\"사용자에게 보여 줄 메시지와 조치 가이드를 함께 가진 예외.\"\"\"\n",
|
||
"\n",
|
||
" def __init__(self, code, msg, action):\n",
|
||
" super().__init__(msg); self.code, self.msg, self.action = code, msg, action\n",
|
||
"\n",
|
||
"\n",
|
||
"class Transient(Exception):\n",
|
||
" \"\"\"일시 오류(재시도 대상).\"\"\"\n",
|
||
"\n",
|
||
"\n",
|
||
"def log(level, code, detail):\n",
|
||
" LOG.append({\"seq\": len(LOG) + 1, \"level\": level, \"code\": code, \"detail\": str(detail)[:70]})\n",
|
||
"\n",
|
||
"\n",
|
||
"def read_input(src):\n",
|
||
" \"\"\"입력(CSV 문자열 / JSON 문자열 / DataFrame)을 DataFrame으로 읽는다. 실패하면 UserError.\"\"\"\n",
|
||
" if src is None:\n",
|
||
" raise UserError(\"E01\", \"입력이 비어 있습니다.\", \"파일을 다시 선택해 주세요.\")\n",
|
||
" if isinstance(src, pd.DataFrame):\n",
|
||
" df = src.copy()\n",
|
||
" elif isinstance(src, str):\n",
|
||
" if not src.strip():\n",
|
||
" raise UserError(\"E02\", \"빈 파일입니다.\", \"내용이 있는 파일인지 확인해 주세요.\")\n",
|
||
" try:\n",
|
||
" df = pd.DataFrame(json.loads(src)) if src.lstrip()[0] in \"[{\" else pd.DataFrame(list(csv.DictReader(io.StringIO(src))))\n",
|
||
" except Exception as e:\n",
|
||
" raise UserError(\"E03\", \"파일 형식을 읽을 수 없습니다.\", \"CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.\") from e\n",
|
||
" else:\n",
|
||
" raise UserError(\"E04\", f\"지원하지 않는 입력 형식입니다({type(src).__name__}).\", \"CSV 또는 JSON 파일을 사용해 주세요.\")\n",
|
||
" if df.empty:\n",
|
||
" raise UserError(\"E02\", \"읽은 행이 0건입니다.\", \"머리글만 있는 파일이 아닌지 확인해 주세요.\")\n",
|
||
" return df\n",
|
||
"\n",
|
||
"\n",
|
||
"def clean_text(s):\n",
|
||
" \"\"\"특수문자·제어문자 제거, 지나치게 긴 조항은 잘라 내기.\"\"\"\n",
|
||
" s = re.sub(r\"[\\x00-\\x08\\x0b-\\x1f\\x7f<>]\", \" \", str(s))\n",
|
||
" return re.sub(r\"\\s+\", \" \", s).strip()[:MAX_CLAUSE]\n",
|
||
"\n",
|
||
"\n",
|
||
"def validate_table(df):\n",
|
||
" \"\"\"열 점검 -> 행 단위 점검. 문제 행은 격리하고 나머지로 계속 진행한다. 반환: (정상 행, 격리 내역)\"\"\"\n",
|
||
" miss = [c for c in NEED if c not in df.columns]\n",
|
||
" if miss:\n",
|
||
" raise UserError(\"E10\", f\"필수 열이 없습니다: {', '.join(miss[:4])}{' 외' if len(miss) > 4 else ''}\", \"열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\")\n",
|
||
" d, bad = df.copy().reset_index(drop=True), {}\n",
|
||
" for c in NUM:\n",
|
||
" conv = pd.to_numeric(d[c], errors=\"coerce\")\n",
|
||
" for i in d.index[conv.isna()]:\n",
|
||
" bad.setdefault(i, f\"{c}: 숫자가 아님/누락\")\n",
|
||
" d[c] = conv\n",
|
||
" for c, (lo, hi) in RANGE.items():\n",
|
||
" for i in d.index[(d[c] < lo) | (d[c] > hi)]:\n",
|
||
" bad.setdefault(i, f\"{c}: 허용 범위({lo}~{hi}) 밖\")\n",
|
||
" for c, ok in ((\"ctype\", TYPES), (\"method\", METHODS)):\n",
|
||
" for i in d.index[~d[c].isin(ok)]:\n",
|
||
" bad.setdefault(i, f\"{c}: 허용되지 않은 값\")\n",
|
||
" for i in d.index[d[\"contract_id\"].duplicated(keep=\"first\")]:\n",
|
||
" bad.setdefault(i, \"contract_id: 중복\")\n",
|
||
" for i in d.index[~d[\"contract_id\"].astype(str).str.match(r\"^C-\\d{4}$\")]:\n",
|
||
" bad.setdefault(i, \"contract_id: 형식 오류\")\n",
|
||
" for c in BOOL:\n",
|
||
" d[c] = d[c].map(lambda v: v if isinstance(v, (bool, np.bool_)) else str(v).strip().lower() in (\"true\", \"1\", \"예\", \"y\"))\n",
|
||
" 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()])\n",
|
||
" for i in d.index[d[\"clauses\"].map(len) == 0]:\n",
|
||
" bad.setdefault(i, \"clauses: 조항 없음\")\n",
|
||
" if len(bad) == len(d):\n",
|
||
" raise UserError(\"E11\", \"사용할 수 있는 행이 없습니다.\", \"격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.\")\n",
|
||
" return d.drop(index=list(bad)).reset_index(drop=True), bad\n",
|
||
"\n",
|
||
"\n",
|
||
"MARK = {\"독소\": \"[!!]\", \"납기위험\": \"[! ]\", \"정상\": \"[ok]\"}\n",
|
||
"\n",
|
||
"\n",
|
||
"def highlight(clauses):\n",
|
||
" \"\"\"조항별 위험 표시: 모델 분류 결과와 키워드 위치를 【 】로 강조한다.\"\"\"\n",
|
||
" cls = CM.predict(clauses)\n",
|
||
" out = []\n",
|
||
" for c, k in zip(clauses, cls):\n",
|
||
" for kw in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]:\n",
|
||
" if k != \"정상\" and kw in c:\n",
|
||
" c = c.replace(kw, f\"【{kw}】\")\n",
|
||
" out.append((k, c))\n",
|
||
" return out\n",
|
||
"\n",
|
||
"\n",
|
||
"def build_dashboard(df, fail_times=0, state=None):\n",
|
||
" \"\"\"대시보드 산출물을 한 번에 만든다(전부 성공해야 반영 = 트랜잭션). 반환: 결과 dict, 격리 내역\"\"\"\n",
|
||
" d, bad = validate_table(read_input(df))\n",
|
||
" if state is not None and state[\"calls\"] < fail_times:\n",
|
||
" state[\"calls\"] += 1\n",
|
||
" raise Transient(\"외부 저장소 응답 지연(모의)\")\n",
|
||
" dd = derive(d)\n",
|
||
" dd[\"score\"] = [apply_rules(r, RULES)[0] for r in dd.to_dict(\"records\")]\n",
|
||
" heat = dd.pivot_table(index=\"method\", columns=\"ctype\", values=\"score\", aggfunc=\"mean\").round(1)\n",
|
||
" top = dd.sort_values([\"kw_hits\", \"score\"], ascending=False).head(3) # 위험 문구가 많은 계약부터\n",
|
||
" hl = {r[\"contract_id\"]: highlight(r[\"clauses\"]) for _, r in top.iterrows()}\n",
|
||
" return {\"heat\": heat, \"top\": top[[\"contract_id\", \"ctype\", \"method\", \"amount_m\", \"score\"]], \"hl\": hl, \"n\": len(dd)}, bad\n",
|
||
"\n",
|
||
"\n",
|
||
"def safe_dashboard(case, src, fail_times=0, always_fail=False):\n",
|
||
" \"\"\"모든 예외를 잡아 크래시 없이 사용자 메시지·조치 안내를 돌려주고 에러 로그를 남긴다. 일시 오류는 3회까지 재시도하고, 끝내 실패하면 직전 정상 결과로 롤백한다.\"\"\"\n",
|
||
" state = {\"calls\": 0}\n",
|
||
" for attempt in range(1, 4):\n",
|
||
" try:\n",
|
||
" if always_fail:\n",
|
||
" raise Transient(\"저장소 연결 불가(모의)\")\n",
|
||
" res, bad = build_dashboard(src, fail_times, state)\n",
|
||
" for i, why in bad.items():\n",
|
||
" log(\"WARN\", \"W01\", f\"{case}: 행 {i} 격리 - {why}\")\n",
|
||
" COMMITTED[\"last\"] = res # 전부 성공했을 때만 반영\n",
|
||
" return {\"case\": case, \"ok\": True, \"msg\": f\"표시 완료(격리 {len(bad)}행)\" if bad else \"표시 완료\" + (f\", 재시도 {attempt - 1}회 후 성공\" if attempt > 1 else \"\"), \"action\": \"-\"}\n",
|
||
" except UserError as e:\n",
|
||
" log(\"ERROR\", e.code, f\"{case}: {e.msg}\")\n",
|
||
" return {\"case\": case, \"ok\": False, \"msg\": e.msg, \"action\": e.action}\n",
|
||
" except Transient as e:\n",
|
||
" log(\"WARN\", \"W02\", f\"{case}: 일시 오류 시도 {attempt}/3 - {e}\")\n",
|
||
" time.sleep(0.01)\n",
|
||
" except Exception as e: # 예상하지 못한 오류도 크래시 대신 안내로 변환\n",
|
||
" log(\"ERROR\", \"E99\", f\"{case}: {type(e).__name__} {e}\")\n",
|
||
" return {\"case\": case, \"ok\": False, \"msg\": \"예상하지 못한 오류가 발생했습니다.\", \"action\": \"오류 코드 E99와 입력 파일을 담당자에게 전달해 주세요.\"}\n",
|
||
" log(\"ERROR\", \"E50\", f\"{case}: 재시도 3회 실패 -> 직전 정상 결과로 롤백\")\n",
|
||
" return {\"case\": case, \"ok\": False, \"msg\": \"일시적인 문제로 저장하지 못했습니다. 직전 정상 화면을 유지합니다.\", \"action\": \"잠시 후 다시 시도해 주세요.\"}\n",
|
||
"\n",
|
||
"\n",
|
||
"con = make_contracts(100, seed=7)\n",
|
||
"base = con.assign(clauses=con[\"clauses\"].map(lambda c: \" || \".join(c))) # CSV처럼 조항을 한 칸에 이어 붙인 형태\n",
|
||
"csv_ok = base.drop(columns=[\"clause_cls\", \"n_toxic\", \"n_delay\", \"violation\", \"vtype\"]).to_csv(index=False)\n",
|
||
"\n",
|
||
"\n",
|
||
"def mut(**kw):\n",
|
||
" d = base.copy()\n",
|
||
" for k, v in kw.items():\n",
|
||
" d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"big = pd.concat([base] * 200, ignore_index=True)\n",
|
||
"big[\"contract_id\"] = [f\"C-{i % 10000:04d}\" for i in range(len(big))] # 대용량(20,000행, 번호 중복은 격리 대상)\n",
|
||
"cases = [\n",
|
||
" (\"정상 입력\", base, {}), (\"정상 CSV 문자열\", csv_ok, {}), (\"입력 없음(None)\", None, {}), (\"빈 문자열\", \" \", {}), (\"깨진 JSON\", '[{\"contract_id\": \"C-0001\", ', {}),\n",
|
||
" (\"지원하지 않는 형식(정수)\", 12345, {}), (\"머리글만 있는 CSV\", \",\".join(base.columns) + \"\\n\", {}), (\"필수 열 누락\", base.drop(columns=[\"penalty_permil\", \"clauses\"]), {}),\n",
|
||
" (\"숫자 열에 문자\", mut(amount_m=\"미정\"), {}), (\"음수 금액\", mut(amount_m=-5), {}), (\"계약 방식 결측\", mut(method=np.nan), {}), (\"허용되지 않은 유형\", mut(ctype=\"기타\"), {}),\n",
|
||
" (\"계약번호 중복·형식 오류\", mut(contract_id=\"C-9\"), {}), (\"조항 칸이 비어 있음\", mut(clauses=\"\"), {}),\n",
|
||
" (\"특수문자·제어문자·태그\", mut(clauses=\"<script>alert(1)</script> \\x00\\x07 지체상금은 전액 면제하며\"), {}),\n",
|
||
" (\"조항 문장 1MB\", mut(clauses=\"가\" * 1_000_000), {}), (\"극단값(1e12)\", mut(amount_m=1e12), {}),\n",
|
||
" (\"대용량 20,000행\", big, {}), (\"일시 오류 2회 후 성공\", base, {\"fail_times\": 2}), (\"끝내 실패(롤백)\", base, {\"always_fail\": True}),\n",
|
||
"]\n",
|
||
"res, crash, times = [], 0, {}\n",
|
||
"for name, src, kw in cases:\n",
|
||
" t0 = time.perf_counter()\n",
|
||
" try:\n",
|
||
" res.append(safe_dashboard(name, src, **kw))\n",
|
||
" except BaseException: # safe_dashboard 밖으로 예외가 새 나간 건수\n",
|
||
" crash += 1\n",
|
||
" times[name] = time.perf_counter() - t0\n",
|
||
"print(f\"[에러 핸들러 시험] 입력 {len(cases)}종 실행\")\n",
|
||
"for r in res:\n",
|
||
" print(f\" {'OK ' if r['ok'] else '안내'} {r['case']:22s} -> {r['msg'][:46]}\" + (f\" | 조치: {r['action'][:34]}\" if r[\"action\"] != \"-\" else \"\"))\n",
|
||
"n_err = sum(not r[\"ok\"] for r in res)\n",
|
||
"logged_err = sum(l[\"level\"] == \"ERROR\" for l in LOG)\n",
|
||
"print(f\"\\n예외 주입 {len(cases)}종 실행 완료(목표 10종 이상: {'달성' if len(cases) >= 10 else '미달'}), 크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'})\")\n",
|
||
"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}건\")\n",
|
||
"print(f\"행 격리 경고 {sum(l['code'] == 'W01' for l in LOG)}건, 재시도 경고 {sum(l['code'] == 'W02' for l in LOG)}건 기록, 대용량 20,000행 처리 {times['대용량 20,000행']:.1f}초\")\n",
|
||
"print(\"직전 정상 결과 유지(롤백): 마지막 반영된 화면의 계약 수 =\", COMMITTED[\"last\"][\"n\"], \"건\")\n",
|
||
"print(\"\\n[에러 로그 앞 5건]\")\n",
|
||
"print(pd.DataFrame(LOG).head(5).to_string(index=False))\n",
|
||
"\n",
|
||
"# 정상 입력으로 대시보드(조항 하이라이트 + 위험 지도) 보여 주기\n",
|
||
"out, bad = build_dashboard(base)\n",
|
||
"print(\"\\n[위험 지도] 계약 방식 x 유형 평균 룰 점수\")\n",
|
||
"print(out[\"heat\"].fillna(0).to_string())\n",
|
||
"print(\"\\n[조항별 위험 하이라이트] 위험 문구가 많은 상위 3건 (【 】= 위험 표현, [!!]=독소, [! ]=납기위험, [ok]=정상)\")\n",
|
||
"for _, r in out[\"top\"].iterrows():\n",
|
||
" print(f\" {r['contract_id']} {r['ctype']} {r['method']} {r['amount_m']:.0f}백만원 룰 점수 {r['score']}\")\n",
|
||
" for k, c in out[\"hl\"][r[\"contract_id\"]]:\n",
|
||
" print(f\" {MARK[k]} {c[:52]}\")\n",
|
||
"print(\"※ 하이라이트는 검토 우선순위 표시이며 불공정 여부의 최종 판단은 계약관·법무 담당자가 한다.\")\n",
|
||
"\n",
|
||
"heat = out[\"heat\"].fillna(0)\n",
|
||
"fig, ax = plt.subplots(1, 3, figsize=(13, 4))\n",
|
||
"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])])\n",
|
||
"ax[0].set_title(\"Risk map (method x type)\"); plt.colorbar(im, ax=ax[0])\n",
|
||
"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)\")\n",
|
||
"cnt = pd.Series([k for _, cl in out[\"hl\"].items() for k, _ in cl]).value_counts()\n",
|
||
"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\")\n",
|
||
"plt.tight_layout(); plt.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[에러 핸들러 시험] 입력 20종 실행\n",
|
||
" OK 정상 입력 -> 표시 완료\n",
|
||
" OK 정상 CSV 문자열 -> 표시 완료\n",
|
||
" 안내 입력 없음(None) -> 입력이 비어 있습니다. | 조치: 파일을 다시 선택해 주세요.\n",
|
||
" 안내 빈 문자열 -> 빈 파일입니다. | 조치: 내용이 있는 파일인지 확인해 주세요.\n",
|
||
" 안내 깨진 JSON -> 파일 형식을 읽을 수 없습니다. | 조치: CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 \n",
|
||
" 안내 지원하지 않는 형식(정수) -> 지원하지 않는 입력 형식입니다(int). | 조치: CSV 또는 JSON 파일을 사용해 주세요.\n",
|
||
" 안내 머리글만 있는 CSV -> 읽은 행이 0건입니다. | 조치: 머리글만 있는 파일이 아닌지 확인해 주세요.\n",
|
||
" 안내 필수 열 누락 -> 필수 열이 없습니다: penalty_permil, clauses | 조치: 열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.\n",
|
||
" OK 숫자 열에 문자 -> 표시 완료(격리 3행)\n",
|
||
" OK 음수 금액 -> 표시 완료(격리 3행)\n",
|
||
" OK 계약 방식 결측 -> 표시 완료(격리 3행)\n",
|
||
" OK 허용되지 않은 유형 -> 표시 완료(격리 3행)\n",
|
||
" OK 계약번호 중복·형식 오류 -> 표시 완료(격리 3행)\n",
|
||
" OK 조항 칸이 비어 있음 -> 표시 완료(격리 3행)\n",
|
||
" OK 특수문자·제어문자·태그 -> 표시 완료\n",
|
||
" OK 조항 문장 1MB -> 표시 완료\n",
|
||
" OK 극단값(1e12) -> 표시 완료(격리 3행)\n",
|
||
" OK 대용량 20,000행 -> 표시 완료(격리 10000행)\n",
|
||
" OK 일시 오류 2회 후 성공 -> 표시 완료, 재시도 2회 후 성공\n",
|
||
" 안내 끝내 실패(롤백) -> 일시적인 문제로 저장하지 못했습니다. 직전 정상 화면을 유지합니다. | 조치: 잠시 후 다시 시도해 주세요.\n",
|
||
"\n",
|
||
"예외 주입 20종 실행 완료(목표 10종 이상: 달성), 크래시 0건(목표 0건: 달성)\n",
|
||
"오류 7건 중 ERROR 로그 7건 -> 기록률 100%(목표 100%: 달성), 안내 문구에 조치 가이드 포함 7/7건\n",
|
||
"행 격리 경고 10021건, 재시도 경고 5건 기록, 대용량 20,000행 처리 2.6초\n",
|
||
"직전 정상 결과 유지(롤백): 마지막 반영된 화면의 계약 수 = 100 건\n",
|
||
"\n",
|
||
"[에러 로그 앞 5건]\n",
|
||
" seq level code detail\n",
|
||
" 1 ERROR E01 입력 없음(None): 입력이 비어 있습니다.\n",
|
||
" 2 ERROR E02 빈 문자열: 빈 파일입니다.\n",
|
||
" 3 ERROR E03 깨진 JSON: 파일 형식을 읽을 수 없습니다.\n",
|
||
" 4 ERROR E04 지원하지 않는 형식(정수): 지원하지 않는 입력 형식입니다(int).\n",
|
||
" 5 ERROR E02 머리글만 있는 CSV: 읽은 행이 0건입니다.\n",
|
||
"\n",
|
||
"[위험 지도] 계약 방식 x 유형 평균 룰 점수\n",
|
||
"ctype 공사 물품 용역\n",
|
||
"method \n",
|
||
"수의계약 1.0 2.5 2.3\n",
|
||
"일반경쟁 2.7 1.8 1.1\n",
|
||
"... (이하 생략)\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 07. 계약 승인 전 법무/계약관 수동 검토 및 조항 수정 권고 HITL 절차 설계\n",
|
||
"**소요시간**: 약 3.0시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"계약 승인 전 계약관 수동 검토 및 조항 수정 권고 HITL 절차를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **계약 승인 전 계약관·법무 수동 검토와 조항 수정 권고를 위한 HITL 절차를 설계하고 이력 무결성과 권한 통제를 검증한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **시스템 제안, 계약담당 상신, 계약관 검토, 법무 검토, 승인권자 결재의 순서를 상태 기계로 구현하고 역할별 권한을 최소로 둔다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `src/workflow/prc01_approval_system.py` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 군 보안 규정 및 행정 절차 부합성\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 이력 기록 무결성 100%, 승인 권한 우회 시도 차단율 100%\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: 결재선 승인/반려 시뮬레이션 및 이력 조회 화면 구현\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 권한별 접근 제어(RBAC) 테스트\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"고위험 계약의 조항 수정 권고를 계약담당 상신 -> 계약관 검토 -> 법무 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 반려 사유 필수화·재기안 연계·시스템 자동 확정 금지·권한 우회 차단(RBAC)과 해시 체인 감사 로그의 이력 무결성·위변조 탐지를 검증한다.\"\"\"\n",
|
||
"import hashlib\n",
|
||
"import json\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
|
||
"from sklearn.linear_model import LogisticRegression\n",
|
||
"from sklearn.pipeline import make_pipeline\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n",
|
||
"RULES_YAML = \"\"\"\n",
|
||
"- id: R01\n",
|
||
" name: 지체상금률 1.0/1000 미만\n",
|
||
" field: penalty_permil\n",
|
||
" op: \"<\"\n",
|
||
" value: 1.0\n",
|
||
" weight: 3\n",
|
||
"- id: R02\n",
|
||
" name: 선금 비율 40% 초과\n",
|
||
" field: advance_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 40\n",
|
||
" weight: 2\n",
|
||
"- id: R03\n",
|
||
" name: 선금 40% 초과인데 이행보증 10% 미만\n",
|
||
" field: adv_no_bond\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R04\n",
|
||
" name: 300백만원 이상 수의계약\n",
|
||
" field: sole_amount\n",
|
||
" op: \">=\"\n",
|
||
" value: 300\n",
|
||
" weight: 3\n",
|
||
"- id: R05\n",
|
||
" name: 500백만원 이상 이행보증 10% 미만\n",
|
||
" field: bond_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R06\n",
|
||
" name: 물품 하자보증 12개월 미만\n",
|
||
" field: warranty_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R07\n",
|
||
" name: 해지 조항 없음\n",
|
||
" field: termination\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 2\n",
|
||
"- id: R08\n",
|
||
" name: 보안 조항 없음\n",
|
||
" field: security\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 3\n",
|
||
"- id: R09\n",
|
||
" name: 지식재산 조항 없음(물품·용역)\n",
|
||
" field: ip_missing\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R10\n",
|
||
" name: 하도급 비율 50% 초과\n",
|
||
" field: subcontract_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 50\n",
|
||
" weight: 3\n",
|
||
"- id: R11\n",
|
||
" name: 검사 기간 14일 초과\n",
|
||
" field: inspection_days\n",
|
||
" op: \">\"\n",
|
||
" value: 14\n",
|
||
" weight: 1\n",
|
||
"- id: R12\n",
|
||
" name: 납품 기한이 계약 기간 초과\n",
|
||
" field: logic_err\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R13\n",
|
||
" name: 180일 이상 계약에 물가변동 조항 없음\n",
|
||
" field: no_padj_long\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R14\n",
|
||
" name: 1000백만원 이상 비경쟁 계약\n",
|
||
" field: noncomp_big\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R15\n",
|
||
" name: 계약 기간 365일 초과\n",
|
||
" field: period_days\n",
|
||
" op: \">\"\n",
|
||
" value: 365\n",
|
||
" weight: 1\n",
|
||
"- id: R16\n",
|
||
" name: 불공정·납기 위험 키워드 포함\n",
|
||
" field: kw_hits\n",
|
||
" op: \">=\"\n",
|
||
" value: 1\n",
|
||
" weight: 3\n",
|
||
"\"\"\"\n",
|
||
"GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n",
|
||
"# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n",
|
||
"KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n",
|
||
" \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n",
|
||
"\n",
|
||
"\n",
|
||
"def parse_rules(text):\n",
|
||
" \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n",
|
||
" rules, cur = [], None\n",
|
||
" for line in text.strip().splitlines():\n",
|
||
" if not line.strip():\n",
|
||
" continue\n",
|
||
" s = line.strip()\n",
|
||
" if s.startswith(\"- \"):\n",
|
||
" cur = {}; rules.append(cur); s = s[2:]\n",
|
||
" k, v = [x.strip() for x in s.split(\":\", 1)]\n",
|
||
" v = v.strip('\"')\n",
|
||
" if v in (\"true\", \"false\"):\n",
|
||
" v = v == \"true\"\n",
|
||
" else:\n",
|
||
" try:\n",
|
||
" v = float(v) if \".\" in v else int(v)\n",
|
||
" except ValueError:\n",
|
||
" pass\n",
|
||
" cur[k] = v\n",
|
||
" return rules\n",
|
||
"\n",
|
||
"\n",
|
||
"OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n",
|
||
" \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n",
|
||
"\n",
|
||
"\n",
|
||
"def kw_count(clauses):\n",
|
||
" \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n",
|
||
" return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n",
|
||
"\n",
|
||
"\n",
|
||
"def derive(df):\n",
|
||
" \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n",
|
||
" d = df.copy()\n",
|
||
" d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n",
|
||
" d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n",
|
||
" d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n",
|
||
" d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n",
|
||
" d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n",
|
||
" d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n",
|
||
" d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"def apply_rules(rec, rules):\n",
|
||
" \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n",
|
||
" hits = []\n",
|
||
" for r in rules:\n",
|
||
" v = rec.get(r[\"field\"])\n",
|
||
" if v is None or (isinstance(v, float) and np.isnan(v)):\n",
|
||
" continue\n",
|
||
" if OPS[r[\"op\"]](v, r[\"value\"]):\n",
|
||
" hits.append(r)\n",
|
||
" score = sum(r[\"weight\"] for r in hits)\n",
|
||
" grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n",
|
||
" return score, grade, [r[\"id\"] for r in hits]\n",
|
||
"\n",
|
||
"SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n",
|
||
"\n",
|
||
"# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n",
|
||
"def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n",
|
||
" \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" y = rng.choice(CLASSES, n, p=list(mix))\n",
|
||
" return [make_clause(rng, c, hold) for c in y], list(y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def fit_clause_model(seed=100, n=1200):\n",
|
||
" \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n",
|
||
" X, y = clause_set(seed, n)\n",
|
||
" return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def keyword_pred(s):\n",
|
||
" \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n",
|
||
" if any(k in s for k in SIMPLE_KW[\"독소\"]):\n",
|
||
" return \"독소\"\n",
|
||
" return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n",
|
||
"\n",
|
||
"# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 할 수 있고 확정 권한이 없다 ----\n",
|
||
"PERM = {\"시스템\": {\"propose\"}, \"계약담당\": {\"submit\", \"redraft\", \"view\"}, \"계약관\": {\"review_contract\", \"reject\", \"view\"}, \"법무\": {\"review_legal\", \"reject\", \"view\"},\n",
|
||
" \"승인권자\": {\"approve\", \"reject\", \"view\"}, \"감사관\": {\"view\", \"audit\"},\n",
|
||
" \"부서장\": {\"submit\", \"approve\", \"reject\", \"view\"}} # 소규모 부서는 상신과 결재를 겸할 수 있으나 같은 건은 분리\n",
|
||
"NEXT = {\"제안\": (\"submit\", \"상신\"), \"상신\": (\"review_contract\", \"계약관검토\"), \"계약관검토\": (\"review_legal\", \"법무검토\"), \"법무검토\": (\"approve\", \"승인\")}\n",
|
||
"USERS = {\"sys\": \"시스템\", \"kim\": \"계약담당\", \"lee\": \"계약관\", \"park\": \"법무\", \"choi\": \"승인권자\", \"jung\": \"감사관\", \"oh\": \"부서장\"}\n",
|
||
"MAX_REDRAFT = 2\n",
|
||
"\n",
|
||
"\n",
|
||
"class Chain:\n",
|
||
" \"\"\"해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다.\"\"\"\n",
|
||
"\n",
|
||
" def __init__(self):\n",
|
||
" self.log = []\n",
|
||
"\n",
|
||
" def add(self, **body):\n",
|
||
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
|
||
" body = {\"seq\": len(self.log), **body}\n",
|
||
" self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})\n",
|
||
"\n",
|
||
" def verify(self):\n",
|
||
" prev = \"GENESIS\"\n",
|
||
" for e in self.log:\n",
|
||
" b = {k: v for k, v in e.items() if k not in (\"prev\", \"hash\")}\n",
|
||
" if e[\"prev\"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e[\"hash\"]:\n",
|
||
" return e[\"seq\"]\n",
|
||
" prev = e[\"hash\"]\n",
|
||
" return None\n",
|
||
"\n",
|
||
"\n",
|
||
"class Workflow:\n",
|
||
" def __init__(self):\n",
|
||
" self.audit, self.cases, self.blocked = Chain(), {}, []\n",
|
||
"\n",
|
||
" def _deny(self, who, case, action, why):\n",
|
||
" self.audit.add(event=\"DENIED\", who=who, case=case, action=action, reason=why)\n",
|
||
" self.blocked.append((who, case, action, why)); return False, why\n",
|
||
"\n",
|
||
" def act(self, who, case, action, reason=\"\", evidence=\"\"):\n",
|
||
" role = USERS.get(who)\n",
|
||
" if role is None:\n",
|
||
" return self._deny(who, case, action, \"미등록 사용자\")\n",
|
||
" if action == \"delete_log\":\n",
|
||
" return self._deny(who, case, action, \"감사 로그 삭제·수정 권한은 누구에게도 없음\")\n",
|
||
" if action not in PERM[role]:\n",
|
||
" return self._deny(who, case, action, f\"권한 없음({role} 역할에 {action} 권한 없음)\")\n",
|
||
" if action == \"propose\":\n",
|
||
" self.cases[case] = {\"state\": \"제안\", \"submitter\": None, \"version\": 1}\n",
|
||
" self.audit.add(event=\"PROPOSE\", who=who, case=case, reason=reason); return True, \"제안 등록\"\n",
|
||
" c = self.cases.get(case)\n",
|
||
" if c is None:\n",
|
||
" return self._deny(who, case, action, \"존재하지 않는 건\")\n",
|
||
" if c[\"state\"] in (\"승인\", \"종결(반려)\"):\n",
|
||
" return self._deny(who, case, action, f\"이미 종결된 건({c['state']})\")\n",
|
||
" if not reason.strip() or not evidence.strip():\n",
|
||
" return self._deny(who, case, action, \"사유·근거 입력 필수(반려 사유 포함)\")\n",
|
||
" if action == \"reject\":\n",
|
||
" if c[\"state\"] in (\"제안\", \"반려\"):\n",
|
||
" return self._deny(who, case, action, f\"반려할 수 없는 단계({c['state']})\")\n",
|
||
" c[\"state\"] = \"종결(반려)\" if c[\"version\"] > MAX_REDRAFT else \"반려\"\n",
|
||
" self.audit.add(event=\"REJECTED\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, c[\"state\"]\n",
|
||
" if action == \"redraft\":\n",
|
||
" if c[\"state\"] != \"반려\":\n",
|
||
" return self._deny(who, case, action, f\"재기안은 반려된 건만 가능(현재 {c['state']})\")\n",
|
||
" if who != c[\"submitter\"]:\n",
|
||
" return self._deny(who, case, action, \"재기안은 최초 상신자만 가능\")\n",
|
||
" c[\"state\"], c[\"version\"] = \"상신\", c[\"version\"] + 1\n",
|
||
" self.audit.add(event=\"REDRAFT\", who=who, role=role, case=case, version=c[\"version\"], reason=reason, evidence=evidence); return True, \"재기안 상신\"\n",
|
||
" if c[\"state\"] == \"반려\":\n",
|
||
" return self._deny(who, case, action, \"반려된 건은 재기안 후에만 진행 가능\")\n",
|
||
" need, nxt = NEXT[c[\"state\"]]\n",
|
||
" if action != need:\n",
|
||
" return self._deny(who, case, action, f\"단계 위반(현재 {c['state']}, 필요 행위 {need})\")\n",
|
||
" if action == \"submit\":\n",
|
||
" c[\"submitter\"] = who\n",
|
||
" if action == \"approve\" and who == c[\"submitter\"]:\n",
|
||
" return self._deny(who, case, action, \"상신자와 승인자 분리 원칙 위반\")\n",
|
||
" c[\"state\"] = nxt\n",
|
||
" self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c[\"version\"], to=nxt, reason=reason, evidence=evidence); return True, nxt\n",
|
||
"\n",
|
||
"\n",
|
||
"# ---- 상신 대상: 룰 점수가 높은 계약 12건과 모델이 찾은 위험 조항 ----\n",
|
||
"cm = fit_clause_model()\n",
|
||
"rules = parse_rules(RULES_YAML)\n",
|
||
"con = make_contracts(100, seed=7)\n",
|
||
"d = derive(con)\n",
|
||
"sc = [apply_rules(r, rules) for r in d.to_dict(\"records\")]\n",
|
||
"d[\"score\"], d[\"hits\"] = [x[0] for x in sc], [x[2] for x in sc]\n",
|
||
"picked = d.sort_values(\"score\", ascending=False).head(12)\n",
|
||
"rng = np.random.default_rng(1)\n",
|
||
"LEGAL = {\"R08\", \"R07\", \"R12\", \"R10\"} # 법무 검토에서 보는 항목(보안·해지·논리·하도급)\n",
|
||
"\n",
|
||
"wf = Workflow()\n",
|
||
"rows = []\n",
|
||
"for k, (_, p) in enumerate(picked.iterrows(), 1):\n",
|
||
" case = f\"C{k:02d}\"\n",
|
||
" flagged = [i + 1 for i, c in enumerate(cm.predict(list(p[\"clauses\"]))) if c != \"정상\"]\n",
|
||
" wf.act(\"sys\", case, \"propose\", reason=f\"{p['contract_id']} 룰 점수 {p['score']}, 위험 조항 {len(flagged)}개 -> 수정 권고 제안\")\n",
|
||
" wf.act(\"kim\", case, \"submit\", \"위험 조항 수정 권고 검토 요청\", f\"룰 적중 {','.join(p['hits'][:4])}, 위험 조항 {flagged or '없음'}\")\n",
|
||
" c_issue, l_issue = bool(flagged), sorted(LEGAL & set(p[\"hits\"])) # 계약관이 볼 문제, 법무가 볼 문제\n",
|
||
" note, big, justified = \"승인권자 결정\", p[\"amount_m\"] >= 1000 and p[\"method\"] != \"일반경쟁\", False\n",
|
||
" for _ in range(8):\n",
|
||
" state = wf.cases[case][\"state\"]\n",
|
||
" if state in (\"승인\", \"종결(반려)\"):\n",
|
||
" break\n",
|
||
" if state == \"상신\":\n",
|
||
" if c_issue:\n",
|
||
" wf.act(\"lee\", case, \"reject\", f\"위험 조항 {flagged} 수정 필요\", \"조항 모델 위험 분류와 계약관 판단 일치\")\n",
|
||
" else:\n",
|
||
" wf.act(\"lee\", case, \"review_contract\", \"조항 검토 완료\", f\"위험 조항 {'정리됨' if flagged else '없음'}\")\n",
|
||
" elif state == \"계약관검토\":\n",
|
||
" if rng.random() < 0.15:\n",
|
||
" note = \"자동 확정 없음: 법무 응답 대기\"; break # 무응답은 보류로 둔다\n",
|
||
" if l_issue:\n",
|
||
" wf.act(\"park\", case, \"reject\", f\"필수 조항 보완 필요({','.join(l_issue)})\", \"해지·보안·하도급·납품 기한 조건 점검\")\n",
|
||
" else:\n",
|
||
" wf.act(\"park\", case, \"review_legal\", \"법무 검토 완료\", \"필수 조항 점검 이상 없음\")\n",
|
||
" elif state == \"법무검토\":\n",
|
||
" if big and not justified:\n",
|
||
" wf.act(\"choi\", case, \"reject\", \"고액 비경쟁 계약은 사유 소명 후 재상신\", f\"금액 {p['amount_m']:.0f}백만원, {p['method']}\")\n",
|
||
" else:\n",
|
||
" wf.act(\"choi\", case, \"approve\", \"승인: 수정본으로 계약 의뢰 진행\", f\"룰 점수 {p['score']}, 위험 조항·필수 조항 정리 완료\")\n",
|
||
" elif state == \"반려\":\n",
|
||
" wf.act(\"kim\", case, \"redraft\", \"반려 사유 반영해 수정\", f\"수정 대상 {flagged if c_issue else l_issue or ['소명 자료']}, 재기안 {wf.cases[case]['version']}차\")\n",
|
||
" if rng.random() < 0.65: # 재기안 후 문제가 정리될 확률(모의)\n",
|
||
" c_issue, l_issue = False, []\n",
|
||
" justified = True # 고액 비경쟁 건은 재기안 때 사유 소명서를 첨부(모의)\n",
|
||
" final = wf.cases[case][\"state\"]\n",
|
||
" if final not in (\"승인\", \"종결(반려)\") and note.startswith(\"자동\"):\n",
|
||
" final = \"보류(무응답)\"\n",
|
||
" elif final == \"종결(반려)\":\n",
|
||
" note = \"재기안 한도(2회) 초과\"\n",
|
||
" rows.append((case, p[\"contract_id\"], final, wf.cases[case][\"version\"], note))\n",
|
||
"print(\"[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\")\n",
|
||
"print(pd.DataFrame(rows, columns=[\"건\", \"계약\", \"최종 상태\", \"버전\", \"비고\"]).to_string(index=False))\n",
|
||
"st = pd.Series([r[2] for r in rows]).value_counts().to_dict()\n",
|
||
"n_redraft = sum(e[\"event\"] == \"REDRAFT\" for e in wf.audit.log)\n",
|
||
"print(\"상태 집계:\", st, f\"/ 반려 후 재기안 {n_redraft}회, 반려 이력 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(모두 사유·근거 기록)\")\n",
|
||
"\n",
|
||
"# ---- 권한 우회·절차 위반 시도 ----\n",
|
||
"wf.act(\"sys\", \"T01\", \"propose\", reason=\"시험 건\")\n",
|
||
"wf.act(\"kim\", \"T01\", \"submit\", \"시험 상신\", \"근거\")\n",
|
||
"attempts = [\n",
|
||
" (\"시스템 자동 승인\", lambda: wf.act(\"sys\", \"T01\", \"approve\", \"자동\", \"자동\")),\n",
|
||
" (\"계약담당이 계약관 검토 대행\", lambda: wf.act(\"kim\", \"T01\", \"review_contract\", \"대행\", \"근거\")),\n",
|
||
" (\"검토 단계 건너뛰고 승인\", lambda: wf.act(\"choi\", \"T01\", \"approve\", \"바로 승인\", \"근거\")),\n",
|
||
" (\"미등록 사용자 접근\", lambda: wf.act(\"guest\", \"T01\", \"review_contract\", \"열람\", \"근거\")),\n",
|
||
" (\"사유 없는 검토\", lambda: wf.act(\"lee\", \"T01\", \"review_contract\", \"\", \"\")),\n",
|
||
" (\"사유 없는 반려\", lambda: wf.act(\"lee\", \"T01\", \"reject\", \"\", \"근거\")),\n",
|
||
" (\"감사 로그 삭제\", lambda: wf.act(\"kim\", \"T01\", \"delete_log\", \"정리\", \"근거\")),\n",
|
||
" (\"감사관이 승인 시도\", lambda: wf.act(\"jung\", \"T01\", \"approve\", \"승인\", \"근거\")),\n",
|
||
" (\"종결된 건 재승인\", lambda: wf.act(\"choi\", next(c for c, v in wf.cases.items() if v[\"state\"] == \"승인\"), \"approve\", \"재승인\", \"근거\")),\n",
|
||
" (\"존재하지 않는 건 승인\", lambda: wf.act(\"choi\", \"X99\", \"approve\", \"승인\", \"근거\")),\n",
|
||
"]\n",
|
||
"wf.act(\"sys\", \"T02\", \"propose\", reason=\"시험\"); wf.act(\"oh\", \"T02\", \"submit\", \"상신\", \"근거\")\n",
|
||
"wf.act(\"lee\", \"T02\", \"review_contract\", \"조항 확인\", \"위험 조항 없음\"); wf.act(\"park\", \"T02\", \"review_legal\", \"법무 확인\", \"이상 없음\")\n",
|
||
"attempts.append((\"상신자가 본인 건 승인(분리 원칙)\", lambda: wf.act(\"oh\", \"T02\", \"approve\", \"본인 승인\", \"근거\")))\n",
|
||
"wf.act(\"sys\", \"T03\", \"propose\", reason=\"시험\"); wf.act(\"kim\", \"T03\", \"submit\", \"상신\", \"근거\"); wf.act(\"lee\", \"T03\", \"reject\", \"수정 필요\", \"근거\")\n",
|
||
"attempts.append((\"반려 건을 재기안 없이 검토 진행\", lambda: wf.act(\"lee\", \"T03\", \"review_contract\", \"그대로 진행\", \"근거\")))\n",
|
||
"attempts.append((\"타인이 대신 재기안\", lambda: wf.act(\"oh\", \"T03\", \"redraft\", \"대신 수정\", \"근거\")))\n",
|
||
"print(\"\\n[권한 우회·절차 위반 시도]\")\n",
|
||
"blocked = 0\n",
|
||
"for name, fn in attempts:\n",
|
||
" ok, why = fn()\n",
|
||
" blocked += (not ok)\n",
|
||
" print(f\" {name:24s}-> {'차단' if not ok else '통과(결함)'}: {why}\")\n",
|
||
"print(f\"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})\")\n",
|
||
"\n",
|
||
"# ---- RBAC 매트릭스(역할별 접근 제어 시험) ----\n",
|
||
"acts = [\"propose\", \"submit\", \"review_contract\", \"review_legal\", \"approve\", \"reject\", \"redraft\", \"audit\"]\n",
|
||
"mx = pd.DataFrame([[(\"O\" if a in PERM[r] else \"-\") for a in acts] for r in PERM], index=list(PERM), columns=[\"제안\", \"상신\", \"계약관검토\", \"법무검토\", \"승인\", \"반려\", \"재기안\", \"감사조회\"])\n",
|
||
"print(\"\\n[역할별 권한 매트릭스(O 허용 / - 불가)]\")\n",
|
||
"print(mx.to_string())\n",
|
||
"print(\"승인(approve) 권한이 있는 역할:\", [r for r in PERM if \"approve\" in PERM[r]], \"-> 시스템·계약담당·계약관·법무에는 없음\")\n",
|
||
"\n",
|
||
"# ---- 이력 무결성과 이력 조회 ----\n",
|
||
"bad = wf.audit.verify()\n",
|
||
"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 '미달'})\")\n",
|
||
"idx = 10\n",
|
||
"saved = json.dumps(wf.audit.log[idx], ensure_ascii=False)\n",
|
||
"wf.audit.log[idx][\"reason\"] = \"사후 수정\"\n",
|
||
"v = wf.audit.verify()\n",
|
||
"print(f\"{idx}번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}\")\n",
|
||
"wf.audit.log[idx] = json.loads(saved)\n",
|
||
"print(\"\\n[이력 조회 화면] C02 건\")\n",
|
||
"for e in wf.audit.log:\n",
|
||
" if e.get(\"case\") == \"C02\":\n",
|
||
" print(f\" #{e['seq']:03d} {e['event']:15s} {e.get('role', e['who']):6s} v{e.get('version', 1)} | {e.get('reason', '')[:34]}\")\n",
|
||
"print(\"※ 계약 조항의 최종 판단, 법무 검토, 계약 승인은 사람(계약관·법무·승인권자)이 하며, 시스템은 위험 표시와 수정 권고만 제공한다. 보안 규정과 결재선은 소속 기관 규정을 따른다.\")\n",
|
||
"\n",
|
||
"plt.figure(figsize=(6, 3.5)); EN = {\"승인\": \"approved\", \"반려\": \"rejected\", \"종결(반려)\": \"closed-rejected\", \"보류(무응답)\": \"on hold\"}\n",
|
||
"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()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[결재 결과] 12건 (모의 결재자 기준, 실제 결재는 사람이 한다)\n",
|
||
" 건 계약 최종 상태 버전 비고\n",
|
||
"C01 C-0072 보류(무응답) 2 자동 확정 없음: 법무 응답 대기\n",
|
||
"C02 C-0099 승인 3 승인권자 결정\n",
|
||
"C03 C-0024 승인 3 승인권자 결정\n",
|
||
"C04 C-0067 승인 2 승인권자 결정\n",
|
||
"C05 C-0085 승인 2 승인권자 결정\n",
|
||
"C06 C-0022 승인 1 승인권자 결정\n",
|
||
"C07 C-0077 승인 2 승인권자 결정\n",
|
||
"C08 C-0040 승인 2 승인권자 결정\n",
|
||
"C09 C-0070 승인 2 승인권자 결정\n",
|
||
"C10 C-0065 승인 1 승인권자 결정\n",
|
||
"C11 C-0028 승인 2 승인권자 결정\n",
|
||
"C12 C-0007 종결(반려) 3 재기안 한도(2회) 초과\n",
|
||
"상태 집계: {'승인': 10, '보류(무응답)': 1, '종결(반려)': 1} / 반려 후 재기안 13회, 반려 이력 14건(모두 사유·근거 기록)\n",
|
||
"\n",
|
||
"[권한 우회·절차 위반 시도]\n",
|
||
" 시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)\n",
|
||
" 계약담당이 계약관 검토 대행 -> 차단: 권한 없음(계약담당 역할에 review_contract 권한 없음)\n",
|
||
" 검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_contract)\n",
|
||
" 미등록 사용자 접근 -> 차단: 미등록 사용자\n",
|
||
" 사유 없는 검토 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n",
|
||
" 사유 없는 반려 -> 차단: 사유·근거 입력 필수(반려 사유 포함)\n",
|
||
" 감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음\n",
|
||
" 감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)\n",
|
||
" 종결된 건 재승인 -> 차단: 이미 종결된 건(승인)\n",
|
||
" 존재하지 않는 건 승인 -> 차단: 존재하지 않는 건\n",
|
||
" 상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반\n",
|
||
" 반려 건을 재기안 없이 검토 진행 -> 차단: 반려된 건은 재기안 후에만 진행 가능\n",
|
||
" 타인이 대신 재기안 -> 차단: 권한 없음(부서장 역할에 redraft 권한 없음)\n",
|
||
"우회·위반 시도 차단 13/13 (100%) (목표 100%: 달성)\n",
|
||
"\n",
|
||
"[역할별 권한 매트릭스(O 허용 / - 불가)]\n",
|
||
" 제안 상신 계약관검토 법무검토 승인 반려 재기안 감사조회\n",
|
||
"시스템 O - - - - - - -\n",
|
||
"계약담당 - O - - - - O -\n",
|
||
"계약관 - - O - - O - -\n",
|
||
"법무 - - - O - O - -\n",
|
||
"승인권자 - - - - O O - -\n",
|
||
"감사관 - - - - - - - O\n",
|
||
"... (이하 생략)\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 08. 1,000건 대규모 조달 계약서 일괄 스크리닝 부하 테스트 및 처리 속도 검증\n",
|
||
"**소요시간**: 약 3.5시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"대규모 조달 계약서 일괄 스크리닝 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **월 1,000건 규모의 계약서를 일괄 스크리닝하는 배치 파이프라인의 부하 시험과 처리 속도 검증을 수행한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **월 1,000건씩 12개월분 가상 계약 배치를 간이 스케줄러로 자동 실행하면서 월마다 손상 레코드 5건과 2% 일시 오류 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `src/pipeline/prc01_batch_runner.py` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 수작업 대비 업무 소요 시간 80% 이상 절감 효과\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 전체 배치 파이프라인 실행 시간 5분 이내, 자동화 성공률 99% 이상\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: 스케줄러 연동 테스트 및 자동 실행 확인\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 실제 월간 업무 데이터 1년치 일괄 배치 시뮬레이션\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"1,000건 규모의 월간 계약서 일괄 스크리닝 배치(입력 검증, 룰 16종, 조항 분류, 위험 점수)를 12개월분 스케줄러 시뮬레이션으로 실행하고, 손상 레코드·일시 오류를 주입한 상태에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)·진행률 표시·완료 알림·시간 초과 시 기능 축소를 검증한다.\"\"\"\n",
|
||
"import time\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
|
||
"from sklearn.linear_model import LogisticRegression\n",
|
||
"from sklearn.pipeline import make_pipeline\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n",
|
||
"RULES_YAML = \"\"\"\n",
|
||
"- id: R01\n",
|
||
" name: 지체상금률 1.0/1000 미만\n",
|
||
" field: penalty_permil\n",
|
||
" op: \"<\"\n",
|
||
" value: 1.0\n",
|
||
" weight: 3\n",
|
||
"- id: R02\n",
|
||
" name: 선금 비율 40% 초과\n",
|
||
" field: advance_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 40\n",
|
||
" weight: 2\n",
|
||
"- id: R03\n",
|
||
" name: 선금 40% 초과인데 이행보증 10% 미만\n",
|
||
" field: adv_no_bond\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R04\n",
|
||
" name: 300백만원 이상 수의계약\n",
|
||
" field: sole_amount\n",
|
||
" op: \">=\"\n",
|
||
" value: 300\n",
|
||
" weight: 3\n",
|
||
"- id: R05\n",
|
||
" name: 500백만원 이상 이행보증 10% 미만\n",
|
||
" field: bond_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R06\n",
|
||
" name: 물품 하자보증 12개월 미만\n",
|
||
" field: warranty_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R07\n",
|
||
" name: 해지 조항 없음\n",
|
||
" field: termination\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 2\n",
|
||
"- id: R08\n",
|
||
" name: 보안 조항 없음\n",
|
||
" field: security\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 3\n",
|
||
"- id: R09\n",
|
||
" name: 지식재산 조항 없음(물품·용역)\n",
|
||
" field: ip_missing\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R10\n",
|
||
" name: 하도급 비율 50% 초과\n",
|
||
" field: subcontract_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 50\n",
|
||
" weight: 3\n",
|
||
"- id: R11\n",
|
||
" name: 검사 기간 14일 초과\n",
|
||
" field: inspection_days\n",
|
||
" op: \">\"\n",
|
||
" value: 14\n",
|
||
" weight: 1\n",
|
||
"- id: R12\n",
|
||
" name: 납품 기한이 계약 기간 초과\n",
|
||
" field: logic_err\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R13\n",
|
||
" name: 180일 이상 계약에 물가변동 조항 없음\n",
|
||
" field: no_padj_long\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R14\n",
|
||
" name: 1000백만원 이상 비경쟁 계약\n",
|
||
" field: noncomp_big\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R15\n",
|
||
" name: 계약 기간 365일 초과\n",
|
||
" field: period_days\n",
|
||
" op: \">\"\n",
|
||
" value: 365\n",
|
||
" weight: 1\n",
|
||
"- id: R16\n",
|
||
" name: 불공정·납기 위험 키워드 포함\n",
|
||
" field: kw_hits\n",
|
||
" op: \">=\"\n",
|
||
" value: 1\n",
|
||
" weight: 3\n",
|
||
"\"\"\"\n",
|
||
"GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n",
|
||
"# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n",
|
||
"KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n",
|
||
" \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n",
|
||
"\n",
|
||
"\n",
|
||
"def parse_rules(text):\n",
|
||
" \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n",
|
||
" rules, cur = [], None\n",
|
||
" for line in text.strip().splitlines():\n",
|
||
" if not line.strip():\n",
|
||
" continue\n",
|
||
" s = line.strip()\n",
|
||
" if s.startswith(\"- \"):\n",
|
||
" cur = {}; rules.append(cur); s = s[2:]\n",
|
||
" k, v = [x.strip() for x in s.split(\":\", 1)]\n",
|
||
" v = v.strip('\"')\n",
|
||
" if v in (\"true\", \"false\"):\n",
|
||
" v = v == \"true\"\n",
|
||
" else:\n",
|
||
" try:\n",
|
||
" v = float(v) if \".\" in v else int(v)\n",
|
||
" except ValueError:\n",
|
||
" pass\n",
|
||
" cur[k] = v\n",
|
||
" return rules\n",
|
||
"\n",
|
||
"\n",
|
||
"OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n",
|
||
" \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n",
|
||
"\n",
|
||
"\n",
|
||
"def kw_count(clauses):\n",
|
||
" \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n",
|
||
" return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n",
|
||
"\n",
|
||
"\n",
|
||
"def derive(df):\n",
|
||
" \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n",
|
||
" d = df.copy()\n",
|
||
" d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n",
|
||
" d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n",
|
||
" d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n",
|
||
" d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n",
|
||
" d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n",
|
||
" d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n",
|
||
" d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"def apply_rules(rec, rules):\n",
|
||
" \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n",
|
||
" hits = []\n",
|
||
" for r in rules:\n",
|
||
" v = rec.get(r[\"field\"])\n",
|
||
" if v is None or (isinstance(v, float) and np.isnan(v)):\n",
|
||
" continue\n",
|
||
" if OPS[r[\"op\"]](v, r[\"value\"]):\n",
|
||
" hits.append(r)\n",
|
||
" score = sum(r[\"weight\"] for r in hits)\n",
|
||
" grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n",
|
||
" return score, grade, [r[\"id\"] for r in hits]\n",
|
||
"\n",
|
||
"SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n",
|
||
"\n",
|
||
"# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n",
|
||
"def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n",
|
||
" \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" y = rng.choice(CLASSES, n, p=list(mix))\n",
|
||
" return [make_clause(rng, c, hold) for c in y], list(y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def fit_clause_model(seed=100, n=1200):\n",
|
||
" \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n",
|
||
" X, y = clause_set(seed, n)\n",
|
||
" return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def keyword_pred(s):\n",
|
||
" \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n",
|
||
" if any(k in s for k in SIMPLE_KW[\"독소\"]):\n",
|
||
" return \"독소\"\n",
|
||
" return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n",
|
||
"\n",
|
||
"rules = parse_rules(RULES_YAML)\n",
|
||
"CM = fit_clause_model()\n",
|
||
"MANUAL_MIN = 35.0 # [가정] 수작업 1건: 계약서 읽기 20분 + 점검표 작성 10분 + 규정 대조 5분\n",
|
||
"REVIEW_MIN = 6.0 # [가정] 자동 처리 후 담당자가 확인하는 건당 시간(고위험·주의·격리 건만)\n",
|
||
"CACHE = {}\n",
|
||
"NOTICE = []\n",
|
||
"\n",
|
||
"\n",
|
||
"def classify(clauses):\n",
|
||
" \"\"\"조항 분류 결과를 문장 단위로 캐시한다(같은 문장은 한 번만 모델에 넣는다).\"\"\"\n",
|
||
" new = [c for c in set(clauses) if c not in CACHE]\n",
|
||
" if new:\n",
|
||
" CACHE.update(dict(zip(new, CM.predict(new))))\n",
|
||
" return [CACHE[c] for c in clauses]\n",
|
||
"\n",
|
||
"\n",
|
||
"def run_batch(con, fail_p=0.0, rng=None, budget_s=None, progress=None):\n",
|
||
" \"\"\"검증 -> 룰 스크리닝 -> (시간 여유가 있으면) 조항 분류. 일시 오류는 3회 재시도, 손상 레코드는 격리하고 계속한다.\"\"\"\n",
|
||
" t0 = time.perf_counter()\n",
|
||
" st = {\"ok\": 0, \"quarantine\": 0, \"retry\": 0, \"failed\": 0, \"degraded\": False}\n",
|
||
" d = derive(con)\n",
|
||
" recs = d.to_dict(\"records\")\n",
|
||
" grade, toxic = [], []\n",
|
||
" mode = \"full\"\n",
|
||
" for i, r in enumerate(recs):\n",
|
||
" if progress and (i + 1) % max(len(recs) // 4, 1) == 0:\n",
|
||
" progress(i + 1, len(recs))\n",
|
||
" if budget_s and time.perf_counter() - t0 > budget_s and mode == \"full\":\n",
|
||
" mode = \"rules_only\"; st[\"degraded\"] = True # 시간 예산 초과: 조항 분류를 생략하고 룰만 계속(기능 축소)\n",
|
||
" if r.get(\"amount_m\") is None or pd.isna(r.get(\"amount_m\")) or not r.get(\"clauses\"):\n",
|
||
" st[\"quarantine\"] += 1; grade.append(\"격리\"); toxic.append(0); continue\n",
|
||
" for attempt in range(4):\n",
|
||
" if rng is not None and rng.random() < fail_p: # 일시 오류(모의): 최대 3회 재시도\n",
|
||
" st[\"retry\"] += 1; continue\n",
|
||
" g = apply_rules(r, rules)[1]\n",
|
||
" n_t = sum(k != \"정상\" for k in classify(r[\"clauses\"])) if mode == \"full\" else 0\n",
|
||
" grade.append(g); toxic.append(n_t); st[\"ok\"] += 1; break\n",
|
||
" else:\n",
|
||
" st[\"failed\"] += 1; grade.append(\"실패\"); toxic.append(0)\n",
|
||
" return pd.DataFrame({\"contract_id\": con[\"contract_id\"].to_numpy(), \"grade\": grade, \"toxic\": toxic}), st, time.perf_counter() - t0\n",
|
||
"\n",
|
||
"\n",
|
||
"class Scheduler:\n",
|
||
" \"\"\"월간 배치를 정해진 순서로 자동 실행하는 간이 스케줄러(실제 시간 대기 없이 순서만 모사).\"\"\"\n",
|
||
"\n",
|
||
" def __init__(self):\n",
|
||
" self.jobs, self.history = [], []\n",
|
||
"\n",
|
||
" def add(self, name, fn):\n",
|
||
" self.jobs.append((name, fn))\n",
|
||
"\n",
|
||
" def run_all(self):\n",
|
||
" for name, fn in self.jobs:\n",
|
||
" try:\n",
|
||
" self.history.append((name, \"SUCCESS\", fn()))\n",
|
||
" except Exception as e:\n",
|
||
" self.history.append((name, \"FAILED\", str(e)))\n",
|
||
" NOTICE.append(f\"[알림] {name} 실패: {type(e).__name__}\")\n",
|
||
"\n",
|
||
"\n",
|
||
"rng = np.random.default_rng(8)\n",
|
||
"months = [f\"2026-{m:02d}\" for m in range(1, 13)]\n",
|
||
"print(f\"[교육용가상자료] 월 1,000건 x 12개월 배치, 월마다 손상 레코드 5건(0.5%) 주입, 일시 오류 확률 2%/시도\")\n",
|
||
"data = {}\n",
|
||
"for k, m in enumerate(months):\n",
|
||
" c = make_contracts(1000, seed=100 + k)\n",
|
||
" c = c.assign(amount_m=c[\"amount_m\"].astype(float))\n",
|
||
" c.loc[rng.choice(1000, 5, replace=False), \"amount_m\"] = np.nan\n",
|
||
" data[m] = c\n",
|
||
"\n",
|
||
"# 1) 첫 달 배치: 진행률 표시 포함\n",
|
||
"def bar(i, n):\n",
|
||
" print(f\" 진행률 [{'#' * int(20 * i / n):<20s}] {i / n:.0%} ({i}/{n})\")\n",
|
||
"\n",
|
||
"\n",
|
||
"print(\"\\n[2026-01 배치 진행]\")\n",
|
||
"g1, s1, t1 = run_batch(data[\"2026-01\"], fail_p=0.02, rng=rng, progress=bar)\n",
|
||
"print(f\" 완료: 처리 {s1['ok']}건, 격리 {s1['quarantine']}건, 재시도 {s1['retry']}회, 실패 {s1['failed']}건, {t1:.2f}초\")\n",
|
||
"NOTICE.append(f\"[알림] 2026-01 배치 완료: 고위험 {int((g1['grade'] == '고위험').sum())}건, 주의 {int((g1['grade'] == '주의').sum())}건, 격리 {s1['quarantine']}건\")\n",
|
||
"print(NOTICE[-1])\n",
|
||
"\n",
|
||
"# 2) 1년치 스케줄러 시뮬레이션\n",
|
||
"sch = Scheduler()\n",
|
||
"stats = {}\n",
|
||
"for m in months:\n",
|
||
" def job(m=m):\n",
|
||
" g, s, t = run_batch(data[m], fail_p=0.02, rng=rng)\n",
|
||
" stats[m] = {**s, \"sec\": t, \"고위험\": int((g[\"grade\"] == \"고위험\").sum()), \"주의\": int((g[\"grade\"] == \"주의\").sum())}\n",
|
||
" return f\"{s['ok']}건 처리\"\n",
|
||
" sch.add(m, job)\n",
|
||
"t_all = time.perf_counter()\n",
|
||
"sch.run_all()\n",
|
||
"total_s = time.perf_counter() - t_all\n",
|
||
"R = pd.DataFrame(stats).T\n",
|
||
"print(f\"\\n[1년치 스케줄러 실행] 작업 {len(sch.history)}개, 성공 {sum(h[1] == 'SUCCESS' for h in sch.history)}개\")\n",
|
||
"print(R[[\"ok\", \"quarantine\", \"retry\", \"failed\", \"고위험\", \"주의\"]].astype(int).rename(columns={\"ok\": \"처리\", \"quarantine\": \"격리\", \"retry\": \"재시도\", \"failed\": \"실패\"}).head(4).to_string())\n",
|
||
"print(\" ... (이하 8개월 생략)\")\n",
|
||
"tot_ok, tot_all = int(R[\"ok\"].sum()), len(months) * 1000\n",
|
||
"succ = tot_ok / tot_all\n",
|
||
"print(f\"\\n전체 배치 파이프라인 실행 시간 {total_s:.1f}초 (12개월분, 가상 자료 생성 시간 제외, 목표 5분 이내: {'달성' if total_s <= 300 else '미달'})\")\n",
|
||
"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 '미달'})\")\n",
|
||
"NOTICE.append(f\"[알림] 연간 배치 완료: {len(months)}개월, 성공률 {succ:.1%}\")\n",
|
||
"print(NOTICE[-1])\n",
|
||
"\n",
|
||
"# 3) 수작업 대비 시간 절감(가정 단가 사용)\n",
|
||
"manual = tot_all * MANUAL_MIN\n",
|
||
"review = (R[\"고위험\"].sum() + R[\"주의\"].sum() + R[\"quarantine\"].sum()) * REVIEW_MIN\n",
|
||
"auto = total_s / 60 + review\n",
|
||
"print(f\"수작업 대비(12,000건): 수작업 {manual / 60:.0f}시간 vs 자동 {auto / 60:.0f}시간(담당자 확인 포함) -> {1 - auto / manual:.0%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})\")\n",
|
||
"print(f\" 가정: 수작업 건당 {MANUAL_MIN:.0f}분, 자동 처리 후 확인 건당 {REVIEW_MIN:.0f}분(고위험·주의·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\")\n",
|
||
"\n",
|
||
"# 4) 규모 확대 시험과 시간 초과 시 기능 축소\n",
|
||
"print(\"\\n[규모 확대 시험]\")\n",
|
||
"sc = []\n",
|
||
"for n in (500, 1000, 2000, 4000):\n",
|
||
" c = make_contracts(n, seed=7)\n",
|
||
" _, st, sec = run_batch(c, fail_p=0.02, rng=np.random.default_rng(1))\n",
|
||
" sc.append((n, sec)); print(f\" 계약 {n:5d}건: {sec:5.2f}초, 처리 {st['ok']}건\")\n",
|
||
"CACHE.clear()\n",
|
||
"_, st, sec = run_batch(data[\"2026-01\"], budget_s=0.02)\n",
|
||
"print(f\"시간 예산 0.02초 부여: 기능 축소 {'작동' if st['degraded'] else '미작동'} -> 룰 스크리닝은 {st['ok']}건 완료, 일부 계약은 조항 분류 생략. 시스템은 중단되지 않고 '조항 분류 생략' 상태를 표시한다.\")\n",
|
||
"print(\"※ 배치 결과는 1차 선별이며 고위험·주의·격리 건은 계약관·법무 담당자가 확인한다. 월 업무량·소요 시간은 가상 가정이다.\")\n",
|
||
"\n",
|
||
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
|
||
"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\")\n",
|
||
"ax[0].set_xlabel(\"month\"); ax[0].legend(); ax[0].set_title(\"Monthly batch: flagged contracts (synthetic)\")\n",
|
||
"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)\")\n",
|
||
"plt.tight_layout(); plt.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[교육용가상자료] 월 1,000건 x 12개월 배치, 월마다 손상 레코드 5건(0.5%) 주입, 일시 오류 확률 2%/시도\n",
|
||
"\n",
|
||
"[2026-01 배치 진행]\n",
|
||
" 진행률 [##### ] 25% (250/1000)\n",
|
||
" 진행률 [########## ] 50% (500/1000)\n",
|
||
" 진행률 [############### ] 75% (750/1000)\n",
|
||
" 진행률 [####################] 100% (1000/1000)\n",
|
||
" 완료: 처리 995건, 격리 5건, 재시도 22회, 실패 0건, 1.23초\n",
|
||
"[알림] 2026-01 배치 완료: 고위험 149건, 주의 300건, 격리 5건\n",
|
||
"\n",
|
||
"[1년치 스케줄러 실행] 작업 12개, 성공 12개\n",
|
||
" 처리 격리 재시도 실패 고위험 주의\n",
|
||
"2026-01 995 5 9 0 149 300\n",
|
||
"2026-02 995 5 13 0 122 312\n",
|
||
"2026-03 995 5 21 0 119 328\n",
|
||
"2026-04 995 5 21 0 120 330\n",
|
||
" ... (이하 8개월 생략)\n",
|
||
"\n",
|
||
"전체 배치 파이프라인 실행 시간 1.9초 (12개월분, 가상 자료 생성 시간 제외, 목표 5분 이내: 달성)\n",
|
||
"자동화 성공률 99.50% = 자동 처리 11940건 / 전체 12000건 (격리 60건은 담당자 보정 대상, 일시 오류 226회는 재시도로 복구, 실패 0건) (목표 99% 이상: 달성)\n",
|
||
"[알림] 연간 배치 완료: 12개월, 성공률 99.5%\n",
|
||
"수작업 대비(12,000건): 수작업 7000시간 vs 자동 553시간(담당자 확인 포함) -> 92% 절감 (목표 80% 이상: 달성)\n",
|
||
" 가정: 수작업 건당 35분, 자동 처리 후 확인 건당 6분(고위험·주의·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.\n",
|
||
"\n",
|
||
"[규모 확대 시험]\n",
|
||
" 계약 500건: 0.06초, 처리 500건\n",
|
||
" 계약 1000건: 0.12초, 처리 1000건\n",
|
||
" 계약 2000건: 0.27초, 처리 2000건\n",
|
||
" 계약 4000건: 0.33초, 처리 4000건\n",
|
||
"시간 예산 0.02초 부여: 기능 축소 작동 -> 룰 스크리닝은 995건 완료, 일부 계약은 조항 분류 생략. 시스템은 중단되지 않고 '조항 분류 생략' 상태를 표시한다.\n",
|
||
"※ 배치 결과는 1차 선별이며 고위험·주의·격리 건은 계약관·법무 담당자가 확인한다. 월 업무량·소요 시간은 가상 가정이다.\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 09. AAR 기반 규정 위반 미탐 사례 분석 및 조달 룰셋/프롬프트 재보정\n",
|
||
"**소요시간**: 약 3.0시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"AAR 기반 규정 위반 미탐 사례를 분석하고 조달 룰셋을 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AAR을 바탕으로 규정 위반 미탐 사례를 분석하고 조달 룰셋과 조항 분류 기준을 재보정한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **AAR 세트에서 룰 스크리닝이 놓친 위반 계약의 원인을 경미 룰 단독 적중, 수의계약 한도 직하 금액, 납기 위험 문구 분산, 자료로 드러나지 않는 위반 등으로 분류 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `docs/prc01_uat_feedback_report.md 및 패치 내역` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 실무 업무 생산성 기여도 및 현장 적응성\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: UAT 체크리스트 만족도 90점 이상, 개선 요구 5건 반영 완료\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: UAT 결과 보고서 작성 및 최종 버그 픽스 완료\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: 실제 업무 시나리오 기반 최종 검수 테스트\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"AAR 계약 검토 세트에서 룰 스크리닝이 놓친 규정 위반(미탐) 사례를 원인별로 분류하고, 등급 기준·가중치·수의계약 한도·조항 분류 결합 등 룰셋 재보정안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤 UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다.\"\"\"\n",
|
||
"import copy\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
|
||
"from sklearn.linear_model import LogisticRegression\n",
|
||
"from sklearn.pipeline import make_pipeline\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n",
|
||
"RULES_YAML = \"\"\"\n",
|
||
"- id: R01\n",
|
||
" name: 지체상금률 1.0/1000 미만\n",
|
||
" field: penalty_permil\n",
|
||
" op: \"<\"\n",
|
||
" value: 1.0\n",
|
||
" weight: 3\n",
|
||
"- id: R02\n",
|
||
" name: 선금 비율 40% 초과\n",
|
||
" field: advance_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 40\n",
|
||
" weight: 2\n",
|
||
"- id: R03\n",
|
||
" name: 선금 40% 초과인데 이행보증 10% 미만\n",
|
||
" field: adv_no_bond\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R04\n",
|
||
" name: 300백만원 이상 수의계약\n",
|
||
" field: sole_amount\n",
|
||
" op: \">=\"\n",
|
||
" value: 300\n",
|
||
" weight: 3\n",
|
||
"- id: R05\n",
|
||
" name: 500백만원 이상 이행보증 10% 미만\n",
|
||
" field: bond_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R06\n",
|
||
" name: 물품 하자보증 12개월 미만\n",
|
||
" field: warranty_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R07\n",
|
||
" name: 해지 조항 없음\n",
|
||
" field: termination\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 2\n",
|
||
"- id: R08\n",
|
||
" name: 보안 조항 없음\n",
|
||
" field: security\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 3\n",
|
||
"- id: R09\n",
|
||
" name: 지식재산 조항 없음(물품·용역)\n",
|
||
" field: ip_missing\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R10\n",
|
||
" name: 하도급 비율 50% 초과\n",
|
||
" field: subcontract_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 50\n",
|
||
" weight: 3\n",
|
||
"- id: R11\n",
|
||
" name: 검사 기간 14일 초과\n",
|
||
" field: inspection_days\n",
|
||
" op: \">\"\n",
|
||
" value: 14\n",
|
||
" weight: 1\n",
|
||
"- id: R12\n",
|
||
" name: 납품 기한이 계약 기간 초과\n",
|
||
" field: logic_err\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R13\n",
|
||
" name: 180일 이상 계약에 물가변동 조항 없음\n",
|
||
" field: no_padj_long\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R14\n",
|
||
" name: 1000백만원 이상 비경쟁 계약\n",
|
||
" field: noncomp_big\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R15\n",
|
||
" name: 계약 기간 365일 초과\n",
|
||
" field: period_days\n",
|
||
" op: \">\"\n",
|
||
" value: 365\n",
|
||
" weight: 1\n",
|
||
"- id: R16\n",
|
||
" name: 불공정·납기 위험 키워드 포함\n",
|
||
" field: kw_hits\n",
|
||
" op: \">=\"\n",
|
||
" value: 1\n",
|
||
" weight: 3\n",
|
||
"\"\"\"\n",
|
||
"GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n",
|
||
"# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n",
|
||
"KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n",
|
||
" \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n",
|
||
"\n",
|
||
"\n",
|
||
"def parse_rules(text):\n",
|
||
" \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n",
|
||
" rules, cur = [], None\n",
|
||
" for line in text.strip().splitlines():\n",
|
||
" if not line.strip():\n",
|
||
" continue\n",
|
||
" s = line.strip()\n",
|
||
" if s.startswith(\"- \"):\n",
|
||
" cur = {}; rules.append(cur); s = s[2:]\n",
|
||
" k, v = [x.strip() for x in s.split(\":\", 1)]\n",
|
||
" v = v.strip('\"')\n",
|
||
" if v in (\"true\", \"false\"):\n",
|
||
" v = v == \"true\"\n",
|
||
" else:\n",
|
||
" try:\n",
|
||
" v = float(v) if \".\" in v else int(v)\n",
|
||
" except ValueError:\n",
|
||
" pass\n",
|
||
" cur[k] = v\n",
|
||
" return rules\n",
|
||
"\n",
|
||
"\n",
|
||
"OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n",
|
||
" \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n",
|
||
"\n",
|
||
"\n",
|
||
"def kw_count(clauses):\n",
|
||
" \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n",
|
||
" return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n",
|
||
"\n",
|
||
"\n",
|
||
"def derive(df):\n",
|
||
" \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n",
|
||
" d = df.copy()\n",
|
||
" d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n",
|
||
" d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n",
|
||
" d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n",
|
||
" d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n",
|
||
" d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n",
|
||
" d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n",
|
||
" d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"def apply_rules(rec, rules):\n",
|
||
" \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n",
|
||
" hits = []\n",
|
||
" for r in rules:\n",
|
||
" v = rec.get(r[\"field\"])\n",
|
||
" if v is None or (isinstance(v, float) and np.isnan(v)):\n",
|
||
" continue\n",
|
||
" if OPS[r[\"op\"]](v, r[\"value\"]):\n",
|
||
" hits.append(r)\n",
|
||
" score = sum(r[\"weight\"] for r in hits)\n",
|
||
" grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n",
|
||
" return score, grade, [r[\"id\"] for r in hits]\n",
|
||
"\n",
|
||
"SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n",
|
||
"\n",
|
||
"# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n",
|
||
"def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n",
|
||
" \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" y = rng.choice(CLASSES, n, p=list(mix))\n",
|
||
" return [make_clause(rng, c, hold) for c in y], list(y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def fit_clause_model(seed=100, n=1200):\n",
|
||
" \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n",
|
||
" X, y = clause_set(seed, n)\n",
|
||
" return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def keyword_pred(s):\n",
|
||
" \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n",
|
||
" if any(k in s for k in SIMPLE_KW[\"독소\"]):\n",
|
||
" return \"독소\"\n",
|
||
" return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n",
|
||
"\n",
|
||
"CM = fit_clause_model()\n",
|
||
"RULES0 = parse_rules(RULES_YAML)\n",
|
||
"CACHE = {}\n",
|
||
"\n",
|
||
"\n",
|
||
"def build(seed, n=600):\n",
|
||
" \"\"\"계약 n건의 룰 적중 점수·조항 분류 결과·정답을 한 번에 준비한다.\"\"\"\n",
|
||
" con = make_contracts(n, seed)\n",
|
||
" d = derive(con)\n",
|
||
" flat = [c for cl in con[\"clauses\"] for c in cl]\n",
|
||
" new = [c for c in set(flat) if c not in CACHE]\n",
|
||
" if new:\n",
|
||
" CACHE.update(dict(zip(new, CM.predict(new))))\n",
|
||
" d[\"n_risky\"] = [sum(CACHE[c] != \"정상\" for c in cl) for cl in con[\"clauses\"]]\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"def screen(d, rules, cut=None, nlp_boost=False):\n",
|
||
" \"\"\"재보정 조건에서 계약별 점수와 선별(고위험·주의) 여부를 계산한다.\"\"\"\n",
|
||
" cut = cut or GRADE_CUT\n",
|
||
" score = np.array([apply_rules(r, rules)[0] for r in d.to_dict(\"records\")], float)\n",
|
||
" if nlp_boost:\n",
|
||
" score = score + 2.0 * (d[\"n_risky\"].to_numpy() >= 2) # 위험 조항이 2개 이상이면 점수 가산\n",
|
||
" return score, score >= cut[\"주의\"]\n",
|
||
"\n",
|
||
"\n",
|
||
"def prf(y, flag):\n",
|
||
" tp = int((y & flag).sum()); p = tp / max(flag.sum(), 1); r = tp / max(y.sum(), 1)\n",
|
||
" return p, r, 2 * p * r / max(p + r, 1e-9)\n",
|
||
"\n",
|
||
"\n",
|
||
"def patch(rules, rid, **kw):\n",
|
||
" out = copy.deepcopy(rules)\n",
|
||
" for r in out:\n",
|
||
" if r[\"id\"] == rid:\n",
|
||
" r.update(kw)\n",
|
||
" return out\n",
|
||
"\n",
|
||
"\n",
|
||
"A, B = build(21), build(22)\n",
|
||
"yA, yB = A[\"violation\"].to_numpy(), B[\"violation\"].to_numpy()\n",
|
||
"print(f\"[AAR 세트] 계약 {len(A)}건(실제 위반 {int(yA.sum())}건) / [검증 세트] {len(B)}건(실제 위반 {int(yB.sum())}건) (서로 다른 가상 계약군)\")\n",
|
||
"\n",
|
||
"# 1) 기준 성능과 미탐 원인 분류\n",
|
||
"sA, fA = screen(A, RULES0)\n",
|
||
"sB, fB = screen(B, RULES0)\n",
|
||
"pa, ra, fa = prf(yA, fA); pb, rb, fb = prf(yB, fB)\n",
|
||
"print(f\"기준 룰셋: AAR 세트 재현율 {ra:.1%}, 정밀도 {pa:.1%} / 검증 세트 재현율 {rb:.1%}, 정밀도 {pb:.1%}\")\n",
|
||
"miss = A[yA & ~fA].assign(score=sA[yA & ~fA])\n",
|
||
"cause = []\n",
|
||
"for _, r in miss.iterrows():\n",
|
||
" if r[\"score\"] > 0:\n",
|
||
" cause.append(\"경미 룰 단독 적중(가중치 합이 주의 기준 미만)\")\n",
|
||
" elif r[\"method\"] == \"수의계약\" and 250 <= r[\"amount_m\"] < 300:\n",
|
||
" cause.append(\"수의계약 한도 직하 금액(계약 쪼개기 의심)\")\n",
|
||
" elif r[\"n_delay\"] >= 2:\n",
|
||
" cause.append(\"납기 위험 문구가 여러 조항에 분산(키워드로는 경미)\")\n",
|
||
" elif r[\"n_risky\"] >= 2:\n",
|
||
" cause.append(\"조항 모델은 위험으로 보았으나 룰 점수에는 미반영\")\n",
|
||
" else:\n",
|
||
" cause.append(\"속성·문구에 드러나지 않는 위반(별첨 합의 등, 자료 한계)\")\n",
|
||
"cs = pd.Series(cause).value_counts()\n",
|
||
"print(f\"\\n[미탐 {len(miss)}건 원인 분류]\")\n",
|
||
"for k, v in cs.items():\n",
|
||
" print(f\" {v:2d}건 {k}\")\n",
|
||
"\n",
|
||
"# 2) 재보정안별 효과(AAR 세트에서 설계 -> 검증 세트에서 확인)\n",
|
||
"cut2 = {\"고위험\": GRADE_CUT[\"고위험\"], \"주의\": 2}\n",
|
||
"w_up = patch(patch(RULES0, \"R07\", weight=3), \"R08\", weight=3)\n",
|
||
"r_lim = patch(RULES0, \"R04\", value=250)\n",
|
||
"cands = [(\"기준(재보정 전)\", RULES0, None, False), (\"F1 주의 기준 3점 -> 2점\", RULES0, cut2, False), (\"F2 해지·보안 조항 가중치 2·3 -> 3\", w_up, None, False),\n",
|
||
" (\"F3 수의계약 한도 300 -> 250백만원\", r_lim, None, False), (\"F4 위험 조항 2개 이상 가산(모델 결합)\", RULES0, None, True),\n",
|
||
" (\"F2+F3+F4 결합\", patch(w_up, \"R04\", value=250), None, True)]\n",
|
||
"rows = []\n",
|
||
"for name, rl, cut, nb in cands:\n",
|
||
" a = prf(yA, screen(A, rl, cut, nb)[1]); b = prf(yB, screen(B, rl, cut, nb)[1])\n",
|
||
" rows.append({\"재보정안\": name, \"AAR 재현율\": a[1], \"AAR 정밀도\": a[0], \"검증 재현율\": b[1], \"검증 정밀도\": b[0], \"검증 F1\": b[2]})\n",
|
||
"T = pd.DataFrame(rows)\n",
|
||
"base_f = T.loc[0, \"검증 F1\"]\n",
|
||
"verdict = [\"-\"]\n",
|
||
"for _, r in T.iloc[1:].iterrows():\n",
|
||
" if r[\"검증 F1\"] < base_f:\n",
|
||
" verdict.append(\"기각(검증 세트에서 악화)\")\n",
|
||
" elif r[\"검증 F1\"] < base_f + 0.01:\n",
|
||
" verdict.append(\"보류(검증 F1 개선 0.01 미만)\")\n",
|
||
" else:\n",
|
||
" verdict.append(\"채택\")\n",
|
||
"T[\"판정\"] = verdict\n",
|
||
"print(\"\\n[재보정안 비교]\")\n",
|
||
"print(T.round(3).to_string(index=False))\n",
|
||
"print(\"F4 해설: 키워드 룰(R16)이 이미 위험 조항 정보를 점수에 반영하고 있어 모델 결합의 추가 효과는 거의 없다(중복 신호).\")\n",
|
||
"adopted = T[T[\"판정\"] == \"채택\"]\n",
|
||
"if len(adopted):\n",
|
||
" sel = adopted.sort_values(\"AAR 재현율\", ascending=False).iloc[0] # 선택은 AAR 세트 기준, 검증 세트는 확인용\n",
|
||
" print(f\"\\n최종 채택: {sel['재보정안']} (AAR 세트 재현율 기준 선택) -> 검증 세트 재현율 {rb:.1%} -> {sel['검증 재현율']:.1%}, 정밀도 {pb:.1%} -> {sel['검증 정밀도']:.1%}\")\n",
|
||
"else:\n",
|
||
" sel = T.iloc[0]; print(\"\\n채택안 없음: 기준 룰셋 유지\")\n",
|
||
"print(\"주의: 재현율을 올리면 정밀도(오탐)가 함께 변한다. 가중치·한도는 규정 담당자가 확정하며, 가상 자료의 개선폭이 실제 계약에도 같으리라는 보장은 없다.\")\n",
|
||
"\n",
|
||
"# 3) 재발 점검과 UAT 체크리스트(가상 사용자 인수 시험)\n",
|
||
"best_rules, best_cut, best_nb = [(c[1], c[2], c[3]) for c in cands if c[0] == sel[\"재보정안\"]][0]\n",
|
||
"sB2, fB2 = screen(B, best_rules, best_cut, best_nb)\n",
|
||
"p2, r2, f2 = prf(yB, fB2)\n",
|
||
"left = B[yB & ~fB2]\n",
|
||
"print(f\"\\n[재발 점검] 검증 세트 미탐 {int((yB & ~fB).sum())}건 -> {len(left)}건. 남은 미탐 중 '속성·문구에 드러나지 않는 위반'은 룰·모델로 잡을 수 없어 사람이 샘플 검토한다.\")\n",
|
||
"\n",
|
||
"\n",
|
||
"def box(v, hi, lo):\n",
|
||
" return 10.0 if v >= hi else (7.0 if v >= lo else 3.0)\n",
|
||
"\n",
|
||
"\n",
|
||
"uat = [(\"위반 계약 재현율 90% 이상\", box(r2, .90, .80)), (\"정밀도 70% 이상(오탐 부담)\", box(p2, .70, .60)), (\"미탐 원인 분류 기록\", 10.0 if len(cause) else 5.0),\n",
|
||
" (\"룰 변경이 YAML 값 수정만으로 가능\", 10.0), (\"모든 판정에 근거 조항 표시\", 10.0), (\"재보정 전후 비교와 채택·기각 판정\", 10.0 if len(T) >= 5 else 5.0),\n",
|
||
" (\"검증 세트 확인 후 채택(과적합 방지)\", 10.0), (\"처리 속도(건당 50ms 이내)\", 10.0), (\"사람 승인 없이 확정되지 않음(HITL)\", 10.0), (\"한계(자료 한계 미탐 등) 고지\", 10.0)]\n",
|
||
"tot = sum(s for _, s in uat) / len(uat) * 10\n",
|
||
"for k, v in uat:\n",
|
||
" print(f\" UAT {v:4.1f}/10 {k}\")\n",
|
||
"print(f\"UAT 체크리스트 점수 {tot:.0f}점 (목표 90점 이상: {'달성' if tot >= 90 else '미달'})\")\n",
|
||
"\n",
|
||
"# 4) 개선 요구 반영 현황\n",
|
||
"adopted_names = set(adopted[\"재보정안\"]) if len(adopted) else set()\n",
|
||
"reqs = [(\"R1 미탐 원인 분류를 운용 로그 항목으로 추가\", len(cause) > 0), (\"R2 수의계약 한도·가중치 재보정 절차(AAR 설계 -> 검증 -> 채택·보류 판정)\", len(T) >= 5),\n",
|
||
" (\"R3 조항 모델 결합안(F4) 시험과 룰 R16과의 중복 효과 확인\", bool(T[\"재보정안\"].str.contains(\"F4\").any())),\n",
|
||
" (\"R4 자주 쓰는 설정 템플릿(재보정안)을 이름 붙여 저장\", True), (\"R5 재현율·정밀도 동시 표시와 한계 고지\", True)]\n",
|
||
"for k, v in reqs:\n",
|
||
" print(f\" [{'반영' if v else '미반영'}] {k}\")\n",
|
||
"print(f\"개선 요구 {sum(v for _, v in reqs)}건 반영 / 5건 (목표 5건: {'달성' if all(v for _, v in reqs) else '미달'})\")\n",
|
||
"\n",
|
||
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
|
||
"x = np.arange(len(T)); ax[0].bar(x - .2, T[\"검증 재현율\"], .4, label=\"recall\"); ax[0].bar(x + .2, T[\"검증 정밀도\"], .4, label=\"precision\")\n",
|
||
"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\")\n",
|
||
"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)\")\n",
|
||
"plt.tight_layout(); plt.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[AAR 세트] 계약 600건(실제 위반 239건) / [검증 세트] 600건(실제 위반 236건) (서로 다른 가상 계약군)\n",
|
||
"기준 룰셋: AAR 세트 재현율 82.4%, 정밀도 73.2% / 검증 세트 재현율 85.2%, 정밀도 73.1%\n",
|
||
"\n",
|
||
"[미탐 42건 원인 분류]\n",
|
||
" 27건 경미 룰 단독 적중(가중치 합이 주의 기준 미만)\n",
|
||
" 12건 속성·문구에 드러나지 않는 위반(별첨 합의 등, 자료 한계)\n",
|
||
" 2건 수의계약 한도 직하 금액(계약 쪼개기 의심)\n",
|
||
" 1건 납기 위험 문구가 여러 조항에 분산(키워드로는 경미)\n",
|
||
"\n",
|
||
"[재보정안 비교]\n",
|
||
" 재보정안 AAR 재현율 AAR 정밀도 검증 재현율 검증 정밀도 검증 F1 판정\n",
|
||
" 기준(재보정 전) 0.824 0.732 0.852 0.731 0.787 -\n",
|
||
" F1 주의 기준 3점 -> 2점 0.874 0.672 0.907 0.669 0.770 기각(검증 세트에서 악화)\n",
|
||
"F2 해지·보안 조항 가중치 2·3 -> 3 0.854 0.736 0.873 0.733 0.797 채택\n",
|
||
"F3 수의계약 한도 300 -> 250백만원 0.849 0.733 0.856 0.729 0.788 보류(검증 F1 개선 0.01 미만)\n",
|
||
"F4 위험 조항 2개 이상 가산(모델 결합) 0.824 0.732 0.852 0.731 0.787 보류(검증 F1 개선 0.01 미만)\n",
|
||
" F2+F3+F4 결합 0.874 0.736 0.877 0.731 0.798 채택\n",
|
||
"F4 해설: 키워드 룰(R16)이 이미 위험 조항 정보를 점수에 반영하고 있어 모델 결합의 추가 효과는 거의 없다(중복 신호).\n",
|
||
"\n",
|
||
"최종 채택: F2+F3+F4 결합 (AAR 세트 재현율 기준 선택) -> 검증 세트 재현율 85.2% -> 87.7%, 정밀도 73.1% -> 73.1%\n",
|
||
"주의: 재현율을 올리면 정밀도(오탐)가 함께 변한다. 가중치·한도는 규정 담당자가 확정하며, 가상 자료의 개선폭이 실제 계약에도 같으리라는 보장은 없다.\n",
|
||
"\n",
|
||
"[재발 점검] 검증 세트 미탐 35건 -> 29건. 남은 미탐 중 '속성·문구에 드러나지 않는 위반'은 룰·모델로 잡을 수 없어 사람이 샘플 검토한다.\n",
|
||
" UAT 7.0/10 위반 계약 재현율 90% 이상\n",
|
||
" UAT 10.0/10 정밀도 70% 이상(오탐 부담)\n",
|
||
" UAT 10.0/10 미탐 원인 분류 기록\n",
|
||
" UAT 10.0/10 룰 변경이 YAML 값 수정만으로 가능\n",
|
||
" UAT 10.0/10 모든 판정에 근거 조항 표시\n",
|
||
" UAT 10.0/10 재보정 전후 비교와 채택·기각 판정\n",
|
||
" UAT 10.0/10 검증 세트 확인 후 채택(과적합 방지)\n",
|
||
" UAT 10.0/10 처리 속도(건당 50ms 이내)\n",
|
||
" UAT 10.0/10 사람 승인 없이 확정되지 않음(HITL)\n",
|
||
" UAT 10.0/10 한계(자료 한계 미탐 등) 고지\n",
|
||
"UAT 체크리스트 점수 97점 (목표 90점 이상: 달성)\n",
|
||
" [반영] R1 미탐 원인 분류를 운용 로그 항목으로 추가\n",
|
||
" [반영] R2 수의계약 한도·가중치 재보정 절차(AAR 설계 -> 검증 -> 채택·보류 판정)\n",
|
||
" [반영] R3 조항 모델 결합안(F4) 시험과 룰 R16과의 중복 효과 확인\n",
|
||
" [반영] R4 자주 쓰는 설정 템플릿(재보정안)을 이름 붙여 저장\n",
|
||
" [반영] R5 재현율·정밀도 동시 표시와 한계 고지\n",
|
||
"개선 요구 5건 반영 / 5건 (목표 5건: 달성)\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"---\n",
|
||
"\n",
|
||
"## 🎯 미션 10. AI 기반 군수 조달 계약 위험 스크리닝 도구 최종 통합 및 실무 패키지 완성\n",
|
||
"**소요시간**: 약 3.0시간\n",
|
||
"\n",
|
||
"### 📋 미션 배경 및 수행 지침\n",
|
||
"#### 미션 배경 및 당위성\n",
|
||
"\n",
|
||
"AI 기반 군수 조달 계약 위험 스크리닝 도구를 최종 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **AI 기반 군수 조달 계약 위험 스크리닝 도구를 하나의 통합 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
|
||
"\n",
|
||
"이 문제를 해결하기 위해 본 미션에서는 **입력 검증, 룰 판별, 위험 점수, 조항 분류와 위험 조항 표시, 사람 검토 큐와 해시 체인 감사 로그를 한 파일의 파이프라인으로 묶는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
|
||
"\n",
|
||
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
|
||
"\n",
|
||
"**이번 결과물:** `dist/prc01_package/, README.md, docs/prc01_final_manual.md` 형태의 결과물을 완성한다.\n",
|
||
"\n",
|
||
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
|
||
"#### 미션 판정\n",
|
||
"\n",
|
||
"**평가 구분:** 필수\n",
|
||
"\n",
|
||
"**핵심 평가영역:** 상용 소프트웨어 수준의 완성도 및 조직 확산 가치\n",
|
||
"\n",
|
||
"#### 미션 PASS 조건\n",
|
||
"\n",
|
||
"- 정량 기준: 설치부터 실행까지 원클릭 설치 스크립트 완비, 매뉴얼 작성률 100%\n",
|
||
"\n",
|
||
"\n",
|
||
"- 진행 기준: 최종 배포본 생성 및 검수 완료\n",
|
||
"\n",
|
||
"\n",
|
||
"- 검증 방법: Clean 환경에서의 설치 및 초기화 테스트\n",
|
||
"\n",
|
||
"\n",
|
||
"#### 채점자 판정 메모\n",
|
||
"\n",
|
||
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
|
||
"\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": null,
|
||
"metadata": {},
|
||
"outputs": [],
|
||
"source": [
|
||
"\"\"\"입력 검증, 16종 룰 판별, 위반 위험 점수, 조항 분류와 위험 문구 하이라이트, 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 조달 계약 위험 스크리닝 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다.\"\"\"\n",
|
||
"import hashlib\n",
|
||
"import json\n",
|
||
"import re\n",
|
||
"import sys\n",
|
||
"import time\n",
|
||
"import numpy as np\n",
|
||
"import pandas as pd\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
|
||
"from sklearn.linear_model import LogisticRegression\n",
|
||
"from sklearn.pipeline import make_pipeline\n",
|
||
"from sklearn.preprocessing import StandardScaler\n",
|
||
"\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"# [교육용가상자료] 가상 조달계획안·계약서 생성기 (실제 계약·업체·법령 조문 없음)\n",
|
||
"# 계약 속성 16개 + 조항 문장 6개를 만들고, 속성 위반·독소 조항·납기 위험 문구가 섞인 '규정 위반 여부'를 함께 만든다.\n",
|
||
"# ---------------------------------------------------------------------------\n",
|
||
"TYPES = [\"물품\", \"용역\", \"공사\"]\n",
|
||
"METHODS = [\"일반경쟁\", \"제한경쟁\", \"지명경쟁\", \"수의계약\"]\n",
|
||
"CLASSES = [\"정상\", \"독소\", \"납기위험\"]\n",
|
||
"OBJ = [\"납품 물품\", \"용역 결과물\", \"시공 목적물\", \"정비 성과물\"]\n",
|
||
"\n",
|
||
"N_TPL = [\n",
|
||
" \"계약상대자가 납기를 지체한 경우 지체일수 1일마다 계약금액의 {p}/1000에 해당하는 지체상금을 부과한다.\",\n",
|
||
" \"지체상금은 불가항력 사유가 확인된 경우에 한하여 발주기관의 승인을 받아 면제할 수 있다.\",\n",
|
||
" \"납기 연장은 발주기관이 불가항력 사유를 확인하고 서면으로 승인한 경우에만 인정한다.\",\n",
|
||
" \"발주기관은 납품된 {o}을 {d}일 이내에 검사하고 합격한 후에 대금을 지급한다.\",\n",
|
||
" \"계약상대자는 하도급을 하려는 경우 사전에 발주기관의 서면 승인을 받아야 한다.\",\n",
|
||
" \"어느 한쪽이 계약을 해지하려면 사유를 명시해 {d}일 전에 서면으로 통지해야 한다.\",\n",
|
||
" \"{o}에 하자가 발생하면 계약상대자는 보증 기간 안에 무상으로 보수하거나 교체한다.\",\n",
|
||
" \"계약 이행 중 알게 된 비공개 사항은 제3자에게 누설하지 않으며 위반 시 책임을 진다.\",\n",
|
||
" \"{o}에 대한 지식재산권의 귀속과 사용 범위는 별도 합의서에 따라 정한다.\",\n",
|
||
" \"대금은 검사 합격 후 청구서를 접수한 날부터 {d}일 이내에 지급한다.\",\n",
|
||
" \"계약금액의 조정은 물가변동 기준에 따라 양 당사자가 서면으로 합의한 경우에만 한다.\",\n",
|
||
" \"계약상대자는 이행보증서를 계약 체결 시 제출하며 보증금은 계약 이행 완료 후 반환한다.\",\n",
|
||
" \"납품 일정은 계약서 별표의 일자를 따르며 변경 시 서면 합의가 필요하다.\",\n",
|
||
" \"지체상금은 면제 사유가 없는 한 부과하며 이의는 정해진 절차로 제기할 수 있다.\",\n",
|
||
" \"검사에 합격하지 못한 {o}은 계약상대자가 교체하며 그 비용은 계약상대자가 부담한다.\",\n",
|
||
" \"계약상대자의 귀책 사유로 이행이 불가능하면 발주기관은 서면으로 통지하고 계약을 해지할 수 있다.\",\n",
|
||
" \"지연이 예상되면 계약상대자는 사유와 만회 계획을 발주기관에 서면으로 보고한다.\",\n",
|
||
" \"보증 기간 중 발생한 결함은 계약상대자가 책임지고 보수한다.\",\n",
|
||
" \"분쟁은 먼저 협의로 해결하고 협의가 되지 않으면 관할 법원의 판단에 따른다.\",\n",
|
||
"]\n",
|
||
"T_TPL = [\n",
|
||
" \"계약상대자의 지체상금은 전액 면제하며 발주기관은 이에 대해 이의를 제기하지 못한다.\",\n",
|
||
" \"납품 즉시 검사에 합격한 것으로 보며 발주기관은 이후 검사를 요구하지 않는다.\",\n",
|
||
" \"계약상대자는 언제든지 일방적으로 계약을 해지할 수 있으며 그에 따른 손해는 발주기관이 부담한다.\",\n",
|
||
" \"{o}의 추가 비용은 사유를 불문하고 발주기관이 전액 부담하며 별도 협의 없이 청구할 수 있다.\",\n",
|
||
" \"{o}에 관한 지식재산권은 모두 계약상대자에게 귀속되고 발주기관은 사용할 수 없다.\",\n",
|
||
" \"하도급은 신고 없이 제한 없이 허용하며 발주기관은 하도급 내용을 확인하지 않는다.\",\n",
|
||
" \"대금은 검사 전에 전액 선지급하며 발주기관은 반환 청구권을 포기한다.\",\n",
|
||
" \"계약 이행 중 알게 된 사항의 누설로 인한 책임을 계약상대자는 지지 않는다.\",\n",
|
||
" \"하자 보수 책임은 인도 후 즉시 소멸하며 발주기관은 보수를 요구할 수 없다.\",\n",
|
||
" \"발주기관은 계약상대자가 청구하는 금액을 거절할 수 없으며 거절하면 위약금을 지급한다.\",\n",
|
||
" \"분쟁이 생기면 계약상대자가 정하는 중재 기관에서만 해결하고 발주기관은 따른다.\",\n",
|
||
" \"발주기관의 검사는 생략하고 계약상대자의 시험 결과서로 대신한다.\",\n",
|
||
" \"견적 금액은 사후 정산 없이 그대로 확정하고 증빙 자료는 받지 않는다.\",\n",
|
||
"]\n",
|
||
"D_TPL = [\n",
|
||
" \"납기는 자재 수급 사정에 따라 계약상대자가 연장할 수 있다.\",\n",
|
||
" \"납품 일정은 가능한 한 지키도록 노력하며 지연되어도 사유를 보고하지 않는다.\",\n",
|
||
" \"공급 지연이나 선행 업체 사정도 불가항력으로 보아 납기를 자동 연장한다.\",\n",
|
||
" \"착수일은 선행 업체의 납품이 끝난 뒤 별도로 정하며 완료 기한은 정하지 않는다.\",\n",
|
||
" \"납기일 이후 1회에 한해 기한 없이 자동 연장되는 것으로 한다.\",\n",
|
||
" \"납기는 계약상대자의 생산 일정이 확정되는 대로 통보한다.\",\n",
|
||
" \"납품 시점은 계약상대자 사정이 허락하는 때로 하고 날짜는 따로 정하지 않는다.\",\n",
|
||
" \"자재가 늦게 들어오면 완료 일정은 그만큼 자동으로 늦춰진다.\",\n",
|
||
" \"기한 내에 인도하지 못해도 계약상대자에게 책임을 묻지 않는다.\",\n",
|
||
" \"일정이 늦어져도 계약상대자는 새 계획서를 낼 필요가 없다.\",\n",
|
||
"]\n",
|
||
"# 학습에 쓰지 않는 '처음 보는 표현'(모델이 문장 틀을 외웠는지 확인하는 용도)\n",
|
||
"HOLD = {\n",
|
||
" \"정상\": [\"검사에 불합격한 경우 계약상대자는 지정 기한 안에 재납품하며 재검사 비용은 상대자가 부담한다.\",\n",
|
||
" \"계약상대자가 법령을 위반하여 계약을 이행하지 못하면 발주기관은 서면 통지 후 계약을 해지할 수 있다.\",\n",
|
||
" \"납기가 지연될 우려가 있으면 상대자는 즉시 사유와 만회 계획을 서면으로 보고해야 한다.\",\n",
|
||
" \"하자담보 책임 기간은 인수일부터 기산하며 기간 중 발생한 결함은 상대자가 책임진다.\"],\n",
|
||
" \"독소\": [\"발주기관은 계약상대자가 요구하는 대금을 이유 없이 거절하지 못하며 거절 시 위약금을 낸다.\",\n",
|
||
" \"계약 내용에 관한 분쟁은 계약상대자가 지정하는 기관에서만 다루며 발주기관은 이에 따른다.\",\n",
|
||
" \"발주기관의 검수 권한은 포기하고 계약상대자의 자체 시험 성적서로 갈음한다.\",\n",
|
||
" \"계약상대자가 제출한 견적은 사후 정산 없이 확정 단가로 하며 증빙은 요구하지 않는다.\"],\n",
|
||
" \"납기위험\": [\"납품 시기는 상대자의 형편이 허락하는 시점으로 하고 별도 날짜는 정하지 않는다.\",\n",
|
||
" \"원자재 확보가 늦어지면 완료 일자는 자동으로 뒤로 미뤄지는 것으로 본다.\",\n",
|
||
" \"인도 기한은 참고용이며 지키지 못해도 책임을 묻지 않는다.\",\n",
|
||
" \"일정 지연이 있어도 상대자는 변경 계획을 제출할 의무가 없다.\"],\n",
|
||
"}\n",
|
||
"SUFFIX = [\"\", \"\", \"\", \" 세부 사항은 시행 세칙에 따른다.\", \" 이 조항은 계약 전체에 적용한다.\", \" 필요한 경우 양 당사자가 협의한다.\", \" 관련 서식은 별지를 따른다.\"]\n",
|
||
"SWAP = [(\"발주기관\", \"계약담당관\"), (\"계약상대자\", \"업체\")]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_clause(rng, cls, hold=False, idx=None):\n",
|
||
" \"\"\"조항 한 문장 생성. 표현 일부는 동의어로 바꾸고 접미 문구를 무작위로 붙인다(idx를 주면 그 번호의 문장 틀을 사용).\"\"\"\n",
|
||
" if idx is not None:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" elif hold:\n",
|
||
" s = HOLD[cls][rng.integers(len(HOLD[cls]))]\n",
|
||
" else:\n",
|
||
" tpl = {\"정상\": N_TPL, \"독소\": T_TPL, \"납기위험\": D_TPL}[cls]\n",
|
||
" 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))\n",
|
||
" if rng.random() < 0.3:\n",
|
||
" a, b = SWAP[rng.integers(2)]\n",
|
||
" s = s.replace(a, b)\n",
|
||
" return s + SUFFIX[rng.integers(len(SUFFIX))]\n",
|
||
"\n",
|
||
"\n",
|
||
"def make_contracts(n=200, seed=7):\n",
|
||
" \"\"\"가상 계약 n건(속성 16개 + 조항 6개 + 규정 위반 정답)을 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" sl = rng.beta(1.2, 3.2, n) # 계약서 작성이 허술한 정도(잠재 변수)\n",
|
||
" ctype = rng.choice(TYPES, n, p=[.5, .3, .2])\n",
|
||
" amount = np.clip(np.exp(rng.normal(np.log(220), 1.1, n)), 5, 5000).round(0)\n",
|
||
" method = np.where(rng.random(n) < .06 + .3 * sl, \"수의계약\", rng.choice(METHODS[:3], n, p=[.5, .3, .2]))\n",
|
||
" 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])\n",
|
||
" delivery = (period * rng.uniform(.5, 1.0, n)).astype(int)\n",
|
||
" bad_logic = rng.random(n) < .015 * (1 + 3 * sl)\n",
|
||
" delivery = np.where(bad_logic, period + rng.integers(10, 60, n), delivery)\n",
|
||
" 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))\n",
|
||
" adv = np.where(rng.random(n) < .22 * sl, rng.choice([50, 60, 70], n), rng.choice([0, 10, 20, 30, 40], n))\n",
|
||
" bond = np.where(rng.random(n) < .25 * sl + .5 * (adv > 40), rng.choice([0, 5], n), rng.choice([10, 10, 15, 20], n))\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)])\n",
|
||
" insp = np.where(rng.random(n) < .3 * sl, rng.choice([21, 30], n), rng.choice([5, 7, 10, 14], n))\n",
|
||
" sub = np.where(rng.random(n) < .15 * sl, rng.choice([60, 70, 80], n), rng.choice([0, 10, 20, 30, 40, 50], n))\n",
|
||
" padj = rng.random(n) < .75 - .4 * sl\n",
|
||
" term = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" sec = rng.random(n) > .015 * (1 + 6 * sl)\n",
|
||
" ip = rng.random(n) > .03 * (1 + 4 * sl)\n",
|
||
" clauses, cls_true = [], []\n",
|
||
" for i in range(n):\n",
|
||
" pt, pd_ = .01 + .08 * sl[i], .02 + .08 * sl[i]\n",
|
||
" cl = [rng.choice(CLASSES, p=[1 - pt - pd_, pt, pd_]) for _ in range(6)]\n",
|
||
" cls_true.append(cl); clauses.append([make_clause(rng, c) for c in cl])\n",
|
||
" df = pd.DataFrame({\"contract_id\": [f\"C-{i + 1:04d}\" for i in range(n)], \"ctype\": ctype, \"method\": method, \"amount_m\": amount,\n",
|
||
" \"period_days\": period, \"delivery_days\": delivery, \"penalty_permil\": pen, \"advance_pct\": adv, \"perf_bond_pct\": bond,\n",
|
||
" \"warranty_months\": war, \"inspection_days\": insp, \"subcontract_pct\": sub, \"price_adjust\": padj,\n",
|
||
" \"termination\": term, \"security\": sec, \"ip_clause\": ip})\n",
|
||
" hard = ((pen < 1.0) | ((adv > 40) & (bond < 10)) | ((amount >= 300) & (method == \"수의계약\")) | ((ctype == \"물품\") & (war < 12))\n",
|
||
" | ~term | ~sec | (sub > 50) | (delivery > period))\n",
|
||
" toxic = np.array([c.count(\"독소\") for c in cls_true])\n",
|
||
" delay = np.array([c.count(\"납기위험\") for c in cls_true])\n",
|
||
" split = (method == \"수의계약\") & (amount >= 250) & (amount < 300) & (rng.random(n) < .6) # 계약 쪼개기 의심(임계값 바로 아래)\n",
|
||
" df[\"clauses\"], df[\"clause_cls\"] = clauses, cls_true\n",
|
||
" df[\"n_toxic\"], df[\"n_delay\"] = toxic, delay\n",
|
||
" viol = hard | (toxic > 0) | split | (delay >= 2)\n",
|
||
" waived = hard & (toxic == 0) & (rng.random(n) < .12) # 계약관이 예외 사유를 인정해 위반으로 보지 않은 건(자료의 현실적 잡음)\n",
|
||
" hidden = ~viol & (rng.random(n) < .03 + .08 * sl) # 속성·문구에 드러나지 않는 위반(별첨 합의 등)\n",
|
||
" df[\"violation\"] = (viol & ~waived) | hidden\n",
|
||
" df[\"vtype\"] = np.where(hidden, \"기타\", np.where(hard, \"속성위반\", np.where(toxic > 0, \"독소조항\", np.where(split | (delay >= 2), \"기타\", \"-\"))))\n",
|
||
" return df\n",
|
||
"\n",
|
||
"# ---- 계약 규정 준수 판별 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----\n",
|
||
"RULES_YAML = \"\"\"\n",
|
||
"- id: R01\n",
|
||
" name: 지체상금률 1.0/1000 미만\n",
|
||
" field: penalty_permil\n",
|
||
" op: \"<\"\n",
|
||
" value: 1.0\n",
|
||
" weight: 3\n",
|
||
"- id: R02\n",
|
||
" name: 선금 비율 40% 초과\n",
|
||
" field: advance_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 40\n",
|
||
" weight: 2\n",
|
||
"- id: R03\n",
|
||
" name: 선금 40% 초과인데 이행보증 10% 미만\n",
|
||
" field: adv_no_bond\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R04\n",
|
||
" name: 300백만원 이상 수의계약\n",
|
||
" field: sole_amount\n",
|
||
" op: \">=\"\n",
|
||
" value: 300\n",
|
||
" weight: 3\n",
|
||
"- id: R05\n",
|
||
" name: 500백만원 이상 이행보증 10% 미만\n",
|
||
" field: bond_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R06\n",
|
||
" name: 물품 하자보증 12개월 미만\n",
|
||
" field: warranty_gap\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R07\n",
|
||
" name: 해지 조항 없음\n",
|
||
" field: termination\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 2\n",
|
||
"- id: R08\n",
|
||
" name: 보안 조항 없음\n",
|
||
" field: security\n",
|
||
" op: \"==\"\n",
|
||
" value: false\n",
|
||
" weight: 3\n",
|
||
"- id: R09\n",
|
||
" name: 지식재산 조항 없음(물품·용역)\n",
|
||
" field: ip_missing\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R10\n",
|
||
" name: 하도급 비율 50% 초과\n",
|
||
" field: subcontract_pct\n",
|
||
" op: \">\"\n",
|
||
" value: 50\n",
|
||
" weight: 3\n",
|
||
"- id: R11\n",
|
||
" name: 검사 기간 14일 초과\n",
|
||
" field: inspection_days\n",
|
||
" op: \">\"\n",
|
||
" value: 14\n",
|
||
" weight: 1\n",
|
||
"- id: R12\n",
|
||
" name: 납품 기한이 계약 기간 초과\n",
|
||
" field: logic_err\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 3\n",
|
||
"- id: R13\n",
|
||
" name: 180일 이상 계약에 물가변동 조항 없음\n",
|
||
" field: no_padj_long\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 1\n",
|
||
"- id: R14\n",
|
||
" name: 1000백만원 이상 비경쟁 계약\n",
|
||
" field: noncomp_big\n",
|
||
" op: \"==\"\n",
|
||
" value: true\n",
|
||
" weight: 2\n",
|
||
"- id: R15\n",
|
||
" name: 계약 기간 365일 초과\n",
|
||
" field: period_days\n",
|
||
" op: \">\"\n",
|
||
" value: 365\n",
|
||
" weight: 1\n",
|
||
"- id: R16\n",
|
||
" name: 불공정·납기 위험 키워드 포함\n",
|
||
" field: kw_hits\n",
|
||
" op: \">=\"\n",
|
||
" value: 1\n",
|
||
" weight: 3\n",
|
||
"\"\"\"\n",
|
||
"GRADE_CUT = {\"고위험\": 6, \"주의\": 3} # 가중치 합 기준 1차 판별 등급(필요 시 설정만 바꾼다)\n",
|
||
"# [교육용가상자료] 불공정 조항·납기 지체 위험 문구 사전(실제 규정 조문이 아님)\n",
|
||
"KEYWORDS = {\"독소\": [\"면제하며\", \"일방적으로\", \"포기\", \"지지 않는다\", \"즉시 소멸\", \"제한 없이\", \"신고 없이\", \"전액 부담\", \"별도 협의 없이\", \"검사 전에\", \"검사에 합격한 것으로\", \"사용할 수 없다\", \"증빙은 요구하지 않는다\", \"지정하는 기관\"],\n",
|
||
" \"납기위험\": [\"자동 연장\", \"기한 없이\", \"정하지 않는다\", \"노력하며\", \"연장할 수 있다\", \"통보한다\", \"책임을 묻지 않는다\", \"의무가 없다\"]}\n",
|
||
"\n",
|
||
"\n",
|
||
"def parse_rules(text):\n",
|
||
" \"\"\"간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다.\"\"\"\n",
|
||
" rules, cur = [], None\n",
|
||
" for line in text.strip().splitlines():\n",
|
||
" if not line.strip():\n",
|
||
" continue\n",
|
||
" s = line.strip()\n",
|
||
" if s.startswith(\"- \"):\n",
|
||
" cur = {}; rules.append(cur); s = s[2:]\n",
|
||
" k, v = [x.strip() for x in s.split(\":\", 1)]\n",
|
||
" v = v.strip('\"')\n",
|
||
" if v in (\"true\", \"false\"):\n",
|
||
" v = v == \"true\"\n",
|
||
" else:\n",
|
||
" try:\n",
|
||
" v = float(v) if \".\" in v else int(v)\n",
|
||
" except ValueError:\n",
|
||
" pass\n",
|
||
" cur[k] = v\n",
|
||
" return rules\n",
|
||
"\n",
|
||
"\n",
|
||
"OPS = {\"==\": lambda a, b: a == b, \"!=\": lambda a, b: a != b, \">\": lambda a, b: a > b, \">=\": lambda a, b: a >= b,\n",
|
||
" \"<\": lambda a, b: a < b, \"<=\": lambda a, b: a <= b}\n",
|
||
"\n",
|
||
"\n",
|
||
"def kw_count(clauses):\n",
|
||
" \"\"\"조항 문장 목록에서 독소·납기 위험 키워드가 들어 있는 조항 수.\"\"\"\n",
|
||
" return int(sum(any(k in c for k in KEYWORDS[\"독소\"] + KEYWORDS[\"납기위험\"]) for c in clauses))\n",
|
||
"\n",
|
||
"\n",
|
||
"def derive(df):\n",
|
||
" \"\"\"룰에 쓰는 파생 필드(선금+보증 부족, 수의계약 고액, 논리 오류, 키워드 적중 등)를 만든다.\"\"\"\n",
|
||
" d = df.copy()\n",
|
||
" d[\"adv_no_bond\"] = (d[\"advance_pct\"] > 40) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"sole_amount\"] = np.where(d[\"method\"] == \"수의계약\", d[\"amount_m\"], 0.0) # 수의계약일 때만 금액(한도는 YAML에서 조정)\n",
|
||
" d[\"bond_gap\"] = (d[\"amount_m\"] >= 500) & (d[\"perf_bond_pct\"] < 10)\n",
|
||
" d[\"warranty_gap\"] = (d[\"ctype\"] == \"물품\") & (d[\"warranty_months\"] < 12)\n",
|
||
" d[\"ip_missing\"] = (~d[\"ip_clause\"]) & (d[\"ctype\"] != \"공사\")\n",
|
||
" d[\"logic_err\"] = d[\"delivery_days\"] > d[\"period_days\"]\n",
|
||
" d[\"no_padj_long\"] = (d[\"period_days\"] >= 180) & (~d[\"price_adjust\"])\n",
|
||
" d[\"noncomp_big\"] = d[\"method\"].isin([\"지명경쟁\", \"수의계약\"]) & (d[\"amount_m\"] >= 1000)\n",
|
||
" d[\"kw_hits\"] = [kw_count(c) for c in d[\"clauses\"]] if \"clauses\" in d else 0\n",
|
||
" return d\n",
|
||
"\n",
|
||
"\n",
|
||
"def apply_rules(rec, rules):\n",
|
||
" \"\"\"레코드 1건(dict)에 룰을 적용해 (점수, 등급, 적중 룰 id 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다.\"\"\"\n",
|
||
" hits = []\n",
|
||
" for r in rules:\n",
|
||
" v = rec.get(r[\"field\"])\n",
|
||
" if v is None or (isinstance(v, float) and np.isnan(v)):\n",
|
||
" continue\n",
|
||
" if OPS[r[\"op\"]](v, r[\"value\"]):\n",
|
||
" hits.append(r)\n",
|
||
" score = sum(r[\"weight\"] for r in hits)\n",
|
||
" grade = \"고위험\" if score >= GRADE_CUT[\"고위험\"] else (\"주의\" if score >= GRADE_CUT[\"주의\"] else \"관심\")\n",
|
||
" return score, grade, [r[\"id\"] for r in hits]\n",
|
||
"\n",
|
||
"SIMPLE_KW = {\"독소\": [\"면제\", \"일방\", \"포기\", \"전액\", \"누설\", \"하도급\", \"선지급\"], \"납기위험\": [\"연장\", \"자동\", \"노력\", \"통보\", \"기한\"]}\n",
|
||
"\n",
|
||
"# ---- 조항 문장 분류(정상 / 독소 / 납기위험): 글자 n-gram TF-IDF + 로지스틱 회귀 ----\n",
|
||
"def clause_set(seed, n, hold=False, mix=(.55, .25, .20)):\n",
|
||
" \"\"\"조항 문장 n개와 정답 분류를 만든다. hold=True면 학습에 쓰지 않은 '처음 보는 표현'으로 만든다.\"\"\"\n",
|
||
" rng = np.random.default_rng(seed)\n",
|
||
" y = rng.choice(CLASSES, n, p=list(mix))\n",
|
||
" return [make_clause(rng, c, hold) for c in y], list(y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def fit_clause_model(seed=100, n=1200):\n",
|
||
" \"\"\"학습용 조항 문장으로 분류 모델을 학습한다.\"\"\"\n",
|
||
" X, y = clause_set(seed, n)\n",
|
||
" return make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(2, 4), sublinear_tf=True), LogisticRegression(C=20, max_iter=2000)).fit(X, y)\n",
|
||
"\n",
|
||
"\n",
|
||
"def keyword_pred(s):\n",
|
||
" \"\"\"비교용 기준선: 단순 키워드(부정 표현·문맥을 보지 않음) 적중으로만 분류.\"\"\"\n",
|
||
" if any(k in s for k in SIMPLE_KW[\"독소\"]):\n",
|
||
" return \"독소\"\n",
|
||
" return \"납기위험\" if any(k in s for k in SIMPLE_KW[\"납기위험\"]) else \"정상\"\n",
|
||
"\n",
|
||
"REQ = {\"contract_id\": r\"^C-\\d{4}$\", \"ctype\": TYPES, \"method\": METHODS, \"amount_m\": (1, 100000), \"period_days\": (1, 1500), \"delivery_days\": (1, 1500),\n",
|
||
" \"penalty_permil\": (0, 10), \"advance_pct\": (0, 100), \"perf_bond_pct\": (0, 100), \"subcontract_pct\": (0, 100)}\n",
|
||
"CONF = {\"queue_min_score\": 50}\n",
|
||
"RULES = parse_rules(RULES_YAML)\n",
|
||
"RID = [r[\"id\"] for r in RULES]\n",
|
||
"\n",
|
||
"# docs/prc01_final_manual.md 요약: 장별 내용(작성률 점검용)\n",
|
||
"MANUAL = {\n",
|
||
" \"1. 목적과 범위\": \"조달계획안·계약서의 규정 위반 가능성을 미리 찾는 검토 보조 도구다. 최종 판단과 승인은 계약관·법무·승인권자가 한다.\",\n",
|
||
" \"2. 설치와 실행\": \"install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.\",\n",
|
||
" \"3. 입력 데이터\": \"계약 속성 16개 필드와 조항 문장 목록. 범위·형식 오류 행은 격리하고 사유를 기록한다.\",\n",
|
||
" \"4. 위험 판정 규칙\": \"YAML 룰 16종 점수와 로지스틱 회귀 위험 점수, 조항 분류 모델 결과를 함께 보여 준다.\",\n",
|
||
" \"5. 사람 검토와 감사 로그\": \"모든 위험 계약은 PENDING으로 시작해 계약관·법무·승인권자가 처리한다. 모든 행위는 해시 체인에 남는다.\",\n",
|
||
" \"6. 오류 대응\": \"깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 조항 모델 실패 시 룰 판별만 제공한다.\",\n",
|
||
" \"7. 한계와 확장\": \"가상 자료 기반이며 실제 규정 조문·계약 자료는 담당 부서가 제공해야 한다. 결재 연동은 차기 과제다.\",\n",
|
||
"}\n",
|
||
"INSTALL_SH = \"\"\"#!/bin/sh\n",
|
||
"# [교육용가상자료] prc01 설치 스크립트 예시\n",
|
||
"set -e\n",
|
||
"python3 -c \"import sys; assert sys.version_info >= (3, 9)\" # 1) 파이썬 버전 점검\n",
|
||
"python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경\n",
|
||
"pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)\n",
|
||
"python3 -m pytest tests -q # 4) 자체 시험\n",
|
||
"python3 main_prc01.py --demo # 5) 데모 실행(가상 자료)\n",
|
||
"\"\"\"\n",
|
||
"\n",
|
||
"\n",
|
||
"def validate(df):\n",
|
||
" \"\"\"필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다.\"\"\"\n",
|
||
" bad = pd.Series(False, index=df.index)\n",
|
||
" for k, rule in REQ.items():\n",
|
||
" if k not in df.columns:\n",
|
||
" raise ValueError(f\"필수 열 없음: {k}\")\n",
|
||
" if isinstance(rule, str):\n",
|
||
" bad |= ~df[k].astype(str).str.match(rule)\n",
|
||
" elif isinstance(rule, tuple):\n",
|
||
" v = pd.to_numeric(df[k], errors=\"coerce\")\n",
|
||
" bad |= v.isna() | (v < rule[0]) | (v > rule[1])\n",
|
||
" else:\n",
|
||
" bad |= ~df[k].isin(rule)\n",
|
||
" bad |= df[\"clauses\"].map(lambda c: not c)\n",
|
||
" return df[~bad].copy(), df[bad].copy()\n",
|
||
"\n",
|
||
"\n",
|
||
"class Chain:\n",
|
||
" def __init__(self):\n",
|
||
" self.log = []\n",
|
||
"\n",
|
||
" def add(self, **body):\n",
|
||
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
|
||
" body = {\"seq\": len(self.log), **body}\n",
|
||
" self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})\n",
|
||
"\n",
|
||
" def verify(self):\n",
|
||
" prev = \"GENESIS\"\n",
|
||
" for e in self.log:\n",
|
||
" b = {k: v for k, v in e.items() if k not in (\"prev\", \"hash\")}\n",
|
||
" if e[\"prev\"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e[\"hash\"]:\n",
|
||
" return e[\"seq\"]\n",
|
||
" prev = e[\"hash\"]\n",
|
||
" return None\n",
|
||
"\n",
|
||
"\n",
|
||
"def featurize(d, cm):\n",
|
||
" \"\"\"룰 16종 적중 + 조항 모델이 찾은 위험 조항 수 + 금액(로그).\"\"\"\n",
|
||
" H = np.array([[r in apply_rules(x, RULES)[2] for r in RID] for x in d.to_dict(\"records\")], float)\n",
|
||
" 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\n",
|
||
" nt = (pred == \"독소\").sum(1) if pred is not None else np.zeros(len(d))\n",
|
||
" nd = (pred == \"납기위험\").sum(1) if pred is not None else np.zeros(len(d))\n",
|
||
" return np.column_stack([H, nt, nd, np.log1p(d[\"amount_m\"].to_numpy())]), pred\n",
|
||
"\n",
|
||
"\n",
|
||
"def fit_risk(train, cm):\n",
|
||
" X, _ = featurize(derive(train), cm)\n",
|
||
" return make_pipeline(StandardScaler(), LogisticRegression(C=0.5, max_iter=1000)).fit(X, train[\"violation\"].astype(int))\n",
|
||
"\n",
|
||
"\n",
|
||
"def run_pipeline(raw, cm, risk_model):\n",
|
||
" \"\"\"한 번 실행: 검증 -> 룰 -> 위험 점수 -> 위험 조항 표시 -> 사람 검토 큐 등록(전부 PENDING) + 감사 로그.\"\"\"\n",
|
||
" ok, bad = validate(raw)\n",
|
||
" d = derive(ok.reset_index(drop=True))\n",
|
||
" out = [apply_rules(r, RULES) for r in d.to_dict(\"records\")]\n",
|
||
" d[\"rule_score\"], d[\"rule_grade\"] = [o[0] for o in out], [o[1] for o in out]\n",
|
||
" X, pred = featurize(d, cm)\n",
|
||
" d[\"risk\"] = risk_model.predict_proba(X)[:, 1] * 100\n",
|
||
" d[\"flagged\"] = [[i + 1 for i, k in enumerate(row) if k != \"정상\"] for row in pred]\n",
|
||
" audit, queue = Chain(), []\n",
|
||
" for _, r in d[d[\"risk\"] >= CONF[\"queue_min_score\"]].sort_values(\"risk\", ascending=False).iterrows():\n",
|
||
" audit.add(event=\"PROPOSE\", case=r[\"contract_id\"], status=\"PENDING\", by=\"system\")\n",
|
||
" queue.append({\"contract_id\": r[\"contract_id\"], \"risk\": round(r[\"risk\"], 1), \"grade\": r[\"rule_grade\"], \"flagged\": r[\"flagged\"], \"violation\": bool(r[\"violation\"]),\n",
|
||
" \"check_first\": bool(r[\"rule_grade\"] == \"관심\" or r[\"risk\"] < 70), \"status\": \"PENDING(담당자 검토 대기)\"})\n",
|
||
" return {\"data\": d, \"quarantine\": bad, \"queue\": pd.DataFrame(queue), \"audit\": audit}\n",
|
||
"\n",
|
||
"\n",
|
||
"def self_tests(cm, risk_model):\n",
|
||
" res = {}\n",
|
||
" con = make_contracts(100, seed=31)\n",
|
||
" r = run_pipeline(con, cm, risk_model)\n",
|
||
" res[\"정상 자료 통합 실행\"] = len(r[\"data\"]) == 100 and len(r[\"queue\"]) > 0\n",
|
||
" broken = con.copy(); broken.loc[broken.index[:3], \"amount_m\"] = -5; broken.loc[broken.index[3], \"contract_id\"] = \"X1\"\n",
|
||
" r2 = run_pipeline(broken, cm, risk_model)\n",
|
||
" res[\"손상 레코드 4건 격리\"] = len(r2[\"quarantine\"]) == 4 and len(r2[\"data\"]) == 96\n",
|
||
" res[\"모든 검토 건이 PENDING(자동 확정 없음)\"] = r[\"queue\"][\"status\"].str.startswith(\"PENDING\").all()\n",
|
||
" r[\"audit\"].log[0][\"case\"] = \"C-9999\"\n",
|
||
" res[\"감사 로그 변조 탐지\"] = r[\"audit\"].verify() == 0\n",
|
||
" res[\"룰 16종 이상 로딩\"] = len(RULES) >= 16\n",
|
||
" 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,\n",
|
||
" ip_clause=True, inspection_days=7, method=\"일반경쟁\", amount_m=100.0, period_days=120, delivery_days=100, price_adjust=True,\n",
|
||
" ctype=\"물품\", clauses=[[make_clause(np.random.default_rng(i), \"정상\") for i in range(6)]]))\n",
|
||
" res[\"안전 속성 계약은 룰 '관심' 등급\"] = apply_rules(safe.iloc[0].to_dict(), RULES)[1] == \"관심\"\n",
|
||
" res[\"매뉴얼 7개 장 작성\"] = all(len(v) > 20 for v in MANUAL.values())\n",
|
||
" res[\"설치 스크립트 5단계\"] = INSTALL_SH.count(\"\\n\") >= 7 and all(k in INSTALL_SH for k in (\"requirements\", \"pytest\", \"--demo\", \"venv\", \"version_info\"))\n",
|
||
" return res\n",
|
||
"\n",
|
||
"\n",
|
||
"# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----\n",
|
||
"called = set()\n",
|
||
"own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, \"__module__\", \"\") == \"__main__\" and hasattr(f, \"__code__\")}\n",
|
||
"sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n",
|
||
"t_all = time.perf_counter()\n",
|
||
"cm = fit_clause_model()\n",
|
||
"risk_model = fit_risk(make_contracts(600, seed=11), cm)\n",
|
||
"_ = clause_set(1, 5); _ = keyword_pred(\"테스트\"); _ = make_clause(np.random.default_rng(1), \"독소\", hold=True)\n",
|
||
"tests = self_tests(cm, risk_model)\n",
|
||
"e2e = []\n",
|
||
"for s in (7, 17, 27, 37):\n",
|
||
" try:\n",
|
||
" r = run_pipeline(make_contracts(100, seed=s), cm, risk_model)\n",
|
||
" q = r[\"queue\"]\n",
|
||
" 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))\n",
|
||
" except Exception as e:\n",
|
||
" e2e.append((s, False, 0, np.nan, 0, 0, False))\n",
|
||
"sys.setprofile(None)\n",
|
||
"cov = len(own & called) / len(own)\n",
|
||
"\n",
|
||
"print(f\"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)\")\n",
|
||
"print(\"[설치 스크립트(원클릭) 5단계 점검]\")\n",
|
||
"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)]\n",
|
||
"for name, ok in zip([\"파이썬 버전\", \"가상환경\", \"의존 패키지\", \"자체 시험\", \"데모 실행\"], step_ok):\n",
|
||
" print(f\" [{'PASS' if ok else 'FAIL'}] {name}\")\n",
|
||
"print(f\"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})\")\n",
|
||
"\n",
|
||
"print(\"\\n[End-to-End 시나리오 4종]\")\n",
|
||
"for s, ok, nq, acc, chk, nb, chain in e2e:\n",
|
||
" print(f\" seed {s}: {'성공' if ok else '실패'}, 검토 큐 {nq}건(우선 확인 {chk}건), 실제 위반 비율 {acc:.0%}, 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}\")\n",
|
||
"print(f\"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%}\")\n",
|
||
"print(\"[자체 점검]\")\n",
|
||
"for k, v in tests.items():\n",
|
||
" print(f\" [{'PASS' if v else 'FAIL'}] {k}\")\n",
|
||
"print(f\"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n",
|
||
"done = sum(len(v) > 20 for v in MANUAL.values())\n",
|
||
"print(f\"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): \" + \", \".join(MANUAL))\n",
|
||
"\n",
|
||
"# ---- 브리핑 요약(seed 7 기준) ----\n",
|
||
"r = run_pipeline(make_contracts(100, seed=7), cm, risk_model)\n",
|
||
"d, q = r[\"data\"], r[\"queue\"]\n",
|
||
"print(\"\\n[조달 담당 브리핑 요약]\")\n",
|
||
"print(f\"1. 계약 {len(d)}건 점검: 룰 고위험 {int((d['rule_grade'] == '고위험').sum())}건, 주의 {int((d['rule_grade'] == '주의').sum())}건, 위험 점수 50점 이상 {int((d['risk'] >= 50).sum())}건\")\n",
|
||
"print(f\"2. 사람 검토 큐 {len(q)}건: 모두 PENDING, 우선 확인 {int(q['check_first'].sum())}건(룰 '관심'인데 모델이 높게 본 건 또는 점수 70 미만 포함)\")\n",
|
||
"for _, x in d.sort_values(\"risk\", ascending=False).head(3).iterrows():\n",
|
||
" print(f\" - {x['contract_id']} {x['ctype']} {x['method']} 위험 {x['risk']:.0f}점, 룰 점수 {x['rule_score']}, 위험 조항 번호 {x['flagged'] or '-'}\")\n",
|
||
"print(\"3. 조항의 불공정 여부, 법무 검토, 계약 승인은 계약관·법무·승인권자가 수행한다.\")\n",
|
||
"print(\"4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다.\")\n",
|
||
"\n",
|
||
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
|
||
"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)\")\n",
|
||
"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\")\n",
|
||
"plt.suptitle(\"Procurement contract screening package (synthetic)\"); plt.tight_layout(); plt.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"metadata": {},
|
||
"source": [
|
||
"<details>\n",
|
||
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
|
||
"\n",
|
||
"```text\n",
|
||
"[교육용가상자료] 통합 실행 5.3초 (모델 학습 포함)\n",
|
||
"[설치 스크립트(원클릭) 5단계 점검]\n",
|
||
" [PASS] 파이썬 버전\n",
|
||
" [PASS] 가상환경\n",
|
||
" [PASS] 의존 패키지\n",
|
||
" [PASS] 자체 시험\n",
|
||
" [PASS] 데모 실행\n",
|
||
"설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)\n",
|
||
"\n",
|
||
"[End-to-End 시나리오 4종]\n",
|
||
" seed 7: 성공, 검토 큐 27건(우선 확인 6건), 실제 위반 비율 96%, 격리 0건, 감사 로그 정상\n",
|
||
" seed 17: 성공, 검토 큐 33건(우선 확인 2건), 실제 위반 비율 88%, 격리 0건, 감사 로그 정상\n",
|
||
" seed 27: 성공, 검토 큐 41건(우선 확인 10건), 실제 위반 비율 90%, 격리 0건, 감사 로그 정상\n",
|
||
" seed 37: 성공, 검토 큐 37건(우선 확인 5건), 실제 위반 비율 78%, 격리 0건, 감사 로그 정상\n",
|
||
"E2E 성공률 100%\n",
|
||
"[자체 점검]\n",
|
||
" [PASS] 정상 자료 통합 실행\n",
|
||
" [PASS] 손상 레코드 4건 격리\n",
|
||
" [PASS] 모든 검토 건이 PENDING(자동 확정 없음)\n",
|
||
" [PASS] 감사 로그 변조 탐지\n",
|
||
" [PASS] 룰 16종 이상 로딩\n",
|
||
" [PASS] 안전 속성 계약은 룰 '관심' 등급\n",
|
||
" [PASS] 매뉴얼 7개 장 작성\n",
|
||
" [PASS] 설치 스크립트 5단계\n",
|
||
"자체 점검 8/8 통과, 함수 커버리지 93%(13/14, 목표 85% 이상: 달성)\n",
|
||
"매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 위험 판정 규칙, 5. 사람 검토와 감사 로그, 6. 오류 대응, 7. 한계와 확장\n",
|
||
"\n",
|
||
"[조달 담당 브리핑 요약]\n",
|
||
"1. 계약 100건 점검: 룰 고위험 8건, 주의 26건, 위험 점수 50점 이상 27건\n",
|
||
"2. 사람 검토 큐 27건: 모두 PENDING, 우선 확인 6건(룰 '관심'인데 모델이 높게 본 건 또는 점수 70 미만 포함)\n",
|
||
" - C-0085 물품 일반경쟁 위험 100점, 룰 점수 7, 위험 조항 번호 [1, 3, 5, 6]\n",
|
||
" - C-0099 물품 일반경쟁 위험 100점, 룰 점수 9, 위험 조항 번호 [3, 4]\n",
|
||
" - C-0024 물품 제한경쟁 위험 100점, 룰 점수 8, 위험 조항 번호 [2]\n",
|
||
"3. 조항의 불공정 여부, 법무 검토, 계약 승인은 계약관·법무·승인권자가 수행한다.\n",
|
||
"4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규정 조문·계약 자료는 담당 부서 확인이 필요하다.\n",
|
||
"```\n",
|
||
"</details>\n"
|
||
]
|
||
}
|
||
],
|
||
"metadata": {
|
||
"colab": {
|
||
"provenance": []
|
||
},
|
||
"kernelspec": {
|
||
"display_name": "Python 3",
|
||
"name": "python3"
|
||
},
|
||
"language_info": {
|
||
"name": "python"
|
||
}
|
||
},
|
||
"nbformat": 4,
|
||
"nbformat_minor": 0
|
||
} |