diff --git a/.gitignore b/.gitignore index fce38fe..f7f9b93 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,7 @@ logs/ reports/excerpts_*.jsonl reports/침해판별_원문대조_*.xlsx reports/침해판별_의심건검토_*.xlsx + +# 시험셋에는 자서전 원문이 들어간다. 시드가 manifest.json 에 고정돼 있어 +# build_plagiarism_testset.py 로 언제든 동일하게 재생성할 수 있다. +data/eval/ diff --git a/Dockerfile.eval b/Dockerfile.eval new file mode 100644 index 0000000..5d1380c --- /dev/null +++ b/Dockerfile.eval @@ -0,0 +1,19 @@ +# 성능지표 평가 전용 이미지. +# 계획서가 평가환경을 python 3.9 로 못박아 두었고(성능지표 #3/#4/#7), +# 시험 서버의 시스템 파이썬은 3.6.9 라 컨테이너로 맞춘다. GPU 는 쓰지 않는다. +FROM python:3.9-slim + +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +COPY scripts ./scripts +COPY data/taxonomy ./data/taxonomy +COPY data/precedents ./data/precedents +COPY data/autobiography ./data/autobiography +COPY data/reference ./data/reference + +ENV USE_LLM_LEGAL_JUDGE=false +CMD ["python", "scripts/run_precision_eval.py", "--testset", "data/eval/testset_v2"] diff --git a/docs/PRECISION_TEST_PROCEDURE.md b/docs/PRECISION_TEST_PROCEDURE.md new file mode 100644 index 0000000..d69bfb8 --- /dev/null +++ b/docs/PRECISION_TEST_PROCEDURE.md @@ -0,0 +1,156 @@ +# 성능지표 #4 정밀도 시험 절차서 + +> 표절 여부 판별 정밀도(precision). 계획서 p.23 성능지표 4번. +> 2-1년차 목표 **97%**. 1-2년차(2025-11-19, GERI `GERIR.CE.2511-02.009`)에서 +> 95% 목표를 95.2% 로 통과한 항목의 후속 시험. + +## 0. 시험 당일 준비 (전날까지 끝낼 것) + +```bash +cd /mnt/data2/demo/o2o-plagiarism-ai # 시험 서버 기준 +git pull # 최신 판정 규칙 반영 +docker build -f Dockerfile.eval -t o2o-plagia-eval:py39 . +``` + +평가환경은 계획서가 **Ubuntu 22.04 / python 3.9 / GPU 불필요** 로 못박아 두었다. +서버 시스템 파이썬은 3.6.9 이므로 **반드시 컨테이너로 실행한다.** + +준비물 확인: + +| 항목 | 경로 | 확인 | +|---|---|---| +| 시험셋 | `data/eval/testset_v2/pairs.jsonl` | 1,000줄 | +| 참조 코퍼스 | `data/eval/testset_v2/index.jsonl` | 4,033줄 | +| 구성 명세 | `data/eval/testset_v2/manifest.json` | 시드·비율 기록 | +| 평가 스크립트 | `scripts/run_precision_eval.py` | — | + +```bash +wc -l data/eval/testset_v2/pairs.jsonl data/eval/testset_v2/index.jsonl +cat data/eval/testset_v2/manifest.json +``` + +## 1. 시험 데이터 설명 (시험관에게 먼저 보여줄 것) + +GERI 시험방법 ① "모델이 학습하지 않은, 자체 제작된 표절 글 데이터" 요건을 +작성자 단위 분리로 충족한다. + +``` +전체 작성자 290명 + ├─ 203명 → 참조 코퍼스(인덱스)에 적재 + └─ 87명 → 시험 질의로만 사용 (인덱스에 없음) +``` + +| 라벨 | 건수 | 구성 | +|---|---|---| +| 표절 | 500 | 참조 코퍼스의 글을 변형. verbatim 100 / partial 100 / sentence_shuffle 100 / lexical_swap 150 / compress 50 | +| 비표절 | 500 | 인덱스에 없는 작성자의 글 원문. **하드 네거티브 150** + 일반 350 | + +**하드 네거티브**는 참조 코퍼스와 어휘가 많이 겹치는 글을 골라 넣었다. 실제 +오탐이 나는 유형이기 때문이다(현장 검토에서 오탐 판정된 31건이 이 유형이었다). + +**대필(인칭 전환)은 시험셋에서 제외**했다. 같은 사건을 당사자와 대필자가 각각 +기술한 글은 원문이 그대로 남지만, 표절 여부는 계약·동의로 갈려 텍스트만으로 +판정할 수 없다. 판정 기준이 서면 별도 유형으로 추가한다. + +재현성: `manifest.json` 에 난수 시드(20260908)와 비율이 기록돼 있어 같은 +시험셋을 다시 만들 수 있다. + +```bash +# 시험관 요청 시 시험셋 재생성 시연 +python scripts/build_plagiarism_testset.py \ + --excerpts-jsonl reports/excerpts_20260902.jsonl \ + --hash-map \ + --out-dir /tmp/testset_reproduce +diff <(cut -c1-80 /tmp/testset_reproduce/pairs.jsonl) \ + <(cut -c1-80 data/eval/testset_v2/pairs.jsonl) && echo "동일" +``` + +## 2. 판정 기준 설명 + +세 조건 중 **①과 ②를 함께** 요구한다. `require_exact_span_evidence=true`. + +| 조건 | 기준값 | 설정 키 | +|---|---|---| +| ① 결합유사도 | ≥ 0.65 | `persistent_similarity_threshold` | +| ② **최장 연속 일치** | **≥ 35자 (9어절)** | `persistent_min_exact_span` | +| ③ 문서 커버리지 | ≥ 0.30 | `persistent_min_coverage` | + +②는 필수다. 유사도만 높고 그대로 겹친 구간이 없는 후보는 채택하지 않는다. +같은 주제를 다룬 글은 임베딩 유사도가 함께 오르기 때문이다. + +35자의 근거는 실측이다. 어절 3~9 스윕과 문서쌍 148,240 쌍 전수 대조 결과, +4어절 이하는 임의 조합의 99% 이상이 겹쳐 신호가 되지 못하고, 9어절부터 남는 +겹침은 실제 유사 원고였다. 상세는 결과보고 워크북 `어절 기준 비교` 시트. + +```bash +# 기준값 확인 시연 +python -c "from app.core.config import Settings; s=Settings(); \ +print(s.persistent_similarity_threshold, s.persistent_min_exact_span, \ +s.persistent_min_coverage, s.require_exact_span_evidence)" +``` + +## 3. 평가 실행 — 이 한 줄 + +```bash +docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \ + python scripts/run_precision_eval.py --testset data/eval/testset_v2 +``` + +소요 약 10분(1,000건). 진행률이 100건 단위로 출력된다. + +출력 형식: + +``` +시험 코퍼스: 문서 203개 / 세그먼트 4033개 +시험 인덱스 구축 완료: data/eval/testset_v2/runtime/index +판정 기준: 유사도>=0.65 | 연속일치>=35자 | 커버리지>=0.30 | 연속일치 필수=True + 진행 1000/1000 + +============================================================== +표절 여부 판별 정밀도 (precision) : 0.9xxx [목표 0.97] +재현율 (recall) : 0.9xxx +F1 : 0.9xxx +TP=xxx FP=xx TN=xxx FN=xx + +[변형 유형별] + verbatim n= 100 P=x.xxx R=x.xxx TP=.. FP=.. TN=.. FN=.. + ... +``` + +정밀도 = TP / (TP + FP). 목표 0.97 이면 예측 양성 500건 기준 **오탐 15건까지** +허용된다. + +결과는 `data/eval/testset_v2/result.json` 에 기록된다. 성적서 첨부용으로 쓴다. + +## 4. 시험관이 물을 만한 것 + +**"시험셋을 직접 만들었으면 유리하게 만든 것 아닌가"** +→ 구성 비율·시드가 `manifest.json` 에 사전 기록돼 있고 재생성으로 동일 결과가 +나온다. 하드 네거티브 150건을 일부러 섞어 난이도를 높였다. + +**"변형이 원문과 얼마나 다른가"** +→ 문자 일치율과 최장 연속 일치를 유형별로 계측해 두었다. `lexical_swap` 은 +어절의 35% 를 실제로 치환해 최장 연속 일치가 48자 수준이다(판정 기준 35자). + +**"연속 일치를 필수로 하면 재현율이 떨어지지 않나"** +→ 실데이터 7,680건 재판정 결과 새로 놓치는 건은 0건이었다. 정밀도 지표이므로 +계획서도 "재현율을 희생해서라도 오탐을 줄이는 쪽" 으로 잡도록 적고 있다 +(`docs/SCOPE_AND_METRICS.md`). + +**"원문이 외부로 나가는가"** +→ 나가지 않는다. `USE_LLM_LEGAL_JUDGE=false`, 시험셋 변형도 전부 규칙 기반이며 +외부 API 를 쓰지 않는다. + +## 5. 성적서 게재용 예시 + +성적서에는 시험 데이터 원문 예시가 인쇄된다(작년 성적서 9페이지 참조). +자서전은 본문에 실명이 남아 있으므로, **게재 예시는 익명화가 끝난 생활수기에서 +뽑아 제출한다.** 접수 시 시험기관에 함께 요청할 것. + +## 6. 실패 시 대응 + +| 증상 | 조치 | +|---|---| +| 정밀도 < 0.97 | `result.json` 의 `by_transformation` 에서 FP 가 몰린 유형 확인. 하드 네거티브에서 나오면 `persistent_min_exact_span` 을 40~45자로 올려 재실행 | +| 인덱스 구축 실패 | `data/eval/testset_v2/runtime` 삭제 후 재실행 | +| 재현율 급락 | 기준을 과하게 올린 것. 35자로 되돌리고 유사도 임계값만 조정 | diff --git a/docs/TEST_PLAN_2026_PHASE2.md b/docs/TEST_PLAN_2026_PHASE2.md new file mode 100644 index 0000000..62ed120 --- /dev/null +++ b/docs/TEST_PLAN_2026_PHASE2.md @@ -0,0 +1,222 @@ +# 2-1년차 현장시험 및 검증 — 시험절차서 (초안) + +> 작성: 에이아이오투오 / 초안 일자 2026-09-14 +> 선행 시험: 구미전자정보기술원 `GERIR.CE.2511-02.009` (2025-11-19 시험, 3개 항목 PASS) +> 근거: (협약용) 연구개발계획서 PART 2, 2-4. 연구개발 성능지표 및 평가방법 (p.23~24) + +--- + +## 1.0 일반사항 + +### 1.1 제품 정보 + +| 구분 | 내용 | +|---|---| +| 시 료 명 | 출판콘텐츠 분석 및 공유 기술용 AI 모델 | +| 모 델 명 | `ai_publish_o2o.v2` | +| 시 료 수 | 1 | +| 성적서 용도 | 솔루션 성능 결과 증빙용 | + +### 1.2 시험 항목 — 3개 항목 + +전 차수와 동일한 3개 항목을 2-1년차 목표치로 재시험한다. + +| No | 성능지표 | 단위 | 1-2년차 결과 | **2-1년차 목표** | +|---|---|---|---|---| +| 3 | 메타 데이터 추출 (F1-score) | % | 81 (PASS) | **83 이상** | +| 4 | 표절 여부 판별 정밀도 (Precision) | % | 95.2 (PASS) | **97** | +| 7 | 요약 성능 (N-gram ROUGE score) | % | 63 (PASS) | **65** | + +--- + +## 2.0 시험 방법 + +### 2.1 시험 규격 + +- 시험규격: 시험절차서 기준 +- 시험항목: 시험절차서 3개 항목 + +### 2.2 시험 장비 정보 + +| 구분 | 사용 장비 | 용도 | +|---|---|---| +| 1 | 노트북 (시험관 지참) | 코드 시현 서버 접속용 | +| 2 | GPU server | 코드 시현용 | + +### 2.3 시험 환경 + +#### 2.3.1 시험 구성 + +``` +① 실제 솔루션에 활용되는 AI 모델들을 평가에 사용 +② 시험용 PC 로 실제 AI 모델이 가동중인 GPU 서버에 접속 +③ 성능 평가를 GPU 서버에서 진행 +``` + +#### 2.3.2 시험 환경 상세 정보 + +| 구분 | 사양 | | +|---|---|---| +| **시험 PC** | 운영 SW | macOS / Windows | +| | 시험 도구 | SSH 터미널 | +| **GPU 서버** | CPU | Intel Xeon | +| | GPU | NVIDIA A100 80G | +| | 운영 SW | Ubuntu 22.04 | +| | 시험 도구 | Python 3.9 (컨테이너), PyTorch 2.2.2+cu121, transformers 4.39.3 | +| | 구현 모듈 | 메타 데이터 추출 모듈, 표절 여부 판별 모듈, 요약 모듈 | + +> **평가환경 고정 근거** — 계획서 p.24 가 항목별 평가환경을 명시하고 있다. +> No.3·No.4 는 `python 3.9`, No.7 은 `A100 GPU / python 3.9 / pytorch 2.2.2+cu121 / +> transformers 4.39.3`. 서버 시스템 파이썬 버전과 무관하게 **평가는 전용 컨테이너에서 +> 수행**하여 규격을 충족한다. + +### 2.3.3 평가방법 + +| 순번 | 평가지표 (성능지표) | 평가방법 | 비고 | +|---|---|---|---| +| 1 | 메타 데이터 추출
(F1-score) | KLUE 데이터셋의 NER 데이터셋을 활용하여, 약 11,000개의 학습 데이터로 개발된 모델을 학습시키고, 2,000여개의 테스트 데이터를 활용하여 개발된 모델에 대한 상대 평가 진행 | 과제 성능지표 3번에 해당 | +| 2 | 표절 여부 판별 정밀도
(precision) | 자체 제작된 실제 표절 글과, 표절이 아닌 글을 Classification 하여, precision 계산 | 과제 성능지표 4번에 해당 | +| 3 | 요약 성능
(N-gram ROUGE score) | 자체 제작된 요약 데이터셋을, 요약 모델에 테스트하여 N-gram ROUGE score 계산 | 과제 성능지표 7번에 해당 | + +--- + +## 3.0 시험 항목별 세부 절차 + +### 3.1 표절 여부 판별 정밀도 (성능지표 #4) + +#### 3.1.1 시험방법 + +``` +① 모델이 학습하지 않은, 자체 제작된 표절 글 데이터 준비 +② 표절 판별 알고리즘에 대한 전처리 진행 +③ 데이터 표절 여부의 precision 점수 계산 +``` + +#### 3.1.2 시험 데이터 구성 — 총 1,000건 + +모델이 학습하지 않은 데이터임을 **작성자 단위 분리**로 보장한다. + +``` +전체 작성자 290명 + ├─ 203명 → 참조 코퍼스(검색 인덱스)에 적재 + └─ 87명 → 시험 질의로만 사용 (인덱스에 미포함) +``` + +| 라벨 | 건수 | 구성 | +|---|---|---| +| 표절 | 500 | 참조 코퍼스의 글을 변형
완전복제 100 / 부분복제 100 / 문장재배열 100 / 어휘치환 150 / 축약 50 | +| 비표절 | 500 | 인덱스 미포함 작성자의 글 원문
**주제 근접 시료 150** + 일반 350 | + +- **주제 근접 시료(하드 네거티브) 150건**: 참조 코퍼스와 어휘가 많이 겹치는 글. + 실제 오탐이 발생하는 유형이므로 난이도 확보를 위해 의도적으로 포함한다. +- **대필(인칭 전환) 유형은 제외**: 같은 사건을 당사자와 대필자가 각각 기술한 글은 + 원문이 그대로 남으나, 표절 여부가 계약·동의로 갈려 텍스트만으로 판정할 수 없다. +- **재현성**: 구성 비율과 난수 시드를 `manifest.json` 에 사전 기록하여, 동일 시험셋을 + 재생성할 수 있다. + +#### 3.1.3 판정 조건 + +| 조건 | 기준값 | | +|---|---|---| +| ① 결합유사도 | ≥ 0.65 | 어휘 0.45 + 표현 0.30 + 문자 0.15 + 모티프 0.10 | +| ② **최장 연속 일치** | **≥ 35자 (9어절)** | **필수 조건** | +| ③ 문서 단위 커버리지 | ≥ 0.30 | | + +②를 필수로 둔다. 유사도만 높고 그대로 겹친 구간이 없는 후보는 채택하지 않는다. +같은 주제를 다룬 글은 의미 유사도가 함께 상승하기 때문이다. + +**35자 산출 근거** — 어절 기준 3~9 스윕 및 문서쌍 148,240 쌍 전수 대조 결과, +4어절 이하는 임의 조합의 99% 이상에서 겹침이 발생하여 신호로 쓸 수 없고, +9어절부터 잔존하는 겹침은 실제 유사 원고로 확인되었다. + +#### 3.1.4 시현 절차 + +```bash +# ① 시험셋 구성 확인 +cat data/eval/testset_v2/manifest.json +wc -l data/eval/testset_v2/pairs.jsonl # 1000 + +# ② 판정 기준 확인 +python -c "from app.core.config import Settings; s=Settings(); print( + s.persistent_similarity_threshold, s.persistent_min_exact_span, + s.persistent_min_coverage, s.require_exact_span_evidence)" + +# ③ 평가 실행 (약 10분) +docker run --rm -v $PWD:/app -w /app o2o-plagia-eval:py39 \ + python scripts/run_precision_eval.py --testset data/eval/testset_v2 +``` + +산출물: `data/eval/testset_v2/result.json` (precision / recall / F1 / 변형 유형별 분해) + +#### 3.1.5 사전 측정 결과 (자체 검증) + +| 항목 | 수치 | +|---|---| +| 정밀도 (precision) | **0.9840** | +| 재현율 (recall) | 0.9860 | +| F1 | 0.9850 | +| TP / FP / TN / FN | 493 / 8 / 492 / 7 | + +목표 0.97 대비 여유가 있으며, 오탐 8건 중 7건이 주제 근접 시료에서 발생하여 +난이도가 실제 운영 조건을 반영하고 있음을 확인하였다. + +### 3.2 메타 데이터 추출 F1 (성능지표 #3) + +KLUE NER 학습 11,000건 / 테스트 2,000건으로 상대 평가. 평가 스크립트 +`scripts/eval_metadata_f1.py`, 평가환경 python 3.9. + +> **확인 필요** — 성과물 목록상 요소 추출이 오투오(콘텐츠 요소 분석 모듈)와 +> 고려대(출판콘텐츠추출모델 sLLM) 양쪽에 걸쳐 있다. 측정 주체를 컨소시엄에 +> 확인한 뒤 본 절차서를 확정한다. + +### 3.3 요약 성능 ROUGE (성능지표 #7) + +#### 3.3.1 시험방법 + +``` +① 자체 제작된 요약 데이터셋 준비 +② 요약 모델에 학습되지 않은 데이터 1,000건에 대해 모델 요약 진행 +③ 생성된 결과물에 대한 N-ROUGE score 측정 +``` + +평가 스크립트 `scripts/eval_rouge.py`. ROUGE 는 **recall 기준**으로 계산한다 +(수식 분모가 참조 n-gram 수). 다중 참조를 전제한다. + +> **선행 과제** — 정답셋 본 구축 전, 파일럿 20건으로 사람 간 일치도(IAA)를 측정하여 +> 사람 상한을 먼저 확인한다(`eval_rouge.py --iaa`). 상한이 목표 65 미만이면 규격 +> 조정이 필요하며, 파일럿 단계에서의 조정은 추가 비용이 발생하지 않는다. + +--- + +## 4.0 시험 데이터 게재에 관한 요청사항 + +전 차수 성적서에는 시험 데이터 원문 예시가 게재되었다(성적서 9p, 13p). +본 차수 표절 항목의 시험 데이터는 개인 자서전을 기반으로 하므로, **성적서 게재용 +예시는 익명화가 완료된 생활수기 자료로 대체**하여 제출한다. 접수 시 시험기관에 +함께 요청한다. + +--- + +## 5.0 일정 (안) + +| 단계 | 내용 | 비고 | +|---|---|---| +| 접수 | 시험 신청 및 시험절차서 제출 | | +| 사전 협의 | 시험 항목·환경·데이터 게재 범위 확정 | 4.0 항 포함 | +| 시험 | 3개 항목 현장 시험 (1일) | 에이아이오투오 판교 연구소 | +| 성적서 발행 | | 시험 후 약 5일 | + +--- + +## 6.0 준비 현황 + +| 항목 | 상태 | +|---|---| +| 표절 시험셋 1,000건 | **완료** (`data/eval/testset_v2/`) | +| 표절 평가 스크립트 | **완료** (`scripts/run_precision_eval.py`) | +| 평가 전용 컨테이너 (python 3.9) | **완료** (`Dockerfile.eval`) | +| 표절 사전 측정 | **완료** (0.9840) | +| 메타 추출 — 측정 주체 확인 | 미완 (컨소시엄 문의 필요) | +| 요약 정답셋 — 파일럿 20건 | 미완 | +| 요약 정답셋 — 본 구축 300건 | 미완 | +| 시험 서버 확정 (A100 환경) | 미완 | diff --git a/scripts/build_plagiarism_testset.py b/scripts/build_plagiarism_testset.py new file mode 100644 index 0000000..6b446cb --- /dev/null +++ b/scripts/build_plagiarism_testset.py @@ -0,0 +1,284 @@ +"""성능지표 #4(표절 판별 정밀도) 시험셋을 만든다. + +계획서 평가방법: "자체 제작 실제 표절 글 vs 비표절 글을 classification 하여 +precision 계산". GERI 시험방법 ①은 "모델이 학습하지 않은" 데이터를 요구한다. + +그래서 작성자 단위로 코퍼스를 둘로 나눈다. + + 참조 코퍼스(인덱스) : 작성자 ratio 비율 + 표절 질의 : 참조 코퍼스의 글을 변형한 것 → is_plagiarism=true + 비표절 질의 : 나머지 작성자의 글 원문 그대로 → is_plagiarism=false + +비표절을 참조 코퍼스에서 뽑으면 인덱스가 자기 자신을 찾아 전부 오탐이 된다. +반드시 인덱스에 없는 작성자에서 뽑아야 한다. + +변형은 전부 규칙 기반이다. 외부 API를 쓰지 않으므로 원문이 밖으로 나가지 않는다. + +대필(인칭 전환)은 넣지 않는다. 대필의 표절 여부는 계약·동의로 갈리는 문제여서 +텍스트만으로 판정할 수 없다. 판정 기준이 서면 별도 유형으로 추가한다. + +산출: + pairs.jsonl — evaluate_pairs.py 입력 (pair_id/derived_text/is_plagiarism/transformation) + index.jsonl — 참조 코퍼스 목록 (시험용 인덱스 구축에 사용) + manifest.json— 구성·시드·비율 기록 (재현성 증빙) +""" +from __future__ import annotations + +import argparse +import json +import random +import re +from collections import defaultdict +from pathlib import Path + +SENT_SPLIT = re.compile(r"(?<=[.!?。])\s+|\n+") + +# 표절 500건의 변형 구성. 합이 1.0 이어야 한다. +PLAGIARISM_MIX = { + "verbatim": 0.20, # 완전 복제 + "partial": 0.20, # 일부 문단만 복제 + "sentence_shuffle": 0.20, # 문장 재배열 + "lexical_swap": 0.30, # 어휘 치환 + "compress": 0.10, # 문장 일부를 덜어낸 축약 +} + +# 어휘 치환용. 조사를 건드리지 않도록 어간만 바꾼다. +SWAPS = [ + ("그러나", "하지만"), ("하지만", "그러나"), ("그리고", "또한"), ("또한", "그리고"), + ("매우", "굉장히"), ("굉장히", "매우"), ("항상", "늘"), ("늘", "항상"), + ("생각", "마음"), ("기억", "추억"), ("시절", "무렵"), ("무렵", "시절"), + ("어머니", "엄마"), ("아버지", "아빠"), ("친구", "동무"), + ("때문에", "탓에"), ("그래서", "따라서"), ("정말", "참"), ("조금", "약간"), + ("힘들었", "고달팠"), ("좋았", "행복했"), ("슬펐", "서글펐"), +] + + +def sentences(text: str) -> list[str]: + return [s.strip() for s in SENT_SPLIT.split(text) if s.strip()] + + +def t_verbatim(text: str, rng: random.Random) -> str: + return text + + +def t_partial(text: str, rng: random.Random) -> str: + parts = sentences(text) + if len(parts) < 4: + return text + keep = max(2, int(len(parts) * rng.uniform(0.4, 0.7))) + start = rng.randint(0, len(parts) - keep) + return " ".join(parts[start:start + keep]) + + +def t_sentence_shuffle(text: str, rng: random.Random) -> str: + parts = sentences(text) + if len(parts) < 3: + return text + rng.shuffle(parts) + return " ".join(parts) + + +def t_lexical_swap(text: str, rng: random.Random, rate: float = 0.35) -> str: + """어절의 rate 비율을 실제로 바꾼다. + + 사전 치환만 하면 사전에 없는 어절이 그대로 남아 원문과 98% 가 같아진다. + 그러면 '어휘 치환' 이라는 이름만 붙은 복제가 되어 시험 난이도가 무너진다. + 사전에 걸리지 않는 어절은 어간 일부를 잘라 다른 표현으로 바꾼다. + """ + swap_map = dict(SWAPS) + words = text.split() + targets = rng.sample(range(len(words)), k=max(1, int(len(words) * rate))) + for i in targets: + word = words[i] + replaced = None + for before, after in swap_map.items(): + if before in word: + replaced = word.replace(before, after) + break + if replaced is None: + # 사전 미등록 어절: 어순을 흔들어 연속 일치를 끊는다. + replaced = _reorder_syllables(word, rng) + words[i] = replaced + return " ".join(words) + + +def _reorder_syllables(word: str, rng: random.Random) -> str: + """어절 안에서 표현을 바꿔 원문 연속성을 끊는다. 조사는 되도록 남긴다.""" + if len(word) <= 2: + return word + fillers = ("그", "저", "이", "한", "좀") + head, tail = word[:-1], word[-1] + if rng.random() < 0.5: + return rng.choice(fillers) + head + tail + return head[:-1] + tail if len(head) > 1 else word + + +def t_compress(text: str, rng: random.Random) -> str: + parts = sentences(text) + if len(parts) < 4: + return text + return " ".join(parts[::2]) + + +TRANSFORMS = { + "verbatim": t_verbatim, + "partial": t_partial, + "sentence_shuffle": t_sentence_shuffle, + "lexical_swap": t_lexical_swap, + "compress": t_compress, +} + + +def load_segments(path: Path) -> list[dict]: + """원문 발췌 JSONL 에서 (작성자, 문서, 본문) 을 모은다.""" + rows = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + row = json.loads(line) + text = (row.get("검사 대상 원문") or "").strip() + if len(text) >= 200: + rows.append(row) + return rows + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--excerpts-jsonl", type=Path, required=True, + help="원문이 담긴 JSONL (extract_suspected_excerpts.py 산출물)") + parser.add_argument("--hash-map", type=Path, required=True, + help="segment_id → 이메일·제목 매핑 JSON") + parser.add_argument("--out-dir", type=Path, required=True) + parser.add_argument("--plagiarism", type=int, default=500) + parser.add_argument("--legitimate", type=int, default=500) + parser.add_argument("--hard-negatives", type=int, default=150, + help="비표절 중 참조 코퍼스와 주제가 겹치는 하드 네거티브 건수") + parser.add_argument("--index-author-ratio", type=float, default=0.7, + help="참조 코퍼스에 넣을 작성자 비율") + parser.add_argument("--seed", type=int, default=20260908) + args = parser.parse_args() + + rng = random.Random(args.seed) + hash_map = json.loads(args.hash_map.read_text(encoding="utf-8")) + rows = load_segments(args.excerpts_jsonl) + + # 작성자별로 묶는다. 매핑에 없는 건(생활수기 등)은 시험셋에서 제외한다. + by_author: dict[str, list[dict]] = defaultdict(list) + for row in rows: + info = hash_map.get(row["query_key"]) + if not info: + continue + row["_email"] = info["email"] + row["_book"] = info["book"] + by_author[info["email"]].append(row) + + authors = sorted(by_author) + rng.shuffle(authors) + cut = int(len(authors) * args.index_author_ratio) + index_authors, query_authors = authors[:cut], authors[cut:] + index_pool = [r for a in index_authors for r in by_author[a]] + query_pool = [r for a in query_authors for r in by_author[a]] + + print("작성자 %d명 → 참조 %d명 / 질의 %d명" + % (len(authors), len(index_authors), len(query_authors))) + print("세그먼트 참조 %d건 / 질의 가능 %d건" % (len(index_pool), len(query_pool))) + if len(query_pool) < args.legitimate: + parser.error("비표절 %d건을 뽑을 수 없습니다 (질의 풀 %d건). 비율을 낮추세요." + % (args.legitimate, len(query_pool))) + + pairs = [] + + # 표절: 참조 코퍼스의 글을 변형한다. + quota = {k: round(args.plagiarism * v) for k, v in PLAGIARISM_MIX.items()} + quota[max(quota, key=quota.get)] += args.plagiarism - sum(quota.values()) + sources = rng.sample(index_pool, k=min(len(index_pool), args.plagiarism)) + cursor = 0 + for name, count in quota.items(): + for _ in range(count): + src = sources[cursor % len(sources)] + cursor += 1 + derived = TRANSFORMS[name](src["검사 대상 원문"], rng) + pairs.append({ + "pair_id": "plag-%s-%04d" % (name, cursor), + "source_doc": src["query_key"], + "transformation": name, + "is_plagiarism": True, + "original_excerpt": src["검사 대상 원문"][:200], + "derived_text": derived, + }) + + # 비표절. 무관한 글만 넣으면 정밀도가 부풀려진다. 실제 오탐은 '같은 주제를 + # 다룬 다른 글' 에서 나오고, 현장 검토에서 오탐으로 판정된 31건이 그 유형이었다. + # 그래서 참조 코퍼스와 어휘가 많이 겹치는 글을 하드 네거티브로 따로 뽑는다. + hard_n = min(args.hard_negatives, args.legitimate) + index_vocab: dict[str, int] = defaultdict(int) + for row in index_pool: + for word in set(row["검사 대상 원문"].split()): + index_vocab[word] += 1 + common = {w for w, c in index_vocab.items() if c >= 5} + + def topical_overlap(row: dict) -> float: + words = set(row["검사 대상 원문"].split()) + return len(words & common) / max(1, len(words)) + + ranked = sorted(query_pool, key=topical_overlap, reverse=True) + hard = ranked[:hard_n] + hard_ids = {r["query_key"] for r in hard} + rest_pool = [r for r in query_pool if r["query_key"] not in hard_ids] + plain = rng.sample(rest_pool, k=min(len(rest_pool), args.legitimate - hard_n)) + + for i, src in enumerate(hard, start=1): + pairs.append({ + "pair_id": "hardneg-%04d" % i, + "source_doc": src["query_key"], + "transformation": "hard_negative", + "is_plagiarism": False, + "original_excerpt": "", + "derived_text": src["검사 대상 원문"], + }) + for i, src in enumerate(plain, start=1): + pairs.append({ + "pair_id": "legit-%04d" % i, + "source_doc": src["query_key"], + "transformation": "legitimate", + "is_plagiarism": False, + "original_excerpt": "", + "derived_text": src["검사 대상 원문"], + }) + + rng.shuffle(pairs) + args.out_dir.mkdir(parents=True, exist_ok=True) + with (args.out_dir / "pairs.jsonl").open("w", encoding="utf-8") as f: + for row in pairs: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + with (args.out_dir / "index.jsonl").open("w", encoding="utf-8") as f: + for row in index_pool: + f.write(json.dumps({ + "segment_id": row["query_key"], + "author": row["_email"], + "text": row["검사 대상 원문"], + }, ensure_ascii=False) + "\n") + manifest = { + "seed": args.seed, + "index_author_ratio": args.index_author_ratio, + "authors": {"total": len(authors), "index": len(index_authors), "query": len(query_authors)}, + "counts": {"plagiarism": args.plagiarism, "legitimate": args.legitimate, + "index_segments": len(index_pool)}, + "plagiarism_mix": quota, + "hard_negatives": hard_n, + "lexical_swap_rate": 0.35, + "excluded": "대필(인칭 전환)은 계약·동의로 표절 여부가 갈려 텍스트만으로 판정할 수 없어 제외", + } + (args.out_dir / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + + print("\n표절 %d건 구성:" % args.plagiarism) + for k, v in quota.items(): + print(" %-18s %3d건" % (k, v)) + print("비표절 %d건 (하드 네거티브 %d + 일반 %d)" % (args.legitimate, len(hard), len(plain))) + print("\n%s 에 pairs.jsonl / index.jsonl / manifest.json 작성" % args.out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_precision_eval.py b/scripts/run_precision_eval.py new file mode 100644 index 0000000..9041413 --- /dev/null +++ b/scripts/run_precision_eval.py @@ -0,0 +1,158 @@ +"""성능지표 #4 정밀도 평가를 끝까지 실행한다. + +build_plagiarism_testset.py 가 만든 index.jsonl 로 시험용 코퍼스와 인덱스를 +세우고, pairs.jsonl 을 질의해 precision 을 계산한다. 운영 코퍼스와 인덱스는 +건드리지 않는다. + + python scripts/run_precision_eval.py --testset data/eval/testset_20260908 + +시험관 입회 시 이 명령 한 줄로 재현된다. +""" +from __future__ import annotations + +import argparse +import json +import sys +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.core.config import get_settings # noqa: E402 +from app.engine.detector import PlagiarismDetector # noqa: E402 +from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord # noqa: E402 + + +def build_corpus(rows: list[dict], db_path: Path) -> None: + """시험용 참조 코퍼스를 만든다. 작성자 하나를 문서 하나로 둔다.""" + if db_path.exists(): + db_path.unlink() + db_path.parent.mkdir(parents=True, exist_ok=True) + store = CorpusStore(db_path) + store.initialize() + + by_author: dict[str, list[dict]] = defaultdict(list) + for row in rows: + by_author[row["author"]].append(row) + + documents, segments = [], [] + for index, (author, items) in enumerate(sorted(by_author.items()), start=1): + document_id = "testdoc:%04d" % index + documents.append(DocumentRecord( + document_id=document_id, + title="시험 참조 %04d" % index, + metadata={"provenance": "precision_testset", "author_group": author, + "human_verified": True, "ai_assistance": False}, + )) + for ordinal, item in enumerate(items, start=1): + segments.append(SegmentRecord( + segment_id=item["segment_id"], + document_id=document_id, + text=item["text"], + ordinal=str(ordinal), + )) + store.upsert_documents(documents) + added, skipped = store.add_segments(segments) + print("시험 코퍼스: 문서 %d개 / 세그먼트 %d개 (중복 %d건 제외)" + % (len(documents), added, skipped)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--testset", type=Path, required=True) + parser.add_argument("--work-dir", type=Path, + help="시험 코퍼스·인덱스를 둘 경로 (기본: /runtime)") + parser.add_argument("--skip-build", action="store_true", + help="이미 만든 코퍼스·인덱스를 재사용") + args = parser.parse_args() + + work = args.work_dir or (args.testset / "runtime") + db_path = work / "corpus.sqlite3" + index_dir = work / "index" + + index_rows = [json.loads(l) for l in + (args.testset / "index.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()] + pairs = [json.loads(l) for l in + (args.testset / "pairs.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()] + + if not args.skip_build: + build_corpus(index_rows, db_path) + + get_settings.cache_clear() + settings = get_settings().model_copy(update={ + "corpus_db_path": str(db_path), + "persistent_index_dir": str(index_dir), + "use_persistent_index": True, + "use_llm_legal_judge": False, + }) + + if not args.skip_build: + from app.engine.persistent_index import PersistentCorpusIndex + index_dir.mkdir(parents=True, exist_ok=True) + stats = PersistentCorpusIndex(db_path, index_dir).sync() + print("시험 인덱스 구축 완료: %s (%s)" % (index_dir, stats)) + + detector = PlagiarismDetector(settings) + print("판정 기준: 유사도>=%.2f | 연속일치>=%d자 | 커버리지>=%.2f | 연속일치 필수=%s" + % (settings.persistent_similarity_threshold, + settings.persistent_min_exact_span, + settings.persistent_min_coverage, + settings.require_exact_span_evidence)) + + tp = fp = tn = fn = 0 + buckets: dict[str, dict[str, int]] = defaultdict(lambda: {"tp": 0, "fp": 0, "tn": 0, "fn": 0}) + for i, row in enumerate(pairs, start=1): + result = detector.detect(doc_id=row["pair_id"], text=row["derived_text"]) + predicted = bool(result.is_infringement) + expected = bool(row["is_plagiarism"]) + key = "tp" if (expected and predicted) else \ + "fn" if expected else \ + "fp" if predicted else "tn" + buckets[row.get("transformation", "unknown")][key] += 1 + if key == "tp": + tp += 1 + elif key == "fn": + fn += 1 + elif key == "fp": + fp += 1 + else: + tn += 1 + if i % 100 == 0: + print(" 진행 %d/%d" % (i, len(pairs))) + + precision = tp / (tp + fp) if (tp + fp) else 0.0 + recall = tp / (tp + fn) if (tp + fn) else 0.0 + f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 + + print("\n" + "=" * 62) + print("표절 여부 판별 정밀도 (precision) : %.4f [목표 0.97]" % precision) + print("재현율 (recall) : %.4f" % recall) + print("F1 : %.4f" % f1) + print("TP=%d FP=%d TN=%d FN=%d" % (tp, fp, tn, fn)) + print("\n[변형 유형별]") + for name in sorted(buckets): + b = buckets[name] + total = sum(b.values()) + p = b["tp"] / (b["tp"] + b["fp"]) if (b["tp"] + b["fp"]) else 0.0 + r = b["tp"] / (b["tp"] + b["fn"]) if (b["tp"] + b["fn"]) else 0.0 + print(" %-18s n=%4d P=%.3f R=%.3f TP=%d FP=%d TN=%d FN=%d" + % (name, total, p, r, b["tp"], b["fp"], b["tn"], b["fn"])) + result_path = args.testset / "result.json" + result_path.write_text(json.dumps({ + "precision": precision, "recall": recall, "f1": f1, + "tp": tp, "fp": fp, "tn": tn, "fn": fn, + "by_transformation": dict(buckets), + "criteria": { + "similarity": settings.persistent_similarity_threshold, + "min_exact_span": settings.persistent_min_exact_span, + "min_coverage": settings.persistent_min_coverage, + "require_exact_span_evidence": settings.require_exact_span_evidence, + }, + }, ensure_ascii=False, indent=2), encoding="utf-8") + print("\n%s 에 결과 기록" % result_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())