feat: 수령 자서전 데이터 파이프라인과 맞춤 요약 추가
This commit is contained in:
parent
5386222f1b
commit
946e9e9474
@ -75,3 +75,6 @@ LSH_TOP_K=50
|
||||
# PDF VII-4 자서전 특화 모드 (공통 표현 제거 + NER 마스킹)
|
||||
AUTOBIOGRAPHY_MODE=true
|
||||
ENABLE_ENTITY_MASKING=true
|
||||
# 수령 원고의 작성자/문서 식별자를 가명화하는 HMAC salt.
|
||||
# 운영에서는 긴 무작위 값을 사용하고 저장소 밖으로 공유하지 말 것.
|
||||
DATA_ANONYMIZATION_SALT=
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
38
app/engine/training_data.py
Normal file
38
app/engine/training_data.py
Normal 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
|
||||
@ -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 학습
|
||||
|
||||
|
||||
@ -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 **실제 선호학습 수행** — 사람 선호 라벨 필요
|
||||
|
||||
59
docs/RECEIVED_DATA_PIPELINE.md
Normal file
59
docs/RECEIVED_DATA_PIPELINE.md
Normal 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 대조문이 아니라 별도의 실제 표절/비표절 정답쌍으로 평가한다.
|
||||
@ -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%** — 자체 제작 표절/비표절 글 필요. 정밀도 지표이므로
|
||||
|
||||
@ -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()]
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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() # 중간에 죽어도 여기까지는 남는다
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"is_plagiarism": true, "original_excerpt": "...", "derived_text": "..."}
|
||||
|
||||
자체 평가 데이터셋(계획서 성능지표 #4) 용도. 운영 모델 학습 시
|
||||
이 데이터와 컴북스 보유 30,000 건 원천 자료를 결합하여 정밀도 95% 달성.
|
||||
이 데이터와 컴북스 보유 원천 자료를 결합하여 계획서 목표 정밀도 97% 달성.
|
||||
|
||||
사용:
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
@ -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 좌표만 저장했습니다. "
|
||||
|
||||
@ -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 = "이것은 저자의 실제 원고 본문이다"
|
||||
|
||||
@ -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("한 문장만 있다.")
|
||||
|
||||
68
tests/test_training_data.py
Normal file
68
tests/test_training_data.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user