feat: 수령 자서전 데이터 파이프라인과 맞춤 요약 추가
This commit is contained in:
parent
5386222f1b
commit
946e9e9474
@ -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=
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 \
|
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 학습
|
||||||
|
|
||||||
|
|||||||
@ -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 **실제 선호학습 수행** — 사람 선호 라벨 필요
|
||||||
|
|||||||
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 | 실데이터 튜닝 |
|
| 고도화 요약 모델 (문맥 기반 문장 추출, 키워드·문장 관계 분석) | ✅ `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%** — 자체 제작 표절/비표절 글 필요. 정밀도 지표이므로
|
||||||
|
|||||||
@ -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()]
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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() # 중간에 죽어도 여기까지는 남는다
|
||||||
|
|||||||
@ -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-...
|
||||||
|
|||||||
@ -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 좌표만 저장했습니다. "
|
||||||
|
|||||||
@ -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 = "이것은 저자의 실제 원고 본문이다"
|
||||||
|
|||||||
@ -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("한 문장만 있다.")
|
||||||
|
|||||||
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