feat: 수령 자서전 데이터 파이프라인과 맞춤 요약 추가

This commit is contained in:
hbyang 2026-08-20 16:59:17 +09:00
parent 5386222f1b
commit 946e9e9474
17 changed files with 437 additions and 48 deletions

View File

@ -75,3 +75,6 @@ LSH_TOP_K=50
# PDF VII-4 자서전 특화 모드 (공통 표현 제거 + NER 마스킹)
AUTOBIOGRAPHY_MODE=true
ENABLE_ENTITY_MASKING=true
# 수령 원고의 작성자/문서 식별자를 가명화하는 HMAC salt.
# 운영에서는 긴 무작위 값을 사용하고 저장소 밖으로 공유하지 말 것.
DATA_ANONYMIZATION_SALT=

View File

@ -185,6 +185,8 @@ async def summarize(req: SummaryRequest) -> SummaryResponse:
ratio=req.ratio,
max_sentences=req.max_sentences,
use_abstractive=req.use_abstractive,
detail=req.detail,
emphasis=req.emphasis,
)
return SummaryResponse(
extractive=result.extractive,
@ -194,6 +196,8 @@ async def summarize(req: SummaryRequest) -> SummaryResponse:
selected_indices=result.selected_indices,
num_sentences_in=result.num_sentences_in,
num_sentences_out=result.num_sentences_out,
detail=req.detail,
emphasis=req.emphasis,
engine_version=settings.engine_version,
)

View File

@ -366,7 +366,13 @@ class BatchStatusResponse(BaseModel):
class SummaryRequest(BaseModel):
text: str = Field(..., min_length=1)
ratio: float = Field(default=0.3, gt=0.0, le=1.0, description="요약 길이 비율 (입력 문장 대비)")
ratio: float | None = Field(default=None, gt=0.0, le=1.0, description="요약 길이 비율(지정 시 detail보다 우선)")
detail: Literal["brief", "standard", "detailed"] = Field(
default="standard", description="요약 상세도"
)
emphasis: list[str] = Field(
default_factory=list, max_length=10, description="요약에서 우선 강조할 주제·키워드"
)
max_sentences: int | None = Field(default=None, ge=1, description="최대 문장 수 (옵션)")
use_abstractive: bool = Field(default=True, description="추상적(LLM) 단계 사용 — 키 없으면 추출적 폴백")
@ -379,6 +385,8 @@ class SummaryResponse(BaseModel):
selected_indices: list[int] = Field(default_factory=list)
num_sentences_in: int
num_sentences_out: int
detail: Literal["brief", "standard", "detailed"] = "standard"
emphasis: list[str] = Field(default_factory=list)
engine_version: str

View File

@ -12,7 +12,8 @@
자체 sLLM(고려대 2차저작 생성지원 모델) 으로 교체할 자리.
통합 요약 (hybrid) 핵심 문장을 입력으로 (계획서 통합 요약 시스템).
사용자 맞춤형 옵션(요약 길이/비율) 계획서 2단계 '사용자 맞춤형 요약' 반영.
사용자 맞춤형 옵션(요약 상세도/강조 주제) 계획서 2단계
'사용자 맞춤형 요약' 반영.
평가지표 No.7(N-gram ROUGE) scripts/eval_rouge.py 측정한다 (정답셋 들어오면).
"""
@ -29,6 +30,8 @@ from app.engine.structural import extract_lemmas
logger = logging.getLogger(__name__)
DETAIL_RATIOS = {"brief": 0.2, "standard": 0.3, "detailed": 0.45}
# 문장 분할 — 종결부호 기준 (한국어 '다./요./까?/!' + 줄바꿈)
_SENT_SPLIT = re.compile(r"(?<=[.!?。…])\s+|\n+")
@ -92,17 +95,29 @@ class SummaryResult:
mode: str # "extractive" | "hybrid"
num_sentences_in: int
num_sentences_out: int
detail: str = "standard"
emphasis: list[str] | None = None
def extractive_summary(text: str, ratio: float = 0.3, max_sentences: int | None = None) -> SummaryResult:
def extractive_summary(
text: str,
ratio: float = 0.3,
max_sentences: int | None = None,
emphasis: list[str] | None = None,
detail: str = "standard",
) -> SummaryResult:
"""비지도 추출적 요약 — 정답셋/LLM/외부호출 불필요."""
sentences = split_sentences(text)
n = len(sentences)
clean_emphasis = [term.strip() for term in (emphasis or []) if term.strip()]
if n == 0:
return SummaryResult("", None, "", [], "extractive", 0, 0)
return SummaryResult("", None, "", [], "extractive", 0, 0, detail, clean_emphasis)
if n <= 2:
joined = " ".join(sentences)
return SummaryResult(joined, None, joined, list(range(n)), "extractive", n, n)
return SummaryResult(
joined, None, joined, list(range(n)), "extractive", n, n,
detail, clean_emphasis,
)
k = max(1, math.ceil(n * ratio))
if max_sentences is not None:
@ -111,6 +126,14 @@ def extractive_summary(text: str, ratio: float = 0.3, max_sentences: int | None
vectors = [_lemma_vector(s) for s in sentences]
scores = _textrank_scores(vectors)
# 사용자가 지정한 주제를 포함한 문장에 명시적 가중치를 준다.
# TextRank 중심성은 유지하되 강조 요청이 상위 선택에 반영되도록 한다.
if clean_emphasis:
lowered_terms = [term.casefold() for term in clean_emphasis]
for i, sentence in enumerate(sentences):
matched = sum(term in sentence.casefold() for term in lowered_terms)
scores[i] += 2.0 * matched / len(lowered_terms)
# 상위 k개 문장 선택 → 원문 등장 순서로 재정렬 (가독성)
top = sorted(range(n), key=lambda i: scores[i], reverse=True)[:k]
top_sorted = sorted(top)
@ -123,6 +146,8 @@ def extractive_summary(text: str, ratio: float = 0.3, max_sentences: int | None
mode="extractive",
num_sentences_in=n,
num_sentences_out=len(top_sorted),
detail=detail,
emphasis=clean_emphasis,
)
@ -147,16 +172,25 @@ class Summarizer:
def summarize(
self,
text: str,
ratio: float = 0.3,
ratio: float | None = None,
max_sentences: int | None = None,
use_abstractive: bool = True,
detail: str = "standard",
emphasis: list[str] | None = None,
) -> SummaryResult:
base = extractive_summary(text, ratio=ratio, max_sentences=max_sentences)
effective_ratio = ratio if ratio is not None else DETAIL_RATIOS.get(detail, 0.3)
base = extractive_summary(
text,
ratio=effective_ratio,
max_sentences=max_sentences,
emphasis=emphasis,
detail=detail,
)
if not base.extractive:
return base
if use_abstractive and self.settings.use_llm_extractor and self.settings.has_openai:
abstractive = self._abstractive(base.extractive)
abstractive = self._abstractive(base.extractive, detail, emphasis or [])
if abstractive:
return SummaryResult(
extractive=base.extractive,
@ -166,10 +200,14 @@ class Summarizer:
mode="hybrid",
num_sentences_in=base.num_sentences_in,
num_sentences_out=base.num_sentences_out,
detail=detail,
emphasis=base.emphasis,
)
return base
def _abstractive(self, extractive_text: str) -> str | None:
def _abstractive(
self, extractive_text: str, detail: str, emphasis: list[str]
) -> str | None:
try:
from openai import OpenAI
client = OpenAI(api_key=self.settings.openai_api_key)
@ -178,7 +216,15 @@ class Summarizer:
temperature=0.2,
messages=[
{"role": "system", "content": "You are a concise Korean summarizer. Never hallucinate."},
{"role": "user", "content": _ABSTRACTIVE_PROMPT + extractive_text},
{
"role": "user",
"content": (
_ABSTRACTIVE_PROMPT
+ extractive_text
+ f"\n\n[\uC0C1세도] {detail}"
+ (f"\n[\uAC15조 주제] {', '.join(emphasis)}" if emphasis else "")
),
},
],
)
return (resp.choices[0].message.content or "").strip() or None

View File

@ -0,0 +1,38 @@
"""수령 원고의 익명화와 학습 그룹 식별에 쓰는 공통 유틸리티."""
from __future__ import annotations
import hashlib
import hmac
import re
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
_PHONE_RE = re.compile(r"(?<!\d)(?:01[016789]|0\d{1,2})[- .]?\d{3,4}[- .]?\d{4}(?!\d)")
_RRN_RE = re.compile(r"(?<!\d)\d{6}[- ]?[1-4]\d{6}(?!\d)")
def pseudonymous_id(value: str, salt: str, *, prefix: str = "author") -> str:
"""비밀 salt를 사용해 원래 식별자를 복원하기 어려운 안정 ID를 만든다."""
if not value.strip():
raise ValueError("익명화할 식별자가 비어 있습니다")
if not salt:
raise ValueError("익명화 salt가 비어 있습니다")
digest = hmac.new(
salt.encode("utf-8"), value.strip().encode("utf-8"), hashlib.sha256,
).hexdigest()[:24]
return f"{prefix}:{digest}"
def redact_direct_identifiers(text: str) -> str:
"""이메일·전화번호·주민번호처럼 직접 식별 가능한 문자열을 제거한다."""
value = _EMAIL_RE.sub("[EMAIL]", str(text))
value = _PHONE_RE.sub("[PHONE]", value)
return _RRN_RE.sub("[ID_NUMBER]", value)
def sanitize_prompt_metadata(value: str) -> str:
"""외부 생성 프롬프트에 넣기 전 직접 식별자와 제목 속 이름을 제거한다."""
cleaned = redact_direct_identifiers(value).strip()
cleaned = re.sub(r"^[가-힣]{2,4}의\s*자서전$", "익명 작성자의 자서전", cleaned)
return cleaned

View File

@ -199,8 +199,10 @@ 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 "도서명" \
--text-column "에피소드 본문" --book-column "자서전 제목" \
--id-column id --group-column id --anonymize \
--ai-jsonl data/training/ai_samples.jsonl \
--ai-group-field source_group \
--out data/training/ai_dataset.jsonl
```
@ -214,7 +216,7 @@ python scripts/build_ai_training_dataset.py \
분할 단위는 개별 텍스트가 아니라 **`source_group`** 입니다.
- human: `book:<도서명>` (도서 정보가 없으면 `sheet:<시트명>`)
- human: 수령 데이터는 `id`를 HMAC 가명화한 `author:<hash>`
- AI: `ai:<generator>` (또는 `--ai-group-field` 로 지정)
같은 책의 에피소드가 train 과 test 에 동시에 들어가면 모델이 문체가 아니라 **그
@ -223,6 +225,8 @@ python scripts/build_ai_training_dataset.py \
분할은 해시 기반이라 결정적이며(`--seed`), 라벨별로 비율을 맞춥니다. 정규화 후
완전 중복 텍스트는 제거합니다(`recovered.csv` 에서 9,633행 중복이 나온 전례).
원문과 같은 소재 그룹에서 만든 AI 대조문은 `source_group`을 같게 넣어
원문-생성물 페어가 분할을 가로지르지 않게 합니다.
### 5.3 학습

View File

@ -1,7 +1,7 @@
# 오투오 2단계 진행 현황 (데이터 수령 전 선행 작업 완료분)
# 오투오 2단계 진행 현황 (2026-08-20 데이터 수령 반영)
> 데이터 수령 전까지 가능한 작업을 선행 구현하여, 컴북스 데이터가 들어오면
> 즉시 학습·검증에 착수할 수 있도록 준비한 결과 요약.
> 컴북스가 제공한 실제 사람 작성 자서전 에피소드를 익명 human 코퍼스로
> 적재하는 기능과 AI 대조문 생성·그룹 분할 파이프라인을 구현했다.
## 1. 한눈에 보기
@ -15,6 +15,8 @@
| 요약 ROUGE 65(No.7) | ✅ ROUGE 평가 모듈·CLI | 요약 정답셋으로 정식 측정 |
| SW 저작권 등록 | ✅ 등록 준비 문서 | 서류 제출 |
| 2단계 통합 | ✅ 통합 인터페이스 명세 | 바이칼/컴북스 E2E |
| 도메인 데이터 적재 | ✅ 533문서·6,331에피소드 드라이런 | 운영 코퍼스 반영 |
| 사용자 맞춤형 요약 | ✅ 상세도·강조 옵션 | 요약 정답셋 튜닝 |
## 2. 구현 산출물
@ -39,7 +41,7 @@
- `docs/INTEGRATION_INTERFACE.md` — 2단계 통합 인터페이스
- `docs/SW_COPYRIGHT_REGISTRATION.md` — SW 저작권 등록 준비
## 3. 데이터에 묶여 남는 것 (수령 후 착수)
## 3. 추가 데이터·라벨에 묶여 남는 것
- 표절 **정밀도 97% 최종 달성** — 자서전 도메인 표절 샘플 필요
- 요약 **ROUGE 65 학습·최종 평가** — 요약 정답셋 필요
- HF **실제 선호학습 수행** — 사람 선호 라벨 필요

View File

@ -0,0 +1,59 @@
# 컴북스 수령 데이터 처리 및 AI 대조문 구축
## 1. 2026-08-20 수령 현황
| 파일 | 확인 내용 | 현재 쓰임 |
|---|---|---|
| `자서전.net-에피소드.xlsx` | 실제 사람 작성, 6,651건의 본문 | human 정답 코퍼스, AI 의심도 비교 기준, 요약·표절 튜닝 |
| `한국인 생활 수기집 _ 파일 목록.xlsx` | 30건의 원본 파일/링크 목록 | 수령 provenance 목록. 본문 파일 확보·OCR 후 코퍼스 적재 |
자서전 원고는 `combooks_confirmed_human`, `human_verified=true`,
`ai_assistance=false`로 기록한다. 두 번째 파일은 본문이 아니므로 목록만으로
학습·탐지에 쓰지 않는다.
## 2. 익명화와 누출 방지
- `id`(이메일)는 운영 비밀 salt로 HMAC 가명화한다. 원본 ID는 DB와 학습셋에 저장하지 않는다.
- 같은 작성자의 에피소드는 같은 `source_group`으로 묶어 train/val/test 누출을 막는다.
- 본문의 이메일·휴대전화·주민번호 형식은 적재 전 치환한다.
- AI 대조문 생성기는 본문 컬럼을 프롬프트로 지정하면 실패한다. 제목·키워드 컬럼만 허용한다.
- `DATA_ANONYMIZATION_SALT`는 코드·문서·산출물에 넣지 않는다.
## 3. 검증된 실행 결과
2026-08-20 원본 XLSX 로컬 드라이런 결과:
- 운영 코퍼스 적재: 533문서, 6,331개 익명 에피소드, 5,048,397자
- 학습 적합 human 표본: 200자 이상 6,139건 → 중복 560건 제거 → 5,579건
- 작성자 그룹: 290개, train/val/test 그룹 교차 0건
- 분할: train 3,946 / val 729 / test 904
- 길이: 평균 847.1자; AI 생성 목표는 human 길이 분포에서 표본화
human 데이터만으로는 이진 AI 생성 판별기를 학습할 수 없다. 다음 단계에서 적어도
2개 생성 모델로 순수 AI 대조문을 생성하고, 독립된 사람 검토용 세트는 별도 보존한다.
## 4. 실행 순서
```bash
export DATA_ANONYMIZATION_SALT='<운영 비밀값>'
python scripts/ingest_o2o_xlsx.py '자서전.net-에피소드.xlsx' \
--database data/runtime/corpus.sqlite3 \
--book-column '자서전 제목' --text-column '에피소드 본문' \
--author-column id --episode-title-column '에피소드 제목' --anonymize
python scripts/generate_ai_samples.py \
--xlsx '자서전.net-에피소드.xlsx' --text-column '에피소드 본문' \
--meta-column '자서전 제목' --meta-column '에피소드 제목' --group-column id \
--model '<생성모델-1>' --model '<생성모델-2>' \
--limit 5600 --out data/training/ai_samples.jsonl
python scripts/build_ai_training_dataset.py \
--xlsx '자서전.net-에피소드.xlsx' --text-column '에피소드 본문' \
--book-column '자서전 제목' --id-column id --group-column id --anonymize \
--ai-jsonl data/training/ai_samples.jsonl --ai-group-field source_group \
--out data/training/ai_dataset.jsonl
```
AI 대조문은 AI 의심도 부가 기능을 개선하기 위한 것이다. 과업 공식 성능지표인
`표절 정밀도 97%`는 AI 대조문이 아니라 별도의 실제 표절/비표절 정답쌍으로 평가한다.

View File

@ -42,14 +42,13 @@
| 계획서 항목 | 상태 | 남은 일 |
|---|---|---|
| 데이터 수집·전처리 (도메인별 데이터셋 구축) | ⬜ 미착수 | 컴북스 데이터 수령 후 |
| 데이터 수집·전처리 (도메인별 데이터셋 구축) | ✅ 자서전 수령·파이프라인 완료 | 생활 수기집 원본 확보·OCR |
| 고도화 요약 모델 (문맥 기반 문장 추출, 키워드·문장 관계 분석) | ✅ `summarizer.py` TextRank | 실데이터 튜닝 |
| 통합 요약 시스템 (추출적+추상적 하이브리드) | ✅ 구현 | LLM 키 연결 시 동작 |
| **사용자 맞춤형 요약 (길이·상세도·강조 내용 옵션)** | ⚠️ **부분** | **상세도·강조 내용 옵션 미구현** |
| **사용자 맞춤형 요약 (길이·상세도·강조 내용 옵션)** | ✅ 구현 | 실데이터 튜닝 |
`SummaryRequest`에 있는 것은 `ratio`(길이), `max_sentences`, `use_abstractive`뿐이다.
계획서가 명시한 **상세도(detail)****강조 내용(emphasis)** 옵션이 없다. 지표에는
안 잡히지만 성과물 명세에 적힌 기능이므로 개발이 필요하다.
`SummaryRequest``detail`(`brief|standard|detailed`)과 `emphasis`를 추가했다.
`ratio`를 직접 주면 상세도 기본 비율보다 우선하며, 강조 주제는 추출·추상 단계에 모두 반영한다.
### 나. 표절 검출 기술 고도화
@ -114,10 +113,9 @@ future 임포트가 있는지 AST 로 검사한다. 3.14 에서도 회귀를 잡
## 6. 요약 — 남은 일
### 더 개발해야 하는 것
1. **사용자 맞춤형 요약 옵션** — 상세도, 강조 내용 (계획서 명시, 현재 없음)
2. **Human Feedback Preference Optimization** — 현재 골격만. 선호 라벨 + GPU 필요
3. **도메인별 요약 데이터셋 구축** — 컴북스 데이터 수령 후
4. ~~python 3.9 호환성 정리~~**완료** (2026-08-19)
1. **Human Feedback Preference Optimization** — 현재 골격만. 선호 라벨 + GPU 필요
2. **요약 정답셋 구축** — 수령 원문에 대한 다중 참조 요약 300건 라벨링 필요
3. **생활 수기집 본문 적재** — 현재 파일 목록만 수령, 원본 다운로드·OCR 필요
### 측정해야 하는 것 (전부 데이터 대기)
1. **No.4 표절 정밀도 97%** — 자체 제작 표절/비표절 글 필요. 정밀도 지표이므로

View File

@ -39,6 +39,7 @@ import csv
import hashlib
import json
import logging
import os
import re
import sys
import unicodedata
@ -49,6 +50,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from app.engine.training_data import pseudonymous_id, redact_direct_identifiers # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger("build-ai-dataset")
@ -57,7 +60,7 @@ LABEL_AI = 1
#: 헤더 자동탐지 힌트 (부분일치, 소문자 비교). 실제 파일을 못 본 상태라
#: 오탐 가능성이 있으니 --inspect 로 먼저 확인하고 --text-column 으로 고정할 것.
TEXT_HINTS = ("에피소드", "본문", "내용", "원고", "텍스트", "story", "text", "body", "content")
TEXT_HINTS = ("본문", "에피소드", "내용", "원고", "텍스트", "story", "text", "body", "content")
BOOK_HINTS = ("도서", "", "서명", "제목", "book", "title", "작품")
ID_HINTS = ("id", "번호", "no", "식별")
AUTHOR_HINTS = ("저자", "작가", "author", "writer")
@ -178,6 +181,9 @@ def load_human_from_xlsx(
id_column: str | None,
sheet: str | None,
min_chars: int,
group_column: str | None = None,
anonymization_salt: str | None = None,
provenance: str = "unknown",
) -> list[Record]:
wb = _load_workbook(path)
sheets = [sheet] if sheet else wb.sheetnames
@ -202,11 +208,13 @@ def load_human_from_xlsx(
bcol = book_column or _match_column(header, BOOK_HINTS)
icol = id_column or _match_column(header, ID_HINTS)
acol = _match_column(header, AUTHOR_HINTS)
gcol = group_column or acol or icol or bcol
ti = header.index(tcol)
bi = header.index(bcol) if bcol in header else None
ii = header.index(icol) if icol in header else None
ai = header.index(acol) if acol in header else None
gi = header.index(gcol) if gcol in header else None
logger.info(
"[%s] text=%r book=%r id=%r author=%r", name, tcol, bcol, icol, acol
@ -216,22 +224,48 @@ def load_human_from_xlsx(
if ti >= len(row):
continue
text = normalize(row[ti] if row[ti] is not None else "")
if anonymization_salt:
text = redact_direct_identifiers(text)
if len(text) < min_chars:
continue
book = ""
if bi is not None and bi < len(row) and row[bi] is not None:
book = str(row[bi]).strip()
# 책 정보가 없으면 시트명으로라도 묶는다. group 없는 분할은 금지.
group = f"book:{book}" if book else f"sheet:{name}"
meta = {"sheet": name, "row": rownum, "source_file": path.name}
raw_group = ""
if gi is not None and gi < len(row) and row[gi] is not None:
raw_group = str(row[gi]).strip()
if anonymization_salt and raw_group:
group = pseudonymous_id(raw_group, anonymization_salt)
elif raw_group:
group = f"source:{raw_group}"
else:
group = f"sheet:{name}"
meta = {
"sheet": name,
"row": rownum,
"source_file": path.name,
"provenance": provenance,
"human_verified": True,
"ai_assistance": False,
}
if ii is not None and ii < len(row) and row[ii] is not None:
meta["row_id"] = str(row[ii]).strip()
raw_id = str(row[ii]).strip()
if anonymization_salt:
meta["row_id_hash"] = pseudonymous_id(raw_id, anonymization_salt)
else:
meta["row_id"] = raw_id
if ai is not None and ai < len(row) and row[ai] is not None:
meta["author"] = str(row[ai]).strip()
raw_author = str(row[ai]).strip()
if anonymization_salt:
meta["author_hash"] = pseudonymous_id(raw_author, anonymization_salt)
else:
meta["author"] = raw_author
records.append(
Record(
text=text, label=LABEL_HUMAN, origin="human",
source_group=group, book=book, meta=meta,
source_group=group,
book="" if anonymization_salt else book,
meta=meta,
)
)
wb.close()
@ -410,6 +444,12 @@ def main() -> int:
ap.add_argument("--text-column", default=None, help="본문 컬럼명 (미지정 시 자동탐지)")
ap.add_argument("--book-column", default=None, help="도서 컬럼명 (분할 그룹 기준)")
ap.add_argument("--id-column", default=None, help="행 식별자 컬럼명")
ap.add_argument("--group-column", default=None,
help="분할 그룹 컬럼. 자서전.net은 id를 지정해 작성자 누출 방지")
ap.add_argument("--anonymize", action="store_true",
help="그룹/행 ID를 HMAC 가명화하고 본문의 직접 식별자를 제거")
ap.add_argument("--anonymization-salt-env", default="DATA_ANONYMIZATION_SALT")
ap.add_argument("--human-provenance", default="combooks_confirmed_human")
ap.add_argument("--ai-jsonl", type=Path, action="append", default=[], help="AI 샘플 JSONL (반복 가능)")
ap.add_argument("--ai-csv", type=Path, action="append", default=[], help="AI 샘플 CSV (반복 가능)")
ap.add_argument("--ai-text-field", default="text", help="AI 파일의 본문 필드명")
@ -430,6 +470,15 @@ def main() -> int:
return 0
records: list[Record] = []
anonymization_salt = None
if args.anonymize:
anonymization_salt = os.environ.get(args.anonymization_salt_env, "")
if not anonymization_salt:
logger.error(
"--anonymize 사용 시 %s 환경변수가 필요합니다.",
args.anonymization_salt_env,
)
return 2
if args.xlsx:
if not args.xlsx.exists():
logger.error("xlsx 없음: %s", args.xlsx)
@ -437,6 +486,7 @@ def main() -> int:
records += load_human_from_xlsx(
args.xlsx, args.text_column, args.book_column,
args.id_column, args.sheet, args.min_chars,
args.group_column, anonymization_salt, args.human_provenance,
)
jsonl = [p for p in args.ai_jsonl if p.exists()]

View File

@ -81,7 +81,7 @@ def main() -> int:
print()
print("=" * 60)
print(f"전체 정밀도 (precision): {precision:.4f} (목표 0.95)")
print(f"전체 정밀도 (precision): {precision:.4f} (계획서 목표 0.97)")
print(f"재현율 (recall): {recall:.4f}")
print(f"F1: {f1:.4f}")
print(f"TP={tp} FP={fp} TN={tn} FN={fn}")

View File

@ -61,6 +61,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(mes
logger = logging.getLogger("gen-ai-samples")
from app.engine.ocr_normalize import normalize_ocr # noqa: E402
from app.engine.training_data import pseudonymous_id, sanitize_prompt_metadata # noqa: E402
#: 프롬프트에 쓸 수 있는 컬럼. 본문 컬럼은 의도적으로 목록에 없다.
META_COLUMNS = (
@ -70,7 +71,8 @@ META_COLUMNS = (
"gpt_character_keyword",
"생애사건_category",
)
TEXT_COLUMN_BLOCKLIST = ("에피소드", "본문", "내용", "원고", "text", "content")
TEXT_COLUMN_BLOCKLIST = ("본문", "내용", "원고", "text", "content", "body")
TEXT_COLUMN_EXACT_BLOCKLIST = ("에피소드", "story")
SYSTEM_PROMPT = (
"당신은 한국어 자서전 원고를 쓰는 작가다. 주어진 소재로 자서전의 한 "
@ -99,8 +101,11 @@ def assert_no_body_columns(columns: list[str]) -> list[str]:
주석만으로는 다음 사람이 META_COLUMNS 본문 컬럼을 추가하는 것을
막는다. 줄이 원고를 외부 API 내보내는 경로가 된다.
"""
leaked = [c for c in columns
if any(bad in c.lower() for bad in TEXT_COLUMN_BLOCKLIST)]
leaked = [
c for c in columns
if c.strip().lower() in TEXT_COLUMN_EXACT_BLOCKLIST
or any(bad in c.lower() for bad in TEXT_COLUMN_BLOCKLIST)
]
if leaked:
raise SystemExit(
f"본문으로 보이는 컬럼이 프롬프트 소재에 있습니다: {leaked}. "
@ -158,6 +163,15 @@ def main() -> int:
ap.add_argument("--xlsx", type=Path, required=True, help="소재를 뽑을 에피소드 xlsx")
ap.add_argument("--text-column", default="에피소드",
help="길이 분포 계산에만 쓴다. 본문은 API 로 보내지 않는다.")
ap.add_argument(
"--meta-column", action="append", default=[],
help="프롬프트 소재 컬럼(반복 지정). 제목·키워드만 허용하며 본문은 차단",
)
ap.add_argument(
"--group-column", default=None,
help="human과 같은 작성자 그룹으로 묶을 컬럼(원본값은 저장·전송하지 않음)",
)
ap.add_argument("--group-salt-env", default="DATA_ANONYMIZATION_SALT")
ap.add_argument("--model", action="append", default=[],
help="생성 모델 (반복 지정 = 혼합). 최소 2개 권장")
ap.add_argument("--base-url", default=None,
@ -179,13 +193,26 @@ def main() -> int:
lengths[len(lengths) // 2], low_chars, high_chars, len(lengths),
)
usable_meta = assert_no_body_columns([c for c in META_COLUMNS if c in frame.columns])
requested_meta = args.meta_column or [c for c in META_COLUMNS if c in frame.columns]
missing_meta = [c for c in requested_meta if c not in frame.columns]
if missing_meta:
raise SystemExit(f"프롬프트 소재 컬럼이 없습니다: {missing_meta}")
usable_meta = assert_no_body_columns(requested_meta)
if not usable_meta:
raise SystemExit(
f"소재로 쓸 컬럼이 없습니다. 기대: {META_COLUMNS} / 실제: {list(frame.columns)}"
)
logger.info("프롬프트 소재 컬럼: %s", usable_meta)
group_salt = os.environ.get(args.group_salt_env, "")
if args.group_column:
if args.group_column not in frame.columns:
raise SystemExit(f"그룹 컬럼이 없습니다: {args.group_column}")
if not group_salt:
raise SystemExit(
f"--group-column 사용 시 {args.group_salt_env} 환경변수가 필요합니다."
)
models = args.model or ["gpt-4o-mini"]
if len(models) == 1 and not args.dry_run:
logger.warning(
@ -229,11 +256,20 @@ def main() -> int:
if prompt_id in done:
continue
meta = {c: str(frame[c].iloc[idx]) for c in usable_meta
if isinstance(frame[c].iloc[idx], str) and frame[c].iloc[idx].strip()}
meta = {
c: sanitize_prompt_metadata(str(frame[c].iloc[idx]))
for c in usable_meta
if isinstance(frame[c].iloc[idx], str) and frame[c].iloc[idx].strip()
}
if not meta:
continue
source_group = f"ai-topic:{prompt_id}"
if args.group_column:
raw_group = str(frame[args.group_column].iloc[idx] or "").strip()
if raw_group and raw_group.lower() != "nan":
source_group = pseudonymous_id(raw_group, group_salt)
target = rng.choice(lengths)
style = STYLE_VARIANTS[_stable_int(prompt_id) % len(STYLE_VARIANTS)]
model = models[attempted % len(models)]
@ -276,6 +312,9 @@ def main() -> int:
"target_chars": target,
"char_count": len(text),
"style": style,
"source_group": source_group,
"generation_type": "pure_ai",
"provenance": "ai_generated_controlled",
"meta": meta,
}, ensure_ascii=False) + "\n")
sink.flush() # 중간에 죽어도 여기까지는 남는다

View File

@ -12,7 +12,7 @@
"is_plagiarism": true, "original_excerpt": "...", "derived_text": "..."}
자체 평가 데이터셋(계획서 성능지표 #4) 용도. 운영 모델 학습 시
데이터와 컴북스 보유 30,000 원천 자료를 결합하여 정밀도 95% 달성.
데이터와 컴북스 보유 원천 자료를 결합하여 계획서 목표 정밀도 97% 달성.
사용:
export OPENAI_API_KEY=sk-...

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter
from pathlib import Path
@ -13,6 +14,11 @@ if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord, stable_id
from app.engine.training_data import (
pseudonymous_id,
redact_direct_identifiers,
sanitize_prompt_metadata,
)
def parse_args() -> argparse.Namespace:
@ -23,7 +29,14 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--book-column", default="book_name")
p.add_argument("--text-column", default="에피소드")
p.add_argument("--index-column", default="episode_index")
p.add_argument("--author-column", default=None,
help="작성자 그룹 컬럼. 지정하면 같은 작성자의 여러 자서전을 함께 묶는다")
p.add_argument("--episode-title-column", default=None)
p.add_argument("--path-column", default="json_path")
p.add_argument("--anonymize", action="store_true",
help="작성자·책 제목을 가명화하고 본문의 직접 식별자를 제거")
p.add_argument("--anonymization-salt-env", default="DATA_ANONYMIZATION_SALT")
p.add_argument("--provenance", default="combooks_confirmed_human")
return p.parse_args()
@ -43,7 +56,9 @@ def main() -> int:
rows = ws.iter_rows(values_only=True)
headers = [str(v).strip() if v is not None else "" for v in next(rows)]
positions = {name: i for i, name in enumerate(headers)}
required = [args.book_column, args.text_column, args.index_column]
required = [args.book_column, args.text_column]
if args.author_column:
required.append(args.author_column)
missing = [name for name in required if name not in positions]
if missing:
print(f"필수 열 없음: {missing}; 실제 열={headers}", file=sys.stderr)
@ -55,24 +70,54 @@ def main() -> int:
book_counts: Counter[str] = Counter()
documents: dict[str, DocumentRecord] = {}
segments: list[SegmentRecord] = []
salt = os.environ.get(args.anonymization_salt_env, "")
if args.anonymize and not salt:
print(
f"--anonymize 사용 시 {args.anonymization_salt_env} 환경변수가 필요합니다.",
file=sys.stderr,
)
return 2
for row in rows:
for rownum, row in enumerate(rows, start=2):
book = str(row[positions[args.book_column]] or "").strip()
text = str(row[positions[args.text_column]] or "").strip()
ordinal = str(row[positions[args.index_column]] or "").strip()
ordinal = str(row[positions[args.index_column]] or "").strip() \
if args.index_column in positions else str(rownum - 1)
if not book or not text:
skipped += 1
continue
author = ""
if args.author_column:
author = str(row[positions[args.author_column]] or "").strip()
raw_group = f"{author}\x1f{book}" if author else book
author_group = pseudonymous_id(author, salt) if args.anonymize and author else None
document_id = (
pseudonymous_id(raw_group, salt, prefix="doc")
if args.anonymize else stable_id("doc", raw_group)
)
display_title = f"익명 자서전 {document_id.split(':')[-1][:8]}" if args.anonymize else book
if args.anonymize:
text = redact_direct_identifiers(text)
source_path = None
if args.path_column in positions:
if args.path_column in positions and not args.anonymize:
source_path = str(row[positions[args.path_column]] or "").strip() or None
document_id = stable_id("doc", book)
documents[document_id] = DocumentRecord(
document_id=document_id,
title=book,
title=display_title,
source_path=source_path,
metadata={"import_source": args.xlsx.name},
metadata={
"import_source": args.xlsx.name,
"provenance": args.provenance,
"human_verified": True,
"ai_assistance": False,
**({"author_group": author_group} if author_group else {}),
},
)
episode_title = None
if args.episode_title_column in positions:
episode_title = str(row[positions[args.episode_title_column]] or "").strip() or None
if episode_title and args.anonymize:
episode_title = sanitize_prompt_metadata(episode_title)
segments.append(SegmentRecord(
segment_id=stable_id("seg", document_id, text),
document_id=document_id,
@ -85,9 +130,13 @@ def main() -> int:
metadata={
"provenance_quality": "episode_only",
"page_offset_available": False,
"provenance": args.provenance,
"human_verified": True,
"ai_assistance": False,
**({"episode_title": episode_title} if episode_title else {}),
},
))
book_counts[book] += 1
book_counts[document_id] += 1
store.upsert_documents(documents.values())
inserted, duplicates = store.add_segments(segments)
@ -99,6 +148,8 @@ def main() -> int:
"duplicate_segments": duplicates,
"skipped_rows": skipped,
"source_books": len(book_counts),
"anonymized": args.anonymize,
"provenance": args.provenance,
"store": store.stats(),
"location_warning": (
"수령 XLSX에는 원본 페이지/문단 offset이 없어 episode 좌표만 저장했습니다. "

View File

@ -35,6 +35,11 @@ class TestManuscriptNeverLeaves:
cols = ["gpt_keyword", "gpt_title"]
assert gen.assert_no_body_columns(cols) == cols
def test_episode_title_is_allowed_but_episode_body_is_not(self):
assert gen.assert_no_body_columns(["에피소드 제목"]) == ["에피소드 제목"]
with pytest.raises(SystemExit):
gen.assert_no_body_columns(["에피소드 본문"])
def test_prompt_contains_only_given_meta(self):
"""프롬프트에 넣지 않은 문자열은 절대 나타나지 않는다."""
secret = "이것은 저자의 실제 원고 본문이다"

View File

@ -52,6 +52,20 @@ def test_max_sentences_cap():
assert result.num_sentences_out <= 2
def test_detail_controls_default_length():
settings = Settings(use_llm_extractor=False, openai_api_key="", use_kosimcse=False)
summarizer = Summarizer(settings)
brief = summarizer.summarize(_DOC, detail="brief")
detailed = summarizer.summarize(_DOC, detail="detailed")
assert brief.num_sentences_out < detailed.num_sentences_out
def test_emphasis_prioritizes_requested_topic():
result = extractive_summary(_DOC, ratio=0.25, emphasis=["김밥"])
assert "김밥" in result.extractive
assert result.emphasis == ["김밥"]
def test_empty_and_short():
assert extractive_summary("").final == ""
one = extractive_summary("한 문장만 있다.")

View File

@ -0,0 +1,68 @@
"""수령 원고 익명화·그룹 분리 회귀 테스트."""
from __future__ import annotations
from pathlib import Path
from openpyxl import Workbook
from app.engine.training_data import (
pseudonymous_id,
redact_direct_identifiers,
sanitize_prompt_metadata,
)
from scripts.build_ai_training_dataset import load_human_from_xlsx
def test_pseudonymous_id_is_stable_and_salt_scoped():
assert pseudonymous_id("writer@example.com", "salt-a") == pseudonymous_id(
"writer@example.com", "salt-a"
)
assert pseudonymous_id("writer@example.com", "salt-a") != pseudonymous_id(
"writer@example.com", "salt-b"
)
assert "writer" not in pseudonymous_id("writer@example.com", "salt-a")
def test_direct_identifiers_are_redacted():
text = "연락처 writer@example.com, 010-1234-5678, 900101-1234567"
redacted = redact_direct_identifiers(text)
assert "writer@example.com" not in redacted
assert "010-1234-5678" not in redacted
assert "900101-1234567" not in redacted
def test_prompt_metadata_removes_named_autobiography_title():
assert sanitize_prompt_metadata("홍길동의 자서전") == "익명 작성자의 자서전"
def test_received_schema_uses_author_group_without_raw_identifier(tmp_path: Path):
path = tmp_path / "received.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "bio"
ws.append(["id", "자서전 제목", "에피소드 제목", "에피소드 본문"])
ws.append([
"writer@example.com", "홍길동의 자서전", "첫 직장",
"이메일 writer@example.com으로 연락했다. " + "인생을 기록했다. " * 20,
])
wb.save(path)
records = load_human_from_xlsx(
path,
text_column="에피소드 본문",
book_column="자서전 제목",
id_column="id",
sheet=None,
min_chars=100,
group_column="id",
anonymization_salt="test-salt",
provenance="combooks_confirmed_human",
)
assert len(records) == 1
record = records[0]
assert record.source_group.startswith("author:")
assert record.book == ""
assert "writer@example.com" not in record.text
assert record.meta["human_verified"] is True
assert record.meta["ai_assistance"] is False