diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 017a57d..d3f3590 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -123,6 +123,8 @@ - [x] PoC #1 기반 코퍼스 확보 및 전처리 파이프라인 — AI-Hub "한국어 SNS 멀티턴 대화" 14.2만 건 확보, `poc/tone-corpus/prepare_dataset.py`로 학습/검증 JSONL 정제 완료 (개인화 검증 자체는 아직 별개로 필요 — `poc-plan.md` "필요한 것" 참고) +- [x] 라벨 없는 원천 대화 34,030건 추가 확보 — `poc/tone-corpus/build_unlabeled_corpus.py`로 + `unlabeled.jsonl` 정리 (화행/슬롯 라벨 없음, 순수 언어모델링용) - [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 → LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제 대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능 diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md index 01de4b8..5f9b8d1 100644 --- a/poc/tone-corpus/README.md +++ b/poc/tone-corpus/README.md @@ -24,9 +24,13 @@ python3 prepare_dataset.py --input <원본 zip이 있는 디렉토리> --output - 주제 9종, 정치(3,242건)·경제및사회(4,257건)가 다른 주제(1.7만~2.2만건)보다 훨씬 적음 — 주제별 균형이 필요하면 샘플링 시 감안할 것 - 화자 연령/성별 필드 노이즈 81건은 정제 과정에서 `null`로 치환 (버리지 않고 필드만 비움) -- **QA 결과**: 원천(CSV) 쪽에는 라벨링된 142,575건 외에 **34,030건이 더 있음** (라벨 없이 텍스트만). - 화행/슬롯 라벨이 필요 없는 순수 언어모델링 목적이라면 이 34,030건도 추가로 끌어올 수 있다 — - 다만 이번 스크립트는 라벨 JSON만 다루므로 추가 파서가 필요함 (아직 안 만듦) +- **QA 결과**: 원천(CSV) 쪽에는 라벨링된 142,575건 외에 34,030건이 더 있었음 (라벨 없이 텍스트만). + `build_unlabeled_corpus.py`로 그 34,030건만 뽑아 `unlabeled.jsonl`(63MB)로 정리함 — 화행/슬롯 + 없이 화자·발화 텍스트만 있어서 순수 언어모델링(다음 문장 예측 등)용으로만 쓸 것 + +```bash +python3 build_unlabeled_corpus.py --input <원본 zip 디렉토리> --output <출력 디렉토리> +``` ## 응답 초안 생성기 (`generate_draft.py`) diff --git a/poc/tone-corpus/build_unlabeled_corpus.py b/poc/tone-corpus/build_unlabeled_corpus.py new file mode 100644 index 0000000..efd35d8 --- /dev/null +++ b/poc/tone-corpus/build_unlabeled_corpus.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Pull the dialogues that exist in the AI-Hub raw source (VS_/TS_ CSV) but +were never labeled (VL_/TL_ JSON) -- 34,030 of them, per the --qa check in +prepare_dataset.py. No speech_act/slot labels are available for these, so +they're only useful for plain language-modeling, not the labeled fields +`generate_draft.py`'s prompt doesn't use anyway. + +Usage: + python3 build_unlabeled_corpus.py --input --output +""" +import argparse +import csv +import glob +import io +import json +import os +import zipfile +from collections import OrderedDict + +VALID_SEX = {"남자", "여자"} + + +def is_valid_age(value): + return bool(value) and value.isdigit() and 10 <= int(value) <= 90 and int(value) % 10 == 0 + + +def clean(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 labeled_ids(input_dir): + ids = set() + for path in glob.glob(os.path.join(input_dir, "*VL_*.zip")) + glob.glob( + os.path.join(input_dir, "*TL_*.zip") + ): + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + if name.endswith(".json"): + with z.open(name) as f: + ids.add(json.load(f)["info"]["id"]) + return ids + + +def read_csv_dialogues(input_dir): + dialogues = OrderedDict() + csv_paths = sorted( + glob.glob(os.path.join(input_dir, "*VS_*.zip")) + glob.glob(os.path.join(input_dir, "*TS_*.zip")) + ) + 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") + current = None + for row in csv.DictReader(text): + if row.get("대화ID"): + did = row["대화ID"] + speakers = {} + for letter in ("A", "B", "C"): + sid = row.get(f"화자{letter} ID") + if not sid: + continue + speakers[letter] = { + "id": sid, + "sex": clean(row.get(f"화자{letter} 성별"), "sex"), + "age": clean(row.get(f"화자{letter} 연령대"), "age"), + } + current = { + "id": did, + "topic": row.get("주제"), + "keyword": row.get("키워드"), + "speakers": speakers, + "utterances": [], + } + dialogues[did] = current + if current is None: + continue + current["utterances"].append( + {"speaker": row.get("발화자", ""), "text": row.get("발화", "")} + ) + return dialogues + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--input", required=True) + ap.add_argument("--output", required=True) + args = ap.parse_args() + + os.makedirs(args.output, exist_ok=True) + already_labeled = labeled_ids(args.input) + all_dialogues = read_csv_dialogues(args.input) + + out_path = os.path.join(args.output, "unlabeled.jsonl") + kept = 0 + with open(out_path, "w", encoding="utf-8") as out: + for did, d in all_dialogues.items(): + if did in already_labeled: + continue + d["turns"] = len(d["utterances"]) + out.write(json.dumps(d, ensure_ascii=False) + "\n") + kept += 1 + + print(f"원천 전체: {len(all_dialogues)}건 / 라벨링됨(제외): {len(already_labeled)}건 / 출력: {kept}건 -> {out_path}") + + +if __name__ == "__main__": + main()