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 마스킹) # PDF VII-4 자서전 특화 모드 (공통 표현 제거 + NER 마스킹)
AUTOBIOGRAPHY_MODE=true AUTOBIOGRAPHY_MODE=true
ENABLE_ENTITY_MASKING=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, ratio=req.ratio,
max_sentences=req.max_sentences, max_sentences=req.max_sentences,
use_abstractive=req.use_abstractive, use_abstractive=req.use_abstractive,
detail=req.detail,
emphasis=req.emphasis,
) )
return SummaryResponse( return SummaryResponse(
extractive=result.extractive, extractive=result.extractive,
@ -194,6 +196,8 @@ async def summarize(req: SummaryRequest) -> SummaryResponse:
selected_indices=result.selected_indices, selected_indices=result.selected_indices,
num_sentences_in=result.num_sentences_in, num_sentences_in=result.num_sentences_in,
num_sentences_out=result.num_sentences_out, num_sentences_out=result.num_sentences_out,
detail=req.detail,
emphasis=req.emphasis,
engine_version=settings.engine_version, engine_version=settings.engine_version,
) )

View File

@ -366,7 +366,13 @@ class BatchStatusResponse(BaseModel):
class SummaryRequest(BaseModel): class SummaryRequest(BaseModel):
text: str = Field(..., min_length=1) 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="최대 문장 수 (옵션)") max_sentences: int | None = Field(default=None, ge=1, description="최대 문장 수 (옵션)")
use_abstractive: bool = Field(default=True, description="추상적(LLM) 단계 사용 — 키 없으면 추출적 폴백") use_abstractive: bool = Field(default=True, description="추상적(LLM) 단계 사용 — 키 없으면 추출적 폴백")
@ -379,6 +385,8 @@ class SummaryResponse(BaseModel):
selected_indices: list[int] = Field(default_factory=list) selected_indices: list[int] = Field(default_factory=list)
num_sentences_in: int num_sentences_in: int
num_sentences_out: int num_sentences_out: int
detail: Literal["brief", "standard", "detailed"] = "standard"
emphasis: list[str] = Field(default_factory=list)
engine_version: str engine_version: str

View File

@ -12,7 +12,8 @@
자체 sLLM(고려대 2차저작 생성지원 모델) 으로 교체할 자리. 자체 sLLM(고려대 2차저작 생성지원 모델) 으로 교체할 자리.
통합 요약 (hybrid) 핵심 문장을 입력으로 (계획서 통합 요약 시스템). 통합 요약 (hybrid) 핵심 문장을 입력으로 (계획서 통합 요약 시스템).
사용자 맞춤형 옵션(요약 길이/비율) 계획서 2단계 '사용자 맞춤형 요약' 반영. 사용자 맞춤형 옵션(요약 상세도/강조 주제) 계획서 2단계
'사용자 맞춤형 요약' 반영.
평가지표 No.7(N-gram ROUGE) scripts/eval_rouge.py 측정한다 (정답셋 들어오면). 평가지표 No.7(N-gram ROUGE) scripts/eval_rouge.py 측정한다 (정답셋 들어오면).
""" """
@ -29,6 +30,8 @@ from app.engine.structural import extract_lemmas
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DETAIL_RATIOS = {"brief": 0.2, "standard": 0.3, "detailed": 0.45}
# 문장 분할 — 종결부호 기준 (한국어 '다./요./까?/!' + 줄바꿈) # 문장 분할 — 종결부호 기준 (한국어 '다./요./까?/!' + 줄바꿈)
_SENT_SPLIT = re.compile(r"(?<=[.!?。…])\s+|\n+") _SENT_SPLIT = re.compile(r"(?<=[.!?。…])\s+|\n+")
@ -92,17 +95,29 @@ class SummaryResult:
mode: str # "extractive" | "hybrid" mode: str # "extractive" | "hybrid"
num_sentences_in: int num_sentences_in: int
num_sentences_out: 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/외부호출 불필요.""" """비지도 추출적 요약 — 정답셋/LLM/외부호출 불필요."""
sentences = split_sentences(text) sentences = split_sentences(text)
n = len(sentences) n = len(sentences)
clean_emphasis = [term.strip() for term in (emphasis or []) if term.strip()]
if n == 0: if n == 0:
return SummaryResult("", None, "", [], "extractive", 0, 0) return SummaryResult("", None, "", [], "extractive", 0, 0, detail, clean_emphasis)
if n <= 2: if n <= 2:
joined = " ".join(sentences) 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)) k = max(1, math.ceil(n * ratio))
if max_sentences is not None: 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] vectors = [_lemma_vector(s) for s in sentences]
scores = _textrank_scores(vectors) 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개 문장 선택 → 원문 등장 순서로 재정렬 (가독성) # 상위 k개 문장 선택 → 원문 등장 순서로 재정렬 (가독성)
top = sorted(range(n), key=lambda i: scores[i], reverse=True)[:k] top = sorted(range(n), key=lambda i: scores[i], reverse=True)[:k]
top_sorted = sorted(top) top_sorted = sorted(top)
@ -123,6 +146,8 @@ def extractive_summary(text: str, ratio: float = 0.3, max_sentences: int | None
mode="extractive", mode="extractive",
num_sentences_in=n, num_sentences_in=n,
num_sentences_out=len(top_sorted), num_sentences_out=len(top_sorted),
detail=detail,
emphasis=clean_emphasis,
) )
@ -147,16 +172,25 @@ class Summarizer:
def summarize( def summarize(
self, self,
text: str, text: str,
ratio: float = 0.3, ratio: float | None = None,
max_sentences: int | None = None, max_sentences: int | None = None,
use_abstractive: bool = True, use_abstractive: bool = True,
detail: str = "standard",
emphasis: list[str] | None = None,
) -> SummaryResult: ) -> 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: if not base.extractive:
return base return base
if use_abstractive and self.settings.use_llm_extractor and self.settings.has_openai: 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: if abstractive:
return SummaryResult( return SummaryResult(
extractive=base.extractive, extractive=base.extractive,
@ -166,10 +200,14 @@ class Summarizer:
mode="hybrid", mode="hybrid",
num_sentences_in=base.num_sentences_in, num_sentences_in=base.num_sentences_in,
num_sentences_out=base.num_sentences_out, num_sentences_out=base.num_sentences_out,
detail=detail,
emphasis=base.emphasis,
) )
return base return base
def _abstractive(self, extractive_text: str) -> str | None: def _abstractive(
self, extractive_text: str, detail: str, emphasis: list[str]
) -> str | None:
try: try:
from openai import OpenAI from openai import OpenAI
client = OpenAI(api_key=self.settings.openai_api_key) client = OpenAI(api_key=self.settings.openai_api_key)
@ -178,7 +216,15 @@ class Summarizer:
temperature=0.2, temperature=0.2,
messages=[ messages=[
{"role": "system", "content": "You are a concise Korean summarizer. Never hallucinate."}, {"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 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 \ python scripts/build_ai_training_dataset.py \
--xlsx episodes.xlsx \ --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-jsonl data/training/ai_samples.jsonl \
--ai-group-field source_group \
--out data/training/ai_dataset.jsonl --out data/training/ai_dataset.jsonl
``` ```
@ -214,7 +216,7 @@ python scripts/build_ai_training_dataset.py \
분할 단위는 개별 텍스트가 아니라 **`source_group`** 입니다. 분할 단위는 개별 텍스트가 아니라 **`source_group`** 입니다.
- human: `book:<도서명>` (도서 정보가 없으면 `sheet:<시트명>`) - human: 수령 데이터는 `id`를 HMAC 가명화한 `author:<hash>`
- AI: `ai:<generator>` (또는 `--ai-group-field` 로 지정) - AI: `ai:<generator>` (또는 `--ai-group-field` 로 지정)
같은 책의 에피소드가 train 과 test 에 동시에 들어가면 모델이 문체가 아니라 **그 같은 책의 에피소드가 train 과 test 에 동시에 들어가면 모델이 문체가 아니라 **그
@ -223,6 +225,8 @@ python scripts/build_ai_training_dataset.py \
분할은 해시 기반이라 결정적이며(`--seed`), 라벨별로 비율을 맞춥니다. 정규화 후 분할은 해시 기반이라 결정적이며(`--seed`), 라벨별로 비율을 맞춥니다. 정규화 후
완전 중복 텍스트는 제거합니다(`recovered.csv` 에서 9,633행 중복이 나온 전례). 완전 중복 텍스트는 제거합니다(`recovered.csv` 에서 9,633행 중복이 나온 전례).
원문과 같은 소재 그룹에서 만든 AI 대조문은 `source_group`을 같게 넣어
원문-생성물 페어가 분할을 가로지르지 않게 합니다.
### 5.3 학습 ### 5.3 학습

View File

@ -1,7 +1,7 @@
# 오투오 2단계 진행 현황 (데이터 수령 전 선행 작업 완료분) # 오투오 2단계 진행 현황 (2026-08-20 데이터 수령 반영)
> 데이터 수령 전까지 가능한 작업을 선행 구현하여, 컴북스 데이터가 들어오면 > 컴북스가 제공한 실제 사람 작성 자서전 에피소드를 익명 human 코퍼스로
> 즉시 학습·검증에 착수할 수 있도록 준비한 결과 요약. > 적재하는 기능과 AI 대조문 생성·그룹 분할 파이프라인을 구현했다.
## 1. 한눈에 보기 ## 1. 한눈에 보기
@ -15,6 +15,8 @@
| 요약 ROUGE 65(No.7) | ✅ ROUGE 평가 모듈·CLI | 요약 정답셋으로 정식 측정 | | 요약 ROUGE 65(No.7) | ✅ ROUGE 평가 모듈·CLI | 요약 정답셋으로 정식 측정 |
| SW 저작권 등록 | ✅ 등록 준비 문서 | 서류 제출 | | SW 저작권 등록 | ✅ 등록 준비 문서 | 서류 제출 |
| 2단계 통합 | ✅ 통합 인터페이스 명세 | 바이칼/컴북스 E2E | | 2단계 통합 | ✅ 통합 인터페이스 명세 | 바이칼/컴북스 E2E |
| 도메인 데이터 적재 | ✅ 533문서·6,331에피소드 드라이런 | 운영 코퍼스 반영 |
| 사용자 맞춤형 요약 | ✅ 상세도·강조 옵션 | 요약 정답셋 튜닝 |
## 2. 구현 산출물 ## 2. 구현 산출물
@ -39,7 +41,7 @@
- `docs/INTEGRATION_INTERFACE.md` — 2단계 통합 인터페이스 - `docs/INTEGRATION_INTERFACE.md` — 2단계 통합 인터페이스
- `docs/SW_COPYRIGHT_REGISTRATION.md` — SW 저작권 등록 준비 - `docs/SW_COPYRIGHT_REGISTRATION.md` — SW 저작권 등록 준비
## 3. 데이터에 묶여 남는 것 (수령 후 착수) ## 3. 추가 데이터·라벨에 묶여 남는 것
- 표절 **정밀도 97% 최종 달성** — 자서전 도메인 표절 샘플 필요 - 표절 **정밀도 97% 최종 달성** — 자서전 도메인 표절 샘플 필요
- 요약 **ROUGE 65 학습·최종 평가** — 요약 정답셋 필요 - 요약 **ROUGE 65 학습·최종 평가** — 요약 정답셋 필요
- HF **실제 선호학습 수행** — 사람 선호 라벨 필요 - 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 | 실데이터 튜닝 | | 고도화 요약 모델 (문맥 기반 문장 추출, 키워드·문장 관계 분석) | ✅ `summarizer.py` TextRank | 실데이터 튜닝 |
| 통합 요약 시스템 (추출적+추상적 하이브리드) | ✅ 구현 | LLM 키 연결 시 동작 | | 통합 요약 시스템 (추출적+추상적 하이브리드) | ✅ 구현 | LLM 키 연결 시 동작 |
| **사용자 맞춤형 요약 (길이·상세도·강조 내용 옵션)** | ⚠️ **부분** | **상세도·강조 내용 옵션 미구현** | | **사용자 맞춤형 요약 (길이·상세도·강조 내용 옵션)** | ✅ 구현 | 실데이터 튜닝 |
`SummaryRequest`에 있는 것은 `ratio`(길이), `max_sentences`, `use_abstractive`뿐이다. `SummaryRequest``detail`(`brief|standard|detailed`)과 `emphasis`를 추가했다.
계획서가 명시한 **상세도(detail)****강조 내용(emphasis)** 옵션이 없다. 지표에는 `ratio`를 직접 주면 상세도 기본 비율보다 우선하며, 강조 주제는 추출·추상 단계에 모두 반영한다.
안 잡히지만 성과물 명세에 적힌 기능이므로 개발이 필요하다.
### 나. 표절 검출 기술 고도화 ### 나. 표절 검출 기술 고도화
@ -114,10 +113,9 @@ future 임포트가 있는지 AST 로 검사한다. 3.14 에서도 회귀를 잡
## 6. 요약 — 남은 일 ## 6. 요약 — 남은 일
### 더 개발해야 하는 것 ### 더 개발해야 하는 것
1. **사용자 맞춤형 요약 옵션** — 상세도, 강조 내용 (계획서 명시, 현재 없음) 1. **Human Feedback Preference Optimization** — 현재 골격만. 선호 라벨 + GPU 필요
2. **Human Feedback Preference Optimization** — 현재 골격만. 선호 라벨 + GPU 필요 2. **요약 정답셋 구축** — 수령 원문에 대한 다중 참조 요약 300건 라벨링 필요
3. **도메인별 요약 데이터셋 구축** — 컴북스 데이터 수령 후 3. **생활 수기집 본문 적재** — 현재 파일 목록만 수령, 원본 다운로드·OCR 필요
4. ~~python 3.9 호환성 정리~~**완료** (2026-08-19)
### 측정해야 하는 것 (전부 데이터 대기) ### 측정해야 하는 것 (전부 데이터 대기)
1. **No.4 표절 정밀도 97%** — 자체 제작 표절/비표절 글 필요. 정밀도 지표이므로 1. **No.4 표절 정밀도 97%** — 자체 제작 표절/비표절 글 필요. 정밀도 지표이므로

View File

@ -39,6 +39,7 @@ import csv
import hashlib import hashlib
import json import json
import logging import logging
import os
import re import re
import sys import sys
import unicodedata import unicodedata
@ -49,6 +50,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT)) 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger("build-ai-dataset") logger = logging.getLogger("build-ai-dataset")
@ -57,7 +60,7 @@ LABEL_AI = 1
#: 헤더 자동탐지 힌트 (부분일치, 소문자 비교). 실제 파일을 못 본 상태라 #: 헤더 자동탐지 힌트 (부분일치, 소문자 비교). 실제 파일을 못 본 상태라
#: 오탐 가능성이 있으니 --inspect 로 먼저 확인하고 --text-column 으로 고정할 것. #: 오탐 가능성이 있으니 --inspect 로 먼저 확인하고 --text-column 으로 고정할 것.
TEXT_HINTS = ("에피소드", "본문", "내용", "원고", "텍스트", "story", "text", "body", "content") TEXT_HINTS = ("본문", "에피소드", "내용", "원고", "텍스트", "story", "text", "body", "content")
BOOK_HINTS = ("도서", "", "서명", "제목", "book", "title", "작품") BOOK_HINTS = ("도서", "", "서명", "제목", "book", "title", "작품")
ID_HINTS = ("id", "번호", "no", "식별") ID_HINTS = ("id", "번호", "no", "식별")
AUTHOR_HINTS = ("저자", "작가", "author", "writer") AUTHOR_HINTS = ("저자", "작가", "author", "writer")
@ -178,6 +181,9 @@ def load_human_from_xlsx(
id_column: str | None, id_column: str | None,
sheet: str | None, sheet: str | None,
min_chars: int, min_chars: int,
group_column: str | None = None,
anonymization_salt: str | None = None,
provenance: str = "unknown",
) -> list[Record]: ) -> list[Record]:
wb = _load_workbook(path) wb = _load_workbook(path)
sheets = [sheet] if sheet else wb.sheetnames 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) bcol = book_column or _match_column(header, BOOK_HINTS)
icol = id_column or _match_column(header, ID_HINTS) icol = id_column or _match_column(header, ID_HINTS)
acol = _match_column(header, AUTHOR_HINTS) acol = _match_column(header, AUTHOR_HINTS)
gcol = group_column or acol or icol or bcol
ti = header.index(tcol) ti = header.index(tcol)
bi = header.index(bcol) if bcol in header else None bi = header.index(bcol) if bcol in header else None
ii = header.index(icol) if icol in header else None ii = header.index(icol) if icol in header else None
ai = header.index(acol) if acol 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( logger.info(
"[%s] text=%r book=%r id=%r author=%r", name, tcol, bcol, icol, acol "[%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): if ti >= len(row):
continue continue
text = normalize(row[ti] if row[ti] is not None else "") text = normalize(row[ti] if row[ti] is not None else "")
if anonymization_salt:
text = redact_direct_identifiers(text)
if len(text) < min_chars: if len(text) < min_chars:
continue continue
book = "" book = ""
if bi is not None and bi < len(row) and row[bi] is not None: if bi is not None and bi < len(row) and row[bi] is not None:
book = str(row[bi]).strip() book = str(row[bi]).strip()
# 책 정보가 없으면 시트명으로라도 묶는다. group 없는 분할은 금지. raw_group = ""
group = f"book:{book}" if book else f"sheet:{name}" if gi is not None and gi < len(row) and row[gi] is not None:
meta = {"sheet": name, "row": rownum, "source_file": path.name} 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: 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: 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( records.append(
Record( Record(
text=text, label=LABEL_HUMAN, origin="human", 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() wb.close()
@ -410,6 +444,12 @@ def main() -> int:
ap.add_argument("--text-column", default=None, help="본문 컬럼명 (미지정 시 자동탐지)") ap.add_argument("--text-column", default=None, help="본문 컬럼명 (미지정 시 자동탐지)")
ap.add_argument("--book-column", default=None, help="도서 컬럼명 (분할 그룹 기준)") ap.add_argument("--book-column", default=None, help="도서 컬럼명 (분할 그룹 기준)")
ap.add_argument("--id-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-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-csv", type=Path, action="append", default=[], help="AI 샘플 CSV (반복 가능)")
ap.add_argument("--ai-text-field", default="text", help="AI 파일의 본문 필드명") ap.add_argument("--ai-text-field", default="text", help="AI 파일의 본문 필드명")
@ -430,6 +470,15 @@ def main() -> int:
return 0 return 0
records: list[Record] = [] 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 args.xlsx:
if not args.xlsx.exists(): if not args.xlsx.exists():
logger.error("xlsx 없음: %s", args.xlsx) logger.error("xlsx 없음: %s", args.xlsx)
@ -437,6 +486,7 @@ def main() -> int:
records += load_human_from_xlsx( records += load_human_from_xlsx(
args.xlsx, args.text_column, args.book_column, args.xlsx, args.text_column, args.book_column,
args.id_column, args.sheet, args.min_chars, 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()] jsonl = [p for p in args.ai_jsonl if p.exists()]

View File

@ -81,7 +81,7 @@ def main() -> int:
print() print()
print("=" * 60) print("=" * 60)
print(f"전체 정밀도 (precision): {precision:.4f} (목표 0.95)") print(f"전체 정밀도 (precision): {precision:.4f} (계획서 목표 0.97)")
print(f"재현율 (recall): {recall:.4f}") print(f"재현율 (recall): {recall:.4f}")
print(f"F1: {f1:.4f}") print(f"F1: {f1:.4f}")
print(f"TP={tp} FP={fp} TN={tn} FN={fn}") 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") logger = logging.getLogger("gen-ai-samples")
from app.engine.ocr_normalize import normalize_ocr # noqa: E402 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 = ( META_COLUMNS = (
@ -70,7 +71,8 @@ META_COLUMNS = (
"gpt_character_keyword", "gpt_character_keyword",
"생애사건_category", "생애사건_category",
) )
TEXT_COLUMN_BLOCKLIST = ("에피소드", "본문", "내용", "원고", "text", "content") TEXT_COLUMN_BLOCKLIST = ("본문", "내용", "원고", "text", "content", "body")
TEXT_COLUMN_EXACT_BLOCKLIST = ("에피소드", "story")
SYSTEM_PROMPT = ( SYSTEM_PROMPT = (
"당신은 한국어 자서전 원고를 쓰는 작가다. 주어진 소재로 자서전의 한 " "당신은 한국어 자서전 원고를 쓰는 작가다. 주어진 소재로 자서전의 한 "
@ -99,8 +101,11 @@ def assert_no_body_columns(columns: list[str]) -> list[str]:
주석만으로는 다음 사람이 META_COLUMNS 본문 컬럼을 추가하는 것을 주석만으로는 다음 사람이 META_COLUMNS 본문 컬럼을 추가하는 것을
막는다. 줄이 원고를 외부 API 내보내는 경로가 된다. 막는다. 줄이 원고를 외부 API 내보내는 경로가 된다.
""" """
leaked = [c for c in columns leaked = [
if any(bad in c.lower() for bad in TEXT_COLUMN_BLOCKLIST)] 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: if leaked:
raise SystemExit( raise SystemExit(
f"본문으로 보이는 컬럼이 프롬프트 소재에 있습니다: {leaked}. " f"본문으로 보이는 컬럼이 프롬프트 소재에 있습니다: {leaked}. "
@ -158,6 +163,15 @@ def main() -> int:
ap.add_argument("--xlsx", type=Path, required=True, help="소재를 뽑을 에피소드 xlsx") ap.add_argument("--xlsx", type=Path, required=True, help="소재를 뽑을 에피소드 xlsx")
ap.add_argument("--text-column", default="에피소드", ap.add_argument("--text-column", default="에피소드",
help="길이 분포 계산에만 쓴다. 본문은 API 로 보내지 않는다.") 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=[], ap.add_argument("--model", action="append", default=[],
help="생성 모델 (반복 지정 = 혼합). 최소 2개 권장") help="생성 모델 (반복 지정 = 혼합). 최소 2개 권장")
ap.add_argument("--base-url", default=None, ap.add_argument("--base-url", default=None,
@ -179,13 +193,26 @@ def main() -> int:
lengths[len(lengths) // 2], low_chars, high_chars, len(lengths), 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: if not usable_meta:
raise SystemExit( raise SystemExit(
f"소재로 쓸 컬럼이 없습니다. 기대: {META_COLUMNS} / 실제: {list(frame.columns)}" f"소재로 쓸 컬럼이 없습니다. 기대: {META_COLUMNS} / 실제: {list(frame.columns)}"
) )
logger.info("프롬프트 소재 컬럼: %s", usable_meta) 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"] models = args.model or ["gpt-4o-mini"]
if len(models) == 1 and not args.dry_run: if len(models) == 1 and not args.dry_run:
logger.warning( logger.warning(
@ -229,11 +256,20 @@ def main() -> int:
if prompt_id in done: if prompt_id in done:
continue continue
meta = {c: str(frame[c].iloc[idx]) for c in usable_meta meta = {
if isinstance(frame[c].iloc[idx], str) and frame[c].iloc[idx].strip()} 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: if not meta:
continue 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) target = rng.choice(lengths)
style = STYLE_VARIANTS[_stable_int(prompt_id) % len(STYLE_VARIANTS)] style = STYLE_VARIANTS[_stable_int(prompt_id) % len(STYLE_VARIANTS)]
model = models[attempted % len(models)] model = models[attempted % len(models)]
@ -276,6 +312,9 @@ def main() -> int:
"target_chars": target, "target_chars": target,
"char_count": len(text), "char_count": len(text),
"style": style, "style": style,
"source_group": source_group,
"generation_type": "pure_ai",
"provenance": "ai_generated_controlled",
"meta": meta, "meta": meta,
}, ensure_ascii=False) + "\n") }, ensure_ascii=False) + "\n")
sink.flush() # 중간에 죽어도 여기까지는 남는다 sink.flush() # 중간에 죽어도 여기까지는 남는다

View File

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

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import sys import sys
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
@ -13,6 +14,11 @@ if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.engine.provenance import CorpusStore, DocumentRecord, SegmentRecord, stable_id 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: 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("--book-column", default="book_name")
p.add_argument("--text-column", default="에피소드") p.add_argument("--text-column", default="에피소드")
p.add_argument("--index-column", default="episode_index") 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("--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() return p.parse_args()
@ -43,7 +56,9 @@ def main() -> int:
rows = ws.iter_rows(values_only=True) rows = ws.iter_rows(values_only=True)
headers = [str(v).strip() if v is not None else "" for v in next(rows)] headers = [str(v).strip() if v is not None else "" for v in next(rows)]
positions = {name: i for i, name in enumerate(headers)} 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] missing = [name for name in required if name not in positions]
if missing: if missing:
print(f"필수 열 없음: {missing}; 실제 열={headers}", file=sys.stderr) print(f"필수 열 없음: {missing}; 실제 열={headers}", file=sys.stderr)
@ -55,24 +70,54 @@ def main() -> int:
book_counts: Counter[str] = Counter() book_counts: Counter[str] = Counter()
documents: dict[str, DocumentRecord] = {} documents: dict[str, DocumentRecord] = {}
segments: list[SegmentRecord] = [] 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() book = str(row[positions[args.book_column]] or "").strip()
text = str(row[positions[args.text_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: if not book or not text:
skipped += 1 skipped += 1
continue 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 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 source_path = str(row[positions[args.path_column]] or "").strip() or None
document_id = stable_id("doc", book)
documents[document_id] = DocumentRecord( documents[document_id] = DocumentRecord(
document_id=document_id, document_id=document_id,
title=book, title=display_title,
source_path=source_path, 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( segments.append(SegmentRecord(
segment_id=stable_id("seg", document_id, text), segment_id=stable_id("seg", document_id, text),
document_id=document_id, document_id=document_id,
@ -85,9 +130,13 @@ def main() -> int:
metadata={ metadata={
"provenance_quality": "episode_only", "provenance_quality": "episode_only",
"page_offset_available": False, "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()) store.upsert_documents(documents.values())
inserted, duplicates = store.add_segments(segments) inserted, duplicates = store.add_segments(segments)
@ -99,6 +148,8 @@ def main() -> int:
"duplicate_segments": duplicates, "duplicate_segments": duplicates,
"skipped_rows": skipped, "skipped_rows": skipped,
"source_books": len(book_counts), "source_books": len(book_counts),
"anonymized": args.anonymize,
"provenance": args.provenance,
"store": store.stats(), "store": store.stats(),
"location_warning": ( "location_warning": (
"수령 XLSX에는 원본 페이지/문단 offset이 없어 episode 좌표만 저장했습니다. " "수령 XLSX에는 원본 페이지/문단 offset이 없어 episode 좌표만 저장했습니다. "

View File

@ -35,6 +35,11 @@ class TestManuscriptNeverLeaves:
cols = ["gpt_keyword", "gpt_title"] cols = ["gpt_keyword", "gpt_title"]
assert gen.assert_no_body_columns(cols) == cols 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): def test_prompt_contains_only_given_meta(self):
"""프롬프트에 넣지 않은 문자열은 절대 나타나지 않는다.""" """프롬프트에 넣지 않은 문자열은 절대 나타나지 않는다."""
secret = "이것은 저자의 실제 원고 본문이다" secret = "이것은 저자의 실제 원고 본문이다"

View File

@ -52,6 +52,20 @@ def test_max_sentences_cap():
assert result.num_sentences_out <= 2 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(): def test_empty_and_short():
assert extractive_summary("").final == "" assert extractive_summary("").final == ""
one = extractive_summary("한 문장만 있다.") 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