feat: 전달 파일 전용 정책과 AI 표본 품질 감사 추가
This commit is contained in:
parent
fcfaaab965
commit
480f00702f
@ -11,6 +11,9 @@
|
|||||||
`ai_assistance=false`로 기록한다. 두 번째 파일은 본문이 아니므로 목록만으로
|
`ai_assistance=false`로 기록한다. 두 번째 파일은 본문이 아니므로 목록만으로
|
||||||
학습·탐지에 쓰지 않는다.
|
학습·탐지에 쓰지 않는다.
|
||||||
|
|
||||||
|
> 운영 정책: 목록에 있는 Google Drive·공개 웹 링크에 자동 접속하지
|
||||||
|
> 않는다. 컴북스가 실제로 다운로드해 전달한 로컬 원본만 OCR·적재한다.
|
||||||
|
|
||||||
## 2. 익명화와 누출 방지
|
## 2. 익명화와 누출 방지
|
||||||
|
|
||||||
- `id`(이메일)는 운영 비밀 salt로 HMAC 가명화한다. 원본 ID는 DB와 학습셋에 저장하지 않는다.
|
- `id`(이메일)는 운영 비밀 salt로 HMAC 가명화한다. 원본 ID는 DB와 학습셋에 저장하지 않는다.
|
||||||
|
|||||||
99
scripts/clean_ai_samples.py
Normal file
99
scripts/clean_ai_samples.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AI 대조문 JSONL의 지시문 누출·인사말·중복을 제거하고 품질 보고서를 남긴다."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROMPT_LEAK_MARKERS = ("아래 소재", "본문만 출력", "문체 지시", "분량:")
|
||||||
|
ASSISTANT_PREFIXES = ("안녕하세요", "물론입니다", "네, ")
|
||||||
|
|
||||||
|
|
||||||
|
def hangul_ratio(text: str) -> float:
|
||||||
|
letters = [ch for ch in text if ch.isalpha()]
|
||||||
|
return sum("가" <= ch <= "힣" for ch in letters) / len(letters) if letters else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def rejection_reason(text: str, min_chars: int, max_chars: int, min_hangul: float) -> str | None:
|
||||||
|
stripped = text.strip()
|
||||||
|
if len(stripped) < min_chars or len(stripped) > max_chars:
|
||||||
|
return "length"
|
||||||
|
if stripped.startswith(ASSISTANT_PREFIXES):
|
||||||
|
return "assistant_greeting"
|
||||||
|
if any(marker in stripped for marker in PROMPT_LEAK_MARKERS):
|
||||||
|
return "prompt_leak"
|
||||||
|
if hangul_ratio(stripped) < min_hangul:
|
||||||
|
return "low_hangul_ratio"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--input", type=Path, action="append", required=True)
|
||||||
|
parser.add_argument("--out", type=Path, required=True)
|
||||||
|
parser.add_argument("--report", type=Path, required=True)
|
||||||
|
parser.add_argument("--min-chars", type=int, default=199)
|
||||||
|
parser.add_argument("--max-chars", type=int, default=1803)
|
||||||
|
parser.add_argument("--min-hangul-ratio", type=float, default=0.55)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
counts: Counter[str] = Counter()
|
||||||
|
seen: set[str] = set()
|
||||||
|
accepted = []
|
||||||
|
for path in args.input:
|
||||||
|
with path.open(encoding="utf-8") as handle:
|
||||||
|
for line_number, line in enumerate(handle, start=1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
counts["input"] += 1
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
counts["invalid_json"] += 1
|
||||||
|
continue
|
||||||
|
text = str(row.get("text") or "").strip()
|
||||||
|
reason = rejection_reason(
|
||||||
|
text, args.min_chars, args.max_chars, args.min_hangul_ratio
|
||||||
|
)
|
||||||
|
if reason:
|
||||||
|
counts[reason] += 1
|
||||||
|
continue
|
||||||
|
key = hashlib.sha1(re.sub(r"\s+", "", text).encode()).hexdigest()
|
||||||
|
if key in seen:
|
||||||
|
counts["duplicate"] += 1
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
row["quality_audit"] = "passed-v1"
|
||||||
|
row["source_file"] = path.name
|
||||||
|
row["source_line"] = line_number
|
||||||
|
accepted.append(row)
|
||||||
|
|
||||||
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with args.out.open("w", encoding="utf-8") as handle:
|
||||||
|
for row in accepted:
|
||||||
|
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||||
|
counts["accepted"] = len(accepted)
|
||||||
|
report = {
|
||||||
|
"inputs": [str(path) for path in args.input],
|
||||||
|
"output": str(args.out),
|
||||||
|
"counts": dict(counts),
|
||||||
|
"quality_policy": {
|
||||||
|
"min_chars": args.min_chars,
|
||||||
|
"max_chars": args.max_chars,
|
||||||
|
"min_hangul_ratio": args.min_hangul_ratio,
|
||||||
|
"assistant_prefixes": list(ASSISTANT_PREFIXES),
|
||||||
|
"prompt_leak_markers": list(PROMPT_LEAK_MARKERS),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(json.dumps(report["counts"], ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""수령한 생활 수기집 목록의 Google Drive 공유 원본을 다운로드한다."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
FILE_ID = re.compile(r"drive\.google\.com/file/d/([^/]+)")
|
|
||||||
FOLDER_ID = re.compile(r"drive\.google\.com/drive/folders/([^/?]+)")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("manifest", type=Path)
|
|
||||||
parser.add_argument("--out-dir", type=Path, required=True)
|
|
||||||
parser.add_argument("--report", type=Path, required=True)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def drive_kind(url: str) -> tuple[str, str] | None:
|
|
||||||
if match := FILE_ID.search(url):
|
|
||||||
return "file", match.group(1)
|
|
||||||
if match := FOLDER_ID.search(url):
|
|
||||||
return "folder", match.group(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
try:
|
|
||||||
import gdown
|
|
||||||
from openpyxl import load_workbook
|
|
||||||
except ImportError:
|
|
||||||
print("gdown과 openpyxl이 필요합니다.", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
workbook = load_workbook(args.manifest, read_only=False, data_only=False)
|
|
||||||
sheet = workbook.worksheets[0]
|
|
||||||
results = []
|
|
||||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for rownum in range(3, sheet.max_row + 1):
|
|
||||||
cell = sheet.cell(row=rownum, column=10)
|
|
||||||
url = cell.hyperlink.target if cell.hyperlink else ""
|
|
||||||
parsed = drive_kind(url or "")
|
|
||||||
if not parsed:
|
|
||||||
continue
|
|
||||||
kind, drive_id = parsed
|
|
||||||
target = args.out_dir / f"source_{rownum:03d}"
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
result = {
|
|
||||||
"row": rownum,
|
|
||||||
"kind": kind,
|
|
||||||
"drive_id": drive_id,
|
|
||||||
"url": url,
|
|
||||||
"label": str(cell.value or ""),
|
|
||||||
"status": "failed",
|
|
||||||
"files": [],
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
if kind == "folder":
|
|
||||||
gdown.download_folder(
|
|
||||||
url=url, output=str(target), quiet=False, remaining_ok=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
gdown.download(id=drive_id, output=str(target) + "/", quiet=False)
|
|
||||||
files = sorted(str(p.relative_to(args.out_dir)) for p in target.rglob("*") if p.is_file())
|
|
||||||
result["files"] = files
|
|
||||||
result["status"] = "downloaded" if files else "empty"
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
result["error"] = str(exc)
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
report = {
|
|
||||||
"manifest": args.manifest.name,
|
|
||||||
"drive_links": len(results),
|
|
||||||
"downloaded": sum(r["status"] == "downloaded" for r in results),
|
|
||||||
"failed_or_empty": sum(r["status"] != "downloaded" for r in results),
|
|
||||||
"results": results,
|
|
||||||
}
|
|
||||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
||||||
print(json.dumps({k: v for k, v in report.items() if k != "results"}, ensure_ascii=False))
|
|
||||||
return 0 if report["downloaded"] else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
12
tests/test_clean_ai_samples.py
Normal file
12
tests/test_clean_ai_samples.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from scripts.clean_ai_samples import rejection_reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_assistant_greeting_and_prompt_leak():
|
||||||
|
body = "삶을 돌아보며 기억을 적었다. " * 30
|
||||||
|
assert rejection_reason("안녕하세요. " + body, 100, 2000, 0.5) == "assistant_greeting"
|
||||||
|
assert rejection_reason(body + " 본문만 출력", 100, 2000, 0.5) == "prompt_leak"
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_clean_korean_prose():
|
||||||
|
body = "삶을 돌아보며 그날의 기억과 가족의 목소리를 기록했다. " * 20
|
||||||
|
assert rejection_reason(body, 100, 2000, 0.5) is None
|
||||||
@ -1,9 +0,0 @@
|
|||||||
from scripts.download_life_writing_drive import drive_kind
|
|
||||||
|
|
||||||
|
|
||||||
def test_drive_kind_parses_file_and_folder():
|
|
||||||
assert drive_kind("https://drive.google.com/file/d/abc123/view") == ("file", "abc123")
|
|
||||||
assert drive_kind("https://drive.google.com/drive/folders/xyz789?usp=drive_link") == (
|
|
||||||
"folder", "xyz789"
|
|
||||||
)
|
|
||||||
assert drive_kind("https://example.com") is None
|
|
||||||
Loading…
Reference in New Issue
Block a user