{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# 초간단 RAG 1탄 — 판결서 익명처리 데이터로 판결 이유 초안 받기\n", "\n", "익명처리 라벨을 적용한 판결문으로 **내 컴퓨터 안에서만** 도는\n", "RAG 를 만듭니다. 쟁점 한 줄을 넣으면 참고 판결 3건을 찾고,\n", "그 판결을 근거로 가상의 판결 이유 초안을 씁니다.\n", "\n", "> **로컬 PC 또는 국내 서버·국내 클라우드에서만 실행하세요.\n", "> Colab 에서는 실행할 수 없습니다.**\n", "> 이 데이터는 AI Hub 약관상 제3자 제공·재배포가 금지되고,\n", "> 해외 서버·클라우드에 전송·저장하거나 그 환경에서 학습하는 것도\n", "> 국외 반출에 해당합니다. 판결문을 외부 API·LLM 으로 보내지 않도록\n", "> 임베딩과 생성 모두 이 컴퓨터의 로컬 모델로 돌립니다.\n", "\n", "이 노트북은 과학기술정보통신부와 한국지능정보사회진흥원의 사업결과로\n", "구축된 AI Hub 판결서 익명처리 데이터를 활용했습니다.\n", "\n", "| 단계 | 하는 일 | 모델·도구 |\n", "|---|---|---|\n", "| 1 | 데이터 경로 지정 · zip 해제 | 표준 라이브러리 |\n", "| 2 | 라벨 적용 → 가명 코퍼스 | 표준 라이브러리 |\n", "| 3 | 임베딩(캐시 파일 저장) | `BAAI/bge-m3` (MIT) |\n", "| 4 | 쟁점 검색 상위 3건 | 코사인 유사도 |\n", "| 5 | 이유 초안 생성 — 근거 있음/없음 | `Qwen3-4B-Instruct-2507` (Apache-2.0) |\n", "\n", "실행 환경은 Apple Silicon Mac 기준입니다. 다른 기기는 5단계에서\n", "transformers 판 셀이 대신 돕니다. 3단계 임베딩이 가장 오래 걸립니다\n", "(M3 Pro 에서 16,800건에 약 1시간). 한 번 만들면 캐시 파일을 씁니다.\n", "\n", "생성 결과는 가상의 초안입니다. 법률 자문이 아닙니다." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import platform\n", "import sys\n", "\n", "APPLE = (sys.platform == \"darwin\"\n", " and platform.machine() == \"arm64\")\n", "\n", "pkgs = \"sentence-transformers numpy\"\n", "pkgs += \" mlx-lm\" if APPLE else \" transformers accelerate\"\n", "%pip install -q {pkgs}" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import importlib.metadata as md\n", "\n", "import torch\n", "\n", "print(\"python :\", platform.python_version())\n", "for name in [\"torch\", \"sentence-transformers\",\n", " \"transformers\", \"numpy\"]:\n", " print(f\"{name:<12}:\", md.version(name))\n", "if APPLE:\n", " print(\"mlx-lm :\", md.version(\"mlx-lm\"))\n", "\n", "if torch.cuda.is_available():\n", " DEV = \"cuda\"\n", "elif torch.backends.mps.is_available():\n", " DEV = \"mps\"\n", "else:\n", " DEV = \"cpu\"\n", "print(\"device :\", DEV)\n", "print(\"machine :\", platform.platform())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. 데이터 경로 지정\n", "\n", "이 저장소의 다른 노트북은 데이터를 실행 시점에 직접 내려받지만,\n", "이 노트북은 예외입니다. 판결서 익명처리 데이터는 **승인제**라\n", "AI Hub 에 로그인해 신청하고 승인받은 사람만 받을 수 있고,\n", "재배포가 금지돼 노트북이 대신 받아 올 수 없습니다.\n", "\n", "1. [데이터 페이지](https://aihub.or.kr/aihubdata/data/view.do?currMenu=115&topMenu=100&aihubDataSe=data&dataSetSn=71968)\n", " 에서 신청하고 내려받습니다.\n", "2. 압축을 풀면 나오는 `3.개방데이터/2.데이터(NIA)` 폴더 경로를\n", " 아래 `DATA_DIR` 에 적습니다. 안에 zip 8개가 있어야 합니다.\n", "\n", "코퍼스·임베딩 같은 중간 파일은 `WORK_DIR` 에 쌓입니다.\n", "이 파일들도 원본과 같은 약관을 따르니 공유하지 마세요." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import os\n", "from pathlib import Path\n", "\n", "# 받은 데이터 폴더 경로를 적으세요. 환경변수로 줘도 됩니다.\n", "DATA_DIR = Path(os.environ.get(\n", " \"AIHUB_JUDGMENT_DIR\", \"여기에/3.개방데이터/2.데이터(NIA)\"))\n", "WORK_DIR = Path(\"data/legal-rag-01\")\n", "\n", "zips = sorted(DATA_DIR.expanduser().rglob(\"*.zip\"))\n", "if not zips:\n", " raise FileNotFoundError(\n", " \"DATA_DIR 에서 zip 을 찾지 못했습니다. \"\n", " \"2.데이터(NIA) 폴더 경로를 확인하세요.\")\n", "for z in zips:\n", " mb = z.stat().st_size / 1024 ** 2\n", " print(f\"{z.parent.parent.name:<11}{mb:5.1f}MB {z.name}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1-1. zip 해제\n", "\n", "zip 8개는 원천데이터(TS·VS)와 라벨링데이터(TL·VL)로 나뉩니다.\n", "라벨링 JSON 이 판결문 본문(`sections`)과 익명처리 라벨\n", "(`annotations`)을 함께 갖고 있어 **라벨링데이터만 풀면 됩니다**.\n", "\n", "기본은 학습셋(Training) 16,800건입니다. 검증셋 2,100건까지\n", "쓰려면 `SPLITS` 에 `\"Validation\"` 을 더하세요.\n", "\n", "이 zip 은 파일명이 UTF-8 인데 표시 플래그가 없어서, 그대로 풀면\n", "한글 파일명이 깨집니다. 파이썬으로 이름을 바로잡아 풉니다." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import zipfile\n", "\n", "SPLITS = [\"Training\"]\n", "RAW_DIR = WORK_DIR / \"raw\"\n", "\n", "\n", "def fix_name(info):\n", " if info.flag_bits & 0x800:\n", " return info.filename\n", " return info.filename.encode(\"cp437\").decode(\"utf-8\")\n", "\n", "\n", "for z in zips:\n", " split = z.parent.parent.name\n", " if split not in SPLITS or \"라벨링\" not in z.parent.name:\n", " continue\n", " dst = RAW_DIR / z.stem\n", " if (dst / \".done\").exists():\n", " print(\"skip \", z.name)\n", " continue\n", " with zipfile.ZipFile(z) as zf:\n", " for info in zf.infolist():\n", " name = fix_name(info)\n", " if name.endswith(\"/\"):\n", " continue\n", " out = dst / name\n", " out.parent.mkdir(parents=True, exist_ok=True)\n", " out.write_bytes(zf.read(info))\n", " (dst / \".done\").touch()\n", " print(\"unzip \", z.name)\n", "\n", "label_files = sorted(RAW_DIR.rglob(\"*.json\"))\n", "print(f\"라벨 파일 {len(label_files):,}건\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2. 라벨 적용 → 가명 코퍼스\n", "\n", "라벨 하나는 이렇게 생겼습니다(값은 예시).\n", "\n", "```json\n", "{\"section_id\": [\"S5\"], \"span\": [{\"start\": 0, \"end\": 3}],\n", " \"original_text\": \"홍길동\", \"replacement_text\": \"인물-1\"}\n", "```\n", "\n", "`section_id` 섹션 본문의 `span` 위치를 `replacement_text` 로\n", "바꿉니다. 실명은 `인물-1`·`원고-2`, 법인명은 `조직-3`, 상세 주소는\n", "`서울특별시 강남구 A` 처럼 시·군·구까지로 일반화됩니다.\n", "\n", "세 가지를 보정합니다.\n", "\n", "- **span 이 어긋난 라벨** — 약 13% 는 `span` 위치의 글자가\n", " `original_text` 와 다릅니다. 이런 라벨은 같은 섹션에서\n", " `original_text` 가 나오는 자리 가운데 **span 에서 가장 가깝고\n", " 아직 다른 라벨이 쓰지 않은 자리 하나**를 고릅니다. 위치를 모두\n", " 정한 뒤 뒤에서부터 바꿔야 앞쪽 위치가 밀리지 않습니다.\n", " 문서 전체를 한꺼번에 문자열 치환하면 안 됩니다 — 원본에서\n", " 이미 `#이름#` 으로 가려진 서로 다른 사람이 첫 라벨의 가명\n", " 하나로 합쳐집니다(`인물-1 … 인물-2` 가 `인물-1 … 인물-1` 로).\n", " 섹션 어디에도 `original_text` 가 없는 라벨(약 2%)은 바꿀 자리가\n", " 없으니 건너뜁니다.\n", "- **라벨 없는 자리에 또 나온 이름** — 한 문서 안에서 가명이\n", " 하나로만 대응하는 원문(2자 이상, `#이름#` 제외)은 남은 자리도\n", " 같은 가명으로 바꿉니다.\n", "- **사건 정보** — 사건명·법원명에도 라벨 대상이 섞여 있어\n", " (`대법원` 도 조직명으로 라벨돼 있습니다) 같은 치환을 적용합니다.\n", "- **가려진 섹션 제목** — 주문 섹션 제목이 `#이름#` 으로 가려진\n", " 문서가 대부분이라 `주문` 으로 되돌립니다.\n", "\n", "검색에 쓸 본문은 사건명·판시사항·판결요지·이유 순으로 이어\n", "앞 1,800자만 씁니다(bge-m3 토큰으로 약 1,000개)." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import json\n", "import unicodedata\n", "from collections import Counter\n", "\n", "MAX_CHARS = 1800\n", "CORPUS = WORK_DIR / \"corpus.jsonl\"\n", "\n", "\n", "def width(s):\n", " return sum(2 if unicodedata.east_asian_width(c) in \"WF\"\n", " else 1 for c in s)\n", "\n", "\n", "def pad(s, cols):\n", " return s + \" \" * (cols - width(s))\n", "\n", "\n", "def wrap(text, cols=64, indent=\"\"):\n", " \"\"\"한글을 2칸으로 세어 cols 칸 안에서 어절 단위 줄바꿈.\"\"\"\n", " out = []\n", " for para in text.split(\"\\n\"):\n", " line = indent\n", " for word in para.rstrip().split(\" \"):\n", " sep = \"\" if line == indent else \" \"\n", " if width(line + sep + word) <= cols:\n", " line += sep + word\n", " continue\n", " if line != indent:\n", " out.append(line)\n", " line = indent\n", " for ch in word:\n", " if width(line + ch) > cols:\n", " out.append(line)\n", " line = indent\n", " line += ch\n", " out.append(line)\n", " return \"\\n\".join(out)\n", "\n", "\n", "MASK = \"#이름#\"\n", "\n", "\n", "def locate(text, o, start, used):\n", " \"\"\"span 이 맞으면 그 자리, 아니면 가장 가까운 빈 자리.\"\"\"\n", " def free(i):\n", " return all(i + len(o) <= a or i >= b for a, b in used)\n", "\n", " if text[start:start + len(o)] == o and free(start):\n", " return start, \"span\"\n", " best, i = None, text.find(o)\n", " while i != -1:\n", " if free(i) and (best is None\n", " or abs(i - start) < abs(best - start)):\n", " best = i\n", " i = text.find(o, i + 1)\n", " return best, \"near\" if best is not None else \"miss\"\n", "\n", "\n", "def pseudonymize(doc, stat):\n", " secs = {s[\"section_id\"]: s[\"text\"]\n", " for s in doc[\"sections\"]}\n", " edits, maps = {}, {}\n", " for a in doc[\"annotations\"]:\n", " sid, sp = a[\"section_id\"][0], a[\"span\"][0]\n", " o, r = a[\"original_text\"], a[\"replacement_text\"]\n", " maps.setdefault(o, set()).add(r)\n", " es = edits.setdefault(sid, [])\n", " pos, how = locate(secs[sid], o, sp[\"start\"],\n", " [(s, e) for s, e, _ in es])\n", " stat[how] += 1\n", " if pos is not None:\n", " es.append((pos, pos + len(o), r))\n", " for sid, es in edits.items():\n", " t = secs[sid]\n", " for s, e, r in sorted(es, reverse=True):\n", " t = t[:s] + r + t[e:]\n", " secs[sid] = t\n", " sweep = {o: min(rs) for o, rs in maps.items()\n", " if len(rs) == 1 and len(o) >= 2\n", " and o != MASK and o not in min(rs)}\n", " order = sorted(sweep, key=len, reverse=True)\n", "\n", " def clean(text):\n", " for o in order:\n", " text = text.replace(o, sweep[o])\n", " return text\n", "\n", " sections = [(\"주문\" if s[\"title\"] == MASK\n", " else s[\"title\"], clean(secs[s[\"section_id\"]]))\n", " for s in doc[\"sections\"]]\n", " return sections, clean\n", "\n", "\n", "def pick(sections, *names):\n", " for title, text in sections:\n", " if title.replace(\" \", \"\") in names:\n", " return text.strip()\n", " return \"\"\n", "\n", "\n", "stat, kinds = Counter(), Counter()\n", "with CORPUS.open(\"w\") as fp:\n", " for f in label_files:\n", " doc = json.loads(f.read_text())\n", " info = doc[\"info\"]\n", " secs, clean = pseudonymize(doc, stat)\n", " holding = pick(secs, \"판시사항\")\n", " summary = pick(secs, \"판결요지\")\n", " reason = pick(secs, \"이유\")\n", " head = clean(f\"{info['caseNm']} | {info['courtNm']} \"\n", " f\"{info['judmnAdjuDe']} {info['caseNo']}\")\n", " body = \"\\n\".join(x for x in [\n", " head, holding, summary, reason] if x)\n", " rec = {\"id\": f.stem, \"kind\": info[\"caseClass\"],\n", " \"head\": head, \"holding\": holding,\n", " \"summary\": summary, \"reason\": reason,\n", " \"text\": body[:MAX_CHARS],\n", " \"full\": \"\\n\\n\".join(t for _, t in secs)}\n", " fp.write(json.dumps(rec, ensure_ascii=False) + \"\\n\")\n", " kinds[info[\"caseClass\"]] += 1\n", "\n", "n_ann = sum(stat.values())\n", "rows = [\n", " (\"라벨 파일\", f\"{len(label_files):>9,}건\"),\n", " (\"라벨\", f\"{n_ann:>9,}개\"),\n", " (\" span 위치 그대로\", f\"{stat['span']:>9,}개\"),\n", " (\" 가까운 자리로 보정\", f\"{stat['near']:>9,}개\"),\n", " (\" 본문에 원문 없음\", f\"{stat['miss']:>9,}개\"),\n", " (\"검색 입력\", f\"문서당 최대 {MAX_CHARS:,}자\"),\n", "]\n", "for k, v in rows:\n", " print(pad(k, 20) + v)\n", "print(\"분야별 문서 수\")\n", "kv = [f\"{k}\\u00a0{v:,}\" for k, v in kinds.most_common()]\n", "print(wrap(\" · \".join(kv), indent=\" \").replace(\"\\u00a0\", \" \"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2-1. 원문 잔존 검사\n", "\n", "가명 코퍼스에 `original_text` 가 남았는지 문서마다 확인합니다.\n", "세 가지는 유출이 아니므로 걸러 내고 셉니다.\n", "\n", "- 대체어가 원문을 품은 라벨(`피해자` → `피해자-1`,\n", " `강릉시` → `강원도 강릉시 A`) — 일반명사이거나 상위 지역으로\n", " 일반화한 결과입니다. 검사 대상에서 뺍니다.\n", "- 다른 라벨의 대체어 속 글자(`군산시` 가 `전북특별자치도 군산시 B`\n", " 안에 있는 경우) — 본문에서 **대체어를 걷어 낸 나머지**를 봅니다.\n", "- `#이름#` — 판결문이 공개될 때 이미 가려진 자리입니다. 라벨이\n", " 붙지 않은 판사·대리인 이름 자리에 그대로 남는데, 실명이 아니라\n", " 가림 기호이므로 검사 대상에서 뺍니다.\n", "\n", "원문 값 자체는 출력하지 않습니다. 건수만 셉니다." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "leaks, checked = 0, 0\n", "bad_docs = []\n", "by_id = {f.stem: f for f in label_files}\n", "with CORPUS.open() as fp:\n", " for line in fp:\n", " rec = json.loads(line)\n", " doc = json.loads(by_id[rec[\"id\"]].read_text())\n", " reps = {a[\"replacement_text\"]\n", " for a in doc[\"annotations\"]}\n", " rest = \"\\n\".join([rec[\"head\"], rec[\"full\"]])\n", " for r in sorted(reps, key=len, reverse=True):\n", " rest = rest.replace(r, \"\\0\")\n", " target = [a[\"original_text\"]\n", " for a in doc[\"annotations\"]\n", " if a[\"original_text\"] != MASK\n", " and a[\"original_text\"]\n", " not in a[\"replacement_text\"]]\n", " n = sum(1 for o in target if o in rest)\n", " checked += len(target)\n", " if n:\n", " leaks += n\n", " bad_docs.append(rec[\"id\"])\n", "\n", "print(f\"검사한 라벨 {checked:,}개\")\n", "print(f\"원문 잔존 {leaks:,}건 · 문서 {len(bad_docs):,}건\")\n", "assert leaks == 0, \"원문 문자열이 남았습니다\"\n", "print(\"검사 통과 — 가명 코퍼스에 원문 문자열 없음\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3. 임베딩 — bge-m3\n", "\n", "`BAAI/bge-m3` (MIT) 로 문서마다 1,024차원 벡터를 만듭니다.\n", "결과는 `WORK_DIR/emb_bge-m3.npy` 에 저장하고, 파일이 있으면\n", "다시 계산하지 않습니다.\n", "\n", "오래 걸립니다(M3 Pro 에서 16,800건에 약 1시간). 먼저 맛만 보려면\n", "`LIMIT = 500` 처럼 줄이세요. 문서 수가 바뀌면 캐시를 새로 만듭니다.\n", "\n", "다 끝나면 모델을 내리고 GPU 메모리를 비웁니다. 임베딩 모델과\n", "5단계 생성 모델이 한 프로세스에서 GPU 를 동시에 잡으면 멈출 수\n", "있어서, 검색 단계의 쟁점 임베딩은 CPU 로 합니다." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import gc\n", "import time\n", "\n", "import numpy as np\n", "from sentence_transformers import SentenceTransformer\n", "\n", "EMB_MODEL = \"BAAI/bge-m3\"\n", "LIMIT = None\n", "EMB = WORK_DIR / \"emb_bge-m3.npy\"\n", "IDS = WORK_DIR / \"emb_ids.json\"\n", "\n", "with CORPUS.open() as fp:\n", " corpus = [json.loads(l) for l in fp]\n", "if LIMIT:\n", " corpus = corpus[:LIMIT]\n", "ids = [r[\"id\"] for r in corpus]\n", "\n", "cached = (EMB.exists() and IDS.exists()\n", " and json.loads(IDS.read_text()) == ids)\n", "if cached:\n", " emb = np.load(EMB)\n", " print(f\"캐시 사용 {emb.shape}\")\n", "else:\n", " model = SentenceTransformer(EMB_MODEL, device=DEV)\n", " if DEV != \"cpu\":\n", " model.half()\n", " t0 = time.perf_counter()\n", " emb = model.encode([r[\"text\"] for r in corpus],\n", " batch_size=16,\n", " normalize_embeddings=True,\n", " show_progress_bar=True)\n", " sec = time.perf_counter() - t0\n", " np.save(EMB, emb.astype(np.float32))\n", " IDS.write_text(json.dumps(ids))\n", " print(f\"문서 {len(corpus):,}건 · 차원 {emb.shape[1]}\")\n", " print(f\"소요 {sec / 60:.1f}분 ({DEV})\")\n", " del model\n", "\n", "gc.collect()\n", "if DEV == \"mps\":\n", " torch.mps.empty_cache()\n", "elif DEV == \"cuda\":\n", " torch.cuda.empty_cache()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4. 쟁점 검색 — 상위 3건\n", "\n", "`QUERY` 에 쟁점을 한 줄로 적습니다. 쟁점을 bge-m3 로 임베딩해\n", "코퍼스 벡터와 코사인 유사도를 재고 가장 가까운 3건을 뽑습니다.\n", "벡터를 정규화해 뒀으므로 내적이 곧 코사인 유사도입니다.\n", "\n", "1·2·최종심 묶음은 한 사건의 심급별 판결이 따로 들어 있어,\n", "같은 사건의 1심·2심이 나란히 뽑힐 수 있습니다." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "QUERY = (\"제방부지 및 제외지가 법률 제2292호 하천법 \"\n", " \"개정법률 시행일(1971. 7. 20.)부터 법률 제3782호 \"\n", " \"하천법 중 개정법률의 시행일(1984. 12. 31.) 전에 \"\n", " \"국유로 된 경우, 명시적인 보상규정이 없더라도 \"\n", " \"관할관청이 소유자가 입은 손실을 보상하여야 \"\n", " \"하는지 여부\")\n", "TOP_K = 3\n", "\n", "qmodel = SentenceTransformer(EMB_MODEL, device=\"cpu\")\n", "q = qmodel.encode([QUERY], normalize_embeddings=True)[0]\n", "del qmodel\n", "gc.collect()\n", "\n", "scores = emb @ q\n", "top = np.argsort(-scores)[:TOP_K]\n", "hits = [corpus[i] for i in top]\n", "\n", "print(wrap(f\"쟁점: {QUERY}\"))\n", "for rank, i in enumerate(top, 1):\n", " r = corpus[i]\n", " gist = (r[\"holding\"] or r[\"summary\"] or r[\"reason\"])\n", " gist = \" \".join(gist.split())\n", " if len(gist) > 80:\n", " gist = gist[:80] + \"…\"\n", " print(\"-\" * 64)\n", " print(f\"[{rank}] 유사도 {scores[i]:.3f} · {r['kind']}\")\n", " print(wrap(r[\"head\"], indent=\" \"))\n", " print(wrap(gist, indent=\" \"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 5. 이유 초안 생성 — Qwen3-4B\n", "\n", "아래 두 셀 중 기기에 맞는 하나만 실제로 돕니다.\n", "\n", "- **Apple Silicon** — `mlx-community/Qwen3-4B-Instruct-2507-4bit`\n", " (Apache-2.0, 4비트 양자화, 약 2.3GB)\n", "- **그 밖의 기기** — 같은 모델의 transformers 판\n", " `Qwen/Qwen3-4B-Instruct-2507` (Apache-2.0, 약 8GB).\n", " GPU 가 없으면 매우 느립니다.\n", "\n", "모델은 처음 실행할 때 Hugging Face 에서 한 번 내려받습니다.\n", "판결문은 이 컴퓨터 밖으로 나가지 않습니다." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "MAX_NEW = 700\n", "\n", "if APPLE:\n", " from mlx_lm import generate as mlx_generate\n", " from mlx_lm import load\n", " from mlx_lm.sample_utils import make_sampler\n", "\n", " LLM = \"mlx-community/Qwen3-4B-Instruct-2507-4bit\"\n", " llm, tok = load(LLM)\n", "\n", " def chat(messages):\n", " prompt = tok.apply_chat_template(\n", " messages, add_generation_prompt=True)\n", " return mlx_generate(\n", " llm, tok, prompt=prompt, max_tokens=MAX_NEW,\n", " sampler=make_sampler(temp=0.0))\n", "\n", " print(\"loaded\", LLM)" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "if not APPLE:\n", " from transformers import AutoModelForCausalLM\n", " from transformers import AutoTokenizer\n", "\n", " LLM = \"Qwen/Qwen3-4B-Instruct-2507\"\n", " tok = AutoTokenizer.from_pretrained(LLM)\n", " llm = AutoModelForCausalLM.from_pretrained(\n", " LLM, torch_dtype=\"auto\", device_map=\"auto\")\n", "\n", " def chat(messages):\n", " ids = tok.apply_chat_template(\n", " messages, add_generation_prompt=True,\n", " return_tensors=\"pt\").to(llm.device)\n", " out = llm.generate(ids, max_new_tokens=MAX_NEW,\n", " do_sample=False)\n", " return tok.decode(out[0, ids.shape[1]:],\n", " skip_special_tokens=True)\n", "\n", " print(\"loaded\", LLM)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 근거 있음 — 참고 판결 3건을 넣고 생성\n", "\n", "검색한 3건의 사건 정보·판시사항·판결요지·이유 앞부분을 프롬프트에\n", "넣고, 인용할 때 `[1]` 처럼 번호를 달게 합니다. 온도 0 으로 뽑아\n", "같은 입력이면 같은 초안이 나옵니다." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "SYSTEM = (\"당신은 한국 법원의 판결 이유 초안을 쓰는 보조자입니다. \"\n", " \"가상의 사건에 대한 초안이며 법률 자문이 아닙니다.\")\n", "TASK = (\"다음 쟁점에 대한 판결 이유 초안을 한국어로 쓰세요. \"\n", " \"'1. 인정 사실', '2. 판단', '3. 결론' 세 부분으로 \"\n", " \"나누고 600자 안팎으로 쓰세요.\")\n", "REF_CHARS = 700\n", "\n", "\n", "def ref_block(hits):\n", " parts = []\n", " for n, r in enumerate(hits, 1):\n", " body = \"\\n\".join(x for x in [\n", " r[\"holding\"], r[\"summary\"],\n", " r[\"reason\"][:REF_CHARS]] if x)\n", " parts.append(f\"[{n}] {r['head']}\\n{body}\")\n", " return \"\\n\\n\".join(parts)\n", "\n", "\n", "with_ref = chat([\n", " {\"role\": \"system\", \"content\": SYSTEM},\n", " {\"role\": \"user\", \"content\": (\n", " f\"{TASK} 아래 참고 판결만 근거로 삼고, 근거로 쓴 \"\n", " \"문장 끝에 [1]처럼 번호를 다세요. 참고 판결에 없는 \"\n", " \"법리는 지어내지 마세요.\\n\\n\"\n", " f\"쟁점: {QUERY}\\n\\n참고 판결\\n{ref_block(hits)}\")},\n", "])\n", "print(wrap(with_ref.strip()))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### 근거 없음 — 같은 지시, 참고 판결 없이 생성\n", "\n", "모델이 제 지식만으로 쓰면 무엇이 달라지는지 비교합니다.\n", "조문 번호·판례 번호를 그럴듯하게 지어내는지 눈여겨보세요." ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "no_ref = chat([\n", " {\"role\": \"system\", \"content\": SYSTEM},\n", " {\"role\": \"user\", \"content\": f\"{TASK}\\n\\n쟁점: {QUERY}\"},\n", "])\n", "print(wrap(no_ref.strip()))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 정리\n", "\n", "- 라벨을 적용한 가명 코퍼스만 임베딩·검색·생성에 썼습니다.\n", " 원문 잔존 검사가 통과해야 다음 단계로 넘어갑니다.\n", "- `WORK_DIR` 의 코퍼스·임베딩 파일은 원본과 같은 약관을 따릅니다.\n", " 공유하거나 저장소에 올리지 마세요.\n", "- 생성된 이유는 가상의 초안입니다. 인용 번호가 달려 있어도\n", " 참고 판결 원문과 대조해 보세요." ] } ], "metadata": { "colab": { "provenance": [] }, "kernelspec": { "display_name": "Python 3", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 0 }