diff --git a/.env.example b/.env.example index ed29150..8050b42 100644 --- a/.env.example +++ b/.env.example @@ -75,3 +75,6 @@ LSH_TOP_K=50 # PDF VII-4 자서전 특화 모드 (공통 표현 제거 + NER 마스킹) AUTOBIOGRAPHY_MODE=true ENABLE_ENTITY_MASKING=true +# 수령 원고의 작성자/문서 식별자를 가명화하는 HMAC salt. +# 운영에서는 긴 무작위 값을 사용하고 저장소 밖으로 공유하지 말 것. +DATA_ANONYMIZATION_SALT= diff --git a/app/api/routes.py b/app/api/routes.py index f718f12..90e8f0d 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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, ) diff --git a/app/api/schemas.py b/app/api/schemas.py index 1a8cc0b..923d0e9 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -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 diff --git a/app/engine/summarizer.py b/app/engine/summarizer.py index 1f34a39..3834fba 100644 --- a/app/engine/summarizer.py +++ b/app/engine/summarizer.py @@ -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 diff --git a/app/engine/training_data.py b/app/engine/training_data.py new file mode 100644 index 0000000..38a033f --- /dev/null +++ b/app/engine/training_data.py @@ -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"(? 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 diff --git a/docs/AI_DETECTION.md b/docs/AI_DETECTION.md index 96df8f2..c5be115 100644 --- a/docs/AI_DETECTION.md +++ b/docs/AI_DETECTION.md @@ -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:` - AI: `ai:` (또는 `--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 학습 diff --git a/docs/PHASE2_PROGRESS.md b/docs/PHASE2_PROGRESS.md index 5d6a2da..96c251e 100644 --- a/docs/PHASE2_PROGRESS.md +++ b/docs/PHASE2_PROGRESS.md @@ -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 **실제 선호학습 수행** — 사람 선호 라벨 필요 diff --git a/docs/RECEIVED_DATA_PIPELINE.md b/docs/RECEIVED_DATA_PIPELINE.md new file mode 100644 index 0000000..622ac7d --- /dev/null +++ b/docs/RECEIVED_DATA_PIPELINE.md @@ -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 대조문이 아니라 별도의 실제 표절/비표절 정답쌍으로 평가한다. diff --git a/docs/SCOPE_AND_METRICS.md b/docs/SCOPE_AND_METRICS.md index 6052970..c974b47 100644 --- a/docs/SCOPE_AND_METRICS.md +++ b/docs/SCOPE_AND_METRICS.md @@ -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%** — 자체 제작 표절/비표절 글 필요. 정밀도 지표이므로 diff --git a/scripts/build_ai_training_dataset.py b/scripts/build_ai_training_dataset.py index 24dcace..2a8128e 100644 --- a/scripts/build_ai_training_dataset.py +++ b/scripts/build_ai_training_dataset.py @@ -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()] diff --git a/scripts/evaluate_pairs.py b/scripts/evaluate_pairs.py index 5b4723b..f9ea924 100644 --- a/scripts/evaluate_pairs.py +++ b/scripts/evaluate_pairs.py @@ -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}") diff --git a/scripts/generate_ai_samples.py b/scripts/generate_ai_samples.py index b0ba9ac..c5191b0 100644 --- a/scripts/generate_ai_samples.py +++ b/scripts/generate_ai_samples.py @@ -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() # 중간에 죽어도 여기까지는 남는다 diff --git a/scripts/generate_plagiarism_pairs.py b/scripts/generate_plagiarism_pairs.py index 0bf4b77..d4ed037 100644 --- a/scripts/generate_plagiarism_pairs.py +++ b/scripts/generate_plagiarism_pairs.py @@ -12,7 +12,7 @@ "is_plagiarism": true, "original_excerpt": "...", "derived_text": "..."} 자체 평가 데이터셋(계획서 성능지표 #4) 용도. 운영 모델 학습 시 -이 데이터와 컴북스 보유 30,000 건 원천 자료를 결합하여 정밀도 95% 달성. +이 데이터와 컴북스 보유 원천 자료를 결합하여 계획서 목표 정밀도 97% 달성. 사용: export OPENAI_API_KEY=sk-... diff --git a/scripts/ingest_o2o_xlsx.py b/scripts/ingest_o2o_xlsx.py index 0889a4b..c0d88ca 100644 --- a/scripts/ingest_o2o_xlsx.py +++ b/scripts/ingest_o2o_xlsx.py @@ -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 좌표만 저장했습니다. " diff --git a/tests/test_generate_ai_samples.py b/tests/test_generate_ai_samples.py index be1b020..74fd8c8 100644 --- a/tests/test_generate_ai_samples.py +++ b/tests/test_generate_ai_samples.py @@ -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 = "이것은 저자의 실제 원고 본문이다" diff --git a/tests/test_summarizer.py b/tests/test_summarizer.py index f210d4d..0eeed10 100644 --- a/tests/test_summarizer.py +++ b/tests/test_summarizer.py @@ -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("한 문장만 있다.") diff --git a/tests/test_training_data.py b/tests/test_training_data.py new file mode 100644 index 0000000..ca1029c --- /dev/null +++ b/tests/test_training_data.py @@ -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