diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..f10b7812f1af8586708106e724874e07774fa300 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# HF Spaces Docker 빌드 컨텍스트에서 제외 — 이미지 크기·빌드 시간 최소화 + +# 안드는 백엔드와 무관 +android/ + +# git/빌드 메타 +.git/ +.github/ +.gitignore +.dockerignore + +# 가상환경 +.venv/ +venv/ +env/ + +# 컴파일 캐시 +**/__pycache__/ +**/*.pyc +**/*.pyo +**/*.pyd +.pytest_cache/ + +# 노트북 (서빙엔 불필요, 큼) +**/*.ipynb +**/.ipynb_checkpoints/ + +# 학습 데이터 / 큰 csv·jsonl (서빙엔 불필요) +data/ +backend/data/ +backend/external_data/ +external_data/ +model/classification/data/ +model/extraction/data/ +model/translation_tts/data/ + +# 로컬 체크포인트 (HF Hub에서 다운로드되니 image에 포함 안 함) +model/classification/checkpoints/ +model/extraction/checkpoints/ + +# 시각화 png·문서 (서빙엔 불필요) +**/*.png +**/*.jpg +docs/ +**/docs/ + +# 로그·임시 +*.log +*.tmp +*.bak + +# 백엔드 자체 .env (Spaces secrets 사용) +backend/.env +.env diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml new file mode 100644 index 0000000000000000000000000000000000000000..237fecc8721c28b9c430b003a462ea0d9b42e172 --- /dev/null +++ b/.github/workflows/backend-tests.yml @@ -0,0 +1,50 @@ +name: backend-tests + +on: + pull_request: + paths: + - "backend/**" + - "model/translation_tts/**" + - ".github/workflows/backend-tests.yml" + push: + branches: [main, dev] + paths: + - "backend/**" + - "model/translation_tts/**" + +# 강사 피드백(2026-04-28): "단위테스트 끝나고 통합테스트 할 때 합의된 기준/절차에 의해 merge" +# → PR 시 자동 실행되는 권한 검증 + 다국어 사전 로딩 게이트. + +jobs: + pytest: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install backend deps (CPU torch + dev) + working-directory: backend + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Symlink external_model (도커 마운트 경로 모사) + run: | + sudo mkdir -p /app + sudo ln -s "$GITHUB_WORKSPACE/backend/app" /app/app + sudo ln -s "$GITHUB_WORKSPACE/model" /app/external_model + sudo ln -s "$GITHUB_WORKSPACE/backend/static" /app/static + sudo mkdir -p /app/static/tts + + - name: Run pytest + working-directory: backend + env: + PYTHONPATH: /app + run: | + pytest tests -v --tb=short diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..486bca76ad9f879dd3e91bd655c5dfd767e2f561 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +dist/ +build/ +*.egg + +# 환경변수 +.env +.env.* +!.env.example +*.env.local + +# ML 체크포인트 / 대용량 파일 +ml/*/checkpoints/ +ml/*/best_model/ +*.pt +*.bin +*.onnx + +# 경이 simple 분류기 자동 학습 결과 (random seed에 따라 달라져 git 충돌 유발) +# 학습 코드 + 데이터(csv)는 git에 있어 컨테이너 첫 호출 시 재현 가능 +model/classification/checkpoints/*.pkl + +# 컨테이너 작업 영역 (학습 데이터·OCR·임시 변환물) +backend/data/ + +# Android build outputs and local settings +android/.gradle/ +android/.idea/ +android/build/ +android/app/build/ +android/local.properties +*.apk +*.aab +*.keystore + + +# IDE +.idea/ +.vscode/ +*.iml + +# OS +.DS_Store +Thumbs.db + +# TTS 출력물 +tts/*.mp3 +tts/*.wav + +# 런타임 업로드 (가통문 원본 PDF/이미지) + TTS mp3 — 폴더는 git에 유지 +backend/static/notices/* +!backend/static/notices/.gitkeep +backend/static/tts/* +!backend/static/tts/.gitkeep + +#클로드 +.claude + +# nested repo 방지 +multicultural-ai/ +multicultural-school-ai/ + +# 미팅 자료 (디코로만 공유, 깃에는 안 올림) +docs/meetings/ + +# 학습 데이터 draft (대용량, 미완성) +data/processed/ +data/raw/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..3005398cda94c0b651a14a63a56fd170893d55bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# HF Spaces Docker SDK — schoolbridge 백엔드 +# build context = repo root (backend/ + model/ 전부 포함) +FROM python:3.11-slim + +WORKDIR /app + +# 파일 변환 의존 (HWP → 텍스트, PDF, 이미지 OCR) +# H2Orestart는 latest 채널 사용 — 버전별 asset 파일명 안전성 확보 +ARG H2O_URL=https://github.com/ebandal/H2Orestart/releases/latest/download/H2Orestart.oxt + +RUN apt-get update -qq && apt-get install -y --no-install-recommends \ + libreoffice-core \ + libreoffice-writer \ + libreoffice-java-common \ + default-jre-headless \ + fonts-nanum \ + fonts-noto-cjk \ + wget \ + ca-certificates \ + tesseract-ocr \ + tesseract-ocr-kor \ + libgl1 \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -O /tmp/h2orestart.oxt "${H2O_URL}" \ + && unopkg add --shared /tmp/h2orestart.oxt \ + && rm -f /tmp/h2orestart.oxt + +# Python 의존 설치 (CPU torch + transformers + edge-tts 등) +COPY backend/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +# 백엔드 코드 +COPY backend/app /app/app + +# 외부 모델 코드 (분류기·추출기 src/) — 가중치는 HF Hub에서 자동 다운로드 +COPY model /app/external_model + +# 정적 디렉토리 (TTS mp3, 가통문 원본 PDF/이미지) +RUN mkdir -p /app/static/tts /app/static/notices \ + && chmod -R 777 /app/static + +# HF Spaces 기본 포트 7860 — 도메인 https://{user}-{space}.hf.space 로 자동 매핑 +EXPOSE 7860 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1a87341b0a2b1e64a7573727cb577077ed04b3e8 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +--- +title: SchoolBridge +emoji: 🏫 +colorFrom: blue +colorTo: indigo +sdk: docker +pinned: false +license: mit +short_description: 가정통신문 AI 도우미 — 다문화 학부모용 +--- + +# SchoolBridge + +가정통신문 AI 도우미 — 다문화 가정 학부모용 백엔드. + +## 엔드포인트 + +- `GET /health` +- `POST /notice/extract-text` — 파일 텍스트 추출 (미리보기, 저장 X) +- `POST /notice/upload` — 파일 발송 (Notice 저장 + 원본 보존) +- `POST /notice/upload-self` — 학부모 자가 업로드 +- `POST /notice/send` — 텍스트 직송 +- `GET /notice/inbox/{parent_id}` +- `POST /notice/analyze/{notice_id}` +- `GET /static/notices/{id}{ext}` — 원본 PDF/이미지 + +## 모델 + +- 추출: 윤정님 KoELECTRA (`yunjeong116/koelectra-extractor`) +- 분류: 경이님 KcELECTRA v3 (`kysophia/kcelectra-category`) +- 번역: NLLB-200-distilled-600M +- TTS: Microsoft Edge-TTS + +## 라이센스 + +MIT diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..8bbd6bc02c91d1dbadc059acb52abc10231708c7 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 파일 변환 의존: LibreOffice headless + H2Orestart 확장 + 한글 폰트. +# 학습 데이터 가공과 런타임 [1] 텍스트 변환 모두 사용. +# H2Orestart는 latest 채널 사용 — 버전별 asset 파일명이 일관되지 않아 latest/download 안전. +ARG H2O_URL=https://github.com/ebandal/H2Orestart/releases/latest/download/H2Orestart.oxt + +RUN apt-get update -qq && apt-get install -y --no-install-recommends \ + libreoffice-core \ + libreoffice-writer \ + libreoffice-java-common \ + default-jre-headless \ + fonts-nanum \ + fonts-noto-cjk \ + wget \ + ca-certificates \ + tesseract-ocr \ + tesseract-ocr-kor \ + libgl1 \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -O /tmp/h2orestart.oxt "${H2O_URL}" \ + && unopkg add --shared /tmp/h2orestart.oxt \ + && rm -f /tmp/h2orestart.oxt + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..7563f6df76fa6c2d6b36911aef32c5bdbc7c74af --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,71 @@ +"""사용자 식별 + 역할 검증. + +X-User-Id 헤더로 사용자를 식별하고, 역할(teacher/parent)에 따른 권한을 검증한다. +시연 단계라 별도 비밀번호/토큰 없이 헤더 한 줄로 처리. 실제 운영 시 JWT/세션 도입 예정. +""" +from fastapi import Depends, Header, HTTPException, status + +from app.models.schemas import UserProfile + +# 모듈 레벨 인메모리 사용자 저장소. user.py 라우터와 공유. +_user_store: dict[str, UserProfile] = {} + + +def get_user(user_id: str) -> UserProfile | None: + return _user_store.get(user_id) + + +def upsert_user(profile: UserProfile) -> UserProfile: + _user_store[profile.user_id] = profile + return profile + + +def list_users() -> list[UserProfile]: + return list(_user_store.values()) + + +def seed_demo_users() -> None: + """시연용 기본 계정 시드. 이미 존재하면 덮어쓰지 않음.""" + demos = [ + UserProfile(user_id="teacher_001", role="teacher"), + UserProfile(user_id="teacher_002", role="teacher"), + UserProfile(user_id="parent_001", role="parent"), + UserProfile(user_id="parent_002", role="parent"), + UserProfile(user_id="parent_003", role="parent"), + ] + for profile in demos: + _user_store.setdefault(profile.user_id, profile) + + +def require_user(x_user_id: str = Header(..., description="요청자 사용자 ID")) -> UserProfile: + """X-User-Id 헤더 필수. 등록되지 않은 ID면 401.""" + if not x_user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="X-User-Id 헤더가 필요합니다", + ) + profile = _user_store.get(x_user_id) + if profile is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"등록되지 않은 사용자: {x_user_id}", + ) + return profile + + +def require_teacher(user: UserProfile = Depends(require_user)) -> UserProfile: + if user.role != "teacher": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="선생님 권한이 필요합니다", + ) + return user + + +def require_parent(user: UserProfile = Depends(require_user)) -> UserProfile: + if user.role != "parent": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="학부모 권한이 필요합니다", + ) + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..819d4c671b6fc39294a7cf30e7bb8ca06fd387be --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,46 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.auth import seed_demo_users +from app.routers import notice, tts, user + +STATIC_DIR = Path("/app/static") +STATIC_DIR.mkdir(parents=True, exist_ok=True) +(STATIC_DIR / "tts").mkdir(parents=True, exist_ok=True) +(STATIC_DIR / "notices").mkdir(parents=True, exist_ok=True) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + seed_demo_users() + yield + + +app = FastAPI( + title="가정통신문 AI 도우미 API", + description="베트남 결혼이민 학부모를 위한 가정통신문 할 일 요약 + TTS 서비스", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + +app.include_router(notice.router, prefix="/notice", tags=["notice"]) +app.include_router(tts.router, prefix="/tts", tags=["tts"]) +app.include_router(user.router, prefix="/user", tags=["user"]) + + +@app.get("/health", tags=["health"]) +def health_check(): + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..1b83366288f2b856ed0750927b83bcbeff6af183 --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,164 @@ +from enum import Enum +from typing import Any +from pydantic import BaseModel + + +class KoreanLevel(str, Enum): + beginner = "beginner" # 초급: 베트남어 TTS + intermediate = "intermediate" # 중급: 한국어 TTS + 용어 설명 + + +class Category(str, Enum): + schedule = "일정" + supplies = "준비물" + submission = "제출" + cost = "비용" + health = "건강·안전" + other = "기타" + + +class TodoItem(BaseModel): + category: Category + text_ko: str + text_vi: str + importance: float # 0.0 ~ 1.0 + due_date: str | None = None + + +class YunjeongTodo(BaseModel): + """윤정님 v2 추출 모델 출력 — 통신문 1개에서 문장 단위로 뽑힌 할일. + + 내부에 정규식 due_date/amount 추출 + binary 분류(BINARY_THRESHOLD=0.5) 포함. + confidence < 0.5 항목은 모델이 자동 필터링해 출력에 포함 안 됨. + """ + text: str # 원문 문장 + source: str | None = None # 파일명 (같은 통신문 묶음용) + due_date: str | None = None # YYYY-MM-DD 또는 상대표현 ("다음 주 금요일") + amount: int | None = None # 원 단위 + confidence: float # binary 확률 (0.0 ~ 1.0) + action_hint: str | None = None # 신청 / 제출 / 납부 / 준비 / 참여 / 확인 + + +class Notice(BaseModel): + notice_id: str + teacher_id: str + parent_id: str + text: str + todos: list[TodoItem] = [] + # 원본 파일 (선생님/학부모가 업로드한 PDF/이미지). text 직송이면 None. + original_file_url: str | None = None # 예: "/static/notices/abc123.pdf" + original_filename: str | None = None # 예: "5월 가정통신문.pdf" + mime_type: str | None = None # 예: "application/pdf" + + +class NoticeSendRequest(BaseModel): + teacher_id: str + parent_id: str + text: str + + +class NoticeAnalyzeRequest(BaseModel): + target_language: str # vi/en/ru/ms/mn/zh/th/ja/ko_easy — 필수, default 없음 + + +# ── 슬롯 기반 응답 (강사 처방 1·3 대응) ────────────────────────── +# source: "regex" | "model" | "model+regex" — 신뢰도 추적용 +# 정규식이 잡은 항목은 LLM 의존 없이 확보됐음을 안드/검수에서 표시 가능. +class SlotEntry(BaseModel): + ko: str + translated: str = "" + source: str = "model" + conditional: bool = False # "흐릴 경우 우산" 같은 조건부 항목 + + +class SummarySlots(BaseModel): + dates: list[SlotEntry] = [] + times: list[SlotEntry] = [] + places: list[SlotEntry] = [] + supplies: list[SlotEntry] = [] # ⚠️ 강사 강조: 누락 금지 + amounts: list[SlotEntry] = [] # ⚠️ 강사 강조: 누락 금지 + deadlines: list[SlotEntry] = [] + urls: list[SlotEntry] = [] # NLLB가 깨먹지 않게 ko 그대로 노출 + phones: list[SlotEntry] = [] # 같은 이유 + + +class AnalyzeItem(BaseModel): + """카테고리별 할 일 — YunjeongTodo + 경이님 카테고리 결합 결과. + + deprecated — SlotCard로 대체 예정 (안드 마이그레이션 완료 후 폐기). + """ + category: Category # 경이님 (주제: 일정/준비물/제출/비용/건강·안전/기타) + action_hint: str | None = None # 윤정님 (행동: 신청/제출/납부/준비/참여/확인) + title_ko: str + title_translated: str = "" + when: str | None = None + where: str | None = None + what: list[str] = [] + amount: str | None = None + deadline: str | None = None + importance: float = 0.5 + note_ko: str | None = None # 조건부 메모 (예: "날씨가 흐릴 경우") + note_translated: str | None = None + + +class SlotCard(BaseModel): + """슬롯 카드 — 헤더 + 값 + 카테고리 칩. + + 강사님 처방 "지저분한 줄글 X, 슬롯 위주로 가공" 대응. + 한 카드 = 한 의미 단위 (운영시간 / 신청기간 / 운영방법 ...). + todos 헤더 분해 + regex 슬롯 컨텍스트 매칭 둘 다 카드로 통합. + """ + header_ko: str # 예: "운영시간" + header_translated: str = "" # 예: "Thời gian hoạt động" + value_ko: str # 예: "오전 10:00 ~ 12:00 (2시간)" + value_easy_ko: str = "" # 세종님 to_easy_korean() 결과 — 미구현 시 value_ko 그대로 + value_translated: str = "" # NLLB 번역 결과 + chip: str | None = None # category 값 — None이면 칩 미표시 + importance: float = 0.5 # 정렬용 (높은 순) + + +class NoticeAnalyzeResponse(BaseModel): + notice_id: str + raw_text: str + target_language: str + # 통신문 제목 (윤정님 PR #90 extract_title heuristic) — 못 찾으면 "" + title: str = "" + title_translated: str = "" + # 신규 — 안드 슬롯 카드 UI 대상 (단계적 마이그레이션, 본 필드가 메인) + cards: list[SlotCard] = [] + # deprecated — 안드 마이그레이션 완료 후 다음 PR에서 폐기 예정 + summary: SummarySlots + items: list[AnalyzeItem] = [] + tts_text: str = "" # 음성 변환 직전 텍스트 — 시연·디버그용 가시화 + tts_url: str = "" # 번역 합본 TTS (안드 기존 버튼) + tts_url_easy_ko: str = "" # 쉬운 한국어 합본 TTS (세종님 별도 버튼 요청) + quality_note: str = "" + review_needed: str = "" + + +class TTSRequest(BaseModel): + user_id: str + todo_items: list[TodoItem] + level: KoreanLevel + + +class UserProfile(BaseModel): + user_id: str + role: str = "parent" # "teacher" | "parent" + child_grade: int = 1 # 1~6학년 (부모만 해당) + level: KoreanLevel = KoreanLevel.beginner + tts_speed: float = 1.0 + + +class ApiResponse(BaseModel): + status: str # "success" | "error" + data: Any = None + message: str = "" + + @classmethod + def success(cls, data: Any = None, message: str = "") -> "ApiResponse": + return cls(status="success", data=data, message=message) + + @classmethod + def error(cls, message: str) -> "ApiResponse": + return cls(status="error", data=None, message=message) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/routers/notice.py b/backend/app/routers/notice.py new file mode 100644 index 0000000000000000000000000000000000000000..0317cbf74583a1aca11941b9625528998ab76f9b --- /dev/null +++ b/backend/app/routers/notice.py @@ -0,0 +1,490 @@ +import mimetypes +import os +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from app.auth import get_user, require_teacher, require_user +from app.models.schemas import ( + AnalyzeItem, ApiResponse, Category, Notice, NoticeAnalyzeRequest, + NoticeSendRequest, SlotCard, SlotEntry, SummarySlots, UserProfile, + YunjeongTodo, +) +from app.services.extractor import extract_todos, extract_title +from app.services.parser import ParserError, parse_bytes_to_text +from app.services.translator import translate_short_sentence, translate_term +from app.services.classifier import classify_category +from app.services.tts import generate_tts_file +from app.services.slot_extractor import ( + extract_summary_regex_slots, find_when_in_text, + split_supply_tokens, strip_markers, +) +from app.services.card_builder import build_cards +from app.services.mock import MOCK_TODOS + +router = APIRouter() + +_notices: dict[str, Notice] = {} +MAX_CARDS = 8 + +NOTICES_DIR = Path("/app/static/notices") + + +def _save_original(notice_id: str, raw_bytes: bytes, filename: str) -> tuple[str, str | None]: + """업로드된 원본 파일을 static/notices/{notice_id}{ext}로 저장. + Returns (url, mime_type). 실패 시 mime_type=None.""" + ext = os.path.splitext(filename or "")[1].lower() + if not ext: + # 시그니처로 추정 + if raw_bytes.startswith(b"%PDF"): + ext = ".pdf" + elif raw_bytes[:3] == b"\xff\xd8\xff": + ext = ".jpg" + elif raw_bytes.startswith(b"\x89PNG"): + ext = ".png" + safe_name = f"{notice_id}{ext}" + NOTICES_DIR.mkdir(parents=True, exist_ok=True) + (NOTICES_DIR / safe_name).write_bytes(raw_bytes) + mime_type, _ = mimetypes.guess_type(safe_name) + return f"/static/notices/{safe_name}", mime_type + + +@router.post("/send", response_model=ApiResponse) +async def send_notice( + req: NoticeSendRequest, + user: UserProfile = Depends(require_teacher), +): + """선생님이 가정통신문 발송 → 부모 수신함에 저장.""" + if user.user_id != req.teacher_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="본인 선생님 ID로만 발송 가능합니다", + ) + parent = get_user(req.parent_id) + if parent is None or parent.role != "parent": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"학부모 계정을 찾을 수 없습니다: {req.parent_id}", + ) + notice_id = str(uuid.uuid4()) + notice = Notice( + notice_id=notice_id, + teacher_id=req.teacher_id, + parent_id=req.parent_id, + text=req.text, + todos=[], + ) + _notices[notice_id] = notice + return ApiResponse.success(data={"notice_id": notice_id}, message="발송 완료") + + +@router.post("/extract-text", response_model=ApiResponse) +async def extract_text( + file: UploadFile = File(...), + user: UserProfile = Depends(require_user), +): + """파일 → 텍스트 추출만 (Notice 저장·발송 X). 선생님 발송 전 미리보기용. + + 실제 발송은 사용자가 발송 버튼을 누를 때 /notice/upload (파일 동봉) 또는 + /notice/send (텍스트만)로 호출됨. + """ + raw_bytes = await file.read() + try: + text = parse_bytes_to_text(raw_bytes, file.filename or "") + except ParserError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"파일 변환 실패: {error}", + ) + if not text: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="파일에서 추출된 텍스트가 비어있습니다", + ) + return ApiResponse.success( + data={"text": text, "char_count": len(text), "filename": file.filename}, + message=f"텍스트 추출 완료 ({file.filename})", + ) + + +@router.post("/upload", response_model=ApiResponse) +async def upload_notice( + teacher_id: str = Form(...), + parent_id: str = Form(...), + file: UploadFile = File(...), + user: UserProfile = Depends(require_teacher), +): + """선생님이 HWP/PDF 파일로 가정통신문 발송. 파일 → 텍스트 변환 후 send와 동일 흐름.""" + if user.user_id != teacher_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="본인 선생님 ID로만 발송 가능합니다", + ) + parent = get_user(parent_id) + if parent is None or parent.role != "parent": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"학부모 계정을 찾을 수 없습니다: {parent_id}", + ) + + raw_bytes = await file.read() + try: + text = parse_bytes_to_text(raw_bytes, file.filename or "") + except ParserError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"파일 변환 실패: {error}", + ) + if not text: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="파일에서 추출된 텍스트가 비어있습니다", + ) + + notice_id = str(uuid.uuid4()) + original_url, mime_type = _save_original(notice_id, raw_bytes, file.filename or "") + notice = Notice( + notice_id=notice_id, + teacher_id=teacher_id, + parent_id=parent_id, + text=text, + todos=[], + original_file_url=original_url, + original_filename=file.filename, + mime_type=mime_type, + ) + _notices[notice_id] = notice + return ApiResponse.success( + data={"notice_id": notice_id, "char_count": len(text), "text": text}, + message=f"파일 업로드 완료 ({file.filename})", + ) + + +@router.post("/upload-self", response_model=ApiResponse) +async def upload_notice_self( + parent_id: str = Form(...), + file: UploadFile = File(...), + user: UserProfile = Depends(require_user), +): + """학부모가 종이 통신문 사진/파일을 직접 업로드 → 수신함에 self-send 형태로 저장. + + teacher_id = parent_id (자기 자신이 발신자)로 저장되며 + 이후 /analyze/{notice_id} 흐름은 동일. + """ + if user.role != "parent" or user.user_id != parent_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="본인 학부모 ID로만 업로드 가능합니다", + ) + + raw_bytes = await file.read() + try: + text = parse_bytes_to_text(raw_bytes, file.filename or "") + except ParserError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"파일 변환 실패: {error}", + ) + if not text: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="파일에서 추출된 텍스트가 비어있습니다", + ) + + notice_id = str(uuid.uuid4()) + original_url, mime_type = _save_original(notice_id, raw_bytes, file.filename or "") + _notices[notice_id] = Notice( + notice_id=notice_id, + teacher_id=parent_id, + parent_id=parent_id, + text=text, + todos=[], + original_file_url=original_url, + original_filename=file.filename, + mime_type=mime_type, + ) + return ApiResponse.success( + data={"notice_id": notice_id, "char_count": len(text), "text": text}, + message=f"업로드 완료 ({file.filename})", + ) + + +@router.get("/inbox/{parent_id}", response_model=ApiResponse) +async def get_inbox( + parent_id: str, + user: UserProfile = Depends(require_user), +): + """부모가 수신된 가정통신문 목록 조회. 본인 ID만 허용.""" + if user.role != "parent" or user.user_id != parent_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="본인 수신함만 조회할 수 있습니다", + ) + inbox = [n for n in _notices.values() if n.parent_id == parent_id] + return ApiResponse.success(data=inbox) + + +@router.delete("/inbox/{parent_id}", response_model=ApiResponse) +async def clear_inbox( + parent_id: str, + user: UserProfile = Depends(require_user), +): + """parent_id 수신함 초기화 (시연용). 본인 ID만 허용.""" + if user.role != "parent" or user.user_id != parent_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="본인 수신함만 삭제할 수 있습니다", + ) + targets = [nid for nid, n in _notices.items() if n.parent_id == parent_id] + for nid in targets: + del _notices[nid] + return ApiResponse.success( + data={"deleted": len(targets)}, + message=f"{parent_id} 수신함 {len(targets)}개 삭제", + ) + + +# ── 슬롯 기반 응답 빌더 ─────────────────────────────────────────── +# 강사 처방(2026-04-28): +# 1. 템플릿 슬롯 출력 (summary) +# 2. 정규식 + 모델 하이브리드 (regex source 표시) +# 3. 핵심 명사(준비물·금액) 누락 가시화 (빈 슬롯 즉시 노출) +def _amount_to_ko(amount: int | None) -> str | None: + if amount is None: + return None + return f"{amount:,}원" + + +def _build_item(todo: YunjeongTodo, target_lang: str) -> AnalyzeItem: + """YunjeongTodo + 경이님 카테고리 → AnalyzeItem. + + - category : 경이님 6-class 분류 (주제) + - action_hint : 윤정님 추출 결과 (행동: 신청/제출/...) + - due_date / amount : 윤정님 모델 결과 신뢰 (정규식 [2]는 summary 전체 단위 담당) + - when : 자유텍스트의 일시 표현은 정규식으로 추가 추출 + - what : 준비물 카테고리일 때만 토큰 분해 + - 마크업(■)은 보존, TTS 빌더에서만 strip. + """ + text = todo.text + category = classify_category(text) + + when = find_when_in_text(text, target_lang) + + what: list[str] = [] + if category == Category.supplies: + what = split_supply_tokens(text) + + title_translated = translate_short_sentence(text, target_lang) or text + + return AnalyzeItem( + category=category, + action_hint=todo.action_hint, + title_ko=text, + title_translated=title_translated, + when=when, + where=None, + what=what, + amount=_amount_to_ko(todo.amount), + deadline=todo.due_date, + importance=todo.confidence, + ) + + +def _slot_entry(ko: str, target_lang: str, source: str = "model") -> SlotEntry: + return SlotEntry(ko=ko, translated=translate_term(ko, target_lang), source=source) + + +def _build_summary( + regex_slots: dict[str, list[dict]], + items: list[AnalyzeItem], + target_lang: str, +) -> SummarySlots: + """정규식 슬롯(dates/times/amounts/urls/phones) + items에서 모델 슬롯(supplies/deadlines) 집계.""" + summary = SummarySlots( + dates=[SlotEntry(**s) for s in regex_slots["dates"]], + times=[SlotEntry(**s) for s in regex_slots["times"]], + amounts=[SlotEntry(**s) for s in regex_slots["amounts"]], + urls=[SlotEntry(**s) for s in regex_slots.get("urls", [])], + phones=[SlotEntry(**s) for s in regex_slots.get("phones", [])], + ) + + # supplies: category=supplies items의 what 토큰 집계 (중복 제거, 순서 보존) + supplies: list[SlotEntry] = [] + seen_supplies: set[str] = set() + for item in items: + if item.category != Category.supplies: + continue + for token in item.what: + if token in seen_supplies: + continue + seen_supplies.add(token) + supplies.append(_slot_entry(token, target_lang, source="model")) + summary.supplies = supplies + + # deadlines: items의 deadline 집계 + deadlines: list[SlotEntry] = [] + seen_deadlines: set[str] = set() + for item in items: + if not item.deadline or item.deadline in seen_deadlines: + continue + seen_deadlines.add(item.deadline) + deadlines.append(_slot_entry(item.deadline, target_lang, source="model+regex")) + summary.deadlines = deadlines + + return summary + + +@router.post("/analyze/{notice_id}", response_model=ApiResponse) +async def analyze_notice( + notice_id: str, + req: NoticeAnalyzeRequest, + user: UserProfile = Depends(require_user), +): + """수신된 가정통신문 → 슬롯 기반 분석 응답. + + 파이프라인: + [3] 윤정 추출 (binary + 정규식, list[YunjeongTodo]) + [2] 정규식 슬롯 (전체 통신문 단위) + [4] 경이 6-class 분류 (각 todo.text) + [5] 슬롯/짧은 문장 번역 (세종) + [6] AnalyzeItem 결합 + summary 집계 + [7] TTS + + 학부모 본인의 가정통신문만 분석 가능. target_language 필수. + """ + if notice_id not in _notices: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="가정통신문을 찾을 수 없습니다", + ) + + notice = _notices[notice_id] + if user.role != "parent" or user.user_id != notice.parent_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="본인의 가정통신문만 분석할 수 있습니다", + ) + target_lang = req.target_language + + # [3] 윤정 추출 → list[YunjeongTodo] (할일 없으면 []) + try: + todos = extract_todos(notice.text) + if not todos: + todos = MOCK_TODOS + except Exception as error: + print(f"[analyze] extractor failed: {error}") + todos = MOCK_TODOS + + # [3'] 제목 추출 (윤정님 PR #90 heuristic) — split_sentences 이전, 원문 직접 스캔 + title_ko = extract_title(notice.text) or "" + title_translated = ( + translate_short_sentence(title_ko, target_lang) if title_ko else "" + ) + + # [2] 정규식 슬롯 (전체 통신문 단위, summary 재료) + regex_slots = extract_summary_regex_slots(notice.text, target_lang) + + # [4]+[6] items: 각 todo에 경이님 카테고리 + 슬롯 결합 (deprecated, 다음 PR 폐기) + items = [_build_item(t, target_lang) for t in todos] + + # [6] summary 집계: 정규식 + items 모델 슬롯 통합 (deprecated, 다음 PR 폐기) + summary = _build_summary(regex_slots, items, target_lang) + + # [6'] cards: 신규 슬롯 카드 응답 — 시연 안정성을 위해 상위 N개만 번역/TTS 대상으로 사용. + top_todos = sorted(todos, key=lambda t: -t.confidence)[:MAX_CARDS] + cards = build_cards(top_todos, regex_slots, target_lang)[:MAX_CARDS] + + # [7] TTS: 두 갈래 — 번역 합본 + 쉬운 한국어 합본 (세종님 별도 버튼 요청) + tts_text_translated = _build_tts_text_from_cards(cards, "translated") + tts_text_easy_ko = _build_tts_text_from_cards(cards, "easy_ko") + try: + tts_url = await generate_tts_file(tts_text_translated, target_lang=target_lang) if tts_text_translated else "" + except Exception as error: + print(f"[analyze] TTS (translated) failed: {error}") + tts_url = "" + try: + tts_url_easy_ko = await generate_tts_file(tts_text_easy_ko, target_lang="ko_easy") if tts_text_easy_ko else "" + except Exception as error: + print(f"[analyze] TTS (easy_ko) failed: {error}") + tts_url_easy_ko = "" + + response = { + "notice_id": notice_id, + "raw_text": notice.text, + "target_language": target_lang, + "title": title_ko, + "title_translated": title_translated, + "cards": [c.model_dump() for c in cards], + "summary": summary.model_dump(), + "items": [item.model_dump() for item in items], + "tts_text": tts_text_translated, + "tts_url": tts_url, + "tts_url_easy_ko": tts_url_easy_ko, + "quality_note": "", + "review_needed": "", + } + return ApiResponse.success(data=response) + + +def _build_tts_text_from_cards(cards: list[SlotCard], mode: str) -> str: + """슬롯 카드 → TTS 텍스트 (헤더 + 값 한 줄씩 합본). + + mode="translated": 대상 언어 TTS용 — value_translated + header_translated + mode="easy_ko": 쉬운 한국어 TTS용 — value_easy_ko + header_ko + """ + if not cards: + return "" + lines: list[str] = [] + for c in cards: + if mode == "translated": + header = c.header_translated or c.header_ko + value = c.value_translated or c.value_ko + else: # easy_ko + header = c.header_ko + value = c.value_easy_ko or c.value_ko + if not value: + continue + value = strip_markers(value) + line = f"{header}. {value}" if header else value + lines.append(line) + return "\n".join(lines) + + +def _build_tts_text( + summary: SummarySlots, + items: list[AnalyzeItem], + target_lang: str, +) -> str: + """items 한 건씩 title + 슬롯 정보를 합쳐 한 문장으로 — 음성 정보량 최대화. + + 슬롯 한국어 값(amount/deadline/what)은 summary의 translated 매핑으로 대상 언어 변환. + 정렬은 importance 내림차순. 경이 분리 후엔 action_required="Y" 우선으로 교체 예정. + """ + if not items: + return "" + + # 한국어 슬롯 → 대상 언어 매핑 (summary가 이미 translated 보유) + supply_map = {s.ko: s.translated or s.ko for s in summary.supplies} + amount_map = {s.ko: s.translated or s.ko for s in summary.amounts} + deadline_map = {s.ko: s.translated or s.ko for s in summary.deadlines} + + sorted_items = sorted(items, key=lambda i: -i.importance) + lines: list[str] = [] + for item in sorted_items: + title = item.title_ko if target_lang == "ko_easy" else item.title_translated + if not title: + continue + # 음성에서 ■ 등 장식 마크업은 노이즈 — TTS 직전 strip + title = strip_markers(title) + extras: list[str] = [] + if item.when: # 이미 target_lang 포맷 + extras.append(item.when) + if item.what: + extras.append(", ".join(supply_map.get(w, w) for w in item.what)) + if item.amount: + extras.append(amount_map.get(item.amount, item.amount)) + if item.deadline: + extras.append(strip_markers(deadline_map.get(item.deadline, item.deadline))) + line = title + (". " + ". ".join(extras) if extras else "") + lines.append(line) + return "\n".join(lines) diff --git a/backend/app/routers/tts.py b/backend/app/routers/tts.py new file mode 100644 index 0000000000000000000000000000000000000000..bb8ea1553e1bfaf33253c2ce6059eacf8852c5a0 --- /dev/null +++ b/backend/app/routers/tts.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter + +from app.models.schemas import ApiResponse, TTSRequest +from app.services.tts import generate_tts_file + +router = APIRouter() + + +@router.post("/generate", response_model=ApiResponse) +async def generate_tts(req: TTSRequest): + """할 일 목록 → 베트남어 음성 파일 생성. 안드는 응답 url을 BASE_URL과 합쳐 재생.""" + text = "\n".join(item.text_vi for item in req.todo_items if item.text_vi) + if not text.strip(): + return ApiResponse.error(message="베트남어 텍스트가 비어 있습니다") + try: + url = await generate_tts_file(text) + except Exception as error: + return ApiResponse.error(message=f"TTS 생성 실패: {error}") + return ApiResponse.success(data={"tts_url": url}) diff --git a/backend/app/routers/user.py b/backend/app/routers/user.py new file mode 100644 index 0000000000000000000000000000000000000000..e55e61c1e5f7c26f65d2282c0dd53400dc83469f --- /dev/null +++ b/backend/app/routers/user.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter + +from app.auth import get_user as _get_user, list_users, upsert_user +from app.models.schemas import ApiResponse, UserProfile + +router = APIRouter() + + +@router.get("/", response_model=ApiResponse) +def list_all_users(): + """등록된 사용자 전체 목록 (시연용).""" + return ApiResponse.success(data=list_users()) + + +@router.get("/{user_id}", response_model=ApiResponse) +def get_user(user_id: str): + """사용자 프로파일 조회 (한국어 수준, 학년, TTS 속도).""" + profile = _get_user(user_id) + if profile is None: + return ApiResponse.error(message="사용자 없음") + return ApiResponse.success(data=profile) + + +@router.post("/", response_model=ApiResponse) +def save_user(profile: UserProfile): + """사용자 프로파일 저장/수정. 시연 시 학부모/선생님 계정 등록에도 사용.""" + saved = upsert_user(profile) + return ApiResponse.success(data=saved) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/services/card_builder.py b/backend/app/services/card_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..669335086b2f1ed55b78910253eadd1eed7e3942 --- /dev/null +++ b/backend/app/services/card_builder.py @@ -0,0 +1,166 @@ +"""슬롯 카드 빌더 — todos + regex_slots → list[SlotCard]. + +강사님 처방 "지저분한 줄글 X, 슬롯 위주 가공" 대응: + 한 카드 = 한 의미 단위 (운영시간 / 신청기간 / 운영방법 ...). + +흐름: + 1. todos → 헤더 분해 + 분류 + 번역 + 쉬운 한국어 + 2. regex 슬롯 (URL/시간/날짜/전화/금액) → 보강 카드 (todo로 못 잡은 정보) + 3. importance 내림차순 정렬 + +윤정님 모델이 임계값에서 컷한 정보(운영시간/신청기간 등)를 regex 슬롯이 +보완 — 강사님 의견 "슬롯 위주 표시" 본질과 정합. +""" +from __future__ import annotations + +import re + +from app.models.schemas import Category, SlotCard, YunjeongTodo +from app.services.classifier import classify_category +from app.services.easy_korean import to_easy_korean +from app.services.header_split import split_header_value +from app.services.translator import translate_short_sentence, translate_term + +# 헤더 추정 실패 시 fallback +_FALLBACK_HEADER = "기타" + +# regex 슬롯별 기본 헤더 (todo에서 못 잡은 정보 보강용 카드) +# todo로 헤더가 추정된 경우엔 이 카드를 만들지 않음 (중복 방지). +_SLOT_HEADERS: dict[str, str] = { + "dates": "일시", + "times": "시간", + "urls": "신청 URL", + "phones": "연락처", + "amounts": "비용", +} + +# regex 슬롯이 todo로 이미 흡수됐는지 판단할 헤더 매핑. +# 예: todo가 "운영시간" 헤더로 이미 추출됐으면 regex times 카드는 만들지 않음. +_TODO_HEADER_COVERS: dict[str, set[str]] = { + "times": {"운영시간", "신청시간", "시간"}, + "dates": {"운영날짜", "일시", "기간", "운영기간"}, + "urls": {"신청 URL", "신청경로", "신청방법"}, + "phones": {"연락처", "문의", "문의처"}, + "amounts": {"비용", "회비", "참가비", "수강료", "급식비"}, +} + + +def _build_card_from_todo(todo: YunjeongTodo, target_lang: str) -> SlotCard: + """YunjeongTodo → SlotCard.""" + header, value = split_header_value(todo.text) + if header is None: + header = _FALLBACK_HEADER + + category = classify_category(value) + chip = category.value if category != Category.other else None + + return SlotCard( + header_ko=header, + header_translated=translate_term(header, target_lang), + value_ko=value, + value_easy_ko=to_easy_korean(value), + value_translated=translate_short_sentence(value, target_lang) or value, + chip=chip, + importance=todo.confidence, + ) + + +def _slot_entry_ko(entry: dict | str) -> str: + if isinstance(entry, dict): + return entry.get("ko", "") + return entry + + +def _slot_entry_translated(entry: dict | str) -> str: + if isinstance(entry, dict): + return entry.get("translated") or entry.get("ko", "") + return entry + + +def _build_cards_from_regex_slots( + regex_slots: dict[str, list[dict]], + target_lang: str, + todo_headers: set[str], +) -> list[SlotCard]: + """regex 슬롯 → 보강 SlotCard. todo 헤더가 이미 커버한 슬롯은 스킵.""" + cards: list[SlotCard] = [] + + for slot_name, default_header in _SLOT_HEADERS.items(): + entries = regex_slots.get(slot_name, []) + if not entries: + continue + # todo가 이미 이 슬롯을 커버하면 스킵 (중복 카드 방지) + if todo_headers & _TODO_HEADER_COVERS.get(slot_name, set()): + continue + + # 슬롯당 한 카드 — 여러 값은 콤마 구분 + values_ko = [_slot_entry_ko(e) for e in entries] + values_translated = [_slot_entry_translated(e) for e in entries] + value_ko = ", ".join(v for v in values_ko if v) + value_translated = ", ".join(v for v in values_translated if v) + if not value_ko: + continue + + cards.append(SlotCard( + header_ko=default_header, + header_translated=translate_term(default_header, target_lang), + value_ko=value_ko, + value_easy_ko=to_easy_korean(value_ko), + value_translated=value_translated or value_ko, + chip=None, # regex 슬롯은 칩 없음 — todo가 아니므로 카테고리 모호 + importance=0.7, # todo 평균 confidence보다 살짝 낮음 + )) + + return cards + + +# dedup 비교용 — 표 구분자/콜론/연속 공백 정규화 (`|`/`:`/`:` 등 차이로 substring 놓치는 것 방지) +_DEDUP_NORMALIZE = re.compile(r"[\s||::]+") + + +def _normalize_for_dedup(text: str) -> str: + """value 비교용 정규화 — 공백/구분자 차이 무시.""" + return _DEDUP_NORMALIZE.sub(" ", text).strip() + + +def _dedup_cards(cards: list[SlotCard]) -> list[SlotCard]: + """같은 헤더 안에서 substring 카드 제거. + + pdfplumber가 본문 + [표] 양쪽에서 같은 정보를 추출해 두 카드로 들어오는 경우 + (예: 서대구초 운영방법 156자 본문 카드 + 38자 표 영역 카드) 짧은 쪽이 긴 + 쪽 안에 substring으로 들어있으면 짧은 쪽 제거. 정보 손실 0. + + 헤더가 다르면 손대지 않음 — 운영방법/일시/시간/URL 등 다른 슬롯은 별개. + """ + by_header: dict[str, list[SlotCard]] = {} + for c in cards: + by_header.setdefault(c.header_ko, []).append(c) + + keep: list[SlotCard] = [] + for group in by_header.values(): + # 긴 value 우선 — 짧은 게 긴 것 substring이면 제거 가능 + group.sort(key=lambda c: -len(c.value_ko)) + kept_norms: list[str] = [] + for card in group: + norm = _normalize_for_dedup(card.value_ko) + if any(norm in k for k in kept_norms): + continue + kept_norms.append(norm) + keep.append(card) + return keep + + +def build_cards( + todos: list[YunjeongTodo], + regex_slots: dict[str, list[dict]], + target_lang: str, +) -> list[SlotCard]: + """todos + regex_slots → list[SlotCard]. dedup + importance 내림차순 정렬.""" + cards = [_build_card_from_todo(t, target_lang) for t in todos] + + todo_headers = {c.header_ko for c in cards} + cards.extend(_build_cards_from_regex_slots(regex_slots, target_lang, todo_headers)) + + cards = _dedup_cards(cards) + cards.sort(key=lambda c: -c.importance) + return cards diff --git a/backend/app/services/classifier.py b/backend/app/services/classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..9b04bececfde205e07f6b1407c49fa8a5106a9b6 --- /dev/null +++ b/backend/app/services/classifier.py @@ -0,0 +1,48 @@ +"""경이님 6-class 분류 모델 wrapper. + +문장 → Category (일정/준비물/제출/비용/건강·안전/기타). +파이프라인 [4] 단계 — 윤정님 todo 각각에 대해 호출되어 AnalyzeItem.category로 들어감. + +모델 모드: "auto" — KcELECTRA 체크포인트 있으면 그쪽, 없으면 simple(TF-IDF+LogReg) 폴백. +경이님 v2 KcELECTRA 학습이 끝나면 자동으로 더 정확한 모델로 업그레이드됨. +""" +import sys +from datetime import date +from pathlib import Path + +from app.models.schemas import Category + +_CLF_DIR = Path("/app/external_model/classification") +if str(_CLF_DIR) not in sys.path: + sys.path.insert(0, str(_CLF_DIR)) + +# 외부 마운트가 없는 환경(CI/테스트)에선 import 가드 — 모듈 로드만큼은 안전하게. +try: + from src.predict import predict_one # noqa: E402 +except ImportError as error: + print(f"[classifier] predict_one unavailable: {error}") + predict_one = None + + +def classify_category(text: str, today: date | None = None) -> Category: + """문장 → 6-class 카테고리. 실패/미정 시 Category.other. + + model="auto"로 호출 — 경이님 predict_one이 KcELECTRA 체크포인트 존재 여부를 + 감지해 자동 선택. simple은 첫 호출 시 학습 데이터로 자가 학습 (~3초). + """ + if not text or not text.strip(): + return Category.other + if predict_one is None: + return Category.other # 모델 부재 (CI 등) — 안전한 기본값 + + try: + result = predict_one(text, model="auto", today=today, explain=False) + except Exception as error: + print(f"[classifier] predict_one failed: {error}") + return Category.other + + label = result.get("category", "") + try: + return Category(label) + except ValueError: + return Category.other diff --git a/backend/app/services/easy_korean.py b/backend/app/services/easy_korean.py new file mode 100644 index 0000000000000000000000000000000000000000..94c9b418e40030a00d542c930082e524c83dafad --- /dev/null +++ b/backend/app/services/easy_korean.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import re + + +EASY_KO_DICT = { + "제출": "제출(내기)", + "납부": "납부(지불)", + "준비물": "준비할 것", + "방역지침": "마스크 착용 등 방역 규칙", + "신청": "신청", + "개인": "각자", + "해당": "관련", +} + +_PROTECTED_PATTERN = re.compile( + r"(" + r"\d{4}\s*년\s*\d{1,2}\s*월\s*\d{1,2}\s*일" + r"|" + r"\d{1,2}\s*월\s*\d{1,2}\s*일" + r"|" + r"\d{1,2}\s*/\s*\d{1,2}" + r"|" + r"\d{1,3}(?:,\d{3})+원" + r"|" + r"\d+원" + r")" +) +_PLACEHOLDER_PATTERN = re.compile(r"__EASY_KO_PROTECTED_(\d+)__") +_WORD_CHARS = r"가-힣A-Za-z0-9_" +_POLITE_ENDING_PATTERN = re.compile(r"(합니다|세요|입니다)[.!?]?$") + + +def _protect_patterns(text: str) -> tuple[str, list[str]]: + protected: list[str] = [] + + def stash(match: re.Match[str]) -> str: + protected.append(match.group(0)) + return f"__EASY_KO_PROTECTED_{len(protected) - 1}__" + + return _PROTECTED_PATTERN.sub(stash, text), protected + + +def _restore_patterns(text: str, protected: list[str]) -> str: + def restore(match: re.Match[str]) -> str: + index = int(match.group(1)) + return protected[index] if index < len(protected) else match.group(0) + + return _PLACEHOLDER_PATTERN.sub(restore, text) + + +def _replace_terms(text: str) -> str: + for source, target in EASY_KO_DICT.items(): + pattern = re.compile(rf"(? str: + stripped = text.strip() + if not stripped or _POLITE_ENDING_PATTERN.search(stripped): + return text + + suffix_map = { + "신청": "신청해야 합니다", + "제출(내기)": "제출해야 합니다", + "준비": "준비해야 합니다", + "참가": "참가해야 합니다", + } + + trailing_punctuation = "" + if stripped[-1] in ".!?": + trailing_punctuation = stripped[-1] + stripped = stripped[:-1].rstrip() + + for suffix, replacement in suffix_map.items(): + if stripped.endswith(suffix): + return stripped[: -len(suffix)] + replacement + trailing_punctuation + return text + + +def to_easy_korean(value_ko: str) -> str: + try: + if not value_ko: + return value_ko + masked, protected = _protect_patterns(value_ko) + converted = _replace_terms(masked) + converted = _make_ending_natural(converted) + return _restore_patterns(converted, protected) + except Exception: + return value_ko + + +if __name__ == "__main__": + samples = [ + "3월 17일까지 신청", + "5,000원 납부", + "개인 준비물 지참", + "온라인 사전예약 후 방문 바랍니다", + "방역지침 준수", + ] + for sample in samples: + print(f"{sample} -> {to_easy_korean(sample)}") diff --git a/backend/app/services/extractor.py b/backend/app/services/extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..2cfb8dcab83614fae6fdcd29789f8b4c3d26ba07 --- /dev/null +++ b/backend/app/services/extractor.py @@ -0,0 +1,92 @@ +"""윤정님 추출 모델 wrapper. + +가정통신문 텍스트 → list[YunjeongTodo]. +v2 모델 (model/extraction/file/predict.py): binary 분류(BINARY_THRESHOLD=0.5) + +정규식 due_date/amount/action_hint. 출력 스키마가 YunjeongTodo와 1:1. + +v1 (구버전 model/extraction/predict.py)은 윤정님이 v2 머지하면서 삭제. +v1 adapter 경로는 호환성을 위해 남겨두지만 실제로는 v2 진입점이 사용됨. +""" +import re +import sys +from pathlib import Path + +from app.models.schemas import YunjeongTodo + +# v2 진입점은 model/extraction/file/predict.py. +# CI/테스트 환경에선 외부 마운트 부재 → 가드로 빈 결과 반환. +_EXTRACTION_DIR = Path("/app/external_model/extraction/file") +if str(_EXTRACTION_DIR) not in sys.path: + sys.path.insert(0, str(_EXTRACTION_DIR)) + +try: + import predict as _yunjeong # noqa: E402 +except ImportError as error: + print(f"[extractor] predict module unavailable: {error}") + _yunjeong = None + +_AMOUNT_RE = re.compile(r"(\d{1,3}(?:,\d{3})+|\d+)\s*원") + + +def extract_title(notice_text: str) -> str | None: + """가정통신문 원문 → 제목 한 줄. 못 찾으면 None. + + 윤정님 PR #90 (predict.py:extract_title) — split_sentences()의 + _HEADER_ONLY 필터가 제목을 차단하기 전에 원문 줄을 직접 스캔. + predict()와 별도 호출. + """ + if not notice_text or not notice_text.strip(): + return None + if _yunjeong is None or not hasattr(_yunjeong, "extract_title"): + return None + try: + return _yunjeong.extract_title(notice_text) + except Exception as error: + print(f"[extractor] extract_title failed: {error}") + return None + + +def extract_todos(notice_text: str, source: str | None = None) -> list[YunjeongTodo]: + """가정통신문 원문 → list[YunjeongTodo]. 할일 없으면 [].""" + if not notice_text or not notice_text.strip(): + return [] + if _yunjeong is None: + return [] # 모델 모듈 없음 (CI 등) — 빈 결과로 후속 단계 정상 동작 + + # v2 진입점: predict(text, source) → list[dict] + if hasattr(_yunjeong, "predict"): + try: + raw_items = _yunjeong.predict(notice_text, source=source) + except TypeError: + # source kwarg 미지원 버전 호환 + raw_items = _yunjeong.predict(notice_text) + return [YunjeongTodo(**raw) for raw in raw_items] + + # v1 fallback (구버전 predict.py가 마운트되어 있을 경우) + if hasattr(_yunjeong, "extract_todos_dict"): + raw_items = _yunjeong.extract_todos_dict(notice_text) + return [_adapt_v1(item) for item in raw_items] + + return [] + + +def _adapt_v1(v1_item: dict) -> YunjeongTodo: + text = v1_item.get("text_ko", "") + return YunjeongTodo( + text=text, + source=None, + due_date=v1_item.get("due_date"), + amount=_extract_amount_value(text), + confidence=float(v1_item.get("importance", 0.5)), + action_hint=None, + ) + + +def _extract_amount_value(text: str) -> int | None: + m = _AMOUNT_RE.search(text) + if not m: + return None + try: + return int(m.group(1).replace(",", "")) + except ValueError: + return None diff --git a/backend/app/services/header_split.py b/backend/app/services/header_split.py new file mode 100644 index 0000000000000000000000000000000000000000..9c5c129e3f1284eb498c9bf005e1808aa4bb24f7 --- /dev/null +++ b/backend/app/services/header_split.py @@ -0,0 +1,65 @@ +"""슬롯 카드 헤더 분해 — todo.text → (헤더, 값) 페어. + +강사님 처방 "슬롯 위주 가공" 대응: + 슬롯 카드 = 헤더(라벨) 굵게 + 값 행. 본 모듈이 헤더 추출 책임. + +윤정님 split_sentences가 이미 헤더 단위로 todo를 분리해서 반환하므로 +보통 todo.text 시작에 헤더 키워드가 옴. 또한 윤정님이 한글 프로 기호 + +표 구분자(`|`)를 정제하기로 합의됨 — 매칭 시 구분자는 옵셔널 처리. + +매칭 실패 시 (None, 원문)을 반환해 호출부가 fallback("기타") 헤더로 처리. +""" +from __future__ import annotations + +import re + +# 가정통신문 표준 헤더 키워드. +# 윤정님 split_sentences 분리 룰의 키워드 + 갈산초/서대구초 케이스 보강. +# 긴 표현이 짧은 것에 흡수되지 않게 정렬은 길이 내림차순 (예: "기타 안내사항" 우선, "기타" 후순위). +HEADER_KEYWORDS: list[str] = [ + # 운영 계열 + "운영시간", "운영방법", "운영날짜", "운영기간", "운영장소", + # 신청 계열 + "신청방법", "신청기간", "신청경로", "신청대상", "신청자격", + # 접수/제출 + "접수기간", "접수방법", "접수처", + "제출방법", "제출기한", "제출처", + # 일정/장소 + "일시", "기간", "장소", "위치", "주소", + # 대상/자격 + "대상", "자격", "참가대상", + # 준비물/비용 + "준비물", "지참물", "준비사항", + "비용", "회비", "참가비", "수강료", "급식비", + # 안내/유의 + "기타 안내사항", "기타안내사항", "안내사항", + "유의사항", "참고사항", "주의사항", + # 문의 + "문의", "연락처", "문의처", + # 기타 (가장 짧음, 마지막) + "기타", +] + +_HEADER_KEYWORDS_SORTED = sorted(HEADER_KEYWORDS, key=len, reverse=True) + +# 줄 시작 + 헤더 키워드 + (선택적 `|`/`:`/`:` 구분자) + 값 +# `|`는 윤정님이 정제하기 전엔 살아있을 수 있어 옵셔널 처리. +_HEADER_RE = re.compile( + r"^\s*(?P
" + "|".join(re.escape(k) for k in _HEADER_KEYWORDS_SORTED) + r")" + r"\s*[|::]?\s*" + r"(?P.+)$", + re.DOTALL, +) + + +def split_header_value(text: str) -> tuple[str | None, str]: + """todo.text → (헤더, 값) 페어. + + 매칭 실패 시 (None, text.strip()) 반환 — 호출부에서 "기타" 같은 fallback 헤더 처리. + """ + if not text or not text.strip(): + return None, "" + m = _HEADER_RE.match(text.strip()) + if m: + return m.group("header"), m.group("value").strip() + return None, text.strip() diff --git a/backend/app/services/mock.py b/backend/app/services/mock.py new file mode 100644 index 0000000000000000000000000000000000000000..ec38dba64dee69c1c78d0b34a60a9bf109e49ab1 --- /dev/null +++ b/backend/app/services/mock.py @@ -0,0 +1,44 @@ +"""모델 연결 전 mock 응답 데이터. + +extract_todos 실패/빈결과 시 fallback. 슬롯 파이프라인에서는 이 mock 도 +[3] YunjeongTodo → [4] 경이님 분류 → [6] AnalyzeItem 흐름을 그대로 통과한다. +""" +from app.models.schemas import YunjeongTodo + +MOCK_TODOS: list[YunjeongTodo] = [ + YunjeongTodo( + text="현장체험학습 동의서를 내일까지 제출해주세요", + due_date="내일", + amount=None, + confidence=0.95, + action_hint="제출", + ), + YunjeongTodo( + text="도시락, 개인 물병, 돗자리를 준비해주세요", + due_date=None, + amount=None, + confidence=0.9, + action_hint="준비", + ), + YunjeongTodo( + text="체험학습비 15,000원을 4월 22일까지 납부해주세요", + due_date="2026-04-22", + amount=15000, + confidence=0.92, + action_hint="납부", + ), + YunjeongTodo( + text="4월 25일(금) 봄 소풍이 있습니다", + due_date="2026-04-25", + amount=None, + confidence=0.78, + action_hint="참여", + ), + YunjeongTodo( + text="미세먼지 심한 날에는 마스크를 착용해주세요", + due_date=None, + amount=None, + confidence=0.7, + action_hint="확인", + ), +] diff --git a/backend/app/services/parser.py b/backend/app/services/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..f5bcf278a2ff7c6ca8afdf0e3bad2378c78cd8f6 --- /dev/null +++ b/backend/app/services/parser.py @@ -0,0 +1,290 @@ +"""HWP/PDF/text 입력 → clean_text 변환. + +파이프라인 [1] 단계. 호스트 앱이 어떤 양식으로 보내든 백엔드가 텍스트로 흡수. + +지원: + - text (text/plain) → 그대로 + - PDF (application/pdf) → pdfplumber 본문 + 표 [표] 섹션 + - HWP/HWPX → LibreOffice + H2Orestart로 ODT 변환 → content.xml 직접 파싱 + +이미지(.jpg/.png) OCR은 별도 단계 (세종님 OCR 합류 시 추가). + +ODT 경로 채택 이유 (vs 이전 HWP→PDF): + HWP→PDF→pdfplumber는 LibreOffice가 텍스트를 두 번 그려 글자가 중복 + 추출되는 문제 ("22002266학학년년도도"). ODT(zip+content.xml)는 구조화된 + 단일 출력이라 중복 0. 검증: hwp5txt 28b 실패, docx 0c 실패, odt 1899c + 키워드 6/6 보존. + +보안 모델: + - 원본 filename은 .suffix 추출에만 사용. 추출된 suffix는 화이트리스트 검사 + (TEXT_EXTS / PDF_EXTS / HWP_EXTS) 통과 못 하면 ParserError로 즉시 거부. + - 디스크 저장 경로는 tempfile.TemporaryDirectory + 고정 이름 input{suffix}. + 원본 filename은 어떤 경로/명령에도 사용되지 않음. + - subprocess 호출은 list 인자 형태(쉘 미사용) → 명령 주입 표면 없음. +""" +from __future__ import annotations + +import os +import re +import subprocess +import tempfile +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +# pdfplumber는 외부 의존이라 CI/테스트 안전하게 가드. +# 운영에선 requirements.txt + Dockerfile로 보장. 부재면 첫 PDF 호출 시점에 명확한 메시지. +try: + import pdfplumber # type: ignore +except ImportError as error: + print(f"[parser] pdfplumber unavailable: {error}") + pdfplumber = None + + +PDF_EXTS = {".pdf"} +HWP_EXTS = {".hwp", ".hwpx"} +TEXT_EXTS = {".txt", ".md"} +IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp"} +ALLOWED_EXTS = PDF_EXTS | HWP_EXTS | TEXT_EXTS | IMG_EXTS + +# LibreOffice 변환 타임아웃 (초). 큰 HWP는 ENV로 오버라이드 가능. +LIBREOFFICE_TIMEOUT_SECONDS = int(os.environ.get("PARSER_LIBREOFFICE_TIMEOUT", "300")) + + +class ParserError(RuntimeError): + """변환 실패 시 호출부가 잡을 수 있는 단일 예외.""" + + +def normalize(text: str) -> str: + """null 제거 + 한 줄 안 다중 공백만 정리. 줄바꿈 보존, 연속 빈 줄은 1개로.""" + text = text.replace("\x00", " ") + out_lines: list[str] = [] + prev_empty = False + for line in text.split("\n"): + line = re.sub(r"[ \t]+", " ", line).strip() + if not line: + if not prev_empty: + out_lines.append(line) + prev_empty = True + else: + out_lines.append(line) + prev_empty = False + return "\n".join(out_lines).strip() + + +def _pdf_to_text(pdf_path: Path) -> str: + """본문 텍스트(표 영역 제외) + 표(행 단위 정리) 분리.""" + if pdfplumber is None: + raise ParserError( + "pdfplumber 미설치. backend 컨테이너 재빌드(docker compose build backend) " + "또는 pip install pdfplumber 필요." + ) + + body_pages: list[str] = [] + table_blocks: list[str] = [] + + with pdfplumber.open(pdf_path) as pdf: + for page in pdf.pages: + tables = page.extract_tables() or [] + for tbl in tables: + rows: list[str] = [] + for row in tbl: + cells = [(c or "").replace("\n", " ").strip() for c in row] + if any(cells): + rows.append(" | ".join(cells)) + if rows: + table_blocks.append("\n".join(rows)) + + table_bboxes = [t.bbox for t in (page.find_tables() or [])] + if table_bboxes: + def outside_tables(obj): + if obj.get("object_type") != "char": + return True + cx = (obj["x0"] + obj["x1"]) / 2 + cy = (obj["top"] + obj["bottom"]) / 2 + for bbox in table_bboxes: + x0, top, x1, bottom = bbox + if x0 <= cx <= x1 and top <= cy <= bottom: + return False + return True + page_view = page.filter(outside_tables) + body = page_view.extract_text() or "" + else: + body = page.extract_text() or "" + + if body: + body_pages.append(body) + + parts: list[str] = [] + if body_pages: + parts.append("\n\n".join(body_pages)) + if table_blocks: + parts.append("[표]\n" + "\n\n".join(table_blocks)) + return "\n\n".join(parts) + + +def _image_to_text(img_path: Path) -> str: + """카메라 사진(.jpg/.png) → 텍스트. Tesseract 한국어 OCR. + + 전체 이미지 1차 OCR → 한국어 문자 외 노이즈 정리. + 표 영역 재처리(2차 OCR)는 별도 로직으로 확장 가능. + """ + try: + import pytesseract + from PIL import Image + except ImportError as e: + raise ParserError(f"OCR 의존 미설치: {e}. Docker 재빌드 필요.") + + try: + img = Image.open(img_path).convert("RGB") + except Exception as e: + raise ParserError(f"이미지 열기 실패: {e}") + + try: + # psm 3: 자동 레이아웃 감지 (표·단락 혼재 가정통신문에 적합) + text = pytesseract.image_to_string(img, lang="kor", config="--psm 3 --oem 1") + except Exception as e: + raise ParserError(f"Tesseract OCR 실패: {e}") + + return text + + +# ODT content.xml 네임스페이스 +_ODT_TEXT_NS = "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}" +_ODT_TABLE_NS = "{urn:oasis:names:tc:opendocument:xmlns:table:1.0}" +_ODT_DRAW_NS = "{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}" + + +def _hwp_to_odt(hwp_path: Path, out_dir: Path) -> Path: + """LibreOffice headless로 HWP → ODT. + + HWP→PDF 경로의 doubled-char 문제 회피. ODT는 zip 구조라 본문/표가 + 단일 트리에 한 번만 들어감. 타임아웃: PARSER_LIBREOFFICE_TIMEOUT. + """ + out_dir.mkdir(parents=True, exist_ok=True) + try: + result = subprocess.run( + [ + "libreoffice", "--headless", + "--convert-to", "odt", + "--outdir", str(out_dir), + str(hwp_path), + ], + capture_output=True, text=True, + timeout=LIBREOFFICE_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + raise ParserError( + f"LibreOffice 변환 타임아웃 ({LIBREOFFICE_TIMEOUT_SECONDS}초 초과). " + "큰 HWP면 PARSER_LIBREOFFICE_TIMEOUT 환경변수로 늘릴 수 있음." + ) + odt_path = out_dir / f"{hwp_path.stem}.odt" + # H2Orestart가 ODT 변환 후 종료 시점에 종종 Signal 11 (cleanup 버그)을 내지만 + # 출력 파일은 정상. 파일 존재 여부를 성공 기준으로 — returncode/stderr는 참고만. + if not odt_path.exists(): + raise ParserError( + f"LibreOffice ODT 변환 실패 (출력 파일 없음). " + f"returncode={result.returncode}, stderr={result.stderr.strip()[:200]}" + ) + return odt_path + + +def _odt_to_text(odt_path: Path, mark_header: bool = False) -> str: + """ODT(zip) content.xml → 본문 + 표 영역 평면 텍스트. + + 표 안 paragraph는 본문 처리에서 제외(중복 방지). 표는 셀 단위 공백 합치고 + 행 단위 줄바꿈으로 평면화 — `|` 구분자 X, `[표]` 마커 X. + 윤정님 split_sentences가 헤더 키워드 lookahead("운영시간"/"운영방법"/...)로 + 행 안에서 의미 단위 자연 분리하므로 셀 구분자 불필요. + + mark_header=True: 각 표의 첫 번째 행 앞에 "[헤더] " 마킹 + 셀을 " | " 구분. + 기본값 False — 기존 호출부(parse_bytes_to_text, batch_convert.py) 변경 없음. + """ + with zipfile.ZipFile(odt_path) as z: + with z.open("content.xml") as f: + tree = ET.parse(f) + + # 표/draw:frame 안 element id 모음 → 본문 처리에서 제외 + # draw:frame: 텍스트 상자/이미지 프레임 — 본문과 같은 텍스트가 중복 저장돼 + # 3배 이상 반복되는 아티팩트 원인. 표 inner 제외와 동일 방식. + table_inner_ids: set[int] = set() + for table in tree.iter(_ODT_TABLE_NS + "table"): + for elem in table.iter(): + table_inner_ids.add(id(elem)) + for frame in tree.iter(_ODT_DRAW_NS + "frame"): + for elem in frame.iter(): + table_inner_ids.add(id(elem)) + + body_parts: list[str] = [] + for elem in tree.iter(): + tag = elem.tag + if tag in (_ODT_TEXT_NS + "p", _ODT_TEXT_NS + "h"): + if id(elem) in table_inner_ids: + continue + text = "".join(elem.itertext()).strip() + if text: + body_parts.append(text) + + table_blocks: list[str] = [] + for table in tree.iter(_ODT_TABLE_NS + "table"): + rows: list[str] = [] + for row_idx, row in enumerate(table.iter(_ODT_TABLE_NS + "table-row")): + cells: list[str] = [] + for cell in row.iter(_ODT_TABLE_NS + "table-cell"): + cell_text = "".join(cell.itertext()).strip() + if cell_text: + cells.append(cell_text) + if cells: + if mark_header and row_idx == 0: + rows.append("[헤더] " + " | ".join(cells)) + else: + rows.append(" ".join(cells)) + if rows: + table_blocks.append("\n".join(rows)) + + parts: list[str] = [] + if body_parts: + parts.append("\n".join(body_parts)) + if table_blocks: + parts.append("\n\n".join(table_blocks)) + return "\n\n".join(parts) + + +def parse_bytes_to_text(data: bytes, filename: str) -> str: + """업로드된 bytes + 파일명 → 정규화된 clean_text. + + 호출부(라우터)는 파일 확장자 분기 신경 안 쓰고 이 함수만 부르면 됨. + """ + if not data: + return "" + + suffix = Path(filename).suffix.lower() + + # 화이트리스트 검사: 알 수 없는 suffix는 일찍 거부. + # (subprocess는 어차피 list-form이라 명령 주입은 불가능하지만 표면을 줄임) + if suffix and suffix not in ALLOWED_EXTS: + raise ParserError(f"지원하지 않는 파일 형식: {suffix}") + + if suffix in TEXT_EXTS or suffix == "": + return normalize(data.decode("utf-8", errors="replace")) + + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) + # 디스크 경로는 항상 tempdir 안의 고정 이름. 원본 filename은 어디에도 안 들어감. + src_path = tmp_dir / f"input{suffix}" + src_path.write_bytes(data) + + if suffix in PDF_EXTS: + raw = _pdf_to_text(src_path) + return normalize(raw) + + if suffix in HWP_EXTS: + odt_path = _hwp_to_odt(src_path, tmp_dir) + raw = _odt_to_text(odt_path) + return normalize(raw) + + if suffix in IMG_EXTS: + raw = _image_to_text(src_path) + return normalize(raw) + + raise ParserError(f"지원하지 않는 파일 형식: {suffix}") diff --git a/backend/app/services/slot_extractor.py b/backend/app/services/slot_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..a6ccc6bfa118c2bb8149b225c82061cb4a50aa6f --- /dev/null +++ b/backend/app/services/slot_extractor.py @@ -0,0 +1,364 @@ +"""정규식 + i18n 포매터 기반 슬롯 추출기. + +강사 처방(2026-04-28) 대응: + - 날짜·시간·금액 같은 명확한 수치 데이터는 정규식으로 1차 안전 추출 + - LLM 추론에 100% 의존하지 않고 지정된 위치(슬롯)에 매핑 + - NLLB/glossary 거치지 않고 i18n 룰만으로 대상 언어 변환 + +설계: + extract_dates/extract_times/extract_amounts → 구조화된 dict 리스트 + format_date/format_time/format_amount → 대상 언어 문자열 + extract_summary_regex_slots → 위 둘을 묶어 SlotEntry 호환 dict 반환 +""" +from __future__ import annotations + +import re + +# ── 정규식 ──────────────────────────────────────────────────────── +_DATE_FULL = re.compile( + r"(?P\d{4})\s*년\s*(?P\d{1,2})\s*월\s*(?P\d{1,2})\s*일" + r"(?:\s*\((?P[월화수목금토일])\))?" +) +_DATE_MD = re.compile( + r"(?P\d{1,2})\s*월\s*(?P\d{1,2})\s*일" + r"(?:\s*\((?P[월화수목금토일])\))?" +) +_DATE_SLASH = re.compile(r"\b(?P\d{1,2})\s*[/.]\s*(?P\d{1,2})\b") +# 점 표기 ("2026. 4. 18.(토)" / "5. 16.(토)") — 요일 필수로 시간·금액 오탐 회피 +_DATE_DOT = re.compile( + r"(?:(?P\d{4})\s*\.\s*)?" + r"(?P\d{1,2})\s*\.\s*" + r"(?P\d{1,2})\s*\.?" + r"\s*\((?P[월화수목금토일])\)" +) +_DATE_RELATIVE = ["내일", "모레", "오늘", "다음 주", "다음주", "이번 주", "이번주"] + +_TIME_AMPM = re.compile( + r"(?P오전|오후)\s*(?P\d{1,2})\s*시(?:\s*(?P\d{1,2})\s*분)?" +) +_TIME_24H = re.compile(r"(?\d{1,2}):(?P\d{2})(?!\d)") + +_AMOUNT_KRW = re.compile(r"(?P\d{1,3}(?:,\d{3})+|\d+)\s*원") +_AMOUNT_KO = re.compile(r"(?P\d+)\s*(?P만|천|억)\s*원") + +# URL: http(s)://… 또는 www.… — NLLB가 토큰화하면서 깨먹는 패턴 방지용 슬롯 +_URL = re.compile(r"\bhttps?://[^\s<>\"'()]+|\bwww\.[^\s<>\"'()]+", re.IGNORECASE) +# 전화번호: 02-xxx-xxxx, 02-xxxx-xxxx, 010-xxxx-xxxx, 1588-0260, 849-7003 등 +# 시작·끝에 숫자 인접 금지 (15,000원 같은 금액 부분 매칭 회피) +_PHONE = re.compile( + r"(? str: + """줄머리 장식 마크업 제거. UI 표시·TTS 양쪽 노이즈 방지.""" + return _MARKER_STRIP.sub("", text).strip() + + +# ── 추출기 ──────────────────────────────────────────────────────── +def extract_dates(text: str) -> list[dict]: + """텍스트에서 날짜 후보를 모두 뽑는다. 중복은 표면형 기준으로 제거.""" + out: list[dict] = [] + seen: set[str] = set() + + for m in _DATE_FULL.finditer(text): + ko = m.group(0).strip() + if ko in seen: + continue + seen.add(ko) + out.append({ + "ko": ko, + "year": int(m.group("year")), + "month": int(m.group("month")), + "day": int(m.group("day")), + "weekday": m.group("wday"), + }) + + for m in _DATE_MD.finditer(text): + # 이미 _DATE_FULL이 잡은 영역과 겹치면 스킵 + if any(s in text and m.start() >= text.find(s) and m.end() <= text.find(s) + len(s) for s in seen): + continue + ko = m.group(0).strip() + if ko in seen: + continue + seen.add(ko) + out.append({ + "ko": ko, + "year": None, + "month": int(m.group("month")), + "day": int(m.group("day")), + "weekday": m.group("wday"), + }) + + for m in _DATE_DOT.finditer(text): + ko = m.group(0).strip() + if ko in seen: + continue + seen.add(ko) + out.append({ + "ko": ko, + "year": int(m.group("year")) if m.group("year") else None, + "month": int(m.group("month")), + "day": int(m.group("day")), + "weekday": m.group("wday"), + }) + + for word in _DATE_RELATIVE: + if word in text and word not in seen: + seen.add(word) + out.append({"ko": word, "year": None, "month": None, "day": None, + "weekday": None, "relative": word}) + + return out + + +def extract_times(text: str) -> list[dict]: + out: list[dict] = [] + seen: set[str] = set() + for m in _TIME_AMPM.finditer(text): + ko = m.group(0).strip() + if ko in seen: + continue + seen.add(ko) + out.append({ + "ko": ko, + "hour": int(m.group("hour")), + "minute": int(m.group("minute")) if m.group("minute") else 0, + "ampm": m.group("ampm"), + }) + for m in _TIME_24H.finditer(text): + ko = m.group(0).strip() + if ko in seen: + continue + seen.add(ko) + out.append({ + "ko": ko, + "hour": int(m.group("hour")), + "minute": int(m.group("minute")), + "ampm": None, + }) + return out + + +def extract_amounts(text: str) -> list[dict]: + """원화 금액을 정규화된 정수와 함께 반환.""" + out: list[dict] = [] + seen: set[str] = set() + + for m in _AMOUNT_KRW.finditer(text): + ko = m.group(0).strip() + if ko in seen: + continue + seen.add(ko) + num = int(m.group("num").replace(",", "")) + out.append({"ko": ko, "value": num, "currency": "KRW"}) + + for m in _AMOUNT_KO.finditer(text): + ko = m.group(0).strip() + if ko in seen: + continue + seen.add(ko) + n = int(m.group("num")) + unit = m.group("unit") + mul = {"천": 1_000, "만": 10_000, "억": 100_000_000}[unit] + out.append({"ko": ko, "value": n * mul, "currency": "KRW"}) + + return out + + +def extract_deadline_phrases(text: str) -> list[str]: + """'까지'가 들어간 어구를 한 문장 단위로 추출. + + URL 안 마침표(`.do`/`.kr` 등)에서 phrase가 잘려 무의미하게 길어지는 것 회피 — + 매칭 전 URL을 placeholder로 마스킹 후 매칭. + """ + masked = _URL.sub(lambda m: "⟦U" + ("_" * (len(m.group(0)) - 3)) + "⟧", text) + return [m.group(1).strip() for m in _DEADLINE_PHRASE.finditer(masked)] + + +def extract_urls(text: str) -> list[str]: + """URL 표면형 그대로. NLLB로 보내지 말고 슬롯으로 격리.""" + seen: set[str] = set() + out: list[str] = [] + for m in _URL.finditer(text): + ko = m.group(0).strip().rstrip(".,)]") + if ko in seen: + continue + seen.add(ko) + out.append(ko) + return out + + +def extract_phones(text: str) -> list[str]: + """전화번호 표면형. 같은 이유로 슬롯 격리.""" + seen: set[str] = set() + out: list[str] = [] + for m in _PHONE.finditer(text): + ko = m.group(0).strip() + if ko in seen: + continue + seen.add(ko) + out.append(ko) + return out + + +# ── 다국어 포매터 ───────────────────────────────────────────────── +# 베트남어가 1차 시연 타깃이라 가장 정교하게. 나머지 언어는 안전한 디폴트. +_WEEKDAY_VI = {"월": "Thứ Hai", "화": "Thứ Ba", "수": "Thứ Tư", "목": "Thứ Năm", + "금": "Thứ Sáu", "토": "Thứ Bảy", "일": "Chủ Nhật"} +_WEEKDAY_EN = {"월": "Mon", "화": "Tue", "수": "Wed", "목": "Thu", + "금": "Fri", "토": "Sat", "일": "Sun"} +_RELATIVE_MAP = { + "vi": {"내일": "Ngày mai", "모레": "Ngày kia", "오늘": "Hôm nay", + "다음 주": "Tuần sau", "다음주": "Tuần sau", + "이번 주": "Tuần này", "이번주": "Tuần này"}, + "en": {"내일": "Tomorrow", "모레": "Day after tomorrow", "오늘": "Today", + "다음 주": "Next week", "다음주": "Next week", + "이번 주": "This week", "이번주": "This week"}, +} + + +def format_date(d: dict, target_lang: str) -> str: + if d.get("relative"): + return _RELATIVE_MAP.get(target_lang, {}).get(d["relative"], d["ko"]) + y, m, day, w = d.get("year"), d.get("month"), d.get("day"), d.get("weekday") + if not (m and day): + return d["ko"] + if target_lang == "vi": + s = f"Ngày {day}/{m}" + (f"/{y}" if y else "") + if w: + s += f" ({_WEEKDAY_VI[w]})" + return s + if target_lang == "en": + months = ["", "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"] + s = f"{months[m]} {day}" + (f", {y}" if y else "") + if w: + s += f" ({_WEEKDAY_EN[w]})" + return s + if target_lang in ("zh", "ja"): + return f"{m}月{day}日" + (f" ({w})" if w else "") + if target_lang == "ko_easy": + return d["ko"] # 그대로 + # ru / ms / th / mn 등은 일단 안전한 숫자 포맷 + return f"{day}/{m}" + (f"/{y}" if y else "") + + +def format_time(t: dict, target_lang: str) -> str: + h, mm, ampm = t.get("hour"), t.get("minute") or 0, t.get("ampm") + if h is None: + return t["ko"] + if target_lang == "vi": + suffix = "" + if ampm == "오전": + suffix = " sáng" + elif ampm == "오후": + suffix = " chiều" + return f"{h} giờ{(' ' + str(mm)) if mm else ''}{suffix}".strip() + if target_lang == "en": + ap = "AM" if ampm == "오전" else "PM" if ampm == "오후" else "" + return f"{h}:{mm:02d} {ap}".strip() if ap else f"{h}:{mm:02d}" + if target_lang in ("zh", "ja"): + prefix = "上午" if ampm == "오전" else "下午" if ampm == "오후" else "" + if target_lang == "ja": + prefix = "午前" if ampm == "오전" else "午後" if ampm == "오후" else "" + body = f"{h}時{mm:02d}分" if target_lang == "ja" else f"{h}时{mm:02d}分" + return f"{prefix}{body}" + if target_lang == "ko_easy": + return t["ko"] + return f"{h}:{mm:02d}" + + +def format_amount(a: dict, target_lang: str) -> str: + n = a["value"] + # 천단위 콤마. (베트남식 점 표기는 _post_process_vi와 충돌하므로 콤마로 통일) + formatted = f"{n:,}" + suffix = { + "vi": " won", "en": " won", "ms": " won", "ru": " вон", "mn": " вон", + "th": " วอน", "zh": "韩元", "ja": "ウォン", "ko_easy": "원", + }.get(target_lang, " won") + return f"{formatted}{suffix}" + + +# ── 통합 진입점 ────────────────────────────────────────────────── +def extract_summary_regex_slots(text: str, target_lang: str) -> dict[str, list[dict]]: + """summary 슬롯 중 정규식으로 채울 수 있는 항목들을 SlotEntry-ready dict로. + + urls/phones는 번역 안 거치고 ko 그대로 노출 (NLLB가 깨먹는 패턴 방어). + """ + out: dict[str, list[dict]] = { + "dates": [], "times": [], "amounts": [], "urls": [], "phones": [], + } + for d in extract_dates(text): + out["dates"].append({ + "ko": d["ko"], + "translated": format_date(d, target_lang), + "source": "regex", + }) + for t in extract_times(text): + out["times"].append({ + "ko": t["ko"], + "translated": format_time(t, target_lang), + "source": "regex", + }) + for a in extract_amounts(text): + out["amounts"].append({ + "ko": a["ko"], + "translated": format_amount(a, target_lang), + "source": "regex", + }) + for url in extract_urls(text): + out["urls"].append({"ko": url, "translated": url, "source": "regex"}) + for phone in extract_phones(text): + out["phones"].append({"ko": phone, "translated": phone, "source": "regex"}) + return out + + +def find_when_in_text(text: str, target_lang: str) -> str | None: + """item.when 용 — 텍스트에서 첫 날짜 + 시간 조합.""" + dates = extract_dates(text) + times = extract_times(text) + parts: list[str] = [] + if dates: + parts.append(format_date(dates[0], target_lang)) + if times: + parts.append(format_time(times[0], target_lang)) + return " ".join(parts) if parts else None + + +def find_amount_in_text(text: str, target_lang: str) -> str | None: + """item.amount 용 — 텍스트의 첫 금액(원문 ko 그대로 반환, 안드 표시는 summary 통해 i18n).""" + amounts = extract_amounts(text) + return amounts[0]["ko"] if amounts else None + + +def find_deadline_in_text(text: str) -> str | None: + """item.deadline 용 — '까지'가 들어간 첫 어구의 ko 표면형.""" + phrases = extract_deadline_phrases(text) + return phrases[0] if phrases else None + + +# ── 준비물 리스트 분해 ──────────────────────────────────────────── +# "도시락, 물통, 돗자리, 편한 운동화, 여벌 옷" → 5개 토큰 +_LIST_SEP = re.compile(r"[,,、]\s*|\s*(?:과|와|및)\s+") + + +def split_supply_tokens(text: str) -> list[str]: + """카테고리=준비물 todo의 텍스트에서 항목 토큰 분리. + + 안내 문구('준비물:', '준비해 주세요' 등)는 제거 후 분리. + """ + cleaned = re.sub(r"준비물\s*[::]?\s*", "", text) + cleaned = re.sub(r"(을|를)?\s*(준비해|챙겨|가져).*$", "", cleaned) + tokens = [t.strip() for t in _LIST_SEP.split(cleaned) if t and t.strip()] + return [t for t in tokens if 1 < len(t) < 30] diff --git a/backend/app/services/translator.py b/backend/app/services/translator.py new file mode 100644 index 0000000000000000000000000000000000000000..9b256acebef5ee26338d516a7efd0e86ab87ced5 --- /dev/null +++ b/backend/app/services/translator.py @@ -0,0 +1,299 @@ +"""세종님 NLLB 번역 + 용어 검수 wrapper. + +run_mvp_pipeline.py의 가벼운 함수들(easy_korean, glossary)은 직접 호출. +NLLB 번역은 매번 모델 새로 로드하지 않게 캐싱. + +URL/전화는 NLLB가 토큰화하면서 깨먹는 패턴이라 placeholder 치환 + 복원으로 보호. +세종님 요청(2026-04-29). +""" +import re +import sys +from pathlib import Path + +import torch +from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + +from app.services.slot_extractor import _PHONE, _URL + +_TRANSLATION_DIR = Path("/app/external_model/translation_tts") +if str(_TRANSLATION_DIR) not in sys.path: + sys.path.insert(0, str(_TRANSLATION_DIR)) + +# 외부 마운트가 없는 환경(CI/테스트)에서도 모듈 로드는 성공해야 한다. +try: + import run_mvp_pipeline as _sejong # noqa: E402 +except ImportError as error: + print(f"[translator] run_mvp_pipeline unavailable: {error}") + _sejong = None + +NLLB_MODEL_NAME = "facebook/nllb-200-distilled-600M" +SOURCE_LANG = "kor_Hang" +MAX_TRANSLATE_CHARS = 100 + +# 안드 언어 코드 → NLLB FLORES-200 코드 +LANG_TO_NLLB = { + "vi": "vie_Latn", + "en": "eng_Latn", + "ru": "rus_Cyrl", + "ms": "zsm_Latn", + "mn": "khk_Cyrl", + "zh": "zho_Hans", + "th": "tha_Thai", + "ja": "jpn_Jpan", +} + +_tokenizer = None +_model = None +# 사전 raw rows 단일 캐시. 언어별 분기는 find_glossary_hits 호출 시 target_lang 인자로 처리. +_glossary_rows: list | None = None + + +def _get_translator(): + global _tokenizer, _model + if _model is None: + _tokenizer = AutoTokenizer.from_pretrained(NLLB_MODEL_NAME, src_lang=SOURCE_LANG) + _model = AutoModelForSeq2SeqLM.from_pretrained(NLLB_MODEL_NAME) + _model.eval() + return _tokenizer, _model + + +def _get_glossary(): + """raw 사전 rows를 1회 로드. 언어별 컬럼 선택은 find_glossary_hits에서 처리.""" + global _glossary_rows + if _glossary_rows is None: + if _sejong is None: + _glossary_rows = [] + return _glossary_rows + try: + _glossary_rows = _sejong.read_glossary(_TRANSLATION_DIR / "term_glossary.csv") + except Exception as error: + print(f"[translator] glossary load failed: {error}") + _glossary_rows = [] + return _glossary_rows + + +def _translate(text: str, target_nllb: str = "vie_Latn", max_length: int = 512) -> str: + tokenizer, model = _get_translator() + target_id = tokenizer.convert_tokens_to_ids(target_nllb) + inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length) + with torch.no_grad(): + out = model.generate( + **inputs, + forced_bos_token_id=target_id, + max_length=max_length, + num_beams=4, + no_repeat_ngram_size=3, + repetition_penalty=1.3, + early_stopping=True, + ) + return tokenizer.batch_decode(out, skip_special_tokens=True)[0] + + +# 한국어 원문 → 베트남어 번역 결과의 명백한 오번역 강제 치환. +# NLLB가 학교 도메인을 못 배워서 발생하는 시각적 결함을 시연 전에 막는 안전망. +_CURRENCY_PATTERNS = [ + re.compile(r"\bđô\s*la\b", re.IGNORECASE), + re.compile(r"\bdollars?\b", re.IGNORECASE), + re.compile(r"\bUSD\b"), +] +# 천단위 점(40.000) → 콤마(40,000). 베트남식 표기지만 한국 학부모는 "40원"으로 오인할 수 있음. +_THOUSAND_DOT = re.compile(r"(\d{1,3}(?:\.\d{3})+)") +_KRW_AMOUNT = re.compile(r"\d[\d,]*\s*원") + + +def _normalize_thousand_separator(text: str) -> str: + def repl(m): + return m.group(1).replace(".", ",") + return _THOUSAND_DOT.sub(repl, text) + + +def _post_process_vi(easy_ko: str, vi_text: str) -> str: + if not vi_text: + return vi_text + if _KRW_AMOUNT.search(easy_ko): + for pat in _CURRENCY_PATTERNS: + vi_text = pat.sub("won", vi_text) + vi_text = _normalize_thousand_separator(vi_text) + return vi_text + + +# OCR 변환 과정에서 생기는 특수문자 제거. HWP 체크박스/불릿이 □·▣ 등으로 깨지는 패턴. +_OCR_NOISE = re.compile(r"[□■▣▷◆◇▶◀►◄■-◿`]+") +_MULTI_SPACE = re.compile(r"[ \t]{2,}") + + +def _clean_for_translation(text: str) -> str: + """NLLB 입력 전 OCR 잔여 특수문자를 제거한다.""" + text = _OCR_NOISE.sub(" ", text) + text = _MULTI_SPACE.sub(" ", text) + return text.strip() + + +# URL/전화 보호 — NLLB가 깨먹는 패턴 방어. 한국어 입력에 안 등장하는 unicode bracket으로 +# 치환하고 번역 후 복원. ⟦…⟧는 NLLB가 분해하지 않는 안전 토큰. +_PROTECT_TOKEN = re.compile(r"⟦P(\d+)⟧") + + +def _mask_protected_entities(text: str) -> tuple[str, list[str]]: + """URL/전화 → ⟦P0⟧ 등 토큰. (masked, originals) 반환.""" + placeholders: list[str] = [] + + def stash(match: re.Match) -> str: + placeholders.append(match.group(0)) + return f"⟦P{len(placeholders) - 1}⟧" + + masked = _URL.sub(stash, text) + masked = _PHONE.sub(stash, masked) + return masked, placeholders + + +def _restore_protected_entities(text: str, placeholders: list[str]) -> str: + if not placeholders: + return text + + def restore(match: re.Match) -> str: + idx = int(match.group(1)) + return placeholders[idx] if idx < len(placeholders) else match.group(0) + + return _PROTECT_TOKEN.sub(restore, text) + + +def _is_url_or_phone(text: str) -> bool: + """슬롯 단위 번역 시 URL/전화면 NLLB 안 거치고 ko 그대로 반환하기 위한 가드.""" + s = text.strip() + return bool(_URL.fullmatch(s) or _PHONE.fullmatch(s)) + + +def translate_term(text: str, target_lang: str) -> str: + """glossary 직접 치환 (summary 슬롯용 — places, supplies, deadlines). + + exact match 우선. 없으면 한국어 원문 그대로 반환 (빈 문자열 금지). + 고유명사("서울숲 생태체험관")처럼 사전에 없으면 한국어 노출이 NLLB 오역보다 낫다. + URL/전화는 어떤 언어든 ko 그대로 (방어적 가드). + """ + if not text or not text.strip(): + return text + if target_lang == "ko_easy" or _is_url_or_phone(text): + return text + + glossary = _get_glossary() + term = text.strip() + for row in glossary: + if row.get("korean", "").strip() == term: + translated = row.get(f"preferred_{target_lang}", "").strip() + if translated: + return translated + return text # Korean passthrough + + +def translate_short_sentence(text: str, target_lang: str) -> str: + """짧은 문장 NLLB 번역 (items[].title_translated용). + + URL/전화 보호 → glossary injection → NLLB → 보호 토큰 복원 → vi post-process. + 실패 시 빈 문자열 반환 (호출부가 fallback 처리). + """ + if not text or not text.strip(): + return "" + if target_lang == "ko_easy": + return text + text = _clean_for_translation(text)[:MAX_TRANSLATE_CHARS] + + # 1) URL/전화 placeholder 치환 — NLLB가 깨먹지 못하게 격리 + masked, placeholders = _mask_protected_entities(text) + + # 2) glossary injection (긴 용어 먼저 치환해야 부분 치환 충돌 방지) + glossary = _get_glossary() + hits = _sejong.find_glossary_hits(masked, glossary, target_lang) if _sejong else [] + injected = masked + for hit in sorted(hits, key=lambda h: len(h["korean"]), reverse=True): + injected = injected.replace( + hit["korean"], f"{hit['korean']}({hit['preferred_term']})" + ) + + target_nllb = LANG_TO_NLLB.get(target_lang, "vie_Latn") + try: + translated = _translate(injected, target_nllb=target_nllb) + if target_lang == "vi": + translated = _post_process_vi(text, translated) + except Exception as error: + print(f"[translator] translate_short_sentence failed: {error}") + return "" + + # 3) 보호 토큰 복원 + return _restore_protected_entities(translated, placeholders) + + +# DEPRECATED: 아래 함수는 단일 blob 번역 구조. 새 API(translate_term / translate_short_sentence)로 전환 후 제거 예정. +def translate_and_review(notice_text: str, target_lang: str = "vi") -> dict: + """[DEPRECATED] 가정통신문 → easy_ko + 다국어 번역 + 용어 검수 결과. + + 슬롯 기반 응답(summary + items)으로 전환 후 호출부 없음. 다음 PR에서 제거 예정. + 신규 호출은 translate_term / translate_short_sentence 사용. + """ + empty = { + "easy_ko_text": "", + "translation": "", + "target_language": target_lang, + "vi_text": "", + "quality_note": "", + "review_needed": "", + } + if not notice_text or not notice_text.strip(): + return empty + + source = {"easy_ko_text": "", "easy_korean": "", "original_text": notice_text} + baseline = {"original_text": notice_text} + + try: + easy_ko_text = _sejong.prepare_easy_ko_text( + _sejong.build_easy_korean(source, baseline) + ) + except Exception as error: + print(f"[translator] easy_ko failed: {error}") + easy_ko_text = notice_text + + if not easy_ko_text: + return empty + + # ko_easy는 한국어 자체라 사전 검수 무의미 → 빈 hits + if target_lang == "ko_easy": + glossary_hits = [] + else: + glossary = _get_glossary() + glossary_hits = _sejong.find_glossary_hits(easy_ko_text, glossary, target_lang) + + # ko_easy: 번역 없이 easy_ko_text를 그대로 사용 + if target_lang == "ko_easy": + rows = _sejong.build_glossary_check_rows(easy_ko_text, "", glossary_hits) + label, note = _sejong.summarize_quality(rows) + return { + "easy_ko_text": easy_ko_text, + "translation": "", + "target_language": "ko_easy", + "vi_text": "", + "quality_note": note if note else f"ok ({label})", + "review_needed": note if label == "review_needed" else "", + } + + target_nllb = LANG_TO_NLLB.get(target_lang, "vie_Latn") + try: + translated = _translate(easy_ko_text, target_nllb=target_nllb) + if target_lang == "vi": + translated = _post_process_vi(easy_ko_text, translated) + except Exception as error: + print(f"[translator] translate failed: {error}") + translated = "" + + rows = _sejong.build_glossary_check_rows(easy_ko_text, translated, glossary_hits) + label, note = _sejong.summarize_quality(rows) + + return { + "easy_ko_text": easy_ko_text, + "translation": translated, + "target_language": target_lang, + # 호환: vi_text는 vi 선택 시만 채움 (안드 기존 fallback 동작) + "vi_text": translated if target_lang == "vi" else "", + "quality_note": note if note else f"ok ({label})", + "review_needed": note if label == "review_needed" else "", + } + diff --git a/backend/app/services/tts.py b/backend/app/services/tts.py new file mode 100644 index 0000000000000000000000000000000000000000..f0dfb08a73fe42cfd2473f50627aeb355bd08c14 --- /dev/null +++ b/backend/app/services/tts.py @@ -0,0 +1,38 @@ +import uuid +from pathlib import Path + +import edge_tts + +# 안드 언어 코드 → Edge-TTS voice +LANG_TO_VOICE = { + "vi": "vi-VN-HoaiMyNeural", + "en": "en-US-JennyNeural", + "ru": "ru-RU-SvetlanaNeural", + "ms": "ms-MY-YasminNeural", + "mn": "mn-MN-YesuiNeural", + "zh": "zh-CN-XiaoxiaoNeural", + "th": "th-TH-PremwadeeNeural", + "ja": "ja-JP-NanamiNeural", + "ko_easy": "ko-KR-SunHiNeural", +} + +STATIC_DIR = Path("/app/static/tts") +STATIC_DIR.mkdir(parents=True, exist_ok=True) + + +async def generate_tts_file(text: str, target_lang: str = "vi") -> str: + """선택 언어의 텍스트 → mp3 파일 생성. 안드가 BASE_URL과 합쳐 재생할 상대 경로 반환.""" + if not text or not text.strip(): + return "" + voice = LANG_TO_VOICE.get(target_lang) + if not voice: + print(f"[tts] no voice for lang={target_lang}") + return "" + filename = f"{uuid.uuid4()}.mp3" + out_path = STATIC_DIR / filename + try: + await edge_tts.Communicate(text=text, voice=voice).save(str(out_path)) + except Exception as error: + print(f"[tts] generation failed for lang={target_lang}: {error}") + return "" + return f"/static/tts/{filename}" diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000000000000000000000000000000000000..5b22584222f6da99ee993ef63c4666c6c0e31530 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,5 @@ +# 개발/테스트 전용 의존성. 운영 컨테이너에는 설치 안 함. +# 사용: docker compose exec backend pip install -r requirements-dev.txt +# 또는 GitHub Actions에서 별도 install +pytest>=8.0 +httpx>=0.27 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..8c7ceb7c226b4c583e667dc5c026cc8e74f74017 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,22 @@ +--extra-index-url https://download.pytorch.org/whl/cpu +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +pydantic==2.7.1 +edge-tts>=7.0.0 +torch==2.3.1+cpu +transformers==4.44.2 +huggingface-hub==0.24.7 +sentencepiece==0.2.0 +numpy==2.0.2 +pandas==2.2.2 + +# 파일 입력 변환 ([1] 텍스트 변환) +pdfplumber==0.11.9 +python-multipart==0.0.20 + +# 이미지 OCR ([1] 카메라 사진 입력 — 세종) +pytesseract>=0.3.10 +pillow>=10.0.0 + +# 경이 분류기 ([4] 단계 — TF-IDF + LogReg simple, KcELECTRA는 transformers 사용) +scikit-learn==1.5.2 diff --git a/backend/static/notices/.gitkeep b/backend/static/notices/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/static/tts/.gitkeep b/backend/static/tts/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/backend/static/tts/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/tests/README.md b/backend/tests/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a25c99b036736235d6ef6a339938cd7e534f97e1 --- /dev/null +++ b/backend/tests/README.md @@ -0,0 +1,31 @@ +# 백엔드 단위테스트 + +> 강사 피드백(2026-04-28) 대응: *"단위테스트 끝나고 통합테스트 할 때 합의된 기준/절차에 의해 merge"* +> → PR이 권한 검증·다국어 사전 로딩을 깨지 않는다는 자동 증빙 레이어. + +## 구성 + +| 파일 | 검증 대상 | +| --- | --- | +| `test_auth_routes.py` | X-User-Id 헤더 인증, 역할(teacher/parent) 권한, 발송→수신 통합 흐름 | +| `test_glossary.py` | 다국어 용어사전(8개) 컬럼 동적 로딩, find_glossary_hits 언어별 동작 | + +## 실행 + +### 도커 컨테이너에서 + +```bash +docker compose exec backend pip install -r /app/../backend/requirements-dev.txt +# 또는: docker compose exec backend pip install pytest httpx +docker compose exec backend pytest /app/../backend/tests -v +``` + +### CI (GitHub Actions)에서 + +PR 시 자동 실행 — `.github/workflows/backend-tests.yml` 참조. + +## 추가 테스트 가이드 + +- 모델 호출(KoELECTRA / NLLB / Edge-TTS)은 무거우므로 직접 호출 X. 필요 시 monkey-patch로 가짜 응답 사용. +- 새 라우트 추가 시 권한 검증 케이스(teacher/parent/anonymous) 3종 모두 작성. +- 테스트는 빠르게 (총 실행시간 30초 이내 목표). diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..c7bebb37d1f38d3c43edef01254dca0b71f6773b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,36 @@ +"""백엔드 테스트 공통 픽스처. + +모델(KoELECTRA / NLLB / Edge-TTS) 호출은 무거우므로 +auth/route 흐름 테스트에서는 monkey-patch로 가짜 응답을 사용한다. +""" +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +@pytest.fixture +def client(): + """lifespan(seed_demo_users) 트리거를 위해 with 블록 안에서 사용.""" + with TestClient(app) as c: + yield c + + +@pytest.fixture +def teacher_headers(): + return {"X-User-Id": "teacher_001"} + + +@pytest.fixture +def teacher2_headers(): + return {"X-User-Id": "teacher_002"} + + +@pytest.fixture +def parent_headers(): + return {"X-User-Id": "parent_001"} + + +@pytest.fixture +def parent2_headers(): + return {"X-User-Id": "parent_002"} diff --git a/backend/tests/test_analyze_routes.py b/backend/tests/test_analyze_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..28bc7d5e082fdbbc77329d8c1df268f20e225ba2 --- /dev/null +++ b/backend/tests/test_analyze_routes.py @@ -0,0 +1,326 @@ +"""`/notice/analyze` + `/notice/inbox` DELETE 권한 검증 + 슬롯 응답 shape 테스트. + +핵심 라우트 권한 분리 + 강사 처방(2026-04-28) 슬롯 응답이 자동 게이트로 잡혀야 한다. +모델 호출(KoELECTRA + NLLB + Edge-TTS)은 무거우므로 monkeypatch로 우회. +""" +import pytest + + +@pytest.fixture(autouse=True) +def reset_notices(): + """각 테스트 전 모듈 상태(_notices) 초기화로 순서 의존성 제거.""" + from app.routers import notice + notice._notices.clear() + yield + notice._notices.clear() + + +# ───────────────────────────────────────── +# 공통 헬퍼: parent_001 앞으로 통신문 1건 발송 +# ───────────────────────────────────────── +def _seed_notice(client, teacher_id="teacher_001", parent_id="parent_001", + text="테스트 통신문") -> str: + payload = {"teacher_id": teacher_id, "parent_id": parent_id, "text": text} + headers = {"X-User-Id": teacher_id} + r = client.post("/notice/send", json=payload, headers=headers) + assert r.status_code == 200, r.text + return r.json()["data"]["notice_id"] + + +def _patch_models(monkeypatch, *, todos=None, category=None, + tts_url="/static/tts/fake.mp3"): + """모델 호출 4종(추출/분류/번역2)을 한 번에 mock. 각 테스트가 인자만 바꿔쓰게.""" + from app.models.schemas import Category + default_category = category or Category.other + monkeypatch.setattr("app.routers.notice.extract_todos", lambda text: todos or []) + monkeypatch.setattr("app.routers.notice.classify_category", + lambda text: default_category) + monkeypatch.setattr("app.routers.notice.translate_short_sentence", + lambda text, target_lang: f"[VI]{text}") + monkeypatch.setattr("app.routers.notice.translate_term", + lambda text, target_lang: f"[T]{text}") + + async def fake_tts(text, target_lang="vi"): + return tts_url + monkeypatch.setattr("app.routers.notice.generate_tts_file", fake_tts) + + +# ───────────────────────────────────────── +# ANALYZE — 권한 게이트 +# ───────────────────────────────────────── +_VI_BODY = {"target_language": "vi"} + + +def test_analyze_unknown_notice(client, parent_headers): + r = client.post("/notice/analyze/ghost-id-xxx", json=_VI_BODY, headers=parent_headers) + assert r.status_code == 404 + assert "찾을 수 없습니다" in r.json()["detail"] + + +def test_analyze_other_parent_blocked(client, teacher_headers, parent_headers, parent2_headers): + notice_id = _seed_notice(client) + r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent2_headers) + assert r.status_code == 403 + assert "본인의 가정통신문" in r.json()["detail"] + + +def test_analyze_teacher_role_blocked(client, teacher_headers): + notice_id = _seed_notice(client) + r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=teacher_headers) + assert r.status_code == 403 + + +def test_analyze_missing_target_language_rejected(client, teacher_headers, parent_headers): + """body에 target_language 없이 호출 → 422 (FastAPI body 검증).""" + notice_id = _seed_notice(client) + r = client.post(f"/notice/analyze/{notice_id}", json={}, headers=parent_headers) + assert r.status_code == 422 + + +# ───────────────────────────────────────── +# ANALYZE — 슬롯 응답 shape (강사 처방) +# ───────────────────────────────────────── +def test_analyze_returns_slot_shape(client, parent_headers, monkeypatch): + """응답이 summary + items 슬롯 구조 — translation 한 덩어리 필드 사라짐.""" + notice_id = _seed_notice(client, text="내일 도시락을 가져와 주세요.") + _patch_models(monkeypatch) + + r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers) + assert r.status_code == 200 + data = r.json()["data"] + + assert data["notice_id"] == notice_id + assert data["target_language"] == "vi" + + # summary 8슬롯 모두 존재 (urls/phones는 NLLB 보호용) + summary = data["summary"] + for key in ("dates", "times", "places", "supplies", "amounts", + "deadlines", "urls", "phones"): + assert key in summary, f"summary 슬롯 누락: {key}" + assert isinstance(summary[key], list) + + # items 리스트 + 각 item 필수 필드 + assert isinstance(data["items"], list) + for item in data["items"]: + for key in ("category", "action_hint", "title_ko", "title_translated", + "when", "where", "what", "amount", "deadline", "importance"): + assert key in item, f"item 필드 누락: {key}" + + # 구버전 한 덩어리 필드는 빠져있어야 함 (안드 마이그레이션 확인용) + assert "translation" not in data + assert "vi_text" not in data + assert "easy_ko_text" not in data + assert "todos" not in data + + assert data["tts_url"] == "/static/tts/fake.mp3" + + +def test_analyze_regex_slots_filled_from_raw_text(client, parent_headers, monkeypatch): + """원문에 날짜·시간·금액이 있으면 정규식 추출이 summary에 채워진다 (LLM 비의존 데이터).""" + text = "5월 14일(수) 오전 9시 출발. 참가비 15,000원." + notice_id = _seed_notice(client, text=text) + _patch_models(monkeypatch) + + r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers) + summary = r.json()["data"]["summary"] + + # 정규식 hit 3종 모두 잡혀야 함 + assert any(d["ko"] == "5월 14일(수)" for d in summary["dates"]) + assert any(t["ko"] == "오전 9시" for t in summary["times"]) + assert any(a["ko"] == "15,000원" for a in summary["amounts"]) + + # source: "regex" 명시 — 강사 처방 "정규식 + 모델 하이브리드" 가시화 + assert all(d["source"] == "regex" for d in summary["dates"]) + assert all(t["source"] == "regex" for t in summary["times"]) + assert all(a["source"] == "regex" for a in summary["amounts"]) + + +def test_analyze_urls_phones_passthrough(client, parent_headers, monkeypatch): + """원문에 URL/전화가 있으면 summary.urls / phones에 ko 그대로 노출 (번역 X).""" + text = "신청 https://apply.school.kr, 문의 02-2649-7232 / 1588-0260" + notice_id = _seed_notice(client, text=text) + _patch_models(monkeypatch) + + r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers) + summary = r.json()["data"]["summary"] + + assert any(u["ko"] == "https://apply.school.kr" for u in summary["urls"]) + assert any(p["ko"] == "02-2649-7232" for p in summary["phones"]) + assert any(p["ko"] == "1588-0260" for p in summary["phones"]) + + # translated가 ko와 동일 = 번역 안 거침 + for u in summary["urls"]: + assert u["translated"] == u["ko"] + for p in summary["phones"]: + assert p["translated"] == p["ko"] + + +def test_analyze_returns_cards_shape(client, parent_headers, monkeypatch): + """신규 cards 필드 구조 검증 — 슬롯 카드 재설계 (Phase 1).""" + from app.models.schemas import Category, YunjeongTodo + todos = [YunjeongTodo( + text="운영시간 오전 10:00 ~ 12:00 (2시간)", + confidence=0.85, + action_hint="참여", + )] + notice_id = _seed_notice(client, text="운영시간 오전 10:00 ~ 12:00 (2시간)") + _patch_models(monkeypatch, todos=todos, category=Category.schedule) + + r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers) + assert r.status_code == 200 + data = r.json()["data"] + + # cards 필드 존재 + 리스트 + assert "cards" in data + assert isinstance(data["cards"], list) + assert len(data["cards"]) >= 1 + + # 카드 필수 필드 + for card in data["cards"]: + for key in ("header_ko", "header_translated", "value_ko", + "value_easy_ko", "value_translated", "chip", "importance"): + assert key in card, f"card 필드 누락: {key}" + + # 헤더 분해 — "운영시간" 헤더 추출됐는지 + headers = [c["header_ko"] for c in data["cards"]] + assert "운영시간" in headers, f"운영시간 헤더 누락: {headers}" + + # tts_url_easy_ko 필드 존재 (세종님 별도 버튼) + assert "tts_url_easy_ko" in data + + +def test_analyze_cards_regex_url_card(client, parent_headers, monkeypatch): + """todo로 못 잡힌 URL이 regex 보강으로 '신청 URL' 카드에 들어가야 한다.""" + text = "신청 안내. http://apply.school.kr 에서 접수." + notice_id = _seed_notice(client, text=text) + _patch_models(monkeypatch) # todos 빈 채로 + + r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers) + cards = r.json()["data"]["cards"] + headers = [c["header_ko"] for c in cards] + assert "신청 URL" in headers, f"URL 카드 누락: {headers}" + url_card = next(c for c in cards if c["header_ko"] == "신청 URL") + assert "http://apply.school.kr" in url_card["value_ko"] + + +def test_analyze_supplies_aggregated_from_items(client, parent_headers, monkeypatch): + """경이님 분류가 supplies로 잡힌 todo의 토큰이 summary.supplies로 집계 (강사 강조: 누락 금지).""" + from app.models.schemas import Category, YunjeongTodo + todos = [YunjeongTodo( + text="도시락, 물통, 돗자리를 준비해 주세요", + confidence=0.9, + action_hint="준비", + )] + notice_id = _seed_notice(client, text="도시락, 물통, 돗자리를 준비해 주세요") + _patch_models(monkeypatch, todos=todos, category=Category.supplies) + + r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers) + supplies = r.json()["data"]["summary"]["supplies"] + ko_tokens = [s["ko"] for s in supplies] + assert "도시락" in ko_tokens + assert "물통" in ko_tokens + assert "돗자리" in ko_tokens + + +# ───────────────────────────────────────── +# UPLOAD — multipart 파일 입력 +# ───────────────────────────────────────── +def _patch_parser(monkeypatch, *, text="파싱 결과 텍스트", error=None): + """parser.parse_bytes_to_text를 mock해서 LibreOffice 의존 없이 검증.""" + from app.services.parser import ParserError + + def fake(data, filename): + if error: + raise ParserError(error) + return text + + monkeypatch.setattr("app.routers.notice.parse_bytes_to_text", fake) + + +def test_upload_text_creates_notice(client, teacher_headers, monkeypatch): + """평문 .txt 업로드 → notice 생성. 응답에 notice_id + char_count.""" + _patch_parser(monkeypatch, text="텍스트 통신문 본문") + files = {"file": ("hello.txt", b"abc", "text/plain")} + data = {"teacher_id": "teacher_001", "parent_id": "parent_001"} + + r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers) + + assert r.status_code == 200, r.text + body = r.json()["data"] + assert "notice_id" in body + assert body["char_count"] == len("텍스트 통신문 본문") + + +def test_upload_other_teacher_blocked(client, teacher_headers): + """본인 ID와 다른 teacher_id로 업로드 → 403.""" + files = {"file": ("hello.txt", b"abc", "text/plain")} + data = {"teacher_id": "teacher_002", "parent_id": "parent_001"} + r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers) + assert r.status_code == 403 + + +def test_upload_unknown_parent_blocked(client, teacher_headers, monkeypatch): + """존재하지 않는 parent_id → 404.""" + _patch_parser(monkeypatch) + files = {"file": ("x.txt", b"abc", "text/plain")} + data = {"teacher_id": "teacher_001", "parent_id": "ghost"} + r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers) + assert r.status_code == 404 + + +def test_upload_parser_failure_returns_400(client, teacher_headers, monkeypatch): + _patch_parser(monkeypatch, error="LibreOffice 변환 실패") + files = {"file": ("broken.hwp", b"x", "application/x-hwp")} + data = {"teacher_id": "teacher_001", "parent_id": "parent_001"} + r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers) + assert r.status_code == 400 + assert "변환 실패" in r.json()["detail"] + + +def test_upload_empty_text_returns_400(client, teacher_headers, monkeypatch): + """파서가 빈 문자열 반환 → 400.""" + _patch_parser(monkeypatch, text="") + files = {"file": ("empty.txt", b"", "text/plain")} + data = {"teacher_id": "teacher_001", "parent_id": "parent_001"} + r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers) + assert r.status_code == 400 + + +def test_upload_then_inbox_round_trip(client, teacher_headers, parent_headers, monkeypatch): + """업로드 → 학부모 inbox에서 같은 텍스트 보임.""" + _patch_parser(monkeypatch, text="upload→inbox 통신문") + files = {"file": ("doc.pdf", b"%PDF-fake", "application/pdf")} + data = {"teacher_id": "teacher_001", "parent_id": "parent_001"} + r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers) + notice_id = r.json()["data"]["notice_id"] + + inbox = client.get("/notice/inbox/parent_001", headers=parent_headers).json()["data"] + assert any(n["notice_id"] == notice_id and n["text"] == "upload→inbox 통신문" + for n in inbox) + + +# ───────────────────────────────────────── +# DELETE INBOX — 본인만 허용 +# ───────────────────────────────────────── +def test_delete_inbox_self_only(client, teacher_headers, parent_headers, parent2_headers): + notice_id = _seed_notice(client) + r = client.delete("/notice/inbox/parent_001", headers=parent2_headers) + assert r.status_code == 403 + + r2 = client.get("/notice/inbox/parent_001", headers=parent_headers) + assert r2.status_code == 200 + assert any(n["notice_id"] == notice_id for n in r2.json()["data"]) + + +def test_delete_inbox_self_succeeds(client, teacher_headers, parent_headers): + _seed_notice(client) + _seed_notice(client, text="두 번째 통신문") + + r1 = client.get("/notice/inbox/parent_001", headers=parent_headers) + assert len(r1.json()["data"]) >= 2 + + r2 = client.delete("/notice/inbox/parent_001", headers=parent_headers) + assert r2.status_code == 200 + + r3 = client.get("/notice/inbox/parent_001", headers=parent_headers) + assert r3.json()["data"] == [] diff --git a/backend/tests/test_auth_routes.py b/backend/tests/test_auth_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..dfb078bea57e3db6dd35b0ac416e13174182d865 --- /dev/null +++ b/backend/tests/test_auth_routes.py @@ -0,0 +1,72 @@ +"""권한 분리 라우트 테스트. + +강사 피드백(2026-04-28): "단위테스트 끝나고 통합테스트 할 때 합의된 기준/절차에 의해 merge" +→ 운영 PR이 권한 검증을 깨지 않는다는 자동 증빙. +""" + + +def test_health_ok(client): + """기본 헬스체크 — 모델 로드 없이 즉시 응답.""" + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_send_notice_requires_teacher_role(client, parent_headers): + """학부모 ID로 발송 시도 → 403.""" + payload = {"teacher_id": "teacher_001", "parent_id": "parent_001", "text": "테스트"} + r = client.post("/notice/send", json=payload, headers=parent_headers) + assert r.status_code == 403 + assert "선생님 권한" in r.json()["detail"] + + +def test_send_notice_teacher_id_must_match_header(client, teacher_headers): + """헤더(teacher_001) ≠ body.teacher_id(teacher_002) → 403.""" + payload = {"teacher_id": "teacher_002", "parent_id": "parent_001", "text": "테스트"} + r = client.post("/notice/send", json=payload, headers=teacher_headers) + assert r.status_code == 403 + assert "본인 선생님 ID" in r.json()["detail"] + + +def test_send_notice_unknown_parent(client, teacher_headers): + """존재하지 않는 학부모로 발송 → 404.""" + payload = {"teacher_id": "teacher_001", "parent_id": "parent_999", "text": "테스트"} + r = client.post("/notice/send", json=payload, headers=teacher_headers) + assert r.status_code == 404 + assert "학부모 계정을 찾을 수 없습니다" in r.json()["detail"] + + +def test_inbox_self_only(client, parent_headers, parent2_headers): + """parent_001은 본인 수신함 OK, parent_002의 수신함 조회는 403.""" + r1 = client.get("/notice/inbox/parent_001", headers=parent_headers) + assert r1.status_code == 200 + + r2 = client.get("/notice/inbox/parent_001", headers=parent2_headers) + assert r2.status_code == 403 + + +def test_inbox_requires_user_header(client): + """X-User-Id 헤더 없으면 422 (FastAPI Header 필수 검증).""" + r = client.get("/notice/inbox/parent_001") + assert r.status_code == 422 + + +def test_unknown_user_id_rejected(client): + """등록 안 된 ID로 헤더 → 401.""" + r = client.get("/notice/inbox/parent_001", headers={"X-User-Id": "ghost_999"}) + assert r.status_code == 401 + assert "등록되지 않은 사용자" in r.json()["detail"] + + +def test_send_then_receive_flow(client, teacher_headers, parent_headers): + """발송 후 학부모 수신함에 보임 (권한 흐름 통합 검증).""" + payload = {"teacher_id": "teacher_001", "parent_id": "parent_001", "text": "단위테스트 통신문"} + r1 = client.post("/notice/send", json=payload, headers=teacher_headers) + assert r1.status_code == 200 + notice_id = r1.json()["data"]["notice_id"] + assert notice_id + + r2 = client.get("/notice/inbox/parent_001", headers=parent_headers) + assert r2.status_code == 200 + inbox = r2.json()["data"] + assert any(n["notice_id"] == notice_id for n in inbox) diff --git a/backend/tests/test_card_builder.py b/backend/tests/test_card_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..dd3befe7b9686215aad5f423e043a61b1d85f1ae --- /dev/null +++ b/backend/tests/test_card_builder.py @@ -0,0 +1,57 @@ +"""card_builder 슬롯 카드 빌드 + dedup 단위 테스트.""" +from app.models.schemas import SlotCard +from app.services.card_builder import _dedup_cards + + +def _card(header: str, value: str, importance: float = 0.5) -> SlotCard: + return SlotCard( + header_ko=header, + header_translated=header, + value_ko=value, + value_easy_ko=value, + value_translated=value, + chip=None, + importance=importance, + ) + + +def test_dedup_removes_substring_within_same_header(): + """같은 헤더 안에서 짧은 value가 긴 value 안에 들어있으면 짧은 것 제거 — 본문/표 영역 중복 흡수.""" + cards = [ + _card("운영방법", "온라인 사전예약 100명 선착순 5가지 체험 안전 복장", 0.99), + _card("운영방법", "온라인 사전예약 100명 선착순", 0.53), # substring + ] + out = _dedup_cards(cards) + assert len(out) == 1 + assert out[0].importance == 0.99 # 긴 쪽이 살아남음 + + +def test_dedup_keeps_different_headers(): + """헤더가 다르면 손대지 않음 — 운영방법과 일시는 별개 슬롯.""" + cards = [ + _card("운영방법", "온라인 사전예약", 0.9), + _card("일시", "2026. 4. 18.(토)", 0.7), + _card("신청 URL", "http://example.com", 0.7), + ] + out = _dedup_cards(cards) + assert len(out) == 3 + + +def test_dedup_normalizes_pipe_and_colon_separators(): + """`|`/`:` 구분자 차이로 substring 매칭 놓치는 것 방지 — 윤정님 기호 정제 전후 둘 다 호환.""" + cards = [ + _card("운영방법", "온라인 사전예약 100명 선착순", 0.99), + _card("운영방법", "온라인 | 사전예약 | 100명 선착순", 0.50), # `|` 구분자만 다름 + ] + out = _dedup_cards(cards) + assert len(out) == 1 + + +def test_dedup_keeps_distinct_values_under_same_header(): + """같은 헤더라도 substring 관계 아니면 둘 다 유지 — 정보 손실 방지.""" + cards = [ + _card("기타", "물 지참", 0.8), + _card("기타", "음식물 반입 금지", 0.6), + ] + out = _dedup_cards(cards) + assert len(out) == 2 diff --git a/backend/tests/test_glossary.py b/backend/tests/test_glossary.py new file mode 100644 index 0000000000000000000000000000000000000000..2e4431fd4def8352a78b422de6989adb9ec651d8 --- /dev/null +++ b/backend/tests/test_glossary.py @@ -0,0 +1,74 @@ +"""다국어 용어사전 로딩 단위 테스트. + +세종 PR 리팩토링 반영(2026-04-28): +- read_glossary(path)는 raw rows 반환 (target_lang 파라미터 제거) +- 언어별 분기는 find_glossary_hits(text, glossary, lang)에서 처리 +- 반환 dict 키: preferred_vi → preferred_term (generic) +""" +import sys +from pathlib import Path + +import pytest + +# run_mvp_pipeline.py 직접 import +_TRANSLATION_DIR = Path("/app/external_model/translation_tts") +if str(_TRANSLATION_DIR) not in sys.path: + sys.path.insert(0, str(_TRANSLATION_DIR)) +import run_mvp_pipeline as _sj # noqa: E402 + +GLOSSARY_PATH = _TRANSLATION_DIR / "term_glossary.csv" + + +def test_glossary_loads_raw_rows(): + """read_glossary(path)는 최소 144행 이상 raw rows를 반환한다.""" + rows = _sj.read_glossary(GLOSSARY_PATH) + assert len(rows) >= 144 + # 한국어 컬럼은 비어있지 않음 + assert rows[0]["korean"] + + +@pytest.mark.parametrize("lang", ["vi", "en", "zh", "th", "ja", "ru", "ms", "mn"]) +def test_glossary_each_language_column_filled(lang): + """각 언어 preferred_{lang} 컬럼이 모든 행에 채워져있다.""" + rows = _sj.read_glossary(GLOSSARY_PATH) + column = f"preferred_{lang}" + filled = [r for r in rows if r.get(column, "").strip()] + assert len(filled) == len(rows), f"{lang}: {len(rows) - len(filled)}건 누락" + + +def test_glossary_dosirak_mapping(): + """대표 학교 도메인 단어 '도시락'이 언어별로 다른 권장어로 매핑된다.""" + expected = {"vi": "cơm hộp", "en": "Lunch box", "zh": "便当", "ja": "お弁당"} + rows = _sj.read_glossary(GLOSSARY_PATH) + dosirak = next((r for r in rows if r["korean"] == "도시락"), None) + assert dosirak is not None, "'도시락' 누락" + for lang, want in expected.items(): + # Note: ja 'お弁당' 표기 차이로 일본어는 startswith 비교 + actual = dosirak.get(f"preferred_{lang}", "").strip() + if lang == "ja": + assert actual.startswith("お弁") or actual.startswith("弁当"), f"ja: {actual!r}" + else: + assert actual == want, f"{lang}: expected {want!r}, got {actual!r}" + + +def test_find_glossary_hits_returns_target_language(): + """find_glossary_hits이 target_lang 권장어를 preferred_term 키로 반환한다.""" + glossary = _sj.read_glossary(GLOSSARY_PATH) + text = "아이는 도시락과 물병을 가져와 주세요." + + en_hits = _sj.find_glossary_hits(text, glossary, "en") + en_dosirak = next((h for h in en_hits if h["korean"] == "도시락"), None) + assert en_dosirak is not None + assert en_dosirak["preferred_term"].lower() == "lunch box" + + vi_hits = _sj.find_glossary_hits(text, glossary, "vi") + vi_dosirak = next((h for h in vi_hits if h["korean"] == "도시락"), None) + assert vi_dosirak is not None + assert vi_dosirak["preferred_term"] == "cơm hộp" + + +def test_find_glossary_hits_empty_text(): + """본문에 사전 용어가 없으면 빈 리스트 반환.""" + glossary = _sj.read_glossary(GLOSSARY_PATH) + hits = _sj.find_glossary_hits("아무 학교 용어 없음", glossary, "vi") + assert hits == [] diff --git a/backend/tests/test_parser.py b/backend/tests/test_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..f65f7bb2495cab6a4a5d64754da09cf09fb9bddd --- /dev/null +++ b/backend/tests/test_parser.py @@ -0,0 +1,171 @@ +"""파서 단위 테스트. + +LibreOffice 호출은 mocking. PDF 추출은 pdfplumber에 의존하므로 +실 PDF 한 장은 monkeypatch 없이 검증해도 됨 (가벼움). +""" +from pathlib import Path + +import pytest + +from app.services.parser import ( + ParserError, + normalize, + parse_bytes_to_text, +) + + +# ── normalize ───────────────────────────────────────────────── +def test_normalize_strips_null_bytes(): + assert "\x00" not in normalize("a\x00b\x00c") + + +def test_normalize_collapses_inline_whitespace_but_keeps_newlines(): + out = normalize("foo bar\n\n\nbaz") + # 한 줄 안 다중 공백은 1개로 + assert out == "foo bar\n\nbaz" + + +def test_normalize_preserves_paragraph_breaks(): + """줄바꿈은 보존 (윤정님 요구: '줄바꿈만 살리고').""" + src = "안녕하세요\n학부모님께\n\n알려드립니다" + assert normalize(src) == "안녕하세요\n학부모님께\n\n알려드립니다" + + +def test_normalize_empty_returns_empty(): + assert normalize("") == "" + assert normalize(" \n\n ") == "" + + +# ── parse_bytes_to_text — 평문 ──────────────────────────────── +def test_parse_text_passthrough(): + raw = "통신문 본문\n\n오늘 안내드립니다".encode("utf-8") + assert parse_bytes_to_text(raw, "notice.txt") == "통신문 본문\n\n오늘 안내드립니다" + + +def test_parse_no_extension_treated_as_text(): + raw = "그냥 텍스트".encode("utf-8") + assert parse_bytes_to_text(raw, "noext") == "그냥 텍스트" + + +def test_parse_empty_bytes_returns_empty(): + assert parse_bytes_to_text(b"", "anything.txt") == "" + + +def test_parse_unsupported_extension_raises(): + with pytest.raises(ParserError): + parse_bytes_to_text(b"x", "image.heic") + + +def test_parse_unknown_extension_rejected(): + """알 수 없는 suffix는 화이트리스트(ALLOWED_EXTS)에서 거부.""" + with pytest.raises(ParserError): + parse_bytes_to_text(b"x", "../../etc/passwd.exe") + with pytest.raises(ParserError): + parse_bytes_to_text(b"x", "weird.bin") + + +def test_parse_filename_metachars_safe(monkeypatch): + """쉘 메타문자가 들어간 .hwp 파일명도 LibreOffice 호출에 안전. + + 원본 filename은 어떤 경로/명령에도 들어가지 않고 tempdir/input.hwp로만 저장. + subprocess 호출 시 list 인자 형태라 명령 주입 표면 자체가 없음. + """ + captured_args = {} + + def fake_run(cmd, **kwargs): + captured_args["cmd"] = cmd + # 메타문자 흔적이 cmd 어디에도 안 보임을 검증 + for arg in cmd: + assert "rm" not in arg + assert ";" not in arg + assert "$(" not in arg + # 가짜 ODT 만들어서 변환 성공처럼 + out_dir = Path(cmd[cmd.index("--outdir") + 1]) + (out_dir / "input.odt").write_bytes(b"PK-fake-odt") + + class Result: + returncode = 0 + stderr = "" + return Result() + + monkeypatch.setattr("app.services.parser.subprocess.run", fake_run) + monkeypatch.setattr( + "app.services.parser._odt_to_text", + lambda p: "ok", + ) + + parse_bytes_to_text(b"HWP-data", "$(rm -rf /).hwp") + # 명령 주입은커녕 원본 filename이 cmd에 등장조차 안 함 + assert all("$(rm" not in arg for arg in captured_args["cmd"]) + + +def test_parse_libreoffice_timeout_raises_with_message(monkeypatch): + """타임아웃 발생 시 ParserError + 환경변수 안내 메시지.""" + import subprocess as sp + + def boom(*args, **kwargs): + raise sp.TimeoutExpired(cmd=args[0], timeout=kwargs.get("timeout", 0)) + + monkeypatch.setattr("app.services.parser.subprocess.run", boom) + with pytest.raises(ParserError) as exc: + parse_bytes_to_text(b"HWP", "x.hwp") + assert "타임아웃" in str(exc.value) + assert "PARSER_LIBREOFFICE_TIMEOUT" in str(exc.value) + + +# ── parse_bytes_to_text — HWP (LibreOffice 모킹) ────────────── +def test_parse_hwp_calls_libreoffice_and_odt_extractor(monkeypatch, tmp_path): + """HWP 입력 → LibreOffice ODT 변환 호출 + content.xml 추출 호출 확인.""" + called = {} + + def fake_hwp_to_odt(hwp_path: Path, out_dir: Path) -> Path: + called["hwp_path"] = hwp_path + called["out_dir"] = out_dir + fake_odt = out_dir / "fake.odt" + fake_odt.write_bytes(b"PK-fake-odt") + return fake_odt + + def fake_odt_to_text(odt_path: Path) -> str: + called["odt_path"] = odt_path + return "본문 추출 결과" + + monkeypatch.setattr("app.services.parser._hwp_to_odt", fake_hwp_to_odt) + monkeypatch.setattr("app.services.parser._odt_to_text", fake_odt_to_text) + + out = parse_bytes_to_text(b"HWP-bytes", "안내.hwp") + + assert out == "본문 추출 결과" + assert called["hwp_path"].suffix == ".hwp" + assert called["odt_path"].suffix == ".odt" + + +def test_parse_pdf_calls_pdfplumber_only(monkeypatch): + """PDF 입력 → LibreOffice 우회, pdfplumber만 호출.""" + called = {"hwp": False, "pdf": False} + + def fake_hwp_to_odt(*args, **kwargs): + called["hwp"] = True + raise AssertionError("PDF 입력에선 LibreOffice가 호출되면 안 됨") + + def fake_pdf_to_text(pdf_path: Path) -> str: + called["pdf"] = True + return "PDF 추출 결과" + + monkeypatch.setattr("app.services.parser._hwp_to_odt", fake_hwp_to_odt) + monkeypatch.setattr("app.services.parser._pdf_to_text", fake_pdf_to_text) + + out = parse_bytes_to_text(b"%PDF-1.4 fake", "doc.pdf") + + assert out == "PDF 추출 결과" + assert called["pdf"] is True + assert called["hwp"] is False + + +def test_parse_libreoffice_failure_raises_parser_error(monkeypatch): + def boom(*args, **kwargs): + raise ParserError("LibreOffice 변환 실패") + + monkeypatch.setattr("app.services.parser._hwp_to_odt", boom) + + with pytest.raises(ParserError): + parse_bytes_to_text(b"HWP", "x.hwp") diff --git a/backend/tests/test_slot_extractor.py b/backend/tests/test_slot_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..e310753613499dd75f8a3476af81d89c621d0116 --- /dev/null +++ b/backend/tests/test_slot_extractor.py @@ -0,0 +1,232 @@ +"""슬롯 추출기 단위 테스트. + +강사 처방(2026-04-28): 날짜·시간·금액은 정규식으로 1차 안전 추출. +이 테스트가 통과한다는 건 LLM 없이도 핵심 수치 데이터가 확보된다는 자동 증명. +""" +from app.services.slot_extractor import ( + extract_amounts, + extract_dates, + extract_phones, + extract_summary_regex_slots, + extract_times, + extract_urls, + find_amount_in_text, + find_deadline_in_text, + find_when_in_text, + format_amount, + format_date, + format_time, + split_supply_tokens, + strip_markers, +) + + +# ── 날짜 ───────────────────────────────────────────────────────── +def test_extract_dates_full_with_year_and_weekday(): + result = extract_dates("2026년 5월 14일(수) 출발") + assert len(result) == 1 + d = result[0] + assert d["year"] == 2026 + assert d["month"] == 5 + assert d["day"] == 14 + assert d["weekday"] == "수" + + +def test_extract_dates_md_only(): + result = extract_dates("5월 9일(금)까지 제출") + assert len(result) == 1 + d = result[0] + assert d["year"] is None + assert d["month"] == 5 + assert d["day"] == 9 + assert d["weekday"] == "금" + + +def test_extract_dates_relative_words(): + result = extract_dates("내일까지 동의서를 제출하세요") + assert any(d.get("relative") == "내일" for d in result) + + +def test_extract_dates_dedups_surface(): + result = extract_dates("5월 9일(금)까지 납부, 5월 9일(금)까지 제출") + # 동일 표면형은 1개로 + surfaces = [d["ko"] for d in result] + assert surfaces.count("5월 9일(금)") == 1 + + +# ── 시간 ───────────────────────────────────────────────────────── +def test_extract_times_ampm(): + result = extract_times("오전 9시 출발") + assert len(result) == 1 + assert result[0]["hour"] == 9 + assert result[0]["ampm"] == "오전" + + +def test_extract_times_with_minute(): + result = extract_times("오후 2시 30분") + assert result[0]["hour"] == 2 + assert result[0]["minute"] == 30 + + +def test_extract_times_24h(): + result = extract_times("회의 14:30 시작") + assert result[0]["hour"] == 14 + assert result[0]["minute"] == 30 + + +# ── 금액 ───────────────────────────────────────────────────────── +def test_extract_amounts_with_comma(): + result = extract_amounts("참가비 15,000원") + assert result[0]["value"] == 15000 + assert result[0]["ko"] == "15,000원" + + +def test_extract_amounts_korean_unit(): + result = extract_amounts("5천원만 가져오세요") + assert any(a["value"] == 5000 for a in result) + + +def test_extract_amounts_no_match(): + assert extract_amounts("그냥 평범한 통신문") == [] + + +# ── 베트남어 포매터 ────────────────────────────────────────────── +def test_format_date_vi_with_year_weekday(): + d = {"ko": "2026년 5월 14일(수)", "year": 2026, "month": 5, "day": 14, "weekday": "수"} + assert format_date(d, "vi") == "Ngày 14/5/2026 (Thứ Tư)" + + +def test_format_date_vi_md_only(): + d = {"ko": "5월 9일(금)", "year": None, "month": 5, "day": 9, "weekday": "금"} + assert format_date(d, "vi") == "Ngày 9/5 (Thứ Sáu)" + + +def test_format_date_vi_relative(): + d = {"ko": "내일", "year": None, "month": None, "day": None, + "weekday": None, "relative": "내일"} + assert format_date(d, "vi") == "Ngày mai" + + +def test_format_time_vi_morning(): + t = {"ko": "오전 9시", "hour": 9, "minute": 0, "ampm": "오전"} + assert format_time(t, "vi") == "9 giờ sáng" + + +def test_format_amount_vi(): + a = {"ko": "15,000원", "value": 15000, "currency": "KRW"} + assert format_amount(a, "vi") == "15,000 won" + + +# ── 영어 포매터 (보조 시연용) ──────────────────────────────────── +def test_format_date_en(): + d = {"ko": "5월 14일(수)", "year": None, "month": 5, "day": 14, "weekday": "수"} + assert format_date(d, "en") == "May 14 (Wed)" + + +# ── URL / 전화 ─────────────────────────────────────────────────── +def test_extract_urls_http_https_www(): + text = "신청은 https://example.kr/apply 에서, 자세한 내용은 www.school.go.kr 참조." + urls = extract_urls(text) + assert "https://example.kr/apply" in urls + assert "www.school.go.kr" in urls + + +def test_extract_urls_dedupes(): + text = "http://a.com 안내 http://a.com 재공지" + urls = extract_urls(text) + assert urls.count("http://a.com") == 1 + + +def test_extract_phones_dash_formats(): + text = "교무실 02-2649-7232, 학교 849-7003, 신고 1588-0260, 휴대폰 010-1234-5678" + phones = extract_phones(text) + assert "02-2649-7232" in phones + assert "849-7003" in phones + assert "1588-0260" in phones + assert "010-1234-5678" in phones + + +def test_extract_phones_does_not_match_amount_with_comma(): + """'15,000원' 같은 숫자에서 전화번호가 잘못 잡히면 안 됨.""" + text = "참가비 15,000원, 문의 02-1234-5678" + phones = extract_phones(text) + assert "02-1234-5678" in phones + assert not any(p.startswith("15") or p.startswith("000") for p in phones) + + +# ── 통합 진입점 ────────────────────────────────────────────────── +def test_extract_summary_regex_slots_full_flow(): + text = "5월 14일(수) 오전 9시 출발. 참가비 15,000원." + out = extract_summary_regex_slots(text, "vi") + assert any(d["ko"] == "5월 14일(수)" for d in out["dates"]) + assert all(d["source"] == "regex" for d in out["dates"]) + assert any(t["ko"] == "오전 9시" for t in out["times"]) + assert any(a["ko"] == "15,000원" for a in out["amounts"]) + + +def test_extract_summary_regex_slots_includes_urls_and_phones(): + """summary 슬롯에 urls/phones가 ko 그대로 통과 (NLLB 안 거침).""" + text = "신청 https://apply.school.kr 문의 02-2649-7232" + out = extract_summary_regex_slots(text, "vi") + assert any(u["ko"] == "https://apply.school.kr" for u in out["urls"]) + assert any(p["ko"] == "02-2649-7232" for p in out["phones"]) + # 보호: translated가 ko와 동일해야 한다 + for slot in out["urls"] + out["phones"]: + assert slot["translated"] == slot["ko"] + assert slot["source"] == "regex" + + +def test_find_when_combines_date_and_time(): + when = find_when_in_text("5월 14일(수) 오전 9시 출발", "vi") + assert "Ngày 14/5" in when + assert "9 giờ sáng" in when + + +def test_find_amount_returns_korean_surface(): + assert find_amount_in_text("참가비 15,000원", "vi") == "15,000원" + + +def test_find_deadline_phrase_with_까지(): + assert "5월 9일(금)까지" in find_deadline_in_text("5월 9일(금)까지 제출") + + +def test_find_deadline_not_broken_by_number_comma(): + """`15,000원 (...까지...)` 의 콤마에서 잘려서 '000원...'만 잡히던 회귀 방지.""" + text = "참가비: 15,000원 (5월 9일(금)까지 스쿨뱅킹으로 납부)" + result = find_deadline_in_text(text) + assert result is not None + assert "5월 9일" in result + assert not result.lstrip().startswith("000") + + +# ── 마크업 strip ──────────────────────────────────────────────── +def test_strip_markers_leading_bullet(): + assert strip_markers("■ 준비물: 도시락") == "준비물: 도시락" + + +def test_strip_markers_multiple_chars(): + assert strip_markers("▶▸ 안내사항") == "안내사항" + + +def test_strip_markers_preserves_inside(): + """문장 내부의 기호는 보존 (시작 부분만 제거).""" + assert strip_markers("■ 5월 14일 - 출발") == "5월 14일 - 출발" + + +def test_strip_markers_no_op_when_clean(): + assert strip_markers("도시락을 준비하세요") == "도시락을 준비하세요" + + +# ── 준비물 토큰 분해 ───────────────────────────────────────────── +def test_split_supply_tokens_comma_separated(): + tokens = split_supply_tokens("도시락, 물통, 돗자리, 편한 운동화, 여벌 옷") + assert "도시락" in tokens + assert "물통" in tokens + assert "돗자리" in tokens + assert "편한 운동화" in tokens + + +def test_split_supply_tokens_strips_prefix(): + tokens = split_supply_tokens("준비물: 도시락, 물병을 준비해 주세요") + assert "도시락" in tokens + assert "물병" in tokens diff --git a/backend/tests/test_translator_protection.py b/backend/tests/test_translator_protection.py new file mode 100644 index 0000000000000000000000000000000000000000..ab56a4ea987725b243fad4e59282d0f67a6703d3 --- /dev/null +++ b/backend/tests/test_translator_protection.py @@ -0,0 +1,119 @@ +"""URL/전화 placeholder 보호 단위 테스트. + +NLLB 호출 자체는 모킹해서 마스킹 → 번역 → 복원 사이클만 검증. +실제 NLLB 모델은 무거우므로 _translate를 monkeypatch. +""" +import pytest + +from app.services.translator import ( + _is_url_or_phone, + _mask_protected_entities, + _restore_protected_entities, + translate_short_sentence, + translate_term, +) + + +# ── 마스킹 / 복원 단위 ───────────────────────────────────────── +def test_mask_url_replaces_with_token(): + masked, holders = _mask_protected_entities("신청은 https://apply.kr 에서") + assert "https://apply.kr" not in masked + assert "⟦P0⟧" in masked + assert holders == ["https://apply.kr"] + + +def test_mask_phone_replaces_with_token(): + masked, holders = _mask_protected_entities("문의 02-2649-7232") + assert "02-2649-7232" not in masked + assert "⟦P0⟧" in masked + assert holders == ["02-2649-7232"] + + +def test_mask_url_and_phone_separate_tokens(): + masked, holders = _mask_protected_entities( + "https://a.com 문의 02-1234-5678" + ) + # 두 엔티티 각각 다른 인덱스 + assert "⟦P0⟧" in masked and "⟦P1⟧" in masked + assert set(holders) == {"https://a.com", "02-1234-5678"} + + +def test_mask_no_entities_returns_text_unchanged(): + masked, holders = _mask_protected_entities("그냥 평범한 통신문") + assert masked == "그냥 평범한 통신문" + assert holders == [] + + +def test_restore_returns_original_entities(): + masked, holders = _mask_protected_entities("문의 02-2649-7232") + restored = _restore_protected_entities(masked, holders) + assert restored == "문의 02-2649-7232" + + +def test_restore_with_translated_token_intact(): + """NLLB 번역 결과에도 토큰이 살아남으면 복원 가능.""" + holders = ["02-2649-7232"] + fake_translated = "Liên hệ ⟦P0⟧" + assert _restore_protected_entities(fake_translated, holders) == "Liên hệ 02-2649-7232" + + +# ── 슬롯 단위 가드 ──────────────────────────────────────────── +def test_is_url_or_phone_true_for_url(): + assert _is_url_or_phone("https://example.kr/path") + assert _is_url_or_phone("www.school.go.kr") + + +def test_is_url_or_phone_true_for_phone(): + assert _is_url_or_phone("02-2649-7232") + assert _is_url_or_phone("1588-0260") + + +def test_is_url_or_phone_false_for_normal_text(): + assert not _is_url_or_phone("도시락") + assert not _is_url_or_phone("5월 14일") + + +def test_translate_term_passthrough_for_url_phone(monkeypatch): + """URL/전화는 어떤 언어든 번역 안 거치고 ko 그대로.""" + # glossary 호출이 일어나면 안 됨 (URL/전화 가드가 그 위에서 차단) + called = [] + monkeypatch.setattr( + "app.services.translator._get_glossary", + lambda: called.append("glossary") or [], + ) + assert translate_term("https://apply.kr", "vi") == "https://apply.kr" + assert translate_term("02-2649-7232", "en") == "02-2649-7232" + assert called == [] + + +# ── translate_short_sentence E2E (NLLB 모킹) ────────────────── +def test_translate_short_sentence_protects_url_through_nllb(monkeypatch): + """URL이 NLLB 호출 후에도 원래 문자열로 복원돼야 한다.""" + captured_input = [] + + def fake_translate(text, target_nllb="vie_Latn", max_length=512): + captured_input.append(text) + # NLLB는 마스킹된 토큰을 그대로 통과시킨다고 가정 (실제로도 ⟦…⟧ 안 깸) + return text.replace("문의", "Liên hệ").replace("신청은", "Đăng ký") + + monkeypatch.setattr("app.services.translator._translate", fake_translate) + monkeypatch.setattr("app.services.translator._get_glossary", lambda: []) + monkeypatch.setattr( + "app.services.translator._sejong.find_glossary_hits", + lambda text, glossary, lang: [], + ) + + out = translate_short_sentence( + "신청은 https://apply.kr 에서, 문의 02-2649-7232", + "vi", + ) + + # 1) NLLB에 들어간 입력엔 URL/전화가 토큰으로 마스킹됨 + assert "https://apply.kr" not in captured_input[0] + assert "02-2649-7232" not in captured_input[0] + assert "⟦P0⟧" in captured_input[0] + + # 2) 최종 출력엔 URL/전화가 원래 문자열로 복원됨 + assert "https://apply.kr" in out + assert "02-2649-7232" in out + assert "⟦P" not in out diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..e21934fd938d6bdb84f408fdb3b6c86fc4846b74 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + backend: + build: ./backend + ports: + - "8000:8000" + volumes: + - ./backend:/app + - ./scripts:/app/scripts + - ./data:/app/external_data + - ./model:/app/external_model + - hf_cache:/root/.cache/huggingface + env_file: + - ./backend/.env + dns: + - 8.8.8.8 + - 1.1.1.1 + # LibreOffice ODT 변환 후 H2Orestart cleanup 버그로 segfault 발생. + # 코어 덤프 비활성화 (RLIMIT_CORE=0) — wsl-crashes 누적 → 디스크 마비 방지. + ulimits: + core: 0 + restart: unless-stopped + +volumes: + hf_cache: diff --git a/model/classification/.gitkeep b/model/classification/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/model/classification/README.md b/model/classification/README.md new file mode 100644 index 0000000000000000000000000000000000000000..24a96a681d640e81d9aad981a9f5683c395c24bf --- /dev/null +++ b/model/classification/README.md @@ -0,0 +1,213 @@ +전체 파이프라인 (확정 버전) + +┌──────────────────────────────────────────────────────────────────┐ +│ 호스트 앱 → POST /notice/analyze │ +│ body: HWP/PDF (multipart) or text (json) │ +└──────────────────────────────────────────────────────────────────┘ + ↓ +[1] 텍스트 변환 (services/parser.py — 신설) + · text → passthrough + · PDF → pdfplumber + table 분리 + · HWP/HWPX → LibreOffice + pdfplumber + · 이미지 → 세종님 OCR (연휴 후 합류) + → output: clean_text (str) + + ↓ +[2] 정규식 슬롯 추출 (services/slot_extractor.py — 이미 구현) + · dates / times / amounts (이미 있음) + · urls / phones (NEW — 추가 필요) + · 통신문 전체 단위. summary용 재료. + → output: regex_slots (dict) + + ↓ +[3] 할일 추출 (services/extractor.py — 윤정님 v2 wrapper) + 입력: clean_text + 내부: 문장분리 → binary 분류(0.5컷) → 정규식(due_date/amount/action_hint) + 출력: list[YunjeongTodo] + 각 항목: { text, source, due_date, amount, + confidence, action_hint } + 빈 리스트면 items 없음. + + ↓ +[4] 카테고리 분류 (services/classifier.py — 경이님 6-class wrapper) + 입력: 각 todo.text + 출력: category ∈ {일정, 준비물, 제출, 비용, 건강·안전, 기타} + + ↓ +[5] 번역 (services/translator.py — 이미 구현, 보강 필요) + 슬롯 단위: + · dates/times/amounts → i18n formatter (deterministic, NLLB 안 거침) + · urls/phones → ko 그대로 (NEW) + · places/supplies → glossary lookup → 없으면 NLLB + 본문 단위 (todo.text): + · URL/전화 ⟦P0⟧ 토큰 치환 → NLLB → 토큰 복원 (NEW, 보호) + · glossary injection → NLLB + + ↓ +[6] 응답 빌드 (routers/notice.py) + summary = SummarySlots( + dates, times, amounts, urls, phones, # 정규식 + places, supplies, deadlines # items에서 집계 + ) + items = [ + AnalyzeItem( + text_ko = todo.text, + title_translated = ...번역..., + category = 경이(todo.text), # 주제 (6-class) + action_hint = todo.action_hint, # 행동 (신청/제출/...) + due_date = todo.due_date, + amount = todo.amount, + importance = todo.confidence, + ... + ) + for todo in yunjeong_todos + ] + + ↓ +[7] TTS (services/tts.py — 이미 구현) + 슬롯 + 할일 합쳐 문장별 mp3 생성. + importance 내림차순 정렬. + + ↓ +JSON 응답 → 호스트 앱 +{ + "summary": SummarySlots, + "items": [AnalyzeItem, ...], + "tts_url": "...", + ... +} + + +### 카테고리 분류-경이님 + +`일정`, `준비물`, `제출`, `비용`, `건강·안전`, `기타` + + +# 가장 중요한 핵심과제: 모델 성능 비교 (베이스라인 VS. 파인튜닝) +1. 조건: 베이스라인 모델, 파인튜닝한 모델에 들어가는 input data가 동일한 데이터셋 및 동일한 조건에서 두 모델의 성능을 비교. 다시 말해서, 기존에 있던 모델을 가지고 동일한 조건을 맞춰서 일정, 준비물, 제출, 비용, 건강·안전, 기타에 대한 분류 성능 점수가 나와야하고 파인튜닝한 모델을 동일한 조건으로 6가지 분류 성능 점수가 나와야 비교가 가능. 그래서 파인튜닝된 모델이 베이스라인모델보다 성능이 좋다라는 지표가 나와야 성능의 우수함을 입증할 수 있음. 근거 자료를 만들어야 함. + +2. 두 모델(베이스라인 모델, 파인튜닝한 모델)에 들어가는 데이터는 답안지가 없기 때문에 accuracy가 아닌 그 모델의 맞는 평가 방식 및 성능 지표를 뽑아야 함. 사용하고자 하는 모델들의 기능을 자세한 설명 듣기. + +예시로, Precision이라고 하면 10개 단어 중에 2개 단어만 맞췄다. 그래서 그 모델로 해서 모든 텍스트 데이터 돌아서 몇 프로 맞췄으니까 얘는 성능이 얼마다 라고 얘기하는 것도 있다. + +==> 파인튜닝의 성능이 베이스라인 성능보다 좋은 쪽으로 모델이 나와야하고 그 모델에 맞는 평가 지표가 나와야 한다. 글씨로 정리하는 것 뿐만아니라 시각적인 도구를 활용해서 그래프 혹은 직선 사용 등으로 제시할 근거 자료가 필요. + + +# 윤정님 데이터 정보들 + +-원본 데이터 : \data\galsan_txt +-전처리 파일 : file/preprocess_txt_to_jsonl.py +-전처리 후 데이터 : data/v2.1_notices_galsan.jsonl + +-윤정님이 전처리 후 데이터로 모델 학습 시켰고 실 서비스에서 +데이터 넣고 나오는 아웃풋을 보려면 predict.py를 돌려봐야 +하고 그 predict.py의 아웃풋이 경이님 모델로 들어감. + +-predict.py를 경이님 모델의 인풋으로 해서 넣어야 함. +results.append({ + "text": sentence, + "source": source, + "due_date": extract_due_date(sentence), + "amount": extract_amount(sentence), + "confidence": round(confidence, 4), + "action_hint": extract_action_hint(sentence), + }) +[ + { + "text": "모국어를 사용하는 강사가 단계별로 친절히 가르치는 동영상 강의(VOD)를 PC나 모바일 기기로 접속하여 언제든지 원하는 장소에서 편리하게 학습할 수 있는 좋은 기회이오니, 한국어 학습이 필요한 다문화가정 학생 및 학부모(보호자) 모두 기한 내 신청하여 주시기 바랍니다.", + "source": "sample_pdfplumber.txt", + "due_date": null, + "amount": null, + "confidence": 0.9946, + "action_hint": "신청" + } +] + +# 추천된 모델 및 기술 스택 + +추천1. KcELECTRA fine-tune + +이유: +1. 데이터 양 충분 (700~1400 sentence) — KcELECTRA fine-tune 권장 sweet spot +2. 학교 도메인 어휘 OOV 문제 해결 (subword tokenization) +3. 윤정님 base 모델과 같은 koelectra-small ─ backbone 공유 가능 + → 백엔드 RAM 중복 로드 회피 (둘 다 base는 같고 head만 다름) +4. CPU 추론 가능 (small 변형이라 ~50ms/문장) +5. 윤정님이 이미 같은 모델로 학습 환경 셋업 완료 — 학습 코드 재활용 +메모리 효율 트릭 + +두 모델이 같은 backbone 공유하면 RAM 중복 0 +shared_encoder = AutoModel.from_pretrained("monologg/koelectra-small-v3-discriminator") + +윤정_head = BinaryHead(shared_encoder) # 할일 추출 (binary) +경이_head = MulticlassHead(shared_encoder, 6) # 카테고리 (6-class) +이렇게 짜면 경이 모델 추가에 따른 RAM 증가가 head만큼(~수MB)으로 줄어듦. 이상적. + +단계적 권장안 + +1주차: 경이님이 KcELECTRA fine-tune 시도 + +데이터: notice_sample_v3.csv + notices_galsan.jsonl 라벨 부분 +GPU: Colab T4 무료로 충분 (~20분 학습) +검증: 갈산초 holdout F1 + cross-validation + +2주차: 정확도 비교 + +simple (현재) vs KcELECTRA fine-tune +5%+ F1 향상 → KcELECTRA 채택, 그 미만 → simple 유지 +simple은 항상 fallback으로 유지 + +3주차: 시연 통합 + +더 좋은 쪽으로 교체 +선생님께 학습 코드/모델 공유로 재현성 보장 +주의 +시연 4주 임박이라 무리하면 안 됨: + +KcELECTRA fine-tune 학습 자체는 빠르지만 검증 + 디버깅 시간 필요 +simple v1이 이미 75% 정확도라 시연 박살 안 남 +실패하면 simple 그대로 — 백업 명확히 +경이님 부담: + +학습 환경 셋업 (윤정님 코드 빌려쓰기로 부담 경감) +라벨 데이터 정제 (현재 v3.csv 라벨 품질 확인) +검증셋 분리 (갈산초 holdout) +한 줄 요약 +데이터 충분 → KcELECTRA fine-tune 시도가 정답. 다만 simple v1을 fallback으로 항상 유지. 윤정님 backbone 공유하면 RAM 효율 + 학습 환경 재활용 가능. + +경이님이 비교 실험 의지 있는 건 좋음. 다만 제대로 비교해야 가치 있고, 시연 4주 안 압박도 있으니 셋업이 중요. + +# 비교 실험 설계 가이드 +1. 후보 모델 선정 (3~4개가 최대) +모델 카테고리 학습 시간 강점 +TF-IDF + LogReg 베이스라인 (필수) 수초 빠름 +SBERT + LightGBM 임베딩 ML 분 의미 유사도 +KcELECTRA-small fine-tune 한국어 BERT 20~30분 도메인 학습 +KoBERT fine-tune 한국어 BERT 20~30분 한국어 특화 + +→ 3개 권장: TF-IDF (baseline) + SBERT + KcELECTRA. 4개는 시간 박살. + +2. 공정 비교를 위한 절대 규칙 +(a) 동일 train/val/test 분할 강제 + +scripts/split_dataset.py 만들어서 ONE TIME 실행: +random.seed(42) # 무조건 고정 +labels = stratified_split(data, train=0.8, val=0.1, test=0.1) + +split_v1.csv 라는 단일 파일로 저장 → 모든 모델이 같은 분할 사용 + +(b) Metric 통일 + +Macro F1 (메인) — 클래스 불균형 대비 +Per-class F1 — "비용은 잘 잡는데 건강·안전은 못 잡는다" 같은 진단 +Confusion matrix — 어디서 헷갈리는지 시각화 + +(c) Seed 고정 — numpy, torch, random 모두 42 + +추천2. 모델: klue/roberta-base + +KoELECTRA(A단계)와 겹치지 않고, KLUE 벤치마크에서 한국어 분류 SOTA. +파인튜닝이 안정적이고 허깅페이스에서 바로 쓸 수 있음. + + + diff --git a/model/classification/README2.md b/model/classification/README2.md new file mode 100644 index 0000000000000000000000000000000000000000..1d17e1cc19eb5419c2c33f024b79ad41ca8869ad --- /dev/null +++ b/model/classification/README2.md @@ -0,0 +1,284 @@ +# 카테고리 분류 모델 — 경이님 작업 가이드 + +> 이 문서는 경이님이 작업 내용을 빠르게 이해하고 바로 실행할 수 있도록 작성되었습니다. +> 코드가 **왜** 이렇게 짜여졌는지, **무엇**을 하는지 중심으로 설명합니다. + +--- + +## 전체 구조 한눈에 보기 + +``` +model/classification/ +├── data/ +│ ├── notice_sample_v3.csv ← 경이님이 라벨링한 학습 데이터 (150개) +│ └── split_v1.csv ← train/val/test 분할 (scripts 실행 후 생성됨) +├── src/ +│ ├── predict.py ← 백엔드 진입점 (이게 핵심!) +│ ├── classifier_simple.py ← 베이스라인 모델 (TF-IDF + LogReg) +│ └── classifier_kcelectra.py ← KcELECTRA 추론 모듈 +├── scripts/ +│ ├── split_dataset.py ← 데이터 분할 (딱 한 번만 실행) +│ └── evaluate_compare.py ← 두 모델 성능 비교 +├── notebooks/ +│ ├── 01_train_kcelectra.ipynb ← GPU 학습 (Colab에서 실행) +│ └── 02_evaluate_compare.ipynb ← 성능 비교 시각화 +├── checkpoints/ +│ ├── simple_tfidf_logreg.pkl ← 베이스라인 모델 저장 파일 (학습 후 생성) +│ └── kcelectra-category/ ← KcELECTRA 파인튜닝 결과 (Colab 후 복사) +└── README2.md ← 지금 이 파일 +``` + +--- + +## 파이프라인에서 경이님 역할 + +전체 가정통신문 분석 시스템에서 경이님 모델은 **[4]번 단계**를 담당합니다. + +``` +[3] 윤정님 모델 (할일 추출) + ↓ + text: "체험학습 비용 20,000원을 납부해 주세요." + ↓ +[4] 경이님 모델 (카테고리 분류) ← 여기! + ↓ + category: "비용" + ↓ +[5] 번역 → [6] 응답 빌드 → [7] TTS +``` + +**윤정님이 추출한 문장들이 경이님 모델로 들어오고**, 경이님 모델이 각 문장을 6개 카테고리 중 하나로 분류합니다. + +--- + +## 6가지 카테고리 설명 + +| 카테고리 | 의미 | 예시 | +|--------|------|------| +| **일정** | 날짜·시간·행사 관련 | "운동회는 10월 5일 오전 9시에..." | +| **준비물** | 챙겨야 할 물건 | "도시락과 물을 준비해 주세요." | +| **제출** | 서류·동의서 제출 | "동의서를 담임선생님께 제출해 주세요." | +| **비용** | 금액·납부 관련 | "급식비 65,000원을 납부해 주세요." | +| **건강·안전** | 건강·안전 지침 | "발열 증상 시 등교를 자제해 주세요." | +| **기타** | 위에 해당 없음 | "궁금한 사항은 담임선생님께 문의..." | + +--- + +## 백엔드 연결 구조 이해하기 + +백엔드 `backend/app/services/classifier.py`가 이렇게 호출합니다: + +```python +from src.predict import predict_one +result = predict_one("납부할 급식비는 6만 5천원입니다.", model="simple") +# → {"category": "비용", "confidence": 0.87, "model_used": "simple"} +``` + +`predict.py`의 `predict_one()`이 **모든 모델의 단일 창구**입니다. +`model="simple"` → TF-IDF+LogReg 사용 +`model="kcelectra"` → KcELECTRA 파인튜닝 모델 사용 +`model="auto"` → KcELECTRA 체크포인트 있으면 사용, 없으면 simple로 자동 전환 + +--- + +## 파일별 역할 상세 설명 + +### `data/notice_sample_v3.csv` +학습 데이터 파일입니다. 컬럼 2개 (`text`, `category`). + +- 150개의 문장이 미리 라벨링 되어 있습니다 +- 각 카테고리당 약 20~25개씩 균등하게 구성 +- **더 많은 데이터를 추가할수록 모델 성능이 향상됩니다** + - 형식: `문장,카테고리` (맨 아래에 행 추가) + - 카테고리는 반드시 `일정`, `준비물`, `제출`, `비용`, `건강·안전`, `기타` 중 하나 + +### `src/classifier_simple.py` — 베이스라인 (TF-IDF + Logistic Regression) + +**왜 TF-IDF인가?** +- TF-IDF는 각 단어가 문서에서 얼마나 중요한지 숫자로 나타냅니다 +- "납부", "입금", "원" 같은 단어가 **비용** 카테고리에서 많이 나오면 높은 점수를 받음 +- GPU 없이 CPU에서 수십 ms만에 실행 — 백엔드 서버 부담 없음 + +**왜 char_wb n-gram인가?** +- 한국어는 "납부해" "납부하여" "납부하시기" 등 동사 변형이 많음 +- 글자 단위 2~4글자 조합(`ngram_range=(2,4)`)으로 형태소 변형 문제 해결 +- 예: "납부" → "납부", "부하", "부해", "납부하" 등으로 분해해 학습 + +**사용법:** +```bash +cd model/classification +python src/classifier_simple.py # 학습 + 저장 +python src/classifier_simple.py --eval # 테스트 평가 +``` + +### `src/classifier_kcelectra.py` — KcELECTRA 추론 모듈 + +**왜 KcELECTRA인가?** +- 한국어 특화 사전학습 모델 (윤정님 모델과 동일한 backbone!) +- ELECTRA 구조: BERT보다 학습 효율이 2~3배 좋음 +- `koelectra-small`: 메모리 사용량 적어 CPU 서버에서도 동작 + +**중요:** 이 파일은 **추론만** 합니다. 학습은 `notebooks/01_train_kcelectra.ipynb`에서. + +학습이 끝나면 `checkpoints/kcelectra-category/` 폴더가 생깁니다. +이 폴더가 없으면 `is_ready()` 함수가 False를 반환하여 simple로 자동 전환됩니다. + +### `src/predict.py` — 백엔드 진입점 + +**왜 이 파일이 중요한가?** +- 백엔드가 `from src.predict import predict_one`으로 이 함수를 호출 +- 내부에서 어떤 모델을 쓸지 결정하는 로직 포함 +- `model="auto"`로 설정하면 체크포인트 유무에 따라 자동 선택 + +**반환 형식:** +```python +{ + "text": "납부할 급식비는 6만 5천원입니다.", + "category": "비용", # 최종 분류 결과 + "confidence": 0.87, # 얼마나 확신하는지 (0~1) + "model_used": "simple", # 실제 사용된 모델 + "probs": { # explain=True일 때만 포함 + "일정": 0.02, + "비용": 0.87, + ... + } +} +``` + +### `scripts/split_dataset.py` — 데이터 분할 + +**왜 딱 한 번만 실행해야 하는가?** +- 베이스라인과 KcELECTRA가 **완전히 동일한** 데이터로 학습/평가해야 공정한 비교 가능 +- 한 번 분할하면 `split_v1.csv`에 고정 저장 +- 랜덤 시드 42로 고정 → 언제 실행해도 같은 결과 + +```bash +python scripts/split_dataset.py # 최초 1회 실행 +python scripts/split_dataset.py --force # 강제 재생성 (비추천) +``` + +분할 비율: **Train 80% / Val 10% / Test 10%** +Stratified Split: 각 카테고리에서 균등하게 뽑음 + +### `scripts/evaluate_compare.py` — 성능 비교 + +두 모델을 같은 test 데이터로 평가하고 결과를 저장합니다. + +```bash +python scripts/evaluate_compare.py +``` + +생성 파일: +- `data/eval_results_simple.json` — 베이스라인 상세 결과 +- `data/eval_results_kcelectra.json` — KcELECTRA 상세 결과 +- `data/eval_comparison_summary.csv` — 두 모델 비교 요약 + +--- + +## 실행 순서 (처음부터 전부 하려면) + +### Step 1. 데이터 분할 (딱 한 번) +```bash +cd c:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification +python scripts/split_dataset.py +``` + +### Step 2. 베이스라인 학습 +```bash +python src/classifier_simple.py +``` +`checkpoints/simple_tfidf_logreg.pkl` 파일이 생성됩니다. + +### Step 3. KcELECTRA 파인튜닝 (Colab GPU 필요) +1. Google Colab 접속 → 런타임 → 런타임 유형 변경 → **GPU** +2. `notebooks/01_train_kcelectra.ipynb` 업로드 +3. `data/notice_sample_v3.csv`와 `data/split_v1.csv` 업로드 +4. 모든 셀 순서대로 실행 (~20분) +5. `checkpoints/kcelectra-category/` 폴더 다운로드 +6. 로컬 `checkpoints/kcelectra-category/`에 붙여넣기 + +### Step 4. 성능 비교 +```bash +python scripts/evaluate_compare.py +``` + +### Step 5. 시각화 확인 +Jupyter에서 `notebooks/02_evaluate_compare.ipynb` 열어서 실행. + +--- + +## 평가 지표 설명 + +### Macro F1 (메인 지표) +- 6개 카테고리 각각의 F1을 구한 뒤 **평균** +- 클래스 불균형에 강함 (특정 클래스가 많아도 편향 없음) +- **0.8 이상이면 좋은 성능** + +### F1 Score = 2 × (Precision × Recall) / (Precision + Recall) +- **Precision (정밀도):** "비용이라고 예측한 것 중 실제로 비용인 비율" +- **Recall (재현율):** "실제 비용인 것 중 비용이라고 맞춘 비율" +- F1은 이 둘의 균형 + +### Confusion Matrix +행 = 실제 카테고리, 열 = 예측 카테고리 +대각선이 클수록 좋음 (맞게 분류한 것들) + +--- + +## KcELECTRA 채택 기준 + +> Simple 대비 **Macro F1이 5% 이상 향상**되면 KcELECTRA 채택 + +- ΔF1 ≥ +0.05 → KcELECTRA 채택, predict_one에서 `model="kcelectra"`로 변경 +- ΔF1 < 0.05 → Simple 유지 (안정성 우선) +- Simple은 항상 fallback으로 유지 + +--- + +## 자주 묻는 질문 + +**Q. 베이스라인 학습이 안 되고 파일을 못 찾는다고 에러가 난다면?** +→ `cd model/classification` 후 실행하세요. 경로 기준이 `model/classification/`입니다. + +**Q. 데이터를 더 추가하고 싶은데 어떻게 하나요?** +→ `data/notice_sample_v3.csv` 맨 아래에 `문장,카테고리` 형식으로 행 추가. +단, split_v1.csv가 없는 상태라면 추가 후 `split_dataset.py` 실행. +이미 split_v1.csv가 있다면 `--force`로 재분할. + +**Q. KcELECTRA 학습이 CUDA OOM 에러가 난다면?** +→ `01_train_kcelectra.ipynb`의 `BATCH_SIZE = 16`을 `8`로 줄이세요. + +**Q. predict_one이 항상 "기타"만 반환한다면?** +→ 베이스라인 모델(pkl 파일)이 없는 것. `python src/classifier_simple.py` 먼저 실행. + +##4월30일##작업 완료 — feature/kyeongyi-classification 브랜치 +model/classification/ 폴더에 다음 구조가 완성됐습니다: + + +model/classification/ +├── data/ +│ ├── notice_sample_v3.csv ← 새로 만든 학습 데이터 (150개, 6카테고리 균등) +│ ├── notices_labeled_v2.csv ← 기존 라벨 데이터 복원 (병합 학습에 사용) +│ └── notices_labeled_v2.jsonl ← 기존 원본 데이터 복원 +├── src/ +│ ├── predict.py ← 백엔드 진입점 (predict_one 함수) +│ ├── classifier_simple.py ← 베이스라인: TF-IDF + LogReg (CPU) +│ └── classifier_kcelectra.py ← KcELECTRA 추론 모듈 +├── scripts/ +│ ├── split_dataset.py ← 데이터 공정 분할 (딱 한 번 실행) +│ └── evaluate_compare.py ← 두 모델 성능 비교 +├── notebooks/ +│ ├── 01_train_kcelectra.ipynb ← GPU 학습 (Colab T4, ~20분) +│ └── 02_evaluate_compare.ipynb ← 성능 비교 시각화 +├── checkpoints/ ← 학습 후 모델 파일 저장 위치 +├── docs/ +│ └── devlog_2026-04-30.md ← 오늘 개발일지 +└── README2.md ← 상세 가이드 + +# 지금 바로 실행할 순서: + +python scripts/split_dataset.py → 데이터 분할 +python src/classifier_simple.py → 베이스라인 학습 +Colab에서 notebooks/01_train_kcelectra.ipynb → KcELECTRA 파인튜닝 (GPU) +python scripts/evaluate_compare.py → 두 모델 성능 비교 +notebooks/02_evaluate_compare.ipynb → 시각화 차트 생성 (발표 근거 자료) +백엔드가 호출하는 predict_one() 인터페이스는 기존과 완전히 호환되며, model="simple" / "kcelectra" / "auto" 세 가지 모드를 지원합니다. 자세한 설명은 README2.md와 devlog_2026-04-30.md를 참고. \ No newline at end of file diff --git a/model/classification/README3.md b/model/classification/README3.md new file mode 100644 index 0000000000000000000000000000000000000000..7bdb22faae5883a1a752b08130ddf358cd6d9be6 --- /dev/null +++ b/model/classification/README3.md @@ -0,0 +1,38 @@ +# 윤정님(2026년 05월 04일): + +-새로운 데이터 추가 +model/extraction/data/train/test_data.jsonl 모델 통과 전 — {text, is_todo} 5,560행 +data/processed/predict_output_testset.jsonl 모델 통과 후 — {text, source, due_date, amount, confidence, action_hint, true_is_todo} 5,330행 (정규식 필터 제외 230행) + +# 로컬에서 비교 평가 실행 + +(ai_env) C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification>python scripts/evaluate_compare_v2_20260504.py +평가 시작 — split: test, 데이터: v4_20260504 + +[Simple] 분류 리포트 + precision recall f1-score support + + 일정 0.79 0.85 0.81 13 + 준비물 0.86 0.75 0.80 8 + 제출 0.69 0.69 0.69 13 + 비용 1.00 0.90 0.95 10 + 건강·안전 0.75 0.82 0.78 11 + 기타 0.71 0.71 0.71 14 + + accuracy 0.78 69 + macro avg 0.80 0.79 0.79 69 +weighted avg 0.79 0.78 0.78 69 + +[kcelectra] 기존 JSON 재활용: eval_results_kcelectra_20260504.json + +[저장] eval_results_simple_20260504.json +[저장] eval_results_kcelectra_20260504.json +[저장] eval_comparison_summary_20260504.csv + +================================================== + 성능 비교 결과 +================================================== + Simple Macro F1 : 0.7919 + KcELECTRA Macro F1 : 0.6938 + Delta : -0.0981 + >> Simple 유지 권장 diff --git a/model/classification/README4.md b/model/classification/README4.md new file mode 100644 index 0000000000000000000000000000000000000000..7a1375518038c7f9c1e8cfacaa41da2adf219adf --- /dev/null +++ b/model/classification/README4.md @@ -0,0 +1,50 @@ +# README.md +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\README.md + +전체 파이프라인 (확정 버전) +[4] 카테고리 분류 (services/classifier.py — 경이님 6-class wrapper) +입력: 각 todo.text +출력: category ∈ {일정, 준비물, 제출, 비용, 건강·안전, 기타} + +- 카테고리 분류-경이님 + +`일정`, `준비물`, `제출`, `비용`, `건강·안전`, `기타` + + +- 가장 중요한 핵심과제: 모델 성능 비교 (베이스라인 VS. 파인튜닝) +1. 조건: 베이스라인 모델, 파인튜닝한 모델에 들어가는 input data가 동일한 데이터셋 및 동일한 조건에서 두 모델의 성능을 비교. 다시 말해서, 기존에 있던 모델을 가지고 동일한 조건을 맞춰서 일정, 준비물, 제출, 비용, 건강·안전, 기타에 대한 분류 성능 점수가 나와야하고 파인튜닝한 모델을 동일한 조건으로 6가지 분류 성능 점수가 나와야 비교가 가능. 그래서 파인튜닝된 모델이 베이스라인모델보다 성능이 좋다라는 지표가 나와야 성능의 우수함을 입증할 수 있음. 근거 자료를 만들어야 함. + +2. 두 모델(베이스라인 모델, 파인튜닝한 모델)에 들어가는 기존의 데이터는 답안지가 없기 때문에 accuracy가 아닌 그 모델의 맞는 평가 방식 및 성능 지표를 뽑아야 함. 그래서 자동라벨링(C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\scripts\auto_label_from_new_data_20260504.py)을 해서 notice_sample_v4_20260504.csv (C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\data\20260504\notice_sample_v4_20260504.csv) 696행 학습 데이터를 만들었다. 그러나 2026년 05월 05일에 받은 새로운 학습 데이터 notice_sample_v5_clean_full_20260504.csv (C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\data\notice_sample_v5_clean_full_20260504.csv) 5001행은 이미 라벨링이 존재함. 그래서 사용하고 있는 모델들의 적절한 평가 및 성능 지표가 나와야 함. 사용하고자 하는 모델 기능들의 자세한 설명 보기. + +예시로, Precision이라고 하면 10개 단어 중에 2개 단어만 맞췄다. 그래서 그 모델로 해서 모든 텍스트 데이터 돌아서 몇 프로 맞췄으니까 얘는 성능이 얼마다 라고 얘기하는 것도 있다. + +==> 파인튜닝의 성능이 베이스라인 성능보다 좋은 쪽으로 모델이 나와야하고 그 모델에 맞는 평가 지표가 나와야 한다. 글씨로 정리하는 것 뿐만아니라 시각적인 도구를 활용해서 그래프 혹은 직선 사용 등으로 제시할 근거 자료가 필요. + +# README2.md +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\README2.md + +# README3.md +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\README3.md + +# docs 개발일지 +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\docs\devlog_2026-04-30.md +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\docs\devlog_2026-04-30_실행결과.md +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\docs\devlog_2026-05-02.md +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\docs\devlog_2026-05-04_자동라벨링.md + +# data +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\data +-중요: 새로운 학습 데이터 C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\data\notice_sample_v5_clean_full_20260504.csv --> 주어진 5001행 데이터를 가지고 학습 시켜야 함. + +나머지는 기존 데이터 + +# scripts +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\scripts + +# src +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\src + +# notebooks +C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\notebooks +-가상환경 설치 권장: tensorflow, torch +-C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\notebooks\03_train_kcelectra_v2_20260504 (1).ipynb 파일처럼 Colab GPU로 돌릴 수 있는 것은 주피터 노트북 형성해야 한다. \ No newline at end of file diff --git a/model/classification/scripts/auto_label_from_new_data_20260504.py b/model/classification/scripts/auto_label_from_new_data_20260504.py new file mode 100644 index 0000000000000000000000000000000000000000..0a1321d8258235ce6994c0ef40556f470ac999d9 --- /dev/null +++ b/model/classification/scripts/auto_label_from_new_data_20260504.py @@ -0,0 +1,575 @@ +""" +auto_label_from_new_data_20260504.py +===================================== +담당: 경이 (kyeongyi) +작성일: 2026-05-04 +브랜치: feature/kyeongyi-classification + +목적: + 윤정님이 새로 추가한 대용량 데이터(5,560행)에는 'is_todo' 정보만 있고 + '일정/준비물/제출/비용/건강·안전/기타' 라벨이 없다. + 이 스크립트는 키워드 규칙과 슬롯(amount, due_date, action_hint)을 이용해 + 자동으로 카테고리를 부여하고, 기존 v3.csv와 합쳐 v4 학습 데이터를 생성한다. + +왜 '자동 라벨링'을 썼나? + 1. 새 데이터 5,560행을 사람이 수작업으로 라벨링하면 수십 시간 걸림. + 2. 기존 라벨 데이터(v3.csv, 142행)만으로는 KcELECTRA 파인튜닝이 부족 + (BERT 계열은 최소 클래스당 50~100개, 전체 300~1000개 이상 필요). + 3. 키워드 기반 자동 라벨링 → 대량 데이터 확보 → KcELECTRA 재학습 → 성능 역전. + +검증 방법: + - 기존 라벨이 있는 v3.csv를 이 규칙으로 다시 분류 → 규칙 정확도 확인. + - 규칙 정확도가 75% 이상이면 자동 라벨 데이터를 신뢰할 수 있다고 판단. + +실행: + cd model/classification + python scripts/auto_label_from_new_data_20260504.py +""" + +import json +import re +import csv +import random +from pathlib import Path +from collections import Counter + +random.seed(42) # 재현성 보장 — 누가 실행해도 동일한 분할·샘플 순서 + + +# ────────────────────────────────────────────────────────────────── +# 1. 경로 설정 +# ────────────────────────────────────────────────────────────────── +# 이 파일의 위치: model/classification/scripts/ +# _BASE → model/classification/ +_BASE = Path(__file__).parent.parent + +DATA_DIR = _BASE / "data" +EXTRACT_BASE = _BASE.parent.parent / "model" / "extraction" # 윤정님 모델 폴더 + +# 입력 파일 (윤정님이 추가한 새 데이터) +SRC_TEST_DATA = EXTRACT_BASE / "data" / "train" / "test_data.jsonl" +SRC_PREDICT_OUT = EXTRACT_BASE / "data" / "processed" / "predict_output_testset.jsonl" + +# 기존 경이님 라벨 데이터 +SRC_V3_CSV = DATA_DIR / "notice_sample_v3.csv" + +# 출력 파일 +OUT_CSV = DATA_DIR / "notice_sample_v4_20260504.csv" + +# 6가지 분류 카테고리 +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] + + +# ────────────────────────────────────────────────────────────────── +# 2. 카테고리별 키워드 규칙 +# ────────────────────────────────────────────────────────────────── +# 설계 원칙: +# (a) 우선순위 순서로 검사 — 먼저 매칭된 카테고리를 반환하고 중단. +# (b) 각 카테고리에서 가장 '강한' 신호(오탐 없는 단어)를 선택. +# (c) 한국어는 조사·어미 변형이 많으므로 어근만 사용 (예: "납부" → "납부해", "납부하여" 모두 포함). +# (d) 숫자 패턴은 정규표현식 사용 (예: "20,000원", "3만원" 등 다양한 금액 표현). +# +# 왜 이 순서인가? +# 비용 > 준비물 > 제출 > 건강·안전 > 일정 > 기타 +# → "3만 원을 5월 15일까지 납부" 같은 문장은 '비용'이 더 강한 신호. +# → '일정'은 날짜 표현이 많아 오탐 위험이 있어 후순위. +# → '기타'는 규칙 없이 나머지를 다 받는 쓰레기통 역할. + +RULES: list[tuple[str, list[str]]] = [ + # ── 비용 ── + # 금액·납부 관련 표현이 명확하게 있는 경우. + # r"\d[\d,]*\s*원" : "65,000원", "3만원"은 이 정규식으로 포착. + ("비용", [ + "납부", # "납부해 주세요", "납부하여" + "입금", # "계좌로 입금" + "계좌이체", # "계좌이체해 주세요" + "급식비", + "참가비", + "수강료", + "교재비", + "버스비", + "이용료", + "수업료", + "구입비", + "지원금", + "면제", # "급식비 면제 신청" + "선납", # "선납해 주세요" + r"\d[\d,]*\s*원", # 숫자+원 (20,000원, 3만원 등) + ]), + + # ── 준비물 ── + # 챙겨야 할 물건·복장 관련. + # "마스크를" 처럼 뒤에 조사가 붙는 경우도 어근으로 포착. + ("준비물", [ + "지참", # "지참하시기 바랍니다" + "챙겨", # "챙겨주시기 바랍니다" + "챙기", # "챙기시기 바랍니다" + "준비해", # "준비해 주세요" + "준비물", # "학습 준비물" + "가져오", # "가져오세요" + "착용", # "착용하여 등교" + "신고 오", # "운동화를 신고 오세요" + "복장", + "도시락", + "우산", + "수영복", + "실내화", + "방한", # "방한용품" + "앞치마", + "고무장갑", + "배낭", + "여벌", # "여벌 옷" + "스케치북", + "색연필", + ]), + + # ── 제출 ── + # 서류·동의서·설문 등 무언가를 학교에 내야 하는 경우. + ("제출", [ + "제출", # "제출해 주세요", "제출하여" + "동의서", # "현장체험학습 동의서" + "신청서", # "급식 신청서" + "설문", # "온라인 설문에 응답" + "서류를", + "작성하여", # "작성하여 제출" + "응답해", # "설문에 응답해 주세요" + "기재", # "기재해 주세요" + "접수", + "첨부", + "등록", # "회원 등록" + "신청해", # "신청해 주세요" + "기한 내 신청", + "아이 편에", # "아이 편에 보내주시기" + "담임선생님께 보내", + ]), + + # ── 건강·안전 ── + # 신체 관련, 안전사고 예방, 감염병 관련. + ("건강·안전", [ + "발열", + "기침", + "증상", + "예방접종", + "백신", + "감염", + "방역", + "소독", + "위생", + "자가진단", + "결석", + "질병", + "코로나", + "독감", + "알레르기", + "안전사고", + "교통안전", + "헬멧", + "안전벨트", + "온열", # "온열 질환" + "응급", + "선별진료", + "PCR", + "진단키트", + "확진", + "수분 보충", + ]), + + # ── 일정 ── + # 날짜·시간·행사 관련. 비용/준비물/제출 보다 후순위인 이유: + # "3만 원을 5월 15일까지 납부" → 날짜가 있어도 '비용'이 정답. + # 날짜 패턴은 오탐이 많아 '강한' 단어와 함께 쓸 때만 신뢰. + ("일정", [ + r"\d+월\s*\d+일", # "5월 20일", "3 월 7 일" + r"\d{4}\.\s*\d+\.\s*\d+", # "2025.05.20" + r"\d+\.\s*\d+\.\s*\([월화수목금토일]\)", # "5.20.(화)" + "오전", # "오전 9시" + "오후", + "~까지", # "3월 20일~까지" + "부터 ", # "3월부터 " + "기간", + "주간", + "방학", + "개학", + "졸업식", + "입학식", + "운동회", + "수학여행", + "체험학습", + "현장학습", + "학부모 총회", + "공개수업", + "학예발표", + "체육대회", + "생태체험", + "시작합니다", + "출발합니다", + "진행됩니다", + "실시됩니다", + "열립니다", + "개최됩니다", + ]), +] +# RULES에 없는 문장 → "기타"로 분류 + + +# ────────────────────────────────────────────────────────────────── +# 3. 정규표현식 여부 판별 헬퍼 +# ────────────────────────────────────────────────────────────────── +def _is_regex(pattern: str) -> bool: + """문자열 안에 정규표현식 특수문자가 있으면 True.""" + # \d, ^, $, |, ?, *, +, (, ), [, ], {, } 중 하나라도 있으면 regex + regex_chars = r"\d^$.|?*+()[]{}".replace(".", r"\.") + return bool(re.search(r"[\\^$.|?*+()\[\]{}]|\\d", pattern)) + + +# ────────────────────────────────────────────────────────────────── +# 4. 핵심 분류 함수 (규칙 기반) +# ────────────────────────────────────────────────────────────────── +def label_by_keywords(text: str) -> str | None: + """ + 텍스트에 키워드·패턴이 포함되면 해당 카테고리를 반환. + RULES 리스트 순서대로 검사 — 첫 매칭에서 즉시 반환. + 없으면 None 반환 (→ 슬롯 기반 분류로 넘어감). + """ + for category, patterns in RULES: + for pat in patterns: + if _is_regex(pat): + if re.search(pat, text): + return category + else: + if pat in text: + return category + return None + + +def label_by_slots( + action_hint: str | None, + amount: str | None, + due_date: str | None, +) -> str | None: + """ + 윤정님 추출 모델이 뽑은 슬롯 정보를 이용한 보조 분류. + 키워드 규칙이 None을 반환했을 때만 호출. + + 논리: + - amount 있음 → 비용 관련 문장일 가능성이 높다 + - action_hint = "제출"/"신청" → 제출 카테고리 + - action_hint = "준비"/"지참" → 준비물 카테고리 + - due_date 있음 + 비용/제출 키워드 없음 → 일정 관련 + """ + if amount: + return "비용" + if action_hint in ("제출", "신청", "작성"): + return "제출" + if action_hint in ("준비", "지참", "착용"): + return "준비물" + if due_date: + # 날짜가 있으나 다른 강한 신호가 없으면 일정 + return "일정" + return None + + +def classify( + text: str, + action_hint: str | None = None, + amount: str | None = None, + due_date: str | None = None, +) -> str | None: + """ + 최종 분류 함수. + 1단계: 키워드 규칙 (빠르고 명확) + 2단계: 슬롯 기반 (윤정님 추출 정보 활용) + 모두 실패하면 None → 이 문장은 학습 데이터에서 제외 (노이즈 방지). + + '기타'를 일부러 규칙에 넣지 않은 이유: + 규칙으로 잡히지 않는 문장이 전부 기타가 되면 기타 데이터가 + 너무 많아지고, 오탐(실제론 일정인데 기타로 잡힌 것)도 섞임. + → 기타는 별도 limit을 두지 않고 "나머지"로만 수집. + """ + label = label_by_keywords(text) + if label: + return label + label = label_by_slots(action_hint, amount, due_date) + if label: + return label + return None # 애매한 문장은 버린다 + + +# ────────────────────────────────────────────────────────────────── +# 5. 데이터 로드 함수 +# ────────────────────────────────────────────────────────────────── +def load_test_data() -> list[dict]: + """ + test_data.jsonl : {text, is_todo} + is_todo = True인 행만 사용. + 이유: False인 행은 학교 공지 중 '할 일이 없는' 순수 안내 문장. + 분류 모델의 목적(학부모가 해야 할 행동 분류)에 맞지 않아 제외. + """ + rows = [] + with open(SRC_TEST_DATA, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + d = json.loads(line) + if d.get("is_todo") is True: + rows.append({ + "text": d["text"], + "action_hint": None, # 이 파일엔 슬롯 정보 없음 + "amount": None, + "due_date": None, + }) + return rows + + +def load_predict_output() -> list[dict]: + """ + predict_output_testset.jsonl : {text, source, due_date, amount, + confidence, action_hint, true_is_todo} + + 필터 기준: + - true_is_todo = True : 명확하게 할 일인 문장 + - confidence >= 0.7 : 윤정님 모델이 확신하는 문장 + 이 두 조건 중 하나라도 만족하면 사용. + 슬롯(amount, due_date, action_hint)은 자동 라벨링 2단계(label_by_slots)에 활용. + """ + rows = [] + with open(SRC_PREDICT_OUT, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + d = json.loads(line) + is_todo = d.get("true_is_todo") is True + high_conf = d.get("confidence", 0) >= 0.7 + if is_todo or high_conf: + rows.append({ + "text": d["text"], + "action_hint": d.get("action_hint"), + "amount": d.get("amount"), + "due_date": d.get("due_date"), + }) + return rows + + +def load_v3_csv() -> list[tuple[str, str]]: + """ + 기존에 경이님이 직접 라벨링한 notice_sample_v3.csv 로드. + 컬럼: text, category + """ + rows = [] + with open(SRC_V3_CSV, encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + for row in reader: + text = row.get("text", "").strip() + cat = row.get("category", "").strip() + if text and cat in LABELS: + rows.append((text, cat)) + return rows + + +# ────────────────────────────────────────────────────────────────── +# 6. 규칙 검증 함수 — 기존 라벨 데이터로 규칙 정확도 측정 +# ────────────────────────────────────────────────────────────────── +def validate_rules(v3_rows: list[tuple[str, str]]) -> float: + """ + v3.csv는 경이님이 직접 라벨링한 '정답 데이터'다. + 이 정답 데이터에 자동 라벨링 규칙을 적용해 몇 %나 맞히는지 확인. + + 목표: 75% 이상 → 자동 라벨 데이터를 신뢰할 수 있다고 판단. + 낮으면 규칙을 수정해야 함. + """ + correct = 0 + total = 0 + errors = [] + + for text, true_cat in v3_rows: + pred = classify(text) + if pred is None: + pred = "기타" # 규칙 미매칭 → 기타로 간주 + total += 1 + if pred == true_cat: + correct += 1 + else: + errors.append((text[:40], true_cat, pred)) + + accuracy = correct / total if total > 0 else 0.0 + + print("\n[규칙 검증] v3.csv 기준 자동 라벨링 정확도") + print(f" 정답: {correct}/{total} = {accuracy:.1%}") + + if errors: + print(f" 오분류 예시 (상위 10개):") + for txt, true, pred in errors[:10]: + print(f" [{true}→{pred}] {txt}") + + if accuracy >= 0.75: + print(" [OK] 규칙 신뢰도 충분 (75% 이상) -> 자동 라벨 데이터 채택") + else: + print(" [경고] 규칙 신뢰도 부족 -- 규칙을 추가/수정할 것을 권장") + + return accuracy + + +# ────────────────────────────────────────────────────────────────── +# 7. 라벨링 + 필터링 +# ────────────────────────────────────────────────────────────────── +MIN_TEXT_LEN = 10 # 10글자 미만 단편 문장은 노이즈 가능성이 높아 제외 + + +def label_all(rows: list[dict]) -> list[tuple[str, str]]: + """ + rows 각각에 classify()를 적용해 (text, category) 쌍 반환. + - MIN_TEXT_LEN 미만 → 제외 + - classify() 결과가 None → 제외 (애매한 문장) + """ + labeled: list[tuple[str, str]] = [] + for r in rows: + text = r["text"].strip() + if len(text) < MIN_TEXT_LEN: + continue + cat = classify(text, r.get("action_hint"), r.get("amount"), r.get("due_date")) + if cat: + labeled.append((text, cat)) + else: + # 규칙·슬롯 미매칭 문장은 '기타'로 넣되, 별도 카운터로 제한 + labeled.append((text, "기타")) + return labeled + + +MAX_NEW_PER_LABEL = 120 # 새 데이터에서 카테고리당 최대 수집 수 +# 이유: 너무 많으면 노이즈가 늘어나고, 클래스 불균형도 발생. +# 기존 v3(약 25/카테고리) + 신규(최대 120/카테고리) → 전체 약 900개 목표. + + +def balance_new_data( + labeled: list[tuple[str, str]], + max_per: int, +) -> list[tuple[str, str]]: + """ + 카테고리별로 max_per 개까지만 샘플링. + random.shuffle(seed=42)로 무작위 선택 → 재현 가능. + + 왜 균형이 필요한가? + KcELECTRA 파인튜닝 시 특정 클래스 데이터가 너무 많으면 + 모델이 그 클래스로 편향됨 (기존 문제와 동일한 class collapse 현상). + """ + buckets: dict[str, list[str]] = {l: [] for l in LABELS} + for text, cat in labeled: + if cat in buckets: + buckets[cat].append(text) + + result: list[tuple[str, str]] = [] + for cat, texts in buckets.items(): + random.shuffle(texts) + for t in texts[:max_per]: + result.append((t, cat)) + return result + + +# ────────────────────────────────────────────────────────────────── +# 8. 중복 제거 +# ────────────────────────────────────────────────────────────────── +def remove_duplicates(rows: list[tuple[str, str]]) -> list[tuple[str, str]]: + """ + 동일 텍스트가 두 번 이상 나타나면 첫 번째만 유지. + 왜 필요한가? + test_data.jsonl과 predict_output_testset.jsonl이 같은 원본에서 + 파생됐기 때문에 중복 문장이 존재할 수 있음. + 중복이 있으면 test 세트에도 같은 문장이 들어가 평가가 부풀려짐. + """ + seen: set[str] = set() + out: list[tuple[str, str]] = [] + for text, cat in rows: + if text not in seen: + seen.add(text) + out.append((text, cat)) + return out + + +# ────────────────────────────────────────────────────────────────── +# 9. 저장 +# ────────────────────────────────────────────────────────────────── +def save_csv(rows: list[tuple[str, str]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8-sig", newline="") as f: + # quoting=QUOTE_ALL : 쉼표·줄바꿈 포함 텍스트도 안전하게 저장 + writer = csv.writer(f, quoting=csv.QUOTE_ALL) + writer.writerow(["text", "category"]) + for text, cat in rows: + writer.writerow([text, cat]) + print(f"\n[저장] {path} ({len(rows)}행)") + + +# ────────────────────────────────────────────────────────────────── +# 10. 메인 실행 +# ────────────────────────────────────────────────────────────────── +def main() -> None: + print("=" * 60) + print(" 자동 라벨링 파이프라인 시작 (2026-05-04)") + print("=" * 60) + + # Step A: 기존 라벨 데이터 로드 + v3_rows = load_v3_csv() + print(f"\n[A] 기존 v3.csv: {len(v3_rows)}개") + cnt_v3 = Counter(cat for _, cat in v3_rows) + for l in LABELS: + print(f" {l}: {cnt_v3.get(l, 0)}개") + + # Step B: 규칙 검증 (v3.csv 기준) + rule_acc = validate_rules(v3_rows) + + # Step C: 새 데이터 로드 + print("\n[C] 새 데이터 로드") + test_rows = load_test_data() + predict_rows = load_predict_output() + print(f" test_data.jsonl (is_todo=True): {len(test_rows):,}개") + print(f" predict_output (필터 후): {len(predict_rows):,}개") + + # Step D: 자동 라벨링 + print("\n[D] 자동 라벨링 중...") + labeled_test = label_all(test_rows) + labeled_predict = label_all(predict_rows) + all_new = labeled_test + labeled_predict + print(f" 라벨링 완료: {len(all_new):,}개") + cnt_new = Counter(cat for _, cat in all_new) + for l in LABELS: + print(f" {l}: {cnt_new.get(l, 0)}개") + + # Step E: 균형 조정 (카테고리당 최대 MAX_NEW_PER_LABEL개) + balanced = balance_new_data(all_new, MAX_NEW_PER_LABEL) + print(f"\n[E] 균형 조정 후: {len(balanced)}개") + cnt_bal = Counter(cat for _, cat in balanced) + for l in LABELS: + print(f" {l}: {cnt_bal.get(l, 0)}개") + + # Step F: 기존 v3 + 새 데이터 병합 → 중복 제거 + combined = v3_rows + balanced + combined = remove_duplicates(combined) + random.shuffle(combined) # 파일 내 순서 무작위화 + print(f"\n[F] 최종 병합 (중복 제거 후): {len(combined)}개") + cnt_final = Counter(cat for _, cat in combined) + for l in LABELS: + print(f" {l}: {cnt_final.get(l, 0)}개") + + # Step G: 저장 + save_csv(combined, OUT_CSV) + + # 최종 요약 + print("\n" + "=" * 60) + print(" 완료 요약") + print("=" * 60) + print(f" 기존 v3.csv: {len(v3_rows):>4}개") + print(f" 새 데이터 (균형 후): {len(balanced):>4}개") + print(f" 최종 v4 (중복 제거): {len(combined):>4}개") + print(f" 규칙 정확도: {rule_acc:.1%}") + print(f" 출력: {OUT_CSV}") + print() + print("다음 단계:") + print(" python scripts/split_dataset.py --data v4_20260504 --force") + print(" python src/classifier_simple.py (베이스라인 재학습)") + print(" Colab에서 notebooks/03_train_kcelectra_v2_20260504.ipynb 실행") + + +if __name__ == "__main__": + main() diff --git a/model/classification/scripts/evaluate_compare.py b/model/classification/scripts/evaluate_compare.py new file mode 100644 index 0000000000000000000000000000000000000000..ab76fb64832e54b8ac960e1201610efd9ff20416 --- /dev/null +++ b/model/classification/scripts/evaluate_compare.py @@ -0,0 +1,203 @@ +""" +베이스라인 vs KcELECTRA 성능 비교 스크립트 +=========================================== +담당: 경이 +목적: 동일한 test 데이터로 두 모델의 성능을 비교하여 CSV·JSON으로 저장. + 결과는 02_evaluate_compare.ipynb에서 시각화. + +실행: + python scripts/evaluate_compare.py # test split 평가 + python scripts/evaluate_compare.py --split val # val split 평가 + +결과 파일: + data/eval_results_simple.json + data/eval_results_kcelectra.json + data/eval_comparison_summary.csv + +평가 지표: + - Macro F1 : 클래스 불균형 무관 전체 성능 (메인 지표) + - Per-class F1, Precision, Recall + - Confusion Matrix +""" + +import argparse +import json +import sys +from datetime import datetime +from pathlib import Path + +import pandas as pd +from sklearn.metrics import ( + classification_report, + confusion_matrix, + f1_score, + precision_score, + recall_score, +) + +_BASE = Path(__file__).parent.parent +sys.path.insert(0, str(_BASE)) + +from src.classifier_simple import load_pipeline, load_data +from src.classifier_kcelectra import predict_kcelectra, is_ready as kcelectra_ready + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] +OUT_DIR = _BASE / "data" + + +def _fill_per_class_from_cm(res: dict) -> dict: + """Colab 저장 JSON에 per_class가 없을 때 confusion_matrix에서 보완.""" + if res.get("per_class"): + return res + cm = res.get("confusion_matrix") + labels = res.get("labels", LABELS) + if not cm: + return res + per_class = {} + for i, label in enumerate(labels): + tp = cm[i][i] + fp = sum(cm[r][i] for r in range(len(cm))) - tp + fn = sum(cm[i]) - tp + precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0 + f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0 + per_class[label] = { + "precision": round(precision, 4), + "recall": round(recall, 4), + "f1": round(f1, 4), + "support": sum(cm[i]), + } + res["per_class"] = per_class + return res + + +def evaluate_simple(split: str = "test") -> dict: + texts, true_labels = load_data(split) + if not texts: + texts, true_labels = load_data("all") + + pipe = load_pipeline() + pred_labels = pipe.predict(texts) + + return _make_result("simple", true_labels, list(pred_labels)) + + +def evaluate_kcelectra(split: str = "test") -> dict: + # Colab에서 이미 평가한 JSON이 있으면 재활용 (torch 없는 환경에서도 비교 가능) + cached_json = OUT_DIR / "eval_results_kcelectra.json" + if cached_json.exists() and not kcelectra_ready(): + print(f"[compare] Colab 결과 파일 사용: {cached_json.name}") + with open(cached_json, encoding="utf-8") as f: + result = json.load(f) + result = _fill_per_class_from_cm(result) + # per_class 보완된 내용을 다시 저장 + with open(cached_json, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + return result + + if not kcelectra_ready(): + print("[compare] KcELECTRA 체크포인트 없음. 01_train_kcelectra.ipynb 먼저 실행하세요.") + return {} + + texts, true_labels = load_data(split) + if not texts: + texts, true_labels = load_data("all") + + pred_labels = [predict_kcelectra(t)["category"] for t in texts] + return _make_result("kcelectra", true_labels, pred_labels) + + +def _make_result(model_name: str, true: list, pred: list) -> dict: + macro_f1 = f1_score(true, pred, labels=LABELS, average="macro", zero_division=0) + macro_pre = precision_score(true, pred, labels=LABELS, average="macro", zero_division=0) + macro_rec = recall_score(true, pred, labels=LABELS, average="macro", zero_division=0) + + report = classification_report( + true, pred, labels=LABELS, output_dict=True, zero_division=0 + ) + cm = confusion_matrix(true, pred, labels=LABELS) + + print(f"\n{'='*50}") + print(f"[{model_name}] 분류 리포트") + print(classification_report(true, pred, labels=LABELS, zero_division=0)) + print(f"[{model_name}] Macro F1={macro_f1:.4f} Pre={macro_pre:.4f} Rec={macro_rec:.4f}") + print(f"[{model_name}] Confusion Matrix:\n{cm}") + + return { + "model": model_name, + "macro_f1": round(macro_f1, 4), + "macro_precision":round(macro_pre, 4), + "macro_recall": round(macro_rec, 4), + "per_class": { + label: { + "precision": round(report[label]["precision"], 4), + "recall": round(report[label]["recall"], 4), + "f1": round(report[label]["f1-score"], 4), + "support": report[label]["support"], + } + for label in LABELS if label in report + }, + "confusion_matrix": cm.tolist(), + "labels": LABELS, + } + + +def save_and_compare(simple_res: dict, kcelectra_res: dict) -> None: + ts = datetime.now().strftime("%Y%m%d") + + def _write_json(data: dict, canonical: str) -> None: + with open(OUT_DIR / canonical, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + stem = canonical.replace(".json", "") + with open(OUT_DIR / f"{stem}_{ts}.json", "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + _write_json(simple_res, "eval_results_simple.json") + if kcelectra_res: + _write_json(kcelectra_res, "eval_results_kcelectra.json") + + rows = [] + for res in [simple_res, kcelectra_res]: + if not res: + continue + row = { + "model": res["model"], + "macro_f1": res["macro_f1"], + "macro_precision":res["macro_precision"], + "macro_recall": res["macro_recall"], + } + for label in LABELS: + if label in res.get("per_class", {}): + row[f"{label}_f1"] = res["per_class"][label]["f1"] + rows.append(row) + + summary_df = pd.DataFrame(rows) + summary_df.to_csv(OUT_DIR / "eval_comparison_summary.csv", index=False, encoding="utf-8-sig") + summary_df.to_csv(OUT_DIR / f"eval_comparison_summary_{ts}.csv", index=False, encoding="utf-8-sig") + print(f"\n[compare] 결과 저장 완료 → {OUT_DIR}") + print("\n── 성능 요약 ──") + print(summary_df[["model", "macro_f1", "macro_precision", "macro_recall"]].to_string(index=False)) + + if kcelectra_res: + delta = kcelectra_res["macro_f1"] - simple_res["macro_f1"] + print(f"\n KcELECTRA vs Simple ΔMacro F1 = {delta:+.4f}") + if delta >= 0.05: + print(" → KcELECTRA 5%+ 향상: 채택 권장!") + else: + print(" → 5% 미만 향상: Simple 유지 고려") + + +def main(split: str = "test") -> None: + print(f"[compare] 평가 split: {split}") + + simple_res = evaluate_simple(split) + kcelectra_res = evaluate_kcelectra(split) + + save_and_compare(simple_res, kcelectra_res) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--split", default="test", choices=["train", "val", "test", "all"]) + args = parser.parse_args() + main(split=args.split) diff --git a/model/classification/scripts/evaluate_compare_ensemble_20260505.py b/model/classification/scripts/evaluate_compare_ensemble_20260505.py new file mode 100644 index 0000000000000000000000000000000000000000..61ba3c912757f286e505818bb37272f0b0272213 --- /dev/null +++ b/model/classification/scripts/evaluate_compare_ensemble_20260505.py @@ -0,0 +1,409 @@ +""" +evaluate_compare_ensemble_20260505.py +====================================== +담당: 경이 (kyeongyi) +작성일: 2026-05-05 + +목적: + Simple + KcELECTRA 소프트 투표 앙상블 평가. + KcELECTRA 단독(v3, Macro F1 0.8545)이 목표 +5%에 0.71%p 미달하여 + devlog 우선순위 3에 따라 앙상블 시도. + + [비교 모델] + 1. Simple : TF-IDF + Logistic Regression (baseline 0.8116) + 2. KcELECTRA: v3 파인튜닝 단독 (0.8545) + 3. Ensemble : KcELECTRA×0.7 + Simple×0.3 (목표: 0.8616+) + + [앙상블 방식] + 소프트 투표(Soft Voting) — 두 모델의 확률을 가중 합산 후 argmax + combined[i] = weight_kc × kc_prob[i] + (1-weight_kc) × s_prob[i] + + [출력 파일 - data/20260505/] + eval_results_ensemble_20260505.json + eval_comparison_summary_ensemble_20260505.csv + +실행: + cd model/classification + python scripts/evaluate_compare_ensemble_20260505.py + python scripts/evaluate_compare_ensemble_20260505.py --weight_kc 0.6 + python scripts/evaluate_compare_ensemble_20260505.py --search_weights +""" + +import argparse +import json +import pickle +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +from sklearn.metrics import classification_report, confusion_matrix, f1_score +from sklearn.pipeline import Pipeline + +_BASE = Path(__file__).parent.parent +sys.path.insert(0, str(_BASE / "src")) + +SPLIT_CSV = _BASE / "data" / "split_v5_20260505.csv" +SIMPLE_PKL = _BASE / "checkpoints" / "simple_tfidf_logreg_v3_20260505.pkl" +KCELECTRA_CKPT = _BASE / "checkpoints" / "kcelectra-category-v3" +OUT_DIR = _BASE / "data" / "20260505" + +# HF Hub fallback (로컬 체크포인트 없을 때) +_HF_REPO = "kysophia/kcelectra-category" +_HF_SUBFOLDER = "kcelectra-category-v3" + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] +_LABEL_TO_COL = {lbl: i for i, lbl in enumerate(LABELS)} + + +# ────────────────────────────────────────────────────────────────── +# 데이터 로드 +# ────────────────────────────────────────────────────────────────── +def load_split(split: str = "test") -> tuple[list[str], list[str]]: + if not SPLIT_CSV.exists(): + raise FileNotFoundError(f"{SPLIT_CSV} 없음") + df = pd.read_csv(SPLIT_CSV, encoding="utf-8-sig") + df = df[df["split"] == split] + df = df[df["category"].isin(LABELS)] + return df["text"].tolist(), df["category"].tolist() + + +# ────────────────────────────────────────────────────────────────── +# Simple 확률 행렬 (N, 6) +# ────────────────────────────────────────────────────────────────── +def _load_simple() -> Pipeline: + if not SIMPLE_PKL.exists(): + raise FileNotFoundError( + f"{SIMPLE_PKL.name} 없음.\n" + " 먼저 실행: python scripts/evaluate_compare_v3_20260505.py" + ) + with open(SIMPLE_PKL, "rb") as f: + return pickle.load(f) + + +def get_simple_proba(texts: list[str]) -> np.ndarray: + """Simple predict_proba → LABELS 순서로 열 정렬한 (N, 6) 행렬.""" + pipe = _load_simple() + raw = pipe.predict_proba(texts) # (N, K), pipe.classes_ 순서 + classes = list(pipe.classes_) + out = np.zeros((len(texts), len(LABELS))) + for j, lbl in enumerate(LABELS): + if lbl in classes: + out[:, j] = raw[:, classes.index(lbl)] + return out + + +# ────────────────────────────────────────────────────────────────── +# KcELECTRA 확률 행렬 (N, 6) +# ────────────────────────────────────────────────────────────────── +def _load_kcelectra_model(): + """(device, tokenizer, model, id2label) 반환. 로컬 우선, HF Hub fallback.""" + try: + import torch + from transformers import AutoModelForSequenceClassification, AutoTokenizer + except ImportError: + raise ImportError("pip install torch transformers") + + device = "cuda" if torch.cuda.is_available() else "cpu" + + local_ready = ( + KCELECTRA_CKPT.exists() + and (KCELECTRA_CKPT / "config.json").exists() + and any( + (KCELECTRA_CKPT / f).exists() + for f in ("pytorch_model.bin", "model.safetensors") + ) + ) + + if local_ready: + tokenizer = AutoTokenizer.from_pretrained(str(KCELECTRA_CKPT)) + model = AutoModelForSequenceClassification.from_pretrained( + str(KCELECTRA_CKPT), num_labels=len(LABELS), ignore_mismatched_sizes=True + ) + src = str(KCELECTRA_CKPT) + else: + print(f"[kcelectra] 로컬 없음 → HF Hub 다운로드: {_HF_REPO}/{_HF_SUBFOLDER}") + tokenizer = AutoTokenizer.from_pretrained(_HF_REPO, subfolder=_HF_SUBFOLDER) + model = AutoModelForSequenceClassification.from_pretrained( + _HF_REPO, subfolder=_HF_SUBFOLDER, num_labels=len(LABELS) + ) + src = f"{_HF_REPO}/{_HF_SUBFOLDER}" + + model.to(device).eval() + print(f"[kcelectra] 모델 로드: {src} → device={device}") + + labels_file = KCELECTRA_CKPT / "label2id.json" + if local_ready and labels_file.exists(): + with open(labels_file, encoding="utf-8") as f: + label2id: dict[str, int] = json.load(f) + id2label = {v: k for k, v in label2id.items()} + else: + id2label = {i: lbl for i, lbl in enumerate(LABELS)} + + return device, tokenizer, model, id2label + + +def get_kcelectra_proba(texts: list[str], batch_size: int = 32) -> np.ndarray: + """KcELECTRA softmax → LABELS 순서로 열 정렬한 (N, 6) 행렬. 배치 처리.""" + import torch + + device, tokenizer, model, id2label = _load_kcelectra_model() + out = np.zeros((len(texts), len(LABELS))) + + with torch.no_grad(): + for start in range(0, len(texts), batch_size): + batch = texts[start : start + batch_size] + enc = tokenizer( + batch, + return_tensors="pt", + truncation=True, + padding=True, + max_length=128, + ).to(device) + probs = torch.softmax(model(**enc).logits, dim=-1).cpu().numpy() + for b_i, prob_row in enumerate(probs): + for k_i, p in enumerate(prob_row): + lbl = id2label.get(k_i, "기타") + col = _LABEL_TO_COL.get(lbl, _LABEL_TO_COL["기타"]) + out[start + b_i, col] = p + + return out + + +# ────────────────────────────────────────────────────────────────── +# 앙상블 평가 +# ────────────────────────────────────────────────────────────────── +def evaluate_ensemble( + split: str, + weight_kc: float, + s_proba: np.ndarray, + kc_proba: np.ndarray, + true_labels: list[str], +) -> dict: + """소프트 투표. s_proba / kc_proba를 받아 가중 합산 → 분류 리포트 반환.""" + combined = weight_kc * kc_proba + (1.0 - weight_kc) * s_proba + pred_idx = combined.argmax(axis=1) + pred_labels = [LABELS[i] for i in pred_idx] + + report = classification_report( + true_labels, pred_labels, + labels=LABELS, output_dict=True, zero_division=0, + ) + cm = confusion_matrix(true_labels, pred_labels, labels=LABELS) + macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS, + average="macro", zero_division=0) + + return { + "model": "ensemble", + "version": f"kc{weight_kc:.1f}+s{1-weight_kc:.1f}", + "weight_kc": weight_kc, + "macro_f1": round(macro_f1, 4), + "macro_precision": round(report["macro avg"]["precision"], 4), + "macro_recall": round(report["macro avg"]["recall"], 4), + "per_class": { + lbl: { + "precision": round(report[lbl]["precision"], 4), + "recall": round(report[lbl]["recall"], 4), + "f1": round(report[lbl]["f1-score"], 4), + "support": report[lbl]["support"], + } + for lbl in LABELS + }, + "confusion_matrix": cm.tolist(), + "labels": LABELS, + "split_used": split, + "data_version": "v5_20260505", + } + + +# ────────────────────────────────────────────────────────────────── +# val 세트 가중치 그리드 탐색 +# ────────────────────────────────────────────────────────────────── +def search_best_weight() -> float: + """val 세트에서 weight_kc 0.4~0.9 탐색 → 최적 weight 반환.""" + print("\n[weight 탐색] val 세트 기준 KcELECTRA 가중치 최적화") + + val_texts, val_labels = load_split("val") + print(f" val {len(val_texts)}건 확률 계산 중...") + val_s = get_simple_proba(val_texts) + val_kc = get_kcelectra_proba(val_texts) + + print(f"\n {'weight_kc':>10s} {'val Macro F1':>14s}") + print(" " + "-" * 28) + + best_w, best_f1 = 0.7, 0.0 + for w in [0.4, 0.5, 0.6, 0.7, 0.8, 0.9]: + res = evaluate_ensemble("val", w, val_s, val_kc, val_labels) + mark = " ← best" if res["macro_f1"] > best_f1 else "" + print(f" {w:>10.1f} {res['macro_f1']:>14.4f}{mark}") + if res["macro_f1"] > best_f1: + best_f1, best_w = res["macro_f1"], w + + print(f"\n 최적 weight_kc = {best_w} (val Macro F1 = {best_f1:.4f})") + return best_w + + +# ────────────────────────────────────────────────────────────────── +# 저장 + 비교 출력 +# ────────────────────────────────────────────────────────────────── +def _summary_row(res: dict) -> dict: + row = { + "macro_f1": res.get("macro_f1", "-"), + "macro_precision": res.get("macro_precision", "-"), + "macro_recall": res.get("macro_recall", "-"), + } + for lbl in LABELS: + row[f"f1_{lbl}"] = res.get("per_class", {}).get(lbl, {}).get("f1", "-") + return row + + +def _build_result_from_proba( + proba: np.ndarray, + true_labels: list[str], + model_name: str, + split: str, +) -> dict: + """확률 행렬 → 분류 리포트 dict (JSON 없을 때 fallback).""" + pred_labels = [LABELS[i] for i in proba.argmax(axis=1)] + report = classification_report(true_labels, pred_labels, + labels=LABELS, output_dict=True, zero_division=0) + macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS, + average="macro", zero_division=0) + cm = confusion_matrix(true_labels, pred_labels, labels=LABELS) + return { + "model": model_name, + "version": "v3", + "macro_f1": round(macro_f1, 4), + "macro_precision": round(report["macro avg"]["precision"], 4), + "macro_recall": round(report["macro avg"]["recall"], 4), + "per_class": { + lbl: { + "precision": round(report[lbl]["precision"], 4), + "recall": round(report[lbl]["recall"], 4), + "f1": round(report[lbl]["f1-score"], 4), + "support": report[lbl]["support"], + } + for lbl in LABELS + }, + "confusion_matrix": cm.tolist(), + "labels": LABELS, + "split_used": split, + "data_version": "v5_20260505", + } + + +def save_and_compare(simple_res: dict, kc_res: dict, ens_res: dict) -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + + ens_path = OUT_DIR / "eval_results_ensemble_20260505.json" + with open(ens_path, "w", encoding="utf-8") as f: + json.dump(ens_res, f, ensure_ascii=False, indent=2) + print(f"\n[저장] {ens_path.name}") + + label_e = f"Ensemble(kc{ens_res['weight_kc']:.1f}+s{1-ens_res['weight_kc']:.1f})" + rows = [ + {"model": "Simple (TF-IDF+LR)", **_summary_row(simple_res)}, + {"model": "KcELECTRA v3 (단독)", **_summary_row(kc_res)}, + {"model": label_e, **_summary_row(ens_res)}, + ] + summary_path = OUT_DIR / "eval_comparison_summary_ensemble_20260505.csv" + pd.DataFrame(rows).to_csv(summary_path, index=False, encoding="utf-8-sig") + print(f"[저장] {summary_path.name}") + + s_f1 = simple_res["macro_f1"] + kc_f1 = kc_res["macro_f1"] + e_f1 = ens_res["macro_f1"] + + print("\n" + "=" * 60) + print(" 앙상블 성능 비교 (v5 데이터 - 4992행)") + print("=" * 60) + print(f" Simple Macro F1 : {s_f1:.4f} (baseline)") + print(f" KcELECTRA Macro F1 : {kc_f1:.4f} (Delta vs Simple: {kc_f1-s_f1:+.4f})") + print(f" Ensemble Macro F1 : {e_f1:.4f} (Delta vs Simple: {e_f1-s_f1:+.4f})") + print() + + delta = e_f1 - s_f1 + if delta >= 0.05: + print(" ★ 앙상블 5%+ 향상 달성! 채택 확정") + elif delta > 0: + print(f" ~ 앙상블 소폭 향상 ({delta:+.4f}) — --search_weights 또는 추가 튜닝 권장") + else: + print(" ✗ 앙상블이 Simple 미달 — weight_kc 조정 필요") + + print("\n [카테고리별 F1 비교]") + print(f" {'카테고리':10s} {'Simple':>8s} {'KcELEC':>8s} {'Ensemble':>10s}") + print(" " + "-" * 42) + for lbl in LABELS: + s = simple_res["per_class"].get(lbl, {}).get("f1", 0.0) + k = kc_res["per_class"].get(lbl, {}).get("f1", 0.0) + e = ens_res["per_class"].get(lbl, {}).get("f1", 0.0) + best = " ★" if e > max(s, k) else (" " if e >= max(s, k) else " ") + print(f" {lbl:10s} {s:>8.4f} {k:>8.4f} {e:>10.4f}{best}") + + print(f"\n[출력 폴더] {OUT_DIR}") + + +# ────────────────────────────────────────────────────────────────── +# CLI +# ────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser( + description="KcELECTRA + Simple 소프트 투표 앙상블 평가" + ) + parser.add_argument("--split", default="test", + choices=["train", "val", "test"]) + parser.add_argument("--weight_kc", type=float, default=0.7, + help="KcELECTRA 가중치 (0.0~1.0). 기본값 0.7") + parser.add_argument("--search_weights", action="store_true", + help="val 세트에서 0.4~0.9 그리드 탐색 후 최적값으로 test 평가") + args = parser.parse_args() + + print(f"앙상블 평가 시작 — split: {args.split}, weight_kc: {args.weight_kc}") + + # ① val 세트 가중치 탐색 (--search_weights) + weight_kc = args.weight_kc + if args.search_weights: + weight_kc = search_best_weight() + print(f"\n[test 평가] 최적 weight_kc={weight_kc} 적용") + + # ② test 확률 행렬 + texts, true_labels = load_split(args.split) + print(f"\n[데이터] {args.split} 세트 {len(texts)}건") + + print("\n[Simple] 확률 행렬 계산 중...") + s_proba = get_simple_proba(texts) + + print("[KcELECTRA] 확률 행렬 계산 중... (CPU면 수 분 소요)") + kc_proba = get_kcelectra_proba(texts) + + # ③ 앙상블 평가 + ens_res = evaluate_ensemble(args.split, weight_kc, s_proba, kc_proba, true_labels) + + # ④ 단독 결과 — 기존 JSON 재활용, 없으면 확률 행렬에서 직접 계산 + simple_json = OUT_DIR / "eval_results_simple_v3_20260505.json" + kc_json = OUT_DIR / "eval_results_kcelectra_v3_20260505.json" + + if simple_json.exists(): + with open(simple_json, encoding="utf-8") as f: + simple_res = json.load(f) + print(f"[simple] 기존 JSON 재활용: {simple_json.name}") + else: + simple_res = _build_result_from_proba(s_proba, true_labels, "simple", args.split) + + if kc_json.exists(): + with open(kc_json, encoding="utf-8") as f: + kc_res = json.load(f) + print(f"[kcelectra] 기존 JSON 재활용: {kc_json.name}") + else: + kc_res = _build_result_from_proba(kc_proba, true_labels, "kcelectra", args.split) + + # ⑤ 저장 + 비교 출력 + print("\n[앙상블] 분류 리포트") + pred_labels = [LABELS[i] for i in (weight_kc * kc_proba + (1 - weight_kc) * s_proba).argmax(axis=1)] + print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0)) + + save_and_compare(simple_res, kc_res, ens_res) + + +if __name__ == "__main__": + main() diff --git a/model/classification/scripts/evaluate_compare_v2_20260504.py b/model/classification/scripts/evaluate_compare_v2_20260504.py new file mode 100644 index 0000000000000000000000000000000000000000..d355999e06f6b38b06e86fab2cee8789b157ef8c --- /dev/null +++ b/model/classification/scripts/evaluate_compare_v2_20260504.py @@ -0,0 +1,355 @@ +""" +evaluate_compare_v2_20260504.py +================================ +담당: 경이 (kyeongyi) +작성일: 2026-05-04 + +목적: + split_v2_20260504.csv (695개 확장 데이터) 기준으로 + 두 모델의 성능을 비교·저장한다. + + [비교 모델] + 1. Simple : TF-IDF + Logistic Regression (베이스라인) + 2. KcELECTRA: 파인튜닝 모델 (03_train_kcelectra_v2_20260504.ipynb 실행 후 사용 가능) + + [공정 비교 원칙] + - 두 모델 모두 split_v2_20260504.csv의 동일한 test 세트(69개)로 평가 + - 학습 데이터도 동일한 train 세트(556개) 사용 + + [출력 파일 — 타임스탬프 포함] + data/eval_results_simple_20260504.json + data/eval_results_kcelectra_20260504.json (KcELECTRA 준비 후 생성) + data/eval_comparison_summary_20260504.csv + +실행: + cd model/classification + python scripts/evaluate_compare_v2_20260504.py + python scripts/evaluate_compare_v2_20260504.py --split val # val 세트로 평가 +""" + +import argparse +import json +import pickle +import sys +from datetime import datetime +from pathlib import Path + +import pandas as pd +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import ( + classification_report, + confusion_matrix, + f1_score, +) +from sklearn.pipeline import Pipeline + +_BASE = Path(__file__).parent.parent +sys.path.insert(0, str(_BASE / "src")) + +SPLIT_CSV = _BASE / "data" / "split_v2_20260504.csv" +SIMPLE_PKL = _BASE / "checkpoints" / "simple_tfidf_logreg_v2_20260504.pkl" +KCELECTRA_CKPT = _BASE / "checkpoints" / "kcelectra-category-v2" + +DATA_DIR = _BASE / "data" +TS = "20260504" # 타임스탬프 + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] + + +# ────────────────────────────────────────────────────────────────── +# 데이터 로드 +# ────────────────────────────────────────────────────────────────── +def load_split(split: str = "test") -> tuple[list[str], list[str]]: + """ + split_v2_20260504.csv에서 지정한 split의 text와 category를 반환. + split: "train" | "val" | "test" + """ + if not SPLIT_CSV.exists(): + raise FileNotFoundError( + f"{SPLIT_CSV} 없음 — split_dataset_v2_20260504.py 먼저 실행하세요." + ) + df = pd.read_csv(SPLIT_CSV, encoding="utf-8-sig") + df = df[df["split"] == split] + df = df[df["category"].isin(LABELS)] + return df["text"].tolist(), df["category"].tolist() + + +# ────────────────────────────────────────────────────────────────── +# Simple 모델 (TF-IDF + LogReg) +# ────────────────────────────────────────────────────────────────── +def train_simple() -> Pipeline: + """ + v2 train 데이터로 베이스라인 재학습. + + 왜 재학습이 필요한가? + 기존 simple_tfidf_logreg.pkl은 v1 데이터(244개) 기준으로 학습됨. + v2 데이터(556개 train)로 재학습해야 동일 조건의 비교가 가능. + """ + texts, labels = load_split("train") + print(f"[simple] train 데이터: {len(texts)}개") + + pipe = Pipeline([ + ("tfidf", TfidfVectorizer( + analyzer="char_wb", + ngram_range=(2, 4), + max_features=30_000, + sublinear_tf=True, + )), + ("clf", LogisticRegression( + C=1.0, + max_iter=1000, + class_weight="balanced", + random_state=42, + solver="lbfgs", + )), + ]) + pipe.fit(texts, labels) + + SIMPLE_PKL.parent.mkdir(parents=True, exist_ok=True) + with open(SIMPLE_PKL, "wb") as f: + pickle.dump(pipe, f) + print(f"[simple] 모델 저장: {SIMPLE_PKL.name}") + return pipe + + +def _load_simple() -> Pipeline: + if SIMPLE_PKL.exists(): + with open(SIMPLE_PKL, "rb") as f: + return pickle.load(f) + return train_simple() + + +def evaluate_simple(split: str = "test") -> dict: + """Simple 모델 평가 → 결과 dict 반환.""" + texts, true_labels = load_split(split) + pipe = _load_simple() + pred_labels = pipe.predict(texts) + + report = classification_report( + true_labels, pred_labels, + labels=LABELS, + output_dict=True, + zero_division=0, + ) + cm = confusion_matrix(true_labels, pred_labels, labels=LABELS) + macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS, + average="macro", zero_division=0) + + print("\n[Simple] 분류 리포트") + print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0)) + + # 결과 구조 — 노트북 시각화와 호환되는 형식 + result = { + "model": "simple", + "macro_f1": round(macro_f1, 4), + "macro_precision": round(report["macro avg"]["precision"], 4), + "macro_recall": round(report["macro avg"]["recall"], 4), + "per_class": { + lbl: { + "precision": round(report[lbl]["precision"], 4), + "recall": round(report[lbl]["recall"], 4), + "f1": round(report[lbl]["f1-score"], 4), + "support": report[lbl]["support"], + } + for lbl in LABELS + }, + "confusion_matrix": cm.tolist(), + "labels": LABELS, + "split_used": split, + "data_version": "v4_20260504", + "train_size": len(load_split("train")[0]), + "test_size": len(texts), + } + return result + + +# ────────────────────────────────────────────────────────────────── +# KcELECTRA 모델 +# ────────────────────────────────────────────────────────────────── +def _kcelectra_ready() -> bool: + """ + 03_train_kcelectra_v2_20260504.ipynb 실행 후 생성되는 체크포인트 확인. + 체크포인트 없으면 평가 스킵 — 에러 없이 진행. + """ + try: + import torch # noqa: F401 + from transformers import AutoTokenizer # noqa: F401 + except ImportError: + print("[kcelectra] torch/transformers 미설치 — KcELECTRA 평가 스킵") + return False + + required = [ + KCELECTRA_CKPT / "config.json", + KCELECTRA_CKPT / "label2id.json", + ] + model_file = ( + (KCELECTRA_CKPT / "model.safetensors").exists() + or (KCELECTRA_CKPT / "pytorch_model.bin").exists() + ) + return all(f.exists() for f in required) and model_file + + +def evaluate_kcelectra(split: str = "test") -> dict: + """ + KcELECTRA 평가. + 체크포인트 없거나 torch 미설치 → 빈 dict 반환 (스킵). + + eval_results_kcelectra_20260504.json이 이미 있으면 재사용. + (Colab에서 학습 후 JSON만 복사해도 동작) + """ + json_path = DATA_DIR / f"eval_results_kcelectra_{TS}.json" + + # JSON 재활용 (Colab에서 다운로드해서 data/ 에 넣은 경우) + if json_path.exists(): + print(f"[kcelectra] 기존 JSON 재활용: {json_path.name}") + with open(json_path, encoding="utf-8") as f: + return json.load(f) + + if not _kcelectra_ready(): + print("[kcelectra] 체크포인트 없음 — 03_train_kcelectra_v2_20260504.ipynb 실행 후 재시도") + return {} + + # 체크포인트가 있을 때만 실행 + import torch + from transformers import AutoModelForSequenceClassification, AutoTokenizer + + texts, true_labels = load_split(split) + + with open(KCELECTRA_CKPT / "label2id.json", encoding="utf-8") as f: + label2id: dict[str, int] = json.load(f) + id2label = {v: k for k, v in label2id.items()} + + device = "cuda" if torch.cuda.is_available() else "cpu" + tokenizer = AutoTokenizer.from_pretrained(str(KCELECTRA_CKPT)) + model = AutoModelForSequenceClassification.from_pretrained( + str(KCELECTRA_CKPT), num_labels=len(LABELS), ignore_mismatched_sizes=True + ).to(device) + model.eval() + + pred_labels = [] + with torch.no_grad(): + for text in texts: + enc = tokenizer(text, return_tensors="pt", + truncation=True, padding=True, max_length=128).to(device) + logits = model(**enc).logits + idx = int(logits.argmax(dim=-1).item()) + pred_labels.append(id2label.get(idx, "기타")) + + report = classification_report( + true_labels, pred_labels, + labels=LABELS, + output_dict=True, + zero_division=0, + ) + cm = confusion_matrix(true_labels, pred_labels, labels=LABELS) + macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS, + average="macro", zero_division=0) + + print("\n[KcELECTRA] 분류 리포트") + print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0)) + + result = { + "model": "kcelectra", + "macro_f1": round(macro_f1, 4), + "macro_precision": round(report["macro avg"]["precision"], 4), + "macro_recall": round(report["macro avg"]["recall"], 4), + "per_class": { + lbl: { + "precision": round(report[lbl]["precision"], 4), + "recall": round(report[lbl]["recall"], 4), + "f1": round(report[lbl]["f1-score"], 4), + "support": report[lbl]["support"], + } + for lbl in LABELS + }, + "confusion_matrix": cm.tolist(), + "labels": LABELS, + "split_used": split, + "data_version": "v4_20260504", + } + return result + + +# ────────────────────────────────────────────────────────────────── +# 저장 + 비교 출력 +# ────────────────────────────────────────────────────────────────── +def save_and_compare(simple_res: dict, kcelectra_res: dict) -> None: + # Simple 결과 저장 + simple_path = DATA_DIR / f"eval_results_simple_{TS}.json" + with open(simple_path, "w", encoding="utf-8") as f: + json.dump(simple_res, f, ensure_ascii=False, indent=2) + print(f"\n[저장] {simple_path.name}") + + # KcELECTRA 결과 저장 (있을 때만) + kc_path = DATA_DIR / f"eval_results_kcelectra_{TS}.json" + if kcelectra_res: + with open(kc_path, "w", encoding="utf-8") as f: + json.dump(kcelectra_res, f, ensure_ascii=False, indent=2) + print(f"[저장] {kc_path.name}") + + # 비교 요약 CSV + rows = [{"model": "Simple (TF-IDF + LR)", **_summary_row(simple_res)}] + if kcelectra_res: + rows.append({"model": "KcELECTRA (fine-tuned)", **_summary_row(kcelectra_res)}) + + summary_df = pd.DataFrame(rows) + summary_path = DATA_DIR / f"eval_comparison_summary_{TS}.csv" + summary_df.to_csv(summary_path, index=False, encoding="utf-8-sig") + print(f"[저장] {summary_path.name}") + + # 채택 판정 + print("\n" + "=" * 50) + print(" 성능 비교 결과") + print("=" * 50) + print(f" Simple Macro F1 : {simple_res['macro_f1']:.4f}") + if kcelectra_res: + delta = kcelectra_res["macro_f1"] - simple_res["macro_f1"] + print(f" KcELECTRA Macro F1 : {kcelectra_res['macro_f1']:.4f}") + print(f" Delta : {delta:+.4f}") + if delta >= 0.05: + print(" >> KcELECTRA 5%+ 향상: 채택 권장!") + elif delta >= 0: + print(" >> KcELECTRA 소폭 향상: 추가 데이터/튜닝 권장") + else: + print(" >> Simple 유지 권장") + else: + print(" KcELECTRA: 평가 미완료 (노트북 실행 후 재시도)") + + +def _summary_row(res: dict) -> dict: + row = { + "macro_f1": res.get("macro_f1", "-"), + "macro_precision": res.get("macro_precision", "-"), + "macro_recall": res.get("macro_recall", "-"), + } + for lbl in LABELS: + row[f"f1_{lbl}"] = res.get("per_class", {}).get(lbl, {}).get("f1", "-") + return row + + +# ────────────────────────────────────────────────────────────────── +# CLI +# ────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--split", default="test", choices=["train", "val", "test"]) + parser.add_argument("--retrain", action="store_true", + help="Simple 모델 강제 재학습 (PKL 있어도 새로 학습)") + args = parser.parse_args() + + print(f"평가 시작 — split: {args.split}, 데이터: v4_20260504") + + # Simple 재학습 여부 + if args.retrain and SIMPLE_PKL.exists(): + SIMPLE_PKL.unlink() + print("[simple] 기존 PKL 삭제 → 재학습") + + simple_res = evaluate_simple(args.split) + kcelectra_res = evaluate_kcelectra(args.split) + + save_and_compare(simple_res, kcelectra_res) + + +if __name__ == "__main__": + main() diff --git a/model/classification/scripts/evaluate_compare_v3_20260505.py b/model/classification/scripts/evaluate_compare_v3_20260505.py new file mode 100644 index 0000000000000000000000000000000000000000..37b09fa98824cfb39cc8b1d230bc3cc9bba0a4b1 --- /dev/null +++ b/model/classification/scripts/evaluate_compare_v3_20260505.py @@ -0,0 +1,343 @@ +""" +evaluate_compare_v3_20260505.py +================================ +담당: 경이 (kyeongyi) +작성일: 2026-05-05 + +목적: + split_v5_20260505.csv (4992행 v5 데이터) 기준으로 + 두 모델의 성능을 비교·저장·출력한다. + + [비교 모델] + 1. Simple : TF-IDF + Logistic Regression (베이스라인) + 2. KcELECTRA: 파인튜닝 모델 v3 (05_train_kcelectra_v3_20260505.ipynb 실행 후) + + [공정 비교 원칙] + - 두 모델 모두 split_v5_20260505.csv의 동일한 test 세트로 평가 + - Simple은 동일한 train 세트(v5)로 재학습 + - KcELECTRA JSON이 없으면 checkpoints/kcelectra-category-v3/에서 직접 추론 + + [출력 파일 - data/20260505/ 폴더] + eval_results_simple_v3_20260505.json + eval_results_kcelectra_v3_20260505.json + eval_comparison_summary_v3_20260505.csv + +실행: + cd model/classification + python scripts/evaluate_compare_v3_20260505.py + python scripts/evaluate_compare_v3_20260505.py --split val + python scripts/evaluate_compare_v3_20260505.py --retrain # Simple 강제 재학습 +""" + +import argparse +import json +import pickle +import sys +from pathlib import Path + +import pandas as pd +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import ( + classification_report, + confusion_matrix, + f1_score, +) +from sklearn.pipeline import Pipeline + +_BASE = Path(__file__).parent.parent +sys.path.insert(0, str(_BASE / "src")) + +SPLIT_CSV = _BASE / "data" / "split_v5_20260505.csv" +SIMPLE_PKL = _BASE / "checkpoints" / "simple_tfidf_logreg_v3_20260505.pkl" +KCELECTRA_CKPT = _BASE / "checkpoints" / "kcelectra-category-v3" +OUT_DIR = _BASE / "data" / "20260505" +TS = "v3_20260505" + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] + + +# ────────────────────────────────────────────────────────────────── +# 데이터 로드 +# ────────────────────────────────────────────────────────────────── +def load_split(split: str = "test") -> tuple[list[str], list[str]]: + if not SPLIT_CSV.exists(): + raise FileNotFoundError( + f"{SPLIT_CSV} 없음 - split_dataset_v5_20260505.py 먼저 실행하세요." + ) + df = pd.read_csv(SPLIT_CSV, encoding="utf-8-sig") + df = df[df["split"] == split] + df = df[df["category"].isin(LABELS)] + return df["text"].tolist(), df["category"].tolist() + + +# ────────────────────────────────────────────────────────────────── +# Simple 모델 (TF-IDF + LogReg) +# ────────────────────────────────────────────────────────────────── +def train_simple() -> Pipeline: + """v5 train 데이터(~3994개)로 베이스라인 재학습.""" + texts, labels = load_split("train") + print(f"[simple] train 데이터: {len(texts)}개") + + pipe = Pipeline([ + ("tfidf", TfidfVectorizer( + analyzer="char_wb", + ngram_range=(2, 4), + max_features=50_000, # v3: max_features 증가 (데이터 많아졌으므로) + sublinear_tf=True, + )), + ("clf", LogisticRegression( + C=1.0, + max_iter=1000, + class_weight="balanced", + random_state=42, + solver="lbfgs", + )), + ]) + pipe.fit(texts, labels) + + SIMPLE_PKL.parent.mkdir(parents=True, exist_ok=True) + with open(SIMPLE_PKL, "wb") as f: + pickle.dump(pipe, f) + print(f"[simple] 모델 저장: {SIMPLE_PKL.name}") + return pipe + + +def _load_simple() -> Pipeline: + if SIMPLE_PKL.exists(): + with open(SIMPLE_PKL, "rb") as f: + return pickle.load(f) + return train_simple() + + +def evaluate_simple(split: str = "test") -> dict: + texts, true_labels = load_split(split) + pipe = _load_simple() + pred_labels = pipe.predict(texts) + + report = classification_report( + true_labels, pred_labels, + labels=LABELS, output_dict=True, zero_division=0, + ) + cm = confusion_matrix(true_labels, pred_labels, labels=LABELS) + macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS, + average="macro", zero_division=0) + + print("\n[Simple - TF-IDF + LogReg] 분류 리포트") + print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0)) + + return { + "model": "simple", + "version": "v3", + "macro_f1": round(macro_f1, 4), + "macro_precision": round(report["macro avg"]["precision"], 4), + "macro_recall": round(report["macro avg"]["recall"], 4), + "per_class": { + lbl: { + "precision": round(report[lbl]["precision"], 4), + "recall": round(report[lbl]["recall"], 4), + "f1": round(report[lbl]["f1-score"], 4), + "support": report[lbl]["support"], + } + for lbl in LABELS + }, + "confusion_matrix": cm.tolist(), + "labels": LABELS, + "split_used": split, + "data_version": "v5_20260505", + "train_size": len(load_split("train")[0]), + "test_size": len(texts), + } + + +# ────────────────────────────────────────────────────────────────── +# KcELECTRA 모델 +# ────────────────────────────────────────────────────────────────── +def _kcelectra_ready() -> bool: + try: + import torch # noqa: F401 + from transformers import AutoTokenizer # noqa: F401 + except ImportError: + print("[kcelectra] torch/transformers 미설치 - 스킵") + return False + + required = [ + KCELECTRA_CKPT / "config.json", + KCELECTRA_CKPT / "label2id.json", + ] + model_file = ( + (KCELECTRA_CKPT / "model.safetensors").exists() + or (KCELECTRA_CKPT / "pytorch_model.bin").exists() + ) + return all(f.exists() for f in required) and model_file + + +def evaluate_kcelectra(split: str = "test") -> dict: + """ + KcELECTRA v3 평가. + eval_results_kcelectra_v3_20260505.json이 있으면 재활용 (Colab 결과 복붙 시). + """ + json_path = OUT_DIR / f"eval_results_kcelectra_{TS}.json" + + if json_path.exists(): + print(f"[kcelectra] 기존 JSON 재활용: {json_path.name}") + with open(json_path, encoding="utf-8") as f: + return json.load(f) + + if not _kcelectra_ready(): + print("[kcelectra] 체크포인트 없음 - 05_train_kcelectra_v3_20260505.ipynb 실행 후 재시도") + return {} + + import torch + from transformers import AutoModelForSequenceClassification, AutoTokenizer + + texts, true_labels = load_split(split) + + with open(KCELECTRA_CKPT / "label2id.json", encoding="utf-8") as f: + label2id: dict[str, int] = json.load(f) + id2label = {v: k for k, v in label2id.items()} + + device = "cuda" if torch.cuda.is_available() else "cpu" + tokenizer = AutoTokenizer.from_pretrained(str(KCELECTRA_CKPT)) + model = AutoModelForSequenceClassification.from_pretrained( + str(KCELECTRA_CKPT), num_labels=len(LABELS), ignore_mismatched_sizes=True + ).to(device) + model.eval() + + pred_labels = [] + with torch.no_grad(): + for text in texts: + enc = tokenizer(text, return_tensors="pt", + truncation=True, padding=True, max_length=128).to(device) + logits = model(**enc).logits + idx = int(logits.argmax(dim=-1).item()) + pred_labels.append(id2label.get(idx, "기타")) + + report = classification_report( + true_labels, pred_labels, + labels=LABELS, output_dict=True, zero_division=0, + ) + cm = confusion_matrix(true_labels, pred_labels, labels=LABELS) + macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS, + average="macro", zero_division=0) + + print("\n[KcELECTRA v3] 분류 리포트") + print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0)) + + result = { + "model": "kcelectra", + "version": "v3", + "macro_f1": round(macro_f1, 4), + "macro_precision": round(report["macro avg"]["precision"], 4), + "macro_recall": round(report["macro avg"]["recall"], 4), + "per_class": { + lbl: { + "precision": round(report[lbl]["precision"], 4), + "recall": round(report[lbl]["recall"], 4), + "f1": round(report[lbl]["f1-score"], 4), + "support": report[lbl]["support"], + } + for lbl in LABELS + }, + "confusion_matrix": cm.tolist(), + "labels": LABELS, + "split_used": split, + "data_version": "v5_20260505", + } + return result + + +# ────────────────────────────────────────────────────────────────── +# 저장 + 비교 출력 +# ────────────────────────────────────────────────────────────────── +def save_and_compare(simple_res: dict, kcelectra_res: dict) -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + + simple_path = OUT_DIR / f"eval_results_simple_{TS}.json" + with open(simple_path, "w", encoding="utf-8") as f: + json.dump(simple_res, f, ensure_ascii=False, indent=2) + print(f"\n[저장] {simple_path.name}") + + kc_path = OUT_DIR / f"eval_results_kcelectra_{TS}.json" + if kcelectra_res: + with open(kc_path, "w", encoding="utf-8") as f: + json.dump(kcelectra_res, f, ensure_ascii=False, indent=2) + print(f"[저장] {kc_path.name}") + + rows = [{"model": "Simple (TF-IDF + LR)", **_summary_row(simple_res)}] + if kcelectra_res: + rows.append({"model": "KcELECTRA v3 (fine-tuned)", **_summary_row(kcelectra_res)}) + + summary_df = pd.DataFrame(rows) + summary_path = OUT_DIR / f"eval_comparison_summary_{TS}.csv" + summary_df.to_csv(summary_path, index=False, encoding="utf-8-sig") + print(f"[저장] {summary_path.name}") + + # 비교 출력 + print("\n" + "=" * 55) + print(" 성능 비교 결과 (v5 데이터 - 4992행)") + print("=" * 55) + print(f" Simple Macro F1 : {simple_res['macro_f1']:.4f}") + + if kcelectra_res: + delta = kcelectra_res["macro_f1"] - simple_res["macro_f1"] + print(f" KcELECTRA Macro F1 : {kcelectra_res['macro_f1']:.4f}") + print(f" Delta : {delta:+.4f}") + print() + if delta >= 0.05: + print(" ★ KcELECTRA 5%+ 향상 - 채택 확정!") + elif delta >= 0: + print(" ~ KcELECTRA 소폭 향상 - 추가 데이터/튜닝 권장") + else: + print(" ✗ Simple이 더 높음 - 05_train_kcelectra_v3 재실행 필요") + + print("\n [카테고리별 F1 비교]") + print(f" {'카테고리':10s} {'Simple':>8s} {'KcELECTRA':>10s} {'차이':>8s}") + print(" " + "-" * 40) + for lbl in LABELS: + s_f1 = simple_res["per_class"].get(lbl, {}).get("f1", 0) + k_f1 = kcelectra_res["per_class"].get(lbl, {}).get("f1", 0) + diff = k_f1 - s_f1 + mark = "↑" if diff > 0.02 else ("↓" if diff < -0.02 else "~") + print(f" {lbl:10s} {s_f1:>8.4f} {k_f1:>10.4f} {diff:>+7.4f} {mark}") + else: + print(" KcELECTRA: 평가 미완료 - 05_train_kcelectra_v3_20260505.ipynb 실행 후 재시도") + + print(f"\n[출력 폴더] {OUT_DIR}") + + +def _summary_row(res: dict) -> dict: + row = { + "macro_f1": res.get("macro_f1", "-"), + "macro_precision": res.get("macro_precision", "-"), + "macro_recall": res.get("macro_recall", "-"), + } + for lbl in LABELS: + row[f"f1_{lbl}"] = res.get("per_class", {}).get(lbl, {}).get("f1", "-") + return row + + +# ────────────────────────────────────────────────────────────────── +# CLI +# ────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--split", default="test", choices=["train", "val", "test"]) + parser.add_argument("--retrain", action="store_true", + help="Simple 모델 강제 재학습 (PKL 있어도 새로 학습)") + args = parser.parse_args() + + print(f"평가 시작 - split: {args.split}, 데이터: v5_20260505") + + if args.retrain and SIMPLE_PKL.exists(): + SIMPLE_PKL.unlink() + print("[simple] 기존 PKL 삭제 → 재학습") + + simple_res = evaluate_simple(args.split) + kcelectra_res = evaluate_kcelectra(args.split) + + save_and_compare(simple_res, kcelectra_res) + + +if __name__ == "__main__": + main() diff --git a/model/classification/scripts/split_dataset.py b/model/classification/scripts/split_dataset.py new file mode 100644 index 0000000000000000000000000000000000000000..8cabf3ca79467bf28a8e3ab4f7dc5293eabe83a7 --- /dev/null +++ b/model/classification/scripts/split_dataset.py @@ -0,0 +1,105 @@ +""" +데이터셋 분할 스크립트 — 반드시 딱 한 번만 실행하세요! +========================================================= +담당: 경이 +목적: 공정한 모델 비교를 위해 train/val/test를 한 번만 나누고 + split_v1.csv로 고정 저장. 이후 모든 모델(베이스라인·KcELECTRA)이 + 동일한 분할을 사용. + +실행: + python scripts/split_dataset.py + +생성 파일: data/split_v1.csv + - 컬럼: text, category, split ("train" | "val" | "test") + - 비율: train 80% / val 10% / test 10% + - 시드: 42 (재현성 보장) + - 전략: Stratified (각 카테고리에서 균등 비율 분리) + +주의: split_v1.csv가 이미 존재하면 덮어쓰지 않습니다. + 강제 재생성이 필요하면 --force 옵션 사용. +""" + +import argparse +import random +from collections import defaultdict +from pathlib import Path + +import pandas as pd + +_BASE = Path(__file__).parent.parent +DATA_CSV = _BASE / "data" / "notice_sample_v3.csv" +LEGACY_CSV = _BASE / "data" / "notices_labeled_v2.csv" # 기존 라벨 데이터 (원래 컬럼명: original_text) +SPLIT_CSV = _BASE / "data" / "split_v1.csv" + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] +TRAIN_RATIO = 0.80 +VAL_RATIO = 0.10 +# TEST = 나머지 (0.10) + +SEED = 42 + + +def stratified_split(df: pd.DataFrame) -> pd.DataFrame: + """카테고리별로 동일 비율 train/val/test 분리.""" + random.seed(SEED) + + split_labels: list[str] = [] + groups = defaultdict(list) + + for i, row in df.iterrows(): + groups[row["category"]].append(i) + + for category, indices in groups.items(): + random.shuffle(indices) + n = len(indices) + n_train = max(1, round(n * TRAIN_RATIO)) + n_val = max(1, round(n * VAL_RATIO)) + + for j, idx in enumerate(indices): + if j < n_train: + df.at[idx, "split"] = "train" + elif j < n_train + n_val: + df.at[idx, "split"] = "val" + else: + df.at[idx, "split"] = "test" + + return df + + +def main(force: bool = False) -> None: + if SPLIT_CSV.exists() and not force: + print(f"[split] {SPLIT_CSV} 이미 존재합니다. 재생성하려면 --force 사용.") + return + + # 기본 데이터 + legacy 데이터 병합 + frames = [pd.read_csv(DATA_CSV)] + if LEGACY_CSV.exists(): + legacy = pd.read_csv(LEGACY_CSV) + # legacy CSV는 'original_text' 컬럼 사용 + if "original_text" in legacy.columns and "text" not in legacy.columns: + legacy = legacy.rename(columns={"original_text": "text"}) + frames.append(legacy[["text", "category"]]) + print(f"[split] legacy 데이터 {len(legacy)}개 병합: {LEGACY_CSV.name}") + + df = pd.concat(frames, ignore_index=True) + df = df.dropna(subset=["text", "category"]) + df = df[df["category"].isin(LABELS)].copy() + df = df.drop_duplicates(subset=["text"]) + df["split"] = "" + + df = stratified_split(df) + df.to_csv(SPLIT_CSV, index=False, encoding="utf-8-sig") + + print(f"[split] 저장 완료: {SPLIT_CSV}") + counts = df.groupby(["split", "category"]).size().unstack(fill_value=0) + print("\n분할 결과 (split × category):") + print(counts) + print(f"\n전체: {len(df)}개 → train: {(df.split=='train').sum()}, " + f"val: {(df.split=='val').sum()}, test: {(df.split=='test').sum()}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--force", action="store_true", help="기존 split_v1.csv 덮어쓰기") + args = parser.parse_args() + main(force=args.force) diff --git a/model/classification/scripts/split_dataset_v2_20260504.py b/model/classification/scripts/split_dataset_v2_20260504.py new file mode 100644 index 0000000000000000000000000000000000000000..d24d7328250b6de2793f3757862cd1a78dbbd119 --- /dev/null +++ b/model/classification/scripts/split_dataset_v2_20260504.py @@ -0,0 +1,122 @@ +""" +split_dataset_v2_20260504.py +============================= +담당: 경이 (kyeongyi) +작성일: 2026-05-04 + +목적: + notice_sample_v4_20260504.csv (695행 — 자동 라벨링으로 확장된 데이터)를 + train/val/test로 고정 분할하여 split_v2_20260504.csv 저장. + + 베이스라인(Simple)과 KcELECTRA 파인튜닝이 '완전히 동일한 데이터'로 + 학습·평가해야 공정한 비교가 가능하다. + → 이 파일이 그 '공정한 기준점' 역할을 한다. + +왜 v1과 별도로 v2를 만드나? + split_v1.csv: 기존 244개 기준 분할 (KcELECTRA 실패의 원인) + split_v2.csv: 695개 기준 분할 (KcELECTRA 재도전용) + 두 버전을 모두 유지해 '데이터 증가 전/후' 비교도 가능하게 함. + +분할 전략: + Stratified Split — 카테고리 비율을 유지하며 분할 + Train 80% / Val 10% / Test 10% + Seed = 42 + +실행: + cd model/classification + python scripts/split_dataset_v2_20260504.py + python scripts/split_dataset_v2_20260504.py --force # 강제 재생성 +""" + +import argparse +import random +from collections import defaultdict +from pathlib import Path + +import pandas as pd + +_BASE = Path(__file__).parent.parent +DATA_CSV = _BASE / "data" / "notice_sample_v4_20260504.csv" +OUT_CSV = _BASE / "data" / "split_v2_20260504.csv" + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] + +TRAIN_RATIO = 0.80 +VAL_RATIO = 0.10 +# TEST = 나머지 0.10 + +SEED = 42 # 고정 — 누가 실행해도 항상 동일한 분할 + + +def stratified_split(df: pd.DataFrame) -> pd.DataFrame: + """ + 카테고리별로 동일 비율로 train/val/test를 나눈다. + + 왜 Stratified가 필요한가? + 데이터가 695개로 늘었지만 카테고리별 수는 여전히 다르다. + (일정 131, 준비물 80, 제출 136, 비용 98, 건강·안전 112, 기타 138) + 단순 랜덤 분할하면 특정 카테고리가 test에 몰리거나 빠질 수 있다. + Stratified는 각 카테고리에서 동일 비율(80/10/10)로 뽑아 + 모든 분할에서 클래스 분포를 유지한다. + """ + random.seed(SEED) + df = df.copy() + df["split"] = "" + + groups: defaultdict[str, list] = defaultdict(list) + for i, row in df.iterrows(): + groups[row["category"]].append(i) + + for category, indices in groups.items(): + random.shuffle(indices) + n = len(indices) + n_train = max(1, round(n * TRAIN_RATIO)) + n_val = max(1, round(n * VAL_RATIO)) + + for j, idx in enumerate(indices): + if j < n_train: + df.at[idx, "split"] = "train" + elif j < n_train + n_val: + df.at[idx, "split"] = "val" + else: + df.at[idx, "split"] = "test" + + return df + + +def main(force: bool = False) -> None: + if OUT_CSV.exists() and not force: + print(f"[split_v2] {OUT_CSV.name} 이미 존재합니다. 재생성하려면 --force 사용.") + return + + if not DATA_CSV.exists(): + print(f"[오류] {DATA_CSV} 없음 — auto_label_from_new_data_20260504.py 먼저 실행하세요.") + return + + df = pd.read_csv(DATA_CSV, encoding="utf-8-sig") + df = df.dropna(subset=["text", "category"]) + df = df[df["category"].isin(LABELS)].copy() + df = df.drop_duplicates(subset=["text"]) + + print(f"[split_v2] 입력 데이터: {len(df)}개") + + df = stratified_split(df) + OUT_CSV.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(OUT_CSV, index=False, encoding="utf-8-sig") + + print(f"[split_v2] 저장 완료: {OUT_CSV}") + + counts = df.groupby(["split", "category"]).size().unstack(fill_value=0) + print("\n분할 결과 (split x category):") + print(counts) + print(f"\n전체: {len(df)}개") + print(f" train: {(df.split == 'train').sum()}개") + print(f" val: {(df.split == 'val').sum()}개") + print(f" test: {(df.split == 'test').sum()}개") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + main(force=args.force) diff --git a/model/classification/scripts/split_dataset_v5_20260505.py b/model/classification/scripts/split_dataset_v5_20260505.py new file mode 100644 index 0000000000000000000000000000000000000000..5e95b60f12f3a9e91c1d34b0329883b8751e75e1 --- /dev/null +++ b/model/classification/scripts/split_dataset_v5_20260505.py @@ -0,0 +1,124 @@ +""" +split_dataset_v5_20260505.py +============================= +담당: 경이 (kyeongyi) +작성일: 2026-05-05 + +목적: + notice_sample_v5_clean_full_20260504.csv (4992행 - 이미 라벨링 완료 데이터)를 + train/val/test로 고정 분할하여 split_v5_20260505.csv 저장. + + v5 데이터는 이미 정답 라벨이 존재하므로 자동 라벨링 없이 바로 사용 가능. + 베이스라인(Simple)과 KcELECTRA v3 파인튜닝이 '완전히 동일한 데이터'로 + 학습·평가해야 공정한 비교가 가능하다. + +v5 vs v4 비교: + v4: 695행 (자동 라벨링, 노이즈 포함 가능성) + v5: 4992행 (수동 라벨링 완료, 7배 이상 데이터) - KcELECTRA가 잘 학습될 최소 규모 확보 + +분할 전략: + Stratified Split - 카테고리 비율을 유지하며 분할 + Train 80% / Val 10% / Test 10% + Seed = 42 + +실행: + cd model/classification + python scripts/split_dataset_v5_20260505.py + python scripts/split_dataset_v5_20260505.py --force # 강제 재생성 +""" + +import argparse +import random +from collections import defaultdict +from pathlib import Path + +import pandas as pd + +_BASE = Path(__file__).parent.parent +DATA_CSV = _BASE / "data" / "notice_sample_v5_clean_full_20260504.csv" +OUT_CSV = _BASE / "data" / "split_v5_20260505.csv" + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] + +TRAIN_RATIO = 0.80 +VAL_RATIO = 0.10 +# TEST = 나머지 0.10 + +SEED = 42 + + +def stratified_split(df: pd.DataFrame) -> pd.DataFrame: + """ + 카테고리별로 동일 비율로 train/val/test를 나눈다. + + v5 클래스 분포 (4992행): + 일정 ~1469, 건강·안전 ~1268, 제출 ~926, 기타 ~788, 준비물 ~322, 비용 ~219 + 준비물·비용이 상대적으로 적으므로 Stratified가 특히 중요. + """ + random.seed(SEED) + df = df.copy() + df["split"] = "" + + groups: defaultdict[str, list] = defaultdict(list) + for i, row in df.iterrows(): + groups[row["category"]].append(i) + + for category, indices in groups.items(): + random.shuffle(indices) + n = len(indices) + n_train = max(1, round(n * TRAIN_RATIO)) + n_val = max(1, round(n * VAL_RATIO)) + + for j, idx in enumerate(indices): + if j < n_train: + df.at[idx, "split"] = "train" + elif j < n_train + n_val: + df.at[idx, "split"] = "val" + else: + df.at[idx, "split"] = "test" + + return df + + +def main(force: bool = False) -> None: + if OUT_CSV.exists() and not force: + print(f"[split_v5] {OUT_CSV.name} 이미 존재합니다. 재생성하려면 --force 사용.") + return + + if not DATA_CSV.exists(): + print(f"[오류] {DATA_CSV} 없음 - notice_sample_v5_clean_full_20260504.csv를 확인하세요.") + return + + df = pd.read_csv(DATA_CSV, encoding="utf-8-sig") + df = df.dropna(subset=["text", "category"]) + df = df[df["category"].isin(LABELS)].copy() + df = df.drop_duplicates(subset=["text"]) + + print(f"[split_v5] 입력 데이터: {len(df)}개 (중복 제거 후)") + print("\n카테고리 분포:") + for lbl in LABELS: + cnt = (df["category"] == lbl).sum() + print(f" {lbl:8s}: {cnt:4d}개") + + df = stratified_split(df) + OUT_CSV.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(OUT_CSV, index=False, encoding="utf-8-sig") + + print(f"\n[split_v5] 저장 완료: {OUT_CSV}") + + counts = df.groupby(["split", "category"]).size().unstack(fill_value=0) + print("\n분할 결과 (split x category):") + print(counts) + print(f"\n전체: {len(df)}개") + print(f" train: {(df.split == 'train').sum():4d}개") + print(f" val: {(df.split == 'val').sum():4d}개") + print(f" test: {(df.split == 'test').sum():4d}개") + print("\n[참고] v4 대비 train 데이터 증가량:") + print(f" v4 train: 556개 → v5 train: {(df.split == 'train').sum()}개 ({(df.split == 'train').sum() / 556:.1f}배 증가)") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + main(force=args.force) diff --git a/model/classification/scripts/upload_classifier_to_hf.py b/model/classification/scripts/upload_classifier_to_hf.py new file mode 100644 index 0000000000000000000000000000000000000000..a6d90a740e5c20b23972e75677eb6b26bb37af9e --- /dev/null +++ b/model/classification/scripts/upload_classifier_to_hf.py @@ -0,0 +1,93 @@ +""" +KcELECTRA 분류 모델 → HuggingFace Hub 업로드 스크립트 +====================================================== +사용법: + 1. HF 토큰 발급: https://huggingface.co/settings/tokens (write 권한) + 2. HF_TOKEN과 HF_USERNAME을 아래 상수에 채우기 + 3. python scripts/upload_classifier_to_hf.py + +업로드 결과: + HF Hub repo: HF_USERNAME/kcelectra-category + 서브폴더: kcelectra-category-v3/ + → AutoModel.from_pretrained("HF_USERNAME/kcelectra-category", + subfolder="kcelectra-category-v3") + +완료 후 반드시: + classifier_kcelectra.py 의 _BASE_MODEL_ID 를 실제 repo ID로 수정할 것. +""" + +import os +import sys +from pathlib import Path + +# ── 여기 두 값을 채워 주세요 ────────────────────────────────────── +HF_TOKEN = "" # HF write 토큰 (빈 문자열이면 huggingface-cli login 세션 사용) +HF_USERNAME = "kysophia" +# ───────────────────────────────────────────────────────────────── + +REPO_NAME = "kcelectra-category" +SUBFOLDER = "kcelectra-category-v3" + +_HERE = Path(__file__).parent.parent # classification/ +CKPT_DIR = _HERE / "checkpoints" / "kcelectra-category-v3" + + +def _check_prerequisites(): + if not HF_USERNAME: + print("[오류] HF_USERNAME을 이 스크립트 상단에 입력하세요.") + sys.exit(1) + + if not CKPT_DIR.exists(): + print(f"[오류] 체크포인트 폴더가 없습니다: {CKPT_DIR}") + print(" Colab 학습 완료 후 kcelectra-category-v3/ 를 이 경로에 두세요.") + sys.exit(1) + + required = ["config.json", "label2id.json", "tokenizer.json", "tokenizer_config.json"] + missing = [f for f in required if not (CKPT_DIR / f).exists()] + has_weights = any((CKPT_DIR / w).exists() for w in ("model.safetensors", "pytorch_model.bin")) + + if missing: + print(f"[오류] 필수 파일 누락: {missing}") + sys.exit(1) + if not has_weights: + print("[오류] model.safetensors 또는 pytorch_model.bin 이 없습니다.") + sys.exit(1) + + try: + from huggingface_hub import HfApi # noqa: F401 + except ImportError: + print("[오류] huggingface_hub 가 설치되지 않았습니다.") + print(" pip install huggingface-hub") + sys.exit(1) + + +def upload(): + _check_prerequisites() + + from huggingface_hub import HfApi + + api = HfApi(token=HF_TOKEN or None) + repo_id = f"{HF_USERNAME}/{REPO_NAME}" + + print(f"[1/3] repo 생성 또는 확인: {repo_id}") + api.create_repo(repo_id=repo_id, repo_type="model", exist_ok=True, private=False) + + print(f"[2/3] 파일 업로드: {CKPT_DIR} → {repo_id}/{SUBFOLDER}/") + api.upload_folder( + folder_path=str(CKPT_DIR), + repo_id=repo_id, + path_in_repo=SUBFOLDER, + commit_message=f"Add KcELECTRA classification v3 checkpoint", + ) + + print(f"\n[3/3] 완료!") + print(f" Hub URL : https://huggingface.co/{repo_id}") + print(f" 로드 코드:") + print(f' AutoTokenizer.from_pretrained("{repo_id}", subfolder="{SUBFOLDER}")') + print(f' AutoModelForSequenceClassification.from_pretrained(') + print(f' "{repo_id}", subfolder="{SUBFOLDER}", num_labels=6)') + print(f"\n classifier_kcelectra.py 의 _BASE_MODEL_ID = \"{repo_id}\" 로 수정하세요.") + + +if __name__ == "__main__": + upload() diff --git a/model/classification/src/__init__.py b/model/classification/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/model/classification/src/classifier_kcelectra.py b/model/classification/src/classifier_kcelectra.py new file mode 100644 index 0000000000000000000000000000000000000000..e10d9a415c6fcefcb4e658995b3c227cd2c53c56 --- /dev/null +++ b/model/classification/src/classifier_kcelectra.py @@ -0,0 +1,149 @@ +""" +KcELECTRA 파인튜닝 분류기 — 추론 전용 모듈 +============================================ +담당: 경이 +역할: 01_train_kcelectra.ipynb에서 학습·저장된 모델을 불러와 + 텍스트 → 6-class 카테고리 추론 수행. + 학습(Training)은 이 파일이 아닌 notebooks/01_train_kcelectra.ipynb에서 진행. + +요구 환경: torch, transformers (CPU 추론 가능) +학습 체크포인트 경로: model/classification/checkpoints/kcelectra-category/ +""" + +import os +import json +from pathlib import Path +from typing import Optional + +# 의존성 지연 임포트 — transformers 없는 환경(CI)에서 모듈 로드만큼은 안전하게 +try: + import torch + from transformers import AutoTokenizer, AutoModelForSequenceClassification + _HF_AVAILABLE = True +except ImportError: + _HF_AVAILABLE = False + +_BASE = Path(__file__).parent.parent +_CKPT_DIR = _BASE / "checkpoints" / "kcelectra-category-v3" +_LABELS_FILE = _CKPT_DIR / "label2id.json" + +# HF Hub fallback — 로컬 체크포인트 없을 때 자동 다운로드 +# upload_classifier_to_hf.py 실행 후 아래 두 값을 채워 주세요. +_BASE_MODEL_ID = "kysophia/kcelectra-category" +_HF_SUBFOLDER = "kcelectra-category-v3" + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] + +_tokenizer = None +_model = None +_id2label: dict[int, str] = {} +_device = "cpu" + +# 가장 핵심적인 함수입니다. 3가지 작업을 합니다. +# ① 로컬 체크포인트 vs Hub 자동 선택 +# ② GPU/CPU 자동 선택 +# ③ 라벨 맵핑 로드 + +def _load_model() -> None: + global _tokenizer, _model, _id2label, _device + + if _model is not None: + return + if not _HF_AVAILABLE: + raise ImportError("torch/transformers가 설치되지 않았습니다. pip install torch transformers") + + _device = "cuda" if torch.cuda.is_available() else "cpu" + + # 로컬 파인튜닝 체크포인트 우선 + _local_ready = ( + _CKPT_DIR.exists() + and (_CKPT_DIR / "config.json").exists() + and any( + (_CKPT_DIR / f).exists() + for f in ("pytorch_model.bin", "model.safetensors") + ) + ) + + if _local_ready: + _tokenizer = AutoTokenizer.from_pretrained(str(_CKPT_DIR)) + _model = AutoModelForSequenceClassification.from_pretrained( + str(_CKPT_DIR), num_labels=len(LABELS) + ) + src = str(_CKPT_DIR) + else: + if not _BASE_MODEL_ID: + raise RuntimeError( + "로컬 체크포인트도 없고 _BASE_MODEL_ID도 비어 있습니다.\n" + "scripts/upload_classifier_to_hf.py 를 먼저 실행하고\n" + "classifier_kcelectra.py 의 _BASE_MODEL_ID 를 채워 주세요." + ) + _tokenizer = AutoTokenizer.from_pretrained( + _BASE_MODEL_ID, subfolder=_HF_SUBFOLDER + ) + _model = AutoModelForSequenceClassification.from_pretrained( + _BASE_MODEL_ID, subfolder=_HF_SUBFOLDER, num_labels=len(LABELS) + ) + src = f"{_BASE_MODEL_ID}/{_HF_SUBFOLDER}" + + _model.to(_device) + _model.eval() + + # 라벨 맵핑 로드 (파인튜닝 완료 후 저장된 파일) + if _LABELS_FILE.exists(): + with open(_LABELS_FILE, "r", encoding="utf-8") as f: + label2id: dict[str, int] = json.load(f) + _id2label = {v: k for k, v in label2id.items()} + else: + _id2label = {i: label for i, label in enumerate(LABELS)} + + print(f"[kcelectra] 모델 로드 완료: {src} → device={_device}") + + +def predict_kcelectra(text: str) -> dict: + """텍스트 → 카테고리 + 신뢰도. + + 반환: + { + "category": str, # 예측 라벨 + "confidence": float, # softmax 최대 확률 + "probs": dict[str, float] + } + """ + _load_model() + + inputs = _tokenizer( + text, + return_tensors="pt", + truncation=True, + padding=True, + max_length=128, + ).to(_device) + + # 추론 시 gradient 계산을 끄는 것입니다. 학습이 아닌 예측만 하므로 메모리를 절약하고 속도를 높입니다. + with torch.no_grad(): + logits = _model(**inputs).logits + probs = torch.softmax(logits, dim=-1)[0] + + idx = int(probs.argmax().item()) + label = _id2label.get(idx, "기타") + + return { + "category": label, + "confidence": float(probs[idx].item()), + "probs": { + _id2label.get(i, str(i)): float(p.item()) + for i, p in enumerate(probs) + }, + } + + +def is_ready() -> bool: + """로컬 체크포인트 또는 HF Hub ID 중 하나라도 준비됐는지 확인.""" + local_ok = ( + _HF_AVAILABLE + and _CKPT_DIR.exists() + and (_CKPT_DIR / "config.json").exists() + and any((_CKPT_DIR / f).exists() for f in ("pytorch_model.bin", "model.safetensors")) + ) + hub_ok = bool(_BASE_MODEL_ID) + return local_ok or hub_ok diff --git a/model/classification/src/classifier_simple.py b/model/classification/src/classifier_simple.py new file mode 100644 index 0000000000000000000000000000000000000000..97cdc619560a011b09111eed518c12e351ad23d3 --- /dev/null +++ b/model/classification/src/classifier_simple.py @@ -0,0 +1,219 @@ +""" +베이스라인 분류기: TF-IDF + Logistic Regression +================================================= +담당: 경이 +역할: 라벨 데이터(notice_sample_v3.csv + notices_labeled_v2.csv)로 학습한 + 6-class 텍스트 분류기. + GPU 불필요. CPU에서 수십 ms 이내 추론 가능. + KcELECTRA 파인튜닝과 성능 비교할 베이스라인. + +사용법: + python classifier_simple.py # 학습 + 저장 + python classifier_simple.py --eval # 저장된 모델 평가 +""" + +import pickle +import argparse +from pathlib import Path + +import pandas as pd +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import Pipeline +from sklearn.metrics import classification_report, confusion_matrix + +# 경로 기준: model/classification/ +_BASE = Path(__file__).parent.parent +DATA_CSV = _BASE / "data" / "notice_sample_v3.csv" +LEGACY_CSV = _BASE / "data" / "notices_labeled_v2.csv" # 기존 라벨 데이터 +SPLIT_CSV = _BASE / "data" / "split_v1.csv" +MODEL_PKL = _BASE / "checkpoints" / "simple_tfidf_logreg.pkl" + +LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"] + +# 기존 CSV는 컬럼명이 'original_text'이므로 통일 처리 +_TEXT_COLS = ["text", "original_text", "sentence"] + + +def _normalize_df(df: pd.DataFrame) -> pd.DataFrame: + """컬럼명이 다른 여러 데이터 소스를 text/category 형식으로 통일.""" + for col in _TEXT_COLS: + if col in df.columns and "text" not in df.columns: + df = df.rename(columns={col: "text"}) + break + return df + + +# ───────────────────────────────────────────────────────────── +# 데이터 로드 +# ───────────────────────────────────────────────────────────── +def load_data(split: str = "train") -> tuple[list[str], list[str]]: + """split_v1.csv → 없으면 원본 CSV (+ legacy CSV 병합)에서 텍스트·라벨 로드. + + split: "train" | "val" | "test" | "all" + """ + if SPLIT_CSV.exists(): + df = pd.read_csv(SPLIT_CSV) + df = _normalize_df(df) + if split != "all": + df = df[df["split"] == split] + else: + frames = [pd.read_csv(DATA_CSV)] + if LEGACY_CSV.exists(): + legacy = pd.read_csv(LEGACY_CSV) + legacy = _normalize_df(legacy) + frames.append(legacy) + df = pd.concat(frames, ignore_index=True) + df = _normalize_df(df) + + df = df.dropna(subset=["text", "category"]) + df = df[df["category"].isin(LABELS)] + return df["text"].tolist(), df["category"].tolist() + + +# ───────────────────────────────────────────────────────────── +# 파이프라인 정의 +# ───────────────────────────────────────────────────────────── +def build_pipeline() -> Pipeline: + """TF-IDF + Logistic Regression 파이프라인. + + TF-IDF 파라미터: + - analyzer="char_wb": 한국어는 글자 단위 n-gram이 어절 단위보다 OOV((Out-of-Vocabulary)에 강함 : 단어 분리 없이 글자 단위 n-gram 사용 → 형태소 분석기 불필요, 미등록어(OOV)에 강함 + - 글자 n-gram은 조사/어미가 달라도 어근 조각이 겹치기 때문에 자연스럽게 대응 + - 글자 조각(n-gram)으로 쪼개면 처음 보는 표현도 익숙한 조각들로 분해되어 의미를 파악할 수 있다. + - ngram_range=(2, 4): 2~4글자 조합으로 어미/조사 같은 형태소 정보 간접 포착 + - max_features=30000: 메모리·속도 균형 + - sublinear_tf=True: 빈도를 log(1+tf)로 변환해 특정 단어의 과도한 영향 억제 + LogReg 파라미터: + - C=1.0: 기본 정규화 (오버피팅 방지) / C=1.0 — 정규화 강도 + - 정규화의 필요성: 모델이 학습 데이터에 너무 딱 맞게 학습되면 새로운 데이터에서 성능이 떨어집니다 (오버피팅). + - C는 정규화의 반대 개념입니다. + - C 값이 작을수록 → 정규화 강함 → 가중치를 강하게 억제 → 단순한 모델 + - C 값이 클수록 → 정규화 약함 → 가중치를 자유롭게 키움 → 복잡한 모델 + - max_iter=1000: 수렴 보장 + - 초기 가중치 → 예측 → 오차 계산 → 가중치 조정 → 예측 → 오차 계산 → ... + (1번째 iter) (2번째 iter) + - class_weight="balanced": 클래스 불균형 대응 + - solver="lbfgs": 다중 클래스에 적합한 최적화 알고리즘 + """ + return Pipeline([ + ("tfidf", TfidfVectorizer( + analyzer="char_wb", + ngram_range=(2, 4), + max_features=30_000, + sublinear_tf=True, + )), + ("clf", LogisticRegression( + C=1.0, + max_iter=1000, + class_weight="balanced", + random_state=42, + solver="lbfgs", + )), + ]) + + +# ───────────────────────────────────────────────────────────── +# 학습 +# ───────────────────────────────────────────────────────────── +def train() -> Pipeline: + texts, labels = load_data("train") + if not texts: + texts, labels = load_data("all") + + pipe = build_pipeline() + pipe.fit(texts, labels) + + MODEL_PKL.parent.mkdir(parents=True, exist_ok=True) + with open(MODEL_PKL, "wb") as f: + pickle.dump(pipe, f) + print(f"[simple] 모델 저장 완료: {MODEL_PKL}") + print(f"[simple] 학습 데이터 수: {len(texts)}개") + return pipe + + +# ───────────────────────────────────────────────────────────── +# 로드 (캐시) +# ───────────────────────────────────────────────────────────── +_pipeline: Pipeline | None = None + + +def load_pipeline() -> Pipeline: + global _pipeline + if _pipeline is not None: + return _pipeline + + if not MODEL_PKL.exists(): + print("[simple] 저장된 모델 없음 → 학습 시작") + _pipeline = train() + else: + with open(MODEL_PKL, "rb") as f: + _pipeline = pickle.load(f) + return _pipeline + + +# ───────────────────────────────────────────────────────────── +# 추론 +# ───────────────────────────────────────────────────────────── +def predict_simple(text: str) -> dict: + """텍스트 → 카테고리 + 신뢰도(각 클래스 확률). + + 반환: + { + "category": str, # 예측 라벨 + "confidence": float, # 예측 클래스 확률 + "probs": dict[str, float] # 전체 클래스 확률 (explain 용) + } + """ + pipe = load_pipeline() + proba = pipe.predict_proba([text])[0] + classes = pipe.classes_ + + idx = proba.argmax() + return { + "category": classes[idx], + "confidence": float(proba[idx]), + "probs": {c: float(p) for c, p in zip(classes, proba)}, + } + + +# ───────────────────────────────────────────────────────────── +# 평가 +# ───────────────────────────────────────────────────────────── +def evaluate(split: str = "test") -> dict: + texts, true_labels = load_data(split) + if not texts: + texts, true_labels = load_data("all") + + pipe = load_pipeline() + pred_labels = pipe.predict(texts) + + report = classification_report( + true_labels, pred_labels, + labels=LABELS, + output_dict=True, + zero_division=0, + ) + cm = confusion_matrix(true_labels, pred_labels, labels=LABELS) + + print("\n[simple] 분류 리포트") + print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0)) + print("[simple] Confusion Matrix") + print(cm) + + return {"report": report, "confusion_matrix": cm.tolist(), "model": "simple"} + + +# ───────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────── +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--eval", action="store_true", help="저장된 모델 평가") + args = parser.parse_args() + + if args.eval: + evaluate() + else: + train() + evaluate("test") diff --git a/model/classification/src/predict.py b/model/classification/src/predict.py new file mode 100644 index 0000000000000000000000000000000000000000..b048d7e2b214298206860ca2393585d7571119d4 --- /dev/null +++ b/model/classification/src/predict.py @@ -0,0 +1,136 @@ +""" +경이님 6-class 카테고리 분류 — 백엔드 진입점 +============================================== +담당: 경이 +파이프라인: [4] 카테고리 분류 단계 + +백엔드(backend/app/services/classifier.py)가 이 파일의 predict_one()을 호출함: + from src.predict import predict_one + result = predict_one(text, model="simple", today=today, explain=False) + +지원 모델: + "simple" → TF-IDF + LogReg (베이스라인, 빠름, CPU) + "kcelectra" → KcELECTRA 파인튜닝 (파인튜닝 완료 후 사용 가능) + "auto" → kcelectra 체크포인트 있으면 사용, 없으면 simple fallback + +출력 형식: + { + "text": str, + "category": str, # "일정" | "준비물" | "제출" | "비용" | "건강·안전" | "기타" + "confidence": float, # 0.0 ~ 1.0 + "model_used": str, # 실제 사용된 모델명 + "probs": dict # explain=True일 때만 채워짐 + } +""" + +from __future__ import annotations + +import datetime +from typing import Optional + +from .classifier_simple import predict_simple +from .classifier_kcelectra import predict_kcelectra, is_ready as kcelectra_ready + +VALID_CATEGORIES = {"일정", "준비물", "제출", "비용", "건강·안전", "기타"} + + +def predict_one( + text: str, + model: str = "simple", + today: Optional[datetime.date] = None, + explain: bool = False, +) -> dict: + """문장 1개 → 6-class 카테고리 예측. + + Args: + text: 분류할 문장 (윤정님 모델 출력의 "text" 필드) + model: "simple" | "kcelectra" | "auto" + today: 사용 안 함 (날짜 의존 분류 대비 인터페이스 호환) + explain: True → probs 딕셔너리 포함 + + Returns: + { + "text": str, + "category": str, + "confidence": float, + "model_used": str, + "probs": dict (explain=True 시), + } + """ + if not text or not text.strip(): + return _empty_result(text, model) + + model = model.lower() + result: dict + used: str + + if model == "kcelectra": + result = predict_kcelectra(text) + used = "kcelectra" + elif model == "auto": + if kcelectra_ready(): + result = predict_kcelectra(text) + used = "kcelectra" + else: + result = predict_simple(text) + used = "simple" + else: + result = predict_simple(text) + used = "simple" + + category = result.get("category", "기타") + if category not in VALID_CATEGORIES: + category = "기타" + + out = { + "text": text, + "category": category, + "confidence": result.get("confidence", 0.0), + "model_used": used, + } + if explain: + out["probs"] = result.get("probs", {}) + return out + + +def predict_batch( + texts: list[str], + model: str = "simple", + today: Optional[datetime.date] = None, + explain: bool = False, +) -> list[dict]: + """여러 문장 일괄 예측. 윤정님 결과 전체를 한 번에 처리할 때 사용.""" + return [predict_one(t, model=model, today=today, explain=explain) for t in texts] + + +def _empty_result(text: str, model: str) -> dict: + return { + "text": text, + "category": "기타", + "confidence": 0.0, + "model_used": model, + } + + +# ───────────────────────────────────────── +# 직접 실행 테스트 +# ───────────────────────────────────────── +if __name__ == "__main__": + samples = [ + "현장체험학습 비용 20,000원을 3월 20일까지 납부해 주세요.", + "체험학습 당일 도시락과 물을 준비해 주세요.", + "동의서를 작성하여 담임선생님께 제출해 주세요.", + "운동회는 10월 5일 오전 9시 30분에 열립니다.", + "발열·기침 증상이 있는 경우 등교를 자제해 주세요.", + "궁금한 사항은 담임선생님께 문의해 주세요.", + ] + print("=" * 60) + print("predict_one 테스트 (model=simple)") + print("=" * 60) + for s in samples: + r = predict_one(s, model="simple", explain=True) + print(f"\n문장: {r['text']}") + print(f" → 카테고리: {r['category']} (신뢰도: {r['confidence']:.3f})") + probs = r.get("probs", {}) + top3 = sorted(probs.items(), key=lambda x: -x[1])[:3] + print(f" → Top3: {top3}") diff --git a/model/extraction/.gitkeep b/model/extraction/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/model/extraction/README.md b/model/extraction/README.md new file mode 100644 index 0000000000000000000000000000000000000000..969ff8ed1779b4ad55723646a30b40bfdc460991 --- /dev/null +++ b/model/extraction/README.md @@ -0,0 +1,258 @@ +# 모델 A — 추출 파이프라인 (A단계: 이진 분류 + B단계: 카테고리 분류) + +**담당: 윤정** · `model/extraction/` · KoELECTRA fine-tuned + +--- + +## 한 줄 요약 + +> 가정통신문 원문 텍스트를 넣으면, 학부모가 실행해야 할 **후보 문장 리스트**가 나옵니다 (B단계 입력용). + +--- + +## 2단계 구조 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ 가정통신문 원문 텍스트 (OCR 출력) │ +└─────────────────────────┬────────────────────────────────────┘ + │ + ┌─────────────────▼──────────────────┐ + │ A단계 — 윤정 담당 │ + │ file/predict.py │ + │ │ + │ ① split_sentences() │ + │ 줄바꿈 복원 + 문장 분리 │ + │ (헤더·OCR 노이즈 조기 차단) │ + │ │ + │ ② is_likely_todo() │ + │ 정규식 1차 필터 │ + │ → KoELECTRA 이진 분류 │ + │ 0: 노이즈 1: 할 일·중요 일정 │ + │ │ + │ ③ extract_due_date() │ + │ extract_amount() │ + │ extract_action_hint() │ + │ 정규식으로 날짜·금액·행동 추출 │ + └─────────────────┬──────────────────┘ + │ + list[dict] — B단계 입력 스키마 + {"text", "source", "due_date", + "amount", "confidence", "action_hint"} + │ + ┌─────────────────▼──────────────────┐ + │ B단계 — 경이 담당 │ + │ KoELECTRA 5-class 카테고리 분류 │ + │ (제출·준비물·건강·안전·비용·일정·기타) │ + └────────────────────────────────────┘ +``` + +--- + +## 기술 스택 + +| | | +| --- | --- | +| A단계 모델 | KoELECTRA-small-v3 이진 분류 (`checkpoints/koelectra-binary/`) | +| B단계 모델 | KoELECTRA-base-v3 5-class · `yunjeong116/koelectra-extractor` | +| 날짜 / 금액 | 정규식 (regex) | +| 실행 환경 | CPU / GPU 자동 선택 | +| 의존성 | `torch` · `transformers` · `huggingface_hub` · `sklearn` | + +> **이전 → 현재:** Llama-3-Korean 8B (Colab T4, 4-bit 양자화) → KoELECTRA 하이브리드 +> 추론 속도 ↑, 서버 배포 용이성 ↑ + +--- + +## 카테고리 & 중요도 (B단계) + +| 카테고리 | 설명 | 기본 점수 | +| --- | --- | --- | +| `제출` | 서류 · 동의서 · 과제 | **1.00** | +| `준비물` | 지참물 안내 | 0.85 | +| `건강·안전` | 건강 · 안전 사항 | 0.80 | +| `비용` | 금액 포함 항목 (정규식) | 0.75 | +| `일정` | 행사 · 일정 안내 | 0.70 | +| `기타` | 위 외 항목 | 0.50 | + +긴급 키워드(`반드시`, `마감`, `즉시` 등) 포함 시 +0.05 / due_date 있을 시 +0.05 + +--- + +## 모델 성능 (B단계 카테고리 분류기) + +### v2 재학습 결과 (2026-04-28 · 15 epoch · cosine LR · WeightedCrossEntropy) + +```text + precision recall f1-score support + + 일정 1.0000 1.0000 1.0000 4 + 준비물 1.0000 0.5000 0.6667 4 + 제출 0.8000 1.0000 0.8889 4 + 건강·안전 0.8571 0.8571 0.8571 7 + 기타 0.5000 1.0000 0.6667 1 + + accuracy 0.8500 20 + macro avg 0.8314 0.8714 0.8159 20 +weighted avg 0.8850 0.8500 0.8444 20 +``` + +### v1 → v2 비교 + +| 지표 | v1 (10 epoch) | v2 (15 epoch) | 변화 | +| ------ | -------------- | -------------- | ------ | +| **accuracy** | 0.7500 | **0.8500** | +0.10 ✅ | +| **macro F1** | 0.5988 | **0.8159** | +0.22 ✅ | +| 기타 F1 | 0.0000 | **0.6667** | 완전 회복 ✅ | + +> MVP 목표 (accuracy ≥ 0.80, macro F1 ≥ 0.75) 달성 +> 학습 파라미터: `num_train_epochs=15`, `lr=2e-5`, `scheduler=cosine`, `warmup_ratio=0.1`, `WeightedCrossEntropy(balanced)` + +--- + +## 버그 수정 이력 (2026-04-27) + +| # | 현상 | 원인 | 수정 | +| --- | ------ | ------ | ------ | +| Bug 1 | 인사말이 TODO로 잡힘 | `NON_TODO_PATTERNS`에 `안녕하세요` 미포함 | 패턴 3개 추가 | +| Bug 2 | NLLB 첫 번역 문장 어색 | 제목 줄이 인사말과 합쳐져 번역 전달 | `_HEADER_ONLY` 필터 추가 | +| Bug 3 | `원→won` 오탐 | `원하시는`, `원인` 등 substring 매칭 | `MONEY_PATTERN`에 숫자 선행 조건 강제 | + +--- + +## 파일 구성 + +```text +model/extraction/ +├── fill_original2.py ← notices_original2.jsonl 자동 채우기 스크립트 +├── file/ +│ ├── predict.py ← A단계 메인 파이프라인 (백엔드 진입점) +│ ├── evaluate_model.py ← Base vs Fine-tuned 성능 비교 (강사 제출용) +│ ├── preprocess_txt_to_jsonl.py ← PDF txt → JSONL 변환 (문장 단위, 라벨링용) +│ └── txt_to_jsonl.py ← PDF txt → JSONL 변환 (문서 단위) +├── data/ +│ ├── notices_labeled_v2.jsonl ← 학습 라벨 데이터 (100문장, is_todo + original_id) +│ ├── notices_original2.jsonl ← 원문 27장 + category/keywords/importance 자동 채우기 +│ ├── notices_original2.csv ← CSV 버전 +│ └── galsan_txt/ ← 갈산초 가정통신문 txt 원본 (~100건) +├── checkpoints/ +│ └── koelectra-extractor/ ← B단계 5-class 카테고리 분류 체크포인트 (Hub 백업) +├── docs/ +│ ├── devlog-2026-04-27.md ← Bug 수정 3종 + 학습 파라미터 개선 +│ ├── devlog-2026-04-28.md ← notices_original2.jsonl 자동 채우기 작업 +│ └── devlog-2026-04-28-v2.md ← v2 재학습 결과 (accuracy 0.85 달성) +└── x/ ← 구버전 보관 (Llama Few-shot + 구 predict.py) + ├── MODEL.py + ├── predict.py + ├── notices_labeled_v2.csv + └── extracted_results.json +``` + +--- + +## A단계 출력 예시 (`file/predict.py`) + +B단계(경이 모델) 입력 스키마: + +```json +[ + { + "text": "개인용 이어폰(3.5mm) 4월 20일까지 준비해 주세요.", + "source": "sample.txt", + "due_date": "2026-04-20", + "amount": null, + "confidence": 0.9312, + "action_hint": "준비" + }, + { + "text": "구입비는 5,000원 이내의 잔돈으로 준비합니다.", + "source": "sample.txt", + "due_date": null, + "amount": 5000, + "confidence": 0.8741, + "action_hint": "준비" + } +] +``` + +--- + +## 실행 + +### A단계 추출 (직접 테스트) + +```bash +pip install torch transformers +python model/extraction/file/predict.py +``` + +### 모델 성능 평가 (Base vs Fine-tuned 비교) + +```bash +pip install scikit-learn pandas +python model/extraction/file/evaluate_model.py +# 테스트 데이터 직접 지정 시: +python model/extraction/file/evaluate_model.py --test_data data/test_data.jsonl +``` + +### txt → JSONL 변환 (데이터 전처리) + +```bash +# 문서 단위 (notices_original2.jsonl 스키마) +python model/extraction/file/txt_to_jsonl.py \ + --input data/galsan_txt/*.txt \ + --output data/notices_original2.jsonl \ + --source_type 초등학교 + +# 문장 단위 (학습 라벨링용) +python model/extraction/file/preprocess_txt_to_jsonl.py \ + --input_dir data/galsan_txt \ + --output data/notices_labeled.jsonl +``` + +### notices_original2.jsonl 자동 채우기 + +```bash +python model/extraction/fill_original2.py +``` + +멱등성 보장 — 재실행 시 항상 재계산하여 덮어씀. + +--- + +## 백엔드 연동 + +```python +from model.extraction.file.predict import predict + +# A단계: 후보 문장 추출 → B단계 입력 +candidates = predict(notice_text, source="파일명.pdf") +# [{"text", "source", "due_date", "amount", "confidence", "action_hint"}, ...] +``` + +모델은 첫 호출 시 로컬 체크포인트(`checkpoints/koelectra-binary/`)를 우선 로드하고, +없으면 HuggingFace Hub(`yunjeong116/koelectra-extractor`)에서 자동 다운로드합니다. + +--- + +## 데이터 구성 + +| 파일 | 내용 | 건수 | +| ------ | ------ | ------ | +| `notices_labeled_v2.jsonl` | 문장 단위 라벨 (is_todo + category + original_id) | 100문장 (N01~N19) | +| `notices_original2.jsonl` | 원문 문서 단위 + category/keywords/importance | 27건 | +| `galsan_txt/` | 갈산초 가정통신문 원본 txt | ~100건 | + +> `notices_labeled_v2.jsonl`의 `original_id` 필드로 원문(`notices_original2.jsonl`)과 연결 가능. +> N16~N19(알림장 4건)는 27장 원문 미포함 → `original_id: null`. + +--- + +## 잔존 한계 및 향후 작업 + +| 항목 | 내용 | +| ------ | ------ | +| 준비물 recall 0.50 | 4개 중 2개 오분류. 데이터 추가 시 개선 여지 있음 | +| 기타 support=1 | 검증셋 샘플 1개 → F1 신뢰도 낮음. 가상 데이터 증강 필요 | +| 전체 샘플 100개 | 데이터 절대량 부족. 가상 데이터 추가 후 3차 학습 예정 (목표: 준비물·기타 F1 ≥ 0.70) | +| Binary 체크포인트 | `checkpoints/koelectra-binary/` 미배포 상태 — Colab 재학습 후 교체 필요 | diff --git a/model/extraction/file/auto_label.py b/model/extraction/file/auto_label.py new file mode 100644 index 0000000000000000000000000000000000000000..9e6d88c0681ac626a32692118208ea770b08dd00 --- /dev/null +++ b/model/extraction/file/auto_label.py @@ -0,0 +1,310 @@ +""" +auto_label.py +============= +가정통신문 문장에 is_todo 초안 라벨을 규칙 기반으로 부여. + +[판정 순서] + 1. FALSE 조건 먼저 체크 — 확실한 노이즈를 조기 제거 + 2. TRUE 조건 OR 체크 — 하나라도 해당하면 True + 3. 기본값 False + +[TRUE 조건 (OR)] + T1. 명확한 마감일 + 액션 동사 (제출/납부/작성/회신 등) + T2. 양식의 작성 칸 (보호자 성명, 불참 사유, 체크박스 등) + T3. 구체적 준비물 목록 + T4. 안전·건강 지침 (행동 권고) + T5. 일정 인지 (날짜 동반 필수 — 표 헤더 제외) + T6. 학부모 직접 액션 (잔액 확인, 동의 체크, CMS 등) + T7. 요청형 종결 어미 (해주세요·바랍니다·하십시오 등) + +[FALSE 조건] + F1. 표 헤더/구분자 (단독 단어 형태) + F2. 소제목/번호만 있는 라인 + F3. 발신일/발신자명/연락처/주소 + F4. 양식 안내문 (개인정보 수집 목적, 보유 기간 등) + F5. 인사말/서명 (안내드립니다, 말씀드립니다 등) + F6. 정보 안내 (총액·지원금 구성·환불 규정) + F7. 운영 방침/취지/기대 효과 설명 + F8. 학교 측 수행 행동 (학교·교사 주어 + 설명 동사) + +[사용법 — 모듈] + from auto_label import label_sentence, label_with_reason + label_sentence("5월 31일까지 동의서를 제출해주세요.") # True + label_with_reason("운동회는 5월 10일입니다.") # (True, "T5_schedule") + +[사용법 — CLI] + python file/auto_label.py --input data/v3_school.jsonl --output data/v3_labeled.jsonl + python file/auto_label.py --input data/v3_school.jsonl --output data/v3_labeled.jsonl --with_reason +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + + +# ───────────────────────────────────────────────────────────────────────────── +# FALSE 조건 패턴 +# ───────────────────────────────────────────────────────────────────────────── + +# F1: 표 헤더/구분자 — 단독 단어 형태 (줄 전체가 카테고리 레이블인 경우) +_F1_TABLE_HEADER = re.compile( + r"^(항목|구분|내용|기간|장소|대상|날짜|비용|금액|시간|방법|신청|담당|비고|" + r"순번|번호|학년|반|번|성명|이름|연락처|주소|이메일|합계|소계|계|일정|" + r"현황|결과|상태|여부|구성|세부|일자|기준|이유|사유|비율|운영|참가비|" + r"내역|항목|절차|기타|첨부|서식|붙임)\s*$" +) + +# F2: 소제목/번호만 있는 라인 (내용 없이 번호·기호만 존재) +_F2_SUBTITLE_ONLY = re.compile( + r"^[\d①②③④⑤⑥⑦⑧⑨⑩]+[.)]\s*$" # 숫자/원형숫자만 + r"|^[가나다라마바사아자차카타파하][.)]\s*$" # 가나다... 단독 + r"|^[A-Za-z][.)]\s*$" # 알파벳 단독 +) + +# F3: 발신일/발신자명/연락처/주소 +_F3_SENDER_INFO = re.compile( + r"^\d{4}\s*[.년]\s*\d{1,2}\s*[.월]\s*\d{1,2}\s*[.일]?\s*$" # 날짜만 + r"|(초등학교|중학교|고등학교|특수학교)\s*장\s*$" # 교장 서명 + r"|교육감\s*$|교육장\s*$|교육지원청\s*$" + r"|^Tel\s*[.::]|^FAX\s*[.::]|^전화\s*[.::]|^팩스\s*[.::]" # 연락처 전용 + r"|^\(?\d{2,3}\)?\s*\d{3,4}-\d{4}\s*$" # 전화번호만 + r"|(서울|경기|인천|부산|대구|광주|대전|울산|세종|강원|충북|충남|" + r"전북|전남|경북|경남|제주).{0,20}(로|길)\s*\d+" # 도로명 주소 +) + +# F4: 양식 안내문 (개인정보 수집·보유 기간 등) +_F4_FORM_GUIDANCE = re.compile( + r"수집\s*(목적|항목|근거|동의\s*여부)|보유\s*(기간|이용\s*기간)" + r"|개인정보\s*(처리|수집|보호|열람|이용)|처리\s*방침" + r"|제3자\s*제공|정보\s*주체|위탁\s*처리|수집\s*이용\s*동의" +) + +# F5: 인사말·안내 결어·서명 (predict.py _NON_TODO_PATTERNS 와 동기화) +_F5_GREETING_SIGN = re.compile( + r"^학부모님\s*안녕하(십니까|세요)" + r"|^안녕하(십니까|세요)" + r"|^.*님\s*안녕하(세요|십니까)" + r"|^학부모님께\s*(안내드립니다|드립니다)" + r"|안내드립니다\s*\.?\s*$" + r"|말씀드립니다\s*\.?\s*$" + r"|드리겠습니다\s*\.?\s*$" + r"|공익제보센터|자살예방상담|청소년상담" + r"|(초등학교|중학교|고등학교)\s*장\s*$" # 서명란 +) + +# F6: 정보 안내 (총액·지원금 구성·환불 규정) +_F6_INFO_ONLY = re.compile( + r"환불\s*(규정|정책|기준|불가)|총액\s*[::]|총\s*비용\s*[::]" + r"|지원금\s*(구성|항목|내역|산출)|예산\s*(항목|내역|산출|내역)" + r"|산출\s*근거|교부\s*기준|지원\s*내역|재정\s*현황" + r"|구성\s*내역|수익자\s*부담금" +) + +# F7: 운영 방침·취지·기대 효과 설명 (목적 서술) +_F7_POLICY_DESC = re.compile( + r"취지\s*(는|로|를)|기대\s*효과|교육적\s*효과|운영\s*방침" + r"|지원하기\s*위해|도모하(고자|기\s*위해|여)" + r"|증진하(고자|기\s*위해)|향상시키(고자|기\s*위해)" + r"|강화하기\s*위해|배경\s*(및|과)|이번에\s*마련" + r"|목적\s*(은|는|으로|에서)\s*.{0,30}(합니다|입니다)" +) + +# F8: 학교 측 수행 행동 (학교·교사 주어 + 완결 서술 동사) +_F8_SCHOOL_ACTION = re.compile( + r"(본교|학교|담임|교사|선생님|교장|교감|행정실|교육청).*?" + r"(실시합니다|운영합니다|진행합니다|제공합니다|지원합니다|" + r"예정입니다|계획합니다|마련합니다|추진합니다|안내합니다)" + r"|(실시할\s*예정|운영할\s*예정|진행할\s*예정|제공할\s*예정)" + r"\s*입니다" +) + + +_FALSE_CHECKS: list[tuple[re.Pattern, str]] = [ + (_F1_TABLE_HEADER, "F1_table_header"), + (_F2_SUBTITLE_ONLY, "F2_subtitle_only"), + (_F3_SENDER_INFO, "F3_sender_info"), + (_F4_FORM_GUIDANCE, "F4_form_guidance"), + (_F5_GREETING_SIGN, "F5_greeting_sign"), + (_F6_INFO_ONLY, "F6_info_only"), + (_F7_POLICY_DESC, "F7_policy_desc"), + (_F8_SCHOOL_ACTION, "F8_school_action"), +] + + +# ───────────────────────────────────────────────────────────────────────────── +# TRUE 조건 패턴 +# ───────────────────────────────────────────────────────────────────────────── + +# T1: 마감일 + 액션 동사 +_T1_DEADLINE_ACTION = re.compile( + r"(까지|기한\s*내|마감일?).*?(제출|납부|작성|회신|신청|등록|입금|반납|보내)" + r"|(제출|납부|작성|회신|신청|등록|입금).{0,30}(까지|기한\s*내|마감)" + r"|\d+\s*[.월]\s*\d+\s*일?.{0,20}(제출|납부|신청|등록|입금|회신)" + r"|(제출|납부|신청|등록|입금|회신).{0,20}\d+\s*[.월]\s*\d+\s*일?" +) + +# T2: 양식의 작성 칸 (보호자 서명, 체크박스 등) +_T2_FORM_FIELD = re.compile( + r"보호자\s*(성명|이름|서명|확인|날인|도장)" + r"|학부모\s*(성명|이름|서명|확인)" + r"|불참\s*(사유|이유)|동의\s*(여부|서명|확인\s*란)" + r"|서명\s*란|날인\s*란|확인\s*란" + r"|\(\s*(예|아니오|동의|미동의|해당|미해당|참|불참)\s*\)" # ( 예 / 아니오 ) + r"|□\s*(예|아니오|동의|참|불참|해당|미해당|확인)" # □ 체크박스 +) + +# T3: 구체적 준비물 목록 +_T3_SUPPLIES = re.compile( + r"준비물\s*[::]|지참물\s*[::]|준비\s*사항\s*[::]" + r"|챙길\s*(것|물품|준비물)|지참\s*(해주세요|바랍니다|하세요|하시기)" + r"|가져(와야|올)\s*(할|것|주세요)" + r"|(실내화|도시락|물병|우산|운동복|체육복|필기구|모자|선크림)" + r"\s*(을|를)?\s*(준비|지참|챙겨)" +) + +# T4: 안전·건강 지침 (행동 권고) +_T4_SAFETY_HEALTH = re.compile( + r"마스크\s*(착용|필착|꼭\s*착용|써\s*주세요)" + r"|발열\s*(체크|확인|시\s*등교\s*금지)" + r"|손\s*(씻기|소독|위생)|체온\s*측정" + r"|등교\s*금지|격리\s*(해주세요|바랍니다)" + r"|(안전|주의)\s*(해\s*주세요|바랍니다|지켜\s*주세요)" + r"|식중독\s*예방|보호대\s*(착용|필착)" +) + +# T5: 일정 인지 — 날짜 패턴과 행사 키워드가 한 문장에 함께 있어야 함 +_T5_SCHEDULE = re.compile( + r"(\d+\s*월\s*\d+\s*일|\d+\.\s*\d+\.?|\d+\s*일\s*\()" + r".{0,40}" + r"(휴업일|재량\s*휴업|개교기념일|수련|소풍|운동회|체험학습|" + r"수학여행|방학|개학|입학식|졸업식|행사일|공휴일|임시\s*공휴일)" + r"|" + r"(휴업일|재량\s*휴업|개교기념일|수련|소풍|운동회|체험학습|" + r"수학여행|방학|개학|입학식|졸업식|공휴일|임시\s*공휴일)" + r".{0,40}" + r"(\d+\s*월\s*\d+\s*일|\d+\.\s*\d+\.?|\d+\s*일\s*\()" +) + +# T6: 학부모 직접 액션 키워드 +_T6_PARENT_ACTION = re.compile( + r"잔액\s*확인|CMS\s*(자동이체|납부)|통장\s*(이체|잔액|확인)" + r"|자녀.*?(함께\s*(읽어|확인|서명)|지도|연습)" + r"|가정에서.*?(지도|확인|연습|시행)" + r"|회신\s*(해주세요|바랍니다|부탁|드립니다)" + r"|동의\s*(해주시기|하시면|체크|서명|란에|란을)" + r"|확인\s*후\s*(서명|날인|제출|반환)" +) + +# T7: 요청형 종결 어미 — 학부모 대상 직접 요청 +_T7_REQUEST_ENDING = re.compile( + r"(해주세요|해\s*주세요|하세요)\s*\.?\s*$" + r"|(주시기\s*바랍니다|바랍니다)\s*\.?\s*$" + r"|(해주시기\s*바랍니다|하시기\s*바랍니다)\s*\.?\s*$" + r"|(부탁드립니다|부탁\s*드립니다)\s*\.?\s*$" + r"|(해주십시오|하십시오)\s*\.?\s*$" + r"|(해주시길\s*바랍니다|하여\s*주시기\s*바랍니다)\s*\.?\s*$" +) + + +_TRUE_CHECKS: list[tuple[re.Pattern, str]] = [ + (_T1_DEADLINE_ACTION, "T1_deadline_action"), + (_T2_FORM_FIELD, "T2_form_field"), + (_T3_SUPPLIES, "T3_supplies"), + (_T4_SAFETY_HEALTH, "T4_safety_health"), + (_T5_SCHEDULE, "T5_schedule"), + (_T6_PARENT_ACTION, "T6_parent_action"), + (_T7_REQUEST_ENDING, "T7_request_ending"), +] + + +# ───────────────────────────────────────────────────────────────────────────── +# 공개 API +# ───────────────────────────────────────────────────────────────────────────── + +def label_with_reason(text: str) -> tuple[bool, str]: + """(is_todo, reason) 반환. reason 은 매칭된 패턴 코드 또는 'no_match'.""" + text = text.strip() + if len(text) < 4: + return False, "too_short" + + for pat, reason in _FALSE_CHECKS: + if pat.search(text): + return False, reason + + for pat, reason in _TRUE_CHECKS: + if pat.search(text): + return True, reason + + return False, "no_match" + + +def label_sentence(text: str) -> bool: + """is_todo 라벨만 반환. True=할 일·중요 일정, False=노이즈.""" + return label_with_reason(text)[0] + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────────────────────── + +def _run_cli() -> None: + parser = argparse.ArgumentParser( + description="JSONL 파일에 is_todo 초안 라벨 부여" + ) + parser.add_argument("--input", type=Path, required=True, help="입력 JSONL 파일") + parser.add_argument("--output", type=Path, required=True, help="출력 JSONL 파일") + parser.add_argument( + "--with_reason", action="store_true", + help="label_reason 필드를 출력에 포함 (검수용)" + ) + args = parser.parse_args() + + if not args.input.exists(): + print(f"[오류] 파일 없음: {args.input}", file=sys.stderr) + sys.exit(1) + + args.output.parent.mkdir(parents=True, exist_ok=True) + + total = true_cnt = false_cnt = 0 + reason_counter: dict[str, int] = {} + + with args.input.open(encoding="utf-8") as fin, \ + args.output.open("w", encoding="utf-8") as fout: + + for raw in fin: + raw = raw.strip() + if not raw: + continue + obj = json.loads(raw) + text = obj.get("text", "") + + is_todo, reason = label_with_reason(text) + obj["is_todo"] = is_todo + if args.with_reason: + obj["label_reason"] = reason + + fout.write(json.dumps(obj, ensure_ascii=False) + "\n") + + total += 1 + if is_todo: + true_cnt += 1 + else: + false_cnt += 1 + reason_counter[reason] = reason_counter.get(reason, 0) + 1 + + print(f"\n처리 완료: {total}개 문장") + print(f" is_todo=True (할 일) : {true_cnt} ({true_cnt/total*100:.1f}%)") + print(f" is_todo=False (노이즈): {false_cnt} ({false_cnt/total*100:.1f}%)") + print(f"\n매칭 이유 분포 (상위 10):") + for reason, cnt in sorted(reason_counter.items(), key=lambda x: -x[1])[:10]: + print(f" {reason:<25} {cnt:>6}회") + print(f"\n저장: {args.output}") + + +if __name__ == "__main__": + _run_cli() diff --git a/model/extraction/file/evaluate_hf_model.ipynb b/model/extraction/file/evaluate_hf_model.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..f8cd1300ad85bf32646ee37f3f17372c3f65e8c4 --- /dev/null +++ b/model/extraction/file/evaluate_hf_model.ipynb @@ -0,0 +1,474 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# KoELECTRA 추출 모델 평가\n", + "**모델**: `yunjeong116/koelectra-extractor` (HF Hub 파인튜닝 모델) \n", + "**목표**: 학습이 제대로 됐는지 정량·정성 평가\n", + "\n", + "| 평가 항목 | 내용 |\n", + "|---|---|\n", + "| 1. 모델 로드 확인 | HF Hub에서 정상 로드되는지 |\n", + "| 2. 상식 점검 | 명백한 할 일 / 노이즈 문장 분류 확인 |\n", + "| 3. 정량 평가 | v2.1 라벨 데이터로 Precision / Recall / F1 |\n", + "| 4. 임계값 분석 | threshold 0.5 적절한지 확인 |\n", + "| 5. 실제 통신문 테스트 | 전체 가정통신문 입력 → 할 일 추출 |\n", + "\n", + "**실행 방법**\n", + "1. `런타임` → `런타임 유형 변경` → **T4 GPU** 선택\n", + "2. 셀을 위에서부터 순서대로 실행" + ] + }, + { + "cell_type": "markdown", + "id": "cell-1", + "metadata": {}, + "source": [ + "## 1. 라이브러리 설치" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-2", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install -q transformers==4.44.2 torch scikit-learn" + ] + }, + { + "cell_type": "markdown", + "id": "cell-3", + "metadata": {}, + "source": [ + "## 2. 모델 로드 (HF Hub)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-4", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import re\n", + "from typing import Optional\n", + "from transformers import AutoTokenizer, AutoModelForSequenceClassification\n", + "\n", + "MODEL_ID = \"yunjeong116/koelectra-extractor\"\n", + "BINARY_THRESHOLD = 0.5\n", + "device = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n", + "\n", + "print(f\"디바이스: {device}\")\n", + "print(f\"모델 로드 중: {MODEL_ID}\")\n", + "\n", + "tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)\n", + "model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID, num_labels=2)\n", + "model.to(device)\n", + "model.eval()\n", + "\n", + "print(\"\\n✅ 모델 로드 완료\")\n", + "print(f\" 파라미터 수: {sum(p.numel() for p in model.parameters()):,}\")\n", + "print(f\" 레이블 매핑: {model.config.id2label}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-5", + "metadata": {}, + "source": [ + "## 3. 추론 함수 정의" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-6", + "metadata": {}, + "outputs": [], + "source": [ + "def predict_sentence(sentence: str) -> tuple[int, float]:\n", + " \"\"\"단일 문장 → (label, confidence). label 1=할 일, 0=노이즈\"\"\"\n", + " inputs = tokenizer(\n", + " sentence,\n", + " return_tensors=\"pt\",\n", + " truncation=True,\n", + " padding=True,\n", + " max_length=128,\n", + " ).to(device)\n", + " with torch.no_grad():\n", + " prob = torch.softmax(model(**inputs).logits, dim=-1)[0]\n", + " confidence = float(prob[1].item())\n", + " label = 1 if confidence >= BINARY_THRESHOLD else 0\n", + " return label, round(confidence, 4)\n", + "\n", + "\n", + "def predict_batch(sentences: list[str], batch_size: int = 32) -> list[tuple[int, float]]:\n", + " \"\"\"배치 추론 — 대량 평가용\"\"\"\n", + " results = []\n", + " for i in range(0, len(sentences), batch_size):\n", + " batch = sentences[i:i+batch_size]\n", + " inputs = tokenizer(\n", + " batch,\n", + " return_tensors=\"pt\",\n", + " truncation=True,\n", + " padding=True,\n", + " max_length=128,\n", + " ).to(device)\n", + " with torch.no_grad():\n", + " probs = torch.softmax(model(**inputs).logits, dim=-1)[:, 1]\n", + " for p in probs.cpu().tolist():\n", + " results.append((1 if p >= BINARY_THRESHOLD else 0, round(p, 4)))\n", + " return results\n", + "\n", + "print(\"추론 함수 정의 완료\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-7", + "metadata": {}, + "source": [ + "## 4. 상식 점검 (Sanity Check)\n", + "명백한 할 일 / 노이즈 문장을 넣어서 모델이 기본적인 방향을 잡고 있는지 확인합니다. \n", + "**모든 ✅ 가 맞아야** 학습이 제대로 된 것입니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-8", + "metadata": {}, + "outputs": [], + "source": [ + "sanity_cases = [\n", + " # (문장, 정답 label, 설명)\n", + " # ── 할 일이어야 하는 것 (label=1) ───────────────────────────────────\n", + " (\"4월 30일까지 체험학습 동의서를 제출해주시기 바랍니다.\", 1, \"제출 요청\"),\n", + " (\"준비물: 도시락, 물통, 실내화를 꼭 지참해 주세요.\", 1, \"준비물 지참\"),\n", + " (\"급식비 79,980원을 5월 10일까지 납부해 주시기 바랍니다.\", 1, \"비용 납부\"),\n", + " (\"학교종이 앱 설문에 5월 7일까지 응답해 주시기 바랍니다.\", 1, \"설문 응답\"),\n", + " (\"인플루엔자 예방접종 후 접종 확인서를 제출해 주십시오.\", 1, \"건강·안전\"),\n", + " (\"신청서를 작성하여 담임선생님께 제출해 주시기 바랍니다.\", 1, \"신청서 제출\"),\n", + " # ── 노이즈여야 하는 것 (label=0) ───────────────────────────────────\n", + " (\"학부모님 안녕하십니까?\", 0, \"인사말\"),\n", + " (\"항상 학교 교육에 관심을 가져주셔서 감사합니다.\", 0, \"감사 인사\"),\n", + " (\"2026년 3월 4일 서울갈산초등학교장\", 0, \"발신자\"),\n", + " (\"본 안내문은 학교 홈페이지에 게시됩니다.\", 0, \"순수 공지\"),\n", + " (\"교무실 2649-7232~3. 팩스 2651-9881\", 0, \"연락처 메타\"),\n", + " (\"자살예방상담전화 109 / 청소년상담전화 1388\", 0, \"푸터 노이즈\"),\n", + "]\n", + "\n", + "print(f\"{'문장':<50} {'정답':>4} {'예측':>4} {'확률':>8} 결과\")\n", + "print(\"-\" * 80)\n", + "\n", + "correct = 0\n", + "for sent, gt, desc in sanity_cases:\n", + " pred, conf = predict_sentence(sent)\n", + " ok = pred == gt\n", + " correct += ok\n", + " tag = \"✅\" if ok else \"❌\"\n", + " gt_tag = \"할일\" if gt == 1 else \"노이즈\"\n", + " pred_tag = \"할일\" if pred == 1 else \"노이즈\"\n", + " print(f\"{sent[:48]:<50} {gt_tag:>4} {pred_tag:>4} {conf:>8.4f} {tag} {desc}\")\n", + "\n", + "print(f\"\\n상식 점검: {correct}/{len(sanity_cases)} 정답\")\n", + "if correct == len(sanity_cases):\n", + " print(\"✅ 모델이 기본 방향을 제대로 학습했습니다.\")\n", + "elif correct >= len(sanity_cases) * 0.8:\n", + " print(\"⚠️ 대부분 맞지만 일부 케이스를 틀렸습니다. 정량 평가로 상세 확인 필요.\")\n", + "else:\n", + " print(\"❌ 학습이 충분하지 않습니다. 재학습 또는 데이터 보강 필요.\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-9", + "metadata": {}, + "source": [ + "## 5. 정량 평가 — v2.1 라벨 데이터\n", + "`v2.1_notices_galsan.jsonl` 파일을 업로드하세요." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-10", + "metadata": {}, + "outputs": [], + "source": [ + "from google.colab import files\n", + "import json\n", + "\n", + "print(\"v2.1_notices_galsan.jsonl 파일을 업로드하세요.\")\n", + "uploaded = files.upload()\n", + "\n", + "fname = list(uploaded.keys())[0]\n", + "records = [json.loads(l) for l in uploaded[fname].decode(\"utf-8\").splitlines() if l.strip()]\n", + "\n", + "texts = [r[\"text\"] for r in records]\n", + "y_true = [int(r[\"is_todo\"]) for r in records]\n", + "\n", + "true_cnt = sum(y_true)\n", + "false_cnt = len(y_true) - true_cnt\n", + "print(f\"\\n로드 완료: 총 {len(records)}개\")\n", + "print(f\" is_todo=True : {true_cnt}개 ({true_cnt/len(y_true)*100:.1f}%)\")\n", + "print(f\" is_todo=False: {false_cnt}개 ({false_cnt/len(y_true)*100:.1f}%)\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-11", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.metrics import classification_report, confusion_matrix\n", + "import numpy as np\n", + "\n", + "print(\"배치 추론 중... (5,475개)\")\n", + "results = predict_batch(texts, batch_size=64)\n", + "y_pred = [r[0] for r in results]\n", + "y_conf = [r[1] for r in results]\n", + "\n", + "print(\"\\n\" + \"=\" * 55)\n", + "print(\"정량 평가 결과\")\n", + "print(\"=\" * 55)\n", + "print(classification_report(\n", + " y_true, y_pred,\n", + " target_names=[\"노이즈(0)\", \"할 일(1)\"],\n", + " digits=4,\n", + " zero_division=0,\n", + "))\n", + "\n", + "cm = confusion_matrix(y_true, y_pred)\n", + "print(\"혼동 행렬 (행=정답, 열=예측)\")\n", + "print(f\" 예측:노이즈 예측:할일\")\n", + "print(f\"정답:노이즈 {cm[0][0]:6d} {cm[0][1]:5d}\")\n", + "print(f\"정답:할 일 {cm[1][0]:6d} {cm[1][1]:5d}\")\n", + "\n", + "# 핵심 지표 요약\n", + "from sklearn.metrics import f1_score, precision_score, recall_score\n", + "f1 = f1_score(y_true, y_pred, pos_label=1, zero_division=0)\n", + "pre = precision_score(y_true, y_pred, pos_label=1, zero_division=0)\n", + "rec = recall_score(y_true, y_pred, pos_label=1, zero_division=0)\n", + "print(f\"\\n핵심 지표 (할 일 클래스 기준)\")\n", + "print(f\" Precision : {pre:.4f} (예측한 할 일 중 실제 할 일 비율)\")\n", + "print(f\" Recall : {rec:.4f} (실제 할 일 중 맞게 예측한 비율)\")\n", + "print(f\" F1 : {f1:.4f} (종합 성능, 목표 ≥ 0.65)\")\n", + "\n", + "if f1 >= 0.75:\n", + " print(\"\\n✅ 좋음 — 실용 수준 달성\")\n", + "elif f1 >= 0.65:\n", + " print(\"\\n⚠️ 보통 — 사용 가능하나 추가 데이터 권장\")\n", + "else:\n", + " print(\"\\n❌ 미흡 — 재학습 또는 데이터 보강 필요\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-12", + "metadata": {}, + "source": [ + "## 6. 임계값(Threshold) 분석\n", + "0.5가 최적인지 확인합니다. Recall을 높이려면 낮추고, Precision을 높이려면 올립니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-13", + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"{'Threshold':>10} {'Precision':>10} {'Recall':>8} {'F1':>8} {'할일예측수':>10}\")\n", + "print(\"-\" * 55)\n", + "\n", + "best_f1, best_thresh = 0, 0.5\n", + "for thresh in [0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7]:\n", + " preds = [1 if c >= thresh else 0 for c in y_conf]\n", + " p = precision_score(y_true, preds, pos_label=1, zero_division=0)\n", + " r = recall_score(y_true, preds, pos_label=1, zero_division=0)\n", + " f = f1_score(y_true, preds, pos_label=1, zero_division=0)\n", + " n = sum(preds)\n", + " mark = \" ← 현재\" if thresh == 0.5 else (\" ← 최적\" if f > best_f1 and thresh != 0.5 else \"\")\n", + " if f > best_f1:\n", + " best_f1, best_thresh = f, thresh\n", + " print(f\"{thresh:>10.2f} {p:>10.4f} {r:>8.4f} {f:>8.4f} {n:>10} {mark}\")\n", + "\n", + "print(f\"\\n최적 threshold: {best_thresh:.2f} (F1={best_f1:.4f})\")\n", + "if best_thresh != 0.5:\n", + " print(f\"→ predict.py의 BINARY_THRESHOLD를 {best_thresh}로 변경 권장\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "## 7. 오분류 샘플 확인\n", + "어떤 문장을 틀렸는지 확인합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-15", + "metadata": {}, + "outputs": [], + "source": [ + "# 실제 할 일인데 노이즈로 예측 (False Negative — 놓친 것)\n", + "fn = [(t, c) for t, gt, (pred, c) in zip(texts, y_true, results) if gt==1 and pred==0]\n", + "# 실제 노이즈인데 할 일로 예측 (False Positive — 잘못 잡은 것)\n", + "fp = [(t, c) for t, gt, (pred, c) in zip(texts, y_true, results) if gt==0 and pred==1]\n", + "\n", + "print(f\"[놓친 할 일 — False Negative] {len(fn)}개 (낮은 confidence로 탈락)\")\n", + "for sent, conf in sorted(fn, key=lambda x: x[1])[:10]: # confidence 낮은 순\n", + " print(f\" conf={conf:.4f} {sent[:80]}\")\n", + "\n", + "print(f\"\\n[잘못 잡은 문장 — False Positive] {len(fp)}개 (노이즈인데 할 일로 분류)\")\n", + "for sent, conf in sorted(fp, key=lambda x: -x[1])[:10]: # confidence 높은 순\n", + " print(f\" conf={conf:.4f} {sent[:80]}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-16", + "metadata": {}, + "source": [ + "## 8. 실제 가정통신문 테스트\n", + "전체 통신문 텍스트를 넣으면 할 일 문장만 뽑아줍니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-17", + "metadata": {}, + "outputs": [], + "source": [ + "# predict.py의 split_sentences 로직 인라인\n", + "_HEADER_ONLY = re.compile(r'^[^.,!?~]{2,40}(안내|공지|알림|공개수업|상담|학습|행사|일정)\\s*$')\n", + "_OCR_NOISE = re.compile(\n", + " r'https?://|^www\\.|☎\\s*\\d'\n", + " r'|^\\d{1,2}:\\d{2}\\s*[~\\-–]\\s*\\d{1,2}:\\d{2}'\n", + " r'|^[→←↑↓]+\\s*$'\n", + ")\n", + "\n", + "def split_sentences(text: str) -> list[str]:\n", + " lines = [l.strip() for l in text.splitlines() if l.strip()]\n", + " out = []\n", + " for line in lines:\n", + " if _HEADER_ONLY.match(line) or _OCR_NOISE.search(line):\n", + " continue\n", + " parts = re.split(\n", + " r'(?<=[.!?])\\s+|(?<=다\\.)\\s+|(?<=요\\.)\\s+|(?<=니다\\.)\\s+'\n", + " r'|(?<=까\\?)\\s+|(?<=요\\?)\\s+|\\s+(?=\\d+[.)]\\s)|\\s+(?=[가-힣]\\.\\s)',\n", + " line,\n", + " )\n", + " out.extend(parts)\n", + " return [s.strip() for s in out if s.strip() and len(s.strip()) > 3]\n", + "\n", + "\n", + "def extract_todos(notice_text: str, threshold: float = 0.5) -> list[dict]:\n", + " results = []\n", + " sentences = split_sentences(notice_text)\n", + " if not sentences:\n", + " return []\n", + " preds = predict_batch(sentences)\n", + " for sent, (label, conf) in zip(sentences, preds):\n", + " if conf >= threshold:\n", + " results.append({\"text\": sent, \"confidence\": conf})\n", + " return results\n", + "\n", + "\n", + "# ── 샘플 통신문 테스트 ───────────────────────────────────────────────────────\n", + "sample_notice = \"\"\"학부모님 안녕하십니까?\n", + "항상 학교 교육에 관심과 협조를 아끼지 않으시는 학부모님께 감사드립니다.\n", + "2026학년도 1학기 학부모 상담 운영에 대해 안내해 드립니다.\n", + "1. 상담 기간: 2026년 5월 12일(월) ~ 5월 16일(금)\n", + "2. 상담 방법: 담임교사와 1:1 면담 (교실 또는 줌)\n", + "3. 신청 방법: 학교종이 앱을 통해 5월 9일(금)까지 신청해 주시기 바랍니다.\n", + "상담을 원하지 않으시는 경우에도 학교종이 앱에서 '상담 불필요'를 선택해 주십시오.\n", + "준비물: 학생 생활기록부 관련 문의 사항을 미리 메모해 오시면 도움이 됩니다.\n", + "자세한 사항은 담임선생님께 문의해 주시기 바랍니다.\n", + "2026년 5월 2일 서울갈산초등학교장\"\"\"\n", + "\n", + "todos = extract_todos(sample_notice)\n", + "\n", + "print(\"=\" * 60)\n", + "print(\"샘플 통신문 → 할 일 추출 결과\")\n", + "print(\"=\" * 60)\n", + "if todos:\n", + " for i, item in enumerate(todos, 1):\n", + " print(f\"\\n{i}. [{item['confidence']:.4f}] {item['text']}\")\n", + "else:\n", + " print(\"추출된 할 일이 없습니다.\")\n", + "print(f\"\\n총 {len(todos)}개 할 일 추출\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-18", + "metadata": {}, + "source": [ + "## 9. 결과 요약\n", + "아래 셀을 실행하면 위 모든 결과를 한 번에 정리해 줍니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-19", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"=\" * 60)\n", + "print(\"최종 결과 요약\")\n", + "print(\"=\" * 60)\n", + "print(f\"모델 : {MODEL_ID}\")\n", + "print(f\"평가 데이터 : {len(records)}개 문장\")\n", + "print(f\" True(할일): {true_cnt}개 / False(노이즈): {false_cnt}개\")\n", + "print()\n", + "print(f\"[성능 — threshold={BINARY_THRESHOLD}]\")\n", + "print(f\" Precision : {pre:.4f}\")\n", + "print(f\" Recall : {rec:.4f}\")\n", + "print(f\" F1 : {f1:.4f}\")\n", + "print(f\" FN(놓침) : {len(fn)}개\")\n", + "print(f\" FP(오탐) : {len(fp)}개\")\n", + "print()\n", + "print(f\"[임계값 분석]\")\n", + "print(f\" 현재 threshold: 0.5\")\n", + "print(f\" 최적 threshold: {best_thresh:.2f} (F1={best_f1:.4f})\")\n", + "print()\n", + "if f1 >= 0.75:\n", + " print(\"판정: ✅ 좋음 — 실용 수준 달성\")\n", + "elif f1 >= 0.65:\n", + " print(\"판정: ⚠️ 보통 — 추가 데이터 권장\")\n", + "else:\n", + " print(\"판정: ❌ 미흡 — 재학습 또는 데이터 보강 필요\")" + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/model/extraction/file/evaluate_model.py b/model/extraction/file/evaluate_model.py new file mode 100644 index 0000000000000000000000000000000000000000..6b29bfcdf2d1ce6815e9d1ab1a7a2ff7e3722ade --- /dev/null +++ b/model/extraction/file/evaluate_model.py @@ -0,0 +1,191 @@ +""" +evaluate_model.py +================= +Base 모델 vs v2 Fine-tuned vs v3 Fine-tuned 성능 비교 + +[실행 전 준비] + 1. test_data.jsonl 준비 + python scripts/export_predict_output.py # 자동 생성 + 2. 체크포인트 배치 + checkpoints/koelectra-binary/ ← 현재(v3) 모델 + checkpoints/koelectra-binary-v2/ ← 이전(v2) 모델 (선택) + +[사용법] + # Base vs v3 + python file/evaluate_model.py + + # Base vs v2 vs v3 + python file/evaluate_model.py --v2_model ../checkpoints/koelectra-binary-v2 + + # 테스트 데이터 직접 지정 + python file/evaluate_model.py --test_data ../data/train/test_data.jsonl +""" + +import argparse +import sys +from pathlib import Path + +from sklearn.metrics import accuracy_score, classification_report, f1_score +from transformers import pipeline + +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +_HERE = Path(__file__).resolve().parent +_ROOT = _HERE.parent + +DEFAULT_TEST_DATA = _ROOT / "data" / "train" / "test_data.jsonl" +BASE_MODEL_ID = "monologg/koelectra-small-v3-discriminator" +V3_MODEL_PATH = str(_ROOT / "checkpoints" / "koelectra-binary") +V2_MODEL_PATH = str(_ROOT / "checkpoints" / "koelectra-binary-v2") + +_LABEL_MAP = { + "노이즈": 0, + "할 일": 1, + "LABEL_0": 0, + "LABEL_1": 1, +} + + +def _parse_label(raw: str) -> int: + if raw in _LABEL_MAP: + return _LABEL_MAP[raw] + try: + return int(raw.split("_")[-1]) + except ValueError: + raise ValueError(f"알 수 없는 라벨: {raw!r}") + + +def evaluate_model( + model_path: str, + test_texts: list[str], + true_labels: list[int], + model_name: str = "Model", +) -> tuple[float, float]: + print(f"\n[{model_name}] 추론 중...") + clf = pipeline( + "text-classification", + model=model_path, + tokenizer=model_path, + device=-1, + truncation=True, + max_length=128, + ) + predictions = clf(test_texts, batch_size=16) + pred_labels = [_parse_label(p["label"]) for p in predictions] + + acc = accuracy_score(true_labels, pred_labels) + f1 = f1_score(true_labels, pred_labels, pos_label=1, zero_division=0) + + print(f"[{model_name}] 완료") + print(f" Accuracy : {acc * 100:.2f}%") + print(f" F1-Score : {f1:.4f}") + print() + print(classification_report( + true_labels, pred_labels, + target_names=["노이즈(0)", "할 일(1)"], + digits=4, + zero_division=0, + )) + return acc, f1 + + +def load_test_data(path: Path) -> tuple[list[str], list[int]]: + import json + texts, labels = [], [] + for line in path.read_text("utf-8").splitlines(): + if not line.strip(): + continue + obj = json.loads(line) + if "text" not in obj or "is_todo" not in obj: + continue + texts.append(str(obj["text"])) + labels.append(int(bool(obj["is_todo"]))) + return texts, labels + + +def main() -> None: + parser = argparse.ArgumentParser(description="Base / v2 / v3 모델 성능 비교") + parser.add_argument( + "--test_data", + type=Path, + default=DEFAULT_TEST_DATA, + help=f"테스트 JSONL 경로 (기본: {DEFAULT_TEST_DATA})", + ) + parser.add_argument( + "--v3_model", + default=V3_MODEL_PATH, + help=f"v3 Fine-tuned 모델 경로 (기본: {V3_MODEL_PATH})", + ) + parser.add_argument( + "--v2_model", + default=None, + help="v2 Fine-tuned 모델 경로 (없으면 Base vs v3 비교만 수행)", + ) + args = parser.parse_args() + + if not args.test_data.exists(): + print(f"[오류] 테스트 파일이 없습니다: {args.test_data}", file=sys.stderr) + print(" 먼저 실행: python scripts/export_predict_output.py", file=sys.stderr) + sys.exit(1) + + test_texts, true_labels = load_test_data(args.test_data) + print(f"테스트 문장: {len(test_texts)}개") + print(f" 할 일(1): {sum(true_labels)}개 " + f"노이즈(0): {len(true_labels) - sum(true_labels)}개") + + # ── Base 모델 ───────────────────────────────────────────────────────── + print("\n" + "=" * 60) + print("Base 모델 (파인튜닝 전, 랜덤 가중치)") + print("=" * 60) + base_acc, base_f1 = evaluate_model( + BASE_MODEL_ID, test_texts, true_labels, "Base" + ) + + # ── v2 Fine-tuned (선택) ────────────────────────────────────────────── + v2_acc = v2_f1 = None + if args.v2_model: + v2_path = Path(args.v2_model) + if not v2_path.exists(): + print(f"[경고] v2 모델 경로 없음: {v2_path} — 건너뜀", file=sys.stderr) + else: + print("=" * 60) + print("v2 Fine-tuned 모델") + print("=" * 60) + v2_acc, v2_f1 = evaluate_model( + str(v2_path), test_texts, true_labels, "v2 Fine-tuned" + ) + + # ── v3 Fine-tuned ───────────────────────────────────────────────────── + print("=" * 60) + print("v3 Fine-tuned 모델 (v3_dual_labeled_clean.jsonl 학습)") + print("=" * 60) + v3_acc, v3_f1 = evaluate_model( + args.v3_model, test_texts, true_labels, "v3 Fine-tuned" + ) + + # ── 비교 요약 ───────────────────────────────────────────────────────── + print("\n" + "=" * 60) + print("[성능 비교 요약]") + print("=" * 60) + print(f"{'모델':<22} {'Accuracy':>10} {'F1 (할 일)':>12}") + print("-" * 48) + print(f"{'Base 모델':<22} {base_acc*100:>9.2f}% {base_f1:>12.4f}") + if v2_acc is not None: + print(f"{'v2 Fine-tuned':<22} {v2_acc*100:>9.2f}% {v2_f1:>12.4f}") + print(f"{'v3 Fine-tuned':<22} {v3_acc*100:>9.2f}% {v3_f1:>12.4f}") + print("-" * 48) + + ref_acc = v2_acc if v2_acc is not None else base_acc + ref_f1 = v2_f1 if v2_f1 is not None else base_f1 + ref_name = "v2 대비" if v2_acc is not None else "Base 대비" + delta_acc = (v3_acc - ref_acc) * 100 + delta_f1 = v3_f1 - ref_f1 + print(f"{'v3 향상 폭 (' + ref_name + ')':<22} " + f"{'+' if delta_acc>=0 else ''}{delta_acc:>8.2f}%p " + f"{'+' if delta_f1>=0 else ''}{delta_f1:>11.4f}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/model/extraction/file/predict.py b/model/extraction/file/predict.py new file mode 100644 index 0000000000000000000000000000000000000000..9fe378e15312735c27c68eef8a8109f5921584e0 --- /dev/null +++ b/model/extraction/file/predict.py @@ -0,0 +1,475 @@ +""" +model/extraction/file/predict.py +============================ +A단계: 가정통신문 → 할 일 및 중요 일정 후보 문장 추출기 + +담당: 윤정 +역할: 원문 텍스트에서 학부모가 인지·행동해야 할 문장만 이진 분류로 추출. + 카테고리 분류·중요도 계산은 B단계(경이 모델)로 위임. +출력: list[dict] — B단계 입력 스키마와 1:1 대응 + +───────────────────────────────────────── +입력 +───────────────────────────────────────── +OCR 모듈(pdfplumber / pymupdf 등)이 추출한 가정통신문 텍스트 (str). +OCR은 A단계 이전에 처리되므로 이 스크립트는 항상 순수 str 을 입력받습니다. + +───────────────────────────────────────── +파이프라인 +───────────────────────────────────────── +OCR 추출 텍스트 (str) + ↓ +[0] extract_title() 원본 줄에서 제목 감지 (heuristic) + → split_sentences() 이전에 실행해야 함 + (_HEADER_ONLY 필터가 제목 줄을 걸러내므로) + ↓ +[1] split_sentences() 줄글 → 문장 리스트 + (OCR 아티팩트 줄 조기 차단 포함) + ↓ +[2] is_likely_todo() ① 정규식 빠른 제외 + ② KoELECTRA 이진 분류 + 0: 노이즈 1: 할 일·중요 일정 + ↓ +[3] extract_due_date() 정규식: 날짜·마감 추출 + has_money() 정규식: 금액 여부 추출 + ↓ +predict() → list[dict] + {"text": str, "source": str|None, "due_date": str|None, + "amount": int|None, "confidence": float, "action_hint": str|None} + +제목 추출: extract_title(notice_text) → str | None (predict()와 별도 호출) +───────────────────────────────────────── +""" + +import datetime +import os +import re +import warnings +from typing import Optional + +import torch + +warnings.filterwarnings("ignore", category=UserWarning, module="torch") +from transformers import AutoTokenizer, AutoModelForSequenceClassification + + +# ───────────────────────────────────────── +# 0. 제목 감지 heuristic +# ───────────────────────────────────────── +_TITLE_ENDING_RE = re.compile(r"(안내|공지|알림|통보|조사|신청|수납|모집)\s*(제\s*\d{4,}-\d+호)?$") +_TITLE_MAX_LEN = 80 +_TITLE_SENT_END_RE = re.compile(r"[.!?。?!]") +_TITLE_SECTION_RE = re.compile(r"^[\d가나다라마바사아자차카타파하][.)]\s") + +# koelectra-title 체크포인트 경로 — 없으면 heuristic fallback +_TITLE_CHECKPOINT_DIR = os.path.join( + os.path.dirname(__file__), "..", "checkpoints", "koelectra-title" +) +TITLE_THRESHOLD = 0.4 # 임계값: train_koelectra_title.ipynb 임계값 분석 결과로 조정 + +_title_tokenizer: Optional[AutoTokenizer] = None +_title_model: Optional[AutoModelForSequenceClassification] = None + + +def _load_title_model() -> bool: + """koelectra-title 체크포인트가 있으면 로드하고 True 반환. 없으면 False.""" + global _title_tokenizer, _title_model + if _title_model is not None: + return True + local_ready = ( + any( + os.path.exists(os.path.join(_TITLE_CHECKPOINT_DIR, f)) + for f in ("pytorch_model.bin", "model.safetensors") + ) + and os.path.exists(os.path.join(_TITLE_CHECKPOINT_DIR, "config.json")) + ) + if not local_ready: + return False + _title_tokenizer = AutoTokenizer.from_pretrained(_TITLE_CHECKPOINT_DIR) + _title_model = AutoModelForSequenceClassification.from_pretrained( + _TITLE_CHECKPOINT_DIR, num_labels=2 + ) + _title_model.to(_device) + _title_model.eval() + return True + + +def is_title_heuristic(text: str) -> bool: + """rule 기반 제목 감지. + + 조건 (모두 만족해야 True): + - 10자 이상, 80자 이하 + - 문장 부호(. ! ?) 없음 + - 항목 번호(1. 가. 등)로 시작하지 않음 + - '안내/공지/알림/통보/조사/신청/수납/모집'으로 끝남 + """ + if not (10 <= len(text) <= _TITLE_MAX_LEN): + return False + if _TITLE_SENT_END_RE.search(text): + return False + if _TITLE_SECTION_RE.match(text): + return False + return bool(_TITLE_ENDING_RE.search(text)) + + +def extract_title(notice_text: str) -> Optional[str]: + """가정통신문에서 제목 문장을 추출. 없으면 None. + + - koelectra-title 체크포인트가 있으면 ML 모델로 전체 줄 스코어링 → 최고점 반환 + - 없으면 heuristic(is_title_heuristic) fallback → 첫 번째 통과 줄 반환 + + split_sentences()의 _HEADER_ONLY 필터가 제목 줄을 차단하므로 + 반드시 predict() 와 별도로, 원문에 대해 호출할 것. + + 사용 예: + title = extract_title(notice_text) + items = predict(notice_text, source=filename) + """ + lines = [line.strip() for line in notice_text.splitlines() if line.strip()] + + if _load_title_model(): + best_line: Optional[str] = None + best_prob = TITLE_THRESHOLD + for line in lines: + if len(line) < 10: + continue + inputs = _title_tokenizer( + line, return_tensors="pt", truncation=True, max_length=128 + ).to(_device) + with torch.no_grad(): + prob = float(torch.softmax(_title_model(**inputs).logits, dim=-1)[0][1].item()) + if prob > best_prob: + best_prob = prob + best_line = line + return best_line + + # heuristic fallback + for line in lines: + if is_title_heuristic(line): + return line + return None + + +# ───────────────────────────────────────── +# 1. 모델 로드 (lazy, 최초 1회) +# ───────────────────────────────────────── +# CPU 시연 환경을 위해 small 변형 사용 +_BASE_MODEL_ID = "yunjeong116/koelectra-extractor" # HF Hub 파인튜닝 모델 +# HF Hub repo 내 모델 파일이 koelectra-extractor/ 서브폴더에 위치 +_HF_SUBFOLDER = "koelectra-extractor" +_LOCAL_CHECKPOINT_DIR = os.path.join( + os.path.dirname(__file__), "..", "checkpoints", "koelectra-binary" +) # file/../checkpoints = extraction/checkpoints (이전: file/checkpoints — 경로 오류 수정) +# label-1 (할 일) 확률 임계값 — v2 평가(2026-04-30) 최적값 0.65로 업데이트 +BINARY_THRESHOLD = 0.65 + +_tokenizer: Optional[AutoTokenizer] = None +_model: Optional[AutoModelForSequenceClassification] = None +_device = "cuda" if torch.cuda.is_available() else "cpu" + + +def _load_model() -> None: + global _tokenizer, _model + if _model is not None: + return + + # 로컬 파인튜닝 체크포인트 우선, 없으면 HF Hub 모델 + # weights + config 둘 다 있어야 로컬 사용 (config 없으면 로드 실패) + _local_ready = ( + any( + os.path.exists(os.path.join(_LOCAL_CHECKPOINT_DIR, fname)) + for fname in ("pytorch_model.bin", "model.safetensors") + ) + and os.path.exists(os.path.join(_LOCAL_CHECKPOINT_DIR, "config.json")) + ) + + if _local_ready: + _tokenizer = AutoTokenizer.from_pretrained(_LOCAL_CHECKPOINT_DIR) + _model = AutoModelForSequenceClassification.from_pretrained( + _LOCAL_CHECKPOINT_DIR, num_labels=2 + ) + else: + # HF Hub: 파일이 koelectra-extractor/ 서브폴더에 위치 + _tokenizer = AutoTokenizer.from_pretrained( + _BASE_MODEL_ID, subfolder=_HF_SUBFOLDER + ) + _model = AutoModelForSequenceClassification.from_pretrained( + _BASE_MODEL_ID, num_labels=2, subfolder=_HF_SUBFOLDER + ) + _model.to(_device) + _model.eval() + + +# ───────────────────────────────────────── +# 2. PDF 줄 끊김 복원 +# ───────────────────────────────────────── +def _join_broken_lines(text: str) -> str: + """PDF 추출 시 발생하는 단어 중간 줄 끊김 복원. + "다문화가정 학\\n생" → "다문화가정 학생" + "지원하기\\n위해" → "지원하기 위해" + """ + text = re.sub(r" ([가-힣])\n([가-힣])", r" \1\2", text) # 단어 중간 끊김 + text = re.sub(r"([^.!?\n])\n([^\n])", r"\1 \2", text) # 문장 이어짐 + text = re.sub(r"[ \t]+", " ", text) + return text.strip() + + +# ───────────────────────────────────────── +# 2-1. 특수기호 정제 (문장 단위) +# ───────────────────────────────────────── +# split_sentences()가 ◆●▪○ 등을 분리 기준으로 사용하므로 +# 마커 제거는 split 이후 문장 단위로 수행 — 학습 데이터 clean_text()와 동일 정제 +_SYMBOL_PATTERN = re.compile(r"[▪▫▸▹◆◇●○◎□■★☆※◁▷△▽→←↑↓·•…❏‧∙∘․]+") +_CIRCLE_NUM_PATTERN = re.compile(r"[①②③④⑤⑥⑦⑧⑨⑩➊➋➌➍➎➏]") + + +def _clean_symbols(sentence: str) -> str: + sentence = _SYMBOL_PATTERN.sub(" ", sentence) + sentence = _CIRCLE_NUM_PATTERN.sub("", sentence) + return re.sub(r"\s+", " ", sentence).strip() + + +# ───────────────────────────────────────── +# 3. 문장 분리 +# ───────────────────────────────────────── +_HEADER_ONLY = re.compile( + r"^[^.,!?~]{2,40}(안내|공지|알림|공개수업|상담|학습|행사|일정)\s*$" +) + +# OCR 출력에서 줄 단위로 나타나는 노이즈 패턴 (문장이 될 수 없는 줄) +# 모든 패턴은 줄 시작(^) anchor — URL·전화·시간이 본문 안에 섞인 줄은 통과시킴 +_OCR_LINE_NOISE = re.compile( + r"^https?://" # URL 전용 줄 + r"|^www\." # 도메인 전용 줄 + r"|^☎\s*\d" # 전화번호 전용 줄 + r"|^\d{1,2}:\d{2}\s*[~\-–]\s*\d{1,2}:\d{2}\s*$" # 시간 범위만 있는 줄 + r"|^[→←↑↓]+\s*$" # 화살표 전용 줄 +) + + +def split_sentences(text: str) -> list[str]: + """OCR 추출 텍스트를 문장 리스트로 분리. 줄 단위 노이즈는 이 단계에서 차단.""" + lines = [line.strip() for line in text.splitlines() if line.strip()] + + sentences: list[str] = [] + for line in lines: + if _HEADER_ONLY.match(line) or _OCR_LINE_NOISE.search(line): + continue # 제목성 줄·OCR 아티팩트 줄 조기 차단 + parts = re.split( + r"(?<=[.!?])\s+|" + r"(?<=다\.)\s+|(?<=요\.)\s+|(?<=니다\.)\s+|" + r"(?<=까\?)\s+|(?<=요\?)\s+|" + r"\s+(?=\d+[.)]\s)|\s+(?=[가-힣]\.\s)|" + r"\s+(?=[❏○◆●▪◎□■])|" # 리스트 마커 앞 분리 + r"\s+(?=운영시간|운영방법|신청방법|신청기간|" + r"준비물|제출|기타\s*안내|접수방법|참가방법)", # 반복 레이블 앞 분리 + line, + ) + sentences.extend(parts) + + return [s.strip() for s in sentences if s.strip() and len(s.strip()) > 3] + + +# ───────────────────────────────────────── +# 3. 이진 분류 필터 (정규식 1차 → KoELECTRA 2차) +# ───────────────────────────────────────── +_NON_TODO_PATTERNS: list[str] = [ + r"^학부모님\s*안녕하십니까", + r"^안녕하십니까", + r"^학부모님\s*안녕하세요", # Bug 1 수정 + r"^안녕하세요", # Bug 1 수정 + r"^.*님\s*안녕하(세요|십니까)", # Bug 1 수정 (일반화) + r"^학부모님께\s*안내드립니다", + r"^학부모님께\s*드립니다", + r"안내드립니다\s*\.?\s*$", + r"드립니다\s*\.?\s*$", + r"^[^.,!?]{1,30}\s*안내\s*$", + r"서울갈산초등학교장$", + r"교장$", + r"^\d{4}\.\s*\d{1,2}\.\s*\d{1,2}\.?\s*$", + r"담당\s*[::]", + r"^\(.\s*\d{4}-\d{4}", + r"^08\d{3}\s*서울특별시", + r"공익제보센터", + r"자살예방상담", + r"청소년상담", +] + + +def _classify(sentence: str) -> Optional[float]: + """ + ① 정규식으로 명백한 노이즈 제외 → None 반환. + ② 통과 시 KoELECTRA label-1(할 일) 확률 반환 (0.0~1.0). + """ + if len(sentence) < 7: + return None + for pat in _NON_TODO_PATTERNS: + if re.search(pat, sentence): + return None + + _load_model() + inputs = _tokenizer( + sentence, + return_tensors="pt", + truncation=True, + padding=True, + max_length=128, + ).to(_device) + + with torch.no_grad(): + logits = _model(**inputs).logits + return float(torch.softmax(logits, dim=-1)[0][1].item()) + + +def is_likely_todo(sentence: str) -> bool: + """외부 호환용 래퍼. 임계값 이상이면 True.""" + prob = _classify(sentence) + return prob is not None and prob >= BINARY_THRESHOLD + + +# ───────────────────────────────────────── +# 4. 정규식 기반 구조 추출 +# ───────────────────────────────────────── +_DATE_ABS = re.compile( + r"(?:(\d{1,2})\s*월\s*(\d{1,2})\s*일)|" + r"(? Optional[str]: + """문장에서 마감일/일정 날짜 추출. 없으면 None.""" + m = _DATE_ABS.search(sentence) + if m: + month = m.group(1) or m.group(3) + day = m.group(2) or m.group(4) + if month and day: + try: + return f"{datetime.date.today().year}-{int(month):02d}-{int(day):02d}" + except ValueError: + pass + + m = _DATE_REL.search(sentence) + if m: + return m.group(1).strip() + + m = _DEADLINE.search(sentence) + if m: + deadline_text = m.group(1).strip() + if deadline_text and len(deadline_text) < 20: + return deadline_text + + return None + + +def extract_amount(sentence: str) -> Optional[int]: + """문장에서 첫 번째 금액(원) 추출. 없으면 None.""" + m = _MONEY.search(sentence) + if not m: + return None + try: + return int(m.group(1).replace(",", "")) + except ValueError: + return None + + +def extract_action_hint(sentence: str) -> Optional[str]: + """납부·제출·신청 등 행동 키워드 힌트 추출. 없으면 None.""" + for pattern, hint in _ACTION_PATTERNS: + if pattern.search(sentence): + return hint + return None + + +# ───────────────────────────────────────── +# 5. 메인 함수 +# ───────────────────────────────────────── +def predict(notice_text: str, source: Optional[str] = None) -> list[dict]: + """ + 가정통신문 원문 → 할 일 및 중요 일정 후보 문장 리스트. + + Args: + notice_text: OCR 모듈이 추출한 가정통신문 텍스트 str. + source: 원본 파일명 등 출처 식별자 (선택). + + Returns: + B단계(경이 모델) 입력 스키마: + [{"text", "source", "due_date", "amount", "confidence", "action_hint"}, ...] + """ + if not notice_text or not notice_text.strip(): + return [] + + notice_text = _join_broken_lines(notice_text) + results: list[dict] = [] + for sentence in split_sentences(notice_text): + sentence = _clean_symbols(sentence) + if not sentence: + continue + confidence = _classify(sentence) + if confidence is None or confidence < BINARY_THRESHOLD: + continue + results.append({ + "text": sentence, + "source": source, + "due_date": extract_due_date(sentence), + "amount": extract_amount(sentence), + "confidence": round(confidence, 4), + "action_hint": extract_action_hint(sentence), + }) + + return results + +# ───────────────────────────────────────── +# 6. 직접 실행 테스트 +# ───────────────────────────────────────── +if __name__ == "__main__": + # 실제 pdfplumber OCR 출력 형식 (sample_pdfplumber.txt 발췌) + sample = """다문화 학부모님 +2023.3.6.(월) 다문화가정 학생(학부모)을 위한 +온라인 한국어학습 신청 안내 +안녕하세요? 사랑합니다. +의정부교육지원청에서 다문화가정 학생의 학습 격차 해소와 학부모님의 한국 생활 조기 정착을 지원하기 +위해 온라인에서 한국어를 학습할 수 있는 프로그램을 무료로 제공합니다. +모국어를 사용하는 강사가 단계별로 친절히 가르치는 동영상 강의(VOD)를 PC나 모바일 기기로 접속하여 +언제든지 원하는 장소에서 편리하게 학습할 수 있는 좋은 기회이오니, 한국어 학습이 필요한 다문화가정 학 +생 및 학부모(보호자) 모두 기한 내 신청하여 주시기 바랍니다. +2. 신청 기간: 2023. 3. 6. (월) ~ 3. 17. (금) 정기 모집 기간 신청 시 3.20.(월)까지 승인 +5. 신청 관련 문의: ☎031-627-7916 (한컴지니케이/살랑코리아) +* 자세한 사항은 3월 20일에 학습자들에게 E-mail로 개별 공지 예정입니다. +http://bit.ly/sarlang www.sarlang.com +의정부신곡초등학교장""" + + # print("=" * 60) + # print("A단계 추출 결과 — OCR 텍스트 입력 (B단계 입력용)") + # print("=" * 60) + # candidates = predict(sample, source="sample_pdfplumber.txt") + # for i, item in enumerate(candidates, 1): + # print(f"\n{i}. {item['text']}") + # print(f" source : {item['source']}") + # print(f" due_date : {item['due_date']}") + # print(f" amount : {item['amount']}") + # print(f" confidence : {item['confidence']}") + # print(f" action_hint: {item['action_hint']}") + # print(f"\n총 {len(candidates)}개 후보 문장 추출") diff --git a/model/extraction/file/preprocess_txt_to_jsonl.py b/model/extraction/file/preprocess_txt_to_jsonl.py new file mode 100644 index 0000000000000000000000000000000000000000..be6bd13f2510feb00a3899298bcc95506b1bdc82 --- /dev/null +++ b/model/extraction/file/preprocess_txt_to_jsonl.py @@ -0,0 +1,341 @@ +""" +preprocess_schema_matched.py +============================ +PDF 추출 텍스트(.txt) -> notices_original2.jsonl 스키마 변환 + +[처리 흐름] + 1. 텍스트 전체 로드 + 2. join_broken_lines(): PDF 줄 중간 끊김 복원 + - 단어 중간 끊김: "다문화가정 학\n생" -> "다문화가정 학생" (공백 없이) + - 문장 이어짐 : "지원하기\n위해" -> "지원하기 위해" (공백으로) + - 문장 종결 후 : "제공합니다.\n..." -> 줄바꿈 유지 + 3. kss.split_sentences() 로 문장 분리 + - kss 미설치 시 predict.py 의 split_sentences() 로 자동 대체 + 4. 문장별 JSONL 레코드 생성 (notices_original2.jsonl 스키마) + 5. 저장 + +[출력 스키마 - 문장 단위] + {"id":1, "source_type":"초등학교", "original_text":"<문장>", + "category":"", "keywords":"", ...} + + ※ keywords 필드를 채워야 학습 라벨이 생성됩니다. + - 해당 문장이 할 일이면: keywords = 문장 그대로 복사 + - 노이즈면 : keywords = "" (비워두기) + +[사용법] + # 기본 (data/ 폴더 안의 파일 사용) + python file/preprocess_schema_matched.py + + # 경로 직접 지정 + python file/preprocess_schema_matched.py \\ + --input data/sample_pdfplumber.txt \\ + --output data/notices_original2.jsonl \\ + --source_type 초등학교 +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +# Windows 터미널 UTF-8 출력 +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +# ── 기본 경로 (스크립트 위치 기준) ────────────────────────────────────────────── +_HERE = Path(__file__).resolve().parent # .../file/ +_DATA = _HERE.parent / "data" # .../data/ + +DEFAULT_INPUT = _DATA / "sample_pdfplumber.txt" +DEFAULT_INPUT_DIR = _DATA / "galsan_txt" +DEFAULT_OUTPUT = _DATA / "notices_original2.jsonl" + +# ───────────────────────────────────────────────────────────────────────────── +# 0. 급식 파일 감지 — 파일명 또는 내용 기반 +# ───────────────────────────────────────────────────────────────────────────── +# 파일명 기반 스킵 없음 — 내용 기반 패턴만 사용 +# (파일명에 '급식'이 있어도 정보 전달 목적 파일은 처리해야 하므로) +_MEAL_NAME_KEYWORDS: list[str] = [] + +# 내용에 이 패턴 중 하나라도 있으면 급식표로 판단 → 스킵 +# 모두 실제 식단표에만 등장하는 패턴 (공사 안내·의견조사·알레르기 조사 등에는 없음) +_MEAL_CONTENT_PATTERNS: list[re.Pattern] = [ + re.compile(r"에너지/단백질"), # 급식 영양표 헤더 + re.compile(r"①난류\s*②우유"), # 알레르기 번호 리스트 + re.compile(r"무상급식비\s*:"), # 급식비 안내 + re.compile(r"\d+회\s*무상급식"), # "20회 무상급식비" +] + + +def is_meal_schedule(path: Path, content: str) -> bool: + """파일명 또는 내용 기반으로 급식 파일 여부 판별""" + # 1차: 파일명 확인 (빠름) + name = path.stem # 확장자 제외 파일명 + if any(kw in name for kw in _MEAL_NAME_KEYWORDS): + return True + # 2차: 내용 확인 (파일명에 '급식'이 없어도 내용에 급식 표가 있는 경우) + return any(pat.search(content) for pat in _MEAL_CONTENT_PATTERNS) + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. PDF 줄 중간 끊김 복원 +# ───────────────────────────────────────────────────────────────────────────── +def join_broken_lines(text: str) -> str: + """ + PDF 추출 시 발생하는 두 종류의 줄 끊김을 복원합니다. + + 규칙 1 - 단어 중간 끊김 (공백 없이 연결): + 공백 뒤에 오는 한글 + 줄바꿈 + 한글 + 예: "다문화가정 학\\n생" -> "다문화가정 학생" + 이유: 마지막 한글 앞에 공백이 있으면 그 한글이 단어의 첫 글자 + -> 줄바꿈이 단어를 끊은 것이므로 공백 없이 붙임 + + 규칙 2 - 문장 이어짐 (공백으로 연결): + 종결 부호(.!?) 없이 끝나는 줄 + 줄바꿈 + 다음 줄 + 예: "지원하기\\n위해" -> "지원하기 위해" + + 규칙 3 - 문장 종결 후 (줄바꿈 유지): + 종결 부호(.!?)로 끝나는 줄 -> 줄바꿈 그대로 + 예: "제공합니다.\\n모국어를" -> 변경 없음 + + 규칙 0 (선행) - 날짜/숫자 분절 복원: + 숫자마침표로 끝나는 줄 -> 줄바꿈을 공백으로 이어줌 + 예: "2026.\\n~ 11." -> "2026. ~ 11." + 이유: 한국식 날짜(2026.~11.30.)는 마침표가 구분자이므로 + 규칙 3이 적용되면 날짜가 조각남 + """ + # 규칙 0: 숫자마침표로 끝나는 줄 → 공백으로 이어줌 (날짜 분절 복원) + text = re.sub(r"(\d+\.)\n", r"\1 ", text) + + # 규칙 1: 공백 + 한글 + 줄바꿈 + 한글 -> 공백 + (두 한글 합침) + text = re.sub(r" ([가-힣])\n([가-힣])", r" \1\2", text) + + # 규칙 2: 종결 부호 없이 끝나는 줄 -> 공백으로 이어줌 + # ([^.!?\n] = 줄바꿈도 종결 부호도 아닌 임의의 글자) + text = re.sub(r"([^.!?\n])\n([^\n])", r"\1 \2", text) + + # 연속 공백 정리 + text = re.sub(r"[ \t]+", " ", text) + return text.strip() + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. 기본 텍스트 정리 +# ───────────────────────────────────────────────────────────────────────────── +def clean_text(text: str) -> str: + """null byte / 캐리지 리턴 / 연속 공백 정리 (기호 정제는 split 이후 clean_sentence 에서)""" + text = re.sub(r"\x00", "", text) # pdfplumber null byte 잔여물 + text = text.replace("\r", "") # Windows CR + text = re.sub(r"[ \t]+", " ", text) + return text.strip() + + +# 특정 기호 → ASCII 대응 문자로 변환 +_NORMALIZE_TABLE = str.maketrans({ + '‘': "'", '’': "'", # ' ' → ' + '“': '"', '”': '"', # " " → " + '「': '"', '」': '"', # 「」 → " + '『': '"', '』': '"', # 『』 → " + '【': '(', '】': ')', # 【】 → () + '〔': '(', '〕': ')', # 〔〕 → () + '「': '"', '」': '"', # 「」 → " + '–': '-', '—': '-', # –— → - + '~': '~', '∼': '~', # ~∼ → ~ + ',': ',', # , → , + '×': 'x', # × → x + '·': ' ', '・': ' ', # ·・ → 공백 + '・': ' ', '〃': ' ', # ・〃 → 공백 + '…': '...', # … → ... + '­': '', # soft hyphen → 제거 + '₩': '', # ₩ → 제거 +}) + +# 한글/영숫자/허용 구두점 이외의 모든 기호를 공백으로 대체 +_SYMBOL_REMOVE_RE = re.compile( + "[^가-힣" # 한글 완성형 + "㄰-㆏" # 한글 자모 + "a-zA-Z0-9" # 영숫자 + " \\t.,!?():/%@~&_\\-'\"]" # 허용 구두점 + 공백 +) + + +def clean_sentence(sentence: str) -> str: + """문장 단위 기호 정제: 정규화 후 허용 문자 외 기호 공백으로 대체""" + sentence = sentence.translate(_NORMALIZE_TABLE) + sentence = _SYMBOL_REMOVE_RE.sub(' ', sentence) + return re.sub(r"\s+", " ", sentence).strip() + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. 문장 분리 (predict.py 의 split_sentences 사용, 최초 1회만 로드) +# ───────────────────────────────────────────────────────────────────────────── +_predict_mod = None # 모듈 캐시 — exec_module 은 최초 1회만 실행 + + +def _get_split_fn(): + """predict.split_sentences 를 반환. 실패 시 정규식 fallback 반환.""" + global _predict_mod + if _predict_mod is None: + import importlib.util + spec = importlib.util.spec_from_file_location("predict", _HERE / "predict.py") + if spec is not None: + _predict_mod = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(_predict_mod) + print(" predict.split_sentences 로드 완료 (이후 재사용)") + except Exception as e: + print(f" [경고] predict.py 로드 실패: {e}") + _predict_mod = None + + if _predict_mod is not None: + return _predict_mod.split_sentences + + # 최후 fallback: 마침표/물음표/느낌표 기준 단순 분리 + def _simple_split(text: str) -> list[str]: + parts = re.split( + r"(? 3] + return _simple_split + + +def split_into_sentences(text: str) -> list[str]: + return _get_split_fn()(text) + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. JSONL 레코드 생성 +# ───────────────────────────────────────────────────────────────────────────── +def build_record(sentence: str) -> dict: + """ + train_koelectra.ipynb 의 문장 단위 포맷 (text + is_todo) 으로 반환. + is_todo 기본값은 false — 라벨링 시 할 일 문장만 true 로 바꾸면 됩니다. + """ + return { + "text": sentence, + "is_todo": False, # 할 일 문장이면 True 로 수정 + } + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. 메인 변환 함수 +# ───────────────────────────────────────────────────────────────────────────── +def preprocess_to_custom_schema( + input_path: Path, + output_path: Path, + append: bool = False, +) -> int: + """ + 단일 txt 파일을 처리해 JSONL 에 추가. + + Returns: + 추가된 문장 수. 급식 파일이면 -1 반환. + """ + raw = input_path.read_text(encoding="utf-8") + + if is_meal_schedule(input_path, raw): + print(f" [스킵] 급식 파일 감지: {input_path.name}") + return -1 + + joined = join_broken_lines(raw) + pre_cleaned = clean_text(joined) # null byte / CR / 공백만 + + raw_sents = split_into_sentences(pre_cleaned) # 마커 기준 분리 먼저 + sentences = [ + clean_sentence(s) for s in raw_sents # 분리 후 기호 정제 + if len(clean_sentence(s)) > 3 + ] + + mode = "a" if append else "w" + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open(mode, encoding="utf-8") as f: + for sent in sentences: + f.write(json.dumps(build_record(sent), ensure_ascii=False) + "\n") + + return len(sentences) + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. CLI +# ───────────────────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser( + description="PDF 추출 텍스트 -> JSONL 변환 (급식 파일 자동 스킵)" + ) + parser.add_argument( + "--input", + type=Path, + default=None, + help="단일 txt 파일 처리", + ) + parser.add_argument( + "--input_dir", + type=Path, + default=None, + help=f"폴더 내 모든 txt 파일 일괄 처리 (기본: {DEFAULT_INPUT_DIR})", + ) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"출력 JSONL 파일 (기본: {DEFAULT_OUTPUT})", + ) + parser.add_argument( + "--append", + action="store_true", + help="기존 JSONL 파일에 이어쓰기", + ) + args = parser.parse_args() + + # ── 입력 파일 목록 결정 ──────────────────────────────────────────────────── + if args.input: + if not args.input.exists(): + print(f"[오류] 파일 없음: {args.input}", file=sys.stderr) + sys.exit(1) + txt_files = [args.input] + else: + target_dir = args.input_dir or DEFAULT_INPUT_DIR + if not target_dir.exists(): + print(f"[오류] 폴더 없음: {target_dir}", file=sys.stderr) + sys.exit(1) + txt_files = sorted(target_dir.glob("*.txt")) + print(f"폴더: {target_dir} ({len(txt_files)}개 txt 파일 발견)\n") + + # ── 일괄 처리 ────────────────────────────────────────────────────────────── + total_sentences = 0 + skipped = [] + + for i, txt_path in enumerate(txt_files): + # 첫 파일은 append 여부를 그대로 사용, 이후는 항상 이어쓰기 + use_append = args.append if i == 0 else True + + result = preprocess_to_custom_schema( + input_path=txt_path, + output_path=args.output, + append=use_append, + ) + + if result == -1: + skipped.append(txt_path.name) + else: + print(f" [완료] {txt_path.name} ({result}개 문장)") + total_sentences += result + + # ── 최종 요약 ────────────────────────────────────────────────────────────── + print("\n" + "=" * 55) + print(f"처리 완료: {len(txt_files) - len(skipped)}개 파일 | {total_sentences}개 문장") + print(f"스킵 (급식): {len(skipped)}개 파일") + if skipped: + for name in skipped: + print(f" - {name}") + print(f"저장 위치: {args.output}") + print("=" * 55) + print("TIP: is_todo 필드를 채워야 학습 라벨이 생성됩니다.") + print(" 할 일 문장 -> is_todo = true") + + +if __name__ == "__main__": + main() diff --git a/model/extraction/file/requirements-extraction.txt b/model/extraction/file/requirements-extraction.txt new file mode 100644 index 0000000000000000000000000000000000000000..60be314d10a435751bb9a367e313c847ff3775e7 --- /dev/null +++ b/model/extraction/file/requirements-extraction.txt @@ -0,0 +1,3 @@ +torch +transformers +huggingface_hub diff --git a/model/extraction/file/test_out.jsonl b/model/extraction/file/test_out.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..bddd4a86c26c3f36f3c0398e3a5caa4f90090720 --- /dev/null +++ b/model/extraction/file/test_out.jsonl @@ -0,0 +1,13 @@ +{"text": "제2023 – 11 호 - 의정부신곡초 교육통신 다문화 학부모님 2023.3.6.(월) 다문화가정학생(학부모)을위한 교 함께 가꾸고 배우며 어울리는 온라인한국어학습신청안내 교무실 849–7003 훈 의정부신곡초 행복 어린이 안녕하세요?", "is_todo": false} +{"text": "사랑합니다.", "is_todo": false} +{"text": "의정부교육지원청에서 다문화가정 학생의 학습 격차 해소와 학부모님의 한국 생활 조기 정착을 지원하기 위해 온라인에서 한국어를 학습할 수 있는 프로그램을 무료로 제공합니다.", "is_todo": false} +{"text": "모국어를 사용하는 강사가 단계별로 친절히 가르치는 동영상 강의(VOD)를 PC나 모바일 기기로 접속하여 언제든지 원하는 장소에서 편리하게 학습할 수 있는 좋은 기회이오니, 한국어 학습이 필요한 다문화가정 학생 및 학부모(보호자) 모두 기한 내 신청하여 주시기 바랍니다.", "is_todo": false} +{"text": "1. 온라인 한국어 교육 콘텐츠 프로그램 안내 가.", "is_todo": false} +{"text": "학습형태: 모국어", "is_todo": false} +{"text": "기반 동영상 학습 (VOD 서비스) _ 모국어로 한국어 배우기 나.", "is_todo": false} +{"text": "학습언어: 6개 언어(중국어, 베트남어, 영어, 일본어, 몽골어, 러시아) 다.", "is_todo": false} +{"text": "학습내용: 초급 한국어 어휘, 문법, 대화 등, 강좌당 10분 내외 라.", "is_todo": false} +{"text": "수업에 필요한 워크북 및 복습 학습지(PDF 파일)", "is_todo": false} +{"text": "제공 마.", "is_todo": false} +{"text": "진도 및 학습 관리 (2주에 한 번씩 진도 알림) 1) 단톡방(그룹채팅): 한국어 학습 관련 내용 자유롭게 질의 응답 2) 온라인 멘토링: 온라인으로 같은 언어권 친구들과 함께 수업 (학기 중- 주 1회, 방학 중- 격주 1회) 2. 신청 기간: : 2023. 3. 6. (월) ~ 3. 17. (금) → 정기 모집 기간 신청 시 3.20.(월)까지 승인 - 상시 신청 : 정기 모집 기간에 신청하지 못한 사람은 수시 신청 가능하나, 이때는 2주 단위로 승인 예정 3. 신청서 작성 및 학습 사이트 미리보기 신청서 작성 (온라인) 학습 사이트 미리보기 http://bit.ly/sarlang www.sarlang.com 4. 일정안내 신청서 작성 → 학습자 안내 → 온라인 설명회 → 온라인 학습 2023. 3. 6.(월)~ 2023. 3. 23.(목) 2023. 3.\n2023. 3. 20.(월) 3. 17.(금) 19:00~20:00(예정) ~2024. 12.\n학생, 업체→신청자 온라인 ZOOM 개인 장소 학부모(보호자) 메일과 문자로 개별 안내 5. 신청 관련 문의: ☎031-627-7916 (한컴지니케이/살랑코리아) * 자세한 사항은 3월 20일에 학습자들에게 E-mail로 개별 공지 예정입니다.", "is_todo": false} +{"text": "의정부신곡초등학교장", "is_todo": false} diff --git a/model/extraction/file/test_output.jsonl b/model/extraction/file/test_output.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..e1e2227740ef5e66b105b217cdedb78517742267 --- /dev/null +++ b/model/extraction/file/test_output.jsonl @@ -0,0 +1 @@ +{"id": 1, "source_type": "초등학교", "original_text": "제2023 – 11 호 - 의정부신곡초 교육통신 다문화 학부모님 2023.3.6.(월) 다문화가정\u0000학생(학부모)을\u0000위한\u0000 교 함께 가꾸고 배우며 어울리는 온라인\u0000한국어학습\u0000신청\u0000안내 교무실 849–7003 훈 의정부신곡초 행복 어린이 안녕하세요? 사랑합니다.\n의정부교육지원청에서 다문화가정 학생의 학습 격차 해소와 학부모님의 한국 생활 조기 정착을 지원하기 위해 온라인에서 한국어를 학습할 수 있는 프로그램을 무료로 제공합니다.\n모국어를 사용하는 강사가 단계별로 친절히 가르치는 동영상 강의(VOD)를 PC나 모바일 기기로 접속하여 언제든지 원하는 장소에서 편리하게 학습할 수 있는 좋은 기회이오니, 한국어 학습이 필요한 다문화가정 학생 및 학부모(보호자) 모두 기한 내 신청하여 주시기 바랍니다.\n1. 온라인 한국어 교육 콘텐츠 프로그램 안내 가. 학습형태: 모국어 기반 동영상 학습 (VOD 서비스) _ 모국어로 한국어 배우기 나. 학습언어: 6개 언어(중국어, 베트남어, 영어, 일본어, 몽골어, 러시아) 다. 학습내용: 초급 한국어 어휘, 문법, 대화 등, 강좌당 10분 내외 라. 수업에 필요한 워크북 및 복습 학습지(PDF 파일) 제공 마. 진도 및 학습 관리 (2주에 한 번씩 진도 알림) 1) 단톡방(그룹채팅): 한국어 학습 관련 내용 자유롭게 질의 응답 2) 온라인 멘토링: 온라인으로 같은 언어권 친구들과 함께 수업 (학기 중- 주 1회, 방학 중- 격주 1회) 2. 신청 기간: : 2023. 3. 6. (월) ~ 3. 17. (금) → 정기 모집 기간 신청 시 3.20.(월)까지 승인 - 상시 신청 : 정기 모집 기간에 신청하지 못한 사람은 수시 신청 가능하나, 이때는 2주 단위로 승인 예정 3. 신청서 작성 및 학습 사이트 미리보기 신청서 작성 (온라인) 학습 사이트 미리보기 http://bit.ly/sarlang www.sarlang.com 4. 일정안내 신청서 작성 → 학습자 안내 → 온라인 설명회 → 온라인 학습 2023. 3. 6.(월)~ 2023. 3. 23.(목) 2023. 3.\n2023. 3. 20.(월) 3. 17.(금) 19:00~20:00(예정) ~2024. 12.\n학생, 업체→신청자 온라인 ZOOM 개인 장소 학부모(보호자) 메일과 문자로 개별 안내 5. 신청 관련 문의: ☎031-627-7916 (한컴지니케이/살랑코리아) * 자세한 사항은 3월 20일에 학습자들에게 E-mail로 개별 공지 예정입니다.\n의정부신곡초등학교장", "category": "", "keywords": "", "importance": "", "action_required": "", "easy_korean": "", "vietnamese": "", "tts_target": ""} diff --git a/model/extraction/file/train_koelectra.ipynb b/model/extraction/file/train_koelectra.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..269cf6e00ef48dc2d5bfb0518029f1c41f0fa8be --- /dev/null +++ b/model/extraction/file/train_koelectra.ipynb @@ -0,0 +1,452 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": "# 윤정 추출 모델 — KoELECTRA 이진 분류 파인튜닝 (A단계)\n\n**목표**: 가정통신문 문장을 받아 `할 일·중요 일정(1)` vs `노이즈(0)` 로 이진 분류\n\n| 항목 | 내용 |\n|------|------|\n| 모델 | `monologg/koelectra-small-v3-discriminator` (CPU 시연용 small 변형) |\n| 데이터 | `v3_dual_labeled_clean.jsonl` — 문장 단위 `{text, is_todo, is_title}`, 27,800행 |\n| 출력 클래스 | 0: 노이즈, 1: 할 일·중요 일정 |\n| 카테고리 분류·중요도 | **B단계(경이 모델)** 로 완전 위임 — 이 노트북에서 제거됨 |\n\n**실행 방법**\n1. `런타임` → `런타임 유형 변경` → **T4 GPU** 선택\n2. 셀을 위에서부터 순서대로 실행 (`Shift + Enter`)\n3. 마지막 셀에서 `koelectra-binary.zip` 다운로드 → `model/extraction/checkpoints/koelectra-binary/` 에 압축 풀기" + }, + { + "cell_type": "markdown", + "id": "cell-1", + "metadata": {}, + "source": [ + "## 1. 라이브러리 설치\n", + "코랩 기본 transformers는 버전이 오래됐으므로 새로 설치합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-2", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install -q transformers==4.44.2 datasets==2.21.0 accelerate==0.34.0 evaluate==0.4.3 scikit-learn" + ] + }, + { + "cell_type": "markdown", + "id": "cell-3", + "metadata": {}, + "source": [ + "## 2. GPU 확인\n", + "T4 GPU가 잡혔는지 확인합니다. CPU만 보이면 런타임 유형을 바꿔야 합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-4", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "print('CUDA 사용 가능:', torch.cuda.is_available())\n", + "if torch.cuda.is_available():\n", + " print('GPU 이름:', torch.cuda.get_device_name(0))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-5", + "metadata": {}, + "source": "## 3. 데이터 로드\n\n`v3_dual_labeled_clean.jsonl` 포맷: `{\"text\": str, \"is_todo\": bool, \"is_title\": bool}`\n\n- `is_todo` 필드만 라벨로 사용 (`is_title` 은 무시)\n- 10자 미만 / 5,000자 초과는 clean 파일에서 이미 제거됨\n- 정제 후 약 27,800행" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-6", + "metadata": {}, + "outputs": [], + "source": "import json\nimport re\nfrom collections import Counter\n\n# ── predict.py 와 동일한 패턴 유지 (변경 시 양쪽 동기화 필요) ────────────────\n_HEADER_ONLY = re.compile(\n r'^[^.,!?~]{2,40}(안내|공지|알림|공개수업|상담|학습|행사|일정)\\s*$'\n)\n\n_OCR_LINE_NOISE = re.compile(\n r'https?://'\n r'|^www\\.'\n r'|☎\\s*\\d'\n r'|^\\d{1,2}:\\d{2}\\s*[~\\-–]\\s*\\d{1,2}:\\d{2}'\n r'|^[→←↑↓]+\\s*$'\n)\n\nNON_TODO_PATTERNS: list[str] = [\n r'^학부모님\\s*안녕하십니까',\n r'^안녕하십니까',\n r'^학부모님\\s*안녕하세요',\n r'^안녕하세요',\n r'^.*님\\s*안녕하(세요|십니까)',\n r'^학부모님께\\s*안내드립니다',\n r'^학부모님께\\s*드립니다',\n r'안내드립니다\\s*\\.?\\s*$',\n r'드립니다\\s*\\.?\\s*$',\n r'^[^.,!?]{1,30}\\s*안내\\s*$',\n r'서울갈산초등학교장$',\n r'교장$',\n r'^\\d{4}\\.\\s*\\d{1,2}\\.\\s*\\d{1,2}\\.?\\s*$',\n r'담당\\s*[::]',\n r'^\\(.\\s*\\d{4}-\\d{4}',\n r'^08\\d{3}\\s*서울특별시',\n r'공익제보센터',\n r'자살예방상담',\n r'청소년상담',\n]\n\n# ── 데이터 로드 ───────────────────────────────────────────────────────────────\n# v3_dual_labeled_clean.jsonl: {text, is_todo, is_title} 문장 단위 포맷\n# is_title 은 이 노트북에서 사용하지 않음 (is_todo 분류기만 학습)\nwith open('v3_dual_labeled_clean.jsonl', encoding='utf-8') as f:\n rows = [json.loads(line) for line in f if line.strip()]\n\ntexts: list[str] = []\nlabels: list[int] = []\n\nfor row in rows:\n text = str(row.get('text', '') or '').strip()\n if len(text) < 7:\n continue\n texts.append(text)\n labels.append(int(bool(row.get('is_todo', False))))\n\ncnt = Counter(labels)\nprint(f'총 문장 수 : {len(texts)}')\nprint(f'라벨 분포:')\nprint(f' 0 (노이즈) : {cnt[0]} ({cnt[0]/len(labels)*100:.1f}%)')\nprint(f' 1 (할 일) : {cnt[1]} ({cnt[1]/len(labels)*100:.1f}%)')\n" + }, + { + "cell_type": "markdown", + "id": "cell-7", + "metadata": {}, + "source": [ + "## 4. Train/Val 분할\n", + "\n", + "**stratified split** 으로 이진 라벨 비율을 맞춰서 나눕니다. \n", + "데이터가 적을 경우 노이즈/할 일 비율이 검증셋에서 무너지는 것을 방지합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-8", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.model_selection import train_test_split\n", + "\n", + "train_texts, val_texts, train_labels, val_labels = train_test_split(\n", + " texts, labels,\n", + " test_size=0.2,\n", + " random_state=42,\n", + " stratify=labels,\n", + ")\n", + "\n", + "print(f'학습셋: {len(train_texts)}')\n", + "print(f' 양성(할 일): {sum(train_labels)}, 음성(노이즈): {len(train_labels) - sum(train_labels)}')\n", + "print(f'검증셋: {len(val_texts)}')\n", + "print(f' 양성(할 일): {sum(val_labels)}, 음성(노이즈): {len(val_labels) - sum(val_labels)}')" + ] + }, + { + "cell_type": "markdown", + "id": "cell-9", + "metadata": {}, + "source": [ + "## 5. 토크나이저 + 데이터셋\n", + "\n", + "small 변형은 base 대비 파라미터가 작아 CPU 추론 속도가 약 2배 빠릅니다. \n", + "가정통신문 한국어 문장 평균 30~80 토큰 → `max_length=128` 로 충분합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-10", + "metadata": {}, + "outputs": [], + "source": [ + "from transformers import AutoTokenizer\n", + "from datasets import Dataset\n", + "\n", + "MODEL_NAME = 'monologg/koelectra-small-v3-discriminator'\n", + "tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)\n", + "\n", + "\n", + "def encode(batch):\n", + " return tokenizer(\n", + " batch['text'],\n", + " truncation=True,\n", + " padding='max_length',\n", + " max_length=128,\n", + " )\n", + "\n", + "\n", + "train_ds = (\n", + " Dataset.from_dict({'text': train_texts, 'label': train_labels})\n", + " .map(encode, batched=True)\n", + " .remove_columns(['text'])\n", + ")\n", + "val_ds = (\n", + " Dataset.from_dict({'text': val_texts, 'label': val_labels})\n", + " .map(encode, batched=True)\n", + " .remove_columns(['text'])\n", + ")\n", + "\n", + "print('학습 데이터셋:', train_ds)\n", + "print('검증 데이터셋:', val_ds)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-11", + "metadata": {}, + "source": [ + "## 6. 모델 + 평가 지표 + 클래스 가중치\n", + "\n", + "이진 분류이므로 **Precision / Recall / F1** (양성 클래스 기준) 을 주요 지표로 봅니다. \n", + "노이즈 vs 할 일 비율이 불균형할 수 있으므로 `compute_class_weight('balanced')` 로 보정합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-12", + "metadata": {}, + "outputs": [], + "source": [ + "from transformers import AutoModelForSequenceClassification, Trainer\n", + "from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score\n", + "from sklearn.utils.class_weight import compute_class_weight\n", + "import numpy as np\n", + "import torch\n", + "\n", + "id2label = {0: '노이즈', 1: '할 일'}\n", + "label2id = {'노이즈': 0, '할 일': 1}\n", + "\n", + "model = AutoModelForSequenceClassification.from_pretrained(\n", + " MODEL_NAME,\n", + " num_labels=2,\n", + " id2label=id2label,\n", + " label2id=label2id,\n", + ")\n", + "\n", + "_w = compute_class_weight('balanced', classes=np.array([0, 1]), y=train_labels)\n", + "_class_weights = torch.tensor(_w, dtype=torch.float)\n", + "print(f'클래스 가중치: 노이즈={_w[0]:.3f}, 할 일={_w[1]:.3f}')\n", + "\n", + "\n", + "class WeightedTrainer(Trainer):\n", + " def compute_loss(self, model, inputs, return_outputs=False, **kwargs):\n", + " labels = inputs.get('labels')\n", + " outputs = model(**inputs)\n", + " logits = outputs.get('logits')\n", + " loss_fn = torch.nn.CrossEntropyLoss(weight=_class_weights.to(logits.device))\n", + " loss = loss_fn(logits, labels)\n", + " return (loss, outputs) if return_outputs else loss\n", + "\n", + "\n", + "def compute_metrics(eval_pred):\n", + " logits, labels = eval_pred\n", + " preds = np.argmax(logits, axis=-1)\n", + " return {\n", + " 'accuracy': accuracy_score(labels, preds),\n", + " 'f1': f1_score(labels, preds, pos_label=1, zero_division=0),\n", + " 'precision': precision_score(labels, preds, pos_label=1, zero_division=0),\n", + " 'recall': recall_score(labels, preds, pos_label=1, zero_division=0),\n", + " }" + ] + }, + { + "cell_type": "markdown", + "id": "cell-13", + "metadata": {}, + "source": [ + "## 7. 학습 실행\n", + "\n", + "에폭 10회, 배치 16. small 모델 기준 T4 GPU 약 5~10분. \n", + "`metric_for_best_model='f1'` — 할 일 클래스 F1이 가장 높은 체크포인트를 자동 보존합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-14", + "metadata": {}, + "outputs": [], + "source": [ + "from transformers import TrainingArguments, DataCollatorWithPadding\n", + "\n", + "args = TrainingArguments(\n", + " output_dir='./koelectra-binary-output',\n", + " save_safetensors=False,\n", + " num_train_epochs=10,\n", + " per_device_train_batch_size=16,\n", + " per_device_eval_batch_size=16,\n", + " learning_rate=2e-5,\n", + " weight_decay=0.01,\n", + " warmup_ratio=0.1,\n", + " lr_scheduler_type='cosine',\n", + " eval_strategy='epoch',\n", + " save_strategy='epoch',\n", + " logging_steps=10,\n", + " save_total_limit=2,\n", + " load_best_model_at_end=True,\n", + " metric_for_best_model='f1',\n", + " greater_is_better=True,\n", + " report_to='none',\n", + ")\n", + "\n", + "trainer = WeightedTrainer(\n", + " model=model,\n", + " args=args,\n", + " train_dataset=train_ds,\n", + " eval_dataset=val_ds,\n", + " tokenizer=tokenizer,\n", + " data_collator=DataCollatorWithPadding(tokenizer),\n", + " compute_metrics=compute_metrics,\n", + ")\n", + "\n", + "trainer.train()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-15", + "metadata": {}, + "source": [ + "## 8. 최종 평가\n", + "\n", + "검증셋에서 이진 분류 성능 확인. **할 일(1)** 클래스 F1이 발표 핵심 지표입니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-16", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.metrics import classification_report\n", + "\n", + "preds_out = trainer.predict(val_ds)\n", + "y_pred = np.argmax(preds_out.predictions, axis=-1)\n", + "y_true = preds_out.label_ids\n", + "\n", + "print(classification_report(\n", + " y_true, y_pred,\n", + " target_names=['노이즈', '할 일'],\n", + " digits=4,\n", + " zero_division=0,\n", + "))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-17", + "metadata": {}, + "source": [ + "## 9. 모델 저장\n", + "\n", + "`predict.py` 가 `checkpoints/koelectra-binary/` 를 자동 참조합니다. \n", + "이진 분류에서는 `labels.json` 이 불필요하므로 저장하지 않습니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-18", + "metadata": {}, + "outputs": [], + "source": [ + "OUTPUT_DIR = './koelectra-binary'\n", + "trainer.save_model(OUTPUT_DIR)\n", + "tokenizer.save_pretrained(OUTPUT_DIR)\n", + "\n", + "print('저장 완료:', OUTPUT_DIR)\n", + "!ls -lh {OUTPUT_DIR}" + ] + }, + { + "cell_type": "markdown", + "id": "cell-19", + "metadata": {}, + "source": [ + "## 10. 추론 테스트\n", + "\n", + "저장된 모델로 이진 추론을 검증합니다. \n", + "**OCR 아티팩트 줄 필터** (`_OCR_LINE_NOISE`) 동작도 함께 확인합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-20", + "metadata": {}, + "outputs": [], + "source": [ + "# ── [Fix 1] OCR 아티팩트 필터 검증 ────────────────────────────────────────────\n", + "ocr_cases = [\n", + " ('http://bit.ly/sarlang www.sarlang.com', True, 'URL 줄'),\n", + " ('☎031-627-7916 (한컴지니케이)', True, '전화번호 줄'),\n", + " ('19:00~20:00(예정)', True, '시간 범위 줄'),\n", + " ('→', True, '화살표 줄'),\n", + " ('3월 20일까지 신청서를 제출해주세요.', False, '정상 TODO 문장'),\n", + "]\n", + "\n", + "print('[ OCR 아티팩트 줄 필터 (_OCR_LINE_NOISE) ]')\n", + "for line, expected, label in ocr_cases:\n", + " got = bool(_OCR_LINE_NOISE.search(line))\n", + " ok = got == expected\n", + " print(f\" {'✅' if ok else '❌'} {label}: filtered={got}\")\n", + "\n", + "# ── 정규식 1차 필터 검증 ──────────────────────────────────────────────────────\n", + "def regex_filter_pass(sentence: str) -> bool:\n", + " if len(sentence) < 7:\n", + " return False\n", + " return not any(re.search(p, sentence) for p in NON_TODO_PATTERNS)\n", + "\n", + "\n", + "filter_cases = [\n", + " ('학부모님 안녕하세요.', False, '인사말 제외'),\n", + " ('안녕하세요.', False, '짧은 인사말'),\n", + " ('2026. 4. 17.', False, '날짜 서명'),\n", + " ('6월 5일까지 가정통신문 회신해주세요.', True, '제출 TODO'),\n", + " ('준비물: 실내화, 출입증 지참', True, '준비물 TODO'),\n", + " ('5월 1일은 학교자율휴업일입니다.', True, '휴업일 공지'),\n", + "]\n", + "\n", + "print('\\n[ 정규식 1차 필터 검증 (NON_TODO_PATTERNS) ]')\n", + "for sent, expected, label in filter_cases:\n", + " got = regex_filter_pass(sent)\n", + " ok = got == expected\n", + " print(f\" {'✅' if ok else '❌'} {label}: pass={got}\")\n", + "\n", + "# ── KoELECTRA 이진 추론 검증 ──────────────────────────────────────────────────\n", + "from transformers import AutoModelForSequenceClassification as AM\n", + "\n", + "BINARY_THRESHOLD = 0.5\n", + "_tok = AutoTokenizer.from_pretrained(OUTPUT_DIR)\n", + "_clf = AM.from_pretrained(OUTPUT_DIR, num_labels=2)\n", + "_clf.eval()\n", + "\n", + "\n", + "def binary_predict(sentence: str) -> tuple[int, float]:\n", + " \"\"\"(label, prob_할일) 반환\"\"\"\n", + " inputs = _tok(sentence, return_tensors='pt', truncation=True, max_length=128)\n", + " with torch.no_grad():\n", + " prob = torch.softmax(_clf(**inputs).logits, dim=-1)[0]\n", + " label = 1 if prob[1].item() >= BINARY_THRESHOLD else 0\n", + " return label, round(float(prob[1].item()), 3)\n", + "\n", + "\n", + "test_sents = [\n", + " '학부모님 안녕하세요.',\n", + " '4월 30일까지 체험학습 동의서를 제출해주세요.',\n", + " '준비물은 도시락과 물병입니다.',\n", + " '5월 1일은 학교자율휴업일입니다.',\n", + " '서울갈산초등학교장',\n", + "]\n", + "\n", + "print('\\n[ KoELECTRA 이진 추론 결과 ]')\n", + "print(f'{\"문장\":<42} {\"라벨\":>6} {\"P(할 일)\":>10}')\n", + "print('-' * 62)\n", + "for s in test_sents:\n", + " lbl, prob = binary_predict(s)\n", + " tag = '할 일' if lbl == 1 else '노이즈'\n", + " print(f'{s:<42} {tag:>6} {prob:>10.3f}')" + ] + }, + { + "cell_type": "markdown", + "id": "cell-21", + "metadata": {}, + "source": [ + "## 11. 압축 + 다운로드\n", + "\n", + "`koelectra-binary.zip` 을 `model/extraction/checkpoints/` 에서 풀면 `predict.py` 가 자동 로드합니다." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22", + "metadata": {}, + "outputs": [], + "source": [ + "!zip -r koelectra-binary.zip koelectra-binary/\n", + "\n", + "from google.colab import files\n", + "files.download('koelectra-binary.zip')" + ] + }, + { + "cell_type": "markdown", + "id": "cell-23", + "metadata": {}, + "source": "## 끝\n\n → 압축 풀기 → \n\n### 수정 내역\n\n| # | 문제 | 수정 내용 |\n|---|------|----------|\n| Fix 1 | split_sentences() OCR 필터 누락 | _OCR_LINE_NOISE 추가 — predict.py 와 동기화 |\n| Fix 2 | is_todo 문서 단위 적용 오류 | JSONL 포맷 자동 감지 — 문장/문서 단위 분기 처리 |\n| Fix 3 | 학습 데이터 v2 → v3 교체 | v3_dual_labeled_clean.jsonl (27,800행, is_todo+is_title 이중 라벨) |\n" + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/model/extraction/file/txt_to_jsonl.py b/model/extraction/file/txt_to_jsonl.py new file mode 100644 index 0000000000000000000000000000000000000000..afc874f7f11560365971c02cd6d319f8d41ccb33 --- /dev/null +++ b/model/extraction/file/txt_to_jsonl.py @@ -0,0 +1,272 @@ +""" +txt_to_jsonl.py +=============== +PDF 추출 텍스트 파일(.txt) → notices_original2.jsonl 스키마 변환 + +[전처리 파이프라인] + 1. join_broken_lines() PDF 추출 시 생기는 줄 중간 끊김 복원 + - 단어 중간 끊김: "다문화가정 학\n생" → "다문화가정 학생" (공백 없이) + - 문장 이어짐 : "지원하기\n위해" → "지원하기 위해" (공백으로) + - 문장 종결 후 : "제공합니다.\n..." → 줄바꿈 유지 + 2. clean_text() 연속 공백·줄바꿈 정규화 + 3. JSONL 저장 notices_original2.jsonl 스키마와 1:1 대응 + +[출력 스키마] + 문서 단위 포맷 (train_koelectra.ipynb이 자동 인식): + {"id":..., "source_type":..., "original_text":..., + "category":"", "keywords":"", "importance":"", + "action_required":"", "easy_korean":"", "vietnamese":"", "tts_target":""} + + ※ category / keywords 는 사후 수동 라벨링 필요 + +[사용법] + # 단일 파일 + python txt_to_jsonl.py --input data/sample_pdfplumber.txt \\ + --output data/notices_original2.jsonl \\ + --source_type 초등학교 + + # 여러 파일 → 기존 JSONL 에 이어쓰기 + python txt_to_jsonl.py --input data/*.txt \\ + --output data/notices_original2.jsonl \\ + --source_type 유치원 --append + + # 전처리 결과 미리보기 (저장 없음) + python txt_to_jsonl.py --input data/sample_pdfplumber.txt --preview +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +# Windows 터미널 UTF-8 출력 +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. 줄 중간 끊김 복원 +# ───────────────────────────────────────────────────────────────────────────── +def join_broken_lines(text: str) -> str: + """ + PDF 추출 텍스트에서 줄 중간에 끊긴 문장을 복원합니다. + + 규칙: + ① 공백 + [한글] + 줄바꿈 + [한글] + → 공백 + [두 한글 합침] (단어 중간 끊김 복원) + 예: "다문화가정 학\\n생" → "다문화가정 학생" + + ② [.!? 아닌 글자] + 줄바꿈 + [다음 줄 내용] + → 공백으로 연결 (문장 이어짐) + 예: "지원하기\\n위해" → "지원하기 위해" + + ③ [.!?] + 줄바꿈 + → 줄바꿈 유지 (문장 종결, 분리 보존) + 예: "제공합니다.\\n모국어를" → 변경 없음 + """ + # ① 단어 중간 끊김: 공백 뒤에 오는 한글이 줄바꿈 후 한글로 이어짐 + # (앞에 공백이 있다는 것 = 그 한글이 단어의 첫 글자 = 단어가 잘림) + text = re.sub(r' ([가-힣])\n([가-힣])', r' \1\2', text) + + # ② 문장 이어짐: 종결 부호 없이 끝나는 줄 → 다음 줄과 공백으로 연결 + # ([^.!?\n] = 줄바꿈도 종결 부호도 아닌 모든 글자) + text = re.sub(r'([^.!?\n])\n([^\n])', r'\1 \2', text) + + # 연속 공백 정리 + text = re.sub(r'[ \t]+', ' ', text) + + return text.strip() + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. 기본 텍스트 정리 +# ───────────────────────────────────────────────────────────────────────────── +def clean_text(text: str) -> str: + """ + 연속 공백 / 줄바꿈 / PDF 추출 잔여 바이트 정규화. + - null byte(), 캐리지 리턴( +) 제거 + - 연속 공백 -> 단일 공백 + - 3개 이상 줄바꿈 -> 두 줄바꿈 + """ + text = re.sub(r'', '', text) # null byte 제거 (pdfplumber 잔여물) + text = text.replace('', '') # Windows CR 제거 + text = re.sub(r'[ ]+', ' ', text) + text = re.sub(r'{3,}', '', text) + return text.strip() + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. JSONL 레코드 생성 +# ───────────────────────────────────────────────────────────────────────────── +def build_record( + record_id: int, + source_type: str, + original_text: str, +) -> dict: + """notices_original2.jsonl 스키마와 1:1 대응하는 레코드 반환""" + return { + "id": record_id, + "source_type": source_type, + "original_text": original_text, + "category": "", # 수동 라벨링 필요 + "keywords": "", # 수동 라벨링 필요 (학습 품질에 직결) + "importance": "", + "action_required": "", + "easy_korean": "", + "vietnamese": "", + "tts_target": "", + } + + +def process_file(txt_path: Path, source_type: str) -> tuple[str, str]: + """ + 텍스트 파일 1개를 전처리하여 (raw_text, clean_original_text) 반환. + preview 모드에서 두 값을 비교할 수 있도록 raw 도 함께 반환. + """ + raw = txt_path.read_text(encoding="utf-8") + cleaned = clean_text(join_broken_lines(raw)) + return raw, cleaned + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. 기존 JSONL 마지막 id 파악 (이어쓰기용) +# ───────────────────────────────────────────────────────────────────────────── +def last_id_in_jsonl(path: Path) -> int: + """기존 JSONL 파일의 마지막 id 반환. 파일 없으면 0.""" + if not path.exists(): + return 0 + last_id = 0 + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line: + try: + last_id = json.loads(line).get("id", last_id) + except json.JSONDecodeError: + continue + return last_id + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. 미리보기 (--preview) +# ───────────────────────────────────────────────────────────────────────────── +def preview(txt_path: Path) -> None: + """전처리 전후 비교 출력 (저장 없음)""" + raw, cleaned = process_file(txt_path, source_type="") + + print("=" * 70) + print(f"파일: {txt_path.name}") + print("=" * 70) + + print("\n[원본 텍스트 - 처음 10줄]") + for i, line in enumerate(raw.splitlines()[:10], 1): + print(f" {i:02d} │ {line}") + + print("\n[전처리 후 original_text - 처음 500자]") + print(f" {cleaned[:500]}") + if len(cleaned) > 500: + print(f" ... (총 {len(cleaned)}자)") + + print() + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. 변환 실행 +# ───────────────────────────────────────────────────────────────────────────── +def convert( + input_paths: list[Path], + output_path: Path, + source_type: str, + append: bool, +) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + + start_id = (last_id_in_jsonl(output_path) + 1) if append else 1 + mode = "a" if append else "w" + + written = 0 + with output_path.open(mode, encoding="utf-8") as f: + for txt_path in input_paths: + record_id = start_id + written + _, cleaned = process_file(txt_path, source_type) + record = build_record(record_id, source_type, cleaned) + f.write(json.dumps(record, ensure_ascii=False) + "\n") + print(f" [{record_id}] {txt_path.name} ({len(cleaned):,}자)") + written += 1 + + print(f"\n완료: {written}개 파일 → {output_path}") + print("⚠️ keywords 필드를 직접 채워야 학습 라벨이 정상 생성됩니다.") + + +# ───────────────────────────────────────────────────────────────────────────── +# 7. CLI +# ───────────────────────────────────────────────────────────────────────────── +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="PDF 추출 텍스트(.txt) → notices_original2.jsonl 변환", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + p.add_argument( + "--input", nargs="+", required=True, + help="입력 텍스트 파일 경로 (여러 개 가능, glob 사용 가능)", + ) + p.add_argument( + "--output", default=None, + help="출력 JSONL 파일 경로 (--preview 시 불필요)", + ) + p.add_argument( + "--source_type", default="초등학교", + help="문서 출처 (예: 유치원, 초등학교, 중학교). 기본값: 초등학교", + ) + p.add_argument( + "--append", action="store_true", + help="기존 JSONL 파일에 이어쓰기 (id 자동 증가)", + ) + p.add_argument( + "--preview", action="store_true", + help="전처리 결과 미리보기만 출력, 저장 안 함", + ) + return p + + +def main() -> None: + args = build_parser().parse_args() + + # 입력 파일 수집 + input_paths: list[Path] = [] + for pattern in args.input: + matched = sorted(Path(".").glob(pattern)) if "*" in pattern else [Path(pattern)] + input_paths.extend(matched) + + missing = [p for p in input_paths if not p.exists()] + if missing: + print(f"[오류] 파일을 찾을 수 없습니다: {missing}", file=sys.stderr) + sys.exit(1) + + if not input_paths: + print("[오류] 입력 파일이 없습니다.", file=sys.stderr) + sys.exit(1) + + # 미리보기 모드 + if args.preview: + for p in input_paths: + preview(p) + return + + # 저장 모드 + if not args.output: + print("[오류] --output 경로를 지정해주세요.", file=sys.stderr) + sys.exit(1) + + convert( + input_paths=input_paths, + output_path=Path(args.output), + source_type=args.source_type, + append=args.append, + ) + + +if __name__ == "__main__": + main() diff --git a/model/extraction/fill_original2.py b/model/extraction/fill_original2.py new file mode 100644 index 0000000000000000000000000000000000000000..8bcee77644f37f58725e6ab864db6ce024ed2eaa --- /dev/null +++ b/model/extraction/fill_original2.py @@ -0,0 +1,168 @@ +""" +fill_original2.py +================== +notices_original2.jsonl 의 빈 필드(category, keywords, importance)를 +notices_labeled_v2.jsonl 과 규칙 기반 추출로 채운다. + +실행: + python model/extraction/fill_original2.py +""" + +import json +import re +import os + +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +ORIGINAL2_PATH = os.path.join(DATA_DIR, "notices_original2.jsonl") +LABELED_PATH = os.path.join(DATA_DIR, "notices_labeled_v2.jsonl") + +# ── 카테고리 중요도 기준 ───────────────────────────────────── +CATEGORY_BASE_IMPORTANCE = { + "제출": 1.0, + "준비물": 0.85, + "건강·안전": 0.80, + "비용": 0.75, + "일정": 0.70, + "기타": 0.50, +} + +URGENT_KEYWORDS = ["반드시", "꼭", "필수", "엄수", "마감", "당일", "즉시", "응급", "위급", "112"] + +MONEY_PATTERN = re.compile(r"(\d{1,3}(?:,\d{3})+|\d+)\s*원") +DATE_PATTERN = re.compile( + r"(?:(\d{1,2})\s*월\s*(\d{1,2})\s*일)|(? str: + s = sentence + if MONEY_PATTERN.search(s): + return "비용" + if any(k in s for k in ["제출", "제출해", "제출하", "내주", "보내주"]): + return "제출" + if any(k in s for k in ["준비", "가져", "챙겨", "구입", "지참"]): + return "준비물" + if any(k in s for k in ["안전", "건강", "마스크", "위험", "유괴", "실종", "신고", "치료", "감염", "질병"]): + return "건강·안전" + if DATE_PATTERN.search(s) or MONTH_ONLY_PATTERN.search(s) or any(k in s for k in ["일시", "기간", "까지", "예정", "실시", "평가"]): + return "일정" + return "기타" + + +def rule_importance(sentence: str, category: str) -> float: + score = CATEGORY_BASE_IMPORTANCE.get(category, 0.5) + if any(k in sentence for k in URGENT_KEYWORDS): + score = min(1.0, score + 0.05) + if DATE_PATTERN.search(sentence): + score = min(1.0, score + 0.05) + return round(score, 2) + + +# ── labeled 데이터 로드 → original_id 기준으로 그룹화 ─────────── +def load_labeled_by_original_id(path: str) -> dict: + groups: dict[int, list] = {} + with open(path, encoding="utf-8") as f: + for line in f: + obj = json.loads(line) + oid = obj.get("original_id") + if oid is not None: + groups.setdefault(oid, []).append(obj) + return groups + + +# ── labeled 그룹 → category/keywords/importance 집계 ─────────── +def aggregate_from_labeled(entries: list) -> dict: + todos = [e for e in entries if e.get("is_todo")] + if not todos: + return {"category": "", "keywords": "", "importance": ""} + + cats = [] + keywords = [] + importances = [] + for e in todos: + cat = e.get("category") or rule_category(e["sentence"]) + cats.append(cat) + imp = rule_importance(e["sentence"], cat) + importances.append(imp) + if imp >= 0.70: + keywords.append(e["sentence"][:60]) + + unique_cats = list(dict.fromkeys(cats)) # 순서 유지 중복 제거 + max_imp = max(importances) if importances else 0.5 + kw_str = " / ".join(keywords[:5]) # 상위 5개까지 + + return { + "category": ", ".join(unique_cats), + "keywords": kw_str, + "importance": str(max_imp), + } + + +# ── 규칙 기반으로 원문에서 직접 추출 ──────────────────────────── +def extract_from_text(text: str) -> dict: + sentences = [s.strip() for s in re.split(r"[.。\n]", text) if len(s.strip()) > 8] + + todo_candidates = [] + for sent in sentences: + # 안부인사·서명류 제외 + if re.search(r"안녕하십니까|안녕하세요|감사드립니다|교장$|^\d{4}\.", sent): + continue + cat = rule_category(sent) + imp = rule_importance(sent, cat) + if imp >= 0.65: + todo_candidates.append({"sentence": sent, "category": cat, "importance": imp}) + + if not todo_candidates: + return {"category": "", "keywords": "", "importance": ""} + + cats = list(dict.fromkeys(c["category"] for c in todo_candidates)) + max_imp = max(c["importance"] for c in todo_candidates) + keywords = [c["sentence"][:60] for c in todo_candidates if c["importance"] >= 0.70][:5] + + return { + "category": ", ".join(cats), + "keywords": " / ".join(keywords), + "importance": str(max_imp), + } + + +# ── 메인 처리 ───────────────────────────────────────────────── +def main(): + labeled_groups = load_labeled_by_original_id(LABELED_PATH) + + updated = [] + with open(ORIGINAL2_PATH, encoding="utf-8") as f: + for line in f: + obj = json.loads(line) + oid = obj["id"] + + if oid in labeled_groups: + result = aggregate_from_labeled(labeled_groups[oid]) + # labeled 데이터에 todo 없으면 규칙 기반으로 폴백 + if not result["category"]: + result = extract_from_text(obj["original_text"]) + method = "rule(fallback)" + else: + method = "labeled" + else: + result = extract_from_text(obj["original_text"]) + method = "rule" + + obj["category"] = result["category"] + obj["keywords"] = result["keywords"] + obj["importance"] = result["importance"] + updated.append(obj) + print(f"[{method}] ID {oid:2d}: category={obj['category'][:40]!r} imp={obj['importance']}") + + with open(ORIGINAL2_PATH, "w", encoding="utf-8") as f: + for obj in updated: + f.write(json.dumps(obj, ensure_ascii=False) + "\n") + + print(f"\n완료: {len(updated)}개 항목 업데이트 → {ORIGINAL2_PATH}") + + +if __name__ == "__main__": + main() diff --git a/model/translation_tts/.gitkeep b/model/translation_tts/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/model/translation_tts/README.md b/model/translation_tts/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6f6a4bfd4208c142f1b65ed8396502b4be64a969 --- /dev/null +++ b/model/translation_tts/README.md @@ -0,0 +1,98 @@ +# Translation/TTS MVP + +![세종 파트 현재 상태](../../docs/assets/translation_tts_status.png) + +## 현재 상태 + +Translation/TTS 파트는 슬롯 기반 응답 구조(summary + items)로 전환됐다. 강사 처방(2026-04-28) 대응으로 번역을 세 경로로 분리해 품질과 신뢰도를 높였다. + +### 번역 3경로 구조 + +| 경로 | 대상 | 방식 | +|---|---|---| +| i18n 포매터 | summary.dates / times / amounts | 정규식 추출 → 언어별 룰 변환 (LLM 없음) | +| glossary 치환 | summary.places / supplies / deadlines | term_glossary.csv exact 매칭, miss 시 한국어 노출 | +| NLLB 번역 | items[].title_translated | glossary injection → NLLB → vi 후처리 | + +### 경이(B단계) → 세종(C단계) 확정 인터페이스 + +```json +{ + "text": "4월 30일까지 체험학습 동의서를 제출해주세요.", + "category": "제출", + "importance": 1.0, + "due_date": "2026-04-30" +} +``` + +카테고리 6종: `제출 / 준비물 / 일정 / 비용 / 건강·안전 / 기타` + +### term_glossary.csv + +학교 특화 용어사전 (8개 언어). 최신 용어 수는 CSV 직접 참고. +주요 추가 이력: 학생/전세버스/생존수영/리코더/물통 등 오번역 방지 용어 중심으로 지속 확장 중. + +## 주요 파일 + +- `run_mvp_pipeline.py`: MVP 파이프라인 실행 스크립트 +- `term_glossary.csv`: 학교 특화 한국어-다국어 용어사전 (8개 언어, 최신 용어 수는 CSV 직접 참고) +- `languages.py`: NLLB target code 및 TTS voice 매핑 +- `run_ab_compare.py`: 원문 전체 번역(A)과 TODO 추출 번역(B) 속도/입력량 비교 +- `run_ab_quality_eval.py`: A/B 번역 품질 평가. 현지 자연스러움과 Round-trip 검사를 포함 +- `run_glossary_compare.py`: NLLB 원번역의 용어사전 반영률 확인 +- `run_quality_eval.py`: 용어사전 전/후 품질 평가 +- `requirements-translation-tts.txt`: 번역/TTS 파트 실행 의존성 +- `../../data/translation_tts/easy_ko_text_sample.csv`: 샘플 입력 +- `../../demo/translation_tts/demo_case_01/`: 고정 데모 산출물 +- `outputs/`: 발표 근거용 평가 요약 결과 + +## 백엔드 연동 파일 (backend/app/services/) + +- `translator.py`: `translate_term` (glossary 치환) / `translate_short_sentence` (NLLB 번역) 두 함수 제공 +- `slot_extractor.py`: 정규식 추출 + i18n 포매터 (태수님 작성, 세종 파트 연동) + +## 실행 예시 + +```bash +python model/translation_tts/run_mvp_pipeline.py --input data/translation_tts/easy_ko_text_sample.csv --output-dir outputs/mvp/vi --lang vi --save-demo-case demo_case_01 +``` + +## 평가 재실행 + +```bash +python model/translation_tts/run_ab_compare.py --lang vi --device cpu +python model/translation_tts/run_ab_quality_eval.py --lang vi +python model/translation_tts/run_glossary_compare.py --lang vi en zh th ja ru ms mn +python model/translation_tts/run_quality_eval.py +``` + +## 데모 케이스 + +`demo/translation_tts/demo_case_01/`에는 다음 산출물을 고정 보관한다. + +- `01_easy_ko_input.txt` +- `02_vi_raw_translation.txt` +- `03_glossary_check.csv` +- `04_review_needed.md` +- `05_vi_corrected_translation.txt` +- `06_tts_output.mp3` +- `demo_summary.md` + +## 2026-04-28 평가 요약 + +| 항목 | 결과 | +|---|---:| +| A/B 입력 단축 | -30.1% | +| A/B 속도향상 | x1.84 | +| A/B 품질평가 | A 50.1점 / B 54.1점 | +| 용어사전 전/후 품질평가 | 39.0점 -> 89.6점 | + +공유용 요약은 `../../docs/share-summary-2026-04-28-quality-eval.md`에 정리했다. + +## 2026-04-28 구조 개편 (강사 처방 대응) + +- 번역 입력을 원문 전체 → 추출된 할 일 문장(text)으로 변경 +- 응답 구조 전환: 단일 translation 블롭 → summary(슬롯) + items(카드) 분리 +- 번역 3경로 분리: i18n 포매터 / glossary 직접 치환 / NLLB +- term_glossary.csv 신규 용어 지속 추가 (물통, 학생, 전세버스 등 오번역 방지 중심) +- 윤정(A) → 경이(B) → 세종(C) 역할 확정: 경이가 6분류 + 중요도 전담 diff --git a/model/translation_tts/fill_glossary_all_langs.py b/model/translation_tts/fill_glossary_all_langs.py new file mode 100644 index 0000000000000000000000000000000000000000..476672a2a389d664322e4df89034a0ba9c3396f1 --- /dev/null +++ b/model/translation_tts/fill_glossary_all_langs.py @@ -0,0 +1,66 @@ +""" +term_glossary.csv의 비어있는 언어 컬럼을 Gemini로 채운다. +언어당 1회 요청 → 총 8회 (무료 한도 20/day 내) +""" + +import csv +import sys +import time +from pathlib import Path + +sys.stdout.reconfigure(encoding="utf-8") + +from dotenv import load_dotenv +load_dotenv(Path(__file__).resolve().parents[2] / ".env") + +from gemini_helper import fill_glossary_column + +GLOSSARY_PATH = Path(__file__).parent / "term_glossary.csv" +TARGET_LANGS = ["en", "zh", "th", "ms", "mn", "ru", "ja"] # vi는 이미 완성 +LANG_DELAY = 10.0 # 언어 간 딜레이 (초) + + +def main(): + with GLOSSARY_PATH.open("r", encoding="utf-8-sig", newline="") as f: + rows = list(csv.DictReader(f)) + fieldnames = list(rows[0].keys()) if rows else [] + + korean_terms = [r["korean"] for r in rows if r.get("korean", "").strip()] + print(f"용어 {len(korean_terms)}개 × {len(TARGET_LANGS)}개 언어 시작\n") + + for lang in TARGET_LANGS: + col = f"preferred_{lang}" + already_filled = sum(1 for r in rows if r.get(col, "").strip()) + if already_filled == len(rows): + print(f"[{lang}] 이미 완성됨 — 스킵") + continue + + print(f"[{lang}] 번역 중...", end=" ", flush=True) + suggestions = fill_glossary_column(korean_terms, lang) + + filled = 0 + for row in rows: + korean = row.get("korean", "") + if not row.get(col, "").strip() and suggestions.get(korean, "").strip(): + row[col] = suggestions[korean] + filled += 1 + + _save(rows, fieldnames) + print(f"{filled}개 채움 → {GLOSSARY_PATH.name} 저장") + + if lang != TARGET_LANGS[-1]: + print(f" {LANG_DELAY:.0f}초 대기 중...", flush=True) + time.sleep(LANG_DELAY) + + print("\n완료.") + + +def _save(rows, fieldnames): + with GLOSSARY_PATH.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + +if __name__ == "__main__": + main() diff --git a/model/translation_tts/gemini_helper.py b/model/translation_tts/gemini_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..3ed128242432f576e5a83561fb85d7dbecee37d3 --- /dev/null +++ b/model/translation_tts/gemini_helper.py @@ -0,0 +1,152 @@ +"""Gemini API를 이용한 glossary 신규 용어 번역 초안 생성.""" + +import json +import os +import re +import time +from pathlib import Path + +try: + from dotenv import load_dotenv + load_dotenv(Path(__file__).resolve().parents[2] / ".env") +except ImportError: + pass + +GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") +GEMINI_MODEL = "gemini-3-flash-preview" +BATCH_SIZE = 150 # 전체를 1번에 처리 (Gemini 컨텍스트 충분) +BATCH_DELAY = 5.0 +MAX_RETRIES = 3 + +LANG_LABEL = { + "en": "영어", + "vi": "베트남어", + "zh": "중국어(간체)", + "th": "태국어", + "ms": "말레이시아어", + "mn": "몽골어", + "ru": "러시아어", + "ja": "일본어", +} + + +def _get_client(): + from google import genai + return genai.Client(api_key=GEMINI_API_KEY) + + +def suggest_terms_batch(korean_terms: list[str], target_lang: str) -> dict[str, str]: + """ + 여러 용어를 한 번의 API 호출로 번역 요청. + 반환: {korean_term: suggested_translation} + 실패한 항목은 딕셔너리에서 누락됨. + """ + if not GEMINI_API_KEY or not korean_terms: + return {} + + lang_label = LANG_LABEL.get(target_lang, target_lang) + numbered = "\n".join(f"{i+1}. {t}" for i, t in enumerate(korean_terms)) + prompt = ( + f"한국 어린이집·유치원·초등학교 가정통신문을 {lang_label}를 모국어로 쓰는 학부모에게 전달하는 상황입니다.\n" + f"아래 용어를 {lang_label}로 번역하세요.\n\n" + f"반드시 지켜야 할 조건:\n" + f"1. 어린이집·유치원·초등학교 학부모가 일상에서 자주 쓰는 친근하고 부드러운 표현을 사용하세요.\n" + f"2. 뉴스·논문·교과서 같은 딱딱한 공식 표현은 피하세요.\n" + f"3. 고유명사·앱 이름(키즈노트, 하이클래스, 스쿨뱅킹 등)과 통화 단위(원=won, ₩)는 번역하지 말고 그대로 쓰세요.\n" + f"4. JSON 객체로만 답하세요. 코드블록·설명 없이.\n" + f"5. 키는 반드시 원래 한국어 용어 그대로 사용하세요.\n\n" + f"예시:\n" + f'{{"앞치마": "tạp dề", "도화지": "giấy vẽ"}}\n\n' + f"번역할 용어:\n{numbered}" + ) + + for attempt in range(MAX_RETRIES): + try: + client = _get_client() + response = client.models.generate_content(model=GEMINI_MODEL, contents=prompt) + text = response.text.strip() + + # 마크다운 코드블록 제거 후 JSON 추출 + text = re.sub(r"```(?:json)?\s*", "", text).replace("```", "") + match = re.search(r"\{[\s\S]+\}", text) + if not match: + return {} + return json.loads(match.group()) + + except Exception as e: + err = str(e) + if "429" in err or "RESOURCE_EXHAUSTED" in err: + m = re.search(r"retry in ([\d.]+)s", err) + wait = float(m.group(1)) if m else BATCH_DELAY * (attempt + 2) + if wait > 90: + print(f"\n 일일 quota 소진 ({wait:.0f}s) — 자정 UTC(오전 9시 KST) 후 재시도", flush=True) + return {} + print(f"\n 429 — {wait:.0f}초 대기...", flush=True) + time.sleep(wait) + elif "503" in err or "UNAVAILABLE" in err: + time.sleep(BATCH_DELAY) + else: + print(f"\n API 오류: {err[:200]}", flush=True) + return {} + + return {} + + +def suggest_term(korean_term: str, target_lang: str, context: str = "") -> str | None: + """단일 용어 번역 (파이프라인 missing_term 처리용). 실패 시 None.""" + result = suggest_terms_batch([korean_term], target_lang) + return result.get(korean_term) or None + + +def suggest_missing_terms( + missing_terms: list[dict], target_lang: str, easy_ko_text: str = "" +) -> list[dict]: + """ + missing_term 목록에 대해 Gemini 초안을 추가해 반환한다. + 각 항목: {"korean_term": ..., "preferred_term": ..., "gemini_suggestion": ...} + """ + if not missing_terms: + return [] + + korean_list = [item.get("korean_term", "") for item in missing_terms] + suggestions = suggest_terms_batch(korean_list, target_lang) + + return [ + {**item, "gemini_suggestion": suggestions.get(item.get("korean_term", ""), "")} + for item in missing_terms + ] + + +def fill_glossary_column( + korean_terms: list[str], target_lang: str, on_progress=None +) -> dict[str, str]: + """ + 144개 등 대량 용어를 BATCH_SIZE씩 나눠 번역. + on_progress(done, total): 진행 콜백 (선택). + 반환: {korean_term: suggested_translation} + """ + results = {} + total = len(korean_terms) + + for i in range(0, total, BATCH_SIZE): + batch = korean_terms[i:i + BATCH_SIZE] + batch_result = suggest_terms_batch(batch, target_lang) + results.update(batch_result) + + done = min(i + BATCH_SIZE, total) + if on_progress: + on_progress(done, total) + + if i + BATCH_SIZE < total: + time.sleep(BATCH_DELAY) + + return results + + +def _find_sentence(text: str, term: str) -> str: + if not term or not text: + return "" + for sentence in re.split(r"(?<=[.!?。!?])\s+|[\r\n]+", text): + if term in sentence: + return sentence.strip() + return "" diff --git a/model/translation_tts/languages.py b/model/translation_tts/languages.py new file mode 100644 index 0000000000000000000000000000000000000000..fbf847117ea8d5e4b6484f454aeb5c50dec4c95a --- /dev/null +++ b/model/translation_tts/languages.py @@ -0,0 +1,51 @@ +"""언어별 NLLB target code 및 Edge-TTS voice 매핑.""" + +LANGUAGES = { + "easy_ko": { + "label": "쉬운 한국어", + "nllb_code": None, # 번역 없음, easy_korean() 결과 그대로 사용 + "tts_voice": "ko-KR-SunHiNeural", + }, + "en": { + "label": "영어", + "nllb_code": "eng_Latn", + "tts_voice": "en-US-JennyNeural", + }, + "ru": { + "label": "러시아어", + "nllb_code": "rus_Cyrl", + "tts_voice": "ru-RU-SvetlanaNeural", + }, + "ms": { + "label": "말레이시아어", + "nllb_code": "zsm_Latn", + "tts_voice": "ms-MY-YasminNeural", + }, + "mn": { + "label": "몽골어", + "nllb_code": "khk_Cyrl", + "tts_voice": "mn-MN-YesuiNeural", + }, + "vi": { + "label": "베트남어", + "nllb_code": "vie_Latn", + "tts_voice": "vi-VN-HoaiMyNeural", + }, + "zh": { + "label": "중국어", + "nllb_code": "zho_Hans", + "tts_voice": "zh-CN-XiaoxiaoNeural", + }, + "th": { + "label": "태국어", + "nllb_code": "tha_Thai", + "tts_voice": "th-TH-PremwadeeNeural", + }, + "ja": { + "label": "일본어", + "nllb_code": "jpn_Jpan", + "tts_voice": "ja-JP-NanamiNeural", + }, +} + +DEFAULT_LANGUAGE = "vi" diff --git a/model/translation_tts/outputs/ab_compare/vi/results.json b/model/translation_tts/outputs/ab_compare/vi/results.json new file mode 100644 index 0000000000000000000000000000000000000000..bb0b9b2bc573a6846c9488bd20e274d45d66eb44 --- /dev/null +++ b/model/translation_tts/outputs/ab_compare/vi/results.json @@ -0,0 +1,308 @@ +[ + { + "notice_id": "N01", + "title": "대기오염(미세먼지) 대응 및 질병결석 안내", + "lang": "vi", + "a_chars": 519, + "b_chars": 349, + "input_reduction_pct": 32.8, + "a_time_sec": 23.6, + "b_time_sec": 14.04, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.68, + "a_translation": "Chào cha mẹ! Mùa xuân ấm áp đang đến nhưng bị ô nhiễm khí quyển gây nguy hiểm cho sức khỏe của học sinh. Nếu PM10 150 hoặc PM2.5 75 cao hơn kéo dài 2 giờ, cảnh báo ô nhiễm được ban hành. Khi cảnh báo, cấm hoạt động ngoài trời vào giờ tập thể dục, chơi giữa giờ và giờ ăn trưa. Nếu học sinh được chẩn đoán bởi bác sĩ có các căn bệnh cơ sở liên quan đến ô nhiễm khí quyển (những căn bệnh liên quan đến ô nhiễm khí quyển, dị ứng, atrophy, bệnh hô hấp, bệnh tim mạch máu, v.v.), cha mẹ nên gửi cho giáo viên đảm bảo chẩn đoán hoặc ý kiến của bác sĩ về căn bệnh đó (chẩn đoán hoặc ý kiến của bác sĩ) .", + "b_translation": "Nếu sinh viên được bác sĩ chẩn đoán có một căn bệnh cơ sở liên quan đến ô nhiễm không khí (như: ︎, ︎, ︎, ︎, ︎, ︎, ︎, ︎) thì cha mẹ nên gửi cho giáo viên bản chẩn đoán hoặc ý kiến của bác sĩ về căn bệnh đó (như: ︎, ︎).", + "a_input": "학부모님 안녕하십니까? 따뜻한 봄이 오고 있지만 대기오염으로 학생들의 건강상 피해가 우려되고 있습니다. PM10 150 이상 또는 PM2.5 75 이상이 2시간 지속되면 미세먼지 주의보가 발령된다. 주의보 시 체육시간, 중간놀이, 점심시간에 실외활동을 금지한다. 학생이 의사의 진단을 통해 대기오염 관련 기저질환(천식, 알레르기, 아토피, 호흡기질환, 심혈관 질환 등)이 있는 경우, 학부모께서는 해당 질환에 대한 의사의 진단서 또는 의견서(의사 소견서, 진료확인서 등)를 담임 선생님께 제출해 주시기 바랍니다. 학기초 1회 제출하며 대기오염과 유관성이 드러나는 의사 소견 또는 향후 치료 의견을 명시한다. 의사소견서 및 진단서를 사전 제출한 경우, 등교시간대 거주지 또는 학교 주변 실시간 미세먼지 농도가 '나쁨' 이상일 때 당일 수업 시작 시간(8시 50분) 이전에 학부모가 학교에 사전 연락한 경우 질병결석으로 인정된다. 이런 경우 5일 이내에 결석계를 제출하여 주시기를 바랍니다. 2026. 3. 17. 서울갈산초등학교장", + "b_input": "학생이 의사의 진단을 통해 대기오염 관련 기저질환(천식, 알레르기, 아토피, 호흡기질환, 심혈관 질환 등)이 있는 경우, 학부모께서는 해당 질환에 대한 의사의 진단서 또는 의견서(의사 소견서, 진료확인서 등)를 담임 선생님께 제출해 주시기 바랍니다. 학기초 1회 제출하며 대기오염과 유관성이 드러나는 의사 소견 또는 향후 치료 의견을 명시한다. 의사소견서 및 진단서를 사전 제출한 경우, 등교시간대 거주지 또는 학교 주변 실시간 미세먼지 농도가 '나쁨' 이상일 때 당일 수업 시작 시간(8시 50분) 이전에 학부모가 학교에 사전 연락한 경우 질병결석으로 인정된다. 이런 경우 5일 이내에 결석계를 제출하여 주시기를 바랍니다." + }, + { + "notice_id": "N02", + "title": "2026 초등안심벨 배부 안내", + "lang": "vi", + "a_chars": 347, + "b_chars": 197, + "input_reduction_pct": 43.2, + "a_time_sec": 21.83, + "b_time_sec": 12.47, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.75, + "a_translation": "Xin chào cha mẹ và cha mẹ! Sau khi các mối quan tâm về an toàn của trẻ em gia tăng gần đây, thành phố Seoul đang thúc đẩy dự án hỗ trợ 'Bông an toàn sơ cấp bảo vệ con tôi'. Tháng 3 vừa qua, nó sẽ được phát hành cho sinh viên lớp 1 và 2 đến 6 năm. Xin vui lòng hướng dẫn con bạn cùng nhau để trẻ em đeo chiếc an toàn đúng cách và chỉ sử dụng trong trường hợp khẩn cấp thực tế. Hãy hướng dẫn trẻ sử dụng nó chỉ trong trường hợp khẩn cấp thực tế.", + "b_translation": "Bạn cũng nên hướng dẫn trẻ sử dụng chiếc chuông an toàn đúng cách, chỉ trong trường hợp khẩn cấp thực tế, chỉ sử dụng trong trường hợp khẩn cấp thực tế, vì cảnh báo rất lớn, hãy cẩn thận với việc sử dụng đồ đạc. Hãy gắn túi xách như dây đeo tay ở nơi có thể chạm được. Thời gian chờ đợi là khoảng hai năm, và chúng tôi khuyên bạn nên sạc mỗi hai đến ba tháng để đảm bảo sự an toàn của trẻ.", + "a_input": "학부모님 안녕하십니까? 서울시는 최근 어린이 안전에 대한 우려가 커짐에 따라 '내 아이 지키는 초등안심벨' 지원사업을 추진하고 있다. 지난 3월 1학년 배부에 이어 2~6학년 전체 학생에게 배부합니다. 가정에서도 자녀가 안심벨을 올바르게 착용하고, 실제 위급한 상황에서만 사용할 수 있도록 함께 지도하여 주시기 바랍니다. 실제 위급 상황에서만 사용하도록 지도해주세요. 경고음이 매우 크므로 장난 사용에 주의해주세요. 가방 어깨끈 등 손이 잘 닿는 곳에 부착해주세요. 미사용 시 대기 시간은 약 2년이나, 아이 안전을 위해 2~3개월마다 충전을 권장합니다. 제조사 SG생활안전(주) 고객센터는 070-4992-8428이다.", + "b_input": "가정에서도 자녀가 안심벨을 올바르게 착용하고, 실제 위급한 상황에서만 사용할 수 있도록 함께 지도하여 주시기 바랍니다. 실제 위급 상황에서만 사용하도록 지도해주세요. 경고음이 매우 크므로 장난 사용에 주의해주세요. 가방 어깨끈 등 손이 잘 닿는 곳에 부착해주세요. 미사용 시 대기 시간은 약 2년이나, 아이 안전을 위해 2~3개월마다 충전을 권장합니다." + }, + { + "notice_id": "N04", + "title": "주간학습계획 3주(3.23~3.27) 및 출결규정 안내", + "lang": "vi", + "a_chars": 577, + "b_chars": 468, + "input_reduction_pct": 18.9, + "a_time_sec": 23.47, + "b_time_sec": 21.98, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.07, + "a_translation": "Học tập ngoại vi có thể nộp đơn cho 10 ngày liên tiếp một lần và có thể nộp cho đến 19 ngày trong một năm. Học tập ngoại vi phải nộp đơn cho giáo viên trưởng thành trước 3 ngày thử nghiệm. Báo cáo phải được xử lý trước khi xuất hiện trong vòng 7 ngày sau khi học tập thử nghiệm. Ứng dụng và báo cáo phải được nộp dưới dạng năm 2026 và được gắn trên trang web và các loại trường học. Nếu được thực hiện liên tục hơn 5 ngày, sinh viên phải liên hệ với giáo viên trưởng thành trong thời gian đó vì sự an toàn của sinh viên.", + "b_translation": "Đơn xin và báo cáo sẽ được xử lý trong vòng 7 ngày kể từ ngày học thử nghiệm. Đơn xin và báo cáo sẽ được nộp theo hình thức năm 2026 và được trang web và các loại trường học. Nếu được thực hiện liên tục hơn 5 ngày, cho sự an toàn của học sinh, học sinh phải liên hệ với giáo viên trưởng trong thời gian nhất trong thời gian này. Nếu kết quả bệnh xảy ra trong vòng 2 ngày, sẽ được nộp trong vòng 5 ngày. Nếu kết quả bệnh xảy ra trong vòng 3 ngày, sẽ được nộp trong vòng 5 ngày. Nếu kết quả bệnh xảy ra trong vòng 3 ngày, sẽ được nộp trong vòng 5 ngày.", + "a_input": "교외체험학습은 한번에 연속 10일까지 신청 가능하며 1년 동안 19일까지 신청 가능합니다. 교외체험학습은 체험일 3일 전에 담임교사에게 신청서를 제출해야 한다. 체험학습 후 7일 이내에 보고서를 제출해야 출석인정 처리됩니다. 신청서 및 보고서는 2026년 양식으로 제출하며 홈페이지 및 학교종이에 탑재되어 있습니다. 연속 5일을 초과하여 실시하는 경우, 학생 안전을 위해 기간 중 1회 이상 학생이 담임교사에게 연락해야 합니다. 수업일수를 191일로 운영하며 1/3 이상(65일) 결석 시 다음 학년으로 진급이 어려울 수 있습니다. 질병결석이 2일 이내인 경우 결석신고서와 증빙자료(학부모 의견서, 처방전, 담임교사 확인서)를 5일 이내에 제출합니다. 질병결석이 3일 이상인 경우 결석신고서와 증빙자료(병명, 진료기간 기록이 나타난 의사의 진단서 또는 의사 소견서, 진료 확인서 등)를 5일 이내에 제출합니다. 경조사 결석 시 결석신고서와 증빙자료(청첩장 등)를 제출합니다. 법정감염병(수두, 홍역 등) 결석 시 결석신고서와 증빙자료(법정감염병명과 기간이 표시된 증빙 서류 — 의사소견서, 진료확인서, 의사진단서 등)를 제출합니다.", + "b_input": "교외체험학습은 체험일 3일 전에 담임교사에게 신청서를 제출해야 한다. 체험학습 후 7일 이내에 보고서를 제출해야 출석인정 처리됩니다. 신청서 및 보고서는 2026년 양식으로 제출하며 홈페이지 및 학교종이에 탑재되어 있습니다. 연속 5일을 초과하여 실시하는 경우, 학생 안전을 위해 기간 중 1회 이상 학생이 담임교사에게 연락해야 합니다. 질병결석이 2일 이내인 경우 결석신고서와 증빙자료(학부모 의견서, 처방전, 담임교사 확인서)를 5일 이내에 제출합니다. 질병결석이 3일 이상인 경우 결석신고서와 증빙자료(병명, 진료기간 기록이 나타난 의사의 진단서 또는 의사 소견서, 진료 확인서 등)를 5일 이내에 제출합니다. 경조사 결석 시 결석신고서와 증빙자료(청첩장 등)를 제출합니다. 법정감염병(수두, 홍역 등) 결석 시 결석신고서와 증빙자료(법정감염병명과 기간이 표시된 증빙 서류 — 의사소견서, 진료확인서, 의사진단서 등)를 제출합니다." + }, + { + "notice_id": "N05", + "title": "주간학습계획 3주(3.23~3.27) 3학년 1반", + "lang": "vi", + "a_chars": 431, + "b_chars": 386, + "input_reduction_pct": 10.4, + "a_time_sec": 26.73, + "b_time_sec": 32.14, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 0.83, + "a_translation": "Thời gian lên lớp là từ 8h30 đến 8h45 và không nên lên sớm hơn 8h30 vì các vấn đề an toàn. Tuần lễ tư vấn phụ huynh lớp 1 được tổ chức từ ngày 13 đến ngày 24 tháng 4 và có thể có tư vấn trực tiếp và qua điện thoại. Tuần 1 có lớp học âm nhạc sáng tạo (tám) và nhạc quốc gia (tám) trong giáo dục nghệ thuật văn hóa. Vì có nhiều học sinh bị cảm lạnh do suyễn và thuốc độc, nên lưu ý đến sinh viên cá nhân và đeo mặt nạ khi thuốc độc không tốt hơn. Cuộc sống đọc sách được viết mỗi tuần và nộp vào thứ Hai.", + "b_translation": "Thời gian học tập từ 8h30 đến 8h45 và không nên lên sớm hơn 8h30 vì các vấn đề an toàn. Tuần lễ tư vấn phụ huynh lớp 1 được tổ chức từ ngày 13 đến ngày 24 tháng 4 và có thể có tư vấn trực tiếp và qua điện thoại. Vì có nhiều học sinh bị cảm lạnh do suyễn và ô nhiễm, nên lưu ý đến sức khỏe cá nhân, và đeo mặt nạ khi ô nhiễm không tốt hơn. Cuộc sống đọc sách được viết một lần mỗi tuần và nộp vào thứ Hai. Để chuẩn bị thời gian sáng tạo, chúng tôi nộp hồ sơ hy vọng cho đến thứ Bảy. Để chuẩn bị các lớp học âm nhạc, chúng tôi chuẩn bị và luyện tập thu âm. Ngày thứ Hai, chúng tôi chuẩn bị sách và hy vọng. Ngày thứ Bảy, chúng tôi chuẩn bị thư ký, bản sao chép, bài tập. Ngày thứ Bảy, chúng tôi chuẩn bị thư ký. Ngày thứ Bảy, chúng tôi chuẩn bị thư ký, bản sao chép, bản sao chép, và bài tập thể thao.", + "a_input": "등교시간은 8시 30분~8시 45분까지이며 안전상의 문제로 8시 30분보다 일찍 등교하지 않도록 합니다. 1학기 학부모 상담주간은 4월 13일~4월 24일에 운영되며 대면 상담 및 전화 상담이 가능합니다. 1학기에는 문화예술교육으로 창의음악(8차시), 국악(10차시) 수업이 있습니다. 환절기와 미세먼지로 인해 감기에 걸린 학생이 많으므로 개인위생에 유의하고, 미세먼지가 나쁨 이상일 때에는 마스크를 착용합니다. 독서 생활은 매주 1편 이상 정성껏 작성해서 월요일에 제출합니다. 창체 시간 준비물로 희망편지를 화요일까지 제출합니다. 음악 수업 준비물로 리코더(독일식)를 준비하고 리코더 연습을 합니다. 월요일 준비물은 독서록과 희망편지입니다. 화요일 준비물은 희망편지, 간편복, 운동화입니다. 수요일 준비물은 리코더입니다. 목요일 준비물은 리코더, 간편복, 운동화입니다.", + "b_input": "등교시간은 8시 30분~8시 45분까지이며 안전상의 문제로 8시 30분보다 일찍 등교하지 않도록 합니다. 1학기 학부모 상담주간은 4월 13일~4월 24일에 운영되며 대면 상담 및 전화 상담이 가능합니다. 환절기와 미세먼지로 인해 감기에 걸린 학생이 많으므로 개인위생에 유의하고, 미세먼지가 나쁨 이상일 때에는 마스크를 착용합니다. 독서 생활은 매주 1편 이상 정성껏 작성해서 월요일에 제출합니다. 창체 시간 준비물로 희망편지를 화요일까지 제출합니다. 음악 수업 준비물로 리코더(독일식)를 준비하고 리코더 연습을 합니다. 월요일 준비물은 독서록과 희망편지입니다. 화요일 준비물은 희망편지, 간편복, 운동화입니다. 수요일 준비물은 리코더입니다. 목요일 준비물은 리코더, 간편복, 운동화입니다." + }, + { + "notice_id": "N06", + "title": "주간학습계획 8주(4.20~4.24) 3학년 6반", + "lang": "vi", + "a_chars": 712, + "b_chars": 552, + "input_reduction_pct": 22.5, + "a_time_sec": 43.57, + "b_time_sec": 16.47, + "a_chunks": 2, + "b_chunks": 1, + "speedup_x": 2.64, + "a_translation": "Tập thể dục hợp tác lớp 3 được tổ chức vào ngày 24 tháng 3 tại các phòng tập thể dục năm lớp, với lớp 1 và lớp 2 được phân phối theo cá nhân và theo nguyên tắc sử dụng tại trường. Tập thể dục hợp tác năm lớp 3 được chuẩn bị cho việc sử dụng thiết bị cho cá nhân 3,5mm, C vào vào cho đến thứ Hai. Tập thể dục được tổ chức vào ngày 30 tháng 4 tại các cửa hàng trên mỗi tầng của trường. Tập thể dục sẽ được tổ chức với các lớp 1 và 2 (giống bán hàng/giống bán hàng) và lớp 2 (giống bán hàng/giống bán hàng). Tập thể dục sẽ được phân phối theo cá nhân và sử dụng tại trường.\nCác bài tập về đời sống đọc sách được viết một hoặc hai lần mỗi tuần và được đưa ra vào thứ Hai.", + "b_translation": "Hoạt động tập thể dục chung lớp 3 được tổ chức vào ngày 24 tháng 3 tại phòng tập thể dục năm học, và sẽ được sử dụng trong lớp học và quần áo đơn giản. Hoạt động tập thể dục chung năm học sẽ được chuẩn bị cho ngày thứ Hai. Hoạt động tập thể dục chung năm học sẽ được tổ chức vào ngày 30 tháng 4 lúc 8:50 đến 10:30 tại mỗi tầng của trung tâm.", + "a_input": "3학년 합동 체육은 3월 24일(금) 5교시 체육관에서 실시되며 학급티 및 간편한 복장을 착용합니다. 3학년 디벗(스마트기기)은 개인별로 배부되며 학교에서 사용하는 것을 원칙으로 합니다. 디벗 사용을 위해 개인용 이어폰(3.5mm, C타입)을 월요일까지 준비합니다. 나눔장터는 4월 30일(목) 8:50~10:30에 본교 각 층 복도에서 진행됩니다. 나눔장터는 1교시(짝수반 판매/홀수반 구매)와 2부(홀수반 판매/짝수반 구매)로 운영됩니다. 나눔장터 준비물은 학급티, 1인용 돗자리, 판매할 물건, 구입비(5000원 이내의 잔돈), 지갑, 천으로 된 장바구니입니다. 나눔장터 구입비는 5000원 이내의 잔돈으로 준비합니다. 나눔장터에서 판매금지 물건은 생물, 먹거리(식중독 위험), 5만원 이상 고가의 물품, 너무 낡은 것, 저작권 위반 물품, 청소년에게 유해한 물품(칼, 비비탄 총 등의 완구류, 화장품류) 등이다. 나눔장터 가격은 100원~3000원(100원 단위)으로 하며, 물건에 가격표를 부착하여 가져옵니다. 나눔장터 후 전교어린이회에서 지구촌 어려운 친구를 위해 수입금 중 일부를 기부하는 모금 행사를 진행합니다. 체육 수업 시 운동화 착용, 물 넉넉하게 준비하기를 합니다. 마스크 1장씩 가방에 넣고 다닙니다. 독서생활 과제는 매주 1~2편 이상 바른 글씨로 정성껏 작성해서 월요일에 제출합니다. 윤독도서 '가정통신문 소동'을 4월 27일(월)까지 읽습니다.", + "b_input": "3학년 합동 체육은 3월 24일(금) 5교시 체육관에서 실시되며 학급티 및 간편한 복장을 착용합니다. 디벗 사용을 위해 개인용 이어폰(3.5mm, C타입)을 월요일까지 준비합니다. 나눔장터는 4월 30일(목) 8:50~10:30에 본교 각 층 복도에서 진행됩니다. 나눔장터 준비물은 학급티, 1인용 돗자리, 판매할 물건, 구입비(5000원 이내의 잔돈), 지갑, 천으로 된 장바구니입니다. 나눔장터 구입비는 5000원 이내의 잔돈으로 준비합니다. 나눔장터에서 판매금지 물건은 생물, 먹거리(식중독 위험), 5만원 이상 고가의 물품, 너무 낡은 것, 저작권 위반 물품, 청소년에게 유해한 물품(칼, 비비탄 총 등의 완구류, 화장품류) 등이다. 나눔장터 가격은 100원~3000원(100원 단위)으로 하며, 물건에 가격표를 부착하여 가져옵니다. 체육 수업 시 운동화 착용, 물 넉넉하게 준비하기를 합니다. 마스크 1장씩 가방에 넣고 다닙니다. 독서생활 과제는 매주 1~2편 이상 바른 글씨로 정성껏 작성해서 월요일에 제출합니다. 윤독도서 '가정통신문 소동'을 4월 27일(월)까지 읽습니다." + }, + { + "notice_id": "N07", + "title": "주간학습계획 9주(4.27~5.1) 3학년 1반", + "lang": "vi", + "a_chars": 212, + "b_chars": 171, + "input_reduction_pct": 19.3, + "a_time_sec": 18.03, + "b_time_sec": 14.17, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.27, + "a_translation": "Hoạt động đọc sách sẽ được thực hiện cho đến ngày 27 tháng 4. Ngày 1 tháng 5 và ngày 4 tháng 5 là ngày nghỉ học tự do, ngày 5 là ngày của trẻ em. Đánh giá thực hiện ngôn ngữ sẽ được thực hiện vào ngày 27 tháng 4 bằng cách viết văn bản với câu trung tâm và câu hỗ trợ. Ngày thứ Hai chuẩn bị là sổ sách đọc và ghi ghi. Ngày thứ Ba chuẩn bị là bài viết ngắn, giày dép, giày dép. Ngày thứ Năm chuẩn bị là lớp học và chia sẻ đồ dùng.", + "b_translation": "Các hoạt động đọc sách sẽ được thực hiện cho đến ngày 27 tháng 4 và sẽ được ghi lại trong các hoạt động đọc sách. Các hoạt động đánh giá ngôn ngữ sẽ được thực hiện vào ngày 27 tháng 4 bằng cách viết văn bản với câu trung tâm và câu đệm. Ngày thứ Hai, việc chuẩn bị là sổ sách đọc và ghi âm. Ngày thứ Ba, việc chuẩn bị là bài viết ngắn, giày dép, nhảy vọt. Ngày thứ Năm, việc chuẩn bị là hàng lớp học và đồ dùng chia sẻ.", + "a_input": "독서활동은 윤독도서(하룻밤)를 4월 27일(월)까지 읽고 독서활동에 기록합니다. 5월 1일(금)과 4일(월)은 학교자율휴업일, 5일(화)은 어린이날이다. 국어 수행평가는 4월 27일에 '중심 문장과 뒷받침 문장을 갖추어 문단 쓰기'로 실시됩니다. 월요일 준비물은 독서록과 리코더입니다. 화요일 준비물은 간편복, 운동화, 줄넘기입니다. 목요일 준비물은 학급티와 나눔장터 물품입니다.", + "b_input": "독서활동은 윤독도서(하룻밤)를 4월 27일(월)까지 읽고 독서활동에 기록합니다. 국어 수행평가는 4월 27일에 '중심 문장과 뒷받침 문장을 갖추어 문단 쓰기'로 실시됩니다. 월요일 준비물은 독서록과 리코더입니다. 화요일 준비물은 간편복, 운동화, 줄넘기입니다. 목요일 준비물은 학급티와 나눔장터 물품입니다." + }, + { + "notice_id": "N08", + "title": "2026학년도 1학기 도서관 도서 구입 신청", + "lang": "vi", + "a_chars": 313, + "b_chars": 67, + "input_reduction_pct": 78.6, + "a_time_sec": 27.07, + "b_time_sec": 5.32, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 5.09, + "a_translation": "Xin chào. Chúc các bậc phụ huynh có sức khỏe và sự bình an trong gia đình. Thư viện trụ sở muốn mua những cuốn sách giúp cải thiện chất lượng giáo dục trường học và giúp học tập và giáo dục của học sinh và cha mẹ. Đơn xin mua sách dựa trên các cuốn sách phù hợp với chương trình giáo dục trường học, sách liên kết, sách giải quyết bài tập của học sinh, sách giáo dục cá nhân.", + "b_translation": "Các đơn xin mua sách sẽ được nộp vào ngày 27 tháng 3 vào lúc 12 giờ, theo cách khảo sát bởi các trường học, trong vòng 5 phiếu cho mỗi người.", + "a_input": "안녕하십니까. 학부모님 가정에 건강과 평안이 가득하시기를 바랍니다. 본교 도서관에서는 학교 장서의 질을 향상하고 학생 및 학부모님의 학습과 교양에 도움이 되는 도서를 구입하고자 합니다. 도서 구입 신청은 학교 교육과정에 알맞은 도서, 교과 연계 도서, 학생 과제해결 도서, 개인 교양 도서를 기준으로 합니다. 이미 소장된 도서, 품절·절판 도서, 만화책·학습지·잡지류 등 교과 비연계 도서, 정보 불명확한 도서는 구입 신청에서 제외됩니다. 도서 구입 신청은 3월 27일(금) 12시까지 학교종이 설문으로 제출합니다. 추천 도서는 1인당 5권 이내로 신청합니다.", + "b_input": "도서 구입 신청은 3월 27일(금) 12시까지 학교종이 설문으로 제출합니다. 추천 도서는 1인당 5권 이내로 신청합니다." + }, + { + "notice_id": "N09", + "title": "갈산 교육공동체 약속 실천 안내", + "lang": "vi", + "a_chars": 277, + "b_chars": 167, + "input_reduction_pct": 39.7, + "a_time_sec": 24.38, + "b_time_sec": 15.14, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.61, + "a_translation": "Xin chào các bậc cha mẹ! Tôi luôn cảm ơn các bạn đã tích cực hợp tác với các hoạt động giáo dục của trường học. Thỏa thuận của cộng đồng giáo dục Gaul nhằm mục đích sự tham gia tự nguyện mà sinh viên, cha mẹ và giáo viên tự quyết định và thực hiện. Chúng tôi mong rằng tất cả mọi người trong gia đình luôn thực hiện cam kết cộng đồng và tham gia vào việc xây dựng văn hóa trường học hạnh phúc của chúng tôi. Thỏa thuận của cha mẹ là niềm tin vào sự chuyên môn của giáo viên và sự hợp tác để giúp con cái phát triển đúng đắn. Thỏa thuận của cha mẹ là niềm tin vào con cái và giáo viên. Thỏa thuận của cha mẹ là niềm tin và lời khen ngợi mỗi ngày.", + "b_translation": "Chúng tôi mong rằng tất cả mọi người trong gia đình đều thực hiện cam kết của cộng đồng, và tất cả mọi người đều tham gia vào việc xây dựng một nền văn hóa trường học hạnh phúc, với cam kết của cha mẹ, tin tưởng vào sự chuyên nghiệp của giáo viên, tôn trọng và hợp tác với nhau để con cái phát triển đúng đắn, với cam kết của cha mẹ, tin tưởng vào con cái và giáo viên, với cam kết của cha mẹ, khen ngợi và an ủi mỗi ngày.", + "a_input": "학부모님 안녕하십니까? 항상 학교의 여러 교육활동에 적극적으로 협조해 주심에 감사드립니다. 갈산 교육공동체 약속은 학생, 학부모, 교직원이 스스로 정하고 실천하는 자발적인 참여를 목적으로 합니다. 가정에서도 공동체 약속을 꾸준히 실천하여 모두가 행복한 우리 학교 문화 만들기에 동참해 주시기 바랍니다. 학부모 약속으로 교사의 전문성을 믿고 존중하며 자녀가 바르게 성장할 수 있도록 함께 협력합니다. 학부모 약속으로 아이와 교사를 믿고 지켜봅니다. 학부모 약속으로 하루 한 번 칭찬하고 안아줍니다.", + "b_input": "가정에서도 공동체 약속을 꾸준히 실천하여 모두가 행복한 우리 학교 문화 만들기에 동참해 주시기 바랍니다. 학부모 약속으로 교사의 전문성을 믿고 존중하며 자녀가 바르게 성장할 수 있도록 함께 협력합니다. 학부모 약속으로 아이와 교사를 믿고 지켜봅니다. 학부모 약속으로 하루 한 번 칭찬하고 안아줍니다." + }, + { + "notice_id": "N10", + "title": "안전한 등하교를 위한 협조 요청 및 자전거 이용 시 안전 수칙 안내", + "lang": "vi", + "a_chars": 496, + "b_chars": 488, + "input_reduction_pct": 1.6, + "a_time_sec": 38.65, + "b_time_sec": 29.73, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.3, + "a_translation": "Xin sự hướng dẫn và sự hợp tác tích cực của cha mẹ để các học sinh sử dụng xe đạp phải tuân thủ các quy tắc sử dụng xe đạp để bảo vệ an toàn của họ cũng như an toàn của người khác. Hãy nghĩ đến tình huống tắc nghẽn trước lớp học và sức khỏe của học sinh (như chấn thương chân học sinh) hoặc kiểm soát việc sử dụng phương tiện.", + "b_translation": "Cố gắng hướng dẫn học sinh đi bộ một cách thận trọng, và yêu cầu sự hướng dẫn và sự hợp tác tích cực của cha mẹ để học sinh sử dụng xe đạp phải tuân thủ các quy tắc xe đạp để bảo vệ sự an toàn của mình cũng như sự an toàn của người khác. Hãy nghĩ đến tình huống lộn xộn trước lớp học và sức khỏe của học sinh, hoặc kiểm soát việc sử dụng xe đạp.", + "a_input": "안녕하십니까? 씩씩하게 도보로 통학하도록 지도해주시고, 부득이하게 자전거를 이용하는 학생들은 자신의 안전은 물론 타인의 안전까지 지킬 수 있도록 자전거 이용 수칙을 반드시 준수하도록 학부모님의 적극적인 지도와 협조를 부탁드립니다. 교문 앞 혼잡한 상황 및 학생들의 건강을 생각하시어 부득이한 상황(학생 다리 부상 등)이 아니면 차량 이용을 자제해주십시오. 자전거 이용 시 안전모(헬멧)와 팔꿈치·무릎 보호대, 장갑 등 안전장비를 반드시 착용합니다. 자전거를 미리 점검합니다(브레이크, 핸들, 안장, 타이어, 페달, 전조등 등). 횡단보도를 건널 때는 내려서 자전거를 끌고 건넙니다. 휴대전화를 사용하거나 이어폰(헤드셋)을 끼고 음악을 듣지 않습니다. 학교 앞까지 자전거를 가지고 오지 않으며 자전거 거치 장소에 올바르게 거치합니다. 13단지 쪽에서 통학할 경우 13단지 앞 사거리 자전거 거치 장소에 바르게 거치합니다. 14단지 쪽에서 통학할 경우 14단지 아파트 내 바르게 거치합니다.", + "b_input": "씩씩하게 도보로 통학하도록 지도해주시고, 부득이하게 자전거를 이용하는 학생들은 자신의 안전은 물론 타인의 안전까지 지킬 수 있도록 자전거 이용 수칙을 반드시 준수하도록 학부모님의 적극적인 지도와 협조를 부탁드립니다. 교문 앞 혼잡한 상황 및 학생들의 건강을 생각하시어 부득이한 상황(학생 다리 부상 등)이 아니면 차량 이용을 자제해주십시오. 자전거 이용 시 안전모(헬멧)와 팔꿈치·무릎 보호대, 장갑 등 안전장비를 반드시 착용합니다. 자전거를 미리 점검합니다(브레이크, 핸들, 안장, 타이어, 페달, 전조등 등). 횡단보도를 건널 때는 내려서 자전거를 끌고 건넙니다. 휴대전화를 사용하거나 이어폰(헤드셋)을 끼고 음악을 듣지 않습니다. 학교 앞까지 자전거를 가지고 오지 않으며 자전거 거치 장소에 올바르게 거치합니다. 13단지 쪽에서 통학할 경우 13단지 앞 사거리 자전거 거치 장소에 바르게 거치합니다. 14단지 쪽에서 통학할 경우 14단지 아파트 내 바르게 거치합니다." + }, + { + "notice_id": "N11", + "title": "2026학년도 학교평가 안내", + "lang": "vi", + "a_chars": 304, + "b_chars": 150, + "input_reduction_pct": 50.7, + "a_time_sec": 20.57, + "b_time_sec": 6.97, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 2.95, + "a_translation": "Xin vui lòng tham gia vào việc xác định và cải thiện hoạt động giáo dục trong trường thông qua các hoạt động tham gia, liên lạc và hợp tác của cộng đồng giáo dục trường học. Đánh giá trường học sẽ được tiến hành vào tháng 11 với việc đánh giá hoạt động giáo dục trong trường học năm 2026 và khảo sát lập kế hoạch giáo dục cho năm 2027. Ủy ban quản lý trường học sẽ có một giám đốc, bảy ủy viên, một ủy viên, hai ủy viên phụ huynh, hai ủy viên sinh viên, một ủy viên bên ngoài tổng cộng 14 thành viên. Kết quả đánh giá trường học năm 2025 và kế hoạch đánh giá trường học năm 2026 được đưa vào danh sách đánh giá trường học.", + "b_translation": "Hãy kiểm tra lịch trình và nội dung đánh giá đầy đủ để bạn có thể tham gia vào việc chẩn đoán và cải thiện hoạt động giáo dục trong trường thông qua các hoạt động tham gia, giao tiếp và hợp tác của cộng đồng giáo dục trường.", + "a_input": "학부모님 안녕하십니까? 전체적인 평가 일정과 내용을 확인하셔서 학교 교육공동체의 참여·소통·협력 활동을 통한 학교교육활동의 진단과 개선이 이루어질 수 있도록 동참하여 주시기 바랍니다. 학교평가는 11월에 2026학년도 학교교육활동 평가 및 2027학년도 학교교육계획 수립 설문으로 진행됩니다. 학교평가운영위원회는 위원장 1명, 교원위원 7명, 직원위원 1명, 학부모위원 2명, 학생위원 2명, 외부위원 1명 총 14명으로 구성됩니다. 2025학년도 학교평가 결과 및 2026학년도 학교평가 계획은 학교 누리집-학교평가 메뉴에 탑재되어 있습니다.", + "b_input": "전체적인 평가 일정과 내용을 확인하셔서 학교 교육공동체의 참여·소통·협력 활동을 통한 학교교육활동의 진단과 개선이 이루어질 수 있도록 동참하여 주시기 바랍니다. 학교평가는 11월에 2026학년도 학교교육활동 평가 및 2027학년도 학교교육계획 수립 설문으로 진행됩니다." + }, + { + "notice_id": "N12", + "title": "제16기 학교운영위원회 위원 선출 결과 안내", + "lang": "vi", + "a_chars": 240, + "b_chars": 82, + "input_reduction_pct": 65.8, + "a_time_sec": 19.07, + "b_time_sec": 5.94, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 3.21, + "a_translation": "Kết quả của cuộc bầu cử ủy viên Ủy ban quản lý trường học lớp 16 của chúng tôi được thực hiện theo quy trình minh bạch về mặt dân chủ và pháp lý trong sự quan tâm tích cực và tham gia của cộng đồng giáo dục, Ủy ban quản lý trường được thành lập như sau: Chủ tịch Ủy ban quản lý trường phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh.", + "b_translation": "Nếu bạn có ý kiến về việc quản lý trường học và tổ chức hội nghị, hãy gửi ý kiến của bạn qua email trường: galsanes@sen.go.kr.", + "a_input": "우리학교 제16기 학교운영위원회 위원 선거가 교육공동체의 적극적인 관심과 참여 속에서 민주적이고 법적인 투명한 절차에 따라 실시된 결과 학교운영위원회가 다음과 같이 구성되었습니다. 학교운영위원회 위원장은 학부모위원 전선영(2학년, 6학년 학부모)이며 부위원장은 지역위원 이상임이다. 학교운영 및 회의 안건에 대하여 의견이 있으신 분은 학교메일(galsanes@sen.go.kr)로 의견 주시면 안건 심의 시 반영하도록 하겠습니다.", + "b_input": "학교운영 및 회의 안건에 대하여 의견이 있으신 분은 학교메일(galsanes@sen.go.kr)로 의견 주시면 안건 심의 시 반영하도록 하겠습니다." + }, + { + "notice_id": "N13", + "title": "2026학년도 기초학력 진단검사 실시 안내", + "lang": "vi", + "a_chars": 426, + "b_chars": 195, + "input_reduction_pct": 54.2, + "a_time_sec": 34.59, + "b_time_sec": 15.78, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 2.19, + "a_translation": "Một trong những hoạt động chẩn đoán này là kiểm tra khả năng chẩn đoán cơ bản được thực hiện trong lớp 2 đến 6 để xác định việc học cơ bản cho sự bắt đầu của lớp. Đây không phải là một đánh giá phản ánh thành tích, mà là để xác định điểm mạnh và điểm yếu, tiềm năng và nhu cầu giáo dục của học sinh và cung cấp phản hồi hữu ích cho việc học tiếp theo. Năm thứ nhất thực hiện các hoạt động chẩn đoán tích hợp như quan sát giáo viên, tư vấn cho học sinh và bậc cha mẹ. Năm thứ hai thực hiện kiểm tra chẩn đoán đọc + viết vào ngày 6 tháng 3. Năm thứ ba thực hiện kiểm tra chẩn đoán đọc, viết và phân tích vào ngày 5 tháng 3. Năm thứ tư thực hiện kiểm tra chẩn đoán ngôn ngữ, toán học, tiếng Anh vào ngày 9 tháng 5. Năm năm thứ năm thực hiện kiểm tra chẩn đoán ngôn ngữ quốc gia, toán học, tiếng Anh vào ngày 6 tháng 3. Năm thứ ba thực hiện kiểm tra chẩn đoán toán vào ngày 5 tháng 3.", + "b_translation": "Năm thứ hai vào ngày 6 tháng 3 có bài kiểm tra chẩn đoán đọc + viết và tính toán. Năm thứ ba có bài kiểm tra chẩn đoán đọc, viết và tính toán. Năm thứ tư có bài kiểm tra chẩn đoán ngôn ngữ, toán học và tiếng Anh. Năm thứ năm có bài kiểm tra chẩn đoán ngôn ngữ, toán học và tiếng Anh vào ngày 9 tháng 3. Năm thứ sáu có bài kiểm tra chẩn đoán ngôn ngữ, toán học và tiếng Anh. Năm thứ sáu có bài kiểm tra chẩn đoán ngôn ngữ, toán học và tiếng Anh vào ngày 5 tháng 3.", + "a_input": "안녕하십니까? 이번 진단 활동 중 2~6학년에서 이루어지는 기초학력 진단검사는 학년 시작을 위한 기초 학습을 확인하는 내용입니다. 이 검사는 성적에 반영되는 평가가 아니며, 학생의 강점과 약점, 잠재력, 교육적 요구를 확인하고 향후 학습을 위한 유용한 피드백을 제공하기 위함입니다. 1학년은 3월 중 교사 관찰, 학생·학부모 상담 등 통합적 진단활동을 실시합니다. 2학년은 3월 6일(금)에 읽기+쓰기, 셈하기 진단검사를 실시합니다. 3학년은 3월 5일(목)에 읽기, 쓰기, 셈하기 진단검사를 실시합니다. 4학년은 3월 5일(목)에 국어, 수학, 영어 진단검사를 실시합니다. 5학년은 3월 9일(월)에 국어, 수학, 영어 진단검사를 실시합니다. 6학년은 3월 5일(목)에 국어, 수학, 영어 진단검사를 실시합니다. 결시생의 경우 등교일에 별도 검사를 실시합니다.", + "b_input": "2학년은 3월 6일(금)에 읽기+쓰기, 셈하기 진단검사를 실시합니다. 3학년은 3월 5일(목)에 읽기, 쓰기, 셈하기 진단검사를 실시합니다. 4학년은 3월 5일(목)에 국어, 수학, 영어 진단검사를 실시합니다. 5학년은 3월 9일(월)에 국어, 수학, 영어 진단검사를 실시합니다. 6학년은 3월 5일(목)에 국어, 수학, 영어 진단검사를 실시합니다." + }, + { + "notice_id": "N14", + "title": "2026 실종·유괴 예방교육 안내", + "lang": "vi", + "a_chars": 550, + "b_chars": 488, + "input_reduction_pct": 11.3, + "a_time_sec": 36.97, + "b_time_sec": 38.78, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 0.95, + "a_translation": "Gần đây có một vụ bắt cóc của một học sinh trung học ở Quảng Đông, nơi cảnh sát đã truy lùng nghi phạm. Chúng tôi yêu cầu các bạn cùng hướng dẫn và tiếp tục hướng dẫn để có thể làm điều này. Chúng tôi tìm thấy các khu vực tuyệt vời và khu vực an toàn gần nhà và đi theo con đường an toàn mà họ đã hứa hẹn với cha mẹ và cha mẹ. Chúng tôi không bao giờ cho biết tên và số điện thoại của họ. Chúng tôi không bao giờ cho biết tên và số điện thoại của họ. Chúng tôi không bao giờ theo dõi bất kỳ nghi phạm nào khi họ không ăn đồ uống hoặc kẹo cao su của người lạ, và họ không bao giờ theo dõi bất kỳ nghi phạm nào. Nếu một người lạ yêu cầu giúp đỡ, họ thường xuyên từ chối. Trong trường hợp khẩn cấp, họ yêu cầu sự giúp đỡ lớn và đi vào một cửa hàng tiện lợi xung quanh, hoặc họ yêu cầu sự giúp đỡ của họ. Họ đăng ký danh tính của trẻ em, ảnh chụp ảnh, và thông tin của người bảo vệ, v.v. Chúng tôi không bao giờ đăng ký trước và đăng ký thông tin an toàn thời gian thông qua ứng dụng an toàn.", + "b_translation": "Hãy cùng hướng dẫn và tiếp tục hướng dẫn những điều sau đây trong gia đình, để bạn có thể làm điều này trở nên sống động hơn. Bạn có thể tìm thấy những khu vực tuyệt vời và an toàn gần nhà và đi theo những con đường an toàn mà bạn đã hứa với cha mẹ của mình. Bạn có thể đi cùng bạn bè của mình trên đường Hà Nội. Bạn không bao giờ cho biết tên và số điện thoại của bạn. Bạn không bao giờ cho biết tên của bạn. Bạn không bao giờ ăn đồ uống hoặc kẹo ngọt của người lạ, và bạn không bao giờ đi theo họ. Nếu một người lạ yêu cầu giúp đỡ, bạn sẽ từ chối một cách tôn trọng. Trong những trường hợp khẩn cấp, bạn sẽ kêu lên để giúp đỡ, bạn có thể đi vào một cửa hàng bên cạnh, bạn sẽ được gắn nhãn hiệu an toàn, bạn có thể đăng ký trước, bạn có thể đăng ký thông tin cá nhân của bạn, bạn có thể đăng ký thông qua ứng dụng an toàn. Bạn có thể thường xuyên chụp hình ảnh của bạn. Bạn không bao giờ để bạn ở nhà, bạn sẽ đi cùng bạn. Nếu bạn có tên của bạn, bạn sẽ sử dụng các sản phẩm an toàn thời gian để phòng ngừa.", + "a_input": "최근 양천구에서 초등학생에게 접근하여 유괴를 시도한 사건이 발생하여 경찰이 용의자를 검거한 사례가 있었습니다. 가정에서도 다음과 같은 사항을 함께 지도해 주시고, 이를 생활화할 수 있도록 지속적으로 지도해주시기 바랍니다. 집 근처에 있는 우범지역과 안전 사각지대를 파악하여 부모님과 약속한 안전한 길로 다닙니다. 등·하굣길에는 친구들과 함께 다닙니다. 이름과 전화번호는 절대로 알려주지 않습니다. 모르는 사람이 주는 음료나 과자 등을 함부로 먹지 않으며, 물건 등을 준다고 유인해도 절대 따라가지 않습니다. 낯선 사람이 도움을 요청하면 정중하게 거절합니다. 위급한 상황에서는 큰소리로 도움을 요청하고 주변의 편의점이나 '아동안전지킴이집' 간판이 부착된 가게에 들어가 도움을 요청합니다. 아이의 지문, 사진, 보호자 인적 사항 등을 미리 등록합니다(경찰서, 안전드림앱을 통해 등록 가능). 정기적으로 아이 사진을 찍습니다. 아이를 집에 혼자 두지 않고 항상 자녀와 함께 다닙니다. 이름표, 미아방지 팔찌 등 실종아동 예방용품을 활용합니다. 아이가 실종된 경우 즉시 112에 신고합니다.", + "b_input": "가정에서도 다음과 같은 사항을 함께 지도해 주시고, 이를 생활화할 수 있도록 지속적으로 지도해주시기 바랍니다. 집 근처에 있는 우범지역과 안전 사각지대를 파악하여 부모님과 약속한 안전한 길로 다닙니다. 등·하굣길에는 친구들과 함께 다닙니다. 이름과 전화번호는 절대로 알려주지 않습니다. 모르는 사람이 주는 음료나 과자 등을 함부로 먹지 않으며, 물건 등을 준다고 유인해도 절대 따라가지 않습니다. 낯선 사람이 도움을 요청하면 정중하게 거절합니다. 위급한 상황에서는 큰소리로 도움을 요청하고 주변의 편의점이나 '아동안전지킴이집' 간판이 부착된 가게에 들어가 도움을 요청합니다. 아이의 지문, 사진, 보호자 인적 사항 등을 미리 등록합니다(경찰서, 안전드림앱을 통해 등록 가능). 정기적으로 아이 사진을 찍습니다. 아이를 집에 혼자 두지 않고 항상 자녀와 함께 다닙니다. 이름표, 미아방지 팔찌 등 실종아동 예방용품을 활용합니다. 아이가 실종된 경우 즉시 112에 신고합니다." + }, + { + "notice_id": "N15", + "title": "미세먼지 정보지", + "lang": "vi", + "a_chars": 256, + "b_chars": 111, + "input_reduction_pct": 56.6, + "a_time_sec": 18.28, + "b_time_sec": 9.96, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.84, + "a_translation": "PM10 là bụi có đường kính dưới 10μm và PM2.5 là bụi có đường kính dưới 2.5μm. Các hạt nhỏ, mà không bị ngã vào mũi, miệng, cơ quan, xâm nhập trực tiếp vào phổi, não. Khi nồng độ của bụi là 'không tốt', các nhóm nhạy cảm như trẻ em, người già, người mắc bệnh mãn tính kiểm soát hoạt động ngoài trời. Khi hoạt động ngoài trời, họ đeo mặt nạ y tế, rửa ngay khi trở về. Họ kiểm tra thời tiết theo dõi.", + "b_translation": "Nếu nồng độ ô nhiễm \"xấu\" hơn, nhóm nhạy cảm như trẻ em, người già, những người bị bệnh mãn tính sẽ ngừng hoạt động ngoài trời, đeo mặt nạ để chăm sóc sức khỏe khi hoạt động ngoài trời, rửa mặt ngay khi trở về, kiểm tra thời tiết thường xuyên.", + "a_input": "미세먼지(PM10)는 지름이 10μm 이하의 먼지이고 초미세먼지(PM2.5)는 지름이 2.5μm 이하의 먼지이다. 미세먼지는 입자가 미세하여 코, 구강, 기관지에서 걸러지지 않고 폐포(뇌)까지 직접 침투한다. 미세먼지 농도가 '나쁨' 이상일 경우 어린이, 노인, 만성질환자 등 민감군은 실외 활동을 자제합니다. 외부 활동 시 보건용 마스크를 착용하고, 돌아오자마자 씻어냅니다. 기상 상황을 수시로 확인합니다. 쉬는 시간마다 3~5분 정기적 환기를 실시합니다.", + "b_input": "미세먼지 농도가 '나쁨' 이상일 경우 어린이, 노인, 만성질환자 등 민감군은 실외 활동을 자제합니다. 외부 활동 시 보건용 마스크를 착용하고, 돌아오자마자 씻어냅니다. 기상 상황을 수시로 확인합니다." + }, + { + "notice_id": "N16", + "title": "알림장 2026.04.07 지역연계 체험학습", + "lang": "vi", + "a_chars": 412, + "b_chars": 412, + "input_reduction_pct": 0.0, + "a_time_sec": 34.03, + "b_time_sec": 33.35, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.02, + "a_translation": "Ngày mai (Thứ Tư, ngày 8 tháng 4) sẽ có một buổi học thử nghiệm địa phương. Giữa trưa ở trường trung học, trung học và trường học sẽ được tổ chức vào thời điểm bình thường. Hãy chuẩn bị tốt: áo thun lớp, áo quần dài, giày dép, đủ nước, một chút thức ăn trong thùng, một chiếc túi xách, một túi nhựa, vải, vải giấy, vải giấy, áo thun, mũ). Các thiết bị sẽ được đưa vào phòng thư ký và được sử dụng khi có túi xách thoải mái hơn. Bạn sẽ được huấn luyện về an toàn cho người bảo vệ. Bạn sẽ được bảo vệ tốt, không chạy hoặc chơi đùa. Bạn sẽ được an toàn trên đường băng thông, giữ dấu hiệu. Bạn sẽ được trang phục và trang phục phù hợp với các hoạt động ngoài trời. Bạn sẽ không làm những hành động nguy hiểm như leo lên cao hoặc đi ra khỏi đám đông. Bạn sẽ không bao giờ làm những hành động bất thường khi bạn đi theo cách tự nhiên.", + "b_translation": "Ngày mai (Thứ Tư, ngày 8 tháng 4) sẽ có một buổi học thử nghiệm địa phương. Giữa trưa ở trường trung học, trung học và trường học sẽ được tổ chức vào thời điểm bình thường. Hãy chuẩn bị tốt: áo thun lớp, áo quần dài, giày dép, đủ nước, một chút thức ăn trong thùng, một chiếc túi xách, một túi nhựa, vải, vải giấy, vải giấy, áo thun, mũ). Các thiết bị sẽ được đưa vào phòng thư ký và được sử dụng khi có túi xách thoải mái hơn. Bạn sẽ được huấn luyện về an toàn cho người bảo vệ. Bạn sẽ được bảo vệ tốt, không chạy hoặc chơi đùa. Bạn sẽ được an toàn trên đường băng thông, giữ dấu hiệu. Bạn sẽ được trang phục và trang phục phù hợp với các hoạt động ngoài trời. Bạn sẽ không làm những hành động nguy hiểm như leo lên cao hoặc đi ra khỏi đám đông. Bạn sẽ không bao giờ làm những hành động bất thường khi bạn đi theo cách tự nhiên.", + "a_input": "지역연계 체험학습이 내일(4월 8일 수요일)에 진행됩니다. 평소와 같은 시각에 등교, 하교, 학교에서 급식을 합니다. 준비물을 잘 챙겨옵니다: 학급티셔츠(속에 긴팔), 외투, 긴 바지, 운동화, 충분한 물, 통에 담은 약간의 간식, 1인용 돗자리, 비닐봉지 1장, 물티슈, 휴지, 필통, 모자(선택). 준비물은 책가방에 넣어 이동하며, 더 편한 가방이 있으면 사용해도 됩니다. 보호자에게 안전교육 받고 다짐하고 옵니다. 이동 시 주변을 잘 살피며, 뛰거나 장난을 치지 않습니다. 신호를 지키며 횡단보도로 안전하게 건넙니다. 야외활동에 맞는 몸가짐과 옷차림을 합니다. 높은 곳에 올라가거나 무리를 이탈하는 등 위험한 행동을 하지 않습니다. 자연환경을 훼손하는 행동을 하지 않습니다. 낯선 사람이 같이 가자고 할 때 절대 따라가지 않습니다.", + "b_input": "지역연계 체험학습이 내일(4월 8일 수요일)에 진행됩니다. 평소와 같은 시각에 등교, 하교, 학교에서 급식을 합니다. 준비물을 잘 챙겨옵니다: 학급티셔츠(속에 긴팔), 외투, 긴 바지, 운동화, 충분한 물, 통에 담은 약간의 간식, 1인용 돗자리, 비닐봉지 1장, 물티슈, 휴지, 필통, 모자(선택). 준비물은 책가방에 넣어 이동하며, 더 편한 가방이 있으면 사용해도 됩니다. 보호자에게 안전교육 받고 다짐하고 옵니다. 이동 시 주변을 잘 살피며, 뛰거나 장난을 치지 않습니다. 신호를 지키며 횡단보도로 안전하게 건넙니다. 야외활동에 맞는 몸가짐과 옷차림을 합니다. 높은 곳에 올라가거나 무리를 이탈하는 등 위험한 행동을 하지 않습니다. 자연환경을 훼손하는 행동을 하지 않습니다. 낯선 사람이 같이 가자고 할 때 절대 따라가지 않습니다." + }, + { + "notice_id": "N17", + "title": "알림장 2026.04.15 준비물 안내", + "lang": "vi", + "a_chars": 83, + "b_chars": 83, + "input_reduction_pct": 0.0, + "a_time_sec": 5.33, + "b_time_sec": 5.4, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 0.99, + "a_translation": "Chúng tôi chuẩn bị cho các bộ ghi âm, quần áo, giày dép, tai nghe dành cho cá nhân cho đến ngày 20 tháng 4.", + "b_translation": "Chúng tôi chuẩn bị cho các bộ ghi âm, quần áo, giày dép, tai nghe dành cho cá nhân cho đến ngày 20 tháng 4.", + "a_input": "리코더, 간편복, 운동화를 준비합니다. 개인용 이어폰(3.5mm, C타입)을 4월 20일까지 준비합니다(크롬북 이용 수업 시 소리를 들을 때 사용).", + "b_input": "리코더, 간편복, 운동화를 준비합니다. 개인용 이어폰(3.5mm, C타입)을 4월 20일까지 준비합니다(크롬북 이용 수업 시 소리를 들을 때 사용)." + }, + { + "notice_id": "N18", + "title": "알림장 2026.04.17 주요 일정 안내", + "lang": "vi", + "a_chars": 199, + "b_chars": 199, + "input_reduction_pct": 0.0, + "a_time_sec": 13.09, + "b_time_sec": 12.75, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.03, + "a_translation": "Tôi sẽ ghi lại một cuốn sách để đọc hơn một cuốn sách cho đến ngày 27 tháng 4. Tôi sẽ chuẩn bị cho những chiếc tai nghe 3.5mm cho cá nhân cho đến ngày 20 tháng 4. Vì vậy, đánh giá bằng phẳng của lớp 2 về toán học là vào ngày 23 tháng 4. Vì vậy, chúng tôi sẽ học theo cách giả mạo các câu trả lời của chương 9 của bài học và tìm hiểu khái niệm của cuốn sách nhỏ.", + "b_translation": "Tôi sẽ ghi lại một cuốn sách để đọc hơn một cuốn sách cho đến ngày 27 tháng 4. Tôi sẽ chuẩn bị cho những chiếc tai nghe 3.5mm cho cá nhân cho đến ngày 20 tháng 4. Vì vậy, đánh giá bằng phẳng của lớp 2 về toán học là vào ngày 23 tháng 4. Vì vậy, chúng tôi sẽ học theo cách giả mạo các câu trả lời của chương 9 của bài học và tìm hiểu khái niệm của cuốn sách nhỏ.", + "a_input": "독서록 1편 이상 써오기 — 윤독도서 [하룻밤]을 4월 27일까지 기록합니다. 개인용 이어폰(3.5mm, C타입)을 4월 20일까지 준비합니다. 수학 2단원 평면도형 평가가 4월 23일에 있으므로 학습지 9장 오답 위주로 공부하고 미니북의 개념을 외웁니다. 신체검사는 4월 22일(수요일)에 진행됩니다. 3학년 전체 체육은 4월 24일(5교시)에 있습니다.", + "b_input": "독서록 1편 이상 써오기 — 윤독도서 [하룻밤]을 4월 27일까지 기록합니다. 개인용 이어폰(3.5mm, C타입)을 4월 20일까지 준비합니다. 수학 2단원 평면도형 평가가 4월 23일에 있으므로 학습지 9장 오답 위주로 공부하고 미니북의 개념을 외웁니다. 신체검사는 4월 22일(수요일)에 진행됩니다. 3학년 전체 체육은 4월 24일(5교시)에 있습니다." + }, + { + "notice_id": "N19", + "title": "2026학년도 3학년 맞춤형 학업성취도 자율평가 실시 안내", + "lang": "vi", + "a_chars": 241, + "b_chars": 154, + "input_reduction_pct": 36.1, + "a_time_sec": 23.18, + "b_time_sec": 13.43, + "a_chunks": 1, + "b_chunks": 1, + "speedup_x": 1.73, + "a_translation": "Thử nghiệm tự đánh giá hiệu quả học tập theo quy định của lớp 3 năm 2026 sẽ được thực hiện vào ngày 9 tháng 4 năm 2026. Các bài đánh giá sẽ được đưa ra trong phạm vi năng lực, khả năng sửa chữa và năm học trước năm học. Đây không phải là đánh giá phản ánh vào điểm số, mà là để xác định mức độ học tập của từng học sinh. Cuối tháng 4 kết quả đánh giá sẽ được báo cáo một cách riêng lẻ, vì vậy chúng ta mong muốn mỗi gia đình phải xác định những điểm mạnh và sự bổ sung cần thiết của học sinh.", + "b_translation": "Thử nghiệm tự đánh giá thành thăng tiến học tập theo quy định năm 2026 sẽ được thực hiện vào ngày 9 tháng 4. Cuối tháng 4 kết quả đánh giá sẽ được công bố một cách riêng biệt, vì vậy chúng tôi mong muốn bạn nhận ra những điểm mạnh và sự bổ sung cần thiết của học sinh trong mỗi gia đình.", + "a_input": "2026학년도 3학년 '맞춤형 학업성취도 자율평가'가 4월 9일(목)에 실시됩니다. 평가 내용은 문해력, 수리력이며 직전 학년(2학년)의 범위에서 출제됩니다. 이 평가는 성적에 반영되는 평가가 아니며 학생 개개인의 학습 수준 파악용입니다. 4월 말에 평가 결과를 개별 통지할 예정이니 각 가정에서도 학생의 강점과 보완이 필요한 부분을 파악하여 주시기 바랍니다. 평가 당일 결석한 학생은 별도로 실시하기 어려우므로 가급적 등교합니다.", + "b_input": "2026학년도 3학년 '맞춤형 학업성취도 자율평가'가 4월 9일(목)에 실시됩니다. 4월 말에 평가 결과를 개별 통지할 예정이니 각 가정에서도 학생의 강점과 보완이 필요한 부분을 파악하여 주시기 바랍니다. 평가 당일 결석한 학생은 별도로 실시하기 어려우므로 가급적 등교합니다." + } +] \ No newline at end of file diff --git a/model/translation_tts/outputs/ab_compare/vi/summary.md b/model/translation_tts/outputs/ab_compare/vi/summary.md new file mode 100644 index 0000000000000000000000000000000000000000..baab5b93bc498d487be7350e6006a39e5ed874f4 --- /dev/null +++ b/model/translation_tts/outputs/ab_compare/vi/summary.md @@ -0,0 +1,24 @@ +# A/B 번역 비교 결과 + +언어: vi | 모델: facebook/nllb-200-distilled-600M + +| 공지 | 제목 | A입력(자) | B입력(자) | 입력단축 | A시간(s) | B시간(s) | 속도향상 | +|---|---|---|---|---|---|---|---| +| N01 | 대기오염(미세먼지) 대응 및 질병결석 | 519 | 349 | -32.8% | 45.96 | 21.52 | x2.14 | +| N02 | 2026 초등안심벨 배부 안내 | 347 | 197 | -43.2% | 35.58 | 20.75 | x1.71 | +| N04 | 주간학습계획 3주(3.23~3.27) | 577 | 468 | -18.9% | 26.32 | 54.71 | x0.48 | +| N05 | 주간학습계획 3주(3.23~3.27) | 431 | 386 | -10.4% | 40.53 | 45.45 | x0.89 | +| N06 | 주간학습계획 8주(4.20~4.24) | 712 | 552 | -22.5% | 32.54 | 22.95 | x1.42 | +| N07 | 주간학습계획 9주(4.27~5.1) | 212 | 171 | -19.3% | 22.07 | 15.94 | x1.38 | +| N08 | 2026학년도 1학기 도서관 도서 구 | 313 | 67 | -78.6% | 35.19 | 7.69 | x4.58 | +| N09 | 갈산 교육공동체 약속 실천 안내 | 277 | 167 | -39.7% | 36.62 | 23.52 | x1.56 | +| N10 | 안전한 등하교를 위한 협조 요청 및 | 496 | 488 | -1.6% | 58.23 | 50.21 | x1.16 | +| N11 | 2026학년도 학교평가 안내 | 304 | 150 | -50.7% | 23.73 | 6.78 | x3.5 | +| N12 | 제16기 학교운영위원회 위원 선출 결 | 240 | 82 | -65.8% | 19.98 | 6.76 | x2.96 | +| N13 | 2026학년도 기초학력 진단검사 실시 | 426 | 195 | -54.2% | 34.31 | 16.95 | x2.02 | +| N14 | 2026 실종·유괴 예방교육 안내 | 550 | 488 | -11.3% | 37.96 | 36.68 | x1.03 | +| N15 | 미세먼지 정보지 | 256 | 111 | -56.6% | 17.39 | 8.57 | x2.03 | +| N16 | 알림장 2026.04.07 지역연계 | 412 | 412 | -0.0% | 30.96 | 34.19 | x0.91 | +| N17 | 알림장 2026.04.15 준비물 안 | 83 | 83 | -0.0% | 5.31 | 5.29 | x1.01 | +| N18 | 알림장 2026.04.17 주요 일정 | 199 | 199 | -0.0% | 11.72 | 12.21 | x0.96 | +| N19 | 2026학년도 3학년 맞춤형 학업성취 | 241 | 154 | -36.1% | 20.85 | 13.53 | x1.54 | diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N01.md b/model/translation_tts/outputs/ab_quality_eval/vi/N01.md new file mode 100644 index 0000000000000000000000000000000000000000..97ecf2f92ed52d754148feec30e07fefc8f5158c --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N01.md @@ -0,0 +1,48 @@ +# N01 — 대기오염(미세먼지) 대응 및 질병결석 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 519자 | 349자 | +| **Gemini 점수** | **55점** | **40점** | +| 주요 문제 | 번역이 중간에 완전히 끊겼으며, 중간놀이(chơi trung)를 직역하거나 아토피를 위축증(atrophy)으로 잘못 번역했고 담임교사(giáo viên chủ nhiệm) 등 상용어 표현이 미흡합니다. | 초등학생을 대학생(sinh viên)으로 오역했고, 질병명과 서류 종류가 특수 기호(︎)로 치환되어 정보 전달이 불가능하며 핵심 절차가 대거 생략되었습니다. | + +**Round-trip 이슈:** 역번역 결과, 8시 50분 사전 연락, 질병 결석 인정 절차, 5일 이내 결석계 제출, 학교명, 날짜 등 핵심 정보가 모두 누락되었습니다. + +**종합 평가:** 두 번역 모두 실제 가정통신문으로 사용하기에 부적합합니다. A는 문장이 중간에 끊겨 가장 중요한 '질병 결석 인정 절차'를 알 수 없고, B는 기호(︎) 표기 오류와 대상(초등생) 오역으로 인해 정보로서의 가치가 없습니다. 굳이 선택하자면 문장이라도 온전한 A가 낫지만, 핵심 내용 누락으로 인해 재번역이 반드시 필요합니다. + +--- + +## A: 원문 전체 (519자) + +**입력:** +``` +학부모님 안녕하십니까? 따뜻한 봄이 오고 있지만 대기오염으로 학생들의 건강상 피해가 우려되고 있습니다. PM10 150 이상 또는 PM2.5 75 이상이 2시간 지속되면 미세먼지 주의보가 발령된다. 주의보 시 체육시간, 중간놀이, 점심시간에 실외활동을 금지한다. 학생이 의사의 진단을 통해 대기오염 관련 기저질환(천식, 알레르기, 아토피, 호흡기질환, 심혈관 질환 등)이 있는 경우, 학부모께서는 해당 질환에 대한 의사의 진단서 또는 의견서(의사 소견서, 진료확인서 등)를 담임 선생님께 제출해 주시기 바랍니다. 학기초 1회 제출하며 대기오염과 유관성이 드러나는 의사 소견 또는 향후 치료 의견을 명시한다. 의사소견서 및 진단서를 사전 제출한 경우, 등교시간대 거주지 또는 학교 주변 실시간 미세먼지 농도가 '나쁨' 이상일 때 당일 수업 시작 시간(8시 50분) 이전에 학부모가 학교에 사전 연락한 경우 질병결석으로 인정된다. 이런 경우 5일 이내에 결석계를 제출하여 주시기를 바랍니다. 2026. 3. 17. 서울갈산초등학교장 +``` + +**번역:** +``` +Chào cha mẹ! Mùa xuân ấm áp đang đến nhưng bị ô nhiễm khí quyển có nguy cơ ảnh hưởng đến sức khỏe của học sinh. Nếu PM10 150 hoặc PM2.5 75 cao hơn kéo dài 2 giờ, cảnh báo ô nhiễm được ban hành. Thời gian tập thể dục, chơi trung và hoạt động ngoài trời trong giờ ăn trưa bị cấm. Nếu học sinh được chẩn đoán bởi bác sĩ có các căn bệnh trầm trọng liên quan đến ô nhiễm khí quyển (những căn bệnh liên quan đến ô nhiễm khí quyển, dị ứng, atrophy, bệnh hô hấp, bệnh tim mạch, v.v.), cha mẹ nên gửi cho giáo viên đảm bảo có được chẩn đoán hoặc ý kiến của bác sĩ về căn bệnh đó (chẩn đoán của bác sĩ, xác nhận của chẩn đoán). +``` + +**한국어 역번역:** +``` +부모님 안녕하세요! 따뜻한 봄이 오고 있지만 대기 오염이 학생들의 건강에 영향을 미칠 위험이 있습니다. PM10 150 또는 PM2.5 75 이상이 2시간 지속되면 오염 경보가 발령됩니다. 체육 시간, 중간 놀이 및 점심시간 야외 활동은 금지됩니다. 학생이 의사로부터 대기 오염과 관련된 심각한 질병(대기 오염 관련 질환, 알레르기, 위축증(Atrophy), 호흡기 질환, 심혈관 질환 등)을 진단받은 경우, 학부모는 해당 질환에 대한 의사의 진단이나 의견(의사 진단, 진단 확인)을 교사에게 보내야 합니다. +``` + +## B: TODO만 (349자) + +**입력:** +``` +학생이 의사의 진단을 통해 대기오염 관련 기저질환(천식, 알레르기, 아토피, 호흡기질환, 심혈관 질환 등)이 있는 경우, 학부모께서는 해당 질환에 대한 의사의 진단서 또는 의견서(의사 소견서, 진료확인서 등)를 담임 선생님께 제출해 주시기 바랍니다. 학기초 1회 제출하며 대기오염과 유관성이 드러나는 의사 소견 또는 향후 치료 의견을 명시한다. 의사소견서 및 진단서를 사전 제출한 경우, 등교시간대 거주지 또는 학교 주변 실시간 미세먼지 농도가 '나쁨' 이상일 때 당일 수업 시작 시간(8시 50분) 이전에 학부모가 학교에 사전 연락한 경우 질병결석으로 인정된다. 이런 경우 5일 이내에 결석계를 제출하여 주시기를 바랍니다. +``` + +**번역:** +``` +Nếu sinh viên được bác sĩ chẩn đoán có một căn bệnh cơ sở liên quan đến ô nhiễm không khí (như: ︎, ︎, ︎, ︎, ︎, ︎, ︎, ︎) thì cha mẹ nên gửi cho giáo viên bản chẩn đoán hoặc ý kiến của bác sĩ về căn bệnh đó (như: ︎, ︎). +``` +**한국어 역번역:** +``` +만약 대학생(Sinh viên)이 의사로부터 대기 오염과 관련된 기저 질환(예: ︎, ︎, ︎, ︎)을 진단받았다면, 학부모는 교사에게 해당 질환에 대한 진단서나 의사 소견서(예: ︎, ︎)를 제출해야 합니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N02.md b/model/translation_tts/outputs/ab_quality_eval/vi/N02.md new file mode 100644 index 0000000000000000000000000000000000000000..34cd2c8d6533f417730f8e5c1103a765c7ac1f4b --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N02.md @@ -0,0 +1,48 @@ +# N02 — 2026 초등안심벨 배부 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 347자 | 197자 | +| **Gemini 점수** | **45점** | **78점** | +| 주요 문제 | 핵심 단어인 '벨(Chuông)'을 '솜(Bông)'으로 오역했고, 초등학생을 대학생으로 번역했으며, 무엇보다 주의사항과 연락처 등 후반부 내용이 완전히 누락됨. | 장난(장난 사용)을 가구/가재도구로 오역하여 문맥이 어색하고, 학부모를 'Bạn'으로 지칭하여 학교 공문서로서의 격식이 부족함. | + +**Round-trip 이슈:** A는 안심벨을 '솜'으로 오역하고 후반부 내용이 통째로 누락되었으며, B는 '장난'을 '가구'로, '어깨끈'을 '손목끈'으로 잘못 번역함. + +**종합 평가:** B가 학부모 전달 목적에 훨씬 적합합니다. A는 번역이 중간에 끊겼을 뿐만 아니라 안심벨을 솜으로 오역하여 정보 전달이 불가능한 수준이나, B는 일부 단어 오역에도 불구하고 충전 주기와 부착 위치 등 부모가 수행해야 할 핵심 정보(TODO)를 대부분 포함하고 있습니다. + +--- + +## A: 원문 전체 (347자) + +**입력:** +``` +학부모님 안녕하십니까? 서울시는 최근 어린이 안전에 대한 우려가 커짐에 따라 '내 아이 지키는 초등안심벨' 지원사업을 추진하고 있다. 지난 3월 1학년 배부에 이어 2~6학년 전체 학생에게 배부합니다. 가정에서도 자녀가 안심벨을 올바르게 착용하고, 실제 위급한 상황에서만 사용할 수 있도록 함께 지도하여 주시기 바랍니다. 실제 위급 상황에서만 사용하도록 지도해주세요. 경고음이 매우 크므로 장난 사용에 주의해주세요. 가방 어깨끈 등 손이 잘 닿는 곳에 부착해주세요. 미사용 시 대기 시간은 약 2년이나, 아이 안전을 위해 2~3개월마다 충전을 권장합니다. 제조사 SG생활안전(주) 고객센터는 070-4992-8428이다. +``` + +**번역:** +``` +Xin chào cha mẹ và cha mẹ! Sau khi các mối quan tâm về an toàn của trẻ em gia tăng gần đây, thành phố Seoul đang thúc đẩy dự án hỗ trợ 'Bông an toàn sơ cấp bảo vệ con tôi'. Tháng 3 vừa qua, nó sẽ được phát hành cho sinh viên lớp 1 và 2 đến 6 năm. Xin vui lòng hướng dẫn con bạn cùng nhau để trẻ em đeo chiếc an toàn đúng cách và chỉ sử dụng trong trường hợp khẩn cấp thực tế. Hãy hướng dẫn trẻ sử dụng nó chỉ trong trường hợp khẩn cấp thực tế. +``` + +**한국어 역번역:** +``` +부모님과 부모님 안녕하세요! 최근 아동 안전 우려가 커진 후, 서울시는 '내 아이를 지키는 초등 안심 솜(Bông)' 지원 사업을 추진하고 있습니다. 지난 3월 1학년 대학생(sinh viên)에게 배포될 것이고 2년에서 6년입니다. 아이가 안심을 잘 착용하고 실제 위급 상황에서만 사용하도록 지도해 주세요. 실제 위급 상황에서만 사용하도록 지도해 주세요. +``` + +## B: TODO만 (197자) + +**입력:** +``` +가정에서도 자녀가 안심벨을 올바르게 착용하고, 실제 위급한 상황에서만 사용할 수 있도록 함께 지도하여 주시기 바랍니다. 실제 위급 상황에서만 사용하도록 지도해주세요. 경고음이 매우 크므로 장난 사용에 주의해주세요. 가방 어깨끈 등 손이 잘 닿는 곳에 부착해주세요. 미사용 시 대기 시간은 약 2년이나, 아이 안전을 위해 2~3개월마다 충전을 권장합니다. +``` + +**번역:** +``` +Bạn cũng nên hướng dẫn trẻ sử dụng chiếc chuông an toàn đúng cách, chỉ trong trường hợp khẩn cấp thực tế, chỉ sử dụng trong trường hợp khẩn cấp thực tế, vì cảnh báo rất lớn, hãy cẩn thận với việc sử dụng đồ đạc. Hãy gắn túi xách như dây đeo tay ở nơi có thể chạm được. Thời gian chờ đợi là khoảng hai năm, và chúng tôi khuyên bạn nên sạc mỗi hai đến ba tháng để đảm bảo sự an toàn của trẻ. +``` +**한국어 역번역:** +``` +당신(Bạn)은 또한 아이가 안심벨을 올바르게 사용하도록 지도해야 합니다. 실제 위급 상황에서만, 실제 위급 상황에서만 사용하세요. 경고가 매우 크니 가구/물품(đồ đạc) 사용에 주의하세요. 가방을 손이 닿는 곳의 손목 스트랩(dây đeo tay)처럼 부착하세요. 대기 시간은 약 2년이며, 아이의 안전을 위해 2~3개월마다 충전할 것을 권장합니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N04.md b/model/translation_tts/outputs/ab_quality_eval/vi/N04.md new file mode 100644 index 0000000000000000000000000000000000000000..26bd1a7ddb6d744339c8a859dc1cd2e760379454 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N04.md @@ -0,0 +1,48 @@ +# N04 — 주간학습계획 3주(3.23~3.27) 및 출결규정 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 577자 | 468자 | +| **Gemini 점수** | **45점** | **20점** | +| 주요 문제 | 원문의 핵심인 결석 관련 규정을 모두 생략했으며, '담임교사(Giáo viên chủ nhiệm)'를 '성인 교사'로, '학교종이(앱)'를 '학교 종류'로 오역함. | 단어(viên)가 비정상적으로 반복되는 시스템 오류가 발생했으며, 문장이 중간에 끊기고 대다수의 필수 행동 정보가 소실됨. | + +**Round-trip 이슈:** '담임교사'를 성인 교사/회사로 오역하고, 결석 사유별(질병, 경조사, 감염병) 필수 증빙 서류와 제출 기한 정보가 모두 누락됨. + +**종합 평가:** 두 번역 모두 실제 학부모에게 전달하기 불가능한 수준입니다. A는 문장 형태는 유지했으나 핵심 지침인 결석 규정을 누락했고, B는 기술적 오류로 단어가 무한 반복되며 내용이 잘렸으므로 A가 상대적으로 낫지만 재번역이 반드시 필요합니다. + +--- + +## A: 원문 전체 (577자) + +**입력:** +``` +교외체험학습은 한번에 연속 10일까지 신청 가능하며 1년 동안 19일까지 신청 가능합니다. 교외체험학습은 체험일 3일 전에 담임교사에게 신청서를 제출해야 한다. 체험학습 후 7일 이내에 보고서를 제출해야 출석인정 처리됩니다. 신청서 및 보고서는 2026년 양식으로 제출하며 홈페이지 및 학교종이에 탑재되어 있습니다. 연속 5일을 초과하여 실시하는 경우, 학생 안전을 위해 기간 중 1회 이상 학생이 담임교사에게 연락해야 합니다. 수업일수를 191일로 운영하며 1/3 이상(65일) 결석 시 다음 학년으로 진급이 어려울 수 있습니다. 질병결석이 2일 이내인 경우 결석신고서와 증빙자료(학부모 의견서, 처방전, 담임교사 확인서)를 5일 이내에 제출합니다. 질병결석이 3일 이상인 경우 결석신고서와 증빙자료(병명, 진료기간 기록이 나타난 의사의 진단서 또는 의사 소견서, 진료 확인서 등)를 5일 이내에 제출합니다. 경조사 결석 시 결석신고서와 증빙자료(청첩장 등)를 제출합니다. 법정감염병(수두, 홍역 등) 결석 시 결석신고서와 증빙자료(법정감염병명과 기간이 표시된 증빙 서류 — 의사소견서, 진료확인서, 의사진단서 등)를 제출합니다. +``` + +**번역:** +``` +Các khóa học ngoại vi có thể nộp đơn cho đến 10 ngày liên tiếp một lần và có thể nộp cho đến 19 ngày trong một năm. Các khóa học ngoại vi phải nộp đơn cho giáo viên trưởng thành 3 ngày trước ngày thử nghiệm. Báo cáo phải được xử lý trong vòng 7 ngày sau khi trải nghiệm. Các hồ sơ và báo cáo phải được nộp dưới dạng năm 2026 và được gắn trên trang web và các loại trường học. Nếu được thực hiện liên tục hơn 5 ngày, một trong số thời gian đó phải liên hệ với công ty trưởng thành vì sự an toàn của học sinh. +``` + +**한국어 역번역:** +``` +외부 강좌는 한 번에 연속 10일까지 신청할 수 있고 1년에 19일까지 가능합니다. 외부 강좌는 실험 3일 전에 성인 교사에게 신청해야 합니다. 보고서는 체험 후 7일 이내에 처리되어야 합니다. 서류와 보고서는 2026년 형식으로 제출하며 웹사이트와 학교 종류들에 탑재됩니다. 5일 이상 연속으로 실시할 경우, 학생 안전을 위해 그 기간 중 한 번 성인 회사에 연락해야 합니다. +``` + +## B: TODO만 (468자) + +**입력:** +``` +교외체험학습은 체험일 3일 전에 담임교사에게 신청서를 제출해야 한다. 체험학습 후 7일 이내에 보고서를 제출해야 출석인정 처리됩니다. 신청서 및 보고서는 2026년 양식으로 제출하며 홈페이지 및 학교종이에 탑재되어 있습니다. 연속 5일을 초과하여 실시하는 경우, 학생 안전을 위해 기간 중 1회 이상 학생이 담임교사에게 연락해야 합니다. 질병결석이 2일 이내인 경우 결석신고서와 증빙자료(학부모 의견서, 처방전, 담임교사 확인서)를 5일 이내에 제출합니다. 질병결석이 3일 이상인 경우 결석신고서와 증빙자료(병명, 진료기간 기록이 나타난 의사의 진단서 또는 의사 소견서, 진료 확인서 등)를 5일 이내에 제출합니다. 경조사 결석 시 결석신고서와 증빙자료(청첩장 등)를 제출합니다. 법정감염병(수두, 홍역 등) 결석 시 결석신고서와 증빙자료(법정감염병명과 기간이 표시된 증빙 서류 — 의사소견서, 진료확인서, 의사진단서 등)를 제출합니다. +``` + +**번역:** +``` +Đơn xin và báo cáo sẽ được xử lý trong vòng 7 ngày kể từ ngày học thử nghiệm. Đơn xin và báo cáo sẽ được nộp theo hình thức năm 2026 và được ghi trên trang web và các loại trường học. Nếu được thực hiện trong 5 ngày liên tiếp, hơn một lần trong thời gian cho sự an toàn của học sinh, học sinh phải liên hệ với giáo viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên viên +``` +**한국어 역번역:** +``` +신청서와 보고서는 체험 학습일로부터 7일 이내에 처리될 것입니다. 신청서와 보고서는 2026년 양식으로 제출되고 웹사이트와 학교 종류들에 기록됩니다. 만약 5일 연속으로 실시되면, 학생 안전을 위해 기간 중 한 번 이상 학생은 교사-사-사-사(단어 반복)...에게 연락해야 합니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N05.md b/model/translation_tts/outputs/ab_quality_eval/vi/N05.md new file mode 100644 index 0000000000000000000000000000000000000000..1659e6b147e79dda8fbf9c4c729705cc6ba3bc6a --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N05.md @@ -0,0 +1,48 @@ +# N05 — 주간학습계획 3주(3.23~3.27) 3학년 1반 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 431자 | 386자 | +| **Gemini 점수** | **45점** | **32점** | +| 주요 문제 | 미세먼지를 '독약(thuốc độc)'으로, 마스크를 '가면(mặt nạ)'으로 오역했으며 1학기를 1학년으로 번역하고 문장이 중간에 끊김. | 화/수/목요일을 전부 토요일(thứ Bảy)로 오역했고, 리코더를 '녹음'으로, 간편복/운동화를 '비서/복사본'으로 번역하여 정보 전달이 불가능함. | + +**Round-trip 이슈:** A는 후반부 준비물 내용이 통째로 누락되었으며, B는 요일을 모두 토요일로 오역하고 준비물을 '비서/복사본' 등으로 심각하게 환각함. + +**종합 평가:** 두 번역 모두 실제 사용이 불가능한 수준이나, 굳이 고른다면 A가 차라리 낫습니다. B는 요일과 준비물 정보를 완전히 잘못 제공(환각)하여 학부모에게 큰 혼란을 줄 수 있는 반면, A는 후반부가 누락되었을지언정 전반부의 날짜 정보는 어느 정도 유지하고 있기 때문입니다. + +--- + +## A: 원문 전체 (431자) + +**입력:** +``` +등교시간은 8시 30분~8시 45분까지이며 안전상의 문제로 8시 30분보다 일찍 등교하지 않도록 합니다. 1학기 학부모 상담주간은 4월 13일~4월 24일에 운영되며 대면 상담 및 전화 상담이 가능합니다. 1학기에는 문화예술교육으로 창의음악(8차시), 국악(10차시) 수업이 있습니다. 환절기와 미세먼지로 인해 감기에 걸린 학생이 많으므로 개인위생에 유의하고, 미세먼지가 나쁨 이상일 때에는 마스크를 착용합니다. 독서 생활은 매주 1편 이상 정성껏 작성해서 월요일에 제출합니다. 창체 시간 준비물로 희망편지를 화요일까지 제출합니다. 음악 수업 준비물로 리코더(독일식)를 준비하고 리코더 연습을 합니다. 월요일 준비물은 독서록과 희망편지입니다. 화요일 준비물은 희망편지, 간편복, 운동화입니다. 수요일 준비물은 리코더입니다. 목요일 준비물은 리코더, 간편복, 운동화입니다. +``` + +**번역:** +``` +Thời gian lên lớp là từ 8h30 đến 8h45 và không nên lên sớm hơn 8h30 vì các vấn đề an toàn. Tuần lễ tư vấn phụ huynh lớp 1 được tổ chức từ ngày 13 đến ngày 24 tháng 4 và có thể có tư vấn trực tiếp và qua điện thoại. Tuần 1 có lớp học âm nhạc sáng tạo (tám) và nhạc quốc gia (tám) trong giáo dục nghệ thuật văn hóa. Vì có nhiều học sinh bị cảm lạnh do suyễn và thuốc độc, nên lưu ý đến sinh viên cá nhân và đeo mặt nạ khi thuốc độc không tốt hơn. Cuộc sống đọc sách được viết mỗi tuần và nộp vào thứ Hai. +``` + +**한국어 역번역:** +``` +등교 시간은 8:30~8:45이며 안전 문제로 8:30보다 일찍 오면 안 됩니다. 1학년(학기 아님) 학부모 상담 주간은 4월 13~24일이며 대면/전화 상담이 가능합니다. 1주(학기 아님)에 창의음악(8회)과 국악(8회) 수업이 있습니다. 천식과 독약(미세먼지 아님) 때문에 감기 환자가 많으니 개인 학생에 유의하고 독약이 좋지 않을 때 가면(마스크 아님)을 씁니다. 독서 생활은 매주 작성해 월요일에 제출합니다. (이후 내용 누락) +``` + +## B: TODO만 (386자) + +**입력:** +``` +등교시간은 8시 30분~8시 45분까지이며 안전상의 문제로 8시 30분보다 일찍 등교하지 않도록 합니다. 1학기 학부모 상담주간은 4월 13일~4월 24일에 운영되며 대면 상담 및 전화 상담이 가능합니다. 환절기와 미세먼지로 인해 감기에 걸린 학생이 많으므로 개인위생에 유의하고, 미세먼지가 나쁨 이상일 때에는 마스크를 착용합니다. 독서 생활은 매주 1편 이상 정성껏 작성해서 월요일에 제출합니다. 창체 시간 준비물로 희망편지를 화요일까지 제출합니다. 음악 수업 준비물로 리코더(독일식)를 준비하고 리코더 연습을 합니다. 월요일 준비물은 독서록과 희망편지입니다. 화요일 준비물은 희망편지, 간편복, 운동화입니다. 수요일 준비물은 리코더입니다. 목요일 준비물은 리코더, 간편복, 운동화입니다. +``` + +**번역:** +``` +Thời gian học tập từ 8h30 đến 8h45 và không nên lên sớm hơn 8h30 vì các vấn đề an toàn. Tuần lễ tư vấn phụ huynh lớp 1 được tổ chức từ ngày 13 đến ngày 24 tháng 4 và có thể có tư vấn trực tiếp và qua điện thoại. Vì có nhiều học sinh bị cảm lạnh do suyễn và ô nhiễm, nên lưu ý đến sức khỏe cá nhân, và đeo mặt nạ khi ô nhiễm không tốt hơn. Cuộc sống đọc sách được viết một lần mỗi tuần và nộp vào thứ Hai. Để chuẩn bị thời gian sáng tạo, chúng tôi nộp hồ sơ hy vọng cho đến thứ Bảy. Để chuẩn bị các lớp học âm nhạc, chúng tôi chuẩn bị và luyện tập thu âm. Ngày thứ Hai, chúng tôi chuẩn bị sách và hy vọng. Ngày thứ Bảy, chúng tôi chuẩn bị thư ký, bản sao chép, bài tập. Ngày thứ Bảy, chúng tôi chuẩn bị thư ký. Ngày thứ Bảy, chúng tôi chuẩn bị thư ký, bản sao chép, bản sao chép, và bài tập thể thao. +``` +**한국어 역번역:** +``` +학습 시간은 8:30~8:45이며 안전 문제로 일찍 오지 마세요. 1학년 학부모 상담은 4월 13~24일입니다. 천식과 오염 때문에 감기 환자가 많으니 개인 건강에 유의하고 오염 시 가면을 씁니다. 독서는 주 1회 써서 월요일에 냅니다. 창의 시간을 위해 토요일(화요일 아님)까지 희망 서류를 냅니다. 음악 수업을 위해 녹음(리코더 아님)을 연습합니다. 월요일엔 책과 희망을 준비합니다. 토요일엔 비서(준비물 아님), 복사본, 숙제를 준비합니다. 토요일엔 비서를 준비합니다. 토요일엔 비서, 복사본, 운동 과제를 준비합니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N06.md b/model/translation_tts/outputs/ab_quality_eval/vi/N06.md new file mode 100644 index 0000000000000000000000000000000000000000..c6693c329498085e5e08360c19f5d51c0aa70d92 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N06.md @@ -0,0 +1,48 @@ +# N06 — 주간학습계획 8주(4.20~4.24) 3학년 6반 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 712자 | 552자 | +| **Gemini 점수** | **45점** | **30점** | +| 주요 문제 | 5교시를 5학년으로 오역했으며, 나눔장터의 상세 운영 방식, 준비물, 금지 물품, 독서 과제 등 후반부 전체가 잘림. | 문장마다 '체육 활동'이라는 단어를 무의미하게 반복하며 나눔장터 준비물, 금액, 주의사항 등 필수 행동 정보를 모두 누락함. | + +**Round-trip 이슈:** A는 전체 내용의 70%가량이 누락되었고, B는 나눔장터와 준비물 등 모든 일정을 '체육 활동'으로 환각(Hallucination)하여 번역함. + +**종합 평가:** 두 번역 모두 학부모에게 전달하기에 부적절한 낙제 수준입니다. 굳이 비교하자면 A가 앞부분의 스마트기기(디벗) 관련 정보를 일부 전달하고 있으나, 두 번역 모두 나눔장터 준비물과 독서 과제 같은 핵심 'TODO'를 전혀 담지 못해 실제 공지로서의 가치가 없습니다. + +--- + +## A: 원문 전체 (712자) + +**입력:** +``` +3학년 합동 체육은 3월 24일(금) 5교시 체육관에서 실시되며 학급티 및 간편한 복장을 착용합니다. 3학년 디벗(스마트기기)은 개인별로 배부되며 학교에서 사용하는 것을 원칙으로 합니다. 디벗 사용을 위해 개인용 이어폰(3.5mm, C타입)을 월요일까지 준비합니다. 나눔장터는 4월 30일(목) 8:50~10:30에 본교 각 층 복도에서 진행됩니다. 나눔장터는 1교시(짝수반 판매/홀수반 구매)와 2부(홀수반 판매/짝수반 구매)로 운영됩니다. 나눔장터 준비물은 학급티, 1인용 돗자리, 판매할 물건, 구입비(5000원 이내의 잔돈), 지갑, 천으로 된 장바구니입니다. 나눔장터 구입비는 5000원 이내의 잔돈으로 준비합니다. 나눔장터에서 판매금지 물건은 생물, 먹거리(식중독 위험), 5만원 이상 고가의 물품, 너무 낡은 것, 저작권 위반 물품, 청소년에게 유해한 물품(칼, 비비탄 총 등의 완구류, 화장품류) 등이다. 나눔장터 가격은 100원~3000원(100원 단위)으로 하며, 물건에 가격표를 부착하여 가져옵니다. 나눔장터 후 전교어린이회에서 지구촌 어려운 친구를 위해 수입금 중 일부를 기부하는 모금 행사를 진행합니다. 체육 수업 시 운동화 착용, 물 넉넉하게 준비하기를 합니다. 마스크 1장씩 가방에 넣고 다닙니다. 독서생활 과제는 매주 1~2편 이상 바른 글씨로 정성껏 작성해서 월요일에 제출합니다. 윤독도서 '가정통신문 소동'을 4월 27일(월)까지 읽습니다. +``` + +**번역:** +``` +Tập thể dục tập hợp năm lớp 3 được tổ chức vào ngày 24 tháng 3 tại phòng tập thể dục năm lớp 5 và được sử dụng trong lớp học và quần áo đơn giản. Các thiết bị thông minh năm lớp 3 được phân phối theo từng cá nhân và sử dụng theo nguyên tắc tại trường học. Các thiết bị nghe nhạc riêng cho việc sử dụng các thiết bị nghe nhạc 3,5mm, C vào được chuẩn bị cho đến thứ Hai. Tập thể dục tập hợp sẽ được tổ chức vào ngày 30 tháng 4 lúc 8:50 đến 10:30 tại các hành lang của cơ sở. +``` + +**한국어 역번역:** +``` +3학년 체육 집합은 3월 24일 5학년(5교시 오역) 체육관에서 열리며 교실 안에서의 사용과 간편한 옷을 착용합니다. 3학년 스마트 기기는 개인별로 배부되고 학교 원칙에 따라 사용됩니다. 3.5mm, C타입 음악 청취 기기(이어폰) 사용을 위한 개별 음악 청취 기기가 월요일까지 준비됩니다. 체육 집합(나눔장터 오역)이 4월 30일 8:50~10:30에 기관 복도에서 열립니다. +``` + +## B: TODO만 (552자) + +**입력:** +``` +3학년 합동 체육은 3월 24일(금) 5교시 체육관에서 실시되며 학급티 및 간편한 복장을 착용합니다. 디벗 사용을 위해 개인용 이어폰(3.5mm, C타입)을 월요일까지 준비합니다. 나눔장터는 4월 30일(목) 8:50~10:30에 본교 각 층 복도에서 진행됩니다. 나눔장터 준비물은 학급티, 1인용 돗자리, 판매할 물건, 구입비(5000원 이내의 잔돈), 지갑, 천으로 된 장바구니입니다. 나눔장터 구입비는 5000원 이내의 잔돈으로 준비합니다. 나눔장터에서 판매금지 물건은 생물, 먹거리(식중독 위험), 5만원 이상 고가의 물품, 너무 낡은 것, 저작권 위반 물품, 청소년에게 유해한 물품(칼, 비비탄 총 등의 완구류, 화장품류) 등이다. 나눔장터 가격은 100원~3000원(100원 단위)으로 하며, 물건에 가격표를 부착하여 가져옵니다. 체육 수업 시 운동화 착용, 물 넉넉하게 준비하기를 합니다. 마스크 1장씩 가방에 넣고 다닙니다. 독서생활 과제는 매주 1~2편 이상 바른 글씨로 정성껏 작성해서 월요일에 제출합니다. 윤독도서 '가정통신문 소동'을 4월 27일(월)까지 읽습니다. +``` + +**번역:** +``` +Hoạt động tập thể dục liên kết lớp 3 được tổ chức vào ngày 24 tháng 3 tại phòng tập thể dục năm học và sẽ được sử dụng trong lớp học và quần áo đơn giản. Hoạt động tập thể dục tập thể dục năm học sẽ được tổ chức cho đến ngày thứ Hai. Hoạt động tập thể dục năm học sẽ được tổ chức vào ngày 30 tháng 4 lúc 8:50 đến 10:30 tại các hành lang của trụ sở. Hoạt động tập thể dục năm học sẽ được tổ chức tại các phòng tập. +``` +**한국어 역번역:** +``` +3학년 연계 체육 활동이 3월 24일 학년 체육관에서 열리고 교실 사용과 간편한 옷을 입습니다. 학년 체육 활동이 월요일까지 열립니다. 학년 체육 활동이 4월 30일 8:50~10:30에 본부 복도에서 열립니다. 학년 체육 활동이 체육실에서 열립니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N07.md b/model/translation_tts/outputs/ab_quality_eval/vi/N07.md new file mode 100644 index 0000000000000000000000000000000000000000..c2005424c35b9a49ed4ee2bec7e9b82b16d1ee5b --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N07.md @@ -0,0 +1,48 @@ +# N07 — 주간학습계획 9주(4.27~5.1) 3학년 1반 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 212자 | 171자 | +| **Gemini 점수** | **55점** | **52점** | +| 주요 문제 | 준비물인 리코더(sáo), 간편복(quần áo thoải mái), 줄넘기(dây nhảy), 학급티(áo lớp)를 모두 엉뚱한 단어로 직역하거나 반복함. | A와 마찬가지로 준비물 오역이 심각하며, 줄넘기를 'jump(nhảy vọt)'로 번역하여 물품인지 알 수 없게 만듦. | + +**Round-trip 이슈:** 준비물 중 '리코더(악기)'가 '녹음'으로, '간편복'이 '짧은 글'로, '줄넘기'가 '도약'이나 '신발'로 오역되었으며 '윤독도서' 제목이 누락됨. + +**종합 평가:** 두 번역 모두 학부모에게 전달하기에 부적합합니다. 특히 준비물 항목에서 리코더를 녹음기기로, 간편복을 글쓰기로, 줄넘기를 도약으로 오역하여 학부모가 챙겨야 할 물품을 전혀 파악할 수 없습니다. 굳이 선택하자면 휴업일 정보라도 포함된 A가 낫지만, 준비물 정보를 반드시 수정해야 합니다. + +--- + +## A: 원문 전체 (212자) + +**입력:** +``` +독서활동은 윤독도서(하룻밤)를 4월 27일(월)까지 읽고 독서활동에 기록합니다. 5월 1일(금)과 4일(월)은 학교자율휴업일, 5일(화)은 어린이날이다. 국어 수행평가는 4월 27일에 '중심 문장과 뒷받침 문장을 갖추어 문단 쓰기'로 실시됩니다. 월요일 준비물은 독서록과 리코더입니다. 화요일 준비물은 간편복, 운동화, 줄넘기입니다. 목요일 준비물은 학급티와 나눔장터 물품입니다. +``` + +**번역:** +``` +Hoạt động đọc sách sẽ được thực hiện cho đến ngày 27 tháng 4. Ngày 1 tháng 5 và ngày 4 tháng 5 là ngày nghỉ học tự do, ngày 5 là ngày của trẻ em. Đánh giá thực hiện ngôn ngữ sẽ được thực hiện vào ngày 27 tháng 4 bằng cách viết văn bản với câu trung tâm và câu hỗ trợ. Ngày thứ Hai chuẩn bị là sổ sách đọc và ghi ghi. Ngày thứ Ba chuẩn bị là bài viết ngắn, giày dép, giày dép. Ngày thứ Năm chuẩn bị là lớp học và chia sẻ đồ dùng. +``` + +**한국어 역번역:** +``` +독서 활동은 4월 27일까지 수행됩니다. 5월 1일과 4일은 자율 휴교일, 5일은 어린이날입니다. 언어 수행 평가는 4월 27일에 중심 문장과 보조 문장으로 글쓰기를 실시합니다. 월요일 준비물은 독서 장부와 '기록/녹음'입니다. 화요일 준비물은 '짧은 글', '신발', '신발'입니다. 목요일 준비물은 '교실'과 '물품 공유'입니다. +``` + +## B: TODO만 (171자) + +**입력:** +``` +독서활동은 윤독도서(하룻밤)를 4월 27일(월)까지 읽고 독서활동에 기록합니다. 국어 수행평가는 4월 27일에 '중심 문장과 뒷받침 문장을 갖추어 문단 쓰기'로 실시됩니다. 월요일 준비물은 독서록과 리코더입니다. 화요일 준비물은 간편복, 운동화, 줄넘기입니다. 목요일 준비물은 학급티와 나눔장터 물품입니다. +``` + +**번역:** +``` +Các hoạt động đọc sách sẽ được thực hiện cho đến ngày 27 tháng 4 và sẽ được ghi lại trong các hoạt động đọc sách. Các hoạt động đánh giá ngôn ngữ sẽ được thực hiện vào ngày 27 tháng 4 bằng cách viết văn bản với câu trung tâm và câu đệm. Ngày thứ Hai, việc chuẩn bị là sổ sách đọc và ghi âm. Ngày thứ Ba, việc chuẩn bị là bài viết ngắn, giày dép, nhảy vọt. Ngày thứ Năm, việc chuẩn bị là hàng lớp học và đồ dùng chia sẻ. +``` +**한국어 역번역:** +``` +독서 활동은 4월 27일까지 수행되고 기록됩니다. 언어 평가 활동은 4월 27일에 중심 문장과 보조 문장으로 글쓰기를 실시합니다. 월요일 준비물은 독서 장부와 '녹음'입니다. 화요일 준비물은 '짧은 글', '신발', '도약'입니다. 목요일 준비물은 '학급 상품'과 '공유 도구'입니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N08.md b/model/translation_tts/outputs/ab_quality_eval/vi/N08.md new file mode 100644 index 0000000000000000000000000000000000000000..74c0128ef6806bf41d97d7e0859f08da3137a926 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N08.md @@ -0,0 +1,48 @@ +# N08 — 2026학년도 1학기 도서관 도서 구입 신청 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 313자 | 67자 | +| **Gemini 점수** | **45점** | **75점** | +| 주요 문제 | 가장 중요한 정보인 신청 마감 기한, 제출처(학교종이), 신청 권수 제한 등 안내문의 핵심인 후반부가 완전히 누락됨. | 전용 앱 명칭인 '학교종이'를 일반 명사(학교들)로 번역하여 혼동을 주며, 권(cuốn) 대신 표(phiếu)를 써서 의미가 약간 어색함. | + +**Round-trip 이슈:** A는 신청 마감일과 제출 방법 등 핵심 정보가 통째로 누락되었으며, B는 '학교종이(앱)'를 '학교들'로, '5권'을 '5표'로 오역함. + +**종합 평가:** B가 학부모 전달 목적에 더 적합합니다. A는 문장은 유려하나 안내문의 필수 정보인 '언제까지 어디로' 제출해야 하는지가 누락되어 정보 전달 기능을 상실한 반면, B는 비록 직역체이고 고유 명사 오역이 있으나 마감 날짜와 시간 등 핵심 행동 지침을 포함하고 있습니다. + +--- + +## A: 원문 전체 (313자) + +**입력:** +``` +안녕하십니까. 학부모님 가정에 건강과 평안이 가득하시기를 바랍니다. 본교 도서관에서는 학교 장서의 질을 향상하고 학생 및 학부모님의 학습과 교양에 도움이 되는 도서를 구입하고자 합니다. 도서 구입 신청은 학교 교육과정에 알맞은 도서, 교과 연계 도서, 학생 과제해결 도서, 개인 교양 도서를 기준으로 합니다. 이미 소장된 도서, 품절·절판 도서, 만화책·학습지·잡지류 등 교과 비연계 도서, 정보 불명확한 도서는 구입 신청에서 제외됩니다. 도서 구입 신청은 3월 27일(금) 12시까지 학교종이 설문으로 제출합니다. 추천 도서는 1인당 5권 이내로 신청합니다. +``` + +**번역:** +``` +Xin chào. Chúc các bậc phụ huynh có sức khỏe và sự bình an trong gia đình. Thư viện trụ sở muốn mua những cuốn sách giúp cải thiện chất lượng giáo dục trường học và giúp học tập và giáo dục của học sinh và cha mẹ. Đơn xin mua sách dựa trên các cuốn sách phù hợp với chương trình giáo dục trường học, sách liên kết, sách giải quyết bài tập của học sinh, sách giáo dục cá nhân. +``` + +**한국어 역번역:** +``` +안녕하세요. 학부모님 가정에 건강과 평안이 가득하시길 바랍니다. 본부 도서관은 학교 교육의 질을 개선하고 학생과 부모의 학습과 교육에 도움이 되는 책을 사고 싶어 합니다. 도서 신청은 학교 교육 과정에 적합한 책, 연계 도서, 학생 과제 해결 도서, 개인 교육 도서를 기반으로 합니다. +``` + +## B: TODO만 (67자) + +**입력:** +``` +도서 구입 신청은 3월 27일(금) 12시까지 학교종이 설문으로 제출합니다. 추천 도서는 1인당 5권 이내로 신청합니다. +``` + +**번역:** +``` +Các đơn xin mua sách sẽ được nộp vào ngày 27 tháng 3 vào lúc 12 giờ, theo cách khảo sát bởi các trường học, trong vòng 5 phiếu cho mỗi người. +``` +**한국어 역번역:** +``` +도서 신청서들은 3월 27일 12시에 학교들에 의한 설문 방식으로 제출될 것이며, 1인당 5표 이내입니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N09.md b/model/translation_tts/outputs/ab_quality_eval/vi/N09.md new file mode 100644 index 0000000000000000000000000000000000000000..f60b78d738de8ba316c5b435a84f9d79a1edeb6f --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N09.md @@ -0,0 +1,48 @@ +# N09 — 갈산 교육공동체 약속 실천 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 277자 | 167자 | +| **Gemini 점수** | **75점** | **68점** | +| 주요 문제 | 고유명사(갈산) 오역과 학교급에 맞지 않는 단어(sinh viên) 사용, 마지막 문장에서 핵심 행동인 '안아주기'가 생략됨. | 여러 문장을 쉼표로만 연결한 비정상적인 만연체이며, '안아주기'를 '위로하기'로 번역하여 긍정적인 행동의 취지가 왜곡됨. | + +**Round-trip 이슈:** A는 학교명(갈산→Gaul)과 학생(초중고생→대학생) 명칭 오역, '안아주기' 누락. B는 '안아주기'를 '위로(an ủi)'로 잘못 번역함. + +**종합 평가:** A가 더 적합합니다. B는 문법적으로 파괴된 문장 구조로 인해 가독성이 매우 떨어지며 '안아주기'의 의미를 '위로'로 오역했습니다. A는 학교 이름과 대상 명칭에 오류가 있으나 문장이 명확하게 끊어져 있어 학부모가 정보를 파악하기에 훨씬 수월합니다. + +--- + +## A: 원문 전체 (277자) + +**입력:** +``` +학부모님 안녕하십니까? 항상 학교의 여러 교육활동에 적극적으로 협조해 주심에 감사드립니다. 갈산 교육공동체 약속은 학생, 학부모, 교직원이 스스로 정하고 실천하는 자발적인 참여를 목적으로 합니다. 가정에서도 공동체 약속을 꾸준히 실천하여 모두가 행복한 우리 학교 문화 만들기에 동참해 주시기 바랍니다. 학부모 약속으로 교사의 전문성을 믿고 존중하며 자녀가 바르게 성장할 수 있도록 함께 협력합니다. 학부모 약속으로 아이와 교사를 믿고 지켜봅니다. 학부모 약속으로 하루 한 번 칭찬하고 안아줍니다. +``` + +**번역:** +``` +Xin chào các bậc cha mẹ! Tôi luôn cảm ơn các bạn đã tích cực hợp tác với các hoạt động giáo dục của trường học. Thỏa thuận của cộng đồng giáo dục Gaul nhằm mục đích sự tham gia tự nguyện mà sinh viên, cha mẹ và giáo viên tự quyết định và thực hiện. Chúng tôi mong rằng tất cả mọi người trong gia đình luôn thực hiện cam kết cộng đồng và tham gia vào việc xây dựng văn hóa trường học hạnh phúc của chúng tôi. Thỏa thuận của cha mẹ là niềm tin vào sự chuyên môn của giáo viên và sự hợp tác để giúp con cái phát triển đúng đắn. Thỏa thuận của cha mẹ là niềm tin vào con cái và giáo viên. Thỏa thuận của cha mẹ là niềm tin và lời khen ngợi mỗi ngày. +``` + +**한국어 역번역:** +``` +학부모님 안녕하십니까? 학교의 교육 활동에 항상 적극적으로 협조해 주셔서 감사합니다. Gaul 교육 공동체 협정은 학생(대학생), 학부모, 교사가 스스로 결정하고 실천하는 자발적 참여를 목적으로 합니다. 가정에서도 항상 공동체 약속을 실천하고 행복한 우리 학교 문화 만들기에 참여해 주시기 바랍니다. 학부모의 협정은 교사의 전문성을 믿고 자녀가 바르게 성장하도록 협력하는 것입니다. 학부모의 협정은 자녀와 교사를 믿는 것입니다. 학부모의 협정은 매일 믿음과 칭찬을 주는 것입니다. +``` + +## B: TODO만 (167자) + +**입력:** +``` +가정에서도 공동체 약속을 꾸준히 실천하여 모두가 행복한 우리 학교 문화 만들기에 동참해 주시기 바랍니다. 학부모 약속으로 교사의 전문성을 믿고 존중하며 자녀가 바르게 성장할 수 있도록 함께 협력합니다. 학부모 약속으로 아이와 교사를 믿고 지켜봅니다. 학부모 약속으로 하루 한 번 칭찬하고 안아줍니다. +``` + +**번역:** +``` +Chúng tôi mong rằng tất cả mọi người trong gia đình đều thực hiện cam kết của cộng đồng, và tất cả mọi người đều tham gia vào việc xây dựng một nền văn hóa trường học hạnh phúc, với cam kết của cha mẹ, tin tưởng vào sự chuyên nghiệp của giáo viên, tôn trọng và hợp tác với nhau để con cái phát triển đúng đắn, với cam kết của cha mẹ, tin tưởng vào con cái và giáo viên, với cam kết của cha mẹ, khen ngợi và an ủi mỗi ngày. +``` +**한국어 역번역:** +``` +가정에서도 공동체 약속을 실천하여 모두가 행복한 학교 문화 만들기에 참여해 주시기 바랍니다. 학부모의 약속과 함께, 교사의 전문성을 믿고 존중하며 자녀가 바르게 성장하도록 협력하고, 학부모의 약속과 함께 아이와 교사를 믿고, 학부모의 약속과 함께 매일 칭찬하고 위로해 주시기 바랍니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N10.md b/model/translation_tts/outputs/ab_quality_eval/vi/N10.md new file mode 100644 index 0000000000000000000000000000000000000000..fa47d34b6ec6c1f6fc84cff395f43b992c738dd7 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N10.md @@ -0,0 +1,48 @@ +# N10 — 안전한 등하교를 위한 협조 요청 및 자전거 이용 시 안전 수칙 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 496자 | 488자 | +| **Gemini 점수** | **45점** | **40점** | +| 주요 문제 | 본문의 절반 이상인 구체적 안전 수칙과 거치 장소 정보가 통째로 누락되었으며, '교문(Cổng trường)'을 '교실(Lớp học)'로 오역함. | 핵심 TODO인 안전 장비와 거치 위치가 모두 빠졌으며, 원문의 '차량 이용 자제'를 '자전거 이용 통제'로 잘못 번역함. | + +**Round-trip 이슈:** 안전모 착용, 자전거 점검, 횡단보도 이용법, 휴대전화 금지, 단지별 상세 거치 장소 등 핵심 행동 지침이 모두 누락됨. + +**종합 평가:** 두 번역 모두 학부모에게 전달하기에 부적합합니다. 가정통신문의 핵심인 구체적인 실천 사항(헬멧 착용, 거치 장소 등)이 대거 누락되어 안전사고 예방이라는 목적을 달성할 수 없으며, 단어 선택에서도 학교 현장의 맥락이 결여되어 있습니다. + +--- + +## A: 원문 전체 (496자) + +**입력:** +``` +안녕하십니까? 씩씩하게 도보로 통학하도록 지도해주시고, 부득이하게 자전거를 이용하는 학생들은 자신의 안전은 물론 타인의 안전까지 지킬 수 있도록 자전거 이용 수칙을 반드시 준수하도록 학부모님의 적극적인 지도와 협조를 부탁드립니다. 교문 앞 혼잡한 상황 및 학생들의 건강을 생각하시어 부득이한 상황(학생 다리 부상 등)이 아니면 차량 이용을 자제해주십시오. 자전거 이용 시 안전모(헬멧)와 팔꿈치·무릎 보호대, 장갑 등 안전장비를 반드시 착용합니다. 자전거를 미리 점검합니다(브레이크, 핸들, 안장, 타이어, 페달, 전조등 등). 횡단보도를 건널 때는 내려서 자전거를 끌고 건넙니다. 휴대전화를 사용하거나 이어폰(헤드셋)을 끼고 음악을 듣지 않습니다. 학교 앞까지 자전거를 가지고 오지 않으며 자전거 거치 장소에 올바르게 거치합니다. 13단지 쪽에서 통학할 경우 13단지 앞 사거리 자전거 거치 장소에 바르게 거치합니다. 14단지 쪽에서 통학할 경우 14단지 아파트 내 바르게 거치합니다. +``` + +**번역:** +``` +Xin sự hướng dẫn và sự hợp tác tích cực của cha mẹ để các học sinh sử dụng xe đạp phải tuân thủ các quy tắc sử dụng xe đạp để bảo vệ an toàn của họ cũng như an toàn của người khác. Hãy nghĩ đến tình huống tắc nghẽn trước lớp học và sức khỏe của học sinh (như chấn thương chân học sinh) hoặc kiểm soát việc sử dụng phương tiện. +``` + +**한국어 역번역:** +``` +학부모님들의 적극적인 지도와 협조를 부탁드립니다. 자전거를 이용하는 학생들이 자신과 타인의 안전을 지키기 위해 자전거 이용 수칙을 준수하도록 해주십시오. 교실 앞의 혼잡한 상황과 학생들의 건강(학생 다리 부상 등)을 생각하시거나 차량 이용을 통제해 주십시오. +``` + +## B: TODO만 (488자) + +**입력:** +``` +씩씩하게 도보로 통학하도록 지도해주시고, 부득이하게 자전거를 이용하는 학생들은 자신의 안전은 물론 타인의 안전까지 지킬 수 있도록 자전거 이용 수칙을 반드시 준수하도록 학부모님의 적극적인 지도와 협조를 부탁드립니다. 교문 앞 혼잡한 상황 및 학생들의 건강을 생각하시어 부득이한 상황(학생 다리 부상 등)이 아니면 차량 이용을 자제해주십시오. 자전거 이용 시 안전모(헬멧)와 팔꿈치·무릎 보호대, 장갑 등 안전장비를 반드시 착용합니다. 자전거를 미리 점검합니다(브레이크, 핸들, 안장, 타이어, 페달, 전조등 등). 횡단보도를 건널 때는 내려서 자전거를 끌고 건넙니다. 휴대전화를 사용하거나 이어폰(헤드셋)을 끼고 음악을 듣지 않습니다. 학교 앞까지 자전거를 가지고 오지 않으며 자전거 거치 장소에 올바르게 거치합니다. 13단지 쪽에서 통학할 경우 13단지 앞 사거리 자전거 거치 장소에 바르게 거치합니다. 14단지 쪽에서 통학할 경우 14단지 아파트 내 바르게 거치합니다. +``` + +**번역:** +``` +Cố gắng hướng dẫn học sinh đi bộ một cách thận trọng, và yêu cầu sự hướng dẫn và sự hợp tác tích cực của cha mẹ để học sinh sử dụng xe đạp phải tuân thủ các quy tắc xe đạp để bảo vệ sự an toàn của mình cũng như sự an toàn của người khác. Hãy nghĩ đến tình huống lộn xộn trước lớp học và sức khỏe của học sinh, hoặc kiểm soát việc sử dụng xe đạp. +``` +**한국어 역번역:** +``` +학생들이 조심스럽게 등교하도록 지도해 주시고, 자전거를 이용하는 학생들이 자신과 타인의 안전을 위해 자전거 수칙을 준수하도록 학부모님의 적극적인 지도와 협조를 요청합니다. 교실 앞의 무질서한 상황과 학생들의 건강을 생각하시거나, 자전거 이용을 통제해 주십시오. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N11.md b/model/translation_tts/outputs/ab_quality_eval/vi/N11.md new file mode 100644 index 0000000000000000000000000000000000000000..3a2255e687c0953e4d6f9dd3b57fec57f29030dd --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N11.md @@ -0,0 +1,48 @@ +# N11 — 2026학년도 학교평가 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 304자 | 150자 | +| **Gemini 점수** | **72점** | **65점** | +| 주요 문제 | 위원회의 구성원 명칭(교원, 직원)이 불분명하며, 학교급에 맞지 않는 '대학생' 단어 사용 및 홈페이지 메뉴를 단순히 '목록'으로 번역함. | 입력된 두 문장 중 두 번째 문장(11월 일정, 학년도, 설문 참여 내용)이 번역되지 않아 학부모가 무엇을 언제 해야 하는지 알 수 없음. | + +**Round-trip 이슈:** A는 교원/직원 구분이 소실되고 학생을 대학생(Sinh viên)으로 오역했으며, B는 입력값의 핵심 정보인 '11월 일정 및 설문 내용'이 통째로 누락됨. + +**종합 평가:** 그나마 A가 학부모 전달 목적에 더 적합합니다. B는 핵심 행동 요약이 목적임에도 불구하고 입력문에 포함된 '11월 일정'과 '설문 참여'라는 가장 중요한 행동 정보를 누락하여 안내문으로서 제 역할을 하지 못합니다. A는 학생(Học sinh)을 대학생(Sinh viên)으로 번역하는 등의 단어 선택 오류가 있으나, 전체적인 정보량과 맥락은 유지하고 있습니다. + +--- + +## A: 원문 전체 (304자) + +**입력:** +``` +학부모님 안녕하십니까? 전체적인 평가 일정과 내용을 확인하셔서 학교 교육공동체의 참여·소통·협력 활동을 통한 학교교육활동의 진단과 개선이 이루어질 수 있도록 동참하여 주시기 바랍니다. 학교평가는 11월에 2026학년도 학교교육활동 평가 및 2027학년도 학교교육계획 수립 설문으로 진행됩니다. 학교평가운영위원회는 위원장 1명, 교원위원 7명, 직원위원 1명, 학부모위원 2명, 학생위원 2명, 외부위원 1명 총 14명으로 구성됩니다. 2025학년도 학교평가 결과 및 2026학년도 학교평가 계획은 학교 누리집-학교평가 메뉴에 탑재되어 있습니다. +``` + +**번역:** +``` +Xin vui lòng tham gia vào việc xác định và cải thiện hoạt động giáo dục trong trường thông qua các hoạt động tham gia, liên lạc và hợp tác của cộng đồng giáo dục trường học. Đánh giá trường học sẽ được tiến hành vào tháng 11 với việc đánh giá hoạt động giáo dục trong trường học năm 2026 và khảo sát lập kế hoạch giáo dục cho năm 2027. Ủy ban quản lý trường học sẽ có một giám đốc, bảy ủy viên, một ủy viên, hai ủy viên phụ huynh, hai ủy viên sinh viên, một ủy viên bên ngoài tổng cộng 14 thành viên. Kết quả đánh giá trường học năm 2025 và kế hoạch đánh giá trường học năm 2026 được đưa vào danh sách đánh giá trường học. +``` + +**한국어 역번역:** +``` +학교 교육공동체의 참여, 소통 및 협력 활동을 통해 학교 교육 활동을 확인하고 개선하는 데 참여해 주시기 바랍니다. 학교 평가는 2026년 학교 교육 활동 평가와 2027년 교육 계획 설문 조사를 통해 11월에 실시됩니다. 학교 운영 위원회는 책임자 1명, 위원 7명, 위원 1명, 학부모 위원 2명, 대학생 위원 2명, 외부 위원 1명으로 총 14명으로 구성됩니다. 2025년 학교 평가 결과 및 2026년 학교 평가 계획은 학교 평가 목록에 포함되어 있습니다. +``` + +## B: TODO만 (150자) + +**입력:** +``` +전체적인 평가 일정과 내용을 확인하셔서 학교 교육공동체의 참여·소통·협력 활동을 통한 학교교육활동의 진단과 개선이 이루어질 수 있도록 동참하여 주시기 바랍니다. 학교평가는 11월에 2026학년도 학교교육활동 평가 및 2027학년도 학교교육계획 수립 설문으로 진행됩니다. +``` + +**번역:** +``` +Hãy kiểm tra lịch trình và nội dung đánh giá đầy đủ để bạn có thể tham gia vào việc chẩn đoán và cải thiện hoạt động giáo dục trong trường thông qua các hoạt động tham gia, giao tiếp và hợp tác của cộng đồng giáo dục trường. +``` +**한국어 역번역:** +``` +학교 교육 공동체의 참여, 소통 및 협력 활동을 통해 학교 교육 활동을 진단하고 개선하는 데 참여할 수 있도록 전체 평가 일정과 내용을 확인하십시오. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N12.md b/model/translation_tts/outputs/ab_quality_eval/vi/N12.md new file mode 100644 index 0000000000000000000000000000000000000000..ec2b67d393cd9cd603c4ab5601e65b261e4d2089 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N12.md @@ -0,0 +1,48 @@ +# N12 — 제16기 학교운영위원회 위원 선출 결과 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 240자 | 82자 | +| **Gemini 점수** | **25점** | **90점** | +| 주요 문제 | 제16기를 16학년(lớp 16)으로 오역했으며, 특정 단어(phụ huynh)가 반복되며 문장이 완성되지 않은 채 중단됨. | 학부모 대상 안내문임에도 2인칭 대명사 'bạn'을 사용한 점이 다소 격식이 부족해 보일 수 있으나 핵심 행동 전달은 완벽함. | + +**Round-trip 이슈:** A는 문장이 중간에 오류로 인해 반복되며 끊겼고, 부위원장 정보와 의견 제출 방법 등 핵심 정보가 모두 누락됨. + +**종합 평가:** B가 압도적으로 우수합니다. A는 번역 엔진의 오류로 인해 동일한 단어가 반복되고 정작 중요한 연락처와 부위원장 정보가 누락되어 정보를 전혀 전달할 수 없는 반면, B는 학부모가 해야 할 일을 명확하고 간결하게 전달합니다. + +--- + +## A: 원문 전체 (240자) + +**입력:** +``` +우리학교 제16기 학교운영위원회 위원 선거가 교육공동체의 적극적인 관심과 참여 속에서 민주적이고 법적인 투명한 절차에 따라 실시된 결과 학교운영위원회가 다음과 같이 구성되었습니다. 학교운영위원회 위원장은 학부모위원 전선영(2학년, 6학년 학부모)이며 부위원장은 지역위원 이상임이다. 학교운영 및 회의 안건에 대하여 의견이 있으신 분은 학교메일(galsanes@sen.go.kr)로 의견 주시면 안건 심의 시 반영하도록 하겠습니다. +``` + +**번역:** +``` +Kết quả của cuộc bầu cử ủy viên Ủy ban quản lý trường học lớp 16 của chúng tôi được thực hiện theo quy trình minh bạch về mặt dân chủ và pháp lý trong sự quan tâm tích cực và tham gia của cộng đồng giáo dục, Ủy ban quản lý trường được thành lập như sau: Chủ tịch Ủy ban quản lý trường phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh, phụ huynh. +``` + +**한국어 역번역:** +``` +교육 공동체의 적극적인 관심과 참여 속에 민주적이고 법적인 투명한 절차에 따라 실시된 우리 학교 16학년 학교 운영위원회 위원 선거 결과, 학교 운영위원회는 다음과 같이 구성되었습니다: 학부모 위원회 위원장 학부모, 학부모, 학부모, 학부모, 학부모, 학부모, 학부모, 학부모, 학부모, 학부모. +``` + +## B: TODO만 (82자) + +**입력:** +``` +학교운영 및 회의 안건에 대하여 의견이 있으신 분은 학교메일(galsanes@sen.go.kr)로 의견 주시면 안건 심의 시 반영하도록 하겠습니다. +``` + +**번역:** +``` +Nếu bạn có ý kiến về việc quản lý trường học và tổ chức hội nghị, hãy gửi ý kiến của bạn qua email trường: galsanes@sen.go.kr. +``` +**한국어 역번역:** +``` +학교 운영 및 회의 조직에 의견이 있으시면 학교 이메일(galsanes@sen.go.kr)로 의견을 보내주시기 바랍니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N13.md b/model/translation_tts/outputs/ab_quality_eval/vi/N13.md new file mode 100644 index 0000000000000000000000000000000000000000..570d5a1aeb675cff439bf4bda5f84982a97fa89f --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N13.md @@ -0,0 +1,48 @@ +# N13 — 2026학년도 기초학력 진단검사 실시 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 426자 | 195자 | +| **Gemini 점수** | **40점** | **65점** | +| 주요 문제 | 4학년(3월 5일→5월 9일)과 5학년(3월 9일→3월 6일) 날짜가 틀렸으며, 마지막 문장에서 6학년을 3학년으로 오기하는 심각한 정보 왜곡이 발생했습니다. | 핵심 정보인 3학년과 4학년의 시험 날짜가 누락되었으며, 6학년 안내 문장이 중복되면서 정보 전달의 효율성이 떨어집니다. | + +**Round-trip 이슈:** A는 4~6학년의 날짜와 학년 정보가 완전히 틀리게 번역되었고(오정보), B는 3~4학년의 날짜 정보가 누락되었습니다. + +**종합 평가:** 둘 다 실제 발송에는 부적합하나, 굳이 선택하자면 B가 낫습니다. A는 날짜를 잘못 알려주는 '거짓 정보(환각)'를 포함하고 있어 학부모에게 큰 혼란을 주지만, B는 일부 정보가 누락되었을 뿐 적혀 있는 날짜(2, 5, 6학년)는 정확하기 때문입니다. + +--- + +## A: 원문 전체 (426자) + +**입력:** +``` +안녕하십니까? 이번 진단 활동 중 2~6학년에서 이루어지는 기초학력 진단검사는 학년 시작을 위한 기초 학습을 확인하는 내용입니다. 이 검사는 성적에 반영되는 평가가 아니며, 학생의 강점과 약점, 잠재력, 교육적 요구를 확인하고 향후 학습을 위한 유용한 피드백을 제공하기 위함입니다. 1학년은 3월 중 교사 관찰, 학생·학부모 상담 등 통합적 진단활동을 실시합니다. 2학년은 3월 6일(금)에 읽기+쓰기, 셈하기 진단검사를 실시합니다. 3학년은 3월 5일(목)에 읽기, 쓰기, 셈하기 진단검사를 실시합니다. 4학년은 3월 5일(목)에 국어, 수학, 영어 진단검사를 실시합니다. 5학년은 3월 9일(월)에 국어, 수학, 영어 진단검사를 실시합니다. 6학년은 3월 5일(목)에 국어, 수학, 영어 진단검사를 실시합니다. 결시생의 경우 등교일에 별도 검사를 실시합니다. +``` + +**번역:** +``` +Một trong những hoạt động chẩn đoán này là kiểm tra khả năng chẩn đoán cơ bản được thực hiện trong lớp 2 đến 6 để xác định việc học cơ bản cho sự bắt đầu của lớp. Đây không phải là một đánh giá phản ánh thành tích, mà là để xác định điểm mạnh và điểm yếu, tiềm năng và nhu cầu giáo dục của học sinh và cung cấp phản hồi hữu ích cho việc học tiếp theo. Năm thứ nhất thực hiện các hoạt động chẩn đoán tích hợp như quan sát giáo viên, tư vấn cho học sinh và bậc cha mẹ. Năm thứ hai thực hiện kiểm tra chẩn đoán đọc + viết vào ngày 6 tháng 3. Năm thứ ba thực hiện kiểm tra chẩn đoán đọc, viết và phân tích vào ngày 5 tháng 3. Năm thứ tư thực hiện kiểm tra chẩn đoán ngôn ngữ, toán học, tiếng Anh vào ngày 9 tháng 5. Năm năm thứ năm thực hiện kiểm tra chẩn đoán ngôn ngữ quốc gia, toán học, tiếng Anh vào ngày 6 tháng 3. Năm thứ ba thực hiện kiểm tra chẩn đoán toán vào ngày 5 tháng 3. +``` + +**한국어 역번역:** +``` +이 진단 활동 중 하나는 학년 시작을 위한 기초 학습을 확인하기 위해 2~6학년에서 실시되는 기초 진단 능력 검사입니다. 이것은 성적을 반영하는 평가가 아니라 학생의 강점, 약점, 잠재력, 교육적 요구를 확인하고 향후 학습을 위한 유용한 피드백을 제공하기 위한 것입니다. 1학년은 교사 관찰, 학생 및 학부모 상담과 같은 통합 진단 활동을 실시합니다. 2학년은 3월 6일에 읽기+쓰기 진단 검사를 실시합니다. 3학년은 3월 5일에 읽기, 쓰기 및 분석(셈하기 오류) 검사를 실시합니다. 4학년은 5월 9일에 언어, 수학, 영어 진단 검사를 실시합니다. 5학년은 3월 6일에 국어, 수학, 영어 진단 검사를 실시합니다. 3학년은 3월 5일에 수학 검사를 실시합니다(6학년 내용 누락 및 오류). +``` + +## B: TODO만 (195자) + +**입력:** +``` +2학년은 3월 6일(금)에 읽기+쓰기, 셈하기 진단검사를 실시합니다. 3학년은 3월 5일(목)에 읽기, 쓰기, 셈하기 진단검사를 실시합니다. 4학년은 3월 5일(목)에 국어, 수학, 영어 진단검사를 실시합니다. 5학년은 3월 9일(월)에 국어, 수학, 영어 진단검사를 실시합니다. 6학년은 3월 5일(목)에 국어, 수학, 영어 진단검사를 실시합니다. +``` + +**번역:** +``` +Năm thứ hai vào ngày 6 tháng 3 có bài kiểm tra chẩn đoán đọc + viết và tính toán. Năm thứ ba có bài kiểm tra chẩn đoán đọc, viết và tính toán. Năm thứ tư có bài kiểm tra chẩn đoán ngôn ngữ, toán học và tiếng Anh. Năm thứ năm có bài kiểm tra chẩn đoán ngôn ngữ, toán học và tiếng Anh vào ngày 9 tháng 3. Năm thứ sáu có bài kiểm tra chẩn đoán ngôn ngữ, toán học và tiếng Anh. Năm thứ sáu có bài kiểm tra chẩn đoán ngôn ngữ, toán học và tiếng Anh vào ngày 5 tháng 3. +``` +**한국어 역번역:** +``` +2학년은 3월 6일에 읽기+쓰기 및 계산 진단 검사가 있습니다. 3학년은 읽기, 쓰기 및 계산 진단 검사가 있습니다. 4학년은 언어, 수학 및 영어 진단 검사가 있습니다. 5학년은 3월 9일에 언어, 수학 및 영어 진단 검사가 있습니다. 6학년은 언어, 수학 및 영어 진단 검사가 있습니다. 6학년은 3월 5일에 언어, 수학 및 영어 진단 검사가 있습니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N14.md b/model/translation_tts/outputs/ab_quality_eval/vi/N14.md new file mode 100644 index 0000000000000000000000000000000000000000..cf3b4dfd34700a0c9bb8a1299027f9530f049780 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N14.md @@ -0,0 +1,48 @@ +# N14 — 2026 실종·유괴 예방교육 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 550자 | 488자 | +| **Gemini 점수** | **30점** | **40점** | +| 주요 문제 | 지명/대상 오역, 문장 중복, '우범지역' 오역, '미리 등록한다'를 '절대 미리 등록하지 않는다'로 반대로 번역함. | 등하굣길을 '하노이 길'로 환각 번역하고, 재귀대명사(당신) 사용이 어색하며 핵심 용어(우범지역, 미아방지용품)의 의미가 완전히 왜곡됨. | + +**Round-trip 이슈:** 지명(양천구→광둥/하노이) 및 대상(초등생→고등생) 오역, 특히 '우범지역'을 '좋은 지역'으로 정반대로 번역하여 안전상 심각한 결함이 있음. + +**종합 평가:** 두 번역 모두 실제 학부모에게 전달하기에 부적절하며 위험합니다. '우범지역'을 '좋은 지역'으로, '양천구'를 '광둥/하노이'로 번역하는 등 핵심 정보의 왜곡과 환각 현상이 심각하여 안전 사고 예방이라는 목적을 전혀 달성할 수 없습니다. + +--- + +## A: 원문 전체 (550자) + +**입력:** +``` +최근 양천구에서 초등학생에게 접근하여 유괴를 시도한 사건이 발생하여 경찰이 용의자를 검거한 사례가 있었습니다. 가정에서도 다음과 같은 사항을 함께 지도해 주시고, 이를 생활화할 수 있도록 지속적으로 지도해주시기 바랍니다. 집 근처에 있는 우범지역과 안전 사각지대를 파악하여 부모님과 약속한 안전한 길로 다닙니다. 등·하굣길에는 친구들과 함께 다닙니다. 이름과 전화번호는 절대로 알려주지 않습니다. 모르는 사람이 주는 음료나 과자 등을 함부로 먹지 않으며, 물건 등을 준다고 유인해도 절대 따라가지 않습니다. 낯선 사람이 도움을 요청하면 정중하게 거절합니다. 위급한 상황에서는 큰소리로 도움을 요청하고 주변의 편의점이나 '아동안전지킴이집' 간판이 부착된 가게에 들어가 도움을 요청합니다. 아이의 지문, 사진, 보호자 인적 사항 등을 미리 등록합니다(경찰서, 안전드림앱을 통해 등록 가능). 정기적으로 아이 사진을 찍습니다. 아이를 집에 혼자 두지 않고 항상 자녀와 함께 다닙니다. 이름표, 미아방지 팔찌 등 실종아동 예방용품을 활용합니다. 아이가 실종된 경우 즉시 112에 신고합니다. +``` + +**번역:** +``` +Gần đây có một vụ bắt cóc của một học sinh trung học ở Quảng Đông, nơi cảnh sát đã truy lùng nghi phạm. Chúng tôi yêu cầu các bạn cùng hướng dẫn và tiếp tục hướng dẫn để có thể làm điều này. Chúng tôi tìm thấy các khu vực tuyệt vời và khu vực an toàn gần nhà và đi theo con đường an toàn mà họ đã hứa hẹn với cha mẹ và cha mẹ. Chúng tôi không bao giờ cho biết tên và số điện thoại của họ. Chúng tôi không bao giờ cho biết tên và số điện thoại của họ. Chúng tôi không bao giờ theo dõi bất kỳ nghi phạm nào khi họ không ăn đồ uống hoặc kẹo cao su của người lạ, và họ không bao giờ theo dõi bất kỳ nghi phạm nào. Nếu một người lạ yêu cầu giúp đỡ, họ thường xuyên từ chối. Trong trường hợp khẩn cấp, họ yêu cầu sự giúp đỡ lớn và đi vào một cửa hàng tiện lợi xung quanh, hoặc họ yêu cầu sự giúp đỡ của họ. Họ đăng ký danh tính của trẻ em, ảnh chụp ảnh, và thông tin của người bảo vệ, v.v. Chúng tôi không bao giờ đăng ký trước và đăng ký thông tin an toàn thời gian thông qua ứng dụng an toàn. +``` + +**한국어 역번역:** +``` +최근 광둥에서 고등학생 납치 사건이 발생하여 경찰이 용의자를 추적했습니다. 이를 위해 계속 지도해주시기 바랍니다. 우리는 집 근처의 아주 좋은 지역과 안전한 구역을 찾아 부모님과 약속한 길로 다닙니다. 이름과 전화번호를 절대 알려주지 않습니다(중복). 낯선 사람의 음료나 껌을 먹지 않을 때 어떤 용의자도 따라가지 않습니다. 낯선 사람이 도움을 요청하면 자주 거절합니다. 비상시 큰 도움을 요청하고 주변 편의점에 들어갑니다. 아이의 신원, 사진, 보호자 정보를 등록합니다. 우리는 절대 미리 등록하지 않으며 안전 앱을 통해 등록합니다. +``` + +## B: TODO만 (488자) + +**입력:** +``` +가정에서도 다음과 같은 사항을 함께 지도해 주시고, 이를 생활화할 수 있도록 지속적으로 지도해주시기 바랍니다. 집 근처에 있는 우범지역과 안전 사각지대를 파악하여 부모님과 약속한 안전한 길로 다닙니다. 등·하굣길에는 친구들과 함께 다닙니다. 이름과 전화번호는 절대로 알려주지 않습니다. 모르는 사람이 주는 음료나 과자 등을 함부로 먹지 않으며, 물건 등을 준다고 유인해도 절대 따라가지 않습니다. 낯선 사람이 도움을 요청하면 정중하게 거절합니다. 위급한 상황에서는 큰소리로 도움을 요청하고 주변의 편의점이나 '아동안전지킴이집' 간판이 부착된 가게에 들어가 도움을 요청합니다. 아이의 지문, 사진, 보호자 인적 사항 등을 미리 등록합니다(경찰서, 안전드림앱을 통해 등록 가능). 정기적으로 아이 사진을 찍습니다. 아이를 집에 혼자 두지 않고 항상 자녀와 함께 다닙니다. 이름표, 미아방지 팔찌 등 실종아동 예방용품을 활용합니다. 아이가 실종된 경우 즉시 112에 신고합니다. +``` + +**번역:** +``` +Hãy cùng hướng dẫn và tiếp tục hướng dẫn những điều sau đây trong gia đình, để bạn có thể làm điều này trở nên sống động hơn. Bạn có thể tìm thấy những khu vực tuyệt vời và an toàn gần nhà và đi theo những con đường an toàn mà bạn đã hứa với cha mẹ của mình. Bạn có thể đi cùng bạn bè của mình trên đường Hà Nội. Bạn không bao giờ cho biết tên và số điện thoại của bạn. Bạn không bao giờ cho biết tên của bạn. Bạn không bao giờ ăn đồ uống hoặc kẹo ngọt của người lạ, và bạn không bao giờ đi theo họ. Nếu một người lạ yêu cầu giúp đỡ, bạn sẽ từ chối một cách tôn trọng. Trong những trường hợp khẩn cấp, bạn sẽ kêu lên để giúp đỡ, bạn có thể đi vào một cửa hàng bên cạnh, bạn sẽ được gắn nhãn hiệu an toàn, bạn có thể đăng ký trước, bạn có thể đăng ký thông tin cá nhân của bạn, bạn có thể đăng ký thông qua ứng dụng an toàn. Bạn có thể thường xuyên chụp hình ảnh của bạn. Bạn không bao giờ để bạn ở nhà, bạn sẽ đi cùng bạn. Nếu bạn có tên của bạn, bạn sẽ sử dụng các sản phẩm an toàn thời gian +``` +**한국어 역번역:** +``` +가정에서 다음 사항을 지도하여 생동감 있게 만드세요. 집 근처의 아주 좋은 지역과 안전한 지역을 찾아 부모님과 약속한 길로 갈 수 있습니다. 하노이 길(등하굣길 오역)에서 친구들과 함께 갈 수 있습니다. 당신의 이름과 번호를 절대 주지 마세요. 낯선 사람의 음료나 사탕을 먹지 말고 따라가지 마세요. 낯선 이가 도움을 요청하면 정중히 거절하세요. 비상시 소리치고 옆 가게로 가세요. 안전 라벨이 붙을 것이고 미리 등록할 수 있습니다. 당신의 사진을 자주 찍으세요. 당신(아이)을 집에 혼자 두지 말고 당신과 함께 가세요. 이름이 있다면 '시간 안전 제품'을 사용하세요. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N15.md b/model/translation_tts/outputs/ab_quality_eval/vi/N15.md new file mode 100644 index 0000000000000000000000000000000000000000..4e2197c086940e0619aa4d8be7157534ba5f5ff2 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N15.md @@ -0,0 +1,48 @@ +# N15 — 미세먼지 정보지 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 256자 | 111자 | +| **Gemini 점수** | **70점** | **88점** | +| 주요 문제 | 기관지(phế quản)를 cơ quan(기관/장기)으로, 걸러지다를 ngã(넘어지다)로 오역하는 등 의학적/상용적 어휘 선택이 매우 부자연스럽고 문장이 하나 누락됨. | 마스크를 'khẩu trang' 대신 'mặt nạ(가면/마스크)'로 쓴 점이 다소 아쉬우나 의미 전달에는 무리가 없으며 문장이 매끄러움. | + +**Round-trip 이슈:** A는 '기관지'를 '기관(cơ quan)'으로 오역하고 마지막 환기 지침이 누락되었으나, B는 핵심 행동과 조건이 모두 잘 보존됨. + +**종합 평가:** B가 학부모 전달 목적에 훨씬 적합합니다. A는 핵심적인 의학 용어 오역과 마지막 문장의 누락이 있어 정보 전달의 신뢰도가 떨어지는 반면, B는 학부모가 해야 할 일을 요약하여 자연스러운 문장으로 재구성했습니다. + +--- + +## A: 원문 전체 (256자) + +**입력:** +``` +미세먼지(PM10)는 지름이 10μm 이하의 먼지이고 초미세먼지(PM2.5)는 지름이 2.5μm 이하의 먼지이다. 미세먼지는 입자가 미세하여 코, 구강, 기관지에서 걸러지지 않고 폐포(뇌)까지 직접 침투한다. 미세먼지 농도가 '나쁨' 이상일 경우 어린이, 노인, 만성질환자 등 민감군은 실외 활동을 자제합니다. 외부 활동 시 보건용 마스크를 착용하고, 돌아오자마자 씻어냅니다. 기상 상황을 수시로 확인합니다. 쉬는 시간마다 3~5분 정기적 환기를 실시합니다. +``` + +**번역:** +``` +PM10 là bụi có đường kính dưới 10μm và PM2.5 là bụi có đường kính dưới 2.5μm. Các hạt nhỏ, mà không bị ngã vào mũi, miệng, cơ quan, xâm nhập trực tiếp vào phổi, não. Khi nồng độ của bụi là 'không tốt', các nhóm nhạy cảm như trẻ em, người già, người mắc bệnh mãn tính kiểm soát hoạt động ngoài trời. Khi hoạt động ngoài trời, họ đeo mặt nạ y tế, rửa ngay khi trở về. Họ kiểm tra thời tiết theo dõi. +``` + +**한국어 역번역:** +``` +PM10은 지름 10μm 이하 먼지이며 PM2.5는 2.5μm 이하 먼지입니다. 작은 입자가 코, 입, 기관(장기/부서)에 넘어지지(걸러지지) 않고 폐와 뇌에 직접 침투합니다. 먼지 농도가 '나쁨'일 때 어린이, 노인, 만성질환자 등 민감군은 실외 활동을 조절합니다. 외부 활동 시 의료용 마스크를 쓰고 돌아오자마자 씻습니다. 기상 상황을 모니터링 확인합니다. +``` + +## B: TODO만 (111자) + +**입력:** +``` +미세먼지 농도가 '나쁨' 이상일 경우 어린이, 노인, 만성질환자 등 민감군은 실외 활동을 자제합니다. 외부 활동 시 보건용 마스크를 착용하고, 돌아오자마자 씻어냅니다. 기상 상황을 수시로 확인합니다. +``` + +**번역:** +``` +Nếu nồng độ ô nhiễm "xấu" hơn, nhóm nhạy cảm như trẻ em, người già, những người bị bệnh mãn tính sẽ ngừng hoạt động ngoài trời, đeo mặt nạ để chăm sóc sức khỏe khi hoạt động ngoài trời, rửa mặt ngay khi trở về, kiểm tra thời tiết thường xuyên. +``` +**한국어 역번역:** +``` +오염 농도가 '나쁨' 수준이면 어린이, 노인, 만성질환자 등 민감군은 실외 활동을 중단하고, 실외 활동 시 건강을 위해 마스크를 착용하며, 귀가 후 즉시 세수하고, 기상 상황을 자주 확인합니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N16.md b/model/translation_tts/outputs/ab_quality_eval/vi/N16.md new file mode 100644 index 0000000000000000000000000000000000000000..71221ac75109a33bb73a57f06d37df77a8e5e458 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N16.md @@ -0,0 +1,48 @@ +# N16 — 알림장 2026.04.07 지역연계 체험학습 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 412자 | 412자 | +| **Gemini 점수** | **35점** | **30점** | +| 주요 문제 | 학교 용어(등하교, 급식)를 전혀 이해하지 못한 직역이며, 특히 준비물과 안전 수칙의 단어 선택이 현지에서 쓰지 않는 엉뚱한 단어로 가득함. | A와 동일한 텍스트를 제공했으며, 필수 행동(TODO) 요약이 전혀 이루어지지 않았고 번역 품질 또한 매우 낮음. | + +**Round-trip 이슈:** 준비물(돗자리→핸드백, 필통→티셔츠), 장소(책가방→비서실), 대상(보호자→경비원), 용어(횡단보도→대역폭) 등 거의 모든 핵심 정보가 왜곡됨. + +**종합 평가:** A와 B 모두 학부모에게 전달하기에 부적합하며, 심각한 오역으로 인해 안전사고나 준비물 혼선이 발생할 위험이 큽니다. 특히 횡단보도를 '대역폭'으로, 책가방을 '비서실'로 번역하는 등 기본적인 문맥 파악이 전혀 안 된 기계 번역 수준입니다. + +--- + +## A: 원문 전체 (412자) + +**입력:** +``` +지역연계 체험학습이 내일(4월 8일 수요일)에 진행됩니다. 평소와 같은 시각에 등교, 하교, 학교에서 급식을 합니다. 준비물을 잘 챙겨옵니다: 학급티셔츠(속에 긴팔), 외투, 긴 바지, 운동화, 충분한 물, 통에 담은 약간의 간식, 1인용 돗자리, 비닐봉지 1장, 물티슈, 휴지, 필통, 모자(선택). 준비물은 책가방에 넣어 이동하며, 더 편한 가방이 있으면 사용해도 됩니다. 보호자에게 안전교육 받고 다짐하고 옵니다. 이동 시 주변을 잘 살피며, 뛰거나 장난을 치지 않습니다. 신호를 지키며 횡단보도로 안전하게 건넙니다. 야외활동에 맞는 몸가짐과 옷차림을 합니다. 높은 곳에 올라가거나 무리를 이탈하는 등 위험한 행동을 하지 않습니다. 자연환경을 훼손하는 행동을 하지 않습니다. 낯선 사람이 같이 가자고 할 때 절대 따라가지 않습니다. +``` + +**번역:** +``` +Ngày mai (Thứ Tư, ngày 8 tháng 4) sẽ có một buổi học thử nghiệm địa phương. Giữa trưa ở trường trung học, trung học và trường học sẽ được tổ chức vào thời điểm bình thường. Hãy chuẩn bị tốt: áo thun lớp, áo quần dài, giày dép, đủ nước, một chút thức ăn trong thùng, một chiếc túi xách, một túi nhựa, vải, vải giấy, vải giấy, áo thun, mũ). Các thiết bị sẽ được đưa vào phòng thư ký và được sử dụng khi có túi xách thoải mái hơn. Bạn sẽ được huấn luyện về an toàn cho người bảo vệ. Bạn sẽ được bảo vệ tốt, không chạy hoặc chơi đùa. Bạn sẽ được an toàn trên đường băng thông, giữ dấu hiệu. Bạn sẽ được trang phục và trang phục phù hợp với các hoạt động ngoài trời. Bạn sẽ không làm những hành động nguy hiểm như leo lên cao hoặc đi ra khỏi đám đông. Bạn sẽ không bao giờ làm những hành động bất thường khi bạn đi theo cách tự nhiên. +``` + +**한국어 역번역:** +``` +내일(4월 8일 수요일) 지역 실험 학습이 있습니다. 중학교, 중학교, 학교의 정오 시간은 평소와 같습니다. 잘 준비하세요: 반티, 긴 옷, 신발, 충분한 물, 통(드럼통 느낌)에 담긴 약간의 음식, 핸드백 하나, 비닐봉투 하나, 천, 종이 천, 종이 천, 티셔츠, 모자. 장비는 비서실에 보관되며 더 편한 가방이 있을 때 사용됩니다. 당신은 경비원(보호자 오역)을 위한 안전 교육을 받게 될 것입니다. 잘 보호받을 것이며, 뛰거나 놀지 마십시오. 대역폭 도로(횡단보도 오역)에서 안전할 것이며 신호를 지키십시오. 야외 활동에 적합한 옷을 입으십시오. 높은 곳에 올라가거나 무리에서 벗어나는 위험한 행동을 하지 마십시오. 자연스러운 방식으로 갈 때 비정상적인 행동을 하지 마십시오. +``` + +## B: TODO만 (412자) + +**입력:** +``` +지역연계 체험학습이 내일(4월 8일 수요일)에 진행됩니다. 평소와 같은 시각에 등교, 하교, 학교에서 급식을 합니다. 준비물을 잘 챙겨옵니다: 학급티셔츠(속에 긴팔), 외투, 긴 바지, 운동화, 충분한 물, 통에 담은 약간의 간식, 1인용 돗자리, 비닐봉지 1장, 물티슈, 휴지, 필통, 모자(선택). 준비물은 책가방에 넣어 이동하며, 더 편한 가방이 있으면 사용해도 됩니다. 보호자에게 안전교육 받고 다짐하고 옵니다. 이동 시 주변을 잘 살피며, 뛰거나 장난을 치지 않습니다. 신호를 지키며 횡단보도로 안전하게 건넙니다. 야외활동에 맞는 몸가짐과 옷차림을 합니다. 높은 곳에 올라가거나 무리를 이탈하는 등 위험한 행동을 하지 않습니다. 자연환경을 훼손하는 행동을 하지 않습니다. 낯선 사람이 같이 가자고 할 때 절대 따라가지 않습니다. +``` + +**번역:** +``` +Ngày mai (Thứ Tư, ngày 8 tháng 4) sẽ có một buổi học thử nghiệm địa phương. Giữa trưa ở trường trung học, trung học và trường học sẽ được tổ chức vào thời điểm bình thường. Hãy chuẩn bị tốt: áo thun lớp, áo quần dài, giày dép, đủ nước, một chút thức ăn trong thùng, một chiếc túi xách, một túi nhựa, vải, vải giấy, vải giấy, áo thun, mũ). Các thiết bị sẽ được đưa vào phòng thư ký và được sử dụng khi có túi xách thoải mái hơn. Bạn sẽ được huấn luyện về an toàn cho người bảo vệ. Bạn sẽ được bảo vệ tốt, không chạy hoặc chơi đùa. Bạn sẽ được an toàn trên đường băng thông, giữ dấu hiệu. Bạn sẽ được trang phục và trang phục phù hợp với các hoạt động ngoài trời. Bạn sẽ không làm những hành động nguy hiểm như leo lên cao hoặc đi ra khỏi đám đông. Bạn sẽ không bao giờ làm những hành động bất thường khi bạn đi theo cách tự nhiên. +``` +**한국어 역번역:** +``` +내일(4월 8일 수요일) 지역 실험 학습이 있습니다. 중학교, 중학교, 학교의 정오 시간은 평소와 같습니다. 잘 준비하세요: 반티, 긴 옷, 신발, 충분한 물, 통(드럼통 느낌)에 담긴 약간의 음식, 핸드백 하나, 비닐봉투 하나, 천, 종이 천, 종이 천, 티셔츠, 모자. 장비는 비서실에 보관되며 더 편한 가방이 있을 때 사용됩니다. 당신은 경비원(보호자 오역)을 위한 안전 교육을 받게 될 것입니다. 잘 보호받을 것이며, 뛰거나 놀지 마십시오. 대역폭 도로(횡단보도 오역)에서 안전할 것이며 신호를 지키십시오. 야외 활동에 적합한 옷을 입으십시오. 높은 곳에 올라가거나 무리에서 벗어나는 위험한 행동을 하지 마십시오. 자연스러운 방식으로 갈 때 비정상적인 행동을 하지 마십시오. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N17.md b/model/translation_tts/outputs/ab_quality_eval/vi/N17.md new file mode 100644 index 0000000000000000000000000000000000000000..28b1a4d586727d44021c57e180f7a31688db7ec8 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N17.md @@ -0,0 +1,48 @@ +# N17 — 알림장 2026.04.15 준비물 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 83자 | 83자 | +| **Gemini 점수** | **65점** | **60점** | +| 주요 문제 | 악기 리코더를 녹음기(bộ ghi âm)로 오역했으며, 주어가 '우리가 준비한다'로 되어 있어 학부모 대상 안내문으로 부적절합니다. | A와 동일한 번역이며, 요약문임에도 불구하고 이어폰 종류를 결정하는 핵심 정보(단자 타입)를 생략하여 실질적인 가이드 역할을 못 합니다. | + +**Round-trip 이슈:** 악기 '리코더'가 '녹음 장치'로 오역되었고, 이어폰의 필수 규격(3.5mm, C타입) 정보가 완전히 누락되었습니다. + +**종합 평가:** 둘 다 학부모 전달용으로 부적합합니다. 악기인 리코더를 전자제품인 녹음기로 오역하여 엉뚱한 물건을 준비하게 만들 위험이 있으며, 이어폰 단자 규격과 같은 핵심 구매 정보를 누락한 치명적인 결함이 있습니다. + +--- + +## A: 원문 전체 (83자) + +**입력:** +``` +리코더, 간편복, 운동화를 준비합니다. 개인용 이어폰(3.5mm, C타입)을 4월 20일까지 준비합니다(크롬북 이용 수업 시 소리를 들을 때 사용). +``` + +**번역:** +``` +Chúng tôi chuẩn bị cho các bộ ghi âm, quần áo, giày dép, tai nghe dành cho cá nhân cho đến ngày 20 tháng 4. +``` + +**한국어 역번역:** +``` +우리는 4월 20일까지 녹음 장치, 옷, 신발, 개인용 이어폰을 준비합니다. +``` + +## B: TODO만 (83자) + +**입력:** +``` +리코더, 간편복, 운동화를 준비합니다. 개인용 이어폰(3.5mm, C타입)을 4월 20일까지 준비합니다(크롬북 이용 수업 시 소리를 들을 때 사용). +``` + +**번역:** +``` +Chúng tôi chuẩn bị cho các bộ ghi âm, quần áo, giày dép, tai nghe dành cho cá nhân cho đến ngày 20 tháng 4. +``` +**한국어 역번역:** +``` +우리는 4월 20일까지 녹음 장치, 옷, 신발, 개인용 이어폰을 준비합니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N18.md b/model/translation_tts/outputs/ab_quality_eval/vi/N18.md new file mode 100644 index 0000000000000000000000000000000000000000..6c038c23db25e185566cf6b1c0c0ad37925b8c68 --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N18.md @@ -0,0 +1,48 @@ +# N18 — 알림장 2026.04.17 주요 일정 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 199자 | 199자 | +| **Gemini 점수** | **45점** | **40점** | +| 주요 문제 | 평면도형(đánh giá bằng phẳng), 학습지 9장(chương 9) 등 전문 용어 오역과 문장 누락(신체검사, 체육)이 치명적임. | A와 동일한 오역을 포함하고 있으며, '필수 행동' 요약임에도 불구하고 정작 중요한 신체검사와 준비물 규격(C타입) 등의 정보를 놓침. | + +**Round-trip 이슈:** '오답 위주 공부'를 '답변 위조(giả mạo)'로 오역하여 교육적으로 심각한 의미 왜곡이 발생했으며, 신체검사와 체육 일정 정보가 통째로 누락됨. + +**종합 평가:** 둘 다 학부모에게 전달하기에 매우 부적절합니다. '오답'을 '위조/가짜'라는 단어로 번역하여 학부모에게 잘못된 행동 지침을 줄 위험이 크며, 후반부 문장이 모두 누락되어 통신문으로서의 제 역할을 전혀 수행하지 못합니다. + +--- + +## A: 원문 전체 (199자) + +**입력:** +``` +독서록 1편 이상 써오기 — 윤독도서 [하룻밤]을 4월 27일까지 기록합니다. 개인용 이어폰(3.5mm, C타입)을 4월 20일까지 준비합니다. 수학 2단원 평면도형 평가가 4월 23일에 있으므로 학습지 9장 오답 위주로 공부하고 미니북의 개념을 외웁니다. 신체검사는 4월 22일(수요일)에 진행됩니다. 3학년 전체 체육은 4월 24일(5교시)에 있습니다. +``` + +**번역:** +``` +Tôi sẽ ghi lại một cuốn sách để đọc hơn một cuốn sách cho đến ngày 27 tháng 4. Tôi sẽ chuẩn bị cho những chiếc tai nghe 3.5mm cho cá nhân cho đến ngày 20 tháng 4. Vì vậy, đánh giá bằng phẳng của lớp 2 về toán học là vào ngày 23 tháng 4. Vì vậy, chúng tôi sẽ học theo cách giả mạo các câu trả lời của chương 9 của bài học và tìm hiểu khái niệm của cuốn sách nhỏ. +``` + +**한국어 역번역:** +``` +나는 4월 27일까지 한 권 이상의 책을 읽기 위해 한 권의 책을 기록할 것입니다. 나는 4월 20일까지 개인용 3.5mm 이어폰을 준비할 것입니다. 그래서 4월 23일에 수학 2학년의 평평한 평가가 있습니다. 그래서 우리는 학습지 9장의 답변을 위조하는 방식으로 공부하고 미니북의 개념을 배울 것입니다. +``` + +## B: TODO만 (199자) + +**입력:** +``` +독서록 1편 이상 써오기 — 윤독도서 [하룻밤]을 4월 27일까지 기록합니다. 개인용 이어폰(3.5mm, C타입)을 4월 20일까지 준비합니다. 수학 2단원 평면도형 평가가 4월 23일에 있으므로 학습지 9장 오답 위주로 공부하고 미니북의 개념을 외웁니다. 신체검사는 4월 22일(수요일)에 진행됩니다. 3학년 전체 체육은 4월 24일(5교시)에 있습니다. +``` + +**번역:** +``` +Tôi sẽ ghi lại một cuốn sách để đọc hơn một cuốn sách cho đến ngày 27 tháng 4. Tôi sẽ chuẩn bị cho những chiếc tai nghe 3.5mm cho cá nhân cho đến ngày 20 tháng 4. Vì vậy, đánh giá bằng phẳng của lớp 2 về toán học là vào ngày 23 tháng 4. Vì vậy, chúng tôi sẽ học theo cách giả mạo các câu trả lời của chương 9 của bài học và tìm hiểu khái niệm của cuốn sách nhỏ. +``` +**한국어 역번역:** +``` +나는 4월 27일까지 한 권 이상의 책을 읽기 위해 한 권의 책을 기록할 것입니다. 나는 4월 20일까지 개인용 3.5mm 이어폰을 준비할 것입니다. 그래서 4월 23일에 수학 2학년의 평평한 평가가 있습니다. 그래서 우리는 학습지 9장의 답변을 위조하는 방식으로 공부하고 미니북의 개념을 배울 것입니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/N19.md b/model/translation_tts/outputs/ab_quality_eval/vi/N19.md new file mode 100644 index 0000000000000000000000000000000000000000..47638d52c1efe0f5767689320236d9c307e0f24a --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/N19.md @@ -0,0 +1,48 @@ +# N19 — 2026학년도 3학년 맞춤형 학업성취도 자율평가 실시 안내 + +## 요약 + +| 항목 | A (원문 전체) | B (TODO만) | +|---|---|---| +| 입력 글자수 | 241자 | 154자 | +| **Gemini 점수** | **65점** | **60점** | +| 주요 문제 | 핵심어휘 '수리력' 오역(khả năng sửa chữa), 마지막 문장(결석 시 별도 실시 불가) 누락, '맞춤형'을 '규정에 따른'으로 오역. | '맞춤형'을 국적 불명의 단어(thành thăng tiến)로 번역, 학부모에게 'bạn(친구/너)'이라는 부적절한 호칭 사용, 핵심 안내 사항 누락. | + +**Round-trip 이슈:** A는 '수리력(math)'을 '수선/수리(repair)'로 오역했고, A와 B 모두 핵심 행동인 '결석 자제 및 등교 독려' 문장을 누락함. + +**종합 평가:** A가 그나마 낫지만 둘 다 부적합합니다. A는 수리력 오역과 핵심 문장 누락이 치명적이며, B는 단어 선택이 비문 수준이고 학부모를 'bạn'이라 칭하는 결례를 범했습니다. 특히 두 번역 모두 '결석하지 말고 등교하라'는 학부모의 필수 행동 지침을 누락하여 안내문의 목적을 달성하지 못했습니다. + +--- + +## A: 원문 전체 (241자) + +**입력:** +``` +2026학년도 3학년 '맞춤형 학업성취도 자율평가'가 4월 9일(목)에 실시됩니다. 평가 내용은 문해력, 수리력이며 직전 학년(2학년)의 범위에서 출제됩니다. 이 평가는 성적에 반영되는 평가가 아니며 학생 개개인의 학습 수준 파악용입니다. 4월 말에 평가 결과를 개별 통지할 예정이니 각 가정에서도 학생의 강점과 보완이 필요한 부분을 파악하여 주시기 바랍니다. 평가 당일 결석한 학생은 별도로 실시하기 어려우므로 가급적 등교합니다. +``` + +**번역:** +``` +Thử nghiệm tự đánh giá hiệu quả học tập theo quy định của lớp 3 năm 2026 sẽ được thực hiện vào ngày 9 tháng 4 năm 2026. Các bài đánh giá sẽ được đưa ra trong phạm vi năng lực, khả năng sửa chữa và năm học trước năm học. Đây không phải là đánh giá phản ánh vào điểm số, mà là để xác định mức độ học tập của từng học sinh. Cuối tháng 4 kết quả đánh giá sẽ được báo cáo một cách riêng lẻ, vì vậy chúng ta mong muốn mỗi gia đình phải xác định những điểm mạnh và sự bổ sung cần thiết của học sinh. +``` + +**한국어 역번역:** +``` +2026년 3학년 규정에 따른 학습 효율성 자가 테스트가 2026년 4월 9일에 실시됩니다. 평가 문항은 역량, '수리(수선) 능력' 및 이전 학년 범위 내에서 출제됩니다. 이것은 성적에 반영되는 평가가 아니라 학생 개개인의 학습 수준을 파악하기 위한 것입니다. 4월 말에 결과가 개별 보고될 것이니, 우리는 각 가정에서 학생의 강점과 보충이 필요한 부분을 파악하기를 원합니다. +``` + +## B: TODO만 (154자) + +**입력:** +``` +2026학년도 3학년 '맞춤형 학업성취도 자율평가'가 4월 9일(목)에 실시됩니다. 4월 말에 평가 결과를 개별 통지할 예정이니 각 가정에서도 학생의 강점과 보완이 필요한 부분을 파악하여 주시기 바랍니다. 평가 당일 결석한 학생은 별도로 실시하기 어려우므로 가급적 등교합니다. +``` + +**번역:** +``` +Thử nghiệm tự đánh giá thành thăng tiến học tập theo quy định năm 2026 sẽ được thực hiện vào ngày 9 tháng 4. Cuối tháng 4 kết quả đánh giá sẽ được công bố một cách riêng biệt, vì vậy chúng tôi mong muốn bạn nhận ra những điểm mạnh và sự bổ sung cần thiết của học sinh trong mỗi gia đình. +``` +**한국어 역번역:** +``` +2026년 규정에 따른 학습 승진 성취 자가 테스트가 4월 9일에 실시됩니다. 4월 말에 평가 결과가 개별 공포될 것이므로, 우리는 당신(bạn)이 각 가정에서 학생의 강점과 필요한 보충을 인지하기를 바랍니다. +``` diff --git a/model/translation_tts/outputs/ab_quality_eval/vi/summary.md b/model/translation_tts/outputs/ab_quality_eval/vi/summary.md new file mode 100644 index 0000000000000000000000000000000000000000..31cede1eb84f3d97f5e53c583d5ce879c95ad17a --- /dev/null +++ b/model/translation_tts/outputs/ab_quality_eval/vi/summary.md @@ -0,0 +1,102 @@ +# A/B 번역 품질 평가 결과 + +언어: vi | 평가 모델: `gemini-3-flash-preview` + +**A 평균: 50.1점 | B 평균: 54.1점 | 차이: +4.0점** + +| 공지 | 제목 | A점수 | B점수 | 차이 | 판정 | +|---|---|---|---|---|---| +| N01 | 대기오염(미세먼지) 대응 및 질병... | 55점 | 40점 | -15점 | A 우세 | +| N02 | 2026 초등안심벨 배부 안내 | 45점 | 78점 | +33점 | B 우세 | +| N04 | 주간학습계획 3주(3.23~3.2... | 45점 | 20점 | -25점 | A 우세 | +| N05 | 주간학습계획 3주(3.23~3.2... | 45점 | 32점 | -13점 | A 우세 | +| N06 | 주간학습계획 8주(4.20~4.2... | 45점 | 30점 | -15점 | A 우세 | +| N07 | 주간학습계획 9주(4.27~5.1... | 55점 | 52점 | -3점 | 동등 | +| N08 | 2026학년도 1학기 도서관 도서... | 45점 | 75점 | +30점 | B 우세 | +| N09 | 갈산 교육공동체 약속 실천 안내 | 75점 | 68점 | -7점 | A 우세 | +| N10 | 안전한 등하교를 위한 협조 요청 ... | 45점 | 40점 | -5점 | 동등 | +| N11 | 2026학년도 학교평가 안내 | 72점 | 65점 | -7점 | A 우세 | +| N12 | 제16기 학교운영위원회 위원 선출... | 25점 | 90점 | +65점 | B 우세 | +| N13 | 2026학년도 기초학력 진단검사 ... | 40점 | 65점 | +25점 | B 우세 | +| N14 | 2026 실종·유괴 예방교육 안내 | 30점 | 40점 | +10점 | B 우세 | +| N15 | 미세먼지 정보지 | 70점 | 88점 | +18점 | B 우세 | +| N16 | 알림장 2026.04.07 지역연... | 35점 | 30점 | -5점 | 동등 | +| N17 | 알림장 2026.04.15 준비물... | 65점 | 60점 | -5점 | 동등 | +| N18 | 알림장 2026.04.17 주요 ... | 45점 | 40점 | -5점 | 동등 | +| N19 | 2026학년도 3학년 맞춤형 학업... | 65점 | 60점 | -5점 | 동등 | + +--- + +## 공지별 종합 평가 + +### N01 — 대기오염(미세먼지) 대응 및 질병결석 안내 + +두 번역 모두 실제 가정통신문으로 사용하기에 부적합합니다. A는 문장이 중간에 끊겨 가장 중요한 '질병 결석 인정 절차'를 알 수 없고, B는 기호(︎) 표기 오류와 대상(초등생) 오역으로 인해 정보로서의 가치가 없습니다. 굳이 선택하자면 문장이라도 온전한 A가 낫지만, 핵심 내용 누락으로 인해 재번역이 반드시 필요합니다. + +### N02 — 2026 초등안심벨 배부 안내 + +B가 학부모 전달 목적에 훨씬 적합합니다. A는 번역이 중간에 끊겼을 뿐만 아니라 안심벨을 솜으로 오역하여 정보 전달이 불가능한 수준이나, B는 일부 단어 오역에도 불구하고 충전 주기와 부착 위치 등 부모가 수행해야 할 핵심 정보(TODO)를 대부분 포함하고 있습니다. + +### N04 — 주간학습계획 3주(3.23~3.27) 및 출결규정 안내 + +두 번역 모두 실제 학부모에게 전달하기 불가능한 수준입니다. A는 문장 형태는 유지했으나 핵심 지침인 결석 규정을 누락했고, B는 기술적 오류로 단어가 무한 반복되며 내용이 잘렸으므로 A가 상대적으로 낫지만 재번역이 반드시 필요합니다. + +### N05 — 주간학습계획 3주(3.23~3.27) 3학년 1반 + +두 번역 모두 실제 사용이 불가능한 수준이나, 굳이 고른다면 A가 차라리 낫습니다. B는 요일과 준비물 정보를 완전히 잘못 제공(환각)하여 학부모에게 큰 혼란을 줄 수 있는 반면, A는 후반부가 누락되었을지언정 전반부의 날짜 정보는 어느 정도 유지하고 있기 때문입니다. + +### N06 — 주간학습계획 8주(4.20~4.24) 3학년 6반 + +두 번역 모두 학부모에게 전달하기에 부적절한 낙제 수준입니다. 굳이 비교하자면 A가 앞부분의 스마트기기(디벗) 관련 정보를 일부 전달하고 있으나, 두 번역 모두 나눔장터 준비물과 독서 과제 같은 핵심 'TODO'를 전혀 담지 못해 실제 공지로서의 가치가 없습니다. + +### N07 — 주간학습계획 9주(4.27~5.1) 3학년 1반 + +두 번역 모두 학부모에게 전달하기에 부적합합니다. 특히 준비물 항목에서 리코더를 녹음기기로, 간편복을 글쓰기로, 줄넘기를 도약으로 오역하여 학부모가 챙겨야 할 물품을 전혀 파악할 수 없습니다. 굳이 선택하자면 휴업일 정보라도 포함된 A가 낫지만, 준비물 정보를 반드시 수정해야 합니다. + +### N08 — 2026학년도 1학기 도서관 도서 구입 신청 + +B가 학부모 전달 목적에 더 적합합니다. A는 문장은 유려하나 안내문의 필수 정보인 '언제까지 어디로' 제출해야 하는지가 누락되어 정보 전달 기능을 상실한 반면, B는 비록 직역체이고 고유 명사 오역이 있으나 마감 날짜와 시간 등 핵심 행동 지침을 포함하고 있습니다. + +### N09 — 갈산 교육공동체 약속 실천 안내 + +A가 더 적합합니다. B는 문법적으로 파괴된 문장 구조로 인해 가독성이 매우 떨어지며 '안아주기'의 의미를 '위로'로 오역했습니다. A는 학교 이름과 대상 명칭에 오류가 있으나 문장이 명확하게 끊어져 있어 학부모가 정보를 파악하기에 훨씬 수월합니다. + +### N10 — 안전한 등하교를 위한 협조 요청 및 자전거 이용 시 안전 수칙 안내 + +두 번역 모두 학부모에게 전달하기에 부적합합니다. 가정통신문의 핵심인 구체적인 실천 사항(헬멧 착용, 거치 장소 등)이 대거 누락되어 안전사고 예방이라는 목적을 달성할 수 없으며, 단어 선택에서도 학교 현장의 맥락이 결여되어 있습니다. + +### N11 — 2026학년도 학교평가 안내 + +그나마 A가 학부모 전달 목적에 더 적합합니다. B는 핵심 행동 요약이 목적임에도 불구하고 입력문에 포함된 '11월 일정'과 '설문 참여'라는 가장 중요한 행동 정보를 누락하여 안내문으로서 제 역할을 하지 못합니다. A는 학생(Học sinh)을 대학생(Sinh viên)으로 번역하는 등의 단어 선택 오류가 있으나, 전체적인 정보량과 맥락은 유지하고 있습니다. + +### N12 — 제16기 학교운영위원회 위원 선출 결과 안내 + +B가 압도적으로 우수합니다. A는 번역 엔진의 오류로 인해 동일한 단어가 반복되고 정작 중요한 연락처와 부위원장 정보가 누락되어 정보를 전혀 전달할 수 없는 반면, B는 학부모가 해야 할 일을 명확하고 간결하게 전달합니다. + +### N13 — 2026학년도 기초학력 진단검사 실시 안내 + +둘 다 실제 발송에는 부적합하나, 굳이 선택하자면 B가 낫습니다. A는 날짜를 잘못 알려주는 '거짓 정보(환각)'를 포함하고 있어 학부모에게 큰 혼란을 주지만, B는 일부 정보가 누락되었을 뿐 적혀 있는 날짜(2, 5, 6학년)는 정확하기 때문입니다. + +### N14 — 2026 실종·유괴 예방교육 안내 + +두 번역 모두 실제 학부모에게 전달하기에 부적절하며 위험합니다. '우범지역'을 '좋은 지역'으로, '양천구'를 '광둥/하노이'로 번역하는 등 핵심 정보의 왜곡과 환각 현상이 심각하여 안전 사고 예방이라는 목적을 전혀 달성할 수 없습니다. + +### N15 — 미세먼지 정보지 + +B가 학부모 전달 목적에 훨씬 적합합니다. A는 핵심적인 의학 용어 오역과 마지막 문장의 누락이 있어 정보 전달의 신뢰도가 떨어지는 반면, B는 학부모가 해야 할 일을 요약하여 자연스러운 문장으로 재구성했습니다. + +### N16 — 알림장 2026.04.07 지역연계 체험학습 + +A와 B 모두 학부모에게 전달하기에 부적합하며, 심각한 오역으로 인해 안전사고나 준비물 혼선이 발생할 위험이 큽니다. 특히 횡단보도를 '대역폭'으로, 책가방을 '비서실'로 번역하는 등 기본적인 문맥 파악이 전혀 안 된 기계 번역 수준입니다. + +### N17 — 알림장 2026.04.15 준비물 안내 + +둘 다 학부모 전달용으로 부적합합니다. 악기인 리코더를 전자제품인 녹음기로 오역하여 엉뚱한 물건을 준비하게 만들 위험이 있으며, 이어폰 단자 규격과 같은 핵심 구매 정보를 누락한 치명적인 결함이 있습니다. + +### N18 — 알림장 2026.04.17 주요 일정 안내 + +둘 다 학부모에게 전달하기에 매우 부적절합니다. '오답'을 '위조/가짜'라는 단어로 번역하여 학부모에게 잘못된 행동 지침을 줄 위험이 크며, 후반부 문장이 모두 누락되어 통신문으로서의 제 역할을 전혀 수행하지 못합니다. + +### N19 — 2026학년도 3학년 맞춤형 학업성취도 자율평가 실시 안내 + +A가 그나마 낫지만 둘 다 부적합합니다. A는 수리력 오역과 핵심 문장 누락이 치명적이며, B는 단어 선택이 비문 수준이고 학부모를 'bạn'이라 칭하는 결례를 범했습니다. 특히 두 번역 모두 '결석하지 말고 등교하라'는 학부모의 필수 행동 지침을 누락하여 안내문의 목적을 달성하지 못했습니다. diff --git a/model/translation_tts/outputs/glossary_compare/glossary_compare.csv b/model/translation_tts/outputs/glossary_compare/glossary_compare.csv new file mode 100644 index 0000000000000000000000000000000000000000..e88ecb9eb6cc2cfe5b30c3943f68fe289ff42d47 --- /dev/null +++ b/model/translation_tts/outputs/glossary_compare/glossary_compare.csv @@ -0,0 +1,65 @@ +lang,label,korean_term,sentence,nllb_translation,glossary_preferred,reflected,status +vi,베트남어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,Hãy gửi báo cáo cho giáo viên giảng dạy mỗi ngày.,sổ liên lạc,N,❌ 누락 +vi,베트남어,담임교사,담임교사에게 결석 사유를 알려 주세요.,Hãy nói chuyện với giáo viên giảng dạy.,giáo viên chủ nhiệm,N,❌ 누락 +vi,베트남어,방과후 신청서,방과후 신청서를 이번 주 금요일까지 내 주세요.,Hãy nộp đơn sau giờ học vào thứ Sáu tuần này.,,N,— 미등록 +vi,베트남어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",Hãy trả 40.000 đô vào tuần tới.,tiền học,N,❌ 누락 +vi,베트남어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,Hãy nộp đơn học tập trước 3 ngày.,buổi trải nghiệm,N,❌ 누락 +vi,베트남어,결석계,결석계를 5일 이내에 담임교사에게 제출합니다.,Chúng tôi sẽ gửi bản kết án cho giáo viên giảng dạy trong vòng 5 ngày.,,N,— 미등록 +vi,베트남어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Ngày mai, chúng ta sẽ chuẩn bị cho màu sắc, cỏ và cỏ.",đồ dùng cần chuẩn bị,N,❌ 누락 +vi,베트남어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,Cuộc đấu thầu công khai sẽ diễn ra vào ngày 23 tháng 4 lúc 3 giờ sáng.,tiết học dự giờ,N,❌ 누락 +en,영어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,Please submit an alert to the principal every day.,Notice book,N,❌ 누락 +en,영어,담임교사,담임교사에게 결석 사유를 알려 주세요.,I'd like you to tell the headmaster the reasoning.,Homeroom teacher,N,❌ 누락 +en,영어,방과후 신청서,방과후 신청서를 이번 주 금요일까지 내 주세요.,I'd like you to submit an after-school application by Friday this week.,,N,— 미등록 +en,영어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.","I'd like you to pay 40,000 for the class next week.",Class fee,N,❌ 누락 +en,영어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,Please submit your application for an apprenticeship three days in advance.,Field trip,N,❌ 누락 +en,영어,결석계,결석계를 5일 이내에 담임교사에게 제출합니다.,I'm going to submit the final report to the principal within five days.,,N,— 미등록 +en,영어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Tomorrow's preparation is colour, grass, thickness.",Things to bring,N,❌ 누락 +en,영어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,The public auction will take place on April 23 at 3 p.m.,Open house class,N,❌ 누락 +zh,중국어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,请每天向校长提交通知书.,联络簿,N,❌ 누락 +zh,중국어,담임교사,담임교사에게 결석 사유를 알려 주세요.,让主任教师知道他的结论.,班主任老师,N,❌ 누락 +zh,중국어,방과후 신청서,방과후 신청서를 이번 주 금요일까지 내 주세요.,让我在本周星期五提交课后申请.,,N,— 미등록 +zh,중국어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",请在下周之前支付4万美元的学费.,学费,Y,✅ 반영 +zh,중국어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,请在三天前提交实习申请.,校外体험활동,N,❌ 누락 +zh,중국어,결석계,결석계를 5일 이내에 담임교사에게 제출합니다.,"在五天内,我们将结算表递给校长.",,N,— 미등록 +zh,중국어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","明天的准备是颜色,草,.",准备物品,N,❌ 누락 +zh,중국어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,在4月23日下午3点上市.,公开课,N,❌ 누락 +th,태국어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,โปรดส่งใบเตือนให้ครูผู้สอนทุกวัน,สมุดจดแจ้งสำหรับผู้ปกครอง,N,❌ 누락 +th,태국어,담임교사,담임교사에게 결석 사유를 알려 주세요.,ให้คุณบอกครูผู้รับผิดชอบถึงข้อสรุป,คุณครูประจำชั้น,N,❌ 누락 +th,태국어,방과후 신청서,방과후 신청서를 이번 주 금요일까지 내 주세요.,ขอใบสมัครหลังเลิกเรียนถึงวันศุกร์ของสัปดาห์นี้,,N,— 미등록 +th,태국어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.","โปรดชําระค่าธรรมเนียม 40,000 บาท ภายในสัปดาห์หน้า",ค่าเรียน,N,❌ 누락 +th,태국어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,โปรดส่งใบสมัครเรียนก่อน 3 วัน,กิจกรรมเรียนรู้นอกห้องเรียน,N,❌ 누락 +th,태국어,결석계,결석계를 5일 이내에 담임교사에게 제출합니다.,ผมส่งรายงานให้ครูผู้สอนภายใน 5 วัน,,N,— 미등록 +th,태국어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","อาหารพรุ่งนี้คือ สี, ผัก, ผัก",สิ่งที่ต้องเตรียมมา,N,❌ 누락 +th,태국어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,การซื้อขายสาธารณะจะจัดขึ้นในวันที่ 23 เมษายน เวลา 3.00 น.,การเปิดห้องเรียนให้เข้าชม,N,❌ 누락 +ja,일본어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,警報を毎日のように 担当教師に提出してください,連絡帳,N,❌ 누락 +ja,일본어,담임교사,담임교사에게 결석 사유를 알려 주세요.,補習班の先生に 締めくくりの説明をお願いします,担任の先生,N,❌ 누락 +ja,일본어,방과후 신청서,방과후 신청서를 이번 주 금요일까지 내 주세요.,この週金曜日までに 課後申請書を提出してください,,N,— 미등록 +ja,일본어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",授業料は4万円です,授業料,Y,✅ 반영 +ja,일본어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,3日前に応募してください,体験学習,N,❌ 누락 +ja,일본어,결석계,결석계를 5일 이내에 담임교사에게 제출합니다.,5日以内に卒業式を 教長に提出します,,N,— 미등록 +ja,일본어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.",明日の準備はカラーや草やガッテンです,持ち物,N,❌ 누락 +ja,일본어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,4月23日 (日) 午後3時に公募が行われます,授業参観,N,❌ 누락 +ru,러시아어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,"Пожалуйста, ежедневно отправляйте уведомление к преподавателю.",дневник или тетрадь для заметок,N,❌ 누락 +ru,러시아어,담임교사,담임교사에게 결석 사유를 알려 주세요.,"Пожалуйста, расскажите своему преподавателю о своем заключении.",классный руководитель (воспитатель),N,❌ 누락 +ru,러시아어,방과후 신청서,방과후 신청서를 이번 주 금요일까지 내 주세요.,Вы можете подать заявку после школы до пятницы этой недели.,,N,— 미등록 +ru,러시아어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",На следующей неделе заплатите 40 000 долларов за обучение.,оплата за занятия,N,❌ 누락 +ru,러시아어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,"Пожалуйста, подавайте заявку на обучение за три дня.",учебная экскурсия,N,❌ 누락 +ru,러시아어,결석계,결석계를 5일 이내에 담임교사에게 제출합니다.,В течение пяти дней я должен представить его преподавателю.,,N,— 미등록 +ru,러시아어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Завтра мы будем готовить цветы, траву и огурцы.",что нужно принести,N,❌ 누락 +ru,러시아어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,Открытые аукционы пройдут 23 апреля в три часа ночи.,открытый урок,N,❌ 누락 +ms,말레이시아어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,Sila hantar surat pemberitahuan kepada guru setiap hari.,buku makluman,N,❌ 누락 +ms,말레이시아어,담임교사,담임교사에게 결석 사유를 알려 주세요.,Beritahu jururawat anda apa yang anda maksudkan.,guru kelas,N,❌ 누락 +ms,말레이시아어,방과후 신청서,방과후 신청서를 이번 주 금요일까지 내 주세요.,Anda boleh hantar permohonan selepas sekolah sehingga Jumaat minggu ini.,,N,— 미등록 +ms,말레이시아어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.","Pergilah membayar kos kelas 40,000 sehingga minggu depan.",yuran kelas,N,❌ 누락 +ms,말레이시아어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,Sila kirimkan permohonan anda tiga hari sebelum tamat pengajian.,aktiviti pembelajaran luar kelas,N,❌ 누락 +ms,말레이시아어,결석계,결석계를 5일 이내에 담임교사에게 제출합니다.,Saya akan menghantar fail kepada guru dalam masa lima hari.,,N,— 미등록 +ms,말레이시아어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Keadaan esok ialah warna, rumput, kulit.",barang yang perlu dibawa,N,❌ 누락 +ms,말레이시아어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,Pengundian awam akan diadakan pada 23 April pada jam 3 petang.,kelas terbuka,N,❌ 누락 +mn,몽골어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,Та нар өдөр бүр анхааруулгыг багшийн өмгөөлөгчдэд хүргүүлээрэй.,мэдэгдэл,N,❌ 누락 +mn,몽골어,담임교사,담임교사에게 결석 사유를 알려 주세요.,Та нар багшийнхээ дүгнэлтэд хүргэж өгөөч.,анги даасан багш,N,❌ 누락 +mn,몽골어,방과후 신청서,방과후 신청서를 이번 주 금요일까지 내 주세요.,Сургуулийн дараах хүсэлтээ энэ долоо хоногийн пүрэв гараг хүртэл өгөөч.,,N,— 미등록 +mn,몽골어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",Ирэх долоо хоногт 40 мянган төгрөгийн сургалтын төлбөрийг төлж өгөөч.,хичээлийн төлбөр,N,❌ 누락 +mn,몽골어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,Оюутны сургалтын хүсэлтээ 3 хоногийн өмнө ирүүлнэ үү?,танин мэдэхүйн аялал,N,❌ 누락 +mn,몽골어,결석계,결석계를 5일 이내에 담임교사에게 제출합니다.,5 хоногийн дотор дэд багшийн дүгнэлтийг хүргүүлнэ.,,N,— 미등록 +mn,몽골어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Маргаашгийн бэлтгэл зүйл нь арьс, ургамал, ногоо юм.",бэлдэх зүйлс,N,❌ 누락 +mn,몽골어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,Олон нийтийн худалдан авалт дөрөвдүгээр сарын 23-ны өдрийн 3 цагт болно.,нээлттэй хичээл,N,❌ 누락 diff --git a/model/translation_tts/outputs/glossary_compare/summary.md b/model/translation_tts/outputs/glossary_compare/summary.md new file mode 100644 index 0000000000000000000000000000000000000000..10723fb766a127948b9e4de8c79c327bd7daae51 --- /dev/null +++ b/model/translation_tts/outputs/glossary_compare/summary.md @@ -0,0 +1,124 @@ +# 용어사전 전/후 번역 비교 + +모델: `facebook/nllb-200-distilled-600M` | 대상 언어: vi, en, zh, th, ja, ru, ms, mn + +## 언어별 반영률 요약 + +| 언어 | 반영 | 누락 | 반영률 | +|---|---|---|---| +| vi (베트남어) | 0 | 6 | **0%** | +| en (영어) | 0 | 6 | **0%** | +| zh (중국어) | 1 | 5 | **17%** | +| th (태국어) | 0 | 6 | **0%** | +| ja (일본어) | 1 | 5 | **17%** | +| ru (러시아어) | 0 | 6 | **0%** | +| ms (말레이시아어) | 0 | 6 | **0%** | +| mn (몽골어) | 0 | 6 | **0%** | + +--- + +## 용어별 상세 비교 + +### 알림장 + +| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 | +|---|---|---|---| +| vi (베트남어) | Hãy gửi báo cáo cho giáo viên giảng dạy ... | sổ liên lạc | ❌ 누락 | +| en (영어) | Please submit an alert to the principal ... | Notice book | ❌ 누락 | +| zh (중국어) | 请每天向校长提交通知书.... | 联络簿 | ❌ 누락 | +| th (태국어) | โปรดส่งใบเตือนให้ครูผู้สอนทุกวัน... | สมุดจดแจ้งสำหรับผู้ปกครอง | ❌ 누락 | +| ja (일본어) | 警報を毎日のように 担当教師に提出してください... | 連絡帳 | ❌ 누락 | +| ru (러시아어) | Пожалуйста, ежедневно отправляйте уведом... | дневник или тетрадь для заметок | ❌ 누락 | +| ms (말레이시아어) | Sila hantar surat pemberitahuan kepada g... | buku makluman | ❌ 누락 | +| mn (몽골어) | Та нар өдөр бүр анхааруулгыг багшийн өмг... | мэдэгдэл | ❌ 누락 | + +### 담임교사 + +| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 | +|---|---|---|---| +| vi (베트남어) | Hãy nói chuyện với giáo viên giảng dạy.... | giáo viên chủ nhiệm | ❌ 누락 | +| en (영어) | I'd like you to tell the headmaster the ... | Homeroom teacher | ❌ 누락 | +| zh (중국어) | 让主任教师知道他的结论.... | 班主任老师 | ❌ 누락 | +| th (태국어) | ให้คุณบอกครูผู้รับผิดชอบถึงข้อสรุป... | คุณครูประจำชั้น | ❌ 누락 | +| ja (일본어) | 補習班の先生に 締めくくりの説明をお願いします... | 担任の先生 | ❌ 누락 | +| ru (러시아어) | Пожалуйста, расскажите своему преподават... | классный руководитель (воспитатель) | ❌ 누락 | +| ms (말레이시아어) | Beritahu jururawat anda apa yang anda ma... | guru kelas | ❌ 누락 | +| mn (몽골어) | Та нар багшийнхээ дүгнэлтэд хүргэж өгөөч... | анги даасан багш | ❌ 누락 | + +### 방과후 신청서 + +| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 | +|---|---|---|---| +| vi (베트남어) | Hãy nộp đơn sau giờ học vào thứ Sáu tuần... | — | — 미등록 | +| en (영어) | I'd like you to submit an after-school a... | — | — 미등록 | +| zh (중국어) | 让我在本周星期五提交课后申请.... | — | — 미등록 | +| th (태국어) | ขอใบสมัครหลังเลิกเรียนถึงวันศุกร์ของสัปด... | — | — 미등록 | +| ja (일본어) | この週金曜日までに 課後申請書を提出してください... | — | — 미등록 | +| ru (러시아어) | Вы можете подать заявку после школы до п... | — | — 미등록 | +| ms (말레이시아어) | Anda boleh hantar permohonan selepas sek... | — | — 미등록 | +| mn (몽골어) | Сургуулийн дараах хүсэлтээ энэ долоо хон... | — | — 미등록 | + +### 수업비 + +| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 | +|---|---|---|---| +| vi (베트남어) | Hãy trả 40.000 đô vào tuần tới.... | tiền học | ❌ 누락 | +| en (영어) | I'd like you to pay 40,000 for the class... | Class fee | ❌ 누락 | +| zh (중국어) | 请在下周之前支付4万美元的学费.... | 学费 | ✅ 반영 | +| th (태국어) | โปรดชําระค่าธรรมเนียม 40,000 บาท ภายในสั... | ค่าเรียน | ❌ 누락 | +| ja (일본어) | 授業料は4万円です... | 授業料 | ✅ 반영 | +| ru (러시아어) | На следующей неделе заплатите 40 000 дол... | оплата за занятия | ❌ 누락 | +| ms (말레이시아어) | Pergilah membayar kos kelas 40,000 sehin... | yuran kelas | ❌ 누락 | +| mn (몽골어) | Ирэх долоо хоногт 40 мянган төгрөгийн су... | хичээлийн төлбөр | ❌ 누락 | + +### 체험학습 + +| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 | +|---|---|---|---| +| vi (베트남어) | Hãy nộp đơn học tập trước 3 ngày.... | buổi trải nghiệm | ❌ 누락 | +| en (영어) | Please submit your application for an ap... | Field trip | ❌ 누락 | +| zh (중국어) | 请在三天前提交实习申请.... | 校外体험활동 | ❌ 누락 | +| th (태국어) | โปรดส่งใบสมัครเรียนก่อน 3 วัน... | กิจกรรมเรียนรู้นอกห้องเรียน | ❌ 누락 | +| ja (일본어) | 3日前に応募してください... | 体験学習 | ❌ 누락 | +| ru (러시아어) | Пожалуйста, подавайте заявку на обучение... | учебная экскурсия | ❌ 누락 | +| ms (말레이시아어) | Sila kirimkan permohonan anda tiga hari ... | aktiviti pembelajaran luar kelas | ❌ 누락 | +| mn (몽골어) | Оюутны сургалтын хүсэлтээ 3 хоногийн өмн... | танин мэдэхүйн аялал | ❌ 누락 | + +### 결석계 + +| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 | +|---|---|---|---| +| vi (베트남어) | Chúng tôi sẽ gửi bản kết án cho giáo viê... | — | — 미등록 | +| en (영어) | I'm going to submit the final report to ... | — | — 미등록 | +| zh (중국어) | 在五天内,我们将结算表递给校长.... | — | — 미등록 | +| th (태국어) | ผมส่งรายงานให้ครูผู้สอนภายใน 5 วัน... | — | — 미등록 | +| ja (일본어) | 5日以内に卒業式を 教長に提出します... | — | — 미등록 | +| ru (러시아어) | В течение пяти дней я должен представить... | — | — 미등록 | +| ms (말레이시아어) | Saya akan menghantar fail kepada guru da... | — | — 미등록 | +| mn (몽골어) | 5 хоногийн дотор дэд багшийн дүгнэлтийг ... | — | — 미등록 | + +### 준비물 + +| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 | +|---|---|---|---| +| vi (베트남어) | Ngày mai, chúng ta sẽ chuẩn bị cho màu s... | đồ dùng cần chuẩn bị | ❌ 누락 | +| en (영어) | Tomorrow's preparation is colour, grass,... | Things to bring | ❌ 누락 | +| zh (중국어) | 明天的准备是颜色,草,.... | 准备物品 | ❌ 누락 | +| th (태국어) | อาหารพรุ่งนี้คือ สี, ผัก, ผัก... | สิ่งที่ต้องเตรียมมา | ❌ 누락 | +| ja (일본어) | 明日の準備はカラーや草やガッテンです... | 持ち物 | ❌ 누락 | +| ru (러시아어) | Завтра мы будем готовить цветы, траву и ... | что нужно принести | ❌ 누락 | +| ms (말레이시아어) | Keadaan esok ialah warna, rumput, kulit.... | barang yang perlu dibawa | ❌ 누락 | +| mn (몽골어) | Маргаашгийн бэлтгэл зүйл нь арьс, ургама... | бэлдэх зүйлс | ❌ 누락 | + +### 공개수업 + +| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 | +|---|---|---|---| +| vi (베트남어) | Cuộc đấu thầu công khai sẽ diễn ra vào n... | tiết học dự giờ | ❌ 누락 | +| en (영어) | The public auction will take place on Ap... | Open house class | ❌ 누락 | +| zh (중국어) | 在4月23日下午3点上市.... | 公开课 | ❌ 누락 | +| th (태국어) | การซื้อขายสาธารณะจะจัดขึ้นในวันที่ 23 เม... | การเปิดห้องเรียนให้เข้าชม | ❌ 누락 | +| ja (일본어) | 4月23日 (日) 午後3時に公募が行われます... | 授業参観 | ❌ 누락 | +| ru (러시아어) | Открытые аукционы пройдут 23 апреля в тр... | открытый урок | ❌ 누락 | +| ms (말레이시아어) | Pengundian awam akan diadakan pada 23 Ap... | kelas terbuka | ❌ 누락 | +| mn (몽골어) | Олон нийтийн худалдан авалт дөрөвдүгээр ... | нээлттэй хичээл | ❌ 누락 | diff --git a/model/translation_tts/outputs/quality_eval/quality_eval.csv b/model/translation_tts/outputs/quality_eval/quality_eval.csv new file mode 100644 index 0000000000000000000000000000000000000000..c248500b735c3f0a1fefd8946ab9f9997ac3d0e2 --- /dev/null +++ b/model/translation_tts/outputs/quality_eval/quality_eval.csv @@ -0,0 +1,49 @@ +lang,lang_label,korean_term,sentence,nllb_translation,glossary_preferred,before_score,after_translation,after_score,reason +vi,베트남어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,Hãy gửi báo cáo cho giáo viên giảng dạy mỗi ngày.,sổ liên lạc,60,Hãy nộp sổ liên lạc cho giáo viên chủ nhiệm mỗi ngày.,95,"기존 번역의 'báo cáo(보고서)'는 학교 문맥에 맞지 않으며, 핵심 용어인 'sổ liên lạc'과 담임교사를 뜻하는 'giáo viên chủ nhiệm'으로 보정하여 현지 상용 표현에 맞게 정확도가 개선되었습니다." +vi,베트남어,담임교사,담임교사에게 결석 사유를 알려 주세요.,Hãy nói chuyện với giáo viên giảng dạy.,giáo viên chủ nhiệm,40,Hãy thông báo lý do vắng mặt cho giáo viên chủ nhiệm.,93,핵심 용어 오역과 더불어 '결석 사유'라는 필수 정보가 누락되었던 것을 바로잡고 학교 문맥에 맞는 격식체로 보정함. +vi,베트남어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",Hãy trả 40.000 đô vào tuần tới.,tiền học,45,Hãy nộp 40.000 won tiền học vào tuần tới.,88,"NLLB는 화폐 단위를 '달러(đô)'로 오역하는 치명적인 오류를 범했으나, 보정본에서 핵심 용어(tiền học)와 화폐 단위를 바로잡고 학교 납부에 적절한 동사(nộp)를 사용하여 정보 전달력을 높였습니다." +vi,베트남어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,Hãy nộp đơn học tập trước 3 ngày.,buổi trải nghiệm,75,Hãy nộp đơn đăng ký buổi trải nghiệm trước 3 ngày.,92,"기존 번역의 '학습 신청서'라는 모호한 표현이 지정된 핵심 용어를 통해 구체화되었으며, 제출 기한과 행동 정보가 명확하고 자연스럽게 수정됨." +vi,베트남어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Ngày mai, chúng ta sẽ chuẩn bị cho màu sắc, cỏ và cỏ.",đồ dùng cần chuẩn bị,20,"Ngày mai, đồ dùng cần chuẩn bị là giấy màu, hồ và kéo.",95,"NLLB가 '풀(glue)'을 식물인 'cỏ'로 오역하고 '가위'를 누락하는 등 심각한 오류가 있었으나, 핵심 용어와 물품명을 정확하게 수정하여 학부모에게 전달 가능한 수준이 됨." +vi,베트남어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,Cuộc đấu thầu công khai sẽ diễn ra vào ngày 23 tháng 4 lúc 3 giờ sáng.,tiết học dự giờ,15,Tiết học dự giờ sẽ diễn ra vào ngày 23 tháng 4 vào tiết 3.,85,"NLLB가 '공개수업'을 '공공 입찰'로, '3교시'를 '새벽 3시'로 심각하게 오역했으나, 핵심 용어와 시간 정보를 학교 문맥에 맞게 수정하여 정보 전달이 가능해짐." +en,영어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,Please submit an alert to the principal every day.,Notice book,40,Please submit the Notice book to the homeroom teacher every day.,92,"기존 번역은 핵심 용어인 '알림장'을 'alert'으로, '담임교사'를 'principal'로 오역하여 정보 왜곡이 심각했으나, 용어 수정 및 오역 보정으로 학부모에게 정확한 행동 지침 전달이 가능해짐." +en,영어,담임교사,담임교사에게 결석 사유를 알려 주세요.,I'd like you to tell the headmaster the reasoning.,Homeroom teacher,40,I'd like you to tell the homeroom teacher the reason.,82,"핵심 용어인 '담임교사'가 'headmaster(교장)'로 잘못 번역되어 대상 정보가 틀렸으며, 'reasoning'이라는 부적절한 표현을 'reason'으로 보정했으나 문장 구조가 가정통신문의 공적인 어조에 비해 다소 비격식적임." +en,영어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.","I'd like you to pay 40,000 for the class next week.",Class fee,70,"I'd like you to pay the 40,000 won Class fee by next week.",88,"원문에는 금액 단위(won)가 누락되었고 'for the class'가 모호했으나, 보정 후 용어와 정보가 명확해졌으며 다만 'I'd like you to' 문체는 공문서보다는 다소 구어체적입니다." +en,영어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,Please submit your application for an apprenticeship three days in advance.,Field trip,65,Please submit your application for a field trip three days in advance.,94,"NLLB가 '체험학습'을 학교 문맥에 맞지 않는 'apprenticeship'으로 오역했으나, 이를 'Field trip'으로 보정하여 정보가 정확해졌고 현지 학부모가 이해하기 자연스러운 문장이 되었습니다." +en,영어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Tomorrow's preparation is colour, grass, thickness.",Things to bring,20,"Tomorrow's things to bring are colour, grass, thickness.",50,"핵심 용어는 수정되었으나 '풀(glue)'을 'grass'로, '가위'를 'thickness'로 오역한 NLLB의 심각한 오류가 남아 있어 준비물 정보를 전달할 수 없음." +en,영어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,The public auction will take place on April 23 at 3 p.m.,Open house class,20,The open house class will take place on April 23 during the 3rd period.,92,"핵심 용어인 '공개수업'을 'public auction(경매)'으로, '3교시'를 '3 p.m.'으로 잘못 번역한 정보를 모두 바로잡아 학부모에게 정확한 정보 전달이 가능함." +zh,중국어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,请每天向校长提交通知书.,联络簿,45,请每天向班主任提交联络簿。,95,"NLLB가 담임교사를 교장(校长)으로 오역하고 알림장 용어도 부정확했으나, 이를 현지 학교 상용 표현인 班主任과 联络簿로 수정하여 정보 전달이 정확해졌습니다." +zh,중국어,담임교사,담임교사에게 결석 사유를 알려 주세요.,让主任教师知道他的结论.,班主任老师,20,请告知班主任老师缺席理由。,92,"NLLB는 핵심 용어인 '담임교사'를 오역했을 뿐만 아니라 '결석 사유'를 '결론(结论)'으로 번역하여 의미를 완전히 왜곡했으나, 용어 보정 후 실제 학부모에게 전달 가능한 수준이 됨." +zh,중국어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",请在下周之前支付4万美元的学费.,学费,40,请在下周之前支付4万韩元的学费.,92,"NLLB가 금액 단위를 원(KRW)이 아닌 달러(USD)로 오역하여 정보 왜곡이 매우 심각했으나, 용어(学费)와 통화 단위를 수정하여 핵심 정보를 정확하게 교정함." +zh,중국어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,请在三天前提交实习申请.,校外体험활동,50,请在三天前提交校外体验活动申请。,82,"기존 번역의 '실습(实习)' 오역을 핵심 용어로 수정하여 정보 전달력은 개선되었으나, '~일 전 제출' 표현이 현지 학교에서 주로 사용하는 '提前(미리)'을 활용한 표현에 비해 직역투의 어색함이 남아 있음." +zh,중국어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","明天的准备是颜色,草,.",准备物品,25,明天的准备物品是色纸、胶水、剪刀。,85,"NLLB는 단어 오역(색종이, 풀)과 누락(가위)으로 정보 전달이 불가능한 수준이나, 핵심 용어와 누락된 항목을 보정하여 정확도를 높였습니다. 다만 문장 구조가 중국 현지 학교에서 사용하는 자연스러운 문체보다는 한국어 직역에 가깝습니다." +zh,중국어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,在4月23日下午3点上市.,公开课,30,公开课在4月23日第3节课进行。,92,"NLLB가 핵심 용어를 '시장 출시(上市)'로, '3교시'를 '오후 3시'로 심각하게 오역했으나, 용어와 시간 정보를 학교 문맥에 맞게 보정하여 정보 전달이 정확해짐." +th,태국어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,โปรดส่งใบเตือนให้ครูผู้สอนทุกวัน,สมุดจดแจ้งสำหรับผู้ปกครอง,60,โปรดส่งสมุดจดแจ้งสำหรับผู้ปกครองให้ครูผู้สอนทุกวัน,85,"NLLB가 알림장을 징계 성격의 '경고장(ใบเตือน)'으로 오역하여 의미 왜곡이 컸으나, 용어 수정으로 핵심 의미는 회복되었습니다. 다만 담임교사를 'ครูประจำชั้น'이 아닌 일반 교사(ครูผู้สอน)로 번역한 점은 여전히 아쉽습니다." +th,태국어,담임교사,담임교사에게 결석 사유를 알려 주세요.,ให้คุณบอกครูผู้รับผิดชอบถึงข้อสรุป,คุณครูประจำชั้น,40,กรุณาแจ้งเหตุผลการขาดเรียนให้คุณครูประจำชั้นทราบ,92,"NLLB는 '결석 사유'를 '결론(ข้อสรุป)'으로 오역하고 문장 체계가 부자연스러웠으나, 보정 후 핵심 용어와 정보가 정확히 반영되었으며 학교 문맥에 맞는 정중한 표현이 되었습니다." +th,태국어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.","โปรดชําระค่าธรรมเนียม 40,000 บาท ภายในสัปดาห์หน้า",ค่าเรียน,65,"โปรดชำระค่าเรียน 40,000 วอน ภายในสัปดาห์หน้า",95,"NLLB가 핵심 용어를 일반 수수료로 번역하고 화폐 단위를 '바트'로 오역했으나, 용어 교정과 단위 보정을 통해 정보의 정확성과 자연스러움을 확보함." +th,태국어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,โปรดส่งใบสมัครเรียนก่อน 3 วัน,กิจกรรมเรียนรู้นอกห้องเรียน,72,โปรดส่งใบสมัครกิจกรรมเรียนรู้นอกห้องเรียนล่วงหน้า 3 วัน,93,"기존 번역은 '체험학습'을 '입학/등록'의 의미인 'เรียน'으로 오역했으나, 용어 수정과 함께 '3일 전'을 뜻하는 현지 상용 표현(ล่วงหน้า)을 사용하여 정보 전달이 명확해짐." +th,태국어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","อาหารพรุ่งนี้คือ สี, ผัก, ผัก",สิ่งที่ต้องเตรียมมา,10,"สิ่งที่ต้องเตรียมมาสำหรับพรุ่งนี้คือ กระดาษสี, กาว, กรรไกร",93,"NLLB가 '준비물'을 '음식'으로, '풀/가위'를 '채소'로 심각하게 오역하여 정보 전달이 불가능했으나, 핵심 용어와 물품 명칭을 정확하게 수정하여 학부모가 이해할 수 있는 수준이 됨." +th,태국어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,การซื้อขายสาธารณะจะจัดขึ้นในวันที่ 23 เมษายน เวลา 3.00 น.,การเปิดห้องเรียนให้เข้าชม,25,การเปิดห้องเรียนให้เข้าชมจะจัดขึ้นในวันที่ 23 เมษายน คาบเรียนที่ 3,92,"NLLB가 '공개수업'을 '공공 거래'로, '3교시'를 '3시'로 오역하여 의미 전달이 안 되었으나, 필수 용어 적용 및 학교 맥락에 맞는 시간 단위(คาบเรียน)로 보정하여 정확해졌습니다." +ja,일본어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,警報を毎日のように 担当教師に提出してください,連絡帳,30,連絡帳を毎日、担当教師に提出してください。,85,"핵심 용어 오역(경보)과 부자연스러운 빈도 표현을 수정하여 의미는 정확해졌으나, 학교 현장에서 주로 쓰는 호칭(担任)과 문체 면에서 약간의 딱딱함이 남음." +ja,일본어,담임교사,담임교사에게 결석 사유를 알려 주세요.,補習班の先生に 締めくくりの説明をお願いします,担任の先生,15,担任の先生に欠席の理由を知らせてください。,85,"NLLB는 '담임'을 '학원 선생님'으로, '결석 사유'를 '마무리 설명'으로 완전히 잘못 번역하여 정보가 소실되었습니다. 핵심 용어와 목적어를 보정하여 의미는 복원되었으나, 일본 학교 문맥에서 더 자연스러운 'ご連絡ください' 대신 직역체인 '知らせてください'를 사용했으므로 85점으로 평가합니다." +ja,일본어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",授業料は4万円です,授業料,40,"授業料40,000ウォンを来週までに納付してください。",92,"NLLB는 마감 기한과 납부 요청 정보가 누락되고 통화 단위(원→엔) 오류가 심각하지만, 보정본은 필수 정보를 모두 복구하고 용어를 통일하여 정확한 전달이 가능합니다." +ja,일본어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,3日前に応募してください,体験学習,40,体験学習の申請書を3日前に提出してください。,92,"기존 번역은 핵심 대상인 '체험학습 신청서'가 통째로 누락되어 정보 전달이 불가능했으나, 보정 후 필수 정보와 제출 행동이 명확하고 자연스럽게 표현되었습니다." +ja,일본어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.",明日の準備はカラーや草やガッテンです,持ち物,10,明日の持ち物は色紙、のり、はさみです。,95,"핵심 용어인 '持ち物'를 반영하고, '풀'과 '가위'를 '草(풀)'와 'ガッテン'으로 오역한 치명적인 오류를 수정하여 실제 사용 가능한 수준으로 개선함." +ja,일본어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,4月23日 (日) 午後3時に公募が行われます,授業参観,30,授業参観は4月23日3校時に実施されます。,95,핵심 용어인 '공개수업'의 오역(公募)과 '3교시'를 '오후 3시'로 잘못 번역한 정보 오류를 모두 수정하여 현지 학교 문맥에 맞게 개선함. +ru,러시아어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,"Пожалуйста, ежедневно отправляйте уведомление к преподавателю.",дневник или тетрадь для заметок,60,"Пожалуйста, ежедневно сдавайте дневник или тетрадь для заметок классному руководителю.",92,"NLLB는 '알림장'을 일반적인 알림으로, '제출'을 '발송(отправлять)'으로 오역했으나, 보정본은 핵심 용어를 반영하고 학교 현장에서 사용하는 자연스러운 표현(сдавать, классный руководитель)을 사용하여 정확도를 높임." +ru,러시아어,담임교사,담임교사에게 결석 사유를 알려 주세요.,"Пожалуйста, расскажите своему преподавателю о своем заключении.",классный руководитель (воспитатель),30,"Пожалуйста, сообщите классному руководителю (воспитателю) о причине отсутствия.",93,"NLLB가 '결석'을 '결론/종결(заключение)'로 심각하게 오역하여 의미가 왜곡되었으나, 핵심 용어 수정과 함께 결석 사유에 대한 자연스러운 현지 표현을 반영하여 정보 전달력을 높임." +ru,러시아어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",На следующей неделе заплатите 40 000 долларов за обучение.,оплата за занятия,30,"Пожалуйста, произведите оплату за занятия в размере 40 000 вон до следующей недели.",92,"화폐 단위 오역(달러)을 수정하고 핵심 용어를 반영했으며, 기한(~까지)과 요청의 어조를 현지 정서에 맞게 자연스럽게 보정했습니다." +ru,러시아어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,"Пожалуйста, подавайте заявку на обучение за три дня.",учебная экскурсия,70,"Пожалуйста, подавайте заявку на учебную экскурсию за три дня.",90,"기존 번역은 '체험학습'을 일반적인 '학습'으로 번역하여 의미가 부정확했으나, 수정 후 핵심 용어를 정확히 반영하였고 제출 기한 등 필수 정보가 명확히 전달됩니다." +ru,러시아어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Завтра мы будем готовить цветы, траву и огурцы.",что нужно принести,10,"Что нужно принести на завтра: цветная бумага, клей и ножницы.",92,"NLLB는 준비물 항목을 꽃, 풀(grass), 오이로 완전히 잘못 번역(환각)했으나, 보정본은 핵심 용어와 실제 준비물 항목을 모두 정확하고 현지 학교 문맥에 맞게 수정함." +ru,러시아어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,Открытые аукционы пройдут 23 апреля в три часа ночи.,открытый урок,25,Открытый урок пройдет 23 апреля на 3-м уроке.,93,"NLLB는 '공개수업'을 '경매'로, '3교시'를 '새벽 3시'로 오역하여 정보가 완전히 왜곡되었으나, 보정 후 핵심 용어와 학교 현장 표현(3-й урок)이 정확히 반영되었습니다." +ms,말레이시아어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,Sila hantar surat pemberitahuan kepada guru setiap hari.,buku makluman,75,Sila hantar buku makluman kepada guru kelas setiap hari.,92,"NLLB는 '알림장'을 편지(surat)로 오역하고 '담임' 표현을 단순 교사로 번역했으나, 핵심 용어 수정과 'guru kelas' 반영을 통해 정보 보존과 자연스러움을 확보했습니다." +ms,말레이시아어,담임교사,담임교사에게 결석 사유를 알려 주세요.,Beritahu jururawat anda apa yang anda maksudkan.,guru kelas,10,Beritahu guru kelas anda sebab ketidakhadiran.,85,"NLLB는 '간호사'나 '의미' 등 원문과 전혀 무관한 오역을 했으나, 용어 보정을 통해 핵심 주체(담임교사)와 목적(결석 사유)을 정확히 전달하며 문장 성격이 학교 안내문에 맞게 수정되었습니다." +ms,말레이시아어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.","Pergilah membayar kos kelas 40,000 sehingga minggu depan.",yuran kelas,60,"Sila bayar yuran kelas 40,000 sehingga minggu depan.",85,원문의 'Pergilah'는 명령조로 학교 문맥에 어색하며 'kos kelas'는 잘못된 용어임. 용어 수정 및 정중한 표현(Sila)으로 보정했으나 문장 구조는 여전히 직역 느낌이 남음. +ms,말레이시아어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,Sila kirimkan permohonan anda tiga hari sebelum tamat pengajian.,aktiviti pembelajaran luar kelas,35,Sila kirimkan permohonan aktiviti pembelajaran luar kelas tiga hari sebelum tarikh tersebut.,88,"NLLB가 체험학습을 '졸업(tamat pengajian)'으로 오역하여 의미가 완전히 왜곡되었습니다. 용어 수정과 기한 표현을 보정하여 정보 전달은 가능해졌으나, 제출하다를 'kirimkan'으로 표현한 점이 학교 문맥상 아주 자연스럽지는 않습니다." +ms,말레이시아어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Keadaan esok ialah warna, rumput, kulit.",barang yang perlu dibawa,10,"Barang yang perlu dibawa esok ialah kertas warna, gam, dan gunting.",95,"NLLB는 '풀(grass)', '가위(skin)' 등 핵심 정보를 완전히 잘못 번역했으나, 용어 교정과 함께 물품명을 현지 상용 표현으로 수정하여 정보 전달이 정확해졌습니다." +ms,말레이시아어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,Pengundian awam akan diadakan pada 23 April pada jam 3 petang.,kelas terbuka,30,Kelas terbuka akan diadakan pada 23 April pada waktu ke-3.,92,"NLLB가 '공개수업'을 '공개 투표'로, '3교시'를 '오후 3시'로 심각하게 오역했으나, 핵심 용어와 시간 단위를 현지 학교 문맥에 맞게 수정하여 정보 전달력을 높임." +mn,몽골어,알림장,알림장을 매일 담임교사에게 제출해 주세요.,Та нар өдөр бүр анхааруулгыг багшийн өмгөөлөгчдэд хүргүүлээрэй.,мэдэгдэл,35,Өдөр бүр мэдэгдлийг ангийн багшид хүргүүлээрэй.,88,"기존 번역은 담임교사를 변호사로, 알림장을 경고로 오역하여 의미가 전혀 통하지 않았으나, 핵심 용어와 문맥을 보정하여 정보 전달이 가능해졌습니다." +mn,몽골어,담임교사,담임교사에게 결석 사유를 알려 주세요.,Та нар багшийнхээ дүгнэлтэд хүргэж өгөөч.,анги даасан багш,30,Анги даасан багшдаа тасалсан шалтгаанаа мэдэгдэнэ үү.,85,"NLLB는 '결석 사유'를 '결론'으로 오역하여 의미 전달이 불가능했기에, 핵심 용어 교체와 함께 누락된 결석 정보를 보완함." +mn,몽골어,수업비,"수업비 40,000원을 다음 주까지 납부해 주세요.",Ирэх долоо хоногт 40 мянган төгрөгийн сургалтын төлбөрийг төлж өгөөч.,хичээлийн төлбөр,75,"Ирэх долоо хоногт 40,000 воны хичээлийн төлбөрийг төлж өгнө үү.",94,"NLLB는 화폐 단위를 몽골 통화(투그릭)로 잘못 번역하고 핵심 용어를 누락했으나, 보정본은 용어 반영 및 화폐 단위를 바로잡고 학교 공지에 적합한 격식체로 개선함." +mn,몽골어,체험학습,체험학습 신청서를 3일 전에 제출해 주세요.,Оюутны сургалтын хүсэлтээ 3 хоногийн өмнө ирүүлнэ үү?,танин мэдэхүйн аялал,70,Танин мэдэхүйн аялал хийх хүсэлтээ 3 хоногийн өмнө ирүүлнэ үү.,92,"NLLB 번역은 핵심 용어가 누락되고 문장 끝에 부적절한 물음표가 포함되었으나, 보정 후 정확한 용어 반영과 함께 실제 학교 안내문에 적합한 정중한 표현으로 개선되었습니다." +mn,몽골어,준비물,"내일 준비물은 색종이, 풀, 가위입니다.","Маргаашгийн бэлтгэл зүйл нь арьс, ургамал, ногоо юм.",бэлдэх зүйлс,30,"Маргаашгийн бэлдэх зүйлс нь өнгөт цаас, цавуу, хайч юм.",92,"핵심 용어 '준비물'과 개별 항목들(색종이, 풀, 가위)의 심각한 오역을 모두 수정하여 정보 보존이 완벽하며, 현지 학교 문맥에 자연스러운 표현임." +mn,몽골어,공개수업,공개수업은 4월 23일 3교시에 실시됩니다.,Олон нийтийн худалдан авалт дөрөвдүгээр сарын 23-ны өдрийн 3 цагт болно.,нээлттэй хичээл,30,Нээлттэй хичээл 4 дүгээр сарын 23-ны өдрийн 3-р цагт болно.,85,"NLLB가 핵심 용어를 '공공 조달'로 오역하여 의미가 완전히 왜곡되었으나, 용어 보정 후 의미는 통하게 되었으며 '3교시'를 '3시'가 아닌 '3-р цаг(3회차 수업)'으로 보정하였습니다." diff --git a/model/translation_tts/outputs/quality_eval/summary.md b/model/translation_tts/outputs/quality_eval/summary.md new file mode 100644 index 0000000000000000000000000000000000000000..2fc045a3f2d9fbe731c982de24342b9d041675b8 --- /dev/null +++ b/model/translation_tts/outputs/quality_eval/summary.md @@ -0,0 +1,98 @@ +# NLLB 번역 품질 평가 — 용어사전 전/후 비교 + +평가 모델: `gemini-3-flash-preview` | 번역 모델: `facebook/nllb-200-distilled-600M` + +## 언어별 평균 점수 + +| 언어 | 전(NLLB) 평균 | 후(용어사전 적용) 평균 | 향상폭 | +|---|---|---|---| +| vi (베트남어) | 42.5점 | 91.3점 | **+48.8점** | +| en (영어) | 42.5점 | 83.0점 | **+40.5점** | +| zh (중국어) | 35.0점 | 89.7점 | **+54.7점** | +| th (태국어) | 45.3점 | 91.7점 | **+46.4점** | +| ja (일본어) | 27.5점 | 90.7점 | **+63.2점** | +| ru (러시아어) | 37.5점 | 92.0점 | **+54.5점** | +| ms (말레이시아어) | 36.7점 | 89.5점 | **+52.8점** | +| mn (몽골어) | 45.0점 | 89.3점 | **+44.3점** | + +--- + +## 용어별 상세 점수 + +### 알림장 + +| 언어 | NLLB 번역 | 전 점수 | 수정 번역 | 후 점수 | 이유 | +|---|---|---|---|---|---| +| vi (베트남어) | Hãy gửi báo cáo cho giáo viên giảng... | **60점** | Hãy nộp sổ liên lạc cho giáo viên c... | **95점** | 기존 번역의 'báo cáo(보고서)'는 학교 문맥에 맞지 않으며, 핵심 용어인 'sổ liên lạc'과 담임교사를 뜻하는 'giáo viên chủ nhiệm'으로 보정하여 현지 상용 표현에 맞게 정확도가 개선되었습니다. | +| en (영어) | Please submit an alert to the princ... | **40점** | Please submit the Notice book to th... | **92점** | 기존 번역은 핵심 용어인 '알림장'을 'alert'으로, '담임교사'를 'principal'로 오역하여 정보 왜곡이 심각했으나, 용어 수정 및 오역 보정으로 학부모에게 정확한 행동 지침 전달이 가능해짐. | +| zh (중국어) | 请每天向校长提交通知书. | **45점** | 请每天向班主任提交联络簿。 | **95점** | NLLB가 담임교사를 교장(校长)으로 오역하고 알림장 용어도 부정확했으나, 이를 현지 학교 상용 표현인 班主任과 联络簿로 수정하여 정보 전달이 정확해졌습니다. | +| th (태국어) | โปรดส่งใบเตือนให้ครูผู้สอนทุกวัน | **60점** | โปรดส่งสมุดจดแจ้งสำหรับผู้ปกครองให้... | **85점** | NLLB가 알림장을 징계 성격의 '경고장(ใบเตือน)'으로 오역하여 의미 왜곡이 컸으나, 용어 수정으로 핵심 의미는 회복되었습니다. 다만 담임교사를 'ครูประจำชั้น'이 아닌 일반 교사(ครูผู้สอน)로 번역한 점은 여전히 아쉽습니다. | +| ja (일본어) | 警報を毎日のように 担当教師に提出してください | **30점** | 連絡帳を毎日、担当教師に提出してください。 | **85점** | 핵심 용어 오역(경보)과 부자연스러운 빈도 표현을 수정하여 의미는 정확해졌으나, 학교 현장에서 주로 쓰는 호칭(担任)과 문체 면에서 약간의 딱딱함이 남음. | +| ru (러시아어) | Пожалуйста, ежедневно отправляйте у... | **60점** | Пожалуйста, ежедневно сдавайте днев... | **92점** | NLLB는 '알림장'을 일반적인 알림으로, '제출'을 '발송(отправлять)'으로 오역했으나, 보정본은 핵심 용어를 반영하고 학교 현장에서 사용하는 자연스러운 표현(сдавать, классный руководитель)을 사용하여 정확도를 높임. | +| ms (말레이시아어) | Sila hantar surat pemberitahuan kep... | **75점** | Sila hantar buku makluman kepada gu... | **92점** | NLLB는 '알림장'을 편지(surat)로 오역하고 '담임' 표현을 단순 교사로 번역했으나, 핵심 용어 수정과 'guru kelas' 반영을 통해 정보 보존과 자연스러움을 확보했습니다. | +| mn (몽골어) | Та нар өдөр бүр анхааруулгыг багший... | **35점** | Өдөр бүр мэдэгдлийг ангийн багшид х... | **88점** | 기존 번역은 담임교사를 변호사로, 알림장을 경고로 오역하여 의미가 전혀 통하지 않았으나, 핵심 용어와 문맥을 보정하여 정보 전달이 가능해졌습니다. | + +### 담임교사 + +| 언어 | NLLB 번역 | 전 점수 | 수정 번역 | 후 점수 | 이유 | +|---|---|---|---|---|---| +| vi (베트남어) | Hãy nói chuyện với giáo viên giảng ... | **40점** | Hãy thông báo lý do vắng mặt cho gi... | **93점** | 핵심 용어 오역과 더불어 '결석 사유'라는 필수 정보가 누락되었던 것을 바로잡고 학교 문맥에 맞는 격식체로 보정함. | +| en (영어) | I'd like you to tell the headmaster... | **40점** | I'd like you to tell the homeroom t... | **82점** | 핵심 용어인 '담임교사'가 'headmaster(교장)'로 잘못 번역되어 대상 정보가 틀렸으며, 'reasoning'이라는 부적절한 표현을 'reason'으로 보정했으나 문장 구조가 가정통신문의 공적인 어조에 비해 다소 비격식적임. | +| zh (중국어) | 让主任教师知道他的结论. | **20점** | 请告知班主任老师缺席理由。 | **92점** | NLLB는 핵심 용어인 '담임교사'를 오역했을 뿐만 아니라 '결석 사유'를 '결론(结论)'으로 번역하여 의미를 완전히 왜곡했으나, 용어 보정 후 실제 학부모에게 전달 가능한 수준이 됨. | +| th (태국어) | ให้คุณบอกครูผู้รับผิดชอบถึงข้อสรุป | **40점** | กรุณาแจ้งเหตุผลการขาดเรียนให้คุณครู... | **92점** | NLLB는 '결석 사유'를 '결론(ข้อสรุป)'으로 오역하고 문장 체계가 부자연스러웠으나, 보정 후 핵심 용어와 정보가 정확히 반영되었으며 학교 문맥에 맞는 정중한 표현이 되었습니다. | +| ja (일본어) | 補習班の先生に 締めくくりの説明をお願いします | **15점** | 担任の先生に欠席の理由を知らせてください。 | **85점** | NLLB는 '담임'을 '학원 선생님'으로, '결석 사유'를 '마무리 설명'으로 완전히 잘못 번역하여 정보가 소실되었습니다. 핵심 용어와 목적어를 보정하여 의미는 복원되었으나, 일본 학교 문맥에서 더 자연스러운 'ご連絡ください' 대신 직역체인 '知らせてください'를 사용했으므로 85점으로 평가합니다. | +| ru (러시아어) | Пожалуйста, расскажите своему препо... | **30점** | Пожалуйста, сообщите классному руко... | **93점** | NLLB가 '결석'을 '결론/종결(заключение)'로 심각하게 오역하여 의미가 왜곡되었으나, 핵심 용어 수정과 함께 결석 사유에 대한 자연스러운 현지 표현을 반영하여 정보 전달력을 높임. | +| ms (말레이시아어) | Beritahu jururawat anda apa yang an... | **10점** | Beritahu guru kelas anda sebab keti... | **85점** | NLLB는 '간호사'나 '의미' 등 원문과 전혀 무관한 오역을 했으나, 용어 보정을 통해 핵심 주체(담임교사)와 목적(결석 사유)을 정확히 전달하며 문장 성격이 학교 안내문에 맞게 수정되었습니다. | +| mn (몽골어) | Та нар багшийнхээ дүгнэлтэд хүргэж ... | **30점** | Анги даасан багшдаа тасалсан шалтга... | **85점** | NLLB는 '결석 사유'를 '결론'으로 오역하여 의미 전달이 불가능했기에, 핵심 용어 교체와 함께 누락된 결석 정보를 보완함. | + +### 수업비 + +| 언어 | NLLB 번역 | 전 점수 | 수정 번역 | 후 점수 | 이유 | +|---|---|---|---|---|---| +| vi (베트남어) | Hãy trả 40.000 đô vào tuần tới. | **45점** | Hãy nộp 40.000 won tiền học vào tuầ... | **88점** | NLLB는 화폐 단위를 '달러(đô)'로 오역하는 치명적인 오류를 범했으나, 보정본에서 핵심 용어(tiền học)와 화폐 단위를 바로잡고 학교 납부에 적절한 동사(nộp)를 사용하여 정보 전달력을 높였습니다. | +| en (영어) | I'd like you to pay 40,000 for the ... | **70점** | I'd like you to pay the 40,000 won ... | **88점** | 원문에는 금액 단위(won)가 누락되었고 'for the class'가 모호했으나, 보정 후 용어와 정보가 명확해졌으며 다만 'I'd like you to' 문체는 공문서보다는 다소 구어체적입니다. | +| zh (중국어) | 请在下周之前支付4万美元的学费. | **40점** | 请在下周之前支付4万韩元的学费. | **92점** | NLLB가 금액 단위를 원(KRW)이 아닌 달러(USD)로 오역하여 정보 왜곡이 매우 심각했으나, 용어(学费)와 통화 단위를 수정하여 핵심 정보를 정확하게 교정함. | +| th (태국어) | โปรดชําระค่าธรรมเนียม 40,000 บาท ภา... | **65점** | โปรดชำระค่าเรียน 40,000 วอน ภายในสั... | **95점** | NLLB가 핵심 용어를 일반 수수료로 번역하고 화폐 단위를 '바트'로 오역했으나, 용어 교정과 단위 보정을 통해 정보의 정확성과 자연스러움을 확보함. | +| ja (일본어) | 授業料は4万円です | **40점** | 授業料40,000ウォンを来週までに納付してください。 | **92점** | NLLB는 마감 기한과 납부 요청 정보가 누락되고 통화 단위(원→엔) 오류가 심각하지만, 보정본은 필수 정보를 모두 복구하고 용어를 통일하여 정확한 전달이 가능합니다. | +| ru (러시아어) | На следующей неделе заплатите 40 00... | **30점** | Пожалуйста, произведите оплату за з... | **92점** | 화폐 단위 오역(달러)을 수정하고 핵심 용어를 반영했으며, 기한(~까지)과 요청의 어조를 현지 정서에 맞게 자연스럽게 보정했습니다. | +| ms (말레이시아어) | Pergilah membayar kos kelas 40,000 ... | **60점** | Sila bayar yuran kelas 40,000 sehin... | **85점** | 원문의 'Pergilah'는 명령조로 학교 문맥에 어색하며 'kos kelas'는 잘못된 용어임. 용어 수정 및 정중한 표현(Sila)으로 보정했으나 문장 구조는 여전히 직역 느낌이 남음. | +| mn (몽골어) | Ирэх долоо хоногт 40 мянган төгрөги... | **75점** | Ирэх долоо хоногт 40,000 воны хичээ... | **94점** | NLLB는 화폐 단위를 몽골 통화(투그릭)로 잘못 번역하고 핵심 용어를 누락했으나, 보정본은 용어 반영 및 화폐 단위를 바로잡고 학교 공지에 적합한 격식체로 개선함. | + +### 체험학습 + +| 언어 | NLLB 번역 | 전 점수 | 수정 번역 | 후 점수 | 이유 | +|---|---|---|---|---|---| +| vi (베트남어) | Hãy nộp đơn học tập trước 3 ngày. | **75점** | Hãy nộp đơn đăng ký buổi trải nghiệ... | **92점** | 기존 번역의 '학습 신청서'라는 모호한 표현이 지정된 핵심 용어를 통해 구체화되었으며, 제출 기한과 행동 정보가 명확하고 자연스럽게 수정됨. | +| en (영어) | Please submit your application for ... | **65점** | Please submit your application for ... | **94점** | NLLB가 '체험학습'을 학교 문맥에 맞지 않는 'apprenticeship'으로 오역했으나, 이를 'Field trip'으로 보정하여 정보가 정확해졌고 현지 학부모가 이해하기 자연스러운 문장이 되었습니다. | +| zh (중국어) | 请在三天前提交实习申请. | **50점** | 请在三天前提交校外体验活动申请。 | **82점** | 기존 번역의 '실습(实习)' 오역을 핵심 용어로 수정하여 정보 전달력은 개선되었으나, '~일 전 제출' 표현이 현지 학교에서 주로 사용하는 '提前(미리)'을 활용한 표현에 비해 직역투의 어색함이 남아 있음. | +| th (태국어) | โปรดส่งใบสมัครเรียนก่อน 3 วัน | **72점** | โปรดส่งใบสมัครกิจกรรมเรียนรู้นอกห้อ... | **93점** | 기존 번역은 '체험학습'을 '입학/등록'의 의미인 'เรียน'으로 오역했으나, 용어 수정과 함께 '3일 전'을 뜻하는 현지 상용 표현(ล่วงหน้า)을 사용하여 정보 전달이 명확해짐. | +| ja (일본어) | 3日前に応募してください | **40점** | 体験学習の申請書を3日前に提出してください。 | **92점** | 기존 번역은 핵심 대상인 '체험학습 신청서'가 통째로 누락되어 정보 전달이 불가능했으나, 보정 후 필수 정보와 제출 행동이 명확하고 자연스럽게 표현되었습니다. | +| ru (러시아어) | Пожалуйста, подавайте заявку на обу... | **70점** | Пожалуйста, подавайте заявку на уче... | **90점** | 기존 번역은 '체험학습'을 일반적인 '학습'으로 번역하여 의미가 부정확했으나, 수정 후 핵심 용어를 정확히 반영하였고 제출 기한 등 필수 정보가 명확히 전달됩니다. | +| ms (말레이시아어) | Sila kirimkan permohonan anda tiga ... | **35점** | Sila kirimkan permohonan aktiviti p... | **88점** | NLLB가 체험학습을 '졸업(tamat pengajian)'으로 오역하여 의미가 완전히 왜곡되었습니다. 용어 수정과 기한 표현을 보정하여 정보 전달은 가능해졌으나, 제출하다를 'kirimkan'으로 표현한 점이 학교 문맥상 아주 자연스럽지는 않습니다. | +| mn (몽골어) | Оюутны сургалтын хүсэлтээ 3 хоногий... | **70점** | Танин мэдэхүйн аялал хийх хүсэлтээ ... | **92점** | NLLB 번역은 핵심 용어가 누락되고 문장 끝에 부적절한 물음표가 포함되었으나, 보정 후 정확한 용어 반영과 함께 실제 학교 안내문에 적합한 정중한 표현으로 개선되었습니다. | + +### 준비물 + +| 언어 | NLLB 번역 | 전 점수 | 수정 번역 | 후 점수 | 이유 | +|---|---|---|---|---|---| +| vi (베트남어) | Ngày mai, chúng ta sẽ chuẩn bị cho ... | **20점** | Ngày mai, đồ dùng cần chuẩn bị là g... | **95점** | NLLB가 '풀(glue)'을 식물인 'cỏ'로 오역하고 '가위'를 누락하는 등 심각한 오류가 있었으나, 핵심 용어와 물품명을 정확하게 수정하여 학부모에게 전달 가능한 수준이 됨. | +| en (영어) | Tomorrow's preparation is colour, g... | **20점** | Tomorrow's things to bring are colo... | **50점** | 핵심 용어는 수정되었으나 '풀(glue)'을 'grass'로, '가위'를 'thickness'로 오역한 NLLB의 심각한 오류가 남아 있어 준비물 정보를 전달할 수 없음. | +| zh (중국어) | 明天的准备是颜色,草,. | **25점** | 明天的准备物品是色纸、胶水、剪刀。 | **85점** | NLLB는 단어 오역(색종이, 풀)과 누락(가위)으로 정보 전달이 불가능한 수준이나, 핵심 용어와 누락된 항목을 보정하여 정확도를 높였습니다. 다만 문장 구조가 중국 현지 학교에서 사용하는 자연스러운 문체보다는 한국어 직역에 가깝습니다. | +| th (태국어) | อาหารพรุ่งนี้คือ สี, ผัก, ผัก | **10점** | สิ่งที่ต้องเตรียมมาสำหรับพรุ่งนี้คื... | **93점** | NLLB가 '준비물'을 '음식'으로, '풀/가위'를 '채소'로 심각하게 오역하여 정보 전달이 불가능했으나, 핵심 용어와 물품 명칭을 정확하게 수정하여 학부모가 이해할 수 있는 수준이 됨. | +| ja (일본어) | 明日の準備はカラーや草やガッテンです | **10점** | 明日の持ち物は色紙、のり、はさみです。 | **95점** | 핵심 용어인 '持ち物'를 반영하고, '풀'과 '가위'를 '草(풀)'와 'ガッテン'으로 오역한 치명적인 오류를 수정하여 실제 사용 가능한 수준으로 개선함. | +| ru (러시아어) | Завтра мы будем готовить цветы, тра... | **10점** | Что нужно принести на завтра: цветн... | **92점** | NLLB는 준비물 항목을 꽃, 풀(grass), 오이로 완전히 잘못 번역(환각)했으나, 보정본은 핵심 용어와 실제 준비물 항목을 모두 정확하고 현지 학교 문맥에 맞게 수정함. | +| ms (말레이시아어) | Keadaan esok ialah warna, rumput, k... | **10점** | Barang yang perlu dibawa esok ialah... | **95점** | NLLB는 '풀(grass)', '가위(skin)' 등 핵심 정보를 완전히 잘못 번역했으나, 용어 교정과 함께 물품명을 현지 상용 표현으로 수정하여 정보 전달이 정확해졌습니다. | +| mn (몽골어) | Маргаашгийн бэлтгэл зүйл нь арьс, у... | **30점** | Маргаашгийн бэлдэх зүйлс нь өнгөт ц... | **92점** | 핵심 용어 '준비물'과 개별 항목들(색종이, 풀, 가위)의 심각한 오역을 모두 수정하여 정보 보존이 완벽하며, 현지 학교 문맥에 자연스러운 표현임. | + +### 공개수업 + +| 언어 | NLLB 번역 | 전 점수 | 수정 번역 | 후 점수 | 이유 | +|---|---|---|---|---|---| +| vi (베트남어) | Cuộc đấu thầu công khai sẽ diễn ra ... | **15점** | Tiết học dự giờ sẽ diễn ra vào ngày... | **85점** | NLLB가 '공개수업'을 '공공 입찰'로, '3교시'를 '새벽 3시'로 심각하게 오역했으나, 핵심 용어와 시간 정보를 학교 문맥에 맞게 수정하여 정보 전달이 가능해짐. | +| en (영어) | The public auction will take place ... | **20점** | The open house class will take plac... | **92점** | 핵심 용어인 '공개수업'을 'public auction(경매)'으로, '3교시'를 '3 p.m.'으로 잘못 번역한 정보를 모두 바로잡아 학부모에게 정확한 정보 전달이 가능함. | +| zh (중국어) | 在4月23日下午3点上市. | **30점** | 公开课在4月23日第3节课进行。 | **92점** | NLLB가 핵심 용어를 '시장 출시(上市)'로, '3교시'를 '오후 3시'로 심각하게 오역했으나, 용어와 시간 정보를 학교 문맥에 맞게 보정하여 정보 전달이 정확해짐. | +| th (태국어) | การซื้อขายสาธารณะจะจัดขึ้นในวันที่ ... | **25점** | การเปิดห้องเรียนให้เข้าชมจะจัดขึ้นใ... | **92점** | NLLB가 '공개수업'을 '공공 거래'로, '3교시'를 '3시'로 오역하여 의미 전달이 안 되었으나, 필수 용어 적용 및 학교 맥락에 맞는 시간 단위(คาบเรียน)로 보정하여 정확해졌습니다. | +| ja (일본어) | 4月23日 (日) 午後3時に公募が行われます | **30점** | 授業参観は4月23日3校時に実施されます。 | **95점** | 핵심 용어인 '공개수업'의 오역(公募)과 '3교시'를 '오후 3시'로 잘못 번역한 정보 오류를 모두 수정하여 현지 학교 문맥에 맞게 개선함. | +| ru (러시아어) | Открытые аукционы пройдут 23 апреля... | **25점** | Открытый урок пройдет 23 апреля на ... | **93점** | NLLB는 '공개수업'을 '경매'로, '3교시'를 '새벽 3시'로 오역하여 정보가 완전히 왜곡되었으나, 보정 후 핵심 용어와 학교 현장 표현(3-й урок)이 정확히 반영되었습니다. | +| ms (말레이시아어) | Pengundian awam akan diadakan pada ... | **30점** | Kelas terbuka akan diadakan pada 23... | **92점** | NLLB가 '공개수업'을 '공개 투표'로, '3교시'를 '오후 3시'로 심각하게 오역했으나, 핵심 용어와 시간 단위를 현지 학교 문맥에 맞게 수정하여 정보 전달력을 높임. | +| mn (몽골어) | Олон нийтийн худалдан авалт дөрөвдү... | **30점** | Нээлттэй хичээл 4 дүгээр сарын 23-н... | **85점** | NLLB가 핵심 용어를 '공공 조달'로 오역하여 의미가 완전히 왜곡되었으나, 용어 보정 후 의미는 통하게 되었으며 '3교시'를 '3시'가 아닌 '3-р цаг(3회차 수업)'으로 보정하였습니다. | diff --git a/model/translation_tts/requirements-translation-tts.txt b/model/translation_tts/requirements-translation-tts.txt new file mode 100644 index 0000000000000000000000000000000000000000..7ba2824847a3b5fafa7bf2ab9dbdee9ebc10bc39 --- /dev/null +++ b/model/translation_tts/requirements-translation-tts.txt @@ -0,0 +1,7 @@ +torch +transformers +sentencepiece +protobuf +scipy +tqdm +edge-tts diff --git a/model/translation_tts/run_ab_compare.py b/model/translation_tts/run_ab_compare.py new file mode 100644 index 0000000000000000000000000000000000000000..6605543da72faec2fc502d766ab7bef5c4452f35 --- /dev/null +++ b/model/translation_tts/run_ab_compare.py @@ -0,0 +1,309 @@ +""" +A/B 번역 비교 스크립트 +A: 공지 전체 문장 → NLLB 직통 번역 +B: is_todo=true 문장만 → NLLB 번역 (파이프라인 방식) + +사용법: + python run_ab_compare.py --notice-id N02 + python run_ab_compare.py --notice-id N06 --lang en + python run_ab_compare.py # 전체 N01~N19 일괄 비교 +""" +import argparse +import io +import json +import re +import sys +import time +from pathlib import Path + +# Windows CP949 터미널에서 다국어 출력 깨짐 방지 +if hasattr(sys.stdout, "buffer"): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +import torch +from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + +from languages import DEFAULT_LANGUAGE, LANGUAGES + +TRANSLATION_MODEL = "facebook/nllb-200-distilled-600M" +SOURCE_LANG = "kor_Hang" +DEFAULT_DATA = Path(__file__).parent.parent.parent / ( + "model/extraction/data/notices_labeled_v2.jsonl" +) + + +# ── 데이터 로딩 ───────────────────────────────────────────── + +def load_notices(path: Path) -> dict[str, list[dict]]: + """notice_id별로 문장 목록 묶기. 중복(original_id 있는 행) 제거.""" + grouped: dict[str, list[dict]] = {} + seen: set[tuple] = set() + for line in path.read_text(encoding="utf-8").splitlines(): + row = json.loads(line) + key = (row["notice_id"], row["sentence"]) + if key in seen: + continue + seen.add(key) + grouped.setdefault(row["notice_id"], []).append(row) + return grouped + + +# ── NLLB 모델 (한 번만 로드) ──────────────────────────────── + +def load_model(device: str): + print(f"[모델 로딩] {TRANSLATION_MODEL} ({device}) ...") + t0 = time.time() + tokenizer = AutoTokenizer.from_pretrained(TRANSLATION_MODEL, src_lang=SOURCE_LANG) + model = AutoModelForSeq2SeqLM.from_pretrained(TRANSLATION_MODEL).to(device) + model.eval() + print(f"[모델 로딩 완료] {time.time() - t0:.1f}초") + return tokenizer, model + + +def split_for_translation(text: str, tokenizer, max_input_tokens: int) -> list[str]: + """Split long notices so NLLB does not silently truncate at tokenizer max_length.""" + sentences = [s.strip() for s in re.split(r"(?<=[.!?。!?])\s+|[\r\n]+", text) if s.strip()] + if not sentences: + sentences = [text.strip()] if text.strip() else [] + + chunks: list[str] = [] + current: list[str] = [] + for sentence in sentences: + candidate = " ".join(current + [sentence]).strip() + token_count = len(tokenizer(candidate, add_special_tokens=True).input_ids) + if current and token_count > max_input_tokens: + chunks.append(" ".join(current)) + current = [sentence] + else: + current.append(sentence) + if current: + chunks.append(" ".join(current)) + return chunks + + +def translate_chunk(text: str, tokenizer, model, device: str, nllb_code: str, + max_input_tokens: int, max_output_tokens: int) -> tuple[str, float]: + target_id = tokenizer.convert_tokens_to_ids(nllb_code) + inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_input_tokens).to(device) + t0 = time.time() + with torch.no_grad(): + tokens = model.generate( + **inputs, + forced_bos_token_id=target_id, + max_new_tokens=max_output_tokens, + num_beams=4, + ) + elapsed = time.time() - t0 + return tokenizer.batch_decode(tokens, skip_special_tokens=True)[0], elapsed + + +def translate(text: str, tokenizer, model, device: str, nllb_code: str, + max_input_tokens: int = 384, max_output_tokens: int = 512) -> tuple[str, float, int]: + chunks = split_for_translation(text, tokenizer, max_input_tokens) + outputs: list[str] = [] + total_elapsed = 0.0 + for chunk in chunks: + translated, elapsed = translate_chunk( + chunk, tokenizer, model, device, nllb_code, max_input_tokens, max_output_tokens + ) + outputs.append(translated) + total_elapsed += elapsed + return "\n".join(outputs), total_elapsed, len(chunks) + + +# ── A/B 비교 ──────────────────────────────────────────────── + +def safe_print(text: str, prefix: str = ""): + encoded = (prefix + text).encode(errors="replace").decode(errors="replace") + print(encoded) + + +def compare_notice(notice_id: str, rows: list[dict], tokenizer, model, device: str, + nllb_code: str, lang: str, max_input_tokens: int, max_output_tokens: int) -> dict: + title = rows[0]["notice_title"] + + # A: 전체 문장 + text_a = " ".join(r["sentence"] for r in rows) + # B: is_todo=true만 + todo_rows = [r for r in rows if r.get("is_todo")] + text_b = " ".join(r["sentence"] for r in todo_rows) + + print(f"\n{'='*60}") + print(f"[{notice_id}] {title}") + print(f" A 전체: {len(text_a)}자 ({len(rows)}문장)") + print(f" B TODO: {len(text_b)}자 ({len(todo_rows)}문장)") + + if nllb_code is None: + print(" [skip] 쉬운 한국어는 NLLB 번역 없음") + return {} + + if not text_b: + print(" [skip] is_todo=true 문장 없음") + return {} + + print("\n [A 번역 중...]") + result_a, time_a, chunks_a = translate( + text_a, tokenizer, model, device, nllb_code, max_input_tokens, max_output_tokens + ) + print(f" → {time_a:.2f}초 | {len(result_a)}자 | {chunks_a}청크") + safe_print(result_a[:120] + ("..." if len(result_a) > 120 else ""), prefix=" ") + + print("\n [B 번역 중...]") + result_b, time_b, chunks_b = translate( + text_b, tokenizer, model, device, nllb_code, max_input_tokens, max_output_tokens + ) + print(f" → {time_b:.2f}초 | {len(result_b)}자 | {chunks_b}청크") + safe_print(result_b[:120] + ("..." if len(result_b) > 120 else ""), prefix=" ") + + reduction_chars = round((1 - len(text_b) / len(text_a)) * 100, 1) + speedup = round(time_a / time_b, 2) if time_b > 0 else 0 + + print(f"\n 입력 단축: -{reduction_chars}% | 속도: {time_b:.2f}s vs {time_a:.2f}s (x{speedup})") + + return { + "notice_id": notice_id, + "title": title, + "lang": lang, + "a_chars": len(text_a), + "b_chars": len(text_b), + "input_reduction_pct": reduction_chars, + "a_time_sec": round(time_a, 2), + "b_time_sec": round(time_b, 2), + "a_chunks": chunks_a, + "b_chunks": chunks_b, + "speedup_x": speedup, + "a_translation": result_a, + "b_translation": result_b, + "a_input": text_a, + "b_input": text_b, + } + + +# ── 결과 저장 ──────────────────────────────────────────────── + +def save_results(results: list[dict], lang: str): + import json as _json + out_dir = Path(__file__).parent / "outputs" / "ab_compare" / lang + out_dir.mkdir(parents=True, exist_ok=True) + + summary_lines = [ + "# A/B 번역 비교 결과", + "", + f"언어: {lang} | 모델: {TRANSLATION_MODEL}", + "", + "| 공지 | 제목 | A입력(자) | B입력(자) | 입력단축 | A시간(s) | B시간(s) | 속도향상 |", + "|---|---|---|---|---|---|---|---|", + ] + + for r in results: + if not r: + continue + summary_lines.append( + f"| {r['notice_id']} | {r['title'][:20]} | {r['a_chars']} | {r['b_chars']} " + f"| -{r['input_reduction_pct']}% | {r['a_time_sec']} | {r['b_time_sec']} | x{r['speedup_x']} |" + ) + detail_path = out_dir / f"{r['notice_id']}.md" + detail_path.write_text( + f"# {r['notice_id']} — {r['title']}\n\n" + f"| 항목 | A (원문 전체) | B (TODO만) |\n" + f"|---|---|---|\n" + f"| 입력 글자수 | {r['a_chars']}자 | {r['b_chars']}자 |\n" + f"| 번역 시간 | {r['a_time_sec']}초 | {r['b_time_sec']}초 |\n" + f"| 입력 단축 | — | -{r['input_reduction_pct']}% |\n" + f"| 번역 청크 | {r['a_chunks']} | {r['b_chunks']} |\n" + f"| 속도향상 | — | x{r['speedup_x']} |\n\n" + f"## A: 원문 전체\n\n" + f"**입력:**\n```\n{r['a_input']}\n```\n\n" + f"**번역:**\n```\n{r['a_translation']}\n```\n\n" + f"## B: TODO만\n\n" + f"**입력:**\n```\n{r['b_input']}\n```\n\n" + f"**번역:**\n```\n{r['b_translation']}\n```\n", + encoding="utf-8", + ) + + summary_path = out_dir / "summary.md" + summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8") + + # JSON 저장 — 품질 평가 등 후속 분석용 + json_path = out_dir / "results.json" + json_path.write_text( + _json.dumps([r for r in results if r], ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + print(f"\n[저장 완료] {out_dir}") + print(f" 요약: {summary_path}") + print(f" JSON: {json_path}") + + +# ── 메인 ───────────────────────────────────────────────────── + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--notice-id", default="", help="특정 공지 ID (예: N02). 비우면 전체 실행") + p.add_argument("--data", default=str(DEFAULT_DATA), help="notices_labeled_v2.jsonl 경로") + p.add_argument("--lang", default=DEFAULT_LANGUAGE, choices=list(LANGUAGES.keys())) + p.add_argument("--device", default="cpu", choices=["cpu", "cuda"]) + p.add_argument("--max-input-tokens", type=int, default=384) + p.add_argument("--max-output-tokens", type=int, default=512) + return p.parse_args() + + +def main(): + args = parse_args() + data_path = Path(args.data) + if not data_path.exists(): + raise FileNotFoundError(f"데이터 파일 없음: {data_path}") + + lang_config = LANGUAGES[args.lang] + nllb_code = lang_config["nllb_code"] + + if nllb_code is None: + print(f"[{args.lang}] NLLB 번역 없는 언어 (쉬운 한국어). 종료.") + return + + device = args.device + if device == "cuda" and not torch.cuda.is_available(): + print("CUDA 없음. CPU로 전환.") + device = "cpu" + + all_notices = load_notices(data_path) + + if args.notice_id: + nids = [args.notice_id] + else: + nids = sorted(all_notices.keys()) + + tokenizer, model = load_model(device) + + results = [] + for nid in nids: + if nid not in all_notices: + print(f"[{nid}] 데이터 없음 — 스킵") + continue + r = compare_notice( + nid, + all_notices[nid], + tokenizer, + model, + device, + nllb_code, + args.lang, + args.max_input_tokens, + args.max_output_tokens, + ) + results.append(r) + + save_results([r for r in results if r], args.lang) + + # 최종 요약 출력 + valid = [r for r in results if r] + if valid: + avg_reduction = round(sum(r["input_reduction_pct"] for r in valid) / len(valid), 1) + avg_speedup = round(sum(r["speedup_x"] for r in valid) / len(valid), 2) + print(f"\n{'='*60}") + print(f"[전체 평균] 입력 단축: -{avg_reduction}% | 속도향상: x{avg_speedup}") + + +if __name__ == "__main__": + main() diff --git a/model/translation_tts/run_ab_quality_eval.py b/model/translation_tts/run_ab_quality_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..cdb97f3be736f27363c5abc04950351e9ead7b45 --- /dev/null +++ b/model/translation_tts/run_ab_quality_eval.py @@ -0,0 +1,334 @@ +""" +A/B 번역 품질 평가 — Gemini-as-Judge +run_ab_compare.py 결과(N*.md)를 읽어 A/B 번역 품질을 점수화 + +사용법: + python run_ab_quality_eval.py + python run_ab_quality_eval.py --lang vi --notices N01 N02 N08 +""" +import argparse +import io +import json +import re +import sys +import time +from pathlib import Path + +if hasattr(sys.stdout, "buffer"): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +try: + from dotenv import load_dotenv + load_dotenv(Path(__file__).resolve().parents[2] / ".env") +except ImportError: + pass + +from gemini_helper import GEMINI_API_KEY, GEMINI_MODEL, _get_client + +AB_DIR = Path(__file__).parent / "outputs" / "ab_compare" +OUT_DIR = Path(__file__).parent / "outputs" / "ab_quality_eval" + +MAX_RETRIES = 3 +CALL_DELAY = 3.0 + + +# ── 데이터 로딩 ──────────────────────────────────────────────── + +def load_from_json(lang: str) -> list[dict]: + """run_ab_compare.py가 저장한 results.json 로딩.""" + json_path = AB_DIR / lang / "results.json" + if not json_path.exists(): + return [] + return json.loads(json_path.read_text(encoding="utf-8")) + + +def parse_md(path: Path) -> dict | None: + """N*.md 파일에서 A/B 입력·번역 추출 (fallback).""" + text = path.read_text(encoding="utf-8") + + title_m = re.search(r"^# (\w+) — (.+)$", text, re.MULTILINE) + if not title_m: + return None + notice_id = title_m.group(1) + title = title_m.group(2).strip() + + blocks = re.findall(r"```\n([\s\S]+?)\n```", text) + if len(blocks) < 4: + return None + + return { + "notice_id": notice_id, + "title": title, + "a_input": blocks[0].strip(), + "a_translation": blocks[1].strip(), + "b_input": blocks[2].strip(), + "b_translation": blocks[3].strip(), + } + + +def load_notices(lang: str, notice_filter: list[str] | None) -> list[dict]: + # JSON 우선 + data = load_from_json(lang) + if data: + if notice_filter: + data = [r for r in data if r["notice_id"] in notice_filter] + return data + + # fallback: .md 파싱 + md_dir = AB_DIR / lang + if not md_dir.exists(): + return [] + results = [] + for md_path in sorted(md_dir.glob("N*.md")): + if notice_filter and md_path.stem not in notice_filter: + continue + r = parse_md(md_path) + if r: + results.append(r) + return results + + +# ── Gemini 평가 ──────────────────────────────────────────────── + +def call_gemini(prompt: str) -> str: + client = _get_client() + for attempt in range(MAX_RETRIES): + try: + resp = client.models.generate_content(model=GEMINI_MODEL, contents=prompt) + return resp.text.strip() + except Exception as e: + err = str(e) + if "429" in err or "RESOURCE_EXHAUSTED" in err: + m = re.search(r"retry in ([\d.]+)s", err) + wait = float(m.group(1)) if m else CALL_DELAY * (attempt + 2) + if wait > 90: + print(" 일일 quota 소진 — 종료") + return "" + print(f" 429 — {wait:.0f}초 대기...") + time.sleep(wait) + else: + print(f" API 오류: {err[:120]}") + time.sleep(CALL_DELAY) + return "" + + +def parse_json(text: str) -> dict: + text = re.sub(r"```(?:json)?\s*", "", text).replace("```", "") + m = re.search(r"\{[\s\S]+\}", text) + if not m: + return {} + try: + return json.loads(m.group()) + except Exception: + return {} + + +def clamp_score(value) -> int: + try: + score = int(round(float(value))) + except Exception: + return 0 + return max(0, min(score, 100)) + + +def evaluate_ab(notice: dict, lang_label: str) -> dict | None: + a_in = notice.get("a_input", "") + a_tr = notice.get("a_translation", "") + b_in = notice.get("b_input", "") + b_tr = notice.get("b_translation", "") + + if not a_tr or not b_tr: + return None + + prompt = f"""학교 가정통신문 번역 품질을 평가합니다. + +[A] 원문 전체 번역 ({lang_label}) +입력: {a_in} +번역: {a_tr} + +[B] 필수 행동(TODO)만 번역 ({lang_label}) +입력: {b_in} +번역: {b_tr} + +평가 기준: +- 의미 전달 정확도 (학부모가 해야 할 일을 파악할 수 있는가) +- 실제 {lang_label}권 학부모 안내문에서 자주 쓰이는 자연스러운 표현인가 +- 학교/유치원 문맥의 상용어를 썼는가. 단어가 맞아도 현지에서 잘 안 쓰는 직역체면 감점 +- 핵심 정보 누락 없음 +- 날짜, 시간, 금액, 준비물, 제출처, 마감일, 주의사항 보존 +- 번역문이 중간에 끊기지 않았는가 +- Round-trip 검사: 번역문을 다시 한국어로 의미 역번역했을 때 원문 입력의 핵심 행동/숫자/날짜/준비물이 유지되는가 + +엄격한 채점 가이드: +- 핵심 행동을 알 수 없거나 문장이 잘리면 60점 이하. +- 날짜/시간/금액/준비물 중 중요한 정보가 하나라도 빠지면 75점 이하. +- 현지에서 어색한 직역체나 학교 문맥에 맞지 않는 단어가 반복되면 80점 이하. +- 의미 역번역 결과가 원문과 다르거나, 없는 행동/대상이 생기면 80점 이하. +- 90점 이상은 실제 해당 언어권 학부모에게 바로 보내도 되는 수준일 때만 부여. +- A/B가 서로 목적이 다름을 고려하되, B는 TODO 요약 목적상 "필수 행동 정보"가 남아있으면 좋은 평가를 받을 수 있음. + +JSON으로만 답하세요: +{{ + "a_score": 숫자(0~100), + "b_score": 숫자(0~100), + "a_back_translation_ko": "A 번역문을 한국어로 의미 역번역한 내용", + "b_back_translation_ko": "B 번역문을 한국어로 의미 역번역한 내용", + "roundtrip_issues": "역번역 기준으로 확인한 의미 누락/왜곡/환각 한 줄 (한국어)", + "a_issues": "A 번역의 주요 문제점 한 줄 (한국어)", + "b_issues": "B 번역의 주요 문제점 한 줄 (한국어)", + "verdict": "A와 B 중 학부모 전달 목적에 더 적합한 번역은? 이유 포함 (한국어 2~3문장)" +}}""" + + raw = call_gemini(prompt) + if not raw: + return None + + parsed = parse_json(raw) + if not parsed: + print(f" [파싱 실패] {raw[:80]}") + return None + + return { + "notice_id": notice["notice_id"], + "title": notice.get("title", ""), + "a_chars": len(a_in), + "b_chars": len(b_in), + "a_score": clamp_score(parsed.get("a_score", 0)), + "b_score": clamp_score(parsed.get("b_score", 0)), + "a_back_translation_ko": parsed.get("a_back_translation_ko", ""), + "b_back_translation_ko": parsed.get("b_back_translation_ko", ""), + "roundtrip_issues": parsed.get("roundtrip_issues", ""), + "a_issues": parsed.get("a_issues", ""), + "b_issues": parsed.get("b_issues", ""), + "verdict": parsed.get("verdict", ""), + "a_input": a_in, + "a_translation": a_tr, + "b_input": b_in, + "b_translation": b_tr, + } + + +# ── 저장 ────────────────────────────────────────────────────── + +def save_results(results: list[dict], lang: str): + out_dir = OUT_DIR / lang + out_dir.mkdir(parents=True, exist_ok=True) + + # 개별 .md 업데이트 (Gemini 평가 섹션 추가) + for r in results: + detail_path = out_dir / f"{r['notice_id']}.md" + detail_path.write_text( + f"# {r['notice_id']} — {r['title']}\n\n" + f"## 요약\n\n" + f"| 항목 | A (원문 전체) | B (TODO만) |\n" + f"|---|---|---|\n" + f"| 입력 글자수 | {r['a_chars']}자 | {r['b_chars']}자 |\n" + f"| **Gemini 점수** | **{r['a_score']}점** | **{r['b_score']}점** |\n" + f"| 주요 문제 | {r['a_issues']} | {r['b_issues']} |\n\n" + f"**Round-trip 이슈:** {r['roundtrip_issues']}\n\n" + f"**종합 평가:** {r['verdict']}\n\n" + f"---\n\n" + f"## A: 원문 전체 ({r['a_chars']}자)\n\n" + f"**입력:**\n```\n{r['a_input']}\n```\n\n" + f"**번역:**\n```\n{r['a_translation']}\n```\n\n" + f"**한국어 역번역:**\n```\n{r['a_back_translation_ko']}\n```\n\n" + f"## B: TODO만 ({r['b_chars']}자)\n\n" + f"**입력:**\n```\n{r['b_input']}\n```\n\n" + f"**번역:**\n```\n{r['b_translation']}\n```\n" + f"**한국어 역번역:**\n```\n{r['b_back_translation_ko']}\n```\n", + encoding="utf-8", + ) + + # 통합 summary + avg_a = round(sum(r["a_score"] for r in results) / len(results), 1) if results else 0 + avg_b = round(sum(r["b_score"] for r in results) / len(results), 1) if results else 0 + + md = [ + "# A/B 번역 품질 평가 결과", + "", + f"언어: {lang} | 평가 모델: `{GEMINI_MODEL}`", + "", + f"**A 평균: {avg_a}점 | B 평균: {avg_b}점 | 차이: {round(avg_b - avg_a, 1):+.1f}점**", + "", + "| 공지 | 제목 | A점수 | B점수 | 차이 | 판정 |", + "|---|---|---|---|---|---|", + ] + + for r in results: + delta = r["b_score"] - r["a_score"] + winner = "B 우세" if delta > 5 else ("A 우세" if delta < -5 else "동등") + title_short = r["title"][:18] + ("..." if len(r["title"]) > 18 else "") + md.append( + f"| {r['notice_id']} | {title_short} " + f"| {r['a_score']}점 | {r['b_score']}점 " + f"| {delta:+d}점 | {winner} |" + ) + + md += ["", "---", "", "## 공지별 종합 평가", ""] + for r in results: + md += [f"### {r['notice_id']} — {r['title']}", "", r["verdict"], ""] + + summary_path = out_dir / "summary.md" + summary_path.write_text("\n".join(md), encoding="utf-8") + + print(f"\n[저장 완료] {out_dir}") + print(f" 요약: {summary_path}") + print(f" 공지별 상세: {out_dir}/N*.md") + + +# ── 메인 ────────────────────────────────────────────────────── + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--lang", default="vi") + p.add_argument("--notices", nargs="*", help="특정 공지만 (예: N01 N02). 없으면 전체") + return p.parse_args() + + +def main(): + args = parse_args() + + if not GEMINI_API_KEY: + print("[오류] GEMINI_API_KEY 없음. .env 파일 확인하세요.") + return + + notices = load_notices(args.lang, args.notices) + if not notices: + print(f"[오류] {AB_DIR / args.lang} 에 데이터 없음. run_ab_compare.py 먼저 실행하세요.") + return + + lang_labels = { + "vi": "베트남어", "en": "영어", "zh": "중국어", + "th": "태국어", "ja": "일본어", "ru": "러시아어", + "ms": "말레이시아어", "mn": "몽골어", + } + lang_label = lang_labels.get(args.lang, args.lang) + + print(f"평가 대상: {len(notices)}개 공지 | 언어: {args.lang} ({lang_label})\n") + + results = [] + for i, notice in enumerate(notices, 1): + nid = notice["notice_id"] + title = notice.get("title", "")[:20] + print(f"[{i}/{len(notices)}] {nid} {title} ...", end=" ", flush=True) + r = evaluate_ab(notice, lang_label) + if r: + results.append(r) + print(f"A:{r['a_score']}점 / B:{r['b_score']}점") + else: + print("스킵") + time.sleep(CALL_DELAY) + + if not results: + print("결과 없음.") + return + + save_results(results, args.lang) + + avg_a = round(sum(r["a_score"] for r in results) / len(results), 1) + avg_b = round(sum(r["b_score"] for r in results) / len(results), 1) + print(f"\n[전체 평균] A: {avg_a}점 | B: {avg_b}점 | 차이: {avg_b - avg_a:+.1f}점") + + +if __name__ == "__main__": + main() diff --git a/model/translation_tts/run_glossary_compare.py b/model/translation_tts/run_glossary_compare.py new file mode 100644 index 0000000000000000000000000000000000000000..c2989625af9bc9f6e258dc03b11f9aa5df65a2e6 --- /dev/null +++ b/model/translation_tts/run_glossary_compare.py @@ -0,0 +1,194 @@ +""" +용어사전 전/후 번역 비교 스크립트 +NLLB 원번역에 용어사전 권장어가 반영됐는지 검증 + +사용법: + python run_glossary_compare.py --lang vi en zh th ja ru ms mn +""" +import argparse +import csv +import io +import sys +import time +from pathlib import Path + +import torch +from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + +from languages import LANGUAGES + +if hasattr(sys.stdout, "buffer"): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +TRANSLATION_MODEL = "facebook/nllb-200-distilled-600M" +SOURCE_LANG = "kor_Hang" +GLOSSARY_PATH = Path(__file__).parent / "term_glossary.csv" + +# 대표 학교 용어 8개 — 각 용어가 자연스럽게 포함된 문장 +TERM_SENTENCES = [ + ("알림장", "알림장을 매일 담임교사에게 제출해 주세요."), + ("담임교사", "담임교사에게 결석 사유를 알려 주세요."), + ("방과후 신청서", "방과후 신청서를 이번 주 금요일까지 내 주세요."), + ("수업비", "수업비 40,000원을 다음 주까지 납부해 주세요."), + ("체험학습", "체험학습 신청서를 3일 전에 제출해 주세요."), + ("결석계", "결석계를 5일 이내에 담임교사에게 제출합니다."), + ("준비물", "내일 준비물은 색종이, 풀, 가위입니다."), + ("공개수업", "공개수업은 4월 23일 3교시에 실시됩니다."), +] + + +def load_glossary(path: Path) -> list[dict]: + with path.open(encoding="utf-8-sig", newline="") as f: + return [r for r in csv.DictReader(f) if r.get("korean", "").strip()] + + +def load_model(device: str): + print(f"[모델 로딩] {TRANSLATION_MODEL} ({device}) ...") + t0 = time.time() + tokenizer = AutoTokenizer.from_pretrained(TRANSLATION_MODEL, src_lang=SOURCE_LANG) + model = AutoModelForSeq2SeqLM.from_pretrained(TRANSLATION_MODEL).to(device) + model.eval() + print(f"[완료] {time.time() - t0:.1f}초\n") + return tokenizer, model + + +def translate(text: str, tokenizer, model, device: str, nllb_code: str, + max_input_tokens: int = 384, max_output_tokens: int = 512) -> str: + target_id = tokenizer.convert_tokens_to_ids(nllb_code) + inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_input_tokens).to(device) + with torch.no_grad(): + tokens = model.generate( + **inputs, + forced_bos_token_id=target_id, + max_new_tokens=max_output_tokens, + num_beams=4, + ) + return tokenizer.batch_decode(tokens, skip_special_tokens=True)[0] + + +def run_lang(lang: str, glossary: list[dict], tokenizer, model, device: str) -> list[dict]: + nllb_code = LANGUAGES[lang]["nllb_code"] + label = LANGUAGES[lang]["label"] + preferred_col = f"preferred_{lang}" + + # 용어사전에서 해당 언어 preferred 조회용 dict + gloss_map = {r["korean"]: r.get(preferred_col, "").strip() for r in glossary} + + print(f"\n{'='*60}") + print(f"[{lang}] {label} | NLLB: {nllb_code}") + print("="*60) + + results = [] + for korean_term, sentence in TERM_SENTENCES: + preferred = gloss_map.get(korean_term, "") + nllb_out = translate(sentence, tokenizer, model, device, nllb_code) + + hit = preferred.lower() in nllb_out.lower() if preferred else False + status = "✅ 반영" if hit else ("❌ 누락" if preferred else "— 미등록") + + print(f"\n [{status}] {korean_term}") + print(f" 원문: {sentence}") + print(f" NLLB: {nllb_out}") + if preferred: + print(f" 사전: {preferred}") + + results.append({ + "lang": lang, + "label": label, + "korean_term": korean_term, + "sentence": sentence, + "nllb_translation": nllb_out, + "glossary_preferred": preferred, + "reflected": "Y" if hit else "N", + "status": status, + }) + return results + + +def save_results(all_results: list[dict], langs: list[str]): + out_dir = Path(__file__).parent / "outputs" / "glossary_compare" + out_dir.mkdir(parents=True, exist_ok=True) + + # CSV + csv_path = out_dir / "glossary_compare.csv" + with csv_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=[ + "lang", "label", "korean_term", "sentence", + "nllb_translation", "glossary_preferred", "reflected", "status" + ]) + writer.writeheader() + writer.writerows(all_results) + + # 마크다운 요약 + md = ["# 용어사전 전/후 번역 비교", "", f"모델: `{TRANSLATION_MODEL}` | 대상 언어: {', '.join(langs)}", ""] + + # 언어별 반영률 테이블 + md += ["## 언어별 반영률 요약", "", "| 언어 | 반영 | 누락 | 반영률 |", "|---|---|---|---|"] + from itertools import groupby + for lang, rows in groupby(all_results, key=lambda r: r["lang"]): + rows = list(rows) + reflected = sum(1 for r in rows if r["reflected"] == "Y") + total = sum(1 for r in rows if r["glossary_preferred"]) + rate = f"{reflected/total*100:.0f}%" if total else "—" + label = rows[0]["label"] + md.append(f"| {lang} ({label}) | {reflected} | {total - reflected} | **{rate}** |") + + md += ["", "---", ""] + + # 용어별 상세 (vi 기준 before/after 강조) + md += ["## 용어별 상세 비교", ""] + terms = list(dict.fromkeys(r["korean_term"] for r in all_results)) + for term in terms: + term_rows = [r for r in all_results if r["korean_term"] == term] + md += [f"### {term}", "", "| 언어 | NLLB 원번역 | 용어사전 권장어 | 반영 |", "|---|---|---|---|"] + for r in term_rows: + md.append( + f"| {r['lang']} ({r['label']}) | {r['nllb_translation'][:40]}... " + f"| {r['glossary_preferred'] or '—'} | {r['status']} |" + ) + md.append("") + + md_path = out_dir / "summary.md" + md_path.write_text("\n".join(md), encoding="utf-8") + print(f"\n[저장] {out_dir}") + print(f" 요약: {md_path}") + print(f" CSV: {csv_path}") + + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--lang", nargs="+", default=["vi"], choices=list(LANGUAGES.keys())) + p.add_argument("--device", default="cpu", choices=["cpu", "cuda"]) + return p.parse_args() + + +def main(): + args = parse_args() + langs = [l for l in args.lang if LANGUAGES[l]["nllb_code"] is not None] + if not langs: + print("번역 가능한 언어 없음. ko_easy 제외 필요.") + return + + device = args.device + if device == "cuda" and not torch.cuda.is_available(): + device = "cpu" + + glossary = load_glossary(GLOSSARY_PATH) + print(f"용어사전: {len(glossary)}개 항목") + + tokenizer, model = load_model(device) + + all_results = [] + for lang in langs: + all_results.extend(run_lang(lang, glossary, tokenizer, model, device)) + + save_results(all_results, langs) + + # 전체 요약 + reflected = sum(1 for r in all_results if r["reflected"] == "Y") + total = sum(1 for r in all_results if r["glossary_preferred"]) + print(f"\n[전체] 용어사전 반영률: {reflected}/{total} ({reflected/total*100:.0f}%)") + + +if __name__ == "__main__": + main() diff --git a/model/translation_tts/run_mvp_pipeline.py b/model/translation_tts/run_mvp_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..db721d4743b167d185e529d99a4e8b4ee4400104 --- /dev/null +++ b/model/translation_tts/run_mvp_pipeline.py @@ -0,0 +1,602 @@ +import argparse +import asyncio +import csv +import json +import re +import shutil +from pathlib import Path + +import torch +from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + +from languages import DEFAULT_LANGUAGE, LANGUAGES +from gemini_helper import suggest_missing_terms + + +TRANSLATION_MODEL = "facebook/nllb-200-distilled-600M" +SOURCE_LANG = "kor_Hang" +MAX_EASY_KO_SENTENCES = 5 +DEFAULT_CATEGORIES = ("일정", "준비물", "제출", "비용", "건강·안전", "기타") +CATEGORY_RULES = { + "일정": ("일", "월", "날짜", "기간", "기한", "마감", "행사", "수업", "체험학습", "상담", "시험", "총회", "수련회", "종업식", "신체검사"), + "준비물": ("준비", "지참", "가져", "색종이", "풀", "가위", "실내화", "체육복", "물병", "돗자리", "네임펜", "크레파스"), + "제출": ("제출", "내 주세요", "회신", "동의", "신청", "확인서", "참가 여부", "불참", "서류", "서명"), + "비용": ("비용", "비", "원", "납부", "입금", "교육비", "체험학습비", "급식비", "수업비"), + "건강·안전": ("건강", "안전", "예방", "독감", "알레르기", "감염병", "마스크", "손 소독제", "응급", "생활지도"), +} +EASY_REPLACEMENTS = { + "실시됩니다": "있습니다", + "참여합니다": "참여합니다", + "참여해 주시기 바랍니다": "참여해 주세요", + "준비해 주시기 바랍니다": "준비해 주세요", + "제출해 주시기 바랍니다": "내 주세요", + "납부해 주시기 바랍니다": "내 주세요", + "등원해 주세요": "등원해 주세요", +} + + +def parse_args(): + parser = argparse.ArgumentParser(description="Run one end-to-end MVP demo pipeline.") + parser.add_argument("--input", default="data/labeled/notice_sample_v3.csv") + parser.add_argument("--row-id", default="") + parser.add_argument("--glossary", default="model/translation_tts/term_glossary.csv") + parser.add_argument("--output-dir", default="") + parser.add_argument("--lang", default=DEFAULT_LANGUAGE, choices=list(LANGUAGES.keys())) + parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"]) + parser.add_argument("--max-input-tokens", type=int, default=384) + parser.add_argument("--max-output-tokens", type=int, default=512) + parser.add_argument("--skip-tts", action="store_true") + parser.add_argument("--tts-voice", default="") + parser.add_argument("--save-demo-case", default="") + return parser.parse_args() + + +def main(): + args = parse_args() + device = resolve_device(args.device) + + lang_config = LANGUAGES[args.lang] + nllb_code = lang_config["nllb_code"] + tts_voice = args.tts_voice or lang_config["tts_voice"] + + output_dir = Path(args.output_dir or f"outputs/mvp/{args.lang}") + output_dir.mkdir(parents=True, exist_ok=True) + clear_error_files(output_dir) + + source = read_source(Path(args.input), args.row_id) + glossary_error = None + try: + glossary = read_glossary(Path(args.glossary)) + except Exception as error: + glossary = [] + glossary_error = error + write_error(output_dir / "glossary_error.txt", error) + + baseline = build_baseline(source, glossary) + try: + glossary_terms = [row["korean"] for row in glossary] + easy_ko_text = prepare_easy_ko_text(build_easy_korean(source, baseline), glossary_terms) + except Exception as error: + easy_ko_text = "" + write_error(output_dir / "translation_error.txt", error) + + (output_dir / "01_input_notice.txt").write_text(source.get("original_text", "") + "\n", encoding="utf-8") + write_json(output_dir / "02_baseline_result.json", baseline) + (output_dir / "03_easy_ko.txt").write_text(easy_ko_text + "\n", encoding="utf-8") + + if easy_ko_text: + try: + if nllb_code is None: + translated_text = easy_ko_text + else: + translated_text = translate( + easy_ko_text, + device, + args.max_input_tokens, + args.max_output_tokens, + nllb_code, + ) + except Exception as error: + translated_text = "" + write_error(output_dir / "translation_error.txt", error) + else: + translated_text = "" + (output_dir / "04_translation.txt").write_text(translated_text + "\n", encoding="utf-8") + + try: + if glossary_error: + raise glossary_error + glossary_hits = find_glossary_hits(easy_ko_text, glossary, args.lang) + glossary_check_rows = build_glossary_check_rows(easy_ko_text, translated_text, glossary_hits) + quality_label, quality_note = summarize_quality(glossary_check_rows) + except Exception as error: + glossary_hits = [] + glossary_check_rows = build_glossary_error_rows(error) + quality_label = "glossary_error" + quality_note = str(error) + write_error(output_dir / "glossary_error.txt", error) + write_glossary_check(output_dir / "05_glossary_check.csv", glossary_check_rows) + + gemini_rows = [] + if quality_label == "review_needed": + missing_rows = [r for r in glossary_check_rows if r.get("quality_label") == "missing_term"] + try: + gemini_rows = suggest_missing_terms(missing_rows, args.lang, easy_ko_text) + write_gemini_suggestions(output_dir / "06_gemini_suggestions.csv", gemini_rows) + except Exception as error: + write_error(output_dir / "gemini_error.txt", error) + + tts_path = "" + if not args.skip_tts: + if translated_text: + tts_path = str(output_dir / "05_tts_output.mp3") + Path(tts_path).unlink(missing_ok=True) + try: + generate_tts(translated_text, Path(tts_path), tts_voice) + except Exception as error: + tts_path = "" + write_error(output_dir / "tts_error.txt", error) + else: + write_error(output_dir / "tts_error.txt", ValueError("TTS skipped because translated text is empty.")) + + write_mvp_csv( + output_dir / "mvp_result.csv", + { + "lang": args.lang, + "source_text": source.get("original_text", ""), + "category": baseline["category"], + "keywords": "|".join(baseline["keywords"]), + "easy_ko_text": easy_ko_text, + "translated_text": translated_text, + "glossary_hits": "; ".join(f"{item['korean']}->{item['preferred_term']}" for item in glossary_hits), + "quality_label": quality_label, + "quality_note": quality_note, + "tts_path": tts_path, + }, + ) + if args.save_demo_case: + save_demo_case(output_dir, args.save_demo_case, easy_ko_text, translated_text, glossary_check_rows) + + print(f"[{args.lang}] Saved MVP outputs to {output_dir}") + + +def resolve_device(device): + if device == "cuda" and not torch.cuda.is_available(): + print("CUDA is not available. Falling back to CPU.") + return "cpu" + return device + + +def read_source(path, row_id): + if path.suffix.lower() == ".txt": + text = path.read_text(encoding="utf-8-sig").strip() + return { + "id": "txt", + "original_text": text, + "easy_korean": "", + "easy_ko_text": text, + "category": "", + "keywords": "", + "source_type": "", + "action_required": "", + } + + with path.open("r", encoding="utf-8-sig", newline="") as file: + rows = list(csv.DictReader(file)) + if not rows: + raise ValueError(f"No rows found in {path}") + if row_id: + for row in rows: + if row.get("id") == row_id: + return normalize_source(row) + raise ValueError(f"row id {row_id} was not found in {path}") + return normalize_source(rows[0]) + + +def normalize_source(row): + source = dict(row) + easy_ko_text = source.get("easy_ko_text") or source.get("easy_korean") or "" + original_text = source.get("original_text") or easy_ko_text + source["easy_ko_text"] = easy_ko_text + source["easy_korean"] = source.get("easy_korean", "") + source["original_text"] = original_text + source["category"] = source.get("category", "") + source["keywords"] = source.get("keywords", "") + source["source_type"] = source.get("source_type", "") + source["action_required"] = source.get("action_required", "") + return source + + +def read_glossary(path): + with path.open("r", encoding="utf-8-sig", newline="") as file: + return [row for row in csv.DictReader(file) if row.get("korean", "").strip()] + + +def build_baseline(source, glossary): + original_text = source.get("original_text", "").strip() + keywords = parse_keywords(source.get("keywords", "")) + glossary_hits = find_glossary_hits(original_text, glossary) + category = source.get("category", "").strip() + if category not in DEFAULT_CATEGORIES: + category = guess_category(original_text) + + return { + "id": source.get("id", ""), + "source_type": source.get("source_type", ""), + "original_text": original_text, + "category": category, + "keywords": keywords, + "glossary_hits": glossary_hits, + "action_required": source.get("action_required", ""), + } + + +def parse_keywords(value): + return [item.strip() for item in re.split(r"[|,;/]+", value or "") if item.strip()] + + +def guess_category(text): + for category, keywords in CATEGORY_RULES.items(): + if any(keyword in text for keyword in keywords): + return category + return "기타" + + +def build_easy_korean(source, baseline): + if source.get("easy_ko_text", "").strip(): + return source["easy_ko_text"].strip() + if source.get("easy_korean", "").strip(): + return source["easy_korean"].strip() + + text = baseline["original_text"] + for old, new in EASY_REPLACEMENTS.items(): + text = text.replace(old, new) + text = re.sub(r"\s+", " ", text).strip() + sentences = split_sentences(text) + return "\n".join(sentences) if sentences else text + + +def prepare_easy_ko_text(text, glossary_terms=None): + text = validate_easy_ko_text(text) + sentences = split_sentences(text) + if len(sentences) <= MAX_EASY_KO_SENTENCES: + return "\n".join(sentences) if sentences else text + + if not glossary_terms: + return "\n".join(sentences[:MAX_EASY_KO_SENTENCES]) + + # 용어 포함 문장 인덱스 확보 (순서 유지) + must_idx = [i for i, s in enumerate(sentences) if any(term in s for term in glossary_terms)] + other_idx = [i for i in range(len(sentences)) if i not in must_idx] + + slots = MAX_EASY_KO_SENTENCES - len(must_idx) + if slots <= 0: + selected = sorted(must_idx[:MAX_EASY_KO_SENTENCES]) + else: + selected = sorted(must_idx + other_idx[:slots]) + + return "\n".join(sentences[i] for i in selected) + + +def validate_easy_ko_text(text): + text = re.sub(r"\s+", " ", (text or "")).strip() + if not text: + raise ValueError("easy_ko_text is required.") + if not any(char.strip() for char in text): + raise ValueError("easy_ko_text must contain visible text.") + return text + + +def split_sentences(text): + normalized = re.sub(r"\s+", " ", (text or "")).strip() + if not normalized: + return [] + sentences = [part.strip() for part in re.split(r"(?<=[.!?。!?])\s+|[\r\n]+", normalized) if part.strip()] + return sentences if sentences else [normalized] + + +def split_for_translation(text, tokenizer, max_input_tokens): + sentences = split_sentences(text) + chunks = [] + current = [] + for sentence in sentences: + candidate = " ".join(current + [sentence]).strip() + token_count = len(tokenizer(candidate, add_special_tokens=True).input_ids) + if current and token_count > max_input_tokens: + chunks.append(" ".join(current)) + current = [sentence] + else: + current.append(sentence) + if current: + chunks.append(" ".join(current)) + return chunks + + +def translate(text, device, max_input_tokens, max_output_tokens, target_lang_code): + tokenizer = AutoTokenizer.from_pretrained(TRANSLATION_MODEL, src_lang=SOURCE_LANG) + model = AutoModelForSeq2SeqLM.from_pretrained(TRANSLATION_MODEL).to(device) + model.eval() + target_lang_id = tokenizer.convert_tokens_to_ids(target_lang_code) + outputs = [] + for chunk in split_for_translation(text, tokenizer, max_input_tokens): + inputs = tokenizer(chunk, return_tensors="pt", truncation=True, max_length=max_input_tokens).to(device) + with torch.no_grad(): + output_tokens = model.generate( + **inputs, + forced_bos_token_id=target_lang_id, + max_new_tokens=max_output_tokens, + num_beams=4, + ) + outputs.append(tokenizer.batch_decode(output_tokens, skip_special_tokens=True)[0]) + return "\n".join(outputs) + + +def find_glossary_hits(text, glossary, lang="vi"): + preferred_col = f"preferred_{lang}" + return [ + {"korean": row["korean"], "preferred_term": row[preferred_col]} + for row in glossary + if row["korean"] in text and row.get(preferred_col, "").strip() + ] + + +def check_quality(translated_text, glossary_hits): + rows = build_glossary_check_rows("", translated_text, glossary_hits) + return summarize_quality(rows) + + +def build_glossary_check_rows(input_text, translated_text, glossary_hits): + if not glossary_hits: + return [ + { + "korean_term": "", + "preferred_term": "", + "found_in_input": "N", + "found_in_translation": "N", + "quality_label": "unchecked", + "note": "입력문에서 사전 용어가 감지되지 않아 자동 판단 불가", + } + ] + + rows = [] + for item in glossary_hits: + found_in_input = item["korean"] in input_text if input_text else True + found_in_translation = item["preferred_term"].lower() in translated_text.lower() + rows.append( + { + "korean_term": item["korean"], + "preferred_term": item["preferred_term"], + "found_in_input": "Y" if found_in_input else "N", + "found_in_translation": "Y" if found_in_translation else "N", + "quality_label": "ok" if found_in_translation else "missing_term", + "note": "권장 번역어 반영" if found_in_translation else "권장 번역어 미반영, 사람 검수 필요", + } + ) + return rows + + +def summarize_quality(glossary_check_rows): + labels = [row["quality_label"] for row in glossary_check_rows] + if "missing_term" in labels: + notes = [ + f"{row['korean_term']}->{row['preferred_term']}" + for row in glossary_check_rows + if row["quality_label"] == "missing_term" + ] + return "review_needed", "; ".join(notes) + if labels == ["unchecked"]: + return "unchecked", glossary_check_rows[0]["note"] + return "ok", "" + + +def build_glossary_error_rows(error): + return [ + { + "korean_term": "", + "preferred_term": "", + "found_in_input": "N", + "found_in_translation": "N", + "quality_label": "glossary_error", + "note": str(error), + } + ] + + +def save_demo_case(output_dir, case_name, easy_ko_text, vi_text, glossary_check_rows): + case_dir = output_dir / case_name + case_dir.mkdir(parents=True, exist_ok=True) + clear_demo_case_dir(case_dir) + + copy_if_exists(output_dir / "03_easy_ko.txt", case_dir / "01_easy_ko_input.txt") + copy_if_exists(output_dir / "04_translation.txt", case_dir / "02_raw_translation.txt") + copy_if_exists(output_dir / "05_glossary_check.csv", case_dir / "03_glossary_check.csv") + copy_if_exists(output_dir / "05_tts_output.mp3", case_dir / "06_tts_output.mp3") + + for error_name in ("translation_error.txt", "glossary_error.txt", "tts_error.txt"): + copy_if_exists(output_dir / error_name, case_dir / error_name) + + missing_rows = [row for row in glossary_check_rows if row.get("quality_label") == "missing_term"] + corrected_translation = build_corrected_translation(easy_ko_text, vi_text, missing_rows) + (case_dir / "04_review_needed.md").write_text( + build_review_needed_markdown(easy_ko_text, vi_text, missing_rows, corrected_translation), + encoding="utf-8", + ) + (case_dir / "05_vi_corrected_translation.txt").write_text(corrected_translation + "\n", encoding="utf-8") + (case_dir / "demo_summary.md").write_text(build_demo_summary_markdown(missing_rows), encoding="utf-8") + + +def copy_if_exists(source, target): + if source.exists(): + shutil.copy2(source, target) + + +def clear_demo_case_dir(case_dir): + for path in case_dir.iterdir(): + if path.is_file(): + path.unlink() + + +def build_corrected_translation(easy_ko_text, vi_text, missing_rows): + missing_terms = {(row.get("korean_term", ""), row.get("preferred_term", "")) for row in missing_rows} + if ("도시락", "cơm hộp") in missing_terms: + return "\n".join( + [ + "Ngày mai các em sẽ có hoạt động trải nghiệm tại trường.", + "Vui lòng cho trẻ mang theo một chai nước và cơm hộp.", + "Các em hãy có mặt tại sân vận động của trường lúc 9 giờ sáng.", + ] + ) + if missing_rows: + notes = ", ".join(f"{row.get('korean_term')} -> {row.get('preferred_term')}" for row in missing_rows) + return f"{vi_text}\n\n[검수 필요: {notes}]" + return vi_text + + +def build_review_needed_markdown(easy_ko_text, vi_text, missing_rows, corrected_translation): + lines = [ + "# Review Needed", + "", + "## 감지된 missing_term 목록", + "", + ] + if missing_rows: + for row in missing_rows: + korean = row.get("korean_term", "") + preferred_term = row.get("preferred_term", "") + source_sentence = find_sentence_with_term(easy_ko_text, korean) + lines.extend( + [ + f"- {korean} -> {preferred_term}", + "", + "## 원문 쉬운 한국어에서 해당 용어가 등장한 문장", + "", + source_sentence or "해당 문장을 찾지 못했습니다.", + "", + "## 번역문에서 누락된 위치", + "", + f"번역문 전체에서 권장 표현 `{preferred_term}`가 발견되지 않았습니다.", + "", + "```text", + vi_text, + "```", + "", + "## 사람이 수정해야 할 권장 문장", + "", + recommend_sentence(korean, preferred_term), + "", + ] + ) + else: + lines.append("- 없음") + lines.append("") + lines.extend( + [ + "## 수정 번역문", + "", + "```text", + corrected_translation, + "```", + "", + ] + ) + return "\n".join(lines) + + +def find_sentence_with_term(text, term): + for sentence in split_sentences(text): + if term and term in sentence: + return sentence + return "" + + +def recommend_sentence(korean, preferred_term): + if korean == "도시락" and preferred_term == "cơm hộp": + return "Vui lòng cho trẻ mang theo một chai nước và cơm hộp." + return f"해당 문장에 `{preferred_term}` 표현을 반영해 사람이 최종 수정합니다." + + +def build_demo_summary_markdown(missing_rows): + missing_terms = ", ".join( + f"{row.get('korean_term')} -> {row.get('preferred_term')}" for row in missing_rows + ) + if not missing_terms: + missing_terms = "없음" + return "\n".join( + [ + "# MVP Demo Result: demo_case_01", + "", + "- 이번 샘플은 번역/TTS 파이프라인이 끝까지 성공한 케이스다.", + "- 번역문은 전체적으로 자연스럽지만, “도시락” 같은 준비물 핵심 용어가 누락되었다.", + f"- glossary check가 {missing_terms} 누락을 missing_term으로 감지했다.", + "- 이는 일반 번역 결과를 그대로 신뢰하면 안 되고, 학교 특화 용어사전 기반 검수 루프가 필요하다는 근거다.", + "- 따라서 MVP의 핵심은 단순 번역이 아니라 “번역 + 용어사전 검수 + TTS” 흐름이다.", + "", + ] + ) + + +def generate_tts(text, output_path, voice): + import edge_tts + + asyncio.run(edge_tts.Communicate(text=text, voice=voice).save(str(output_path))) + + +def write_error(path, error): + path.write_text(str(error).strip() + "\n", encoding="utf-8") + + +def clear_error_files(output_dir): + for name in ("translation_error.txt", "glossary_error.txt", "tts_error.txt", "gemini_error.txt"): + path = output_dir / name + if path.exists(): + path.unlink() + + +def write_json(path, value): + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def write_gemini_suggestions(path, rows): + fieldnames = ["korean_term", "preferred_term", "gemini_suggestion", "quality_label", "note"] + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.DictWriter(file, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def write_glossary_check(path, rows): + fieldnames = [ + "korean_term", + "preferred_term", + "found_in_input", + "found_in_translation", + "quality_label", + "note", + ] + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.DictWriter(file, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + +def write_mvp_csv(path, row): + fieldnames = [ + "lang", + "source_text", + "category", + "keywords", + "easy_ko_text", + "translated_text", + "glossary_hits", + "quality_label", + "quality_note", + "tts_path", + ] + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.DictWriter(file, fieldnames=fieldnames) + writer.writeheader() + writer.writerow(row) + + +if __name__ == "__main__": + main() diff --git a/model/translation_tts/run_quality_eval.py b/model/translation_tts/run_quality_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..158b865d02230746db48ab7156d31208a93eba21 --- /dev/null +++ b/model/translation_tts/run_quality_eval.py @@ -0,0 +1,284 @@ +""" +NLLB 번역 품질 평가 — Gemini-as-Judge +용어사전 적용 전/후 정확도 비교 + +사용법: + python run_quality_eval.py + python run_quality_eval.py --lang vi en zh +""" +import argparse +import csv +import io +import json +import re +import sys +import time +from pathlib import Path + +if hasattr(sys.stdout, "buffer"): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +try: + from dotenv import load_dotenv + load_dotenv(Path(__file__).resolve().parents[2] / ".env") +except ImportError: + pass + +from gemini_helper import GEMINI_API_KEY, GEMINI_MODEL, _get_client + +GLOSSARY_CSV = Path(__file__).parent / "outputs" / "glossary_compare" / "glossary_compare.csv" +OUT_DIR = Path(__file__).parent / "outputs" / "quality_eval" + +LANG_LABEL = { + "vi": "베트남어", "en": "영어", "zh": "중국어", + "th": "태국어", "ja": "일본어", "ru": "러시아어", + "ms": "말레이시아어", "mn": "몽골어", +} + +MAX_RETRIES = 3 +CALL_DELAY = 3.0 + + +def load_csv(path: Path) -> list[dict]: + with path.open(encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def call_gemini(prompt: str) -> str: + client = _get_client() + for attempt in range(MAX_RETRIES): + try: + resp = client.models.generate_content(model=GEMINI_MODEL, contents=prompt) + return resp.text.strip() + except Exception as e: + err = str(e) + if "429" in err or "RESOURCE_EXHAUSTED" in err: + m = re.search(r"retry in ([\d.]+)s", err) + wait = float(m.group(1)) if m else CALL_DELAY * (attempt + 2) + if wait > 90: + print(f" 일일 quota 소진 — 종료", flush=True) + return "" + print(f" 429 — {wait:.0f}초 대기...", flush=True) + time.sleep(wait) + else: + print(f" API 오류: {err[:120]}", flush=True) + time.sleep(CALL_DELAY) + return "" + + +def parse_json_response(text: str) -> dict: + text = re.sub(r"```(?:json)?\s*", "", text).replace("```", "") + m = re.search(r"\{[\s\S]+\}", text) + if not m: + return {} + try: + return json.loads(m.group()) + except Exception: + return {} + + +def clamp_score(value, max_score: int = 95) -> int: + try: + score = int(round(float(value))) + except Exception: + return 0 + return max(0, min(score, max_score)) + + +def evaluate_row(row: dict) -> dict | None: + """NLLB 번역 전/후를 Gemini로 평가. preferred 없으면 None.""" + preferred = row["glossary_preferred"].strip() + if not preferred: + return None + + lang = row["lang"] + lang_label = LANG_LABEL.get(lang, lang) + sentence = row["sentence"] + nllb_out = row["nllb_translation"] + korean_term = row["korean_term"] + + prompt = f"""학교 가정통신문 번역 품질 평가입니다. 아래 내용을 바탕으로 JSON으로만 답하세요. + +원문 (한국어): {sentence} +핵심 용어: "{korean_term}" → 올바른 {lang_label} 번역: "{preferred}" +NLLB 자동번역 ({lang_label}): {nllb_out} + +평가 지시: +1. before_score: NLLB 자동번역이 얼마나 정확한지 0~100점. +2. after_translation: NLLB 자동번역을 완전히 새로 쓰지 말고, 핵심 용어 "{korean_term}" 관련 표현을 "{preferred}" 중심으로 최소 보정한 번역문. +3. after_score: "용어사전 보정만 적용된 번역"의 정확도 0~100점. NLLB의 다른 오류가 남아 있으면 반드시 감점. +4. reason: 전/후 점수 차이 이유 한 줄 (한국어로). + +엄격한 채점 기준: +- 핵심 용어가 들어가도 현지에서 잘 안 쓰는 직역체, 어색한 조합, 학교 문맥에 맞지 않는 표현이면 80점 이하. +- 날짜, 금액, 시간, 제출/준비/참석 같은 행동 정보가 하나라도 빠지면 85점 이하. +- 문장이 중간에 끊기거나 의미가 불완전하면 60점 이하. +- after_translation은 "전체 재번역"이 아니라 "용어사전 기반 최소 보정"이어야 함. +- 핵심 용어만 고쳐지고 나머지 번역 오류가 남아 있으면 70~85점. +- 핵심 용어, 숫자/날짜/행동 정보, 자연스러운 현지 표현이 모두 맞을 때만 90점 이상. +- after_translation은 평가자가 만든 후보이므로 100점은 주지 말고, 거의 완벽해도 최대 95점. +- 90점 이상은 "실제 해당 언어권 학부모에게 바로 보내도 되는 수준"일 때만 부여. +- Round-trip 검사: after_translation을 다시 한국어로 의미 역번역했을 때 원문 핵심 용어/행동/숫자/날짜가 유지되는지 확인. +- 역번역 결과에 원문에 없는 행동/대상이 생기거나 핵심 정보가 사라지면 80점 이하. +- reason에는 반드시 현지 자연스러움/상용 표현 여부와 정보 보존 여부를 함께 언급. + +JSON 형식: +{{"before_score": 숫자, "after_translation": "번역문", "after_back_translation_ko": "after_translation을 한국어로 의미 역번역한 내용", "roundtrip_issue": "역번역 기준 의미 누락/왜곡/환각", "after_score": 숫자, "reason": "이유"}}""" + + raw = call_gemini(prompt) + if not raw: + return None + + parsed = parse_json_response(raw) + if not parsed: + print(f" [파싱 실패] {korean_term}/{lang}: {raw[:80]}", flush=True) + return None + + return { + "lang": lang, + "lang_label": lang_label, + "korean_term": korean_term, + "sentence": sentence, + "nllb_translation": nllb_out, + "glossary_preferred": preferred, + "before_score": clamp_score(parsed.get("before_score", 0), 100), + "after_translation": parsed.get("after_translation", ""), + "after_back_translation_ko": parsed.get("after_back_translation_ko", ""), + "roundtrip_issue": parsed.get("roundtrip_issue", ""), + "after_score": clamp_score(parsed.get("after_score", 0), 95), + "reason": parsed.get("reason", ""), + } + + +def save_results(results: list[dict]): + OUT_DIR.mkdir(parents=True, exist_ok=True) + + csv_path = OUT_DIR / "quality_eval.csv" + fields = [ + "lang", "lang_label", "korean_term", "sentence", + "nllb_translation", "glossary_preferred", + "before_score", "after_translation", "after_back_translation_ko", + "roundtrip_issue", "after_score", "reason", + ] + with csv_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fields) + writer.writeheader() + writer.writerows(results) + + # 마크다운 요약 + md = [ + "# NLLB 번역 품질 평가 — 용어사전 전/후 비교", + "", + f"평가 모델: `{GEMINI_MODEL}` | 번역 모델: `facebook/nllb-200-distilled-600M`", + "", + "## 언어별 평균 점수", + "", + "| 언어 | 전(NLLB) 평균 | 후(용어사전 적용) 평균 | 향상폭 |", + "|---|---|---|---|", + ] + + from itertools import groupby + for lang, rows in groupby(results, key=lambda r: r["lang"]): + rows = list(rows) + avg_before = round(sum(r["before_score"] for r in rows) / len(rows), 1) + avg_after = round(sum(r["after_score"] for r in rows) / len(rows), 1) + delta = round(avg_after - avg_before, 1) + label = rows[0]["lang_label"] + md.append(f"| {lang} ({label}) | {avg_before}점 | {avg_after}점 | **+{delta}점** |") + + md += ["", "---", "", "## 용어별 상세 점수", ""] + + terms = list(dict.fromkeys(r["korean_term"] for r in results)) + for term in terms: + term_rows = [r for r in results if r["korean_term"] == term] + md += [ + f"### {term}", + "", + "| 언어 | NLLB 번역 | 전 점수 | 수정 번역 | 역번역 이슈 | 후 점수 | 이유 |", + "|---|---|---|---|---|---|---|", + ] + for r in term_rows: + nllb_short = r["nllb_translation"][:35] + "..." if len(r["nllb_translation"]) > 35 else r["nllb_translation"] + after_short = r["after_translation"][:35] + "..." if len(r["after_translation"]) > 35 else r["after_translation"] + md.append( + f"| {r['lang']} ({r['lang_label']}) " + f"| {nllb_short} | **{r['before_score']}점** " + f"| {after_short} | {r['roundtrip_issue']} | **{r['after_score']}점** " + f"| {r['reason']} |" + ) + md.append("") + + md_path = OUT_DIR / "summary.md" + md_path.write_text("\n".join(md), encoding="utf-8") + + print(f"\n[저장 완료] {OUT_DIR}") + print(f" 요약: {md_path}") + print(f" CSV: {csv_path}") + + +def print_summary(results: list[dict]): + print(f"\n{'='*60}") + print("전체 요약 — 언어별 평균") + print(f"{'='*60}") + from itertools import groupby + total_before, total_after, total_n = 0, 0, 0 + for lang, rows in groupby(results, key=lambda r: r["lang"]): + rows = list(rows) + avg_b = round(sum(r["before_score"] for r in rows) / len(rows), 1) + avg_a = round(sum(r["after_score"] for r in rows) / len(rows), 1) + label = rows[0]["lang_label"] + print(f" {lang:3} ({label:8}) | 전: {avg_b:5.1f}점 → 후: {avg_a:5.1f}점 | +{avg_a-avg_b:.1f}점") + total_before += avg_b + total_after += avg_a + total_n += 1 + if total_n: + print(f"\n 전체 평균 | 전: {total_before/total_n:.1f}점 → 후: {total_after/total_n:.1f}점 | +{(total_after-total_before)/total_n:.1f}점") + + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--lang", nargs="+", default=list(LANG_LABEL.keys()), + choices=list(LANG_LABEL.keys())) + return p.parse_args() + + +def main(): + args = parse_args() + + if not GEMINI_API_KEY: + print("[오류] GEMINI_API_KEY 없음. .env 파일 확인하세요.") + return + + if not GLOSSARY_CSV.exists(): + print(f"[오류] {GLOSSARY_CSV} 없음. run_glossary_compare.py 먼저 실행하세요.") + return + + all_rows = load_csv(GLOSSARY_CSV) + target_rows = [r for r in all_rows if r["lang"] in args.lang and r["glossary_preferred"].strip()] + + print(f"평가 대상: {len(target_rows)}건 (용어사전 등록 항목만)") + print(f"언어: {', '.join(args.lang)}\n") + + results = [] + for i, row in enumerate(target_rows, 1): + term = row["korean_term"] + lang = row["lang"] + print(f"[{i}/{len(target_rows)}] {term} / {lang} ...", end=" ", flush=True) + r = evaluate_row(row) + if r: + results.append(r) + print(f"전:{r['before_score']}점 → 후:{r['after_score']}점") + else: + print("스킵") + time.sleep(CALL_DELAY) + + if not results: + print("결과 없음.") + return + + save_results(results) + print_summary(results) + + +if __name__ == "__main__": + main() diff --git a/model/translation_tts/term_glossary.csv b/model/translation_tts/term_glossary.csv new file mode 100644 index 0000000000000000000000000000000000000000..bbe27d7b7dc251aa4b93c29b0a112dada4c26519 --- /dev/null +++ b/model/translation_tts/term_glossary.csv @@ -0,0 +1,177 @@ +korean,preferred_vi,preferred_en,preferred_zh,preferred_th,preferred_ms,preferred_mn,preferred_ru,preferred_ja,note +앞치마,tạp dề,Apron,围裙,ผ้ากันเปื้อน,apron,хормогч,фартук,エプロン,유치원 요리/김장 활동 준비물 +머릿수건,khăn trùm đầu,Bandana,头巾,ผ้าโพกหัว,kain penutup kepala,толгойны алчуур,косынка,三角巾,유치원 요리/김장 활동 준비물 +체험학습,buổi trải nghiệm,Field trip,校外体验活动,กิจกรรมเรียนรู้นอกห้องเรียน,aktiviti pembelajaran luar kelas,танин мэдэхүйн аялал,учебная экскурсия,体験学習,학교 체험 활동 +현장체험학습,buổi trải nghiệm thực tế,Field trip,户外体验活动,ทัศนศึกษานอกสถานที่,lawatan sambil belajar di luar sekolah,сургалтын аялал,выездная экскурсия,校外学習,학교 밖 체험학습 +원복,đồng phục trường,Preschool uniform,园服,ชุดอนุบาล,uniform tadika,цэцэрлэгийн дүрэмт хувцас,форма садика,園服,유치원 지정 복장 +도화지,giấy vẽ,Drawing paper,画纸,กระดาษวาดเขียน,kertas lukisan,зургийн цаас,бумага для рисования,画用紙,미술 준비물 +색칠 도구,đồ dùng tô màu,Coloring tools,涂色工具,อุปกรณ์ระบายสี,alat mewarna,будах хэрэгсэл,всё для раскрашивания,お絵描きセット,미술 준비물 +정서행동 검사,kiểm tra tâm lý và hành vi,Emotional & behavioral screening,心理情绪检查,แบบประเมินพฤติกรรมและอารมณ์,ujian emosi dan tingkah laku,сэтгэл зүйн сорил,тест на эмоциональное состояние,情緒・行動チェック,학생 검사 +돌봄교실,lớp chăm sóc trẻ,After-school care,托管班,ห้องเรียนดูแลเด็กหลังเลิกเรียน,kelas jagaan,өдөр өнжүүлэх анги,продленка,学童保育,초등 돌봄 +방과후학교,lớp học sau giờ học,After-school programs,课后班,กิจกรรมหลังเลิกเรียน,program selepas sekolah,хичээлээс гадуурх дугуйлан,занятия после школы,放課後教室,방과후 프로그램 +하원,đón trẻ về,Pick-up,放学,กลับบ้านจากศูนย์เด็กเล็ก,pulang dari tadika,цэцэрлэгээс гэртээ харих,уход домой из детского сада,降園,유치원 귀가 +등원,đưa trẻ đến trường,Drop-off,上学,ไปศูนย์เด็กเล็ก,hadir ke tadika,цэцэрлэгт ирэх,приход в садик,登園,유치원 등교 +가정통신문,thông báo gửi gia đình,School notice to parents,家长通知书,จดหมายแจ้งจากโรงเรียน,surat makluman,мэдэгдэх хуудас,информационное письмо для родителей,お便り,기타 +알림장,sổ liên lạc,Notice book,联络簿,สมุดติดต่อผู้ปกครอง,buku makluman,мэдэгдэл,дневник,連絡帳,기타 +공지사항,thông báo,Announcements,公告事项,ประกาศแจ้งให้ทราบ,pengumuman,зарлал,важные объявления,お知らせ,기타 +동의서,giấy đồng ý,Consent form,同意书,ใบยินยอม,borang izin,зөвшөөрлийн хуудас,согласие,同意書,제출 서류 +신청서,đơn đăng ký,Application form,申请表,ใบสมัคร,borang permohonan,хүсэлт,заявление,申し込み書,제출 서류 +조사서,phiếu khảo sát,Survey form,调查表,แบบสอบถามข้อมูล,borang kaji selidik,судалгаа,анкета,調査票,제출 서류 +확인서,giấy xác nhận,Confirmation form,确认书,ใบยืนยัน,borang pengesahan,тодорхойлолт,справка,確認書,제출 서류 +안내문,thông báo,Notice,通知说明,เอกสารแจ้งรายละเอียด,surat panduan,зааварчилгаа,памятка,ご案内,기타 +회신,gửi phản hồi,Reply,回执,การตอบกลับ,hantar balasan,хариу илгээх,ответ,お返事,제출 +제출,nộp,Submit,交回,การส่งเอกสาร,hantar,хураалгах,сдача,提出,행동 동사 +서명,ký tên,Signature,签名,เซ็นชื่อ,tandatangan,гарын үсэг,подпись,署名,행동 동사 +참가 여부,việc có tham gia hay không,Participation status,是否参加,การยืนยันเข้าร่วมหรือไม่เข้าร่วม,penyertaan,оролцох эсэх,участие,出欠,제출 +불참,không tham gia,Unable to attend,不参加,ไม่เข้าร่วม,tidak hadir,оролцохгүй,неучастие,欠席,제출 +기한,thời hạn,Due date,截止时间,กำหนดส่ง,tempoh masa,хугацаа,срок,期限,일정 +마감일,hạn chót,Deadline,截止日期,วันสุดท้าย,tarikh tutup,дуусах хугацаа,последний день сдачи,締め切り日,일정 +준비물,đồ dùng cần chuẩn bị,Things to bring,准备物品,สิ่งที่ต้องเตรียมมา,barang yang perlu dibawa,бэлдэх зүйлс,что нужно принести,持ち物,준비물 범주 +실내화,dép đi trong nhà,Indoor shoes,室内鞋,รองเท้าสำหรับใส่ในอาคาร,kasut dalam bangunan,дотор өмсөх шаахай,сменная обувь,上履き,준비물 +체육복,đồ thể dục,Gym clothes,运动服,ชุดพละ,baju sukan,биеийн тамирын хувцас,спортивная форма,体操服,준비물 +여벌옷,quần áo dự phòng,Extra set of clothes,备用衣服,ชุดสำรองสำหรับเปลี่ยน,baju salin,солих хувцас,сменная одежда,着替え,준비물 +개인 물병,bình nước cá nhân,Water bottle,个人水壶,กระติกน้ำส่วนตัว,botol air sendiri,хувийн усны сав,личная бутылочка для воды,水筒,준비물 +도시락,cơm hộp,Lunch box,便当,ข้าวกล่อง,bekal makanan,бэлдсэн хоол,ланч-бокс,お弁当,준비물 +간식,đồ ăn nhẹ,Snack,点心,ขนม,snek,хөнгөн зууш,перекус,おやつ,준비물/비용 +간식비,tiền ăn nhẹ,Snack fee,点心费,ค่าขนม,duit snek,зуушны мө่งө,оплата за перекус,おやつ代,비용 +돗자리,thảm ngồi,Picnic mat,野餐垫,เสื่อ,tikar,дэвсгер,коврик для пикника,レジャーシート,준비물 +색종이,giấy màu,Colored paper,折纸,กระดาษสี,kertas warna,өнгөт цаас,цветная бумага,折り紙,준비물 +풀,hồ dán,Glue,胶水,กาว,gam,цавуу,клей,のり,준비물 +가위,kéo,Scissors,剪刀,กรรไกร,gunting,хайч,ножницы,はさみ,준비물 +크레파스,bút màu sáp,Crayons,蜡笔,สีเทียน,krayon,тосон харандаа,мелки,クレヨン,준비물 +네임펜,bút dạ viết tên,Permanent marker,姓名笔,ปากกาเขียนชื่อ,pen penanda,нэр бичдэг маркер,перманентный маркер,お名前ペン,준비물 +스쿨뱅킹,School Banking,School Banking,School Banking(学校自动扣款服务),School Banking,School Banking,School Banking,School Banking,スクールバンキング,납부 방식 +납부,nộp tiền,Payment,缴纳费用,การชำระเงิน,bayaran,төлбөр төлөх,оплата,お支払い,비용 행동 +자동이체,trừ tiền tự động,Auto-pay,自动转账,การหักบัญชีอัตโนมัติ,potongan automatik,автомат шилжүүлэг,автоплатеж,引き落とし,비용 행동 +교육비,tiền học phí,Tuition,教育费,ค่าเล่าเรียน,yuran sekolah,сургалтын төлбөр,плата за обучение,教育費,비용 +급식비,tiền ăn ở trường,Lunch fee,伙食费,ค่าอาหารกลางวัน,yuran makan,хоолны мө่งө,плата за питание,給食費,비용 +체험학습비,phí trải nghiệm,Field trip fee,校外活动费,ค่ากิจกรรมทัศนศึกษา,yuran lawatan,аяллын зардал,оплата за экскурсию,体験学習費,비용 +등교,đi học,Coming to school,上学,การไปโรงเรียน,datang ke sekolah,хичээлд ирэх,приход в школу,登校,초등학교 등교 +하교,tan học,Going home,放学,กลับบ้านจากโรงเรียน,balik dari sekolah,хичээл тарах,уход из школы,下校,초등학교 하교 +결석,nghỉ học,Absence,缺席,การขาดเรียน,tidak hadir,таслах,пропуск занятий,欠席,출결 +지각,đi muộn,Late,迟到,การมาสาย,datang lewat,хоцрох,опоздание,遅刻,출결 +조퇴,về sớm,Early dismissal,早退,การขอกลับก่อนเวลา,balik awal,эрт харих,уход пораньше,早退,출결 +전학,chuyển trường,Transferring schools,转学,การย้ายโรงเรียน,pindah sekolah,сургууль шилжих,переход в другую школу,転校,출결/학적 +출석인정,được tính là có mặt,Recognized attendance,视为出席,นับว่าเข้าเรียน,dikira hadir,ирцэд тооцох,считается присутствующим,出席として認める,출결 +출결,tình trạng chuyên cần,Attendance,出勤情况,การมาเรียน,rekod kehadiran,ирц,посещаемость,出欠,출결 +담임교사,giáo viên chủ nhiệm,Homeroom teacher,班主任老师,คุณครูประจำชั้น,guru kelas,анги даасан багш,классный руководитель,担任の先生,학교 담당자 +원장,hiệu trưởng,Director,园长,ผู้อำนวยการโรงเรียนอนุบาล,pengarah tadika,эрхлэгч,директор детского сада,園長先生,유치원 담당자 +교무실,phòng giáo vụ,School office,教务处,ห้องพักครู,bilik guru,багш нарын өрөө,учительская,職員室,학교 부서 +행정실,phòng hành chính,Admin office,行政室,ห้องธุรการ,pejabat,захиргааны өрөө,администрация,事務室,학교 부서 +상담,trao đổi với giáo viên,Consultation,面谈咨询,การปรึกษา,sesi berjumpa guru,зөвлөгөө,консультация,面談,상담 +학부모 상담,buổi trao đổi giữa phụ huynh và giáo viên,Parent-teacher conference,家长面谈,การพบครูของผู้ปกครอง,perjumpaan ibu bapa,эцэг эхийн зөвлөгөө,беседа с родителями,保護者面談,상담 +공개수업,tiết học dự giờ,Open house class,公开课,การเปิดห้องเรียนให้เข้าชม,kelas terbuka,нээлттэй хичээл,открытый урок,授業参観,일정 +학부모 총회,họp phụ huynh đầu năm,General parent meeting,家长会,งานประชุมผู้ปกครอง,mesyuarat ibu bapa,эцэг эхийн хурал,родительское собрание,保護者会,일정 +운동회,hội thao,Field day,运动会,งานกีฬาสี,hari sukan,спортын өдөрлөг,спортивный праздник,運動会,행사 +학예회,buổi biểu diễn văn nghệ,Talent show,才艺表演,งานแสดงผลงานนักเรียน,hari persembahan,урлагийн наадам,школьный фестиваль,学習発表会,행사 +소풍,đi dã ngoại,School picnic,郊游,การไปทัศนศึกษา,berkelah,салхинд гарах,пикник,遠足,행사 +수련회,buổi sinh hoạt tập thể,School retreat,研学营,การเข้าค่าย,kem sekolah,зуслан,поездка в лагерь,宿泊学習,일정 +방학,kỳ nghỉ,School break,假期,ปิดเทอม,cuti sekolah,амралт,каникулы,長期休み,일정 +개학,ngày bắt đầu học kỳ mới,First day of school,开学,เปิดเทอม,sekolah buka semula,хичээл эхлэх,начало учебы,始業日,일정 +입학식,lễ nhập học,Entrance ceremony,入学典礼,พิธีเข้าเรียน,hari pendaftaran,элсэлтийн баяр,церемония поступления,入学式・入園式,행사 +졸업식,lễ tốt nghiệp,Graduation,毕业典礼,งานจบการศึกษา,majlis graduasi,төгсөлтийн баяр,выпускной,卒業式・卒園式,행사 +종업식,lễ bế giảng năm học,Last day of school,结业典礼,พิธีปิดภาคเรียน,majlis akhir tahun,хичээлийн жилийн хаалтын ёслол,окончание учебного года,修了式,일정 +학급,lớp học,Class,班级,ชั้นเรียน,kelas,анги,класс,クラス,학교 단위 +학년,khối lớp,Grade,年级,ระดับชั้น,tahun,ангийн түвшин,год обучения,学年,학교 단위 +반,lớp,Class,班,ห้อง,kelas,анги,класс,組,학교 단위 +번호,số thứ tự,Student number,学号,เลขที่,nombor,дугаар,номер в списке,出席番号,학교 단위 +급식,bữa ăn ở trường,School lunch,校餐,อาหารกลางวัน,makanan sekolah,сургуулийн хоол,питание в школе,給食,급식 +식단표,thực đơn,Lunch menu,每周菜单,รายการอาหาร,menu makanan,хоолны цэс,меню,献立表,급식 +알레르기,dị ứng,Allergy,过敏,อาการแพ้,alahan,харшил,аллергия,アレルギー,건강 +예방접종,tiêm phòng,Vaccination,疫苗接种,การฉีดวัคซีน,suntikan vaksin,вакцин,прививка,予防接種,건강 +건강검진,khám sức khỏe,Health check-up,健康体检,การตรวจสุขภาพ,pemeriksaan kesihatan,эрүүл мэндийн үзлэг,медосмотр,健康診断,건강 +신체검사,kiểm tra sức khỏe thể chất,Physical exam,身体检查,การตรวจร่างกาย,pemeriksaan fizikal,биеийн үзлэг,проверка роста и веса,身体測定,건강 +감염병,bệnh truyền nhiễm,Contagious illness,传染病,โรคติดต่อ,penyakit berjangkit,халдварт өвчин,инфекция,感染症,건강 +머릿니,chấy rận,Head lice,头虱,เหา,kutu rambut,толгойны бөөс,вши,アタマジラミ,건강 +독감,cúm,Flu,流感,ไข้หวัดใหญ่,selesema,томуу,грипп,インフルエンザ,건강 +코로나,COVID-19,COVID-19,COVID-19,COVID-19,COVID-19,COVID-19,COVID-19,COVID-19,건강 +미세먼지,bụi mịn,Fine dust,细颗粒物,ฝุ่น PM2.5,habuk halus,нарийн ширхэгт тоос,мелкая пыль,PM2.5,건강 +안전교육,giáo dục an toàn,Safety education,安全教育,การเรียนรู้เรื่องความปลอดภัย,didikan keselamatan,аюулгүй байдлын хичээл,уроки безопасности,安全教育,안전 +재난대피훈련,diễn tập sơ tán,Disaster drill,避险演练,การซ้อมอพยพหนีภัย,latihan kecemasan,гамшгаас хамгаалах сургуулилт,тренировка на случай ЧП,避難訓練,안전 +학교폭력,bạo lực học đường,School bullying,校园暴力,ความรุนแรงในโรงเรียน,buli di sekolah,сургуулийн хүчирхийлэл,школьное насилие,いじめ,안전 +생활지도,hướng dẫn nề nếp sinh hoạt,Student guidance,生活指导,การแนะนำระเบียบวินัยและการใช้ชีวิต,bimbingan tingkah laku,хүмүүжлийн заавар,правила поведения,生活指導,건강·안전 +교통안전,an toàn giao thông,Traffic safety,交通安全,ความปลอดภัยทางจราจร,keselamatan jalan raya,замын хөдөлгөөний аюулгүй байдал,дорожная безопасность,交通安全,안전 +귀가 동의,đồng ý về cách về nhà,Home dismissal consent,放学回家同意,ยินยอมการกลับบ้าน,kebenaran balik rumah,гэртээ харих зөвшөөрөл,согласие на уход домой,帰宅方法の同意,제출 +귀가 동의서,giấy đồng ý về cách về nhà,Home dismissal consent form,放学回家同意书,ใบยินยอมการกลับบ้าน,borang kebenaran balik rumah,гэртээ харих зөвшөөрлийн маягт,согласие на самостоятельный уход домой,帰宅方法同意書,제출 서류 +응급처치,sơ cứu,First aid,急救措施,การปฐมพยาบาล,pertolongan cemas,анхны тусламж,первая помощь,応急処置,건강/안전 +투약 의뢰서,phiếu yêu cầu cho trẻ uống thuốc,Medication request form,服药委托书,ใบคำขอให้ช่วยป้อนยา,borang permohonan pemberian ubat,эм өгөх хүсэлт,просьба дать лекарство,投薬依頼書,제출 서류 +칫솔,bàn chải đánh răng,Toothbrush,牙刷,แปรงสีฟัน,berus gigi,шүдний сойз,зубная щетка,歯ブラシ,준비물 +컵,cốc nước,Cup,杯子,แก้วน้ำ,cawan,аяга,кружка,コップ,준비물 +수건,khăn mặt,Towel,毛巾,ผ้าเช็ดตัว,tuala,алчуур,полотенце,タオル,준비물 +방과후 신청,đăng ký học sau giờ học,After-school sign-up,课后班申请,การสมัครเรียนเสริมหลังเลิกเรียน,mohon kelas selepas sekolah,дугуйланд бүртгүүлэх,запись на кружки,放課後教室の申し込み,제출 +돌봄 신청,đăng ký lớp chăm sóc trẻ,Care program sign-up,托管申请,การสมัครเข้าห้องดูแลเด็ก,mohon jagaan pelajar,өдөр өнжүүлэхэд бүртгүүлэх,запись в группу продленки,学童の申し込み,제출 +키즈노트,Kids Note(키즈노트),Kids Note(키즈노트),Kids Note(키즈노트),Kids Note(키즈노트),Kids Note(키즈노트),Kids Note(키즈노트),Kids Note(키즈노트),Kids Note(키즈노트),서비스명 +학교종이,Hakgyo Jongi(학교종이),Hakgyo Jongi(학교종이),Hakgyo Jongi(학교종이),Hakgyo Jongi(학교종이),Hakgyo Jongi(학교종이),Hakgyo Jongi(학교종이),Hakgyo Jongi(학교종이),Hakgyo Jongi(학교종이),서비스명 확인완료 +하이클래스,HiClass(하이클래스),HiClass(하이클래스),HiClass(하이클래스),HiClass(하이클래스),HiClass(하이클래스),HiClass(하이클래스),HiClass(하이클래스),HiClass(ハイクラス),서비스명 +e알리미,e-Alimi(e알리미),e-Alimi(e알리미),e-Alimi(e알리미),e-Alimi(e알리미),e-Alimi(e알리미),e-Alimi(e알리미),e-Alimi(e알리미),e-Alimi(e알리미),서비스명 +숙제,bài tập về nhà,Homework,作业,การบ้าน,kerja sekolah,гэрийн даалгавар,домашнее задание,宿題,기타 +수행평가,đánh giá quá trình,Performance assessment,学业表现评价,การประเมินผลการเรียน,penilaian prestasi,гүйцэтгэлийн үнэлгээ,оценивание выполнения задания,パフォーマンス評価,기타 +준비 학습,chuẩn bị bài học,Prep work,预习,การเตรียมตัวก่อนเรียน,belajar persediaan,хичээлийн бэлтгэл,подготовка к уроку,予習,기타 +독서 활동,hoạt động đọc sách,Reading activity,阅读活动,กิจกรรมการอ่านหนังสือ,aktiviti membaca,ном унших цаг,чтение книг,読書活動,기타 +방학 과제,bài tập kỳ nghỉ,Vacation homework,假期作业,งานที่ต้องทำช่วงปิดเทอม,tugasan cuti sekolah,амралтын даалгавар,задание на каникулы,長期休みの宿題,기타 +개인정보 동의서,giấy đồng ý sử dụng thông tin cá nhân,Privacy consent form,个人信息同意书,ใบยินยอมให้ข้อมูลส่วนบุคคล,borang izin maklumat peribadi,хувийн мэдээллийн зөвшөөрөл,согласие на использование личных данных,個人情報同意書,제출 +응급 연락처,số liên lạc khẩn cấp,Emergency contact,紧急联系方式,เบอร์ติดต่อฉุกเฉิน,nombor kecemasan,яаралтай үед холбоо барих утас,экстренный номер телефона,緊急連絡先,건강·안전 +보호자,người giám hộ,Guardian,家长,ผู้ปกครอง,penjaga,асран хамгаалагч,опекун,保護者,기타 +보호자 서명,chữ ký của người giám hộ,Guardian signature,家长签字,ลายเซ็นผู้ปกครอง,tandatangan penjaga,асран хамгаалагчийн гарын үсэг,подпись родителя,保護者署名,제출 +비상 연락망,danh sách liên lạc khẩn cấp,Emergency contact list,紧急联系网,รายชื่อติดต่อยามฉุกเฉิน,senarai nombor kecemasan,яаралтай холбоо барих дугаар,контакты для экстренной связи,緊急連絡網,건강·안전 +우산,cái ô,Umbrella,雨伞,ร่ม,payung,шүхэр,зонтик,傘,준비물 +마스크,khẩu trang,Mask,口罩,หน้ากากอนามัย,pelitup muka,амны хаалт,маска,マスク,건강·안전 +여분 마스크,khẩu trang dự phòng,Extra mask,备用口罩,หน้ากากอนามัยสำรอง,pelitup muka simpanan,илүү амны хаалт,запасная маска,予備のマスク,건강·안전 +손 소독제,nước rửa tay khô,Hand sanitizer,手部消毒液,เจลล้างมือ,pencuci tangan,гар ариутгагч,антисептик для рук,除菌ジェル,건강·안전 +실습복,đồ thực hành,Activity clothes,活动服,ชุดสำหรับฝึกปฏิบัติ,baju praktikal,дадлагын хувцас,рабочая одежда,活動着,준비물 +우천 시,trường hợp trời mưa,In case of rain,下雨时,ในกรณีที่ฝนตก,jika hujan,бороо орсон тохиолдолд,в случае дождя,雨天の場合,일정 +연기,hoãn lại,Postponed,推迟,การเลื่อนกำหนดการ,tangguhkan,хойшлуулах,перенос,延期,일정 +취소,hủy bỏ,Canceled,取消,การยกเลิก,batal,цуцлах,отмена,中止,일정 +변경 사항,nội dung thay đổi,Changes,变更事项,ข้อมูลที่เปลี่ยนแปลง,perubahan,өөрчлөгдсөн зүйлс,изменения,変更事項,기타 +원,won Hàn Quốc,Korean won,韩元,วอนเกาหลี,won Korea,солонгос вон,корейская вона,ウォン,한국 통화 단위 (₩) +수업비,tiền học,Class fee,学费,ค่าเรียน,yuran kelas,хичээлийн төлбөр,оплата за занятия,授業料,비용 +수업료,học phí,Tuition,学费,ค่าเล่าเรียน,yuran sekolah,сургалтын төлбөр,стоимость обучения,授業料,비용 +방과후 수업,lớp học thêm sau giờ học,After-school class,课后课程,เรียนเสริมหลังเลิกเรียน,kelas selepas sekolah,дугуйлангийн хичээл,занятия после школы,放課後授業,일정 +영어 수업,lớp học tiếng Anh,English class,英语课,เรียนภาษาอังกฤษ,kelas Bahasa Inggeris,англи хэлний хичээл,уроки английского,英語の授業,일정 +교재,tài liệu học tập,Learning materials,教材,สื่อการเรียน,bahan belajar,сурах бичиг,учебные материалы,教材,준비물 +필통,hộp bút,Pencil case,铅笔盒,กล่องดินสอ,kotak pensel,үзгэн хайрцаг,пенал,筆箱,준비물 +색연필,bút chì màu,Colored pencils,彩色铅笔,สีไม้,pensel warna,өнгөт харандаа,цветные карандаши,色鉛筆,준비물 +연필,bút chì,Pencil,铅笔,ดินสอ,pensel,харандаа,карандаш,鉛筆,준비물 +지우개,cục tẩy,Eraser,橡皮,ยางลบ,pemadam,баллуур,ластик,消しゴム,준비물 +공책,quyển vở,Notebook,笔记本,สมุด,buku tulis,дэвтэр,тетрадь,ノート,준비물 +보호자 연락처,số điện thoại phụ huynh,Guardian contact info,家长联系电话,เบอร์ติดต่อผู้ปกครอง,nombor telefon penjaga,асран хамгаалагчийн утасны дугаар,номер телефона родителя,保護者の連絡先,건강·안전 +오후,buổi chiều,PM,下午,ช่วงบ่าย,petang,үдээс хойш,после обеда,午後,일정 +오전,buổi sáng,AM,上午,ช่วงเช้า,pagi,үдээс өмнө,до обеда,午前,일정 +월요일,Thứ Hai,Monday,星期一,วันจันทร์,Isnin,даваа гараг,понедельник,月曜日,일정 +화요일,Thứ Ba,Tuesday,星期二,วันอังคาร,Selasa,мягмар гараг,вторник,火曜日,일정 +수요일,Thứ Tư,Wednesday,星期三,วันพุธ,Rabu,лхагва гараг,среда,水曜日,일정 +목요일,Thứ Năm,Thursday,星期四,วันพฤหัสบดี,Khamis,пүрэв гараг,четверг,木曜日,일정 +금요일,Thứ Sáu,Friday,星期五,วันศุกร์,Jumaat,баасан гараг,пятница,金曜日,일정 +귀가,về nhà,Going home,回家,การกลับบ้าน,balik rumah,харих,возвращение домой,帰り,일정 +학생,học sinh,student,学生,นักเรียน,murid,сурагч,ученик,児童・生徒,sinh viên(대학생) 오역 방지 +초등학생,học sinh tiểu học,elementary school student,小学生,นักเรียนประถมศึกษา,murid sekolah rendah,бага сургуулийн сурагч,ученик начальной школы,小学生,대학생 오역 방지 +전세버스,xe buýt thuê,charter bus,包车,รถบัสเช่าเหมา,bas sewaan,түрээсийн автобус,арендованный автобус,チャーターバス,서버/콘돔 오역 방지 +생존수영,bơi lội an toàn,survival swimming,生存游泳,การว่ายน้ำเพื่อความปลอดภัย,renang keselamatan,аврах усанд сэлэлт,навыки безопасного плавания,水泳安全教育,생존수영 교육 +리코더,sáo recorder,recorder,竖笛,รีคอร์เดอร์,rekoder,блок флейт,блокфлейта,リコーダー,녹음기 오역 방지 +우범지역,khu vực cần chú ý an toàn,area requiring safety caution,危险地区,พื้นที่เสี่ยงอันตราย,kawasan berisiko,аюултай бүс нутаг,опасный район,危険な地域,좋은 지역 반대 오역 방지 +줄넘기,dây nhảy,jump rope,跳绳,เชือกกระโดด,tali lompat,дэвхрэх олс,скакалка,なわとび,준비물 +간편복,quần áo thoải mái,casual clothes,便服,เสื้อผ้าสบาย,pakaian selesa,тав тухтай хувцас,удобная одежда,動きやすい服装,준비물 +어깨끈,quai đeo vai,shoulder strap,肩带,สายสะพายไหล่,tali bahu,мөрний уяа,плечевой ремень,肩ひも,가방 관련 +횡단보도,vạch sang đường,crosswalk,人行横道,ทางม้าลาย,tempat penyeberangan,явган хүний гарц,пешеходный переход,横断歩道,교통안전 +안심벨,chuông an toàn,safety alarm,安心铃,กริ่งนิรภัย,loceng keselamatan,аюулгүй байдлын хонх,сигнал безопасности,安心ベル,학교 안전 기기 +수리력,năng lực toán học,numeracy,数学能力,ความสามารถทางคณิตศาสตร์,kemahiran numerasi,тоон чадвар,математические навыки,算数の力,수선/수리 오역 방지 +물통,bình nước,water bottle,水壶,ขวดน้ำ,botol air,усны сав,бутылка для воды,水筒,준비물 +운영시간,Thời gian hoạt động,Operating hours,运营时间,เวลาดำเนินการ,Waktu operasi,Үйл ажиллагааны цаг,Время работы,運営時間,슬롯 카드 헤더 +운영방법,Cách thức hoạt động,Operating method,运营方法,วิธีดำเนินการ,Cara operasi,Үйл ажиллагааны арга,Способ проведения,運営方法,슬롯 카드 헤더 +운영날짜,Ngày hoạt động,Operating date,运营日期,วันดำเนินการ,Tarikh operasi,Үйл ажиллагааны өдөр,Дата проведения,運営日,슬롯 카드 헤더 +신청방법,Cách đăng ký,How to apply,申请方法,วิธีสมัคร,Cara mendaftar,Бүртгүүлэх арга,Как подать заявку,申込方法,슬롯 카드 헤더 +신청기간,Thời gian đăng ký,Application period,申请期间,ระยะเวลาสมัคร,Tempoh permohonan,Бүртгэлийн хугацаа,Период подачи заявок,申込期間,슬롯 카드 헤더 +신청 URL,URL đăng ký,Application URL,申请网址,URL สำหรับสมัคร,URL permohonan,Бүртгэлийн URL,Ссылка для подачи,申込URL,슬롯 카드 헤더 +일시,Thời gian,Date and time,日期时间,วันเวลา,Tarikh dan masa,Огноо ба цаг,Дата и время,日時,슬롯 카드 헤더 +시간,Thời gian,Time,时间,เวลา,Masa,Цаг,Время,時間,슬롯 카드 헤더 +장소,Địa điểm,Place,场所,สถานที่,Tempat,Газар,Место,場所,슬롯 카드 헤더 +대상,Đối tượng,Target,对象,กลุ่มเป้าหมาย,Sasaran,Зорилтот,Целевая аудитория,対象,슬롯 카드 헤더 +비용,Chi phí,Cost,费用,ค่าใช้จ่าย,Kos,Зардал,Стоимость,費用,슬롯 카드 헤더 +회비,Phí hội,Membership fee,会费,ค่าสมาชิก,Yuran keahlian,Гишүүний хураамж,Членский взнос,会費,슬롯 카드 헤더 +참가비,Phí tham gia,Participation fee,参加费,ค่าเข้าร่วม,Yuran penyertaan,Оролцооны хураамж,Взнос за участие,参加費,슬롯 카드 헤더 +기타 안내사항,Thông tin khác,Other information,其他通知事项,ข้อแจ้งอื่นๆ,Pengumuman lain,Бусад мэдээлэл,Прочие уведомления,その他のお知らせ,슬롯 카드 헤더 +안내사항,Thông tin hướng dẫn,Notices,通知事项,ข้อแจ้ง,Pengumuman,Мэдээлэл,Уведомление,お知らせ,슬롯 카드 헤더 +유의사항,Lưu ý,Important Notes,注意事项,ข้อควรระวัง,Perhatian,Анхааруулга,Внимание,注意事項,슬롯 카드 헤더 +문의,Liên hệ,Inquiries,咨询,สอบถาม,Pertanyaan,Лавлагаа,Вопросы,問合せ,슬롯 카드 헤더 +연락처,Số liên hệ,Contact,联系方式,ช่องทางติดต่อ,Hubungan,Холбоо барих,Контакты,連絡先,슬롯 카드 헤더 +기타,Khác,Other,其他,อื่นๆ,Lain-lain,Бусад,Прочее,その他,슬롯 카드 헤더 diff --git a/model/translation_tts/validate_glossary_with_gemini.py b/model/translation_tts/validate_glossary_with_gemini.py new file mode 100644 index 0000000000000000000000000000000000000000..f7690872e6a062c0bf99de180708f9a6c213259d --- /dev/null +++ b/model/translation_tts/validate_glossary_with_gemini.py @@ -0,0 +1,87 @@ +""" +term_glossary.csv의 vi 항목 전체를 Gemini로 검증. +배치 방식으로 요청해 rate limit 문제를 방지한다. +""" + +import csv +import sys +from pathlib import Path + +sys.stdout.reconfigure(encoding="utf-8") + +from dotenv import load_dotenv +load_dotenv(Path(__file__).resolve().parents[2] / ".env") + +from gemini_helper import fill_glossary_column + +GLOSSARY_PATH = Path(__file__).parent / "term_glossary.csv" +OUTPUT_PATH = Path(__file__).parent.parent / "outputs" / "glossary_validation_vi.csv" + + +def normalize(text: str) -> str: + return text.strip().lower() + + +def is_match(preferred: str, suggestion: str) -> bool: + p = normalize(preferred) + s = normalize(suggestion) + return p == s or p in s or s in p + + +def main(): + with GLOSSARY_PATH.open("r", encoding="utf-8-sig", newline="") as f: + rows = [r for r in csv.DictReader(f) if r.get("korean", "").strip()] + + total = len(rows) + korean_terms = [r["korean"] for r in rows] + print(f"총 {total}개 용어 배치 검증 시작 (10개씩 묶음)...\n") + + def on_progress(done, total): + print(f" [{done}/{total}] 완료") + + suggestions = fill_glossary_column(korean_terms, "vi", on_progress=on_progress) + + results = [] + match_count = 0 + mismatch_count = 0 + fail_count = 0 + + for row in rows: + korean = row["korean"] + preferred = row.get("preferred_vi", "").strip() + suggestion = suggestions.get(korean, "") + + if not suggestion: + status = "no_response" + fail_count += 1 + elif is_match(preferred, suggestion): + status = "match" + match_count += 1 + else: + status = "mismatch" + mismatch_count += 1 + + results.append({ + "korean": korean, + "preferred_vi": preferred, + "gemini_suggestion": suggestion, + "status": status, + "note": row.get("note", ""), + }) + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with OUTPUT_PATH.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["korean", "preferred_vi", "gemini_suggestion", "status", "note"]) + writer.writeheader() + writer.writerows(results) + + print(f"\n=== 결과 ===") + print(f"전체: {total}개") + print(f"일치: {match_count}개 ({match_count/total*100:.1f}%)") + print(f"불일치: {mismatch_count}개 ({mismatch_count/total*100:.1f}%)") + print(f"응답없음: {fail_count}개 ({fail_count/total*100:.1f}%)") + print(f"\n결과 저장: {OUTPUT_PATH}") + + +if __name__ == "__main__": + main()