AI 생성 표본이 없어 정확도를 측정할 수 없는 상태에서, 규칙 기반 휴리스틱을 실제로 쓸 수 있게 만든다. 점수의 의미를 "AI일 확률"에서 "등록 코퍼스의 인간 저작물 대비 문체 이례도"로 재정의해, 라벨 없이도 참인 진술이 되도록 했다. 덤으로 high 배지 비율이 정의상 고정되어 검토 부하가 예측 가능해진다. - ai_detector: low_cut/high_cut 주입 지원. 둘 다 주어질 때만 적용하고, 적용되면 model_version 에 +corpus-percentile 을 붙여 컷 출처를 드러낸다. is_stub 은 여전히 true — 컷을 맞췄을 뿐 학습된 모델이 아니다. - calibrate_cuts/percentile/score_text_heuristic 순수 함수 추가. - scripts/calibrate_ai_detector_cuts.py: 코퍼스 표본의 점수 분포에서 백분위 컷을 산출하고 .env 두 줄을 출력한다. - 규칙 점수가 상단에서 clip 되어 p90=p98 이 되면 그 컷은 high 배지를 영원히 0건으로 만든다. 이 경우 .env 를 출력하지 않고 exit 3 으로 중단하며 saturated_share/distinct_scores 진단을 남긴다. 유효 표본 100건 미만은 exit 2. - kiwipiepy 유무가 점수를 바꾸므로 실제 사용 여부를 리포트에 기록하고 경고한다. 기본값은 그대로 비활성(AI_DETECTOR_ALLOW_HEURISTIC=false)이라 켜지 않으면 동작이 바뀌지 않는다. 컷 미설정 시에는 자리표시자임을 note 로 경고한다. 테스트 180건 통과 (신규 13건). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
330 lines
16 KiB
Markdown
330 lines
16 KiB
Markdown
# 한국어 AI 생성 의심도 (ai_detector)
|
||
|
||
> **이 기능이 산출하는 값은 "AI가 썼다"는 판정이 아니라, 사람 검토를 어디에 먼저
|
||
> 배정할지 정하는 우선순위 점수입니다.** 저자 통보·계약 조치·출간 거부의 단독
|
||
> 근거로 사용할 수 없습니다. 이유는 아래 [3. 한계와 오탐 위험](#3-한계와-오탐-위험)에
|
||
> 정리했습니다.
|
||
|
||
관련 파일:
|
||
|
||
| 파일 | 역할 |
|
||
|---|---|
|
||
| `app/engine/ai_detector.py` | 특징 추출 + 점수 산출 + 구간 채점 |
|
||
| `scripts/build_ai_training_dataset.py` | xlsx(human) + JSONL/CSV(AI) → 학습셋 |
|
||
| `scripts/train_ai_detector.py` | CPU 학습 + 보정 + 지표/아티팩트 저장 |
|
||
| `tests/test_ai_detector.py` | 단위테스트 (외부 다운로드 불필요) |
|
||
|
||
---
|
||
|
||
## 1. 동작 모드 — 점수를 지어내지 않는다
|
||
|
||
세 가지 모드가 있고, 결과 객체의 `is_stub` / `model_version` / `note` 로 **항상
|
||
구분 가능**합니다. 기존 `detector.py:_dummy_ai_generation_signal` 처럼 문자 코드
|
||
합으로 만든 임의 더미는 이 모듈에 없습니다.
|
||
|
||
| 모드 | 조건 | `available` | `score` | `is_stub` | `model_version` |
|
||
|---|---|---|---|---|---|
|
||
| `unavailable` | 아티팩트 없음 + `allow_heuristic=False` (**기본**) | `False` | `None` | `False` | `"unavailable"` |
|
||
| `heuristic` | 아티팩트 없음 + `allow_heuristic=True` | `True` | 0~1 | `True` | `"heuristic-baseline-v1"` |
|
||
| `trained` | 학습 아티팩트 로드 성공 | `True` | 0~1 (보정됨) | `False` | 예: `logreg-nolen-kf-ko-v1-auroc0.912` |
|
||
|
||
기본값이 `unavailable` 인 이유는, 검증되지 않은 점수가 조용히 운영 화면에 노출되는
|
||
상황을 막기 위해서입니다. 휴리스틱을 켜려면 명시적으로 opt-in 해야 합니다.
|
||
|
||
```bash
|
||
export AI_DETECTOR_MODEL_PATH=/mnt/data1/o2o/ai_detector/model.joblib
|
||
export AI_DETECTOR_ALLOW_HEURISTIC=false # 기본값
|
||
```
|
||
|
||
### 휴리스틱 baseline 에 대한 경고
|
||
|
||
`heuristic` 모드는 문헌상 알려진 경향(문장 길이 균일성, 쉼표 과다 등)을 선형
|
||
위치로 환산해 평균하는 규칙 계층입니다. **참조 구간 값은 아직 어떤 데이터로도
|
||
캘리브레이션되지 않은 자리표시자입니다.** 79권 인간 저작 코퍼스로 FPR을 측정하기
|
||
전까지는 데모·개발 편의용으로만 쓰고, 대외 산출물이나 사용자 화면에 노출하지
|
||
마십시오.
|
||
|
||
---
|
||
|
||
## 2. 특징 (`kf-ko-v1`)
|
||
|
||
KatFishNet 계열의 한국어 표지를 결정적·설명 가능하게 구현했습니다. 총
|
||
**54개**(기본 37 + 품사 17)이며 `FEATURE_NAMES` 순서가 곧 모델 입력 벡터의
|
||
순서입니다. 이 순서는
|
||
아티팩트에 저장되어 로드 시 대조되며, 불일치하면 **로드를 거부**합니다.
|
||
|
||
| 계열 | 대표 특징 | 근거 |
|
||
|---|---|---|
|
||
| 띄어쓰기 | `mean_eojeol_len`, `cv_eojeol_len`, `long_eojeol_ratio` | 생성문의 어절 길이 분포가 더 균일 |
|
||
| 문장 | `cv_sentence_len`, `std_sentence_len`, `mean_sentence_len` | **burstiness** — 사람 글은 문장 길이 기복이 큼 |
|
||
| 쉼표·부호 | `comma_per_sentence`, `comma_ratio`, `punct_diversity` | 생성문의 쉼표 과다 사용 경향 |
|
||
| 종결 | `end_da_ratio`, `end_yo_ratio`, `end_noun_ratio` | 종결어미 분포의 정형성 |
|
||
| 반복 | `char3gram_repeat_ratio`, `word_bigram_repeat_ratio`, `hapax_ratio` | 표현 재사용 |
|
||
| 품사 | `pos_bigram_entropy`, `pos_trigram_repeat_ratio`, `josa_diversity`, `eomi_diversity` | 품사 배열의 정형성, 조사·어미 다양도 |
|
||
| 길이 | `char_count`, `sentence_count`, `morph_count` 등 | 보조 — 아래 경고 참조 |
|
||
|
||
### 품사 특징 폴백
|
||
|
||
품사 특징은 `kiwipiepy` 가 있을 때만 산출됩니다. 미설치·초기화 실패·토큰화 예외
|
||
어느 경우에도 **예외를 밖으로 던지지 않고** 품사 특징을 0.0 으로 두며
|
||
`pos_available=0.0` 을 세웁니다. **특징 벡터의 길이와 순서는 절대 변하지
|
||
않습니다.** 모델이 품사로 학습됐는데(`requires_pos=True`) 실행 환경에 kiwi 가
|
||
없으면 `pos_required_by_model_but_missing` 경고가 붙습니다.
|
||
|
||
### ⚠️ 길이 특징 편향
|
||
|
||
human 에피소드와 AI 생성 표본의 **분량 분포가 다르면, 모델은 문체가 아니라
|
||
길이를 학습합니다.** 실제로 합성 데이터로 검증했을 때 상위 기여 특징이
|
||
`sentence_count`, `char_count`, `morph_count` 로 채워졌고,
|
||
`--drop-length-features` 를 켜자 `comma_ratio`, `josa_ratio`,
|
||
`std_sentence_len` 같은 실제 문체 지표로 바뀌었습니다.
|
||
|
||
**실데이터에서는 두 설정을 모두 돌려 성능 차이를 반드시 비교하십시오.** 길이를
|
||
빼도 성능이 유지되면 문체를 배운 것이고, 크게 떨어지면 길이만 보고 있었던
|
||
것입니다.
|
||
|
||
---
|
||
|
||
## 3. 한계와 오탐 위험
|
||
|
||
이 기능을 도입하기 전에 반드시 합의해야 할 항목입니다.
|
||
|
||
1. **자서전은 대필·윤문이 일상적입니다.** 편집자가 다듬은 원고는 문체가 정제되어
|
||
AI 쪽으로 기울기 쉽습니다. 이 도메인에서 FP는 "저자가 AI로 썼다"는 통보로
|
||
이어질 수 있고, 그건 명예 문제입니다.
|
||
2. **학습에 쓰이지 않은 생성 모델에는 일반화가 잘 되지 않습니다.** 특정 모델의
|
||
출력으로 학습하면 그 모델만 잡습니다.
|
||
3. **가벼운 수정으로 회피됩니다.** 문장을 몇 개 쪼개고 쉼표만 지워도 주요 특징이
|
||
흔들립니다.
|
||
4. **짧은 글은 통계가 성립하지 않습니다.** `HARD_MIN_CHARS=120` 미만은 채점을
|
||
거부하고, `MIN_RELIABLE_CHARS=300` 미만은 `short_text_low_confidence` 경고를
|
||
붙입니다.
|
||
5. **표절 점수와 절대 합산하지 마십시오.** 세 점수(표절 / 침해위험 / AI의심)는
|
||
끝까지 독립 필드여야 합니다.
|
||
|
||
### 평가 기준은 F1 이 아니라 FPR
|
||
|
||
인간 저작을 AI로 오판하는 비용이 압도적으로 크므로, 임계값은 F1 최적점이 아니라
|
||
**목표 FPR 상한**에서 잡습니다. `train_ai_detector.py` 의 `--target-fpr`(기본
|
||
0.05, `low_cut`)과 `--high-fpr`(기본 0.01, `high_cut`)이 그 역할입니다. 모델은
|
||
train에서 적합하고 임계값은 모델이 보지 않은 val에서 정하며, test는 최종 보고에만
|
||
사용합니다. val human 표본이 100건 미만이면 FPR 컷이 불안정하다는 경고가 납니다.
|
||
|
||
`low_cut == high_cut` 경고가 뜨면 medium 배지가 사라진 상태입니다. 표본이 너무
|
||
쉽게 분리되거나 표본 수가 부족하다는 신호이니 데이터를 재점검하십시오.
|
||
|
||
---
|
||
|
||
## 4. provenance (작성 경로 **추정**)
|
||
|
||
확정이 아니라 검토자 참고용 분류입니다. 구간 점수 분포에서 유도합니다.
|
||
|
||
| 값 | 규칙 | 검토자 해석 |
|
||
|---|---|---|
|
||
| `human` | 문서·구간 모두 low | 통상 검토 |
|
||
| `ai` | 문서 high, low 구간 없음 | 우선 검토 |
|
||
| `mixed` | high 구간과 low 구간이 공존 | **부분 삽입 의심** — 어느 구간인지 확인 |
|
||
| `edited` | 전 구간이 medium 에 몰림 | AI 초안 + 사람 윤문(또는 역) 가능성 |
|
||
| `unknown` | 채점 가능한 구간 없음 / 모델 미가용 | 판단 보류 |
|
||
|
||
`mixed` 가 실무상 가장 유용합니다. 문서 전체 점수 하나로는 "30만 자 중 한 챕터만
|
||
생성물"을 절대 못 잡지만, 구간 점수는 잡습니다.
|
||
|
||
---
|
||
|
||
## 5. 학습 절차
|
||
|
||
### 5.1 데이터 준비
|
||
|
||
```bash
|
||
# ① 컬럼 확인 (아무것도 쓰지 않음) — 반드시 먼저 실행
|
||
python scripts/build_ai_training_dataset.py --xlsx episodes.xlsx --inspect
|
||
|
||
# ② 빌드
|
||
python scripts/build_ai_training_dataset.py \
|
||
--xlsx episodes.xlsx \
|
||
--text-column "에피소드 본문" --book-column "도서명" \
|
||
--ai-jsonl data/training/ai_samples.jsonl \
|
||
--out data/training/ai_dataset.jsonl
|
||
```
|
||
|
||
**AI 샘플 JSONL 형식** (`--ai-text-field`/`--ai-generator-field` 로 변경 가능):
|
||
|
||
```json
|
||
{"text": "생성된 본문 …", "generator": "gpt-4o-mini", "book": ""}
|
||
```
|
||
|
||
### 5.2 누출 방지 — 이 설계의 핵심
|
||
|
||
분할 단위는 개별 텍스트가 아니라 **`source_group`** 입니다.
|
||
|
||
- human: `book:<도서명>` (도서 정보가 없으면 `sheet:<시트명>`)
|
||
- AI: `ai:<generator>` (또는 `--ai-group-field` 로 지정)
|
||
|
||
같은 책의 에피소드가 train 과 test 에 동시에 들어가면 모델이 문체가 아니라 **그
|
||
책을 외웁니다.** 빌더는 분할 후 그룹 중복을 검사해 발견 시 **exit 1** 로
|
||
실패하고, 학습 CLI도 train/val/test 그룹 중복을 확인해 **exit 2** 로 중단합니다.
|
||
|
||
분할은 해시 기반이라 결정적이며(`--seed`), 라벨별로 비율을 맞춥니다. 정규화 후
|
||
완전 중복 텍스트는 제거합니다(`recovered.csv` 에서 9,633행 중복이 나온 전례).
|
||
|
||
### 5.3 학습
|
||
|
||
```bash
|
||
python scripts/train_ai_detector.py \
|
||
--data data/training/ai_dataset.jsonl \
|
||
--model logreg \
|
||
--drop-length-features \
|
||
--target-fpr 0.05 --high-fpr 0.01 \
|
||
--out data/models/ai_detector.joblib
|
||
```
|
||
|
||
- `--model logreg` — StandardScaler + LogisticRegression. **기본 권장.** 계수 ×
|
||
표준화값으로 기여도를 뽑을 수 있어 검토자에게 근거를 보여줄 수 있습니다.
|
||
- `--model hgb` — HistGradientBoosting. 성능이 나을 수 있으나 기여도를 못 뽑아
|
||
`top_contributions` 가 빕니다(**지어내지 않습니다**).
|
||
- 두 경우 모두 `CalibratedClassifierCV`(sigmoid) + `StratifiedGroupKFold` 로
|
||
확률을 보정합니다. 보정 안 된 점수를 "의심도 %"로 띄우면 검토자가 과신합니다.
|
||
- GPU 불필요, 외부 API 호출 없음.
|
||
|
||
**즉시 실패하는 조건** (조용히 넘어가지 않고 exit 2):
|
||
label 필드 없음 / 단일 클래스 / 소수 클래스 10건 미만 / `source_group` 4개 미만 /
|
||
train·val·test 그룹 중복 / 어느 split이 단일 클래스 / train 그룹이 CV fold 보다 적음.
|
||
|
||
### 5.4 산출물
|
||
|
||
`model.joblib` 에는 estimator 와 함께 재현에 필요한 메타가 들어갑니다:
|
||
`feature_names`, `feature_set_version`, `model_version`, `requires_pos`,
|
||
`zeroed_features`, `low_cut`, `high_cut`, `metrics`, `sklearn_version`,
|
||
`trained_at`. `model.metrics.json` 에 AUROC / AUPRC / confusion matrix / FPR /
|
||
TPR / precision / recall / F1 이 train·validation·test 각각 저장됩니다.
|
||
|
||
---
|
||
|
||
## 6. 통합 연결점 (반영 완료)
|
||
|
||
아래 항목은 API 통합에 반영됐습니다. 변경 시 유지해야 할 계약으로 참고합니다.
|
||
|
||
### 6.1 `app/core/config.py`
|
||
|
||
```python
|
||
ai_detector_model_path: str = "./data/models/ai_detector.joblib"
|
||
ai_detector_allow_heuristic: bool = False
|
||
ai_detector_enabled: bool = True
|
||
```
|
||
|
||
Settings가 `get_ai_detector()`에 모델 경로·휴리스틱 허용·품사 사용 설정을 주입합니다.
|
||
|
||
### 6.2 `app/api/schemas.py` — `AiGenerationSignal` 확장
|
||
|
||
`AiGenerationSignal`에는 아래 필드가 반영돼 있습니다.
|
||
|
||
| 추가 필드 | 타입 | 비고 |
|
||
|---|---|---|
|
||
| `available` | `bool` | `False` 면 `score`/`suspicion_level` 이 `None` |
|
||
| `provenance` | `Literal["human","ai","mixed","edited","unknown"]` | 4절 |
|
||
| `model_version` | `str` | 감사 추적용 — 어느 모델이 낸 점수인지 |
|
||
| `feature_set_version` | `str` | `kf-ko-v1` |
|
||
| `warnings` | `list[str]` | `short_text_low_confidence` 등 |
|
||
| `segments` | `list[SegmentScore]` | 구간별 점수 |
|
||
| `top_contributions` | `list[{feature, contribution}]` | 설명 근거 |
|
||
| `pos_available` | `bool` | 품사 특징 사용 여부 |
|
||
|
||
`score`는 `None`을 허용하고 `suspicion_level=unknown`으로 unavailable 상태를 표현합니다.
|
||
|
||
### 6.3 `app/engine/detector.py`
|
||
|
||
문자 코드 합 더미는 제거됐고 전처리 전 raw text를 탐지기에 전달합니다.
|
||
|
||
```python
|
||
from app.engine.ai_detector import get_ai_detector
|
||
|
||
ai_result = get_ai_detector().detect(text)
|
||
```
|
||
|
||
`ReviewSummary.ai_suspicion_level`은 `unknown`을 지원하므로 미학습 결과를 `low`로
|
||
오독하지 않습니다.
|
||
|
||
단건 탐지는 threadpool에서 실행하고, 배치 경로는 구간 채점을 생략해 CPU 부하를 줄입니다.
|
||
|
||
### 6.4 바이칼 연동 문서
|
||
|
||
`docs/API_SPEC_BAIKAL.md` / `docs/API_GUIDE_BAIKAL.md`도 미학습 시 `unknown/null`을
|
||
반환하도록 갱신했습니다. **`is_stub=false`여도 확정 판정은 아닙니다.**
|
||
|
||
---
|
||
|
||
## 7. 테스트
|
||
|
||
```bash
|
||
python -m pytest tests/test_ai_detector.py -q
|
||
```
|
||
|
||
51건. **외부 모델 다운로드·네트워크 없이** 통과합니다. sklearn/kiwipiepy/joblib
|
||
미설치 환경에서도 핵심 경로는 전부 돌아가며(학습 모델 경로는 stub estimator 로
|
||
검증), joblib 이 있어야만 의미 있는 파일 아티팩트 3건만 skip 됩니다.
|
||
|
||
검증 항목: 특징 키 완전성·결정성·벡터 순서 계약 / 해시 더미가 아님(구조가 같으면
|
||
구조 특징도 같음) / kiwi 부재·토큰화 예외 폴백 / 길이특징 마스킹의 학습·추론
|
||
일치 / unavailable 시 점수 미산출 / 휴리스틱의 스텁 표기와 방향성 /
|
||
짧은 텍스트 거부·경고 / 구간 오프셋 유효성 / provenance 규칙 / 추론 실패 전파 /
|
||
특징 불일치 아티팩트 로드 거부 / JSON 직렬화.
|
||
|
||
---
|
||
|
||
## 8. 지표 없이 운영하기 — 백분위 컷 (권장 경로)
|
||
|
||
AI 생성 표본이 없어 정확도를 측정할 수 없을 때의 실용 경로다. **모델 학습도
|
||
성능지표도 없이** 규칙 점수만으로 검토 우선순위를 매긴다.
|
||
|
||
### 8.1 점수의 의미를 바꾼다
|
||
|
||
"AI일 확률"은 라벨 없이 측정할 수 없다. 대신 등록 코퍼스가 전부 인간 저작이라는
|
||
사실을 이용해, 그 점수 분포의 상위 백분위를 컷으로 잡는다. 그러면 결과는
|
||
|
||
> "등록 자서전 대비 상위 2% 이례적 문체 → 우선 검토"
|
||
|
||
가 되며, 이 진술은 **정확도를 측정하지 않아도 참**이다. 덤으로 `high` 배지 비율이
|
||
정의상 고정되어 검토 부하가 예측 가능해진다. 반면 "AI 확률 72%"는 어떤 근거로도
|
||
방어할 수 없다. 이 차이 때문에 백분위 컷을 권장한다.
|
||
|
||
### 8.2 절차
|
||
|
||
```bash
|
||
# 서버와 같은 환경(kiwipiepy 포함)에서 실행할 것 — 품사 특징 유무가 점수를 바꾼다
|
||
python scripts/calibrate_ai_detector_cuts.py \
|
||
--database data/runtime/corpus.sqlite3 --sample 3000
|
||
```
|
||
|
||
출력된 두 줄을 `.env` 에 넣고 `AI_DETECTOR_ALLOW_HEURISTIC=true` 와 함께 재시작한다.
|
||
|
||
```dotenv
|
||
AI_DETECTOR_ALLOW_HEURISTIC=true
|
||
AI_DETECTOR_LOW_CUT=0.7024
|
||
AI_DETECTOR_HIGH_CUT=0.7352
|
||
```
|
||
|
||
`--low-percentile`(기본 90) / `--high-percentile`(기본 98) 로 배지 비율을 조절한다.
|
||
low/high 는 **둘 다** 설정해야 적용된다. 하나만 넣으면 자리표시자로 되돌아간다.
|
||
|
||
### 8.3 스크립트가 거부하는 경우
|
||
|
||
규칙 점수는 각 규칙이 [0,1] 로 clip 되므로 상단에서 **포화**할 수 있다. 포화가
|
||
심하면 p90 과 p98 이 같은 값이 되고, 그 컷을 쓰면 `high` 배지가 영원히 0건이 된다.
|
||
이 경우 스크립트는 `.env` 를 출력하지 않고 **exit 3** 으로 중단하며 진단을 남긴다
|
||
(`saturated_share`, `distinct_scores`). 대응은 `--high-percentile` 을 낮추거나,
|
||
표본을 늘리거나, 표본이 특정 도서에 치우쳤는지 확인하는 것이다.
|
||
|
||
유효 표본 100건 미만이면 백분위가 불안정하므로 **exit 2** 로 중단한다.
|
||
|
||
### 8.4 캘리브레이션해도 달라지지 않는 것
|
||
|
||
- `is_stub` 은 **여전히 `true`** 다. 컷을 맞췄을 뿐 학습된 모델이 아니다.
|
||
- `model_version` 만 `heuristic-baseline-v1` → `heuristic-baseline-v1+corpus-percentile`
|
||
로 바뀌어 컷의 출처를 드러낸다.
|
||
- AI 생성 텍스트를 실제로 구분한다는 근거는 **여전히 없다.** 이 점수가 높다는 것은
|
||
"등록된 인간 원고들과 문체 통계가 다르다"는 뜻일 뿐이며, 번역체·대필·윤문·장르
|
||
차이 모두가 같은 방향으로 점수를 올린다.
|
||
- 따라서 저자·편집자에게 노출할 때는 "AI 생성 의심도"보다 **"문체 이례도"** 로
|
||
표기하고, 검토 우선순위 정렬 용도로만 쓸 것을 권한다.
|