Add PoC #1 base-corpus preprocessing pipeline
Consolidates the AI-Hub "한국어 SNS 멀티턴 대화" TL/VL zip parts into clean train/val JSONL (142,575 dialogues), with a QA cross-check against the raw TS/VS source. Script and docs only -- the dataset itself stays out of git per .gitignore, both for size and because AI-Hub's terms restrict redistribution.
This commit is contained in:
parent
1271531a14
commit
699ba68f49
|
|
@ -0,0 +1,5 @@
|
|||
# PoC data — large, and AI-Hub source data may not be redistributed.
|
||||
# Keep scripts in git; keep the actual datasets out.
|
||||
poc/tone-corpus/data/
|
||||
*.jsonl
|
||||
*.zip
|
||||
|
|
@ -120,6 +120,9 @@
|
|||
- [x] §4의 기술 PoC 중 1번(말투 학습)과 3번(사칭/신뢰 수용성) 실행 계획서 작성 — `poc-plan.md`
|
||||
- [x] PoC 실행 준비물(모집 문구·동의 안내·역할극 스크립트·인터뷰 질문지) 초안 작성 — `poc-materials.md`
|
||||
(문서 준비만 완료. 실제 참가자 모집·데이터 수집·인터뷰 실행은 사람이 직접 해야 하는 단계 — 아직 미착수)
|
||||
- [x] PoC #1 기반 코퍼스 확보 및 전처리 파이프라인 — AI-Hub "한국어 SNS 멀티턴 대화" 14.2만 건
|
||||
확보, `poc/tone-corpus/prepare_dataset.py`로 학습/검증 JSONL 정제 완료 (개인화 검증 자체는
|
||||
아직 별개로 필요 — `poc-plan.md` "필요한 것" 참고)
|
||||
- [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정
|
||||
- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
|
||||
4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포).
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@
|
|||
- 응답 초안 생성기 (이 세션에서는 프로토타입 수준 — 실제 온디바이스 모델이 아니어도, LLM 프롬프트에
|
||||
말투 특징을 주입하는 방식으로 초기 신호를 얻을 수 있음. 배터리/성능 검증은 이 PoC의 범위가 아님)
|
||||
- 블라인드 평가지 (설문 형태, 5점 척도 + 자유 코멘트)
|
||||
- **기반 코퍼스**: AI-Hub "한국어 SNS 멀티턴 대화" 데이터셋(14만여 건)을 확보해 개인화 전
|
||||
일반 톤 생성 모델을 먼저 학습/평가할 수 있게 정리함 — [`poc/tone-corpus/`](../poc/tone-corpus/)
|
||||
참고. 이건 개인화 검증(위 방법론)을 대체하지 않는다 — 익명 화자쌍의 일반 대화라 "내 말투 같다"는
|
||||
질문에는 답을 못 준다. 개인화 검증은 여전히 실제 참가자의 대화 샘플이 필요하다.
|
||||
|
||||
## PoC #3 — 사칭/신뢰 수용성 (분신 뱃지·거부권 UX)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
| 베타 참가자 확보 어려움 | 검증 지연 | 소규모 지인 네트워크 초대 기반 클로즈드 베타로 시작 | 계획 단계 |
|
||||
| (v2 대비) 카카오톡/인스타 알림 파싱이 앱 업데이트로 깨짐 | OS 레이어 확장 시 유지보수 부담 | v1 범위에서 제외, v2 착수 시 안정적 API 채널(문자·이메일) 우선 | v1 범위 밖 — 그대로 유지 |
|
||||
| (v2 대비) iOS 플랫폼 정책 제약 | OS 레이어 iOS 확장 어려움 | 안드로이드 우선 검증 후 iOS는 자체 앱 전환 유도 | v1 범위 밖 — 그대로 유지 |
|
||||
| PoC #1 기반 코퍼스(AI-Hub) 재배포/보관 리스크 | 이용약관 위반, 데이터 유실 | git에 커밋 금지(`.gitignore` 반영), 원본·가공본 모두 세션 로컬에만 두고 영구 저장소로는 별도 이동 | 설계 반영 — `poc/tone-corpus/README.md` |
|
||||
|
||||
## 우선순위
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
# PoC #1 기반 코퍼스 — AI-Hub 한국어 SNS 멀티턴 대화
|
||||
|
||||
`docs/poc-plan.md`의 PoC #1(온디바이스 말투 학습)에서, 개인화 레이어를 얹기 전 **기반 톤 생성
|
||||
모델**을 학습/평가할 코퍼스로 AI-Hub "한국어 SNS 멀티턴 대화" 데이터셋을 사용한다. 이 데이터는
|
||||
특정 개인의 말투가 아니라 익명 화자쌍의 일반 대화이므로, PoC #1의 "내 말투 같다" 개인화 검증
|
||||
자체를 대체하지는 않는다 — 개인화 검증은 여전히 실제 참가자의 대화 샘플이 필요하다
|
||||
(`docs/poc-materials.md` §1 참고).
|
||||
|
||||
## 사용법
|
||||
|
||||
```bash
|
||||
python3 prepare_dataset.py --input <원본 zip이 있는 디렉토리> --output <출력 디렉토리> [--qa]
|
||||
```
|
||||
|
||||
- `--input`: AI-Hub에서 내려받은 `TS_*.zip`/`TL_*.zip`(학습)·`VS_*.zip`/`VL_*.zip`(검증) 원본 파일이
|
||||
있는 디렉토리. 파일명에 `VL_`/`TL_`이 포함된 zip(라벨링 JSON)만 읽는다 — 여기에 발화 텍스트와
|
||||
화행(speech_act)·슬롯 라벨이 모두 있어서 `VS_`/`TS_`(원천 CSV)는 출력에 필요 없다.
|
||||
- `--output`: `train.jsonl`, `val.jsonl`, `stats.json`을 쓸 디렉토리.
|
||||
- `--qa`: 원천 CSV의 대화ID와 라벨 JSON의 대화ID를 교차검증해서 커버리지 차이를 출력.
|
||||
|
||||
## 실행 결과 (2026-07-29, 이 세션에서 실제로 돌려본 값)
|
||||
|
||||
- 총 142,575건 (학습 122,951 / 검증 19,624), 평균 발화 16.5턴/대화
|
||||
- 주제 9종, 정치(3,242건)·경제및사회(4,257건)가 다른 주제(1.7만~2.2만건)보다 훨씬 적음 — 주제별
|
||||
균형이 필요하면 샘플링 시 감안할 것
|
||||
- 화자 연령/성별 필드 노이즈 81건은 정제 과정에서 `null`로 치환 (버리지 않고 필드만 비움)
|
||||
- **QA 결과**: 원천(CSV) 쪽에는 라벨링된 142,575건 외에 **34,030건이 더 있음** (라벨 없이 텍스트만).
|
||||
화행/슬롯 라벨이 필요 없는 순수 언어모델링 목적이라면 이 34,030건도 추가로 끌어올 수 있다 —
|
||||
다만 이번 스크립트는 라벨 JSON만 다루므로 추가 파서가 필요함 (아직 안 만듦)
|
||||
|
||||
## 반드시 지킬 것
|
||||
|
||||
- **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상
|
||||
제3자 재배포가 제한되고, 용량도 수백MB~1GB라 저장소에 맞지 않는다. 저장소 루트의 `.gitignore`에
|
||||
`poc/tone-corpus/data/`가 등록되어 있으니 원본·출력은 그 아래에 두고 작업할 것.
|
||||
- 이 저장소는 기획 문서 전용이라, 이 스크립트도 "PoC 도구"로만 취급한다 — 여기서 앱 코드/프레임워크로
|
||||
확장하지 않는다 (`AGENTS.md` 참고).
|
||||
- 실제 모델 학습에 쓰기 전에, 이 데이터가 세션이 아닌 영구 저장소(본인 로컬/스토리지)로 옮겨졌는지
|
||||
확인할 것 — 클라우드 세션은 종료되면 임시 파일이 사라진다.
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Consolidate the AI-Hub "한국어 SNS 멀티턴 대화" TS/TL/VS/VL zip parts into
|
||||
clean train/val JSONL for PoC #1 (base tone-generation corpus).
|
||||
|
||||
Usage:
|
||||
python3 prepare_dataset.py --input <dir with the *.zip parts> --output <output dir>
|
||||
|
||||
Input zips are matched by filename substring: files containing "VL_" or "TL_"
|
||||
are the labeled JSON splits (VL=validation, TL=train) and are the only ones
|
||||
consumed for the JSONL output -- they already carry the full utterance text
|
||||
plus speech_act/slot labels, so the raw CSV (VS_/TS_) is redundant for this
|
||||
purpose. VS_/TS_ zips are only used for the optional --qa cross-check.
|
||||
|
||||
Does NOT commit or move the source zips anywhere -- run this against a local
|
||||
copy of the AI-Hub download, and keep both the source zips and this script's
|
||||
output out of git (see poc/tone-corpus/README.md).
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import glob
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
|
||||
VALID_SEX = {"남자", "여자"}
|
||||
|
||||
|
||||
def is_valid_age(value):
|
||||
# raw field is a bare decade number as a string, e.g. "20", "30" -- not "20대".
|
||||
return bool(value) and value.isdigit() and 10 <= int(value) <= 90 and int(value) % 10 == 0
|
||||
|
||||
|
||||
def clean_speaker_field(value, kind):
|
||||
if not value:
|
||||
return None
|
||||
if kind == "sex":
|
||||
return value if value in VALID_SEX else None
|
||||
return value if is_valid_age(value) else None
|
||||
|
||||
|
||||
def iter_dialogues(zip_paths):
|
||||
for path in zip_paths:
|
||||
split = "val" if "VL_" in os.path.basename(path) else "train"
|
||||
with zipfile.ZipFile(path) as z:
|
||||
for name in z.namelist():
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
with z.open(name) as f:
|
||||
yield split, json.load(f)
|
||||
|
||||
|
||||
def normalize(dialogue):
|
||||
info = dialogue.get("info", {})
|
||||
speaker = info.get("speaker", {})
|
||||
speakers = {}
|
||||
for letter in ("A", "B", "C"):
|
||||
sid = speaker.get(f"speaker{letter}Id")
|
||||
if not sid:
|
||||
continue
|
||||
speakers[letter] = {
|
||||
"id": sid,
|
||||
"sex": clean_speaker_field(speaker.get(f"speaker{letter}Sex"), "sex"),
|
||||
"age": clean_speaker_field(speaker.get(f"speaker{letter}Age"), "age"),
|
||||
}
|
||||
utterances = [
|
||||
{
|
||||
"speaker": u.get("speaker", "").replace("speaker", ""),
|
||||
"text": u.get("text", ""),
|
||||
"speech_act": u.get("speech_act"),
|
||||
"slot": u.get("slot") or [],
|
||||
}
|
||||
for u in dialogue.get("utterances", [])
|
||||
]
|
||||
return {
|
||||
"id": info.get("id"),
|
||||
"topic": info.get("topic"),
|
||||
"keyword": info.get("keyword"),
|
||||
"speakers": speakers,
|
||||
"turns": len(utterances),
|
||||
"utterances": utterances,
|
||||
}
|
||||
|
||||
|
||||
def run_qa(input_dir, seen_ids):
|
||||
csv_paths = sorted(
|
||||
glob.glob(os.path.join(input_dir, "*VS_*.zip"))
|
||||
+ glob.glob(os.path.join(input_dir, "*TS_*.zip"))
|
||||
)
|
||||
csv_ids = set()
|
||||
for path in csv_paths:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
for name in z.namelist():
|
||||
if not name.endswith(".csv"):
|
||||
continue
|
||||
with z.open(name) as f:
|
||||
text = io.TextIOWrapper(f, encoding="utf-8-sig")
|
||||
for row in csv.DictReader(text):
|
||||
did = row.get("대화ID")
|
||||
if did:
|
||||
csv_ids.add(did)
|
||||
only_in_json = seen_ids - csv_ids
|
||||
only_in_csv = csv_ids - seen_ids
|
||||
print(f"[QA] 원천(CSV) 대화ID 수: {len(csv_ids)}")
|
||||
print(f"[QA] 라벨(JSON)에만 있음: {len(only_in_json)}")
|
||||
print(f"[QA] 원천(CSV)에만 있음: {len(only_in_csv)}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--input", required=True, help="원본 zip 파일들이 있는 디렉토리")
|
||||
ap.add_argument("--output", required=True, help="train.jsonl/val.jsonl을 쓸 디렉토리")
|
||||
ap.add_argument("--qa", action="store_true", help="VS/TS 원천 CSV와 대화ID 교차검증")
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
zip_paths = sorted(
|
||||
glob.glob(os.path.join(args.input, "*VL_*.zip"))
|
||||
+ glob.glob(os.path.join(args.input, "*TL_*.zip"))
|
||||
)
|
||||
if not zip_paths:
|
||||
raise SystemExit(f"VL_/TL_ zip을 {args.input}에서 찾지 못했습니다.")
|
||||
|
||||
writers = {
|
||||
"train": open(os.path.join(args.output, "train.jsonl"), "w", encoding="utf-8"),
|
||||
"val": open(os.path.join(args.output, "val.jsonl"), "w", encoding="utf-8"),
|
||||
}
|
||||
topic_counts = Counter()
|
||||
split_counts = Counter()
|
||||
dropped_age = 0
|
||||
dropped_sex = 0
|
||||
seen_ids = set()
|
||||
|
||||
try:
|
||||
for split, raw in iter_dialogues(zip_paths):
|
||||
record = normalize(raw)
|
||||
seen_ids.add(record["id"])
|
||||
split_counts[split] += 1
|
||||
topic_counts[record["topic"]] += 1
|
||||
for s in record["speakers"].values():
|
||||
if s["age"] is None:
|
||||
dropped_age += 1
|
||||
if s["sex"] is None:
|
||||
dropped_sex += 1
|
||||
writers[split].write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
finally:
|
||||
for w in writers.values():
|
||||
w.close()
|
||||
|
||||
stats = {
|
||||
"total_dialogues": sum(split_counts.values()),
|
||||
"split_counts": dict(split_counts),
|
||||
"topic_counts": dict(topic_counts.most_common()),
|
||||
"speaker_fields_dropped_as_noise": {"age": dropped_age, "sex": dropped_sex},
|
||||
}
|
||||
with open(os.path.join(args.output, "stats.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(stats, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"완료: {stats['total_dialogues']}건 -> {args.output}/{{train,val}}.jsonl")
|
||||
print(f"분할: {stats['split_counts']}")
|
||||
print(f"이상치로 제외된 speaker 필드 - age: {dropped_age}, sex: {dropped_sex}")
|
||||
|
||||
if args.qa:
|
||||
run_qa(args.input, seen_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue