Spaces:
Sleeping
Sleeping
tsKim commited on
Commit ·
7f105c8
0
Parent(s):
feat: schoolbridge spaces deploy (extract-text endpoint added)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +54 -0
- .github/workflows/backend-tests.yml +50 -0
- .gitignore +73 -0
- Dockerfile +46 -0
- README.md +36 -0
- backend/Dockerfile +33 -0
- backend/app/__init__.py +0 -0
- backend/app/auth.py +71 -0
- backend/app/main.py +46 -0
- backend/app/models/__init__.py +0 -0
- backend/app/models/schemas.py +164 -0
- backend/app/routers/__init__.py +0 -0
- backend/app/routers/notice.py +490 -0
- backend/app/routers/tts.py +19 -0
- backend/app/routers/user.py +28 -0
- backend/app/services/__init__.py +0 -0
- backend/app/services/card_builder.py +166 -0
- backend/app/services/classifier.py +48 -0
- backend/app/services/easy_korean.py +103 -0
- backend/app/services/extractor.py +92 -0
- backend/app/services/header_split.py +65 -0
- backend/app/services/mock.py +44 -0
- backend/app/services/parser.py +290 -0
- backend/app/services/slot_extractor.py +364 -0
- backend/app/services/translator.py +299 -0
- backend/app/services/tts.py +38 -0
- backend/requirements-dev.txt +5 -0
- backend/requirements.txt +22 -0
- backend/static/notices/.gitkeep +0 -0
- backend/static/tts/.gitkeep +1 -0
- backend/tests/README.md +31 -0
- backend/tests/__init__.py +0 -0
- backend/tests/conftest.py +36 -0
- backend/tests/test_analyze_routes.py +326 -0
- backend/tests/test_auth_routes.py +72 -0
- backend/tests/test_card_builder.py +57 -0
- backend/tests/test_glossary.py +74 -0
- backend/tests/test_parser.py +171 -0
- backend/tests/test_slot_extractor.py +232 -0
- backend/tests/test_translator_protection.py +119 -0
- docker-compose.yml +24 -0
- model/classification/.gitkeep +0 -0
- model/classification/README.md +213 -0
- model/classification/README2.md +284 -0
- model/classification/README3.md +38 -0
- model/classification/README4.md +50 -0
- model/classification/scripts/auto_label_from_new_data_20260504.py +575 -0
- model/classification/scripts/evaluate_compare.py +203 -0
- model/classification/scripts/evaluate_compare_ensemble_20260505.py +409 -0
- model/classification/scripts/evaluate_compare_v2_20260504.py +355 -0
.dockerignore
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HF Spaces Docker 빌드 컨텍스트에서 제외 — 이미지 크기·빌드 시간 최소화
|
| 2 |
+
|
| 3 |
+
# 안드는 백엔드와 무관
|
| 4 |
+
android/
|
| 5 |
+
|
| 6 |
+
# git/빌드 메타
|
| 7 |
+
.git/
|
| 8 |
+
.github/
|
| 9 |
+
.gitignore
|
| 10 |
+
.dockerignore
|
| 11 |
+
|
| 12 |
+
# 가상환경
|
| 13 |
+
.venv/
|
| 14 |
+
venv/
|
| 15 |
+
env/
|
| 16 |
+
|
| 17 |
+
# 컴파일 캐시
|
| 18 |
+
**/__pycache__/
|
| 19 |
+
**/*.pyc
|
| 20 |
+
**/*.pyo
|
| 21 |
+
**/*.pyd
|
| 22 |
+
.pytest_cache/
|
| 23 |
+
|
| 24 |
+
# 노트북 (서빙엔 불필요, 큼)
|
| 25 |
+
**/*.ipynb
|
| 26 |
+
**/.ipynb_checkpoints/
|
| 27 |
+
|
| 28 |
+
# 학습 데이터 / 큰 csv·jsonl (서빙엔 불필요)
|
| 29 |
+
data/
|
| 30 |
+
backend/data/
|
| 31 |
+
backend/external_data/
|
| 32 |
+
external_data/
|
| 33 |
+
model/classification/data/
|
| 34 |
+
model/extraction/data/
|
| 35 |
+
model/translation_tts/data/
|
| 36 |
+
|
| 37 |
+
# 로컬 체크포인트 (HF Hub에서 다운로드되니 image에 포함 안 함)
|
| 38 |
+
model/classification/checkpoints/
|
| 39 |
+
model/extraction/checkpoints/
|
| 40 |
+
|
| 41 |
+
# 시각화 png·문서 (서빙엔 불필요)
|
| 42 |
+
**/*.png
|
| 43 |
+
**/*.jpg
|
| 44 |
+
docs/
|
| 45 |
+
**/docs/
|
| 46 |
+
|
| 47 |
+
# 로그·임시
|
| 48 |
+
*.log
|
| 49 |
+
*.tmp
|
| 50 |
+
*.bak
|
| 51 |
+
|
| 52 |
+
# 백엔드 자체 .env (Spaces secrets 사용)
|
| 53 |
+
backend/.env
|
| 54 |
+
.env
|
.github/workflows/backend-tests.yml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: backend-tests
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
paths:
|
| 6 |
+
- "backend/**"
|
| 7 |
+
- "model/translation_tts/**"
|
| 8 |
+
- ".github/workflows/backend-tests.yml"
|
| 9 |
+
push:
|
| 10 |
+
branches: [main, dev]
|
| 11 |
+
paths:
|
| 12 |
+
- "backend/**"
|
| 13 |
+
- "model/translation_tts/**"
|
| 14 |
+
|
| 15 |
+
# 강사 피드백(2026-04-28): "단위테스트 끝나고 통합테스트 할 때 합의된 기준/절차에 의해 merge"
|
| 16 |
+
# → PR 시 자동 실행되는 권한 검증 + 다국어 사전 로딩 게이트.
|
| 17 |
+
|
| 18 |
+
jobs:
|
| 19 |
+
pytest:
|
| 20 |
+
runs-on: ubuntu-latest
|
| 21 |
+
timeout-minutes: 10
|
| 22 |
+
steps:
|
| 23 |
+
- uses: actions/checkout@v4
|
| 24 |
+
|
| 25 |
+
- name: Set up Python 3.11
|
| 26 |
+
uses: actions/setup-python@v5
|
| 27 |
+
with:
|
| 28 |
+
python-version: "3.11"
|
| 29 |
+
cache: pip
|
| 30 |
+
|
| 31 |
+
- name: Install backend deps (CPU torch + dev)
|
| 32 |
+
working-directory: backend
|
| 33 |
+
run: |
|
| 34 |
+
pip install -r requirements.txt
|
| 35 |
+
pip install -r requirements-dev.txt
|
| 36 |
+
|
| 37 |
+
- name: Symlink external_model (도커 마운트 경로 모사)
|
| 38 |
+
run: |
|
| 39 |
+
sudo mkdir -p /app
|
| 40 |
+
sudo ln -s "$GITHUB_WORKSPACE/backend/app" /app/app
|
| 41 |
+
sudo ln -s "$GITHUB_WORKSPACE/model" /app/external_model
|
| 42 |
+
sudo ln -s "$GITHUB_WORKSPACE/backend/static" /app/static
|
| 43 |
+
sudo mkdir -p /app/static/tts
|
| 44 |
+
|
| 45 |
+
- name: Run pytest
|
| 46 |
+
working-directory: backend
|
| 47 |
+
env:
|
| 48 |
+
PYTHONPATH: /app
|
| 49 |
+
run: |
|
| 50 |
+
pytest tests -v --tb=short
|
.gitignore
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.egg-info/
|
| 5 |
+
.venv/
|
| 6 |
+
venv/
|
| 7 |
+
dist/
|
| 8 |
+
build/
|
| 9 |
+
*.egg
|
| 10 |
+
|
| 11 |
+
# 환경변수
|
| 12 |
+
.env
|
| 13 |
+
.env.*
|
| 14 |
+
!.env.example
|
| 15 |
+
*.env.local
|
| 16 |
+
|
| 17 |
+
# ML 체크포인트 / 대용량 파일
|
| 18 |
+
ml/*/checkpoints/
|
| 19 |
+
ml/*/best_model/
|
| 20 |
+
*.pt
|
| 21 |
+
*.bin
|
| 22 |
+
*.onnx
|
| 23 |
+
|
| 24 |
+
# 경이 simple 분류기 자동 학습 결과 (random seed에 따라 달라져 git 충돌 유발)
|
| 25 |
+
# 학습 코드 + 데이터(csv)는 git에 있어 컨테이너 첫 호출 시 재현 가능
|
| 26 |
+
model/classification/checkpoints/*.pkl
|
| 27 |
+
|
| 28 |
+
# 컨테이너 작업 영역 (학습 데이터·OCR·임시 변환물)
|
| 29 |
+
backend/data/
|
| 30 |
+
|
| 31 |
+
# Android build outputs and local settings
|
| 32 |
+
android/.gradle/
|
| 33 |
+
android/.idea/
|
| 34 |
+
android/build/
|
| 35 |
+
android/app/build/
|
| 36 |
+
android/local.properties
|
| 37 |
+
*.apk
|
| 38 |
+
*.aab
|
| 39 |
+
*.keystore
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# IDE
|
| 43 |
+
.idea/
|
| 44 |
+
.vscode/
|
| 45 |
+
*.iml
|
| 46 |
+
|
| 47 |
+
# OS
|
| 48 |
+
.DS_Store
|
| 49 |
+
Thumbs.db
|
| 50 |
+
|
| 51 |
+
# TTS 출력물
|
| 52 |
+
tts/*.mp3
|
| 53 |
+
tts/*.wav
|
| 54 |
+
|
| 55 |
+
# 런타임 업로드 (가통문 원본 PDF/이미지) + TTS mp3 — 폴더는 git에 유지
|
| 56 |
+
backend/static/notices/*
|
| 57 |
+
!backend/static/notices/.gitkeep
|
| 58 |
+
backend/static/tts/*
|
| 59 |
+
!backend/static/tts/.gitkeep
|
| 60 |
+
|
| 61 |
+
#클로드
|
| 62 |
+
.claude
|
| 63 |
+
|
| 64 |
+
# nested repo 방지
|
| 65 |
+
multicultural-ai/
|
| 66 |
+
multicultural-school-ai/
|
| 67 |
+
|
| 68 |
+
# 미팅 자료 (디코로만 공유, 깃에는 안 올림)
|
| 69 |
+
docs/meetings/
|
| 70 |
+
|
| 71 |
+
# 학습 데이터 draft (대용량, 미완성)
|
| 72 |
+
data/processed/
|
| 73 |
+
data/raw/
|
Dockerfile
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HF Spaces Docker SDK — schoolbridge 백엔드
|
| 2 |
+
# build context = repo root (backend/ + model/ 전부 포함)
|
| 3 |
+
FROM python:3.11-slim
|
| 4 |
+
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 파일 변환 의존 (HWP → 텍스트, PDF, 이미지 OCR)
|
| 8 |
+
# H2Orestart는 latest 채널 사용 — 버전별 asset 파일명 안전성 확보
|
| 9 |
+
ARG H2O_URL=https://github.com/ebandal/H2Orestart/releases/latest/download/H2Orestart.oxt
|
| 10 |
+
|
| 11 |
+
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
|
| 12 |
+
libreoffice-core \
|
| 13 |
+
libreoffice-writer \
|
| 14 |
+
libreoffice-java-common \
|
| 15 |
+
default-jre-headless \
|
| 16 |
+
fonts-nanum \
|
| 17 |
+
fonts-noto-cjk \
|
| 18 |
+
wget \
|
| 19 |
+
ca-certificates \
|
| 20 |
+
tesseract-ocr \
|
| 21 |
+
tesseract-ocr-kor \
|
| 22 |
+
libgl1 \
|
| 23 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 24 |
+
|
| 25 |
+
RUN wget -O /tmp/h2orestart.oxt "${H2O_URL}" \
|
| 26 |
+
&& unopkg add --shared /tmp/h2orestart.oxt \
|
| 27 |
+
&& rm -f /tmp/h2orestart.oxt
|
| 28 |
+
|
| 29 |
+
# Python 의존 설치 (CPU torch + transformers + edge-tts 등)
|
| 30 |
+
COPY backend/requirements.txt /app/requirements.txt
|
| 31 |
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
| 32 |
+
|
| 33 |
+
# 백엔드 코드
|
| 34 |
+
COPY backend/app /app/app
|
| 35 |
+
|
| 36 |
+
# 외부 모델 코드 (분류기·추출기 src/) — 가중치는 HF Hub에서 자동 다운로드
|
| 37 |
+
COPY model /app/external_model
|
| 38 |
+
|
| 39 |
+
# 정적 디렉토리 (TTS mp3, 가통문 원본 PDF/이미지)
|
| 40 |
+
RUN mkdir -p /app/static/tts /app/static/notices \
|
| 41 |
+
&& chmod -R 777 /app/static
|
| 42 |
+
|
| 43 |
+
# HF Spaces 기본 포트 7860 — 도메인 https://{user}-{space}.hf.space 로 자동 매핑
|
| 44 |
+
EXPOSE 7860
|
| 45 |
+
|
| 46 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SchoolBridge
|
| 3 |
+
emoji: 🏫
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
short_description: 가정통신문 AI 도우미 — 다문화 학부모용
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# SchoolBridge
|
| 13 |
+
|
| 14 |
+
가정통신문 AI 도우미 — 다문화 가정 학부모용 백엔드.
|
| 15 |
+
|
| 16 |
+
## 엔드포인트
|
| 17 |
+
|
| 18 |
+
- `GET /health`
|
| 19 |
+
- `POST /notice/extract-text` — 파일 텍스트 추출 (미리보기, 저장 X)
|
| 20 |
+
- `POST /notice/upload` — 파일 발송 (Notice 저장 + 원본 보존)
|
| 21 |
+
- `POST /notice/upload-self` — 학부모 자가 업로드
|
| 22 |
+
- `POST /notice/send` — 텍스트 직송
|
| 23 |
+
- `GET /notice/inbox/{parent_id}`
|
| 24 |
+
- `POST /notice/analyze/{notice_id}`
|
| 25 |
+
- `GET /static/notices/{id}{ext}` — 원본 PDF/이미지
|
| 26 |
+
|
| 27 |
+
## 모델
|
| 28 |
+
|
| 29 |
+
- 추출: 윤정님 KoELECTRA (`yunjeong116/koelectra-extractor`)
|
| 30 |
+
- 분류: 경이님 KcELECTRA v3 (`kysophia/kcelectra-category`)
|
| 31 |
+
- 번역: NLLB-200-distilled-600M
|
| 32 |
+
- TTS: Microsoft Edge-TTS
|
| 33 |
+
|
| 34 |
+
## 라이센스
|
| 35 |
+
|
| 36 |
+
MIT
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# 파일 변환 의존: LibreOffice headless + H2Orestart 확장 + 한글 폰트.
|
| 6 |
+
# 학습 데이터 가공과 런타임 [1] 텍스트 변환 모두 사용.
|
| 7 |
+
# H2Orestart는 latest 채널 사용 — 버전별 asset 파일명이 일관되지 않아 latest/download 안전.
|
| 8 |
+
ARG H2O_URL=https://github.com/ebandal/H2Orestart/releases/latest/download/H2Orestart.oxt
|
| 9 |
+
|
| 10 |
+
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
|
| 11 |
+
libreoffice-core \
|
| 12 |
+
libreoffice-writer \
|
| 13 |
+
libreoffice-java-common \
|
| 14 |
+
default-jre-headless \
|
| 15 |
+
fonts-nanum \
|
| 16 |
+
fonts-noto-cjk \
|
| 17 |
+
wget \
|
| 18 |
+
ca-certificates \
|
| 19 |
+
tesseract-ocr \
|
| 20 |
+
tesseract-ocr-kor \
|
| 21 |
+
libgl1 \
|
| 22 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 23 |
+
|
| 24 |
+
RUN wget -O /tmp/h2orestart.oxt "${H2O_URL}" \
|
| 25 |
+
&& unopkg add --shared /tmp/h2orestart.oxt \
|
| 26 |
+
&& rm -f /tmp/h2orestart.oxt
|
| 27 |
+
|
| 28 |
+
COPY requirements.txt .
|
| 29 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 30 |
+
|
| 31 |
+
COPY . .
|
| 32 |
+
|
| 33 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/auth.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""사용자 식별 + 역할 검증.
|
| 2 |
+
|
| 3 |
+
X-User-Id 헤더로 사용자를 식별하고, 역할(teacher/parent)에 따른 권한을 검증한다.
|
| 4 |
+
시연 단계라 별도 비밀번호/토큰 없이 헤더 한 줄로 처리. 실제 운영 시 JWT/세션 도입 예정.
|
| 5 |
+
"""
|
| 6 |
+
from fastapi import Depends, Header, HTTPException, status
|
| 7 |
+
|
| 8 |
+
from app.models.schemas import UserProfile
|
| 9 |
+
|
| 10 |
+
# 모듈 레벨 인메모리 사용자 저장소. user.py 라우터와 공유.
|
| 11 |
+
_user_store: dict[str, UserProfile] = {}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_user(user_id: str) -> UserProfile | None:
|
| 15 |
+
return _user_store.get(user_id)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upsert_user(profile: UserProfile) -> UserProfile:
|
| 19 |
+
_user_store[profile.user_id] = profile
|
| 20 |
+
return profile
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def list_users() -> list[UserProfile]:
|
| 24 |
+
return list(_user_store.values())
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def seed_demo_users() -> None:
|
| 28 |
+
"""시연용 기본 계정 시드. 이미 존재하면 덮어쓰지 않음."""
|
| 29 |
+
demos = [
|
| 30 |
+
UserProfile(user_id="teacher_001", role="teacher"),
|
| 31 |
+
UserProfile(user_id="teacher_002", role="teacher"),
|
| 32 |
+
UserProfile(user_id="parent_001", role="parent"),
|
| 33 |
+
UserProfile(user_id="parent_002", role="parent"),
|
| 34 |
+
UserProfile(user_id="parent_003", role="parent"),
|
| 35 |
+
]
|
| 36 |
+
for profile in demos:
|
| 37 |
+
_user_store.setdefault(profile.user_id, profile)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def require_user(x_user_id: str = Header(..., description="요청자 사용자 ID")) -> UserProfile:
|
| 41 |
+
"""X-User-Id 헤더 필수. 등록되지 않은 ID면 401."""
|
| 42 |
+
if not x_user_id:
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 45 |
+
detail="X-User-Id 헤더가 필요합니다",
|
| 46 |
+
)
|
| 47 |
+
profile = _user_store.get(x_user_id)
|
| 48 |
+
if profile is None:
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 51 |
+
detail=f"등록되지 않은 사용자: {x_user_id}",
|
| 52 |
+
)
|
| 53 |
+
return profile
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def require_teacher(user: UserProfile = Depends(require_user)) -> UserProfile:
|
| 57 |
+
if user.role != "teacher":
|
| 58 |
+
raise HTTPException(
|
| 59 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 60 |
+
detail="선생님 권한이 필요합니다",
|
| 61 |
+
)
|
| 62 |
+
return user
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def require_parent(user: UserProfile = Depends(require_user)) -> UserProfile:
|
| 66 |
+
if user.role != "parent":
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 69 |
+
detail="학부모 권한이 필요합니다",
|
| 70 |
+
)
|
| 71 |
+
return user
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.staticfiles import StaticFiles
|
| 7 |
+
|
| 8 |
+
from app.auth import seed_demo_users
|
| 9 |
+
from app.routers import notice, tts, user
|
| 10 |
+
|
| 11 |
+
STATIC_DIR = Path("/app/static")
|
| 12 |
+
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
| 13 |
+
(STATIC_DIR / "tts").mkdir(parents=True, exist_ok=True)
|
| 14 |
+
(STATIC_DIR / "notices").mkdir(parents=True, exist_ok=True)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@asynccontextmanager
|
| 18 |
+
async def lifespan(app: FastAPI):
|
| 19 |
+
seed_demo_users()
|
| 20 |
+
yield
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
app = FastAPI(
|
| 24 |
+
title="가정통신문 AI 도우미 API",
|
| 25 |
+
description="베트남 결혼이민 학부모를 위한 가정통신문 할 일 요약 + TTS 서비스",
|
| 26 |
+
version="0.1.0",
|
| 27 |
+
lifespan=lifespan,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=["*"],
|
| 33 |
+
allow_methods=["*"],
|
| 34 |
+
allow_headers=["*"],
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 38 |
+
|
| 39 |
+
app.include_router(notice.router, prefix="/notice", tags=["notice"])
|
| 40 |
+
app.include_router(tts.router, prefix="/tts", tags=["tts"])
|
| 41 |
+
app.include_router(user.router, prefix="/user", tags=["user"])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.get("/health", tags=["health"])
|
| 45 |
+
def health_check():
|
| 46 |
+
return {"status": "ok"}
|
backend/app/models/__init__.py
ADDED
|
File without changes
|
backend/app/models/schemas.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from enum import Enum
|
| 2 |
+
from typing import Any
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class KoreanLevel(str, Enum):
|
| 7 |
+
beginner = "beginner" # 초급: 베트남어 TTS
|
| 8 |
+
intermediate = "intermediate" # 중급: 한국어 TTS + 용어 설명
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Category(str, Enum):
|
| 12 |
+
schedule = "일정"
|
| 13 |
+
supplies = "준비물"
|
| 14 |
+
submission = "제출"
|
| 15 |
+
cost = "비용"
|
| 16 |
+
health = "건강·안전"
|
| 17 |
+
other = "기타"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TodoItem(BaseModel):
|
| 21 |
+
category: Category
|
| 22 |
+
text_ko: str
|
| 23 |
+
text_vi: str
|
| 24 |
+
importance: float # 0.0 ~ 1.0
|
| 25 |
+
due_date: str | None = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class YunjeongTodo(BaseModel):
|
| 29 |
+
"""윤정님 v2 추출 모델 출력 — 통신문 1개에서 문장 단위로 뽑힌 할일.
|
| 30 |
+
|
| 31 |
+
내부에 정규식 due_date/amount 추출 + binary 분류(BINARY_THRESHOLD=0.5) 포함.
|
| 32 |
+
confidence < 0.5 항목은 모델이 자동 필터링해 출력에 포함 안 됨.
|
| 33 |
+
"""
|
| 34 |
+
text: str # 원문 문장
|
| 35 |
+
source: str | None = None # 파일명 (같은 통신문 묶음용)
|
| 36 |
+
due_date: str | None = None # YYYY-MM-DD 또는 상대표현 ("다음 주 금요일")
|
| 37 |
+
amount: int | None = None # 원 단위
|
| 38 |
+
confidence: float # binary 확률 (0.0 ~ 1.0)
|
| 39 |
+
action_hint: str | None = None # 신청 / 제출 / 납부 / 준비 / 참여 / 확인
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Notice(BaseModel):
|
| 43 |
+
notice_id: str
|
| 44 |
+
teacher_id: str
|
| 45 |
+
parent_id: str
|
| 46 |
+
text: str
|
| 47 |
+
todos: list[TodoItem] = []
|
| 48 |
+
# 원본 파일 (선생님/학부모가 업로드한 PDF/이미지). text 직송이면 None.
|
| 49 |
+
original_file_url: str | None = None # 예: "/static/notices/abc123.pdf"
|
| 50 |
+
original_filename: str | None = None # 예: "5월 가정통신문.pdf"
|
| 51 |
+
mime_type: str | None = None # 예: "application/pdf"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class NoticeSendRequest(BaseModel):
|
| 55 |
+
teacher_id: str
|
| 56 |
+
parent_id: str
|
| 57 |
+
text: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class NoticeAnalyzeRequest(BaseModel):
|
| 61 |
+
target_language: str # vi/en/ru/ms/mn/zh/th/ja/ko_easy — 필수, default 없음
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── 슬롯 기반 응답 (강사 처방 1·3 대응) ──────────────────────────
|
| 65 |
+
# source: "regex" | "model" | "model+regex" — 신뢰도 추적용
|
| 66 |
+
# 정규식이 잡은 항목은 LLM 의존 없이 확보됐음을 안드/검수에서 표시 가능.
|
| 67 |
+
class SlotEntry(BaseModel):
|
| 68 |
+
ko: str
|
| 69 |
+
translated: str = ""
|
| 70 |
+
source: str = "model"
|
| 71 |
+
conditional: bool = False # "흐릴 경우 우산" 같은 조건부 항목
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class SummarySlots(BaseModel):
|
| 75 |
+
dates: list[SlotEntry] = []
|
| 76 |
+
times: list[SlotEntry] = []
|
| 77 |
+
places: list[SlotEntry] = []
|
| 78 |
+
supplies: list[SlotEntry] = [] # ⚠️ 강사 강조: 누락 금지
|
| 79 |
+
amounts: list[SlotEntry] = [] # ⚠️ 강사 강조: 누락 금지
|
| 80 |
+
deadlines: list[SlotEntry] = []
|
| 81 |
+
urls: list[SlotEntry] = [] # NLLB가 깨먹지 않게 ko 그대로 노출
|
| 82 |
+
phones: list[SlotEntry] = [] # 같은 이유
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class AnalyzeItem(BaseModel):
|
| 86 |
+
"""카테고리별 할 일 — YunjeongTodo + 경이님 카테고리 결합 결과.
|
| 87 |
+
|
| 88 |
+
deprecated — SlotCard로 대체 예정 (안드 마이그레이션 완료 후 폐기).
|
| 89 |
+
"""
|
| 90 |
+
category: Category # 경이님 (주제: 일정/준비물/제출/비용/건강·안전/기타)
|
| 91 |
+
action_hint: str | None = None # 윤정님 (행동: 신청/제출/납부/준비/참여/확인)
|
| 92 |
+
title_ko: str
|
| 93 |
+
title_translated: str = ""
|
| 94 |
+
when: str | None = None
|
| 95 |
+
where: str | None = None
|
| 96 |
+
what: list[str] = []
|
| 97 |
+
amount: str | None = None
|
| 98 |
+
deadline: str | None = None
|
| 99 |
+
importance: float = 0.5
|
| 100 |
+
note_ko: str | None = None # 조건부 메모 (예: "날씨가 흐릴 경우")
|
| 101 |
+
note_translated: str | None = None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class SlotCard(BaseModel):
|
| 105 |
+
"""슬롯 카드 — 헤더 + 값 + 카테고리 칩.
|
| 106 |
+
|
| 107 |
+
강사님 처방 "지저분한 줄글 X, 슬롯 위주로 가공" 대응.
|
| 108 |
+
한 카드 = 한 의미 단위 (운영시간 / 신청기간 / 운영방법 ...).
|
| 109 |
+
todos 헤더 분해 + regex 슬롯 컨텍스트 매칭 둘 다 카드로 통합.
|
| 110 |
+
"""
|
| 111 |
+
header_ko: str # 예: "운영시간"
|
| 112 |
+
header_translated: str = "" # 예: "Thời gian hoạt động"
|
| 113 |
+
value_ko: str # 예: "오전 10:00 ~ 12:00 (2시간)"
|
| 114 |
+
value_easy_ko: str = "" # 세종님 to_easy_korean() 결과 — 미구현 시 value_ko 그대로
|
| 115 |
+
value_translated: str = "" # NLLB 번역 결과
|
| 116 |
+
chip: str | None = None # category 값 — None이면 칩 미표시
|
| 117 |
+
importance: float = 0.5 # 정렬용 (높은 순)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class NoticeAnalyzeResponse(BaseModel):
|
| 121 |
+
notice_id: str
|
| 122 |
+
raw_text: str
|
| 123 |
+
target_language: str
|
| 124 |
+
# 통신문 제목 (윤정님 PR #90 extract_title heuristic) — 못 찾으면 ""
|
| 125 |
+
title: str = ""
|
| 126 |
+
title_translated: str = ""
|
| 127 |
+
# 신규 — 안드 슬롯 카드 UI 대상 (단계적 마이그레이션, 본 필드가 메인)
|
| 128 |
+
cards: list[SlotCard] = []
|
| 129 |
+
# deprecated — 안드 마이그레이션 완료 후 다음 PR에서 폐기 예정
|
| 130 |
+
summary: SummarySlots
|
| 131 |
+
items: list[AnalyzeItem] = []
|
| 132 |
+
tts_text: str = "" # 음성 변환 직전 텍스트 — 시연·디버그용 가시화
|
| 133 |
+
tts_url: str = "" # 번역 합본 TTS (안드 기존 버튼)
|
| 134 |
+
tts_url_easy_ko: str = "" # 쉬운 한국어 합본 TTS (세종님 별도 버튼 요청)
|
| 135 |
+
quality_note: str = ""
|
| 136 |
+
review_needed: str = ""
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class TTSRequest(BaseModel):
|
| 140 |
+
user_id: str
|
| 141 |
+
todo_items: list[TodoItem]
|
| 142 |
+
level: KoreanLevel
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class UserProfile(BaseModel):
|
| 146 |
+
user_id: str
|
| 147 |
+
role: str = "parent" # "teacher" | "parent"
|
| 148 |
+
child_grade: int = 1 # 1~6학년 (부모만 해당)
|
| 149 |
+
level: KoreanLevel = KoreanLevel.beginner
|
| 150 |
+
tts_speed: float = 1.0
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class ApiResponse(BaseModel):
|
| 154 |
+
status: str # "success" | "error"
|
| 155 |
+
data: Any = None
|
| 156 |
+
message: str = ""
|
| 157 |
+
|
| 158 |
+
@classmethod
|
| 159 |
+
def success(cls, data: Any = None, message: str = "") -> "ApiResponse":
|
| 160 |
+
return cls(status="success", data=data, message=message)
|
| 161 |
+
|
| 162 |
+
@classmethod
|
| 163 |
+
def error(cls, message: str) -> "ApiResponse":
|
| 164 |
+
return cls(status="error", data=None, message=message)
|
backend/app/routers/__init__.py
ADDED
|
File without changes
|
backend/app/routers/notice.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mimetypes
|
| 2 |
+
import os
|
| 3 |
+
import uuid
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
| 7 |
+
from app.auth import get_user, require_teacher, require_user
|
| 8 |
+
from app.models.schemas import (
|
| 9 |
+
AnalyzeItem, ApiResponse, Category, Notice, NoticeAnalyzeRequest,
|
| 10 |
+
NoticeSendRequest, SlotCard, SlotEntry, SummarySlots, UserProfile,
|
| 11 |
+
YunjeongTodo,
|
| 12 |
+
)
|
| 13 |
+
from app.services.extractor import extract_todos, extract_title
|
| 14 |
+
from app.services.parser import ParserError, parse_bytes_to_text
|
| 15 |
+
from app.services.translator import translate_short_sentence, translate_term
|
| 16 |
+
from app.services.classifier import classify_category
|
| 17 |
+
from app.services.tts import generate_tts_file
|
| 18 |
+
from app.services.slot_extractor import (
|
| 19 |
+
extract_summary_regex_slots, find_when_in_text,
|
| 20 |
+
split_supply_tokens, strip_markers,
|
| 21 |
+
)
|
| 22 |
+
from app.services.card_builder import build_cards
|
| 23 |
+
from app.services.mock import MOCK_TODOS
|
| 24 |
+
|
| 25 |
+
router = APIRouter()
|
| 26 |
+
|
| 27 |
+
_notices: dict[str, Notice] = {}
|
| 28 |
+
MAX_CARDS = 8
|
| 29 |
+
|
| 30 |
+
NOTICES_DIR = Path("/app/static/notices")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _save_original(notice_id: str, raw_bytes: bytes, filename: str) -> tuple[str, str | None]:
|
| 34 |
+
"""업로드된 원본 파일을 static/notices/{notice_id}{ext}로 저장.
|
| 35 |
+
Returns (url, mime_type). 실패 시 mime_type=None."""
|
| 36 |
+
ext = os.path.splitext(filename or "")[1].lower()
|
| 37 |
+
if not ext:
|
| 38 |
+
# 시그니처로 추정
|
| 39 |
+
if raw_bytes.startswith(b"%PDF"):
|
| 40 |
+
ext = ".pdf"
|
| 41 |
+
elif raw_bytes[:3] == b"\xff\xd8\xff":
|
| 42 |
+
ext = ".jpg"
|
| 43 |
+
elif raw_bytes.startswith(b"\x89PNG"):
|
| 44 |
+
ext = ".png"
|
| 45 |
+
safe_name = f"{notice_id}{ext}"
|
| 46 |
+
NOTICES_DIR.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
(NOTICES_DIR / safe_name).write_bytes(raw_bytes)
|
| 48 |
+
mime_type, _ = mimetypes.guess_type(safe_name)
|
| 49 |
+
return f"/static/notices/{safe_name}", mime_type
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.post("/send", response_model=ApiResponse)
|
| 53 |
+
async def send_notice(
|
| 54 |
+
req: NoticeSendRequest,
|
| 55 |
+
user: UserProfile = Depends(require_teacher),
|
| 56 |
+
):
|
| 57 |
+
"""선생님이 가정통신문 발송 → 부모 수신함에 저장."""
|
| 58 |
+
if user.user_id != req.teacher_id:
|
| 59 |
+
raise HTTPException(
|
| 60 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 61 |
+
detail="본인 선생님 ID로만 발송 가능합니다",
|
| 62 |
+
)
|
| 63 |
+
parent = get_user(req.parent_id)
|
| 64 |
+
if parent is None or parent.role != "parent":
|
| 65 |
+
raise HTTPException(
|
| 66 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 67 |
+
detail=f"학부모 계정을 찾을 수 없습니다: {req.parent_id}",
|
| 68 |
+
)
|
| 69 |
+
notice_id = str(uuid.uuid4())
|
| 70 |
+
notice = Notice(
|
| 71 |
+
notice_id=notice_id,
|
| 72 |
+
teacher_id=req.teacher_id,
|
| 73 |
+
parent_id=req.parent_id,
|
| 74 |
+
text=req.text,
|
| 75 |
+
todos=[],
|
| 76 |
+
)
|
| 77 |
+
_notices[notice_id] = notice
|
| 78 |
+
return ApiResponse.success(data={"notice_id": notice_id}, message="발송 완료")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.post("/extract-text", response_model=ApiResponse)
|
| 82 |
+
async def extract_text(
|
| 83 |
+
file: UploadFile = File(...),
|
| 84 |
+
user: UserProfile = Depends(require_user),
|
| 85 |
+
):
|
| 86 |
+
"""파일 → 텍스트 추출만 (Notice 저장·발송 X). 선생님 발송 전 미리보기용.
|
| 87 |
+
|
| 88 |
+
실제 발송은 사용자가 발송 버튼을 누를 때 /notice/upload (파일 동봉) 또는
|
| 89 |
+
/notice/send (텍스트만)로 호출됨.
|
| 90 |
+
"""
|
| 91 |
+
raw_bytes = await file.read()
|
| 92 |
+
try:
|
| 93 |
+
text = parse_bytes_to_text(raw_bytes, file.filename or "")
|
| 94 |
+
except ParserError as error:
|
| 95 |
+
raise HTTPException(
|
| 96 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 97 |
+
detail=f"파일 변환 실패: {error}",
|
| 98 |
+
)
|
| 99 |
+
if not text:
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 102 |
+
detail="파일에서 추출된 텍스트가 비어있습니다",
|
| 103 |
+
)
|
| 104 |
+
return ApiResponse.success(
|
| 105 |
+
data={"text": text, "char_count": len(text), "filename": file.filename},
|
| 106 |
+
message=f"텍스트 추출 완료 ({file.filename})",
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.post("/upload", response_model=ApiResponse)
|
| 111 |
+
async def upload_notice(
|
| 112 |
+
teacher_id: str = Form(...),
|
| 113 |
+
parent_id: str = Form(...),
|
| 114 |
+
file: UploadFile = File(...),
|
| 115 |
+
user: UserProfile = Depends(require_teacher),
|
| 116 |
+
):
|
| 117 |
+
"""선생님이 HWP/PDF 파일로 가정통신문 발송. 파일 → 텍스트 변환 후 send와 동일 흐름."""
|
| 118 |
+
if user.user_id != teacher_id:
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 121 |
+
detail="본인 선생님 ID로만 발송 가능합니다",
|
| 122 |
+
)
|
| 123 |
+
parent = get_user(parent_id)
|
| 124 |
+
if parent is None or parent.role != "parent":
|
| 125 |
+
raise HTTPException(
|
| 126 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 127 |
+
detail=f"학부모 계정을 찾을 수 없습니다: {parent_id}",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
raw_bytes = await file.read()
|
| 131 |
+
try:
|
| 132 |
+
text = parse_bytes_to_text(raw_bytes, file.filename or "")
|
| 133 |
+
except ParserError as error:
|
| 134 |
+
raise HTTPException(
|
| 135 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 136 |
+
detail=f"파일 변환 실패: {error}",
|
| 137 |
+
)
|
| 138 |
+
if not text:
|
| 139 |
+
raise HTTPException(
|
| 140 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 141 |
+
detail="파일에서 추출된 텍스트가 비어있습니다",
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
notice_id = str(uuid.uuid4())
|
| 145 |
+
original_url, mime_type = _save_original(notice_id, raw_bytes, file.filename or "")
|
| 146 |
+
notice = Notice(
|
| 147 |
+
notice_id=notice_id,
|
| 148 |
+
teacher_id=teacher_id,
|
| 149 |
+
parent_id=parent_id,
|
| 150 |
+
text=text,
|
| 151 |
+
todos=[],
|
| 152 |
+
original_file_url=original_url,
|
| 153 |
+
original_filename=file.filename,
|
| 154 |
+
mime_type=mime_type,
|
| 155 |
+
)
|
| 156 |
+
_notices[notice_id] = notice
|
| 157 |
+
return ApiResponse.success(
|
| 158 |
+
data={"notice_id": notice_id, "char_count": len(text), "text": text},
|
| 159 |
+
message=f"파일 업로드 완료 ({file.filename})",
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.post("/upload-self", response_model=ApiResponse)
|
| 164 |
+
async def upload_notice_self(
|
| 165 |
+
parent_id: str = Form(...),
|
| 166 |
+
file: UploadFile = File(...),
|
| 167 |
+
user: UserProfile = Depends(require_user),
|
| 168 |
+
):
|
| 169 |
+
"""학부모가 종이 통신문 사진/파일을 직접 업로드 → 수신함에 self-send 형태로 저장.
|
| 170 |
+
|
| 171 |
+
teacher_id = parent_id (자기 자신이 발신자)로 저장되며
|
| 172 |
+
이후 /analyze/{notice_id} 흐름은 동일.
|
| 173 |
+
"""
|
| 174 |
+
if user.role != "parent" or user.user_id != parent_id:
|
| 175 |
+
raise HTTPException(
|
| 176 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 177 |
+
detail="본인 학부모 ID로만 업로드 가능합니다",
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
raw_bytes = await file.read()
|
| 181 |
+
try:
|
| 182 |
+
text = parse_bytes_to_text(raw_bytes, file.filename or "")
|
| 183 |
+
except ParserError as error:
|
| 184 |
+
raise HTTPException(
|
| 185 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 186 |
+
detail=f"파일 변환 실패: {error}",
|
| 187 |
+
)
|
| 188 |
+
if not text:
|
| 189 |
+
raise HTTPException(
|
| 190 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 191 |
+
detail="파일에서 추출된 텍스트가 비어있습니다",
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
notice_id = str(uuid.uuid4())
|
| 195 |
+
original_url, mime_type = _save_original(notice_id, raw_bytes, file.filename or "")
|
| 196 |
+
_notices[notice_id] = Notice(
|
| 197 |
+
notice_id=notice_id,
|
| 198 |
+
teacher_id=parent_id,
|
| 199 |
+
parent_id=parent_id,
|
| 200 |
+
text=text,
|
| 201 |
+
todos=[],
|
| 202 |
+
original_file_url=original_url,
|
| 203 |
+
original_filename=file.filename,
|
| 204 |
+
mime_type=mime_type,
|
| 205 |
+
)
|
| 206 |
+
return ApiResponse.success(
|
| 207 |
+
data={"notice_id": notice_id, "char_count": len(text), "text": text},
|
| 208 |
+
message=f"업로드 완료 ({file.filename})",
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@router.get("/inbox/{parent_id}", response_model=ApiResponse)
|
| 213 |
+
async def get_inbox(
|
| 214 |
+
parent_id: str,
|
| 215 |
+
user: UserProfile = Depends(require_user),
|
| 216 |
+
):
|
| 217 |
+
"""부모가 수신된 가정통신문 목록 조회. 본인 ID만 허용."""
|
| 218 |
+
if user.role != "parent" or user.user_id != parent_id:
|
| 219 |
+
raise HTTPException(
|
| 220 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 221 |
+
detail="본인 수신함만 조회할 수 있습니다",
|
| 222 |
+
)
|
| 223 |
+
inbox = [n for n in _notices.values() if n.parent_id == parent_id]
|
| 224 |
+
return ApiResponse.success(data=inbox)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@router.delete("/inbox/{parent_id}", response_model=ApiResponse)
|
| 228 |
+
async def clear_inbox(
|
| 229 |
+
parent_id: str,
|
| 230 |
+
user: UserProfile = Depends(require_user),
|
| 231 |
+
):
|
| 232 |
+
"""parent_id 수신함 초기화 (시연용). 본인 ID만 허용."""
|
| 233 |
+
if user.role != "parent" or user.user_id != parent_id:
|
| 234 |
+
raise HTTPException(
|
| 235 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 236 |
+
detail="본인 수신함만 삭제할 수 있습니다",
|
| 237 |
+
)
|
| 238 |
+
targets = [nid for nid, n in _notices.items() if n.parent_id == parent_id]
|
| 239 |
+
for nid in targets:
|
| 240 |
+
del _notices[nid]
|
| 241 |
+
return ApiResponse.success(
|
| 242 |
+
data={"deleted": len(targets)},
|
| 243 |
+
message=f"{parent_id} 수신함 {len(targets)}개 삭제",
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ── 슬롯 기반 응답 빌더 ───────────────────────────────────────────
|
| 248 |
+
# 강사 처방(2026-04-28):
|
| 249 |
+
# 1. 템플릿 슬롯 출력 (summary)
|
| 250 |
+
# 2. 정규식 + 모델 하이브리드 (regex source 표시)
|
| 251 |
+
# 3. 핵심 명사(준비물·금액) 누락 가시화 (빈 슬롯 즉시 노출)
|
| 252 |
+
def _amount_to_ko(amount: int | None) -> str | None:
|
| 253 |
+
if amount is None:
|
| 254 |
+
return None
|
| 255 |
+
return f"{amount:,}원"
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _build_item(todo: YunjeongTodo, target_lang: str) -> AnalyzeItem:
|
| 259 |
+
"""YunjeongTodo + 경이님 카테고리 → AnalyzeItem.
|
| 260 |
+
|
| 261 |
+
- category : 경이님 6-class 분류 (주제)
|
| 262 |
+
- action_hint : 윤정님 추출 결과 (행동: 신청/제출/...)
|
| 263 |
+
- due_date / amount : 윤정님 모델 결과 신뢰 (정규식 [2]는 summary 전체 단위 담당)
|
| 264 |
+
- when : 자유텍스트의 일시 표현은 정규식으로 추가 추출
|
| 265 |
+
- what : 준비물 카테고리일 때만 토큰 분해
|
| 266 |
+
- 마크업(■)은 보존, TTS 빌더에서만 strip.
|
| 267 |
+
"""
|
| 268 |
+
text = todo.text
|
| 269 |
+
category = classify_category(text)
|
| 270 |
+
|
| 271 |
+
when = find_when_in_text(text, target_lang)
|
| 272 |
+
|
| 273 |
+
what: list[str] = []
|
| 274 |
+
if category == Category.supplies:
|
| 275 |
+
what = split_supply_tokens(text)
|
| 276 |
+
|
| 277 |
+
title_translated = translate_short_sentence(text, target_lang) or text
|
| 278 |
+
|
| 279 |
+
return AnalyzeItem(
|
| 280 |
+
category=category,
|
| 281 |
+
action_hint=todo.action_hint,
|
| 282 |
+
title_ko=text,
|
| 283 |
+
title_translated=title_translated,
|
| 284 |
+
when=when,
|
| 285 |
+
where=None,
|
| 286 |
+
what=what,
|
| 287 |
+
amount=_amount_to_ko(todo.amount),
|
| 288 |
+
deadline=todo.due_date,
|
| 289 |
+
importance=todo.confidence,
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _slot_entry(ko: str, target_lang: str, source: str = "model") -> SlotEntry:
|
| 294 |
+
return SlotEntry(ko=ko, translated=translate_term(ko, target_lang), source=source)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _build_summary(
|
| 298 |
+
regex_slots: dict[str, list[dict]],
|
| 299 |
+
items: list[AnalyzeItem],
|
| 300 |
+
target_lang: str,
|
| 301 |
+
) -> SummarySlots:
|
| 302 |
+
"""정규식 슬롯(dates/times/amounts/urls/phones) + items에서 모델 슬롯(supplies/deadlines) 집계."""
|
| 303 |
+
summary = SummarySlots(
|
| 304 |
+
dates=[SlotEntry(**s) for s in regex_slots["dates"]],
|
| 305 |
+
times=[SlotEntry(**s) for s in regex_slots["times"]],
|
| 306 |
+
amounts=[SlotEntry(**s) for s in regex_slots["amounts"]],
|
| 307 |
+
urls=[SlotEntry(**s) for s in regex_slots.get("urls", [])],
|
| 308 |
+
phones=[SlotEntry(**s) for s in regex_slots.get("phones", [])],
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# supplies: category=supplies items의 what 토큰 집계 (중복 제거, 순서 보존)
|
| 312 |
+
supplies: list[SlotEntry] = []
|
| 313 |
+
seen_supplies: set[str] = set()
|
| 314 |
+
for item in items:
|
| 315 |
+
if item.category != Category.supplies:
|
| 316 |
+
continue
|
| 317 |
+
for token in item.what:
|
| 318 |
+
if token in seen_supplies:
|
| 319 |
+
continue
|
| 320 |
+
seen_supplies.add(token)
|
| 321 |
+
supplies.append(_slot_entry(token, target_lang, source="model"))
|
| 322 |
+
summary.supplies = supplies
|
| 323 |
+
|
| 324 |
+
# deadlines: items의 deadline 집계
|
| 325 |
+
deadlines: list[SlotEntry] = []
|
| 326 |
+
seen_deadlines: set[str] = set()
|
| 327 |
+
for item in items:
|
| 328 |
+
if not item.deadline or item.deadline in seen_deadlines:
|
| 329 |
+
continue
|
| 330 |
+
seen_deadlines.add(item.deadline)
|
| 331 |
+
deadlines.append(_slot_entry(item.deadline, target_lang, source="model+regex"))
|
| 332 |
+
summary.deadlines = deadlines
|
| 333 |
+
|
| 334 |
+
return summary
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
@router.post("/analyze/{notice_id}", response_model=ApiResponse)
|
| 338 |
+
async def analyze_notice(
|
| 339 |
+
notice_id: str,
|
| 340 |
+
req: NoticeAnalyzeRequest,
|
| 341 |
+
user: UserProfile = Depends(require_user),
|
| 342 |
+
):
|
| 343 |
+
"""수신된 가정통신문 → 슬롯 기반 분석 응답.
|
| 344 |
+
|
| 345 |
+
파이프라인:
|
| 346 |
+
[3] 윤정 추출 (binary + 정규식, list[YunjeongTodo])
|
| 347 |
+
[2] 정규식 슬롯 (전체 통신문 단위)
|
| 348 |
+
[4] 경이 6-class 분류 (각 todo.text)
|
| 349 |
+
[5] 슬롯/짧은 문장 번역 (세종)
|
| 350 |
+
[6] AnalyzeItem 결합 + summary 집계
|
| 351 |
+
[7] TTS
|
| 352 |
+
|
| 353 |
+
학부모 본인의 가정통신문만 분석 가능. target_language 필수.
|
| 354 |
+
"""
|
| 355 |
+
if notice_id not in _notices:
|
| 356 |
+
raise HTTPException(
|
| 357 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 358 |
+
detail="가정통신문을 찾을 수 없습니다",
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
notice = _notices[notice_id]
|
| 362 |
+
if user.role != "parent" or user.user_id != notice.parent_id:
|
| 363 |
+
raise HTTPException(
|
| 364 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 365 |
+
detail="본인의 가정통신문만 분석할 수 있습니다",
|
| 366 |
+
)
|
| 367 |
+
target_lang = req.target_language
|
| 368 |
+
|
| 369 |
+
# [3] 윤정 추출 → list[YunjeongTodo] (할일 없으면 [])
|
| 370 |
+
try:
|
| 371 |
+
todos = extract_todos(notice.text)
|
| 372 |
+
if not todos:
|
| 373 |
+
todos = MOCK_TODOS
|
| 374 |
+
except Exception as error:
|
| 375 |
+
print(f"[analyze] extractor failed: {error}")
|
| 376 |
+
todos = MOCK_TODOS
|
| 377 |
+
|
| 378 |
+
# [3'] 제목 추출 (윤정님 PR #90 heuristic) — split_sentences 이전, 원문 직접 스캔
|
| 379 |
+
title_ko = extract_title(notice.text) or ""
|
| 380 |
+
title_translated = (
|
| 381 |
+
translate_short_sentence(title_ko, target_lang) if title_ko else ""
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
# [2] 정규식 슬롯 (전체 통신문 단위, summary 재료)
|
| 385 |
+
regex_slots = extract_summary_regex_slots(notice.text, target_lang)
|
| 386 |
+
|
| 387 |
+
# [4]+[6] items: 각 todo에 경이님 카테고리 + 슬롯 결합 (deprecated, 다음 PR 폐기)
|
| 388 |
+
items = [_build_item(t, target_lang) for t in todos]
|
| 389 |
+
|
| 390 |
+
# [6] summary 집계: 정규식 + items 모델 슬롯 통합 (deprecated, 다음 PR 폐기)
|
| 391 |
+
summary = _build_summary(regex_slots, items, target_lang)
|
| 392 |
+
|
| 393 |
+
# [6'] cards: 신규 슬롯 카드 응답 — 시연 안정성을 위해 상위 N개만 번역/TTS 대상으로 사용.
|
| 394 |
+
top_todos = sorted(todos, key=lambda t: -t.confidence)[:MAX_CARDS]
|
| 395 |
+
cards = build_cards(top_todos, regex_slots, target_lang)[:MAX_CARDS]
|
| 396 |
+
|
| 397 |
+
# [7] TTS: 두 갈래 — 번역 합본 + 쉬운 한국어 합본 (세종님 별도 버튼 요청)
|
| 398 |
+
tts_text_translated = _build_tts_text_from_cards(cards, "translated")
|
| 399 |
+
tts_text_easy_ko = _build_tts_text_from_cards(cards, "easy_ko")
|
| 400 |
+
try:
|
| 401 |
+
tts_url = await generate_tts_file(tts_text_translated, target_lang=target_lang) if tts_text_translated else ""
|
| 402 |
+
except Exception as error:
|
| 403 |
+
print(f"[analyze] TTS (translated) failed: {error}")
|
| 404 |
+
tts_url = ""
|
| 405 |
+
try:
|
| 406 |
+
tts_url_easy_ko = await generate_tts_file(tts_text_easy_ko, target_lang="ko_easy") if tts_text_easy_ko else ""
|
| 407 |
+
except Exception as error:
|
| 408 |
+
print(f"[analyze] TTS (easy_ko) failed: {error}")
|
| 409 |
+
tts_url_easy_ko = ""
|
| 410 |
+
|
| 411 |
+
response = {
|
| 412 |
+
"notice_id": notice_id,
|
| 413 |
+
"raw_text": notice.text,
|
| 414 |
+
"target_language": target_lang,
|
| 415 |
+
"title": title_ko,
|
| 416 |
+
"title_translated": title_translated,
|
| 417 |
+
"cards": [c.model_dump() for c in cards],
|
| 418 |
+
"summary": summary.model_dump(),
|
| 419 |
+
"items": [item.model_dump() for item in items],
|
| 420 |
+
"tts_text": tts_text_translated,
|
| 421 |
+
"tts_url": tts_url,
|
| 422 |
+
"tts_url_easy_ko": tts_url_easy_ko,
|
| 423 |
+
"quality_note": "",
|
| 424 |
+
"review_needed": "",
|
| 425 |
+
}
|
| 426 |
+
return ApiResponse.success(data=response)
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _build_tts_text_from_cards(cards: list[SlotCard], mode: str) -> str:
|
| 430 |
+
"""슬롯 카드 → TTS 텍스트 (헤더 + 값 한 줄씩 합본).
|
| 431 |
+
|
| 432 |
+
mode="translated": 대상 언어 TTS용 — value_translated + header_translated
|
| 433 |
+
mode="easy_ko": 쉬운 한국어 TTS용 — value_easy_ko + header_ko
|
| 434 |
+
"""
|
| 435 |
+
if not cards:
|
| 436 |
+
return ""
|
| 437 |
+
lines: list[str] = []
|
| 438 |
+
for c in cards:
|
| 439 |
+
if mode == "translated":
|
| 440 |
+
header = c.header_translated or c.header_ko
|
| 441 |
+
value = c.value_translated or c.value_ko
|
| 442 |
+
else: # easy_ko
|
| 443 |
+
header = c.header_ko
|
| 444 |
+
value = c.value_easy_ko or c.value_ko
|
| 445 |
+
if not value:
|
| 446 |
+
continue
|
| 447 |
+
value = strip_markers(value)
|
| 448 |
+
line = f"{header}. {value}" if header else value
|
| 449 |
+
lines.append(line)
|
| 450 |
+
return "\n".join(lines)
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def _build_tts_text(
|
| 454 |
+
summary: SummarySlots,
|
| 455 |
+
items: list[AnalyzeItem],
|
| 456 |
+
target_lang: str,
|
| 457 |
+
) -> str:
|
| 458 |
+
"""items 한 건씩 title + 슬롯 정보를 합쳐 한 문장으로 — 음성 정보량 최대화.
|
| 459 |
+
|
| 460 |
+
슬롯 한국어 값(amount/deadline/what)은 summary의 translated 매핑으로 대상 언어 변환.
|
| 461 |
+
정렬은 importance 내림차순. 경이 분리 후엔 action_required="Y" 우선으로 교체 예정.
|
| 462 |
+
"""
|
| 463 |
+
if not items:
|
| 464 |
+
return ""
|
| 465 |
+
|
| 466 |
+
# 한국어 슬롯 → 대상 언어 매핑 (summary가 이미 translated 보유)
|
| 467 |
+
supply_map = {s.ko: s.translated or s.ko for s in summary.supplies}
|
| 468 |
+
amount_map = {s.ko: s.translated or s.ko for s in summary.amounts}
|
| 469 |
+
deadline_map = {s.ko: s.translated or s.ko for s in summary.deadlines}
|
| 470 |
+
|
| 471 |
+
sorted_items = sorted(items, key=lambda i: -i.importance)
|
| 472 |
+
lines: list[str] = []
|
| 473 |
+
for item in sorted_items:
|
| 474 |
+
title = item.title_ko if target_lang == "ko_easy" else item.title_translated
|
| 475 |
+
if not title:
|
| 476 |
+
continue
|
| 477 |
+
# 음성에서 ■ 등 장식 마크업은 노이즈 — TTS 직전 strip
|
| 478 |
+
title = strip_markers(title)
|
| 479 |
+
extras: list[str] = []
|
| 480 |
+
if item.when: # 이미 target_lang 포맷
|
| 481 |
+
extras.append(item.when)
|
| 482 |
+
if item.what:
|
| 483 |
+
extras.append(", ".join(supply_map.get(w, w) for w in item.what))
|
| 484 |
+
if item.amount:
|
| 485 |
+
extras.append(amount_map.get(item.amount, item.amount))
|
| 486 |
+
if item.deadline:
|
| 487 |
+
extras.append(strip_markers(deadline_map.get(item.deadline, item.deadline)))
|
| 488 |
+
line = title + (". " + ". ".join(extras) if extras else "")
|
| 489 |
+
lines.append(line)
|
| 490 |
+
return "\n".join(lines)
|
backend/app/routers/tts.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
from app.models.schemas import ApiResponse, TTSRequest
|
| 4 |
+
from app.services.tts import generate_tts_file
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.post("/generate", response_model=ApiResponse)
|
| 10 |
+
async def generate_tts(req: TTSRequest):
|
| 11 |
+
"""할 일 목록 → 베트남어 음성 파일 생성. 안드는 응답 url을 BASE_URL과 합쳐 재생."""
|
| 12 |
+
text = "\n".join(item.text_vi for item in req.todo_items if item.text_vi)
|
| 13 |
+
if not text.strip():
|
| 14 |
+
return ApiResponse.error(message="베트남어 텍스트가 비어 있습니다")
|
| 15 |
+
try:
|
| 16 |
+
url = await generate_tts_file(text)
|
| 17 |
+
except Exception as error:
|
| 18 |
+
return ApiResponse.error(message=f"TTS 생성 실패: {error}")
|
| 19 |
+
return ApiResponse.success(data={"tts_url": url})
|
backend/app/routers/user.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
from app.auth import get_user as _get_user, list_users, upsert_user
|
| 4 |
+
from app.models.schemas import ApiResponse, UserProfile
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.get("/", response_model=ApiResponse)
|
| 10 |
+
def list_all_users():
|
| 11 |
+
"""등록된 사용자 전체 목록 (시연용)."""
|
| 12 |
+
return ApiResponse.success(data=list_users())
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get("/{user_id}", response_model=ApiResponse)
|
| 16 |
+
def get_user(user_id: str):
|
| 17 |
+
"""사용자 프로파일 조회 (한국어 수준, 학년, TTS 속도)."""
|
| 18 |
+
profile = _get_user(user_id)
|
| 19 |
+
if profile is None:
|
| 20 |
+
return ApiResponse.error(message="사용자 없음")
|
| 21 |
+
return ApiResponse.success(data=profile)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post("/", response_model=ApiResponse)
|
| 25 |
+
def save_user(profile: UserProfile):
|
| 26 |
+
"""사용자 프로파일 저장/수정. 시연 시 학부모/선생님 계정 등록에도 사용."""
|
| 27 |
+
saved = upsert_user(profile)
|
| 28 |
+
return ApiResponse.success(data=saved)
|
backend/app/services/__init__.py
ADDED
|
File without changes
|
backend/app/services/card_builder.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""슬롯 카드 빌더 — todos + regex_slots → list[SlotCard].
|
| 2 |
+
|
| 3 |
+
강사님 처방 "지저분한 줄글 X, 슬롯 위주 가공" 대응:
|
| 4 |
+
한 카드 = 한 의미 단위 (운영시간 / 신청기간 / 운영방법 ...).
|
| 5 |
+
|
| 6 |
+
흐름:
|
| 7 |
+
1. todos → 헤더 분해 + 분류 + 번역 + 쉬운 한국어
|
| 8 |
+
2. regex 슬롯 (URL/시간/날짜/전화/금액) → 보강 카드 (todo로 못 잡은 정보)
|
| 9 |
+
3. importance 내림차순 정렬
|
| 10 |
+
|
| 11 |
+
윤정님 모델이 임계값에서 컷한 정보(운영시간/신청기간 등)를 regex 슬롯이
|
| 12 |
+
보완 — 강사님 의견 "슬롯 위주 표시" 본질과 정합.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
|
| 18 |
+
from app.models.schemas import Category, SlotCard, YunjeongTodo
|
| 19 |
+
from app.services.classifier import classify_category
|
| 20 |
+
from app.services.easy_korean import to_easy_korean
|
| 21 |
+
from app.services.header_split import split_header_value
|
| 22 |
+
from app.services.translator import translate_short_sentence, translate_term
|
| 23 |
+
|
| 24 |
+
# 헤더 추정 실패 시 fallback
|
| 25 |
+
_FALLBACK_HEADER = "기타"
|
| 26 |
+
|
| 27 |
+
# regex 슬롯별 기본 헤더 (todo에서 못 잡은 정보 보강용 카드)
|
| 28 |
+
# todo로 헤더가 추정된 경우엔 이 카드를 만들지 않음 (중복 방지).
|
| 29 |
+
_SLOT_HEADERS: dict[str, str] = {
|
| 30 |
+
"dates": "일시",
|
| 31 |
+
"times": "시간",
|
| 32 |
+
"urls": "신청 URL",
|
| 33 |
+
"phones": "연락처",
|
| 34 |
+
"amounts": "비용",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# regex 슬롯이 todo로 이미 흡수됐는지 판단할 헤더 매핑.
|
| 38 |
+
# 예: todo가 "운영시간" 헤더로 이미 추출됐으면 regex times 카드는 만들지 않음.
|
| 39 |
+
_TODO_HEADER_COVERS: dict[str, set[str]] = {
|
| 40 |
+
"times": {"운영시간", "신청시간", "시간"},
|
| 41 |
+
"dates": {"운영날짜", "일시", "기간", "운영기간"},
|
| 42 |
+
"urls": {"신청 URL", "신청경로", "신청방법"},
|
| 43 |
+
"phones": {"연락처", "문의", "문의처"},
|
| 44 |
+
"amounts": {"비용", "회비", "참가비", "수강료", "급식비"},
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _build_card_from_todo(todo: YunjeongTodo, target_lang: str) -> SlotCard:
|
| 49 |
+
"""YunjeongTodo → SlotCard."""
|
| 50 |
+
header, value = split_header_value(todo.text)
|
| 51 |
+
if header is None:
|
| 52 |
+
header = _FALLBACK_HEADER
|
| 53 |
+
|
| 54 |
+
category = classify_category(value)
|
| 55 |
+
chip = category.value if category != Category.other else None
|
| 56 |
+
|
| 57 |
+
return SlotCard(
|
| 58 |
+
header_ko=header,
|
| 59 |
+
header_translated=translate_term(header, target_lang),
|
| 60 |
+
value_ko=value,
|
| 61 |
+
value_easy_ko=to_easy_korean(value),
|
| 62 |
+
value_translated=translate_short_sentence(value, target_lang) or value,
|
| 63 |
+
chip=chip,
|
| 64 |
+
importance=todo.confidence,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _slot_entry_ko(entry: dict | str) -> str:
|
| 69 |
+
if isinstance(entry, dict):
|
| 70 |
+
return entry.get("ko", "")
|
| 71 |
+
return entry
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _slot_entry_translated(entry: dict | str) -> str:
|
| 75 |
+
if isinstance(entry, dict):
|
| 76 |
+
return entry.get("translated") or entry.get("ko", "")
|
| 77 |
+
return entry
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _build_cards_from_regex_slots(
|
| 81 |
+
regex_slots: dict[str, list[dict]],
|
| 82 |
+
target_lang: str,
|
| 83 |
+
todo_headers: set[str],
|
| 84 |
+
) -> list[SlotCard]:
|
| 85 |
+
"""regex 슬롯 → 보강 SlotCard. todo 헤더가 이미 커버한 슬롯은 스킵."""
|
| 86 |
+
cards: list[SlotCard] = []
|
| 87 |
+
|
| 88 |
+
for slot_name, default_header in _SLOT_HEADERS.items():
|
| 89 |
+
entries = regex_slots.get(slot_name, [])
|
| 90 |
+
if not entries:
|
| 91 |
+
continue
|
| 92 |
+
# todo가 이미 이 슬롯을 커버하면 스킵 (중복 카드 방지)
|
| 93 |
+
if todo_headers & _TODO_HEADER_COVERS.get(slot_name, set()):
|
| 94 |
+
continue
|
| 95 |
+
|
| 96 |
+
# 슬롯당 한 카드 — 여러 값은 콤마 구분
|
| 97 |
+
values_ko = [_slot_entry_ko(e) for e in entries]
|
| 98 |
+
values_translated = [_slot_entry_translated(e) for e in entries]
|
| 99 |
+
value_ko = ", ".join(v for v in values_ko if v)
|
| 100 |
+
value_translated = ", ".join(v for v in values_translated if v)
|
| 101 |
+
if not value_ko:
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
cards.append(SlotCard(
|
| 105 |
+
header_ko=default_header,
|
| 106 |
+
header_translated=translate_term(default_header, target_lang),
|
| 107 |
+
value_ko=value_ko,
|
| 108 |
+
value_easy_ko=to_easy_korean(value_ko),
|
| 109 |
+
value_translated=value_translated or value_ko,
|
| 110 |
+
chip=None, # regex 슬롯은 칩 없음 — todo가 아니므로 카테고리 모호
|
| 111 |
+
importance=0.7, # todo 평균 confidence보다 살짝 낮음
|
| 112 |
+
))
|
| 113 |
+
|
| 114 |
+
return cards
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# dedup 비교용 — 표 구분자/콜론/연속 공백 정규화 (`|`/`:`/`:` 등 차이로 substring 놓치는 것 방지)
|
| 118 |
+
_DEDUP_NORMALIZE = re.compile(r"[\s||::]+")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _normalize_for_dedup(text: str) -> str:
|
| 122 |
+
"""value 비교용 정규화 — 공백/구분자 차이 무시."""
|
| 123 |
+
return _DEDUP_NORMALIZE.sub(" ", text).strip()
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _dedup_cards(cards: list[SlotCard]) -> list[SlotCard]:
|
| 127 |
+
"""같은 헤더 안에서 substring 카드 제거.
|
| 128 |
+
|
| 129 |
+
pdfplumber가 본문 + [표] 양쪽에서 같은 정보를 추출해 두 카드로 들어오는 경우
|
| 130 |
+
(예: 서대구초 운영방법 156자 본문 카드 + 38자 표 영역 카드) 짧은 쪽이 긴
|
| 131 |
+
쪽 안에 substring으로 들어있으면 짧은 쪽 제거. 정보 손실 0.
|
| 132 |
+
|
| 133 |
+
헤더가 다르면 손대지 않음 — 운영방법/일시/시간/URL 등 다른 슬롯은 별개.
|
| 134 |
+
"""
|
| 135 |
+
by_header: dict[str, list[SlotCard]] = {}
|
| 136 |
+
for c in cards:
|
| 137 |
+
by_header.setdefault(c.header_ko, []).append(c)
|
| 138 |
+
|
| 139 |
+
keep: list[SlotCard] = []
|
| 140 |
+
for group in by_header.values():
|
| 141 |
+
# 긴 value 우선 — 짧은 게 긴 것 substring이면 제거 가능
|
| 142 |
+
group.sort(key=lambda c: -len(c.value_ko))
|
| 143 |
+
kept_norms: list[str] = []
|
| 144 |
+
for card in group:
|
| 145 |
+
norm = _normalize_for_dedup(card.value_ko)
|
| 146 |
+
if any(norm in k for k in kept_norms):
|
| 147 |
+
continue
|
| 148 |
+
kept_norms.append(norm)
|
| 149 |
+
keep.append(card)
|
| 150 |
+
return keep
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def build_cards(
|
| 154 |
+
todos: list[YunjeongTodo],
|
| 155 |
+
regex_slots: dict[str, list[dict]],
|
| 156 |
+
target_lang: str,
|
| 157 |
+
) -> list[SlotCard]:
|
| 158 |
+
"""todos + regex_slots → list[SlotCard]. dedup + importance 내림차순 정렬."""
|
| 159 |
+
cards = [_build_card_from_todo(t, target_lang) for t in todos]
|
| 160 |
+
|
| 161 |
+
todo_headers = {c.header_ko for c in cards}
|
| 162 |
+
cards.extend(_build_cards_from_regex_slots(regex_slots, target_lang, todo_headers))
|
| 163 |
+
|
| 164 |
+
cards = _dedup_cards(cards)
|
| 165 |
+
cards.sort(key=lambda c: -c.importance)
|
| 166 |
+
return cards
|
backend/app/services/classifier.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""경이님 6-class 분류 모델 wrapper.
|
| 2 |
+
|
| 3 |
+
문장 → Category (일정/준비물/제출/비용/건강·안전/기타).
|
| 4 |
+
파이프라인 [4] 단계 — 윤정님 todo 각각에 대해 호출되어 AnalyzeItem.category로 들어감.
|
| 5 |
+
|
| 6 |
+
모델 모드: "auto" — KcELECTRA 체크포인트 있으면 그쪽, 없으면 simple(TF-IDF+LogReg) 폴백.
|
| 7 |
+
경이님 v2 KcELECTRA 학습이 끝나면 자동으로 더 정확한 모델로 업그레이드됨.
|
| 8 |
+
"""
|
| 9 |
+
import sys
|
| 10 |
+
from datetime import date
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from app.models.schemas import Category
|
| 14 |
+
|
| 15 |
+
_CLF_DIR = Path("/app/external_model/classification")
|
| 16 |
+
if str(_CLF_DIR) not in sys.path:
|
| 17 |
+
sys.path.insert(0, str(_CLF_DIR))
|
| 18 |
+
|
| 19 |
+
# 외부 마운트가 없는 환경(CI/테스트)에선 import 가드 — 모듈 로드만큼은 안전하게.
|
| 20 |
+
try:
|
| 21 |
+
from src.predict import predict_one # noqa: E402
|
| 22 |
+
except ImportError as error:
|
| 23 |
+
print(f"[classifier] predict_one unavailable: {error}")
|
| 24 |
+
predict_one = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def classify_category(text: str, today: date | None = None) -> Category:
|
| 28 |
+
"""문장 → 6-class 카테고리. 실패/미정 시 Category.other.
|
| 29 |
+
|
| 30 |
+
model="auto"로 호출 — 경이님 predict_one이 KcELECTRA 체크포인트 존재 여부를
|
| 31 |
+
감지해 자동 선택. simple은 첫 호출 시 학습 데이터로 자가 학습 (~3초).
|
| 32 |
+
"""
|
| 33 |
+
if not text or not text.strip():
|
| 34 |
+
return Category.other
|
| 35 |
+
if predict_one is None:
|
| 36 |
+
return Category.other # 모델 부재 (CI 등) — 안전한 기본값
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
result = predict_one(text, model="auto", today=today, explain=False)
|
| 40 |
+
except Exception as error:
|
| 41 |
+
print(f"[classifier] predict_one failed: {error}")
|
| 42 |
+
return Category.other
|
| 43 |
+
|
| 44 |
+
label = result.get("category", "")
|
| 45 |
+
try:
|
| 46 |
+
return Category(label)
|
| 47 |
+
except ValueError:
|
| 48 |
+
return Category.other
|
backend/app/services/easy_korean.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
EASY_KO_DICT = {
|
| 7 |
+
"제출": "제출(내기)",
|
| 8 |
+
"납부": "납부(지불)",
|
| 9 |
+
"준비물": "준비할 것",
|
| 10 |
+
"방역지침": "마스크 착용 등 방역 규칙",
|
| 11 |
+
"신청": "신청",
|
| 12 |
+
"개인": "각자",
|
| 13 |
+
"해당": "관련",
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
_PROTECTED_PATTERN = re.compile(
|
| 17 |
+
r"("
|
| 18 |
+
r"\d{4}\s*년\s*\d{1,2}\s*월\s*\d{1,2}\s*일"
|
| 19 |
+
r"|"
|
| 20 |
+
r"\d{1,2}\s*월\s*\d{1,2}\s*일"
|
| 21 |
+
r"|"
|
| 22 |
+
r"\d{1,2}\s*/\s*\d{1,2}"
|
| 23 |
+
r"|"
|
| 24 |
+
r"\d{1,3}(?:,\d{3})+원"
|
| 25 |
+
r"|"
|
| 26 |
+
r"\d+원"
|
| 27 |
+
r")"
|
| 28 |
+
)
|
| 29 |
+
_PLACEHOLDER_PATTERN = re.compile(r"__EASY_KO_PROTECTED_(\d+)__")
|
| 30 |
+
_WORD_CHARS = r"가-힣A-Za-z0-9_"
|
| 31 |
+
_POLITE_ENDING_PATTERN = re.compile(r"(합니다|세요|입니다)[.!?]?$")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _protect_patterns(text: str) -> tuple[str, list[str]]:
|
| 35 |
+
protected: list[str] = []
|
| 36 |
+
|
| 37 |
+
def stash(match: re.Match[str]) -> str:
|
| 38 |
+
protected.append(match.group(0))
|
| 39 |
+
return f"__EASY_KO_PROTECTED_{len(protected) - 1}__"
|
| 40 |
+
|
| 41 |
+
return _PROTECTED_PATTERN.sub(stash, text), protected
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _restore_patterns(text: str, protected: list[str]) -> str:
|
| 45 |
+
def restore(match: re.Match[str]) -> str:
|
| 46 |
+
index = int(match.group(1))
|
| 47 |
+
return protected[index] if index < len(protected) else match.group(0)
|
| 48 |
+
|
| 49 |
+
return _PLACEHOLDER_PATTERN.sub(restore, text)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _replace_terms(text: str) -> str:
|
| 53 |
+
for source, target in EASY_KO_DICT.items():
|
| 54 |
+
pattern = re.compile(rf"(?<![{_WORD_CHARS}]){re.escape(source)}(?![{_WORD_CHARS}])")
|
| 55 |
+
text = pattern.sub(target, text)
|
| 56 |
+
return text
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _make_ending_natural(text: str) -> str:
|
| 60 |
+
stripped = text.strip()
|
| 61 |
+
if not stripped or _POLITE_ENDING_PATTERN.search(stripped):
|
| 62 |
+
return text
|
| 63 |
+
|
| 64 |
+
suffix_map = {
|
| 65 |
+
"신청": "신청해야 합니다",
|
| 66 |
+
"제출(내기)": "제출해야 합니다",
|
| 67 |
+
"준비": "준비해야 합니다",
|
| 68 |
+
"참가": "참가해야 합니다",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
trailing_punctuation = ""
|
| 72 |
+
if stripped[-1] in ".!?":
|
| 73 |
+
trailing_punctuation = stripped[-1]
|
| 74 |
+
stripped = stripped[:-1].rstrip()
|
| 75 |
+
|
| 76 |
+
for suffix, replacement in suffix_map.items():
|
| 77 |
+
if stripped.endswith(suffix):
|
| 78 |
+
return stripped[: -len(suffix)] + replacement + trailing_punctuation
|
| 79 |
+
return text
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def to_easy_korean(value_ko: str) -> str:
|
| 83 |
+
try:
|
| 84 |
+
if not value_ko:
|
| 85 |
+
return value_ko
|
| 86 |
+
masked, protected = _protect_patterns(value_ko)
|
| 87 |
+
converted = _replace_terms(masked)
|
| 88 |
+
converted = _make_ending_natural(converted)
|
| 89 |
+
return _restore_patterns(converted, protected)
|
| 90 |
+
except Exception:
|
| 91 |
+
return value_ko
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
samples = [
|
| 96 |
+
"3월 17일까지 신청",
|
| 97 |
+
"5,000원 납부",
|
| 98 |
+
"개인 준비물 지참",
|
| 99 |
+
"온라인 사전예약 후 방문 바랍니다",
|
| 100 |
+
"방역지침 준수",
|
| 101 |
+
]
|
| 102 |
+
for sample in samples:
|
| 103 |
+
print(f"{sample} -> {to_easy_korean(sample)}")
|
backend/app/services/extractor.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""윤정님 추출 모델 wrapper.
|
| 2 |
+
|
| 3 |
+
가정통신문 텍스트 → list[YunjeongTodo].
|
| 4 |
+
v2 모델 (model/extraction/file/predict.py): binary 분류(BINARY_THRESHOLD=0.5) +
|
| 5 |
+
정규식 due_date/amount/action_hint. 출력 스키마가 YunjeongTodo와 1:1.
|
| 6 |
+
|
| 7 |
+
v1 (구버전 model/extraction/predict.py)은 윤정님이 v2 머지하면서 삭제.
|
| 8 |
+
v1 adapter 경로는 호환성을 위해 남겨두지만 실제로는 v2 진입점이 사용됨.
|
| 9 |
+
"""
|
| 10 |
+
import re
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from app.models.schemas import YunjeongTodo
|
| 15 |
+
|
| 16 |
+
# v2 진입점은 model/extraction/file/predict.py.
|
| 17 |
+
# CI/테스트 환경에선 외부 마운트 부재 → 가드로 빈 결과 반환.
|
| 18 |
+
_EXTRACTION_DIR = Path("/app/external_model/extraction/file")
|
| 19 |
+
if str(_EXTRACTION_DIR) not in sys.path:
|
| 20 |
+
sys.path.insert(0, str(_EXTRACTION_DIR))
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
import predict as _yunjeong # noqa: E402
|
| 24 |
+
except ImportError as error:
|
| 25 |
+
print(f"[extractor] predict module unavailable: {error}")
|
| 26 |
+
_yunjeong = None
|
| 27 |
+
|
| 28 |
+
_AMOUNT_RE = re.compile(r"(\d{1,3}(?:,\d{3})+|\d+)\s*원")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def extract_title(notice_text: str) -> str | None:
|
| 32 |
+
"""가정통신문 원문 → 제목 한 줄. 못 찾으면 None.
|
| 33 |
+
|
| 34 |
+
윤정님 PR #90 (predict.py:extract_title) — split_sentences()의
|
| 35 |
+
_HEADER_ONLY 필터가 제목을 차단하기 전에 원문 줄을 직접 스캔.
|
| 36 |
+
predict()와 별도 호출.
|
| 37 |
+
"""
|
| 38 |
+
if not notice_text or not notice_text.strip():
|
| 39 |
+
return None
|
| 40 |
+
if _yunjeong is None or not hasattr(_yunjeong, "extract_title"):
|
| 41 |
+
return None
|
| 42 |
+
try:
|
| 43 |
+
return _yunjeong.extract_title(notice_text)
|
| 44 |
+
except Exception as error:
|
| 45 |
+
print(f"[extractor] extract_title failed: {error}")
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def extract_todos(notice_text: str, source: str | None = None) -> list[YunjeongTodo]:
|
| 50 |
+
"""가정통신문 원문 → list[YunjeongTodo]. 할일 없으면 []."""
|
| 51 |
+
if not notice_text or not notice_text.strip():
|
| 52 |
+
return []
|
| 53 |
+
if _yunjeong is None:
|
| 54 |
+
return [] # 모델 모듈 없음 (CI 등) — 빈 결과로 후속 단계 정상 동작
|
| 55 |
+
|
| 56 |
+
# v2 진입점: predict(text, source) → list[dict]
|
| 57 |
+
if hasattr(_yunjeong, "predict"):
|
| 58 |
+
try:
|
| 59 |
+
raw_items = _yunjeong.predict(notice_text, source=source)
|
| 60 |
+
except TypeError:
|
| 61 |
+
# source kwarg 미지원 버전 호환
|
| 62 |
+
raw_items = _yunjeong.predict(notice_text)
|
| 63 |
+
return [YunjeongTodo(**raw) for raw in raw_items]
|
| 64 |
+
|
| 65 |
+
# v1 fallback (구버전 predict.py가 마운트되어 있을 경우)
|
| 66 |
+
if hasattr(_yunjeong, "extract_todos_dict"):
|
| 67 |
+
raw_items = _yunjeong.extract_todos_dict(notice_text)
|
| 68 |
+
return [_adapt_v1(item) for item in raw_items]
|
| 69 |
+
|
| 70 |
+
return []
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _adapt_v1(v1_item: dict) -> YunjeongTodo:
|
| 74 |
+
text = v1_item.get("text_ko", "")
|
| 75 |
+
return YunjeongTodo(
|
| 76 |
+
text=text,
|
| 77 |
+
source=None,
|
| 78 |
+
due_date=v1_item.get("due_date"),
|
| 79 |
+
amount=_extract_amount_value(text),
|
| 80 |
+
confidence=float(v1_item.get("importance", 0.5)),
|
| 81 |
+
action_hint=None,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _extract_amount_value(text: str) -> int | None:
|
| 86 |
+
m = _AMOUNT_RE.search(text)
|
| 87 |
+
if not m:
|
| 88 |
+
return None
|
| 89 |
+
try:
|
| 90 |
+
return int(m.group(1).replace(",", ""))
|
| 91 |
+
except ValueError:
|
| 92 |
+
return None
|
backend/app/services/header_split.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""슬롯 카드 헤더 분해 — todo.text → (헤더, 값) 페어.
|
| 2 |
+
|
| 3 |
+
강사님 처방 "슬롯 위주 가공" 대응:
|
| 4 |
+
슬롯 카드 = 헤더(라벨) 굵게 + 값 행. 본 모듈이 헤더 추출 책임.
|
| 5 |
+
|
| 6 |
+
윤정님 split_sentences가 이미 헤더 단위로 todo를 분리해서 반환하므로
|
| 7 |
+
보통 todo.text 시작에 헤더 키워드가 옴. 또한 윤정님이 한글 프로 기호 +
|
| 8 |
+
표 구분자(`|`)를 정제하기로 합의됨 — 매칭 시 구분자는 옵셔널 처리.
|
| 9 |
+
|
| 10 |
+
매칭 실패 시 (None, 원문)을 반환해 호출부가 fallback("기타") 헤더로 처리.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
|
| 16 |
+
# 가정통신문 표준 헤더 키워드.
|
| 17 |
+
# 윤정님 split_sentences 분리 룰의 키워드 + 갈산초/서대구초 케이스 보강.
|
| 18 |
+
# 긴 표현이 짧은 것에 흡수되지 않게 정렬은 길이 내림차순 (예: "기타 안내사항" 우선, "기타" 후순위).
|
| 19 |
+
HEADER_KEYWORDS: list[str] = [
|
| 20 |
+
# 운영 계열
|
| 21 |
+
"운영시간", "운영방법", "운영날짜", "운영기간", "운영장소",
|
| 22 |
+
# 신청 계열
|
| 23 |
+
"신청방법", "신청기간", "신청경로", "신청대상", "신청자격",
|
| 24 |
+
# 접수/제출
|
| 25 |
+
"접수기간", "접수방법", "접수처",
|
| 26 |
+
"제출방법", "제출기한", "제출처",
|
| 27 |
+
# 일정/장소
|
| 28 |
+
"일시", "기간", "장소", "위치", "주소",
|
| 29 |
+
# 대상/자격
|
| 30 |
+
"대상", "자격", "참가대상",
|
| 31 |
+
# 준비물/비용
|
| 32 |
+
"준비물", "지참물", "준비사항",
|
| 33 |
+
"비용", "회비", "참가비", "수강료", "급식비",
|
| 34 |
+
# 안내/유의
|
| 35 |
+
"기타 안내사항", "기타안내사항", "안내사항",
|
| 36 |
+
"유의사항", "참고사항", "주의사항",
|
| 37 |
+
# 문의
|
| 38 |
+
"문의", "연락처", "문의처",
|
| 39 |
+
# 기타 (가장 짧음, 마지막)
|
| 40 |
+
"기타",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
_HEADER_KEYWORDS_SORTED = sorted(HEADER_KEYWORDS, key=len, reverse=True)
|
| 44 |
+
|
| 45 |
+
# 줄 시작 + 헤더 키워드 + (선택적 `|`/`:`/`:` 구분자) + 값
|
| 46 |
+
# `|`는 윤정님이 정제하기 전엔 살아있을 수 있어 옵셔널 처리.
|
| 47 |
+
_HEADER_RE = re.compile(
|
| 48 |
+
r"^\s*(?P<header>" + "|".join(re.escape(k) for k in _HEADER_KEYWORDS_SORTED) + r")"
|
| 49 |
+
r"\s*[|::]?\s*"
|
| 50 |
+
r"(?P<value>.+)$",
|
| 51 |
+
re.DOTALL,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def split_header_value(text: str) -> tuple[str | None, str]:
|
| 56 |
+
"""todo.text → (헤더, 값) 페어.
|
| 57 |
+
|
| 58 |
+
매칭 실패 시 (None, text.strip()) 반환 — 호출부에서 "기타" 같은 fallback 헤더 처리.
|
| 59 |
+
"""
|
| 60 |
+
if not text or not text.strip():
|
| 61 |
+
return None, ""
|
| 62 |
+
m = _HEADER_RE.match(text.strip())
|
| 63 |
+
if m:
|
| 64 |
+
return m.group("header"), m.group("value").strip()
|
| 65 |
+
return None, text.strip()
|
backend/app/services/mock.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""모델 연결 전 mock 응답 데이터.
|
| 2 |
+
|
| 3 |
+
extract_todos 실패/빈결과 시 fallback. 슬롯 파이프라인에서는 이 mock 도
|
| 4 |
+
[3] YunjeongTodo → [4] 경이님 분류 → [6] AnalyzeItem 흐름을 그대로 통과한다.
|
| 5 |
+
"""
|
| 6 |
+
from app.models.schemas import YunjeongTodo
|
| 7 |
+
|
| 8 |
+
MOCK_TODOS: list[YunjeongTodo] = [
|
| 9 |
+
YunjeongTodo(
|
| 10 |
+
text="현장체험학습 동의서를 내일까지 제출해주세요",
|
| 11 |
+
due_date="내일",
|
| 12 |
+
amount=None,
|
| 13 |
+
confidence=0.95,
|
| 14 |
+
action_hint="제출",
|
| 15 |
+
),
|
| 16 |
+
YunjeongTodo(
|
| 17 |
+
text="도시락, 개인 물병, 돗자리를 준비해주세요",
|
| 18 |
+
due_date=None,
|
| 19 |
+
amount=None,
|
| 20 |
+
confidence=0.9,
|
| 21 |
+
action_hint="준비",
|
| 22 |
+
),
|
| 23 |
+
YunjeongTodo(
|
| 24 |
+
text="체험학습비 15,000원을 4월 22일까지 납부해주세요",
|
| 25 |
+
due_date="2026-04-22",
|
| 26 |
+
amount=15000,
|
| 27 |
+
confidence=0.92,
|
| 28 |
+
action_hint="납부",
|
| 29 |
+
),
|
| 30 |
+
YunjeongTodo(
|
| 31 |
+
text="4월 25일(금) 봄 소풍이 있습니다",
|
| 32 |
+
due_date="2026-04-25",
|
| 33 |
+
amount=None,
|
| 34 |
+
confidence=0.78,
|
| 35 |
+
action_hint="참여",
|
| 36 |
+
),
|
| 37 |
+
YunjeongTodo(
|
| 38 |
+
text="미세먼지 심한 날에는 마스크를 착용해주세요",
|
| 39 |
+
due_date=None,
|
| 40 |
+
amount=None,
|
| 41 |
+
confidence=0.7,
|
| 42 |
+
action_hint="확인",
|
| 43 |
+
),
|
| 44 |
+
]
|
backend/app/services/parser.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HWP/PDF/text 입력 → clean_text 변환.
|
| 2 |
+
|
| 3 |
+
파이프라인 [1] 단계. 호스트 앱이 어떤 양식으로 보내든 백엔드가 텍스트로 흡수.
|
| 4 |
+
|
| 5 |
+
지원:
|
| 6 |
+
- text (text/plain) → 그대로
|
| 7 |
+
- PDF (application/pdf) → pdfplumber 본문 + 표 [표] 섹션
|
| 8 |
+
- HWP/HWPX → LibreOffice + H2Orestart로 ODT 변환 → content.xml 직접 파싱
|
| 9 |
+
|
| 10 |
+
이미지(.jpg/.png) OCR은 별도 단계 (세종님 OCR 합류 시 추가).
|
| 11 |
+
|
| 12 |
+
ODT 경로 채택 이유 (vs 이전 HWP→PDF):
|
| 13 |
+
HWP→PDF→pdfplumber는 LibreOffice가 텍스트를 두 번 그려 글자가 중복
|
| 14 |
+
추출되는 문제 ("22002266학학년년도도"). ODT(zip+content.xml)는 구조화된
|
| 15 |
+
단일 출력이라 중복 0. 검증: hwp5txt 28b 실패, docx 0c 실패, odt 1899c
|
| 16 |
+
키워드 6/6 보존.
|
| 17 |
+
|
| 18 |
+
보안 모델:
|
| 19 |
+
- 원본 filename은 .suffix 추출에만 사용. 추출된 suffix는 화이트리스트 검사
|
| 20 |
+
(TEXT_EXTS / PDF_EXTS / HWP_EXTS) 통과 못 하면 ParserError로 즉시 거부.
|
| 21 |
+
- 디스크 저장 경로는 tempfile.TemporaryDirectory + 고정 이름 input{suffix}.
|
| 22 |
+
원본 filename은 어떤 경로/명령에도 사용되지 않음.
|
| 23 |
+
- subprocess 호출은 list 인자 형태(쉘 미사용) → 명령 주입 표면 없음.
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import os
|
| 28 |
+
import re
|
| 29 |
+
import subprocess
|
| 30 |
+
import tempfile
|
| 31 |
+
import xml.etree.ElementTree as ET
|
| 32 |
+
import zipfile
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
# pdfplumber는 외부 의존이라 CI/테스트 안전하게 가드.
|
| 36 |
+
# 운영에선 requirements.txt + Dockerfile로 보장. 부재면 첫 PDF 호출 시점에 명확한 메시지.
|
| 37 |
+
try:
|
| 38 |
+
import pdfplumber # type: ignore
|
| 39 |
+
except ImportError as error:
|
| 40 |
+
print(f"[parser] pdfplumber unavailable: {error}")
|
| 41 |
+
pdfplumber = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
PDF_EXTS = {".pdf"}
|
| 45 |
+
HWP_EXTS = {".hwp", ".hwpx"}
|
| 46 |
+
TEXT_EXTS = {".txt", ".md"}
|
| 47 |
+
IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
|
| 48 |
+
ALLOWED_EXTS = PDF_EXTS | HWP_EXTS | TEXT_EXTS | IMG_EXTS
|
| 49 |
+
|
| 50 |
+
# LibreOffice 변환 타임아웃 (초). 큰 HWP는 ENV로 오버라이드 가능.
|
| 51 |
+
LIBREOFFICE_TIMEOUT_SECONDS = int(os.environ.get("PARSER_LIBREOFFICE_TIMEOUT", "300"))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ParserError(RuntimeError):
|
| 55 |
+
"""변환 실패 시 호출부가 잡을 수 있는 단일 예외."""
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def normalize(text: str) -> str:
|
| 59 |
+
"""null 제거 + 한 줄 안 다중 공백만 정리. 줄바꿈 보존, 연속 빈 줄은 1개로."""
|
| 60 |
+
text = text.replace("\x00", " ")
|
| 61 |
+
out_lines: list[str] = []
|
| 62 |
+
prev_empty = False
|
| 63 |
+
for line in text.split("\n"):
|
| 64 |
+
line = re.sub(r"[ \t]+", " ", line).strip()
|
| 65 |
+
if not line:
|
| 66 |
+
if not prev_empty:
|
| 67 |
+
out_lines.append(line)
|
| 68 |
+
prev_empty = True
|
| 69 |
+
else:
|
| 70 |
+
out_lines.append(line)
|
| 71 |
+
prev_empty = False
|
| 72 |
+
return "\n".join(out_lines).strip()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _pdf_to_text(pdf_path: Path) -> str:
|
| 76 |
+
"""본문 텍스트(표 영역 제외) + 표(행 단위 정리) 분리."""
|
| 77 |
+
if pdfplumber is None:
|
| 78 |
+
raise ParserError(
|
| 79 |
+
"pdfplumber 미설치. backend 컨테이너 재빌드(docker compose build backend) "
|
| 80 |
+
"또는 pip install pdfplumber 필요."
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
body_pages: list[str] = []
|
| 84 |
+
table_blocks: list[str] = []
|
| 85 |
+
|
| 86 |
+
with pdfplumber.open(pdf_path) as pdf:
|
| 87 |
+
for page in pdf.pages:
|
| 88 |
+
tables = page.extract_tables() or []
|
| 89 |
+
for tbl in tables:
|
| 90 |
+
rows: list[str] = []
|
| 91 |
+
for row in tbl:
|
| 92 |
+
cells = [(c or "").replace("\n", " ").strip() for c in row]
|
| 93 |
+
if any(cells):
|
| 94 |
+
rows.append(" | ".join(cells))
|
| 95 |
+
if rows:
|
| 96 |
+
table_blocks.append("\n".join(rows))
|
| 97 |
+
|
| 98 |
+
table_bboxes = [t.bbox for t in (page.find_tables() or [])]
|
| 99 |
+
if table_bboxes:
|
| 100 |
+
def outside_tables(obj):
|
| 101 |
+
if obj.get("object_type") != "char":
|
| 102 |
+
return True
|
| 103 |
+
cx = (obj["x0"] + obj["x1"]) / 2
|
| 104 |
+
cy = (obj["top"] + obj["bottom"]) / 2
|
| 105 |
+
for bbox in table_bboxes:
|
| 106 |
+
x0, top, x1, bottom = bbox
|
| 107 |
+
if x0 <= cx <= x1 and top <= cy <= bottom:
|
| 108 |
+
return False
|
| 109 |
+
return True
|
| 110 |
+
page_view = page.filter(outside_tables)
|
| 111 |
+
body = page_view.extract_text() or ""
|
| 112 |
+
else:
|
| 113 |
+
body = page.extract_text() or ""
|
| 114 |
+
|
| 115 |
+
if body:
|
| 116 |
+
body_pages.append(body)
|
| 117 |
+
|
| 118 |
+
parts: list[str] = []
|
| 119 |
+
if body_pages:
|
| 120 |
+
parts.append("\n\n".join(body_pages))
|
| 121 |
+
if table_blocks:
|
| 122 |
+
parts.append("[표]\n" + "\n\n".join(table_blocks))
|
| 123 |
+
return "\n\n".join(parts)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _image_to_text(img_path: Path) -> str:
|
| 127 |
+
"""카메라 사진(.jpg/.png) → 텍스트. Tesseract 한국어 OCR.
|
| 128 |
+
|
| 129 |
+
전체 이미지 1차 OCR → 한국어 문자 외 노이즈 정리.
|
| 130 |
+
표 영역 재처리(2차 OCR)는 별도 로직으로 확장 가능.
|
| 131 |
+
"""
|
| 132 |
+
try:
|
| 133 |
+
import pytesseract
|
| 134 |
+
from PIL import Image
|
| 135 |
+
except ImportError as e:
|
| 136 |
+
raise ParserError(f"OCR 의존 미설치: {e}. Docker 재빌드 필요.")
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
img = Image.open(img_path).convert("RGB")
|
| 140 |
+
except Exception as e:
|
| 141 |
+
raise ParserError(f"이미지 열기 실패: {e}")
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
# psm 3: 자동 레이아웃 감지 (표·단락 혼재 가정통신문에 적합)
|
| 145 |
+
text = pytesseract.image_to_string(img, lang="kor", config="--psm 3 --oem 1")
|
| 146 |
+
except Exception as e:
|
| 147 |
+
raise ParserError(f"Tesseract OCR 실패: {e}")
|
| 148 |
+
|
| 149 |
+
return text
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ODT content.xml 네임스페이스
|
| 153 |
+
_ODT_TEXT_NS = "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}"
|
| 154 |
+
_ODT_TABLE_NS = "{urn:oasis:names:tc:opendocument:xmlns:table:1.0}"
|
| 155 |
+
_ODT_DRAW_NS = "{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}"
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _hwp_to_odt(hwp_path: Path, out_dir: Path) -> Path:
|
| 159 |
+
"""LibreOffice headless로 HWP → ODT.
|
| 160 |
+
|
| 161 |
+
HWP→PDF 경로의 doubled-char 문제 회피. ODT는 zip 구조라 본문/표가
|
| 162 |
+
단일 트리에 한 번만 들어감. 타임아웃: PARSER_LIBREOFFICE_TIMEOUT.
|
| 163 |
+
"""
|
| 164 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 165 |
+
try:
|
| 166 |
+
result = subprocess.run(
|
| 167 |
+
[
|
| 168 |
+
"libreoffice", "--headless",
|
| 169 |
+
"--convert-to", "odt",
|
| 170 |
+
"--outdir", str(out_dir),
|
| 171 |
+
str(hwp_path),
|
| 172 |
+
],
|
| 173 |
+
capture_output=True, text=True,
|
| 174 |
+
timeout=LIBREOFFICE_TIMEOUT_SECONDS,
|
| 175 |
+
)
|
| 176 |
+
except subprocess.TimeoutExpired:
|
| 177 |
+
raise ParserError(
|
| 178 |
+
f"LibreOffice 변환 타임아웃 ({LIBREOFFICE_TIMEOUT_SECONDS}초 초과). "
|
| 179 |
+
"큰 HWP면 PARSER_LIBREOFFICE_TIMEOUT 환경변수로 늘릴 수 있음."
|
| 180 |
+
)
|
| 181 |
+
odt_path = out_dir / f"{hwp_path.stem}.odt"
|
| 182 |
+
# H2Orestart가 ODT 변환 후 종료 시점에 종종 Signal 11 (cleanup 버그)을 내지만
|
| 183 |
+
# 출력 파일은 정상. 파일 존재 여부를 성공 기준으로 — returncode/stderr는 참고만.
|
| 184 |
+
if not odt_path.exists():
|
| 185 |
+
raise ParserError(
|
| 186 |
+
f"LibreOffice ODT 변환 실패 (출력 파일 없음). "
|
| 187 |
+
f"returncode={result.returncode}, stderr={result.stderr.strip()[:200]}"
|
| 188 |
+
)
|
| 189 |
+
return odt_path
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _odt_to_text(odt_path: Path, mark_header: bool = False) -> str:
|
| 193 |
+
"""ODT(zip) content.xml → 본문 + 표 영역 평면 텍스트.
|
| 194 |
+
|
| 195 |
+
표 안 paragraph는 본문 처리에서 제외(중복 방지). 표는 셀 단위 공백 합치고
|
| 196 |
+
행 단위 줄바꿈으로 평면화 — `|` 구분자 X, `[표]` 마커 X.
|
| 197 |
+
윤정님 split_sentences가 헤더 키워드 lookahead("운영시간"/"운영방법"/...)로
|
| 198 |
+
행 안에서 의미 단위 자연 분리하므로 셀 구분자 불필요.
|
| 199 |
+
|
| 200 |
+
mark_header=True: 각 표의 첫 번째 행 앞에 "[헤더] " 마킹 + 셀을 " | " 구분.
|
| 201 |
+
기본값 False — 기존 호출부(parse_bytes_to_text, batch_convert.py) 변경 없음.
|
| 202 |
+
"""
|
| 203 |
+
with zipfile.ZipFile(odt_path) as z:
|
| 204 |
+
with z.open("content.xml") as f:
|
| 205 |
+
tree = ET.parse(f)
|
| 206 |
+
|
| 207 |
+
# 표/draw:frame 안 element id 모음 → 본문 처리에서 제외
|
| 208 |
+
# draw:frame: 텍스트 상자/이미지 프레임 — 본문과 같은 텍스트가 중복 저장돼
|
| 209 |
+
# 3배 이상 반복되는 아티팩트 원인. 표 inner 제외와 동일 방식.
|
| 210 |
+
table_inner_ids: set[int] = set()
|
| 211 |
+
for table in tree.iter(_ODT_TABLE_NS + "table"):
|
| 212 |
+
for elem in table.iter():
|
| 213 |
+
table_inner_ids.add(id(elem))
|
| 214 |
+
for frame in tree.iter(_ODT_DRAW_NS + "frame"):
|
| 215 |
+
for elem in frame.iter():
|
| 216 |
+
table_inner_ids.add(id(elem))
|
| 217 |
+
|
| 218 |
+
body_parts: list[str] = []
|
| 219 |
+
for elem in tree.iter():
|
| 220 |
+
tag = elem.tag
|
| 221 |
+
if tag in (_ODT_TEXT_NS + "p", _ODT_TEXT_NS + "h"):
|
| 222 |
+
if id(elem) in table_inner_ids:
|
| 223 |
+
continue
|
| 224 |
+
text = "".join(elem.itertext()).strip()
|
| 225 |
+
if text:
|
| 226 |
+
body_parts.append(text)
|
| 227 |
+
|
| 228 |
+
table_blocks: list[str] = []
|
| 229 |
+
for table in tree.iter(_ODT_TABLE_NS + "table"):
|
| 230 |
+
rows: list[str] = []
|
| 231 |
+
for row_idx, row in enumerate(table.iter(_ODT_TABLE_NS + "table-row")):
|
| 232 |
+
cells: list[str] = []
|
| 233 |
+
for cell in row.iter(_ODT_TABLE_NS + "table-cell"):
|
| 234 |
+
cell_text = "".join(cell.itertext()).strip()
|
| 235 |
+
if cell_text:
|
| 236 |
+
cells.append(cell_text)
|
| 237 |
+
if cells:
|
| 238 |
+
if mark_header and row_idx == 0:
|
| 239 |
+
rows.append("[헤더] " + " | ".join(cells))
|
| 240 |
+
else:
|
| 241 |
+
rows.append(" ".join(cells))
|
| 242 |
+
if rows:
|
| 243 |
+
table_blocks.append("\n".join(rows))
|
| 244 |
+
|
| 245 |
+
parts: list[str] = []
|
| 246 |
+
if body_parts:
|
| 247 |
+
parts.append("\n".join(body_parts))
|
| 248 |
+
if table_blocks:
|
| 249 |
+
parts.append("\n\n".join(table_blocks))
|
| 250 |
+
return "\n\n".join(parts)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def parse_bytes_to_text(data: bytes, filename: str) -> str:
|
| 254 |
+
"""업로드된 bytes + 파일명 → 정규화된 clean_text.
|
| 255 |
+
|
| 256 |
+
호출부(라우터)는 파일 확장자 분기 신경 안 쓰고 이 함수만 부르면 됨.
|
| 257 |
+
"""
|
| 258 |
+
if not data:
|
| 259 |
+
return ""
|
| 260 |
+
|
| 261 |
+
suffix = Path(filename).suffix.lower()
|
| 262 |
+
|
| 263 |
+
# 화이트리스트 검사: 알 수 없는 suffix는 일찍 거부.
|
| 264 |
+
# (subprocess는 어차피 list-form이라 명령 주입은 불가능하지만 표면을 줄임)
|
| 265 |
+
if suffix and suffix not in ALLOWED_EXTS:
|
| 266 |
+
raise ParserError(f"지원하지 않는 파일 형식: {suffix}")
|
| 267 |
+
|
| 268 |
+
if suffix in TEXT_EXTS or suffix == "":
|
| 269 |
+
return normalize(data.decode("utf-8", errors="replace"))
|
| 270 |
+
|
| 271 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 272 |
+
tmp_dir = Path(tmp)
|
| 273 |
+
# 디스크 경로는 항상 tempdir 안의 고정 이름. 원본 filename은 어디에도 안 들어감.
|
| 274 |
+
src_path = tmp_dir / f"input{suffix}"
|
| 275 |
+
src_path.write_bytes(data)
|
| 276 |
+
|
| 277 |
+
if suffix in PDF_EXTS:
|
| 278 |
+
raw = _pdf_to_text(src_path)
|
| 279 |
+
return normalize(raw)
|
| 280 |
+
|
| 281 |
+
if suffix in HWP_EXTS:
|
| 282 |
+
odt_path = _hwp_to_odt(src_path, tmp_dir)
|
| 283 |
+
raw = _odt_to_text(odt_path)
|
| 284 |
+
return normalize(raw)
|
| 285 |
+
|
| 286 |
+
if suffix in IMG_EXTS:
|
| 287 |
+
raw = _image_to_text(src_path)
|
| 288 |
+
return normalize(raw)
|
| 289 |
+
|
| 290 |
+
raise ParserError(f"지원하지 않는 파일 형식: {suffix}")
|
backend/app/services/slot_extractor.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""정규식 + i18n 포매터 기반 슬롯 추출기.
|
| 2 |
+
|
| 3 |
+
강사 처방(2026-04-28) 대응:
|
| 4 |
+
- 날짜·시간·금액 같은 명확한 수치 데이터는 정규식으로 1차 안전 추출
|
| 5 |
+
- LLM 추론에 100% 의존하지 않고 지정된 위치(슬롯)에 매핑
|
| 6 |
+
- NLLB/glossary 거치지 않고 i18n 룰만으로 대상 언어 변환
|
| 7 |
+
|
| 8 |
+
설계:
|
| 9 |
+
extract_dates/extract_times/extract_amounts → 구조화된 dict 리스트
|
| 10 |
+
format_date/format_time/format_amount → 대상 언어 문자열
|
| 11 |
+
extract_summary_regex_slots → 위 둘을 묶어 SlotEntry 호환 dict 반환
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
|
| 17 |
+
# ── 정규식 ────────────────────────────────────────────────────────
|
| 18 |
+
_DATE_FULL = re.compile(
|
| 19 |
+
r"(?P<year>\d{4})\s*년\s*(?P<month>\d{1,2})\s*월\s*(?P<day>\d{1,2})\s*일"
|
| 20 |
+
r"(?:\s*\((?P<wday>[월화수목금토일])\))?"
|
| 21 |
+
)
|
| 22 |
+
_DATE_MD = re.compile(
|
| 23 |
+
r"(?P<month>\d{1,2})\s*월\s*(?P<day>\d{1,2})\s*일"
|
| 24 |
+
r"(?:\s*\((?P<wday>[월화수목금토일])\))?"
|
| 25 |
+
)
|
| 26 |
+
_DATE_SLASH = re.compile(r"\b(?P<month>\d{1,2})\s*[/.]\s*(?P<day>\d{1,2})\b")
|
| 27 |
+
# 점 표기 ("2026. 4. 18.(토)" / "5. 16.(토)") — 요일 필수로 시간·금액 오탐 회피
|
| 28 |
+
_DATE_DOT = re.compile(
|
| 29 |
+
r"(?:(?P<year>\d{4})\s*\.\s*)?"
|
| 30 |
+
r"(?P<month>\d{1,2})\s*\.\s*"
|
| 31 |
+
r"(?P<day>\d{1,2})\s*\.?"
|
| 32 |
+
r"\s*\((?P<wday>[월화수목금토일])\)"
|
| 33 |
+
)
|
| 34 |
+
_DATE_RELATIVE = ["내일", "모레", "오늘", "다음 주", "다음주", "이번 주", "이번주"]
|
| 35 |
+
|
| 36 |
+
_TIME_AMPM = re.compile(
|
| 37 |
+
r"(?P<ampm>오전|오후)\s*(?P<hour>\d{1,2})\s*시(?:\s*(?P<minute>\d{1,2})\s*분)?"
|
| 38 |
+
)
|
| 39 |
+
_TIME_24H = re.compile(r"(?<!\d)(?P<hour>\d{1,2}):(?P<minute>\d{2})(?!\d)")
|
| 40 |
+
|
| 41 |
+
_AMOUNT_KRW = re.compile(r"(?P<num>\d{1,3}(?:,\d{3})+|\d+)\s*원")
|
| 42 |
+
_AMOUNT_KO = re.compile(r"(?P<num>\d+)\s*(?P<unit>만|천|억)\s*원")
|
| 43 |
+
|
| 44 |
+
# URL: http(s)://… 또는 www.… — NLLB가 토큰화하면서 깨먹는 패턴 방지용 슬롯
|
| 45 |
+
_URL = re.compile(r"\bhttps?://[^\s<>\"'()]+|\bwww\.[^\s<>\"'()]+", re.IGNORECASE)
|
| 46 |
+
# 전화번호: 02-xxx-xxxx, 02-xxxx-xxxx, 010-xxxx-xxxx, 1588-0260, 849-7003 등
|
| 47 |
+
# 시작·끝에 숫자 인접 금지 (15,000원 같은 금액 부분 매칭 회피)
|
| 48 |
+
_PHONE = re.compile(
|
| 49 |
+
r"(?<![\d-])"
|
| 50 |
+
r"(?:\d{2,4}-\d{3,4}-\d{4}|\d{4}-\d{4}|\d{3,4}-\d{4})"
|
| 51 |
+
r"(?!\d)"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# 까지 마감 표현: "5월 9일까지", "내일까지", "5월 9일(금)까지 ... 제출"
|
| 55 |
+
# 콤마는 negation에서 제외 — "15,000원 (...까지...)" 같이 숫자 콤마에서 잘리는 버그 회피
|
| 56 |
+
# 한글 프로 마커(❍❏|※)도 종결자 — pdfplumber 본문이 한 줄로 들어와도 항목 단위로 끊김
|
| 57 |
+
_DEADLINE_PHRASE = re.compile(r"([^.\n❍❏|※]*?까지[^.\n❍❏|※]*?)(?=[.\n❍❏|※]|$)")
|
| 58 |
+
|
| 59 |
+
# 안내문 줄머리 장식 마크업 (■ ▶ ▸ etc.) — items/슬롯 추출 전 strip
|
| 60 |
+
_MARKER_STRIP = re.compile(r"^[\s■▶▸◆●○*\-•]+")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def strip_markers(text: str) -> str:
|
| 64 |
+
"""줄머리 장식 마크업 제거. UI 표시·TTS 양쪽 노이즈 방지."""
|
| 65 |
+
return _MARKER_STRIP.sub("", text).strip()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ── 추출기 ────────────────────────────────────────────────────────
|
| 69 |
+
def extract_dates(text: str) -> list[dict]:
|
| 70 |
+
"""텍스트에서 날짜 후보를 모두 뽑는다. 중복은 표면형 기준으로 제거."""
|
| 71 |
+
out: list[dict] = []
|
| 72 |
+
seen: set[str] = set()
|
| 73 |
+
|
| 74 |
+
for m in _DATE_FULL.finditer(text):
|
| 75 |
+
ko = m.group(0).strip()
|
| 76 |
+
if ko in seen:
|
| 77 |
+
continue
|
| 78 |
+
seen.add(ko)
|
| 79 |
+
out.append({
|
| 80 |
+
"ko": ko,
|
| 81 |
+
"year": int(m.group("year")),
|
| 82 |
+
"month": int(m.group("month")),
|
| 83 |
+
"day": int(m.group("day")),
|
| 84 |
+
"weekday": m.group("wday"),
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
for m in _DATE_MD.finditer(text):
|
| 88 |
+
# 이미 _DATE_FULL이 잡은 영역과 겹치면 스킵
|
| 89 |
+
if any(s in text and m.start() >= text.find(s) and m.end() <= text.find(s) + len(s) for s in seen):
|
| 90 |
+
continue
|
| 91 |
+
ko = m.group(0).strip()
|
| 92 |
+
if ko in seen:
|
| 93 |
+
continue
|
| 94 |
+
seen.add(ko)
|
| 95 |
+
out.append({
|
| 96 |
+
"ko": ko,
|
| 97 |
+
"year": None,
|
| 98 |
+
"month": int(m.group("month")),
|
| 99 |
+
"day": int(m.group("day")),
|
| 100 |
+
"weekday": m.group("wday"),
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
for m in _DATE_DOT.finditer(text):
|
| 104 |
+
ko = m.group(0).strip()
|
| 105 |
+
if ko in seen:
|
| 106 |
+
continue
|
| 107 |
+
seen.add(ko)
|
| 108 |
+
out.append({
|
| 109 |
+
"ko": ko,
|
| 110 |
+
"year": int(m.group("year")) if m.group("year") else None,
|
| 111 |
+
"month": int(m.group("month")),
|
| 112 |
+
"day": int(m.group("day")),
|
| 113 |
+
"weekday": m.group("wday"),
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
for word in _DATE_RELATIVE:
|
| 117 |
+
if word in text and word not in seen:
|
| 118 |
+
seen.add(word)
|
| 119 |
+
out.append({"ko": word, "year": None, "month": None, "day": None,
|
| 120 |
+
"weekday": None, "relative": word})
|
| 121 |
+
|
| 122 |
+
return out
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def extract_times(text: str) -> list[dict]:
|
| 126 |
+
out: list[dict] = []
|
| 127 |
+
seen: set[str] = set()
|
| 128 |
+
for m in _TIME_AMPM.finditer(text):
|
| 129 |
+
ko = m.group(0).strip()
|
| 130 |
+
if ko in seen:
|
| 131 |
+
continue
|
| 132 |
+
seen.add(ko)
|
| 133 |
+
out.append({
|
| 134 |
+
"ko": ko,
|
| 135 |
+
"hour": int(m.group("hour")),
|
| 136 |
+
"minute": int(m.group("minute")) if m.group("minute") else 0,
|
| 137 |
+
"ampm": m.group("ampm"),
|
| 138 |
+
})
|
| 139 |
+
for m in _TIME_24H.finditer(text):
|
| 140 |
+
ko = m.group(0).strip()
|
| 141 |
+
if ko in seen:
|
| 142 |
+
continue
|
| 143 |
+
seen.add(ko)
|
| 144 |
+
out.append({
|
| 145 |
+
"ko": ko,
|
| 146 |
+
"hour": int(m.group("hour")),
|
| 147 |
+
"minute": int(m.group("minute")),
|
| 148 |
+
"ampm": None,
|
| 149 |
+
})
|
| 150 |
+
return out
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def extract_amounts(text: str) -> list[dict]:
|
| 154 |
+
"""원화 금액을 정규화된 정수와 함께 반환."""
|
| 155 |
+
out: list[dict] = []
|
| 156 |
+
seen: set[str] = set()
|
| 157 |
+
|
| 158 |
+
for m in _AMOUNT_KRW.finditer(text):
|
| 159 |
+
ko = m.group(0).strip()
|
| 160 |
+
if ko in seen:
|
| 161 |
+
continue
|
| 162 |
+
seen.add(ko)
|
| 163 |
+
num = int(m.group("num").replace(",", ""))
|
| 164 |
+
out.append({"ko": ko, "value": num, "currency": "KRW"})
|
| 165 |
+
|
| 166 |
+
for m in _AMOUNT_KO.finditer(text):
|
| 167 |
+
ko = m.group(0).strip()
|
| 168 |
+
if ko in seen:
|
| 169 |
+
continue
|
| 170 |
+
seen.add(ko)
|
| 171 |
+
n = int(m.group("num"))
|
| 172 |
+
unit = m.group("unit")
|
| 173 |
+
mul = {"천": 1_000, "만": 10_000, "억": 100_000_000}[unit]
|
| 174 |
+
out.append({"ko": ko, "value": n * mul, "currency": "KRW"})
|
| 175 |
+
|
| 176 |
+
return out
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def extract_deadline_phrases(text: str) -> list[str]:
|
| 180 |
+
"""'까지'가 들어간 어구를 한 문장 단위로 추출.
|
| 181 |
+
|
| 182 |
+
URL 안 마침표(`.do`/`.kr` 등)에서 phrase가 잘려 무의미하게 길어지는 것 회피 —
|
| 183 |
+
매칭 전 URL을 placeholder로 마스킹 후 매칭.
|
| 184 |
+
"""
|
| 185 |
+
masked = _URL.sub(lambda m: "⟦U" + ("_" * (len(m.group(0)) - 3)) + "⟧", text)
|
| 186 |
+
return [m.group(1).strip() for m in _DEADLINE_PHRASE.finditer(masked)]
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def extract_urls(text: str) -> list[str]:
|
| 190 |
+
"""URL 표면형 그대로. NLLB로 보내지 말고 슬롯으로 격리."""
|
| 191 |
+
seen: set[str] = set()
|
| 192 |
+
out: list[str] = []
|
| 193 |
+
for m in _URL.finditer(text):
|
| 194 |
+
ko = m.group(0).strip().rstrip(".,)]")
|
| 195 |
+
if ko in seen:
|
| 196 |
+
continue
|
| 197 |
+
seen.add(ko)
|
| 198 |
+
out.append(ko)
|
| 199 |
+
return out
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def extract_phones(text: str) -> list[str]:
|
| 203 |
+
"""전화번호 표면형. 같은 이유로 슬롯 격리."""
|
| 204 |
+
seen: set[str] = set()
|
| 205 |
+
out: list[str] = []
|
| 206 |
+
for m in _PHONE.finditer(text):
|
| 207 |
+
ko = m.group(0).strip()
|
| 208 |
+
if ko in seen:
|
| 209 |
+
continue
|
| 210 |
+
seen.add(ko)
|
| 211 |
+
out.append(ko)
|
| 212 |
+
return out
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ── 다국어 포매터 ─────────────────────────────────────────────────
|
| 216 |
+
# 베트남어가 1차 시연 타깃이라 가장 정교하게. 나머지 언어는 안전한 디폴트.
|
| 217 |
+
_WEEKDAY_VI = {"월": "Thứ Hai", "화": "Thứ Ba", "수": "Thứ Tư", "목": "Thứ Năm",
|
| 218 |
+
"금": "Thứ Sáu", "토": "Thứ Bảy", "일": "Chủ Nhật"}
|
| 219 |
+
_WEEKDAY_EN = {"월": "Mon", "화": "Tue", "수": "Wed", "목": "Thu",
|
| 220 |
+
"금": "Fri", "토": "Sat", "일": "Sun"}
|
| 221 |
+
_RELATIVE_MAP = {
|
| 222 |
+
"vi": {"내일": "Ngày mai", "모레": "Ngày kia", "오늘": "Hôm nay",
|
| 223 |
+
"다음 주": "Tuần sau", "다음주": "Tuần sau",
|
| 224 |
+
"이번 주": "Tuần này", "이번주": "Tuần này"},
|
| 225 |
+
"en": {"내일": "Tomorrow", "모레": "Day after tomorrow", "오늘": "Today",
|
| 226 |
+
"다음 주": "Next week", "다음주": "Next week",
|
| 227 |
+
"이번 주": "This week", "이번주": "This week"},
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def format_date(d: dict, target_lang: str) -> str:
|
| 232 |
+
if d.get("relative"):
|
| 233 |
+
return _RELATIVE_MAP.get(target_lang, {}).get(d["relative"], d["ko"])
|
| 234 |
+
y, m, day, w = d.get("year"), d.get("month"), d.get("day"), d.get("weekday")
|
| 235 |
+
if not (m and day):
|
| 236 |
+
return d["ko"]
|
| 237 |
+
if target_lang == "vi":
|
| 238 |
+
s = f"Ngày {day}/{m}" + (f"/{y}" if y else "")
|
| 239 |
+
if w:
|
| 240 |
+
s += f" ({_WEEKDAY_VI[w]})"
|
| 241 |
+
return s
|
| 242 |
+
if target_lang == "en":
|
| 243 |
+
months = ["", "January", "February", "March", "April", "May", "June",
|
| 244 |
+
"July", "August", "September", "October", "November", "December"]
|
| 245 |
+
s = f"{months[m]} {day}" + (f", {y}" if y else "")
|
| 246 |
+
if w:
|
| 247 |
+
s += f" ({_WEEKDAY_EN[w]})"
|
| 248 |
+
return s
|
| 249 |
+
if target_lang in ("zh", "ja"):
|
| 250 |
+
return f"{m}月{day}日" + (f" ({w})" if w else "")
|
| 251 |
+
if target_lang == "ko_easy":
|
| 252 |
+
return d["ko"] # 그대로
|
| 253 |
+
# ru / ms / th / mn 등은 일단 안전한 숫자 포맷
|
| 254 |
+
return f"{day}/{m}" + (f"/{y}" if y else "")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def format_time(t: dict, target_lang: str) -> str:
|
| 258 |
+
h, mm, ampm = t.get("hour"), t.get("minute") or 0, t.get("ampm")
|
| 259 |
+
if h is None:
|
| 260 |
+
return t["ko"]
|
| 261 |
+
if target_lang == "vi":
|
| 262 |
+
suffix = ""
|
| 263 |
+
if ampm == "오전":
|
| 264 |
+
suffix = " sáng"
|
| 265 |
+
elif ampm == "오후":
|
| 266 |
+
suffix = " chiều"
|
| 267 |
+
return f"{h} giờ{(' ' + str(mm)) if mm else ''}{suffix}".strip()
|
| 268 |
+
if target_lang == "en":
|
| 269 |
+
ap = "AM" if ampm == "오전" else "PM" if ampm == "오후" else ""
|
| 270 |
+
return f"{h}:{mm:02d} {ap}".strip() if ap else f"{h}:{mm:02d}"
|
| 271 |
+
if target_lang in ("zh", "ja"):
|
| 272 |
+
prefix = "上午" if ampm == "오전" else "下午" if ampm == "오후" else ""
|
| 273 |
+
if target_lang == "ja":
|
| 274 |
+
prefix = "午前" if ampm == "오전" else "午後" if ampm == "오후" else ""
|
| 275 |
+
body = f"{h}時{mm:02d}分" if target_lang == "ja" else f"{h}时{mm:02d}分"
|
| 276 |
+
return f"{prefix}{body}"
|
| 277 |
+
if target_lang == "ko_easy":
|
| 278 |
+
return t["ko"]
|
| 279 |
+
return f"{h}:{mm:02d}"
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def format_amount(a: dict, target_lang: str) -> str:
|
| 283 |
+
n = a["value"]
|
| 284 |
+
# 천단위 콤마. (베트남식 점 표기는 _post_process_vi와 충돌하므로 콤마로 통일)
|
| 285 |
+
formatted = f"{n:,}"
|
| 286 |
+
suffix = {
|
| 287 |
+
"vi": " won", "en": " won", "ms": " won", "ru": " вон", "mn": " вон",
|
| 288 |
+
"th": " วอน", "zh": "韩元", "ja": "ウォン", "ko_easy": "원",
|
| 289 |
+
}.get(target_lang, " won")
|
| 290 |
+
return f"{formatted}{suffix}"
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ── 통합 진입점 ──────────────────────────────────────────────────
|
| 294 |
+
def extract_summary_regex_slots(text: str, target_lang: str) -> dict[str, list[dict]]:
|
| 295 |
+
"""summary 슬롯 중 정규식으로 채울 수 있는 항목들을 SlotEntry-ready dict로.
|
| 296 |
+
|
| 297 |
+
urls/phones는 번역 안 거치고 ko 그대로 노출 (NLLB가 깨먹는 패턴 방어).
|
| 298 |
+
"""
|
| 299 |
+
out: dict[str, list[dict]] = {
|
| 300 |
+
"dates": [], "times": [], "amounts": [], "urls": [], "phones": [],
|
| 301 |
+
}
|
| 302 |
+
for d in extract_dates(text):
|
| 303 |
+
out["dates"].append({
|
| 304 |
+
"ko": d["ko"],
|
| 305 |
+
"translated": format_date(d, target_lang),
|
| 306 |
+
"source": "regex",
|
| 307 |
+
})
|
| 308 |
+
for t in extract_times(text):
|
| 309 |
+
out["times"].append({
|
| 310 |
+
"ko": t["ko"],
|
| 311 |
+
"translated": format_time(t, target_lang),
|
| 312 |
+
"source": "regex",
|
| 313 |
+
})
|
| 314 |
+
for a in extract_amounts(text):
|
| 315 |
+
out["amounts"].append({
|
| 316 |
+
"ko": a["ko"],
|
| 317 |
+
"translated": format_amount(a, target_lang),
|
| 318 |
+
"source": "regex",
|
| 319 |
+
})
|
| 320 |
+
for url in extract_urls(text):
|
| 321 |
+
out["urls"].append({"ko": url, "translated": url, "source": "regex"})
|
| 322 |
+
for phone in extract_phones(text):
|
| 323 |
+
out["phones"].append({"ko": phone, "translated": phone, "source": "regex"})
|
| 324 |
+
return out
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def find_when_in_text(text: str, target_lang: str) -> str | None:
|
| 328 |
+
"""item.when 용 — 텍스트에서 첫 날짜 + 시간 조합."""
|
| 329 |
+
dates = extract_dates(text)
|
| 330 |
+
times = extract_times(text)
|
| 331 |
+
parts: list[str] = []
|
| 332 |
+
if dates:
|
| 333 |
+
parts.append(format_date(dates[0], target_lang))
|
| 334 |
+
if times:
|
| 335 |
+
parts.append(format_time(times[0], target_lang))
|
| 336 |
+
return " ".join(parts) if parts else None
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def find_amount_in_text(text: str, target_lang: str) -> str | None:
|
| 340 |
+
"""item.amount 용 — 텍스트의 첫 금액(원문 ko 그대로 반환, 안드 표시는 summary 통해 i18n)."""
|
| 341 |
+
amounts = extract_amounts(text)
|
| 342 |
+
return amounts[0]["ko"] if amounts else None
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def find_deadline_in_text(text: str) -> str | None:
|
| 346 |
+
"""item.deadline 용 — '까지'가 들어간 첫 어구의 ko 표면형."""
|
| 347 |
+
phrases = extract_deadline_phrases(text)
|
| 348 |
+
return phrases[0] if phrases else None
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# ── 준비물 리스트 분해 ────────────────────────────────────────────
|
| 352 |
+
# "도시락, 물통, 돗자리, 편한 운동화, 여벌 옷" → 5개 토큰
|
| 353 |
+
_LIST_SEP = re.compile(r"[,,、]\s*|\s*(?:과|와|및)\s+")
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def split_supply_tokens(text: str) -> list[str]:
|
| 357 |
+
"""카테고리=준비물 todo의 텍스트에서 항목 토큰 분리.
|
| 358 |
+
|
| 359 |
+
안내 문구('준비물:', '준비해 주세요' 등)는 제거 후 분리.
|
| 360 |
+
"""
|
| 361 |
+
cleaned = re.sub(r"준비물\s*[::]?\s*", "", text)
|
| 362 |
+
cleaned = re.sub(r"(을|를)?\s*(준비해|챙겨|가져).*$", "", cleaned)
|
| 363 |
+
tokens = [t.strip() for t in _LIST_SEP.split(cleaned) if t and t.strip()]
|
| 364 |
+
return [t for t in tokens if 1 < len(t) < 30]
|
backend/app/services/translator.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""세종님 NLLB 번역 + 용어 검수 wrapper.
|
| 2 |
+
|
| 3 |
+
run_mvp_pipeline.py의 가벼운 함수들(easy_korean, glossary)은 직접 호출.
|
| 4 |
+
NLLB 번역은 매번 모델 새로 로드하지 않게 캐싱.
|
| 5 |
+
|
| 6 |
+
URL/전화는 NLLB가 토큰화하면서 깨먹는 패턴이라 placeholder 치환 + 복원으로 보호.
|
| 7 |
+
세종님 요청(2026-04-29).
|
| 8 |
+
"""
|
| 9 |
+
import re
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
| 15 |
+
|
| 16 |
+
from app.services.slot_extractor import _PHONE, _URL
|
| 17 |
+
|
| 18 |
+
_TRANSLATION_DIR = Path("/app/external_model/translation_tts")
|
| 19 |
+
if str(_TRANSLATION_DIR) not in sys.path:
|
| 20 |
+
sys.path.insert(0, str(_TRANSLATION_DIR))
|
| 21 |
+
|
| 22 |
+
# 외부 마운트가 없는 환경(CI/테스트)에서도 모듈 로드는 성공해야 한다.
|
| 23 |
+
try:
|
| 24 |
+
import run_mvp_pipeline as _sejong # noqa: E402
|
| 25 |
+
except ImportError as error:
|
| 26 |
+
print(f"[translator] run_mvp_pipeline unavailable: {error}")
|
| 27 |
+
_sejong = None
|
| 28 |
+
|
| 29 |
+
NLLB_MODEL_NAME = "facebook/nllb-200-distilled-600M"
|
| 30 |
+
SOURCE_LANG = "kor_Hang"
|
| 31 |
+
MAX_TRANSLATE_CHARS = 100
|
| 32 |
+
|
| 33 |
+
# 안드 언어 코드 → NLLB FLORES-200 코드
|
| 34 |
+
LANG_TO_NLLB = {
|
| 35 |
+
"vi": "vie_Latn",
|
| 36 |
+
"en": "eng_Latn",
|
| 37 |
+
"ru": "rus_Cyrl",
|
| 38 |
+
"ms": "zsm_Latn",
|
| 39 |
+
"mn": "khk_Cyrl",
|
| 40 |
+
"zh": "zho_Hans",
|
| 41 |
+
"th": "tha_Thai",
|
| 42 |
+
"ja": "jpn_Jpan",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
_tokenizer = None
|
| 46 |
+
_model = None
|
| 47 |
+
# 사전 raw rows 단일 캐시. 언어별 분기는 find_glossary_hits 호출 시 target_lang 인자로 처리.
|
| 48 |
+
_glossary_rows: list | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _get_translator():
|
| 52 |
+
global _tokenizer, _model
|
| 53 |
+
if _model is None:
|
| 54 |
+
_tokenizer = AutoTokenizer.from_pretrained(NLLB_MODEL_NAME, src_lang=SOURCE_LANG)
|
| 55 |
+
_model = AutoModelForSeq2SeqLM.from_pretrained(NLLB_MODEL_NAME)
|
| 56 |
+
_model.eval()
|
| 57 |
+
return _tokenizer, _model
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _get_glossary():
|
| 61 |
+
"""raw 사전 rows를 1회 로드. 언어별 컬럼 선택은 find_glossary_hits에서 처리."""
|
| 62 |
+
global _glossary_rows
|
| 63 |
+
if _glossary_rows is None:
|
| 64 |
+
if _sejong is None:
|
| 65 |
+
_glossary_rows = []
|
| 66 |
+
return _glossary_rows
|
| 67 |
+
try:
|
| 68 |
+
_glossary_rows = _sejong.read_glossary(_TRANSLATION_DIR / "term_glossary.csv")
|
| 69 |
+
except Exception as error:
|
| 70 |
+
print(f"[translator] glossary load failed: {error}")
|
| 71 |
+
_glossary_rows = []
|
| 72 |
+
return _glossary_rows
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _translate(text: str, target_nllb: str = "vie_Latn", max_length: int = 512) -> str:
|
| 76 |
+
tokenizer, model = _get_translator()
|
| 77 |
+
target_id = tokenizer.convert_tokens_to_ids(target_nllb)
|
| 78 |
+
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
|
| 79 |
+
with torch.no_grad():
|
| 80 |
+
out = model.generate(
|
| 81 |
+
**inputs,
|
| 82 |
+
forced_bos_token_id=target_id,
|
| 83 |
+
max_length=max_length,
|
| 84 |
+
num_beams=4,
|
| 85 |
+
no_repeat_ngram_size=3,
|
| 86 |
+
repetition_penalty=1.3,
|
| 87 |
+
early_stopping=True,
|
| 88 |
+
)
|
| 89 |
+
return tokenizer.batch_decode(out, skip_special_tokens=True)[0]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# 한국어 원문 → 베트남어 번역 결과의 명백한 오번역 강제 치환.
|
| 93 |
+
# NLLB가 학교 도메인을 못 배워서 발생하는 시각적 결함을 시연 전에 막는 안전망.
|
| 94 |
+
_CURRENCY_PATTERNS = [
|
| 95 |
+
re.compile(r"\bđô\s*la\b", re.IGNORECASE),
|
| 96 |
+
re.compile(r"\bdollars?\b", re.IGNORECASE),
|
| 97 |
+
re.compile(r"\bUSD\b"),
|
| 98 |
+
]
|
| 99 |
+
# 천단위 점(40.000) → 콤마(40,000). 베트남식 표기지만 한국 학부모는 "40원"으로 오인할 수 있음.
|
| 100 |
+
_THOUSAND_DOT = re.compile(r"(\d{1,3}(?:\.\d{3})+)")
|
| 101 |
+
_KRW_AMOUNT = re.compile(r"\d[\d,]*\s*원")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _normalize_thousand_separator(text: str) -> str:
|
| 105 |
+
def repl(m):
|
| 106 |
+
return m.group(1).replace(".", ",")
|
| 107 |
+
return _THOUSAND_DOT.sub(repl, text)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _post_process_vi(easy_ko: str, vi_text: str) -> str:
|
| 111 |
+
if not vi_text:
|
| 112 |
+
return vi_text
|
| 113 |
+
if _KRW_AMOUNT.search(easy_ko):
|
| 114 |
+
for pat in _CURRENCY_PATTERNS:
|
| 115 |
+
vi_text = pat.sub("won", vi_text)
|
| 116 |
+
vi_text = _normalize_thousand_separator(vi_text)
|
| 117 |
+
return vi_text
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# OCR 변환 과정에서 생기는 특수문자 제거. HWP 체크박스/불릿이 □·▣ 등으로 깨지는 패턴.
|
| 121 |
+
_OCR_NOISE = re.compile(r"[□■▣▷◆◇▶◀►◄■-◿`]+")
|
| 122 |
+
_MULTI_SPACE = re.compile(r"[ \t]{2,}")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _clean_for_translation(text: str) -> str:
|
| 126 |
+
"""NLLB 입력 전 OCR 잔여 특수문자를 제거한다."""
|
| 127 |
+
text = _OCR_NOISE.sub(" ", text)
|
| 128 |
+
text = _MULTI_SPACE.sub(" ", text)
|
| 129 |
+
return text.strip()
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# URL/전화 보호 — NLLB가 깨먹는 패턴 방어. 한국어 입력에 안 등장하는 unicode bracket으로
|
| 133 |
+
# 치환하고 번역 후 복원. ⟦…⟧는 NLLB가 분해하지 않는 안전 토큰.
|
| 134 |
+
_PROTECT_TOKEN = re.compile(r"⟦P(\d+)⟧")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _mask_protected_entities(text: str) -> tuple[str, list[str]]:
|
| 138 |
+
"""URL/전화 → ⟦P0⟧ 등 토큰. (masked, originals) 반환."""
|
| 139 |
+
placeholders: list[str] = []
|
| 140 |
+
|
| 141 |
+
def stash(match: re.Match) -> str:
|
| 142 |
+
placeholders.append(match.group(0))
|
| 143 |
+
return f"⟦P{len(placeholders) - 1}⟧"
|
| 144 |
+
|
| 145 |
+
masked = _URL.sub(stash, text)
|
| 146 |
+
masked = _PHONE.sub(stash, masked)
|
| 147 |
+
return masked, placeholders
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _restore_protected_entities(text: str, placeholders: list[str]) -> str:
|
| 151 |
+
if not placeholders:
|
| 152 |
+
return text
|
| 153 |
+
|
| 154 |
+
def restore(match: re.Match) -> str:
|
| 155 |
+
idx = int(match.group(1))
|
| 156 |
+
return placeholders[idx] if idx < len(placeholders) else match.group(0)
|
| 157 |
+
|
| 158 |
+
return _PROTECT_TOKEN.sub(restore, text)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _is_url_or_phone(text: str) -> bool:
|
| 162 |
+
"""슬롯 단위 번역 시 URL/전화면 NLLB 안 거치고 ko 그대로 반환하기 위한 가드."""
|
| 163 |
+
s = text.strip()
|
| 164 |
+
return bool(_URL.fullmatch(s) or _PHONE.fullmatch(s))
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def translate_term(text: str, target_lang: str) -> str:
|
| 168 |
+
"""glossary 직접 치환 (summary 슬롯용 — places, supplies, deadlines).
|
| 169 |
+
|
| 170 |
+
exact match 우선. 없으면 한국어 원문 그대로 반환 (빈 문자열 금지).
|
| 171 |
+
고유명사("서울숲 생태체험관")처럼 사전에 없으면 한국어 노출이 NLLB 오역보다 낫다.
|
| 172 |
+
URL/전화는 어떤 언어든 ko 그대로 (방어적 가드).
|
| 173 |
+
"""
|
| 174 |
+
if not text or not text.strip():
|
| 175 |
+
return text
|
| 176 |
+
if target_lang == "ko_easy" or _is_url_or_phone(text):
|
| 177 |
+
return text
|
| 178 |
+
|
| 179 |
+
glossary = _get_glossary()
|
| 180 |
+
term = text.strip()
|
| 181 |
+
for row in glossary:
|
| 182 |
+
if row.get("korean", "").strip() == term:
|
| 183 |
+
translated = row.get(f"preferred_{target_lang}", "").strip()
|
| 184 |
+
if translated:
|
| 185 |
+
return translated
|
| 186 |
+
return text # Korean passthrough
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def translate_short_sentence(text: str, target_lang: str) -> str:
|
| 190 |
+
"""짧은 문장 NLLB 번역 (items[].title_translated용).
|
| 191 |
+
|
| 192 |
+
URL/전화 보호 → glossary injection → NLLB → 보호 토큰 복원 → vi post-process.
|
| 193 |
+
실패 시 빈 문자열 반환 (호출부가 fallback 처리).
|
| 194 |
+
"""
|
| 195 |
+
if not text or not text.strip():
|
| 196 |
+
return ""
|
| 197 |
+
if target_lang == "ko_easy":
|
| 198 |
+
return text
|
| 199 |
+
text = _clean_for_translation(text)[:MAX_TRANSLATE_CHARS]
|
| 200 |
+
|
| 201 |
+
# 1) URL/전화 placeholder 치환 — NLLB가 깨먹지 못하게 격리
|
| 202 |
+
masked, placeholders = _mask_protected_entities(text)
|
| 203 |
+
|
| 204 |
+
# 2) glossary injection (긴 용어 먼저 치환해야 부분 치환 충돌 방지)
|
| 205 |
+
glossary = _get_glossary()
|
| 206 |
+
hits = _sejong.find_glossary_hits(masked, glossary, target_lang) if _sejong else []
|
| 207 |
+
injected = masked
|
| 208 |
+
for hit in sorted(hits, key=lambda h: len(h["korean"]), reverse=True):
|
| 209 |
+
injected = injected.replace(
|
| 210 |
+
hit["korean"], f"{hit['korean']}({hit['preferred_term']})"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
target_nllb = LANG_TO_NLLB.get(target_lang, "vie_Latn")
|
| 214 |
+
try:
|
| 215 |
+
translated = _translate(injected, target_nllb=target_nllb)
|
| 216 |
+
if target_lang == "vi":
|
| 217 |
+
translated = _post_process_vi(text, translated)
|
| 218 |
+
except Exception as error:
|
| 219 |
+
print(f"[translator] translate_short_sentence failed: {error}")
|
| 220 |
+
return ""
|
| 221 |
+
|
| 222 |
+
# 3) 보호 토큰 복원
|
| 223 |
+
return _restore_protected_entities(translated, placeholders)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# DEPRECATED: 아래 함수는 단일 blob 번역 구조. 새 API(translate_term / translate_short_sentence)로 전환 후 제거 예정.
|
| 227 |
+
def translate_and_review(notice_text: str, target_lang: str = "vi") -> dict:
|
| 228 |
+
"""[DEPRECATED] 가정통신문 → easy_ko + 다국어 번역 + 용어 검수 결과.
|
| 229 |
+
|
| 230 |
+
슬롯 기반 응답(summary + items)으로 전환 후 호출부 없음. 다음 PR에서 제거 예정.
|
| 231 |
+
신규 호출은 translate_term / translate_short_sentence 사용.
|
| 232 |
+
"""
|
| 233 |
+
empty = {
|
| 234 |
+
"easy_ko_text": "",
|
| 235 |
+
"translation": "",
|
| 236 |
+
"target_language": target_lang,
|
| 237 |
+
"vi_text": "",
|
| 238 |
+
"quality_note": "",
|
| 239 |
+
"review_needed": "",
|
| 240 |
+
}
|
| 241 |
+
if not notice_text or not notice_text.strip():
|
| 242 |
+
return empty
|
| 243 |
+
|
| 244 |
+
source = {"easy_ko_text": "", "easy_korean": "", "original_text": notice_text}
|
| 245 |
+
baseline = {"original_text": notice_text}
|
| 246 |
+
|
| 247 |
+
try:
|
| 248 |
+
easy_ko_text = _sejong.prepare_easy_ko_text(
|
| 249 |
+
_sejong.build_easy_korean(source, baseline)
|
| 250 |
+
)
|
| 251 |
+
except Exception as error:
|
| 252 |
+
print(f"[translator] easy_ko failed: {error}")
|
| 253 |
+
easy_ko_text = notice_text
|
| 254 |
+
|
| 255 |
+
if not easy_ko_text:
|
| 256 |
+
return empty
|
| 257 |
+
|
| 258 |
+
# ko_easy는 한국어 자체라 사전 검수 무의미 → 빈 hits
|
| 259 |
+
if target_lang == "ko_easy":
|
| 260 |
+
glossary_hits = []
|
| 261 |
+
else:
|
| 262 |
+
glossary = _get_glossary()
|
| 263 |
+
glossary_hits = _sejong.find_glossary_hits(easy_ko_text, glossary, target_lang)
|
| 264 |
+
|
| 265 |
+
# ko_easy: 번역 없이 easy_ko_text를 그대로 사용
|
| 266 |
+
if target_lang == "ko_easy":
|
| 267 |
+
rows = _sejong.build_glossary_check_rows(easy_ko_text, "", glossary_hits)
|
| 268 |
+
label, note = _sejong.summarize_quality(rows)
|
| 269 |
+
return {
|
| 270 |
+
"easy_ko_text": easy_ko_text,
|
| 271 |
+
"translation": "",
|
| 272 |
+
"target_language": "ko_easy",
|
| 273 |
+
"vi_text": "",
|
| 274 |
+
"quality_note": note if note else f"ok ({label})",
|
| 275 |
+
"review_needed": note if label == "review_needed" else "",
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
target_nllb = LANG_TO_NLLB.get(target_lang, "vie_Latn")
|
| 279 |
+
try:
|
| 280 |
+
translated = _translate(easy_ko_text, target_nllb=target_nllb)
|
| 281 |
+
if target_lang == "vi":
|
| 282 |
+
translated = _post_process_vi(easy_ko_text, translated)
|
| 283 |
+
except Exception as error:
|
| 284 |
+
print(f"[translator] translate failed: {error}")
|
| 285 |
+
translated = ""
|
| 286 |
+
|
| 287 |
+
rows = _sejong.build_glossary_check_rows(easy_ko_text, translated, glossary_hits)
|
| 288 |
+
label, note = _sejong.summarize_quality(rows)
|
| 289 |
+
|
| 290 |
+
return {
|
| 291 |
+
"easy_ko_text": easy_ko_text,
|
| 292 |
+
"translation": translated,
|
| 293 |
+
"target_language": target_lang,
|
| 294 |
+
# 호환: vi_text는 vi 선택 시만 채움 (안드 기존 fallback 동작)
|
| 295 |
+
"vi_text": translated if target_lang == "vi" else "",
|
| 296 |
+
"quality_note": note if note else f"ok ({label})",
|
| 297 |
+
"review_needed": note if label == "review_needed" else "",
|
| 298 |
+
}
|
| 299 |
+
|
backend/app/services/tts.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import edge_tts
|
| 5 |
+
|
| 6 |
+
# 안드 언어 코드 → Edge-TTS voice
|
| 7 |
+
LANG_TO_VOICE = {
|
| 8 |
+
"vi": "vi-VN-HoaiMyNeural",
|
| 9 |
+
"en": "en-US-JennyNeural",
|
| 10 |
+
"ru": "ru-RU-SvetlanaNeural",
|
| 11 |
+
"ms": "ms-MY-YasminNeural",
|
| 12 |
+
"mn": "mn-MN-YesuiNeural",
|
| 13 |
+
"zh": "zh-CN-XiaoxiaoNeural",
|
| 14 |
+
"th": "th-TH-PremwadeeNeural",
|
| 15 |
+
"ja": "ja-JP-NanamiNeural",
|
| 16 |
+
"ko_easy": "ko-KR-SunHiNeural",
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
STATIC_DIR = Path("/app/static/tts")
|
| 20 |
+
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def generate_tts_file(text: str, target_lang: str = "vi") -> str:
|
| 24 |
+
"""선택 언어의 텍스트 → mp3 파일 생성. 안드가 BASE_URL과 합쳐 재생할 상대 경로 반환."""
|
| 25 |
+
if not text or not text.strip():
|
| 26 |
+
return ""
|
| 27 |
+
voice = LANG_TO_VOICE.get(target_lang)
|
| 28 |
+
if not voice:
|
| 29 |
+
print(f"[tts] no voice for lang={target_lang}")
|
| 30 |
+
return ""
|
| 31 |
+
filename = f"{uuid.uuid4()}.mp3"
|
| 32 |
+
out_path = STATIC_DIR / filename
|
| 33 |
+
try:
|
| 34 |
+
await edge_tts.Communicate(text=text, voice=voice).save(str(out_path))
|
| 35 |
+
except Exception as error:
|
| 36 |
+
print(f"[tts] generation failed for lang={target_lang}: {error}")
|
| 37 |
+
return ""
|
| 38 |
+
return f"/static/tts/{filename}"
|
backend/requirements-dev.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 개발/테스트 전용 의존성. 운영 컨테이너에는 설치 안 함.
|
| 2 |
+
# 사용: docker compose exec backend pip install -r requirements-dev.txt
|
| 3 |
+
# 또는 GitHub Actions에서 별도 install
|
| 4 |
+
pytest>=8.0
|
| 5 |
+
httpx>=0.27
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
+
fastapi==0.111.0
|
| 3 |
+
uvicorn[standard]==0.29.0
|
| 4 |
+
pydantic==2.7.1
|
| 5 |
+
edge-tts>=7.0.0
|
| 6 |
+
torch==2.3.1+cpu
|
| 7 |
+
transformers==4.44.2
|
| 8 |
+
huggingface-hub==0.24.7
|
| 9 |
+
sentencepiece==0.2.0
|
| 10 |
+
numpy==2.0.2
|
| 11 |
+
pandas==2.2.2
|
| 12 |
+
|
| 13 |
+
# 파일 입력 변환 ([1] 텍스트 변환)
|
| 14 |
+
pdfplumber==0.11.9
|
| 15 |
+
python-multipart==0.0.20
|
| 16 |
+
|
| 17 |
+
# 이미지 OCR ([1] 카메라 사진 입력 — 세종)
|
| 18 |
+
pytesseract>=0.3.10
|
| 19 |
+
pillow>=10.0.0
|
| 20 |
+
|
| 21 |
+
# 경이 분류기 ([4] 단계 — TF-IDF + LogReg simple, KcELECTRA는 transformers 사용)
|
| 22 |
+
scikit-learn==1.5.2
|
backend/static/notices/.gitkeep
ADDED
|
File without changes
|
backend/static/tts/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
backend/tests/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 백엔드 단위테스트
|
| 2 |
+
|
| 3 |
+
> 강사 피드백(2026-04-28) 대응: *"단위테스트 끝나고 통합테스트 할 때 합의된 기준/절차에 의해 merge"*
|
| 4 |
+
> → PR이 권한 검증·다국어 사전 로딩을 깨지 않는다는 자동 증빙 레이어.
|
| 5 |
+
|
| 6 |
+
## 구성
|
| 7 |
+
|
| 8 |
+
| 파일 | 검증 대상 |
|
| 9 |
+
| --- | --- |
|
| 10 |
+
| `test_auth_routes.py` | X-User-Id 헤더 인증, 역할(teacher/parent) 권한, 발송→수신 통합 흐름 |
|
| 11 |
+
| `test_glossary.py` | 다국어 용어사전(8개) 컬럼 동적 로딩, find_glossary_hits 언어별 동작 |
|
| 12 |
+
|
| 13 |
+
## 실행
|
| 14 |
+
|
| 15 |
+
### 도커 컨테이너에서
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
docker compose exec backend pip install -r /app/../backend/requirements-dev.txt
|
| 19 |
+
# 또는: docker compose exec backend pip install pytest httpx
|
| 20 |
+
docker compose exec backend pytest /app/../backend/tests -v
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
### CI (GitHub Actions)에서
|
| 24 |
+
|
| 25 |
+
PR 시 자동 실행 — `.github/workflows/backend-tests.yml` 참조.
|
| 26 |
+
|
| 27 |
+
## 추가 테스트 가이드
|
| 28 |
+
|
| 29 |
+
- 모델 호출(KoELECTRA / NLLB / Edge-TTS)은 무거우므로 직접 호출 X. 필요 시 monkey-patch로 가짜 응답 사용.
|
| 30 |
+
- 새 라우트 추가 시 권한 검증 케이스(teacher/parent/anonymous) 3종 모두 작성.
|
| 31 |
+
- 테스트는 빠르게 (총 실행시간 30초 이내 목표).
|
backend/tests/__init__.py
ADDED
|
File without changes
|
backend/tests/conftest.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""백엔드 테스트 공통 픽스처.
|
| 2 |
+
|
| 3 |
+
모델(KoELECTRA / NLLB / Edge-TTS) 호출은 무거우므로
|
| 4 |
+
auth/route 흐름 테스트에서는 monkey-patch로 가짜 응답을 사용한다.
|
| 5 |
+
"""
|
| 6 |
+
import pytest
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
from app.main import app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def client():
|
| 14 |
+
"""lifespan(seed_demo_users) 트리거를 위해 with 블록 안에서 사용."""
|
| 15 |
+
with TestClient(app) as c:
|
| 16 |
+
yield c
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def teacher_headers():
|
| 21 |
+
return {"X-User-Id": "teacher_001"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture
|
| 25 |
+
def teacher2_headers():
|
| 26 |
+
return {"X-User-Id": "teacher_002"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.fixture
|
| 30 |
+
def parent_headers():
|
| 31 |
+
return {"X-User-Id": "parent_001"}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
def parent2_headers():
|
| 36 |
+
return {"X-User-Id": "parent_002"}
|
backend/tests/test_analyze_routes.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`/notice/analyze` + `/notice/inbox` DELETE 권한 검증 + 슬롯 응답 shape 테스트.
|
| 2 |
+
|
| 3 |
+
핵심 라우트 권한 분리 + 강사 처방(2026-04-28) 슬롯 응답이 자동 게이트로 잡혀야 한다.
|
| 4 |
+
모델 호출(KoELECTRA + NLLB + Edge-TTS)은 무거우므로 monkeypatch로 우회.
|
| 5 |
+
"""
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture(autouse=True)
|
| 10 |
+
def reset_notices():
|
| 11 |
+
"""각 테스트 전 모듈 상태(_notices) 초기화로 순서 의존성 제거."""
|
| 12 |
+
from app.routers import notice
|
| 13 |
+
notice._notices.clear()
|
| 14 |
+
yield
|
| 15 |
+
notice._notices.clear()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ─────────────────────────────────────────
|
| 19 |
+
# 공통 헬퍼: parent_001 앞으로 통신문 1건 발송
|
| 20 |
+
# ─────────────────────────────────────────
|
| 21 |
+
def _seed_notice(client, teacher_id="teacher_001", parent_id="parent_001",
|
| 22 |
+
text="테스트 통신문") -> str:
|
| 23 |
+
payload = {"teacher_id": teacher_id, "parent_id": parent_id, "text": text}
|
| 24 |
+
headers = {"X-User-Id": teacher_id}
|
| 25 |
+
r = client.post("/notice/send", json=payload, headers=headers)
|
| 26 |
+
assert r.status_code == 200, r.text
|
| 27 |
+
return r.json()["data"]["notice_id"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _patch_models(monkeypatch, *, todos=None, category=None,
|
| 31 |
+
tts_url="/static/tts/fake.mp3"):
|
| 32 |
+
"""모델 호출 4종(추출/분류/번역2)을 한 번에 mock. 각 테스트가 인자만 바꿔쓰게."""
|
| 33 |
+
from app.models.schemas import Category
|
| 34 |
+
default_category = category or Category.other
|
| 35 |
+
monkeypatch.setattr("app.routers.notice.extract_todos", lambda text: todos or [])
|
| 36 |
+
monkeypatch.setattr("app.routers.notice.classify_category",
|
| 37 |
+
lambda text: default_category)
|
| 38 |
+
monkeypatch.setattr("app.routers.notice.translate_short_sentence",
|
| 39 |
+
lambda text, target_lang: f"[VI]{text}")
|
| 40 |
+
monkeypatch.setattr("app.routers.notice.translate_term",
|
| 41 |
+
lambda text, target_lang: f"[T]{text}")
|
| 42 |
+
|
| 43 |
+
async def fake_tts(text, target_lang="vi"):
|
| 44 |
+
return tts_url
|
| 45 |
+
monkeypatch.setattr("app.routers.notice.generate_tts_file", fake_tts)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ─────────────────────────────────────────
|
| 49 |
+
# ANALYZE — 권한 게이트
|
| 50 |
+
# ─────────────────────────────────────────
|
| 51 |
+
_VI_BODY = {"target_language": "vi"}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_analyze_unknown_notice(client, parent_headers):
|
| 55 |
+
r = client.post("/notice/analyze/ghost-id-xxx", json=_VI_BODY, headers=parent_headers)
|
| 56 |
+
assert r.status_code == 404
|
| 57 |
+
assert "찾을 수 없습니다" in r.json()["detail"]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_analyze_other_parent_blocked(client, teacher_headers, parent_headers, parent2_headers):
|
| 61 |
+
notice_id = _seed_notice(client)
|
| 62 |
+
r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent2_headers)
|
| 63 |
+
assert r.status_code == 403
|
| 64 |
+
assert "본인의 가정통신문" in r.json()["detail"]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_analyze_teacher_role_blocked(client, teacher_headers):
|
| 68 |
+
notice_id = _seed_notice(client)
|
| 69 |
+
r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=teacher_headers)
|
| 70 |
+
assert r.status_code == 403
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_analyze_missing_target_language_rejected(client, teacher_headers, parent_headers):
|
| 74 |
+
"""body에 target_language 없이 호출 → 422 (FastAPI body 검증)."""
|
| 75 |
+
notice_id = _seed_notice(client)
|
| 76 |
+
r = client.post(f"/notice/analyze/{notice_id}", json={}, headers=parent_headers)
|
| 77 |
+
assert r.status_code == 422
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ─────────────────────────────────────────
|
| 81 |
+
# ANALYZE — 슬롯 응답 shape (강사 처방)
|
| 82 |
+
# ─────────────────────────────────────────
|
| 83 |
+
def test_analyze_returns_slot_shape(client, parent_headers, monkeypatch):
|
| 84 |
+
"""응답이 summary + items 슬롯 구조 — translation 한 덩어리 필드 사라짐."""
|
| 85 |
+
notice_id = _seed_notice(client, text="내일 도시락을 가져와 주세요.")
|
| 86 |
+
_patch_models(monkeypatch)
|
| 87 |
+
|
| 88 |
+
r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers)
|
| 89 |
+
assert r.status_code == 200
|
| 90 |
+
data = r.json()["data"]
|
| 91 |
+
|
| 92 |
+
assert data["notice_id"] == notice_id
|
| 93 |
+
assert data["target_language"] == "vi"
|
| 94 |
+
|
| 95 |
+
# summary 8슬롯 모두 존재 (urls/phones는 NLLB 보호용)
|
| 96 |
+
summary = data["summary"]
|
| 97 |
+
for key in ("dates", "times", "places", "supplies", "amounts",
|
| 98 |
+
"deadlines", "urls", "phones"):
|
| 99 |
+
assert key in summary, f"summary 슬롯 누락: {key}"
|
| 100 |
+
assert isinstance(summary[key], list)
|
| 101 |
+
|
| 102 |
+
# items 리스트 + 각 item 필수 필드
|
| 103 |
+
assert isinstance(data["items"], list)
|
| 104 |
+
for item in data["items"]:
|
| 105 |
+
for key in ("category", "action_hint", "title_ko", "title_translated",
|
| 106 |
+
"when", "where", "what", "amount", "deadline", "importance"):
|
| 107 |
+
assert key in item, f"item 필드 누락: {key}"
|
| 108 |
+
|
| 109 |
+
# 구버전 한 덩어리 필드는 빠져있어야 함 (안드 마이그레이션 확인용)
|
| 110 |
+
assert "translation" not in data
|
| 111 |
+
assert "vi_text" not in data
|
| 112 |
+
assert "easy_ko_text" not in data
|
| 113 |
+
assert "todos" not in data
|
| 114 |
+
|
| 115 |
+
assert data["tts_url"] == "/static/tts/fake.mp3"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_analyze_regex_slots_filled_from_raw_text(client, parent_headers, monkeypatch):
|
| 119 |
+
"""원문에 날짜·시간·금액이 있으면 정규식 추출이 summary에 채워진다 (LLM 비의존 데이터)."""
|
| 120 |
+
text = "5월 14일(수) 오전 9시 출발. 참가비 15,000원."
|
| 121 |
+
notice_id = _seed_notice(client, text=text)
|
| 122 |
+
_patch_models(monkeypatch)
|
| 123 |
+
|
| 124 |
+
r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers)
|
| 125 |
+
summary = r.json()["data"]["summary"]
|
| 126 |
+
|
| 127 |
+
# 정규식 hit 3종 모두 잡혀야 함
|
| 128 |
+
assert any(d["ko"] == "5월 14일(수)" for d in summary["dates"])
|
| 129 |
+
assert any(t["ko"] == "오전 9시" for t in summary["times"])
|
| 130 |
+
assert any(a["ko"] == "15,000원" for a in summary["amounts"])
|
| 131 |
+
|
| 132 |
+
# source: "regex" 명시 — 강사 처방 "정규식 + 모델 하이브리드" 가시화
|
| 133 |
+
assert all(d["source"] == "regex" for d in summary["dates"])
|
| 134 |
+
assert all(t["source"] == "regex" for t in summary["times"])
|
| 135 |
+
assert all(a["source"] == "regex" for a in summary["amounts"])
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_analyze_urls_phones_passthrough(client, parent_headers, monkeypatch):
|
| 139 |
+
"""원문에 URL/전화가 있으면 summary.urls / phones에 ko 그대로 노출 (번역 X)."""
|
| 140 |
+
text = "신청 https://apply.school.kr, 문의 02-2649-7232 / 1588-0260"
|
| 141 |
+
notice_id = _seed_notice(client, text=text)
|
| 142 |
+
_patch_models(monkeypatch)
|
| 143 |
+
|
| 144 |
+
r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers)
|
| 145 |
+
summary = r.json()["data"]["summary"]
|
| 146 |
+
|
| 147 |
+
assert any(u["ko"] == "https://apply.school.kr" for u in summary["urls"])
|
| 148 |
+
assert any(p["ko"] == "02-2649-7232" for p in summary["phones"])
|
| 149 |
+
assert any(p["ko"] == "1588-0260" for p in summary["phones"])
|
| 150 |
+
|
| 151 |
+
# translated가 ko와 동일 = 번역 안 거침
|
| 152 |
+
for u in summary["urls"]:
|
| 153 |
+
assert u["translated"] == u["ko"]
|
| 154 |
+
for p in summary["phones"]:
|
| 155 |
+
assert p["translated"] == p["ko"]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_analyze_returns_cards_shape(client, parent_headers, monkeypatch):
|
| 159 |
+
"""신규 cards 필드 구조 검증 — 슬롯 카드 재설계 (Phase 1)."""
|
| 160 |
+
from app.models.schemas import Category, YunjeongTodo
|
| 161 |
+
todos = [YunjeongTodo(
|
| 162 |
+
text="운영시간 오전 10:00 ~ 12:00 (2시간)",
|
| 163 |
+
confidence=0.85,
|
| 164 |
+
action_hint="참여",
|
| 165 |
+
)]
|
| 166 |
+
notice_id = _seed_notice(client, text="운영시간 오전 10:00 ~ 12:00 (2시간)")
|
| 167 |
+
_patch_models(monkeypatch, todos=todos, category=Category.schedule)
|
| 168 |
+
|
| 169 |
+
r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers)
|
| 170 |
+
assert r.status_code == 200
|
| 171 |
+
data = r.json()["data"]
|
| 172 |
+
|
| 173 |
+
# cards 필드 존재 + 리스트
|
| 174 |
+
assert "cards" in data
|
| 175 |
+
assert isinstance(data["cards"], list)
|
| 176 |
+
assert len(data["cards"]) >= 1
|
| 177 |
+
|
| 178 |
+
# 카드 필수 필드
|
| 179 |
+
for card in data["cards"]:
|
| 180 |
+
for key in ("header_ko", "header_translated", "value_ko",
|
| 181 |
+
"value_easy_ko", "value_translated", "chip", "importance"):
|
| 182 |
+
assert key in card, f"card 필드 누락: {key}"
|
| 183 |
+
|
| 184 |
+
# 헤더 분해 — "운영시간" 헤더 추출됐는지
|
| 185 |
+
headers = [c["header_ko"] for c in data["cards"]]
|
| 186 |
+
assert "운영시간" in headers, f"운영시간 헤더 누락: {headers}"
|
| 187 |
+
|
| 188 |
+
# tts_url_easy_ko 필드 존재 (세종님 별도 버튼)
|
| 189 |
+
assert "tts_url_easy_ko" in data
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_analyze_cards_regex_url_card(client, parent_headers, monkeypatch):
|
| 193 |
+
"""todo로 못 잡힌 URL이 regex 보강으로 '신청 URL' 카드에 들어가야 한다."""
|
| 194 |
+
text = "신청 안내. http://apply.school.kr 에서 접수."
|
| 195 |
+
notice_id = _seed_notice(client, text=text)
|
| 196 |
+
_patch_models(monkeypatch) # todos 빈 채로
|
| 197 |
+
|
| 198 |
+
r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers)
|
| 199 |
+
cards = r.json()["data"]["cards"]
|
| 200 |
+
headers = [c["header_ko"] for c in cards]
|
| 201 |
+
assert "신청 URL" in headers, f"URL 카드 누락: {headers}"
|
| 202 |
+
url_card = next(c for c in cards if c["header_ko"] == "신청 URL")
|
| 203 |
+
assert "http://apply.school.kr" in url_card["value_ko"]
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_analyze_supplies_aggregated_from_items(client, parent_headers, monkeypatch):
|
| 207 |
+
"""경이님 분류가 supplies로 잡힌 todo의 토큰이 summary.supplies로 집계 (강사 강조: 누락 금지)."""
|
| 208 |
+
from app.models.schemas import Category, YunjeongTodo
|
| 209 |
+
todos = [YunjeongTodo(
|
| 210 |
+
text="도시락, 물통, 돗자리를 준비해 주세요",
|
| 211 |
+
confidence=0.9,
|
| 212 |
+
action_hint="준비",
|
| 213 |
+
)]
|
| 214 |
+
notice_id = _seed_notice(client, text="도시락, 물통, 돗자리를 준비해 주세요")
|
| 215 |
+
_patch_models(monkeypatch, todos=todos, category=Category.supplies)
|
| 216 |
+
|
| 217 |
+
r = client.post(f"/notice/analyze/{notice_id}", json=_VI_BODY, headers=parent_headers)
|
| 218 |
+
supplies = r.json()["data"]["summary"]["supplies"]
|
| 219 |
+
ko_tokens = [s["ko"] for s in supplies]
|
| 220 |
+
assert "도시락" in ko_tokens
|
| 221 |
+
assert "물통" in ko_tokens
|
| 222 |
+
assert "돗자리" in ko_tokens
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ─────────────────────────────────────────
|
| 226 |
+
# UPLOAD — multipart 파일 입력
|
| 227 |
+
# ─────────────────────────────────────────
|
| 228 |
+
def _patch_parser(monkeypatch, *, text="파싱 결과 텍스트", error=None):
|
| 229 |
+
"""parser.parse_bytes_to_text를 mock해서 LibreOffice 의존 없이 검증."""
|
| 230 |
+
from app.services.parser import ParserError
|
| 231 |
+
|
| 232 |
+
def fake(data, filename):
|
| 233 |
+
if error:
|
| 234 |
+
raise ParserError(error)
|
| 235 |
+
return text
|
| 236 |
+
|
| 237 |
+
monkeypatch.setattr("app.routers.notice.parse_bytes_to_text", fake)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_upload_text_creates_notice(client, teacher_headers, monkeypatch):
|
| 241 |
+
"""평문 .txt 업로드 → notice 생성. 응답에 notice_id + char_count."""
|
| 242 |
+
_patch_parser(monkeypatch, text="텍스트 통신문 본문")
|
| 243 |
+
files = {"file": ("hello.txt", b"abc", "text/plain")}
|
| 244 |
+
data = {"teacher_id": "teacher_001", "parent_id": "parent_001"}
|
| 245 |
+
|
| 246 |
+
r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers)
|
| 247 |
+
|
| 248 |
+
assert r.status_code == 200, r.text
|
| 249 |
+
body = r.json()["data"]
|
| 250 |
+
assert "notice_id" in body
|
| 251 |
+
assert body["char_count"] == len("텍스트 통신문 본문")
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def test_upload_other_teacher_blocked(client, teacher_headers):
|
| 255 |
+
"""본인 ID와 다른 teacher_id로 업로드 → 403."""
|
| 256 |
+
files = {"file": ("hello.txt", b"abc", "text/plain")}
|
| 257 |
+
data = {"teacher_id": "teacher_002", "parent_id": "parent_001"}
|
| 258 |
+
r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers)
|
| 259 |
+
assert r.status_code == 403
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def test_upload_unknown_parent_blocked(client, teacher_headers, monkeypatch):
|
| 263 |
+
"""존재하지 않는 parent_id → 404."""
|
| 264 |
+
_patch_parser(monkeypatch)
|
| 265 |
+
files = {"file": ("x.txt", b"abc", "text/plain")}
|
| 266 |
+
data = {"teacher_id": "teacher_001", "parent_id": "ghost"}
|
| 267 |
+
r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers)
|
| 268 |
+
assert r.status_code == 404
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def test_upload_parser_failure_returns_400(client, teacher_headers, monkeypatch):
|
| 272 |
+
_patch_parser(monkeypatch, error="LibreOffice 변환 실패")
|
| 273 |
+
files = {"file": ("broken.hwp", b"x", "application/x-hwp")}
|
| 274 |
+
data = {"teacher_id": "teacher_001", "parent_id": "parent_001"}
|
| 275 |
+
r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers)
|
| 276 |
+
assert r.status_code == 400
|
| 277 |
+
assert "변환 실패" in r.json()["detail"]
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def test_upload_empty_text_returns_400(client, teacher_headers, monkeypatch):
|
| 281 |
+
"""파서가 빈 문자열 반환 → 400."""
|
| 282 |
+
_patch_parser(monkeypatch, text="")
|
| 283 |
+
files = {"file": ("empty.txt", b"", "text/plain")}
|
| 284 |
+
data = {"teacher_id": "teacher_001", "parent_id": "parent_001"}
|
| 285 |
+
r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers)
|
| 286 |
+
assert r.status_code == 400
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def test_upload_then_inbox_round_trip(client, teacher_headers, parent_headers, monkeypatch):
|
| 290 |
+
"""업로드 → 학부모 inbox에서 같은 텍스트 보임."""
|
| 291 |
+
_patch_parser(monkeypatch, text="upload→inbox 통신문")
|
| 292 |
+
files = {"file": ("doc.pdf", b"%PDF-fake", "application/pdf")}
|
| 293 |
+
data = {"teacher_id": "teacher_001", "parent_id": "parent_001"}
|
| 294 |
+
r = client.post("/notice/upload", data=data, files=files, headers=teacher_headers)
|
| 295 |
+
notice_id = r.json()["data"]["notice_id"]
|
| 296 |
+
|
| 297 |
+
inbox = client.get("/notice/inbox/parent_001", headers=parent_headers).json()["data"]
|
| 298 |
+
assert any(n["notice_id"] == notice_id and n["text"] == "upload→inbox 통신문"
|
| 299 |
+
for n in inbox)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ─────────────────────────────────────────
|
| 303 |
+
# DELETE INBOX — 본인만 허용
|
| 304 |
+
# ─────────────────────────────────────────
|
| 305 |
+
def test_delete_inbox_self_only(client, teacher_headers, parent_headers, parent2_headers):
|
| 306 |
+
notice_id = _seed_notice(client)
|
| 307 |
+
r = client.delete("/notice/inbox/parent_001", headers=parent2_headers)
|
| 308 |
+
assert r.status_code == 403
|
| 309 |
+
|
| 310 |
+
r2 = client.get("/notice/inbox/parent_001", headers=parent_headers)
|
| 311 |
+
assert r2.status_code == 200
|
| 312 |
+
assert any(n["notice_id"] == notice_id for n in r2.json()["data"])
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def test_delete_inbox_self_succeeds(client, teacher_headers, parent_headers):
|
| 316 |
+
_seed_notice(client)
|
| 317 |
+
_seed_notice(client, text="두 번째 통신문")
|
| 318 |
+
|
| 319 |
+
r1 = client.get("/notice/inbox/parent_001", headers=parent_headers)
|
| 320 |
+
assert len(r1.json()["data"]) >= 2
|
| 321 |
+
|
| 322 |
+
r2 = client.delete("/notice/inbox/parent_001", headers=parent_headers)
|
| 323 |
+
assert r2.status_code == 200
|
| 324 |
+
|
| 325 |
+
r3 = client.get("/notice/inbox/parent_001", headers=parent_headers)
|
| 326 |
+
assert r3.json()["data"] == []
|
backend/tests/test_auth_routes.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""권한 분리 라우트 테스트.
|
| 2 |
+
|
| 3 |
+
강사 피드백(2026-04-28): "단위테스트 끝나고 통합테스트 할 때 합의된 기준/절차에 의해 merge"
|
| 4 |
+
→ 운영 PR이 권한 검증을 깨지 않는다는 자동 증빙.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_health_ok(client):
|
| 9 |
+
"""기본 헬스체크 — 모델 로드 없이 즉시 응답."""
|
| 10 |
+
r = client.get("/health")
|
| 11 |
+
assert r.status_code == 200
|
| 12 |
+
assert r.json()["status"] == "ok"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_send_notice_requires_teacher_role(client, parent_headers):
|
| 16 |
+
"""학부모 ID로 발송 시도 → 403."""
|
| 17 |
+
payload = {"teacher_id": "teacher_001", "parent_id": "parent_001", "text": "테스트"}
|
| 18 |
+
r = client.post("/notice/send", json=payload, headers=parent_headers)
|
| 19 |
+
assert r.status_code == 403
|
| 20 |
+
assert "선생님 권한" in r.json()["detail"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_send_notice_teacher_id_must_match_header(client, teacher_headers):
|
| 24 |
+
"""헤더(teacher_001) ≠ body.teacher_id(teacher_002) → 403."""
|
| 25 |
+
payload = {"teacher_id": "teacher_002", "parent_id": "parent_001", "text": "테스트"}
|
| 26 |
+
r = client.post("/notice/send", json=payload, headers=teacher_headers)
|
| 27 |
+
assert r.status_code == 403
|
| 28 |
+
assert "본인 선생님 ID" in r.json()["detail"]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_send_notice_unknown_parent(client, teacher_headers):
|
| 32 |
+
"""존재하지 않는 학부모로 발송 → 404."""
|
| 33 |
+
payload = {"teacher_id": "teacher_001", "parent_id": "parent_999", "text": "테스트"}
|
| 34 |
+
r = client.post("/notice/send", json=payload, headers=teacher_headers)
|
| 35 |
+
assert r.status_code == 404
|
| 36 |
+
assert "학부모 계정을 찾을 수 없습니다" in r.json()["detail"]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_inbox_self_only(client, parent_headers, parent2_headers):
|
| 40 |
+
"""parent_001은 본인 수신함 OK, parent_002의 수신함 조회는 403."""
|
| 41 |
+
r1 = client.get("/notice/inbox/parent_001", headers=parent_headers)
|
| 42 |
+
assert r1.status_code == 200
|
| 43 |
+
|
| 44 |
+
r2 = client.get("/notice/inbox/parent_001", headers=parent2_headers)
|
| 45 |
+
assert r2.status_code == 403
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_inbox_requires_user_header(client):
|
| 49 |
+
"""X-User-Id 헤더 없으면 422 (FastAPI Header 필수 검증)."""
|
| 50 |
+
r = client.get("/notice/inbox/parent_001")
|
| 51 |
+
assert r.status_code == 422
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_unknown_user_id_rejected(client):
|
| 55 |
+
"""등록 안 된 ID로 헤더 → 401."""
|
| 56 |
+
r = client.get("/notice/inbox/parent_001", headers={"X-User-Id": "ghost_999"})
|
| 57 |
+
assert r.status_code == 401
|
| 58 |
+
assert "등록되지 않은 사용자" in r.json()["detail"]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_send_then_receive_flow(client, teacher_headers, parent_headers):
|
| 62 |
+
"""발송 후 학부모 수신함에 보임 (권한 흐름 통합 검증)."""
|
| 63 |
+
payload = {"teacher_id": "teacher_001", "parent_id": "parent_001", "text": "단위테스트 통신문"}
|
| 64 |
+
r1 = client.post("/notice/send", json=payload, headers=teacher_headers)
|
| 65 |
+
assert r1.status_code == 200
|
| 66 |
+
notice_id = r1.json()["data"]["notice_id"]
|
| 67 |
+
assert notice_id
|
| 68 |
+
|
| 69 |
+
r2 = client.get("/notice/inbox/parent_001", headers=parent_headers)
|
| 70 |
+
assert r2.status_code == 200
|
| 71 |
+
inbox = r2.json()["data"]
|
| 72 |
+
assert any(n["notice_id"] == notice_id for n in inbox)
|
backend/tests/test_card_builder.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""card_builder 슬롯 카드 빌드 + dedup 단위 테스트."""
|
| 2 |
+
from app.models.schemas import SlotCard
|
| 3 |
+
from app.services.card_builder import _dedup_cards
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _card(header: str, value: str, importance: float = 0.5) -> SlotCard:
|
| 7 |
+
return SlotCard(
|
| 8 |
+
header_ko=header,
|
| 9 |
+
header_translated=header,
|
| 10 |
+
value_ko=value,
|
| 11 |
+
value_easy_ko=value,
|
| 12 |
+
value_translated=value,
|
| 13 |
+
chip=None,
|
| 14 |
+
importance=importance,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_dedup_removes_substring_within_same_header():
|
| 19 |
+
"""같은 헤더 안에서 짧은 value가 긴 value 안에 들어있으면 짧은 것 제거 — 본문/표 영역 중복 흡수."""
|
| 20 |
+
cards = [
|
| 21 |
+
_card("운영방법", "온라인 사전예약 100명 선착순 5가지 체험 안전 복장", 0.99),
|
| 22 |
+
_card("운영방법", "온라인 사전예약 100명 선착순", 0.53), # substring
|
| 23 |
+
]
|
| 24 |
+
out = _dedup_cards(cards)
|
| 25 |
+
assert len(out) == 1
|
| 26 |
+
assert out[0].importance == 0.99 # 긴 쪽이 살아남음
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_dedup_keeps_different_headers():
|
| 30 |
+
"""헤더가 다르면 손대지 않음 — 운영방법과 일시는 별개 슬롯."""
|
| 31 |
+
cards = [
|
| 32 |
+
_card("운영방법", "온라인 사전예약", 0.9),
|
| 33 |
+
_card("일시", "2026. 4. 18.(토)", 0.7),
|
| 34 |
+
_card("신청 URL", "http://example.com", 0.7),
|
| 35 |
+
]
|
| 36 |
+
out = _dedup_cards(cards)
|
| 37 |
+
assert len(out) == 3
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_dedup_normalizes_pipe_and_colon_separators():
|
| 41 |
+
"""`|`/`:` 구분자 차이로 substring 매칭 놓치는 것 방지 — 윤정님 기호 정제 전후 둘 다 호환."""
|
| 42 |
+
cards = [
|
| 43 |
+
_card("운영방법", "온라인 사전예약 100명 선착순", 0.99),
|
| 44 |
+
_card("운영방법", "온라인 | 사전예약 | 100명 선착순", 0.50), # `|` 구분자만 다름
|
| 45 |
+
]
|
| 46 |
+
out = _dedup_cards(cards)
|
| 47 |
+
assert len(out) == 1
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_dedup_keeps_distinct_values_under_same_header():
|
| 51 |
+
"""같은 헤더라도 substring 관계 아니면 둘 다 유지 — 정보 손실 방지."""
|
| 52 |
+
cards = [
|
| 53 |
+
_card("기타", "물 지참", 0.8),
|
| 54 |
+
_card("기타", "음식물 반입 금지", 0.6),
|
| 55 |
+
]
|
| 56 |
+
out = _dedup_cards(cards)
|
| 57 |
+
assert len(out) == 2
|
backend/tests/test_glossary.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""다국어 용어사전 로딩 단위 테스트.
|
| 2 |
+
|
| 3 |
+
세종 PR 리팩토링 반영(2026-04-28):
|
| 4 |
+
- read_glossary(path)는 raw rows 반환 (target_lang 파라미터 제거)
|
| 5 |
+
- 언어별 분기는 find_glossary_hits(text, glossary, lang)에서 처리
|
| 6 |
+
- 반환 dict 키: preferred_vi → preferred_term (generic)
|
| 7 |
+
"""
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
# run_mvp_pipeline.py 직접 import
|
| 14 |
+
_TRANSLATION_DIR = Path("/app/external_model/translation_tts")
|
| 15 |
+
if str(_TRANSLATION_DIR) not in sys.path:
|
| 16 |
+
sys.path.insert(0, str(_TRANSLATION_DIR))
|
| 17 |
+
import run_mvp_pipeline as _sj # noqa: E402
|
| 18 |
+
|
| 19 |
+
GLOSSARY_PATH = _TRANSLATION_DIR / "term_glossary.csv"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_glossary_loads_raw_rows():
|
| 23 |
+
"""read_glossary(path)는 최소 144행 이상 raw rows를 반환한다."""
|
| 24 |
+
rows = _sj.read_glossary(GLOSSARY_PATH)
|
| 25 |
+
assert len(rows) >= 144
|
| 26 |
+
# 한국어 컬럼은 비어있지 않음
|
| 27 |
+
assert rows[0]["korean"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.mark.parametrize("lang", ["vi", "en", "zh", "th", "ja", "ru", "ms", "mn"])
|
| 31 |
+
def test_glossary_each_language_column_filled(lang):
|
| 32 |
+
"""각 언어 preferred_{lang} 컬럼이 모든 행에 채워져있다."""
|
| 33 |
+
rows = _sj.read_glossary(GLOSSARY_PATH)
|
| 34 |
+
column = f"preferred_{lang}"
|
| 35 |
+
filled = [r for r in rows if r.get(column, "").strip()]
|
| 36 |
+
assert len(filled) == len(rows), f"{lang}: {len(rows) - len(filled)}건 누락"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_glossary_dosirak_mapping():
|
| 40 |
+
"""대표 학교 도메인 단어 '도시락'이 언어별로 다른 권장어로 매핑된다."""
|
| 41 |
+
expected = {"vi": "cơm hộp", "en": "Lunch box", "zh": "便当", "ja": "お弁당"}
|
| 42 |
+
rows = _sj.read_glossary(GLOSSARY_PATH)
|
| 43 |
+
dosirak = next((r for r in rows if r["korean"] == "도시락"), None)
|
| 44 |
+
assert dosirak is not None, "'도시락' 누락"
|
| 45 |
+
for lang, want in expected.items():
|
| 46 |
+
# Note: ja 'お弁당' 표기 차이로 일본어는 startswith 비교
|
| 47 |
+
actual = dosirak.get(f"preferred_{lang}", "").strip()
|
| 48 |
+
if lang == "ja":
|
| 49 |
+
assert actual.startswith("お弁") or actual.startswith("弁当"), f"ja: {actual!r}"
|
| 50 |
+
else:
|
| 51 |
+
assert actual == want, f"{lang}: expected {want!r}, got {actual!r}"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_find_glossary_hits_returns_target_language():
|
| 55 |
+
"""find_glossary_hits이 target_lang 권장어를 preferred_term 키로 반환한다."""
|
| 56 |
+
glossary = _sj.read_glossary(GLOSSARY_PATH)
|
| 57 |
+
text = "아이는 도시락과 물병을 가져와 주세요."
|
| 58 |
+
|
| 59 |
+
en_hits = _sj.find_glossary_hits(text, glossary, "en")
|
| 60 |
+
en_dosirak = next((h for h in en_hits if h["korean"] == "도시락"), None)
|
| 61 |
+
assert en_dosirak is not None
|
| 62 |
+
assert en_dosirak["preferred_term"].lower() == "lunch box"
|
| 63 |
+
|
| 64 |
+
vi_hits = _sj.find_glossary_hits(text, glossary, "vi")
|
| 65 |
+
vi_dosirak = next((h for h in vi_hits if h["korean"] == "도시락"), None)
|
| 66 |
+
assert vi_dosirak is not None
|
| 67 |
+
assert vi_dosirak["preferred_term"] == "cơm hộp"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_find_glossary_hits_empty_text():
|
| 71 |
+
"""본문에 사전 용어가 없으면 빈 리스트 반환."""
|
| 72 |
+
glossary = _sj.read_glossary(GLOSSARY_PATH)
|
| 73 |
+
hits = _sj.find_glossary_hits("아무 학교 용어 없음", glossary, "vi")
|
| 74 |
+
assert hits == []
|
backend/tests/test_parser.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""파서 단위 테스트.
|
| 2 |
+
|
| 3 |
+
LibreOffice 호출은 mocking. PDF 추출은 pdfplumber에 의존하므로
|
| 4 |
+
실 PDF 한 장은 monkeypatch 없이 검증해도 됨 (가벼움).
|
| 5 |
+
"""
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from app.services.parser import (
|
| 11 |
+
ParserError,
|
| 12 |
+
normalize,
|
| 13 |
+
parse_bytes_to_text,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ── normalize ─────────────────────────────────────────────────
|
| 18 |
+
def test_normalize_strips_null_bytes():
|
| 19 |
+
assert "\x00" not in normalize("a\x00b\x00c")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_normalize_collapses_inline_whitespace_but_keeps_newlines():
|
| 23 |
+
out = normalize("foo bar\n\n\nbaz")
|
| 24 |
+
# 한 줄 안 다중 공백은 1개로
|
| 25 |
+
assert out == "foo bar\n\nbaz"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_normalize_preserves_paragraph_breaks():
|
| 29 |
+
"""줄바꿈은 보존 (윤정님 요구: '줄바꿈만 살리고')."""
|
| 30 |
+
src = "안녕하세요\n학부모님께\n\n알려드립니다"
|
| 31 |
+
assert normalize(src) == "안녕하세요\n학부모님께\n\n알려드립니다"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_normalize_empty_returns_empty():
|
| 35 |
+
assert normalize("") == ""
|
| 36 |
+
assert normalize(" \n\n ") == ""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ── parse_bytes_to_text — 평문 ────────────────────────────────
|
| 40 |
+
def test_parse_text_passthrough():
|
| 41 |
+
raw = "통신문 본문\n\n오늘 안내드립니다".encode("utf-8")
|
| 42 |
+
assert parse_bytes_to_text(raw, "notice.txt") == "통신문 본문\n\n오늘 안내드립니다"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_parse_no_extension_treated_as_text():
|
| 46 |
+
raw = "그냥 텍스트".encode("utf-8")
|
| 47 |
+
assert parse_bytes_to_text(raw, "noext") == "그냥 텍스트"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_parse_empty_bytes_returns_empty():
|
| 51 |
+
assert parse_bytes_to_text(b"", "anything.txt") == ""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_parse_unsupported_extension_raises():
|
| 55 |
+
with pytest.raises(ParserError):
|
| 56 |
+
parse_bytes_to_text(b"x", "image.heic")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_parse_unknown_extension_rejected():
|
| 60 |
+
"""알 수 없는 suffix는 화이트리스트(ALLOWED_EXTS)에서 거부."""
|
| 61 |
+
with pytest.raises(ParserError):
|
| 62 |
+
parse_bytes_to_text(b"x", "../../etc/passwd.exe")
|
| 63 |
+
with pytest.raises(ParserError):
|
| 64 |
+
parse_bytes_to_text(b"x", "weird.bin")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_parse_filename_metachars_safe(monkeypatch):
|
| 68 |
+
"""쉘 메타문자가 들어간 .hwp 파일명도 LibreOffice 호출에 안전.
|
| 69 |
+
|
| 70 |
+
원본 filename은 어떤 경로/명령에도 들어가지 않고 tempdir/input.hwp로만 저장.
|
| 71 |
+
subprocess 호출 시 list 인자 형태라 명령 주입 표면 자체가 없음.
|
| 72 |
+
"""
|
| 73 |
+
captured_args = {}
|
| 74 |
+
|
| 75 |
+
def fake_run(cmd, **kwargs):
|
| 76 |
+
captured_args["cmd"] = cmd
|
| 77 |
+
# 메타문자 흔적이 cmd 어디에도 안 보임을 검증
|
| 78 |
+
for arg in cmd:
|
| 79 |
+
assert "rm" not in arg
|
| 80 |
+
assert ";" not in arg
|
| 81 |
+
assert "$(" not in arg
|
| 82 |
+
# 가짜 ODT 만들어서 변환 성공처럼
|
| 83 |
+
out_dir = Path(cmd[cmd.index("--outdir") + 1])
|
| 84 |
+
(out_dir / "input.odt").write_bytes(b"PK-fake-odt")
|
| 85 |
+
|
| 86 |
+
class Result:
|
| 87 |
+
returncode = 0
|
| 88 |
+
stderr = ""
|
| 89 |
+
return Result()
|
| 90 |
+
|
| 91 |
+
monkeypatch.setattr("app.services.parser.subprocess.run", fake_run)
|
| 92 |
+
monkeypatch.setattr(
|
| 93 |
+
"app.services.parser._odt_to_text",
|
| 94 |
+
lambda p: "ok",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
parse_bytes_to_text(b"HWP-data", "$(rm -rf /).hwp")
|
| 98 |
+
# 명령 주입은커녕 원본 filename이 cmd에 등장조차 안 함
|
| 99 |
+
assert all("$(rm" not in arg for arg in captured_args["cmd"])
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_parse_libreoffice_timeout_raises_with_message(monkeypatch):
|
| 103 |
+
"""타임아웃 발생 시 ParserError + 환경변수 안내 메시지."""
|
| 104 |
+
import subprocess as sp
|
| 105 |
+
|
| 106 |
+
def boom(*args, **kwargs):
|
| 107 |
+
raise sp.TimeoutExpired(cmd=args[0], timeout=kwargs.get("timeout", 0))
|
| 108 |
+
|
| 109 |
+
monkeypatch.setattr("app.services.parser.subprocess.run", boom)
|
| 110 |
+
with pytest.raises(ParserError) as exc:
|
| 111 |
+
parse_bytes_to_text(b"HWP", "x.hwp")
|
| 112 |
+
assert "타임아웃" in str(exc.value)
|
| 113 |
+
assert "PARSER_LIBREOFFICE_TIMEOUT" in str(exc.value)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ── parse_bytes_to_text — HWP (LibreOffice 모킹) ──────────────
|
| 117 |
+
def test_parse_hwp_calls_libreoffice_and_odt_extractor(monkeypatch, tmp_path):
|
| 118 |
+
"""HWP 입력 → LibreOffice ODT 변환 호출 + content.xml 추출 호출 확인."""
|
| 119 |
+
called = {}
|
| 120 |
+
|
| 121 |
+
def fake_hwp_to_odt(hwp_path: Path, out_dir: Path) -> Path:
|
| 122 |
+
called["hwp_path"] = hwp_path
|
| 123 |
+
called["out_dir"] = out_dir
|
| 124 |
+
fake_odt = out_dir / "fake.odt"
|
| 125 |
+
fake_odt.write_bytes(b"PK-fake-odt")
|
| 126 |
+
return fake_odt
|
| 127 |
+
|
| 128 |
+
def fake_odt_to_text(odt_path: Path) -> str:
|
| 129 |
+
called["odt_path"] = odt_path
|
| 130 |
+
return "본문 추출 결과"
|
| 131 |
+
|
| 132 |
+
monkeypatch.setattr("app.services.parser._hwp_to_odt", fake_hwp_to_odt)
|
| 133 |
+
monkeypatch.setattr("app.services.parser._odt_to_text", fake_odt_to_text)
|
| 134 |
+
|
| 135 |
+
out = parse_bytes_to_text(b"HWP-bytes", "안내.hwp")
|
| 136 |
+
|
| 137 |
+
assert out == "본문 추출 결과"
|
| 138 |
+
assert called["hwp_path"].suffix == ".hwp"
|
| 139 |
+
assert called["odt_path"].suffix == ".odt"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_parse_pdf_calls_pdfplumber_only(monkeypatch):
|
| 143 |
+
"""PDF 입력 → LibreOffice 우회, pdfplumber만 호출."""
|
| 144 |
+
called = {"hwp": False, "pdf": False}
|
| 145 |
+
|
| 146 |
+
def fake_hwp_to_odt(*args, **kwargs):
|
| 147 |
+
called["hwp"] = True
|
| 148 |
+
raise AssertionError("PDF 입력에선 LibreOffice가 호출되면 안 됨")
|
| 149 |
+
|
| 150 |
+
def fake_pdf_to_text(pdf_path: Path) -> str:
|
| 151 |
+
called["pdf"] = True
|
| 152 |
+
return "PDF 추출 결과"
|
| 153 |
+
|
| 154 |
+
monkeypatch.setattr("app.services.parser._hwp_to_odt", fake_hwp_to_odt)
|
| 155 |
+
monkeypatch.setattr("app.services.parser._pdf_to_text", fake_pdf_to_text)
|
| 156 |
+
|
| 157 |
+
out = parse_bytes_to_text(b"%PDF-1.4 fake", "doc.pdf")
|
| 158 |
+
|
| 159 |
+
assert out == "PDF 추출 결과"
|
| 160 |
+
assert called["pdf"] is True
|
| 161 |
+
assert called["hwp"] is False
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_parse_libreoffice_failure_raises_parser_error(monkeypatch):
|
| 165 |
+
def boom(*args, **kwargs):
|
| 166 |
+
raise ParserError("LibreOffice 변환 실패")
|
| 167 |
+
|
| 168 |
+
monkeypatch.setattr("app.services.parser._hwp_to_odt", boom)
|
| 169 |
+
|
| 170 |
+
with pytest.raises(ParserError):
|
| 171 |
+
parse_bytes_to_text(b"HWP", "x.hwp")
|
backend/tests/test_slot_extractor.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""슬롯 추출기 단위 테스트.
|
| 2 |
+
|
| 3 |
+
강사 처방(2026-04-28): 날짜·시간·금액은 정규식으로 1차 안전 추출.
|
| 4 |
+
이 테스트가 통과한다는 건 LLM 없이도 핵심 수치 데이터가 확보된다는 자동 증명.
|
| 5 |
+
"""
|
| 6 |
+
from app.services.slot_extractor import (
|
| 7 |
+
extract_amounts,
|
| 8 |
+
extract_dates,
|
| 9 |
+
extract_phones,
|
| 10 |
+
extract_summary_regex_slots,
|
| 11 |
+
extract_times,
|
| 12 |
+
extract_urls,
|
| 13 |
+
find_amount_in_text,
|
| 14 |
+
find_deadline_in_text,
|
| 15 |
+
find_when_in_text,
|
| 16 |
+
format_amount,
|
| 17 |
+
format_date,
|
| 18 |
+
format_time,
|
| 19 |
+
split_supply_tokens,
|
| 20 |
+
strip_markers,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ── 날짜 ─────────────────────────────────────────────────────────
|
| 25 |
+
def test_extract_dates_full_with_year_and_weekday():
|
| 26 |
+
result = extract_dates("2026년 5월 14일(수) 출발")
|
| 27 |
+
assert len(result) == 1
|
| 28 |
+
d = result[0]
|
| 29 |
+
assert d["year"] == 2026
|
| 30 |
+
assert d["month"] == 5
|
| 31 |
+
assert d["day"] == 14
|
| 32 |
+
assert d["weekday"] == "수"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_extract_dates_md_only():
|
| 36 |
+
result = extract_dates("5월 9일(금)까지 제출")
|
| 37 |
+
assert len(result) == 1
|
| 38 |
+
d = result[0]
|
| 39 |
+
assert d["year"] is None
|
| 40 |
+
assert d["month"] == 5
|
| 41 |
+
assert d["day"] == 9
|
| 42 |
+
assert d["weekday"] == "금"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_extract_dates_relative_words():
|
| 46 |
+
result = extract_dates("내일까지 동의서를 제출하세요")
|
| 47 |
+
assert any(d.get("relative") == "내일" for d in result)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_extract_dates_dedups_surface():
|
| 51 |
+
result = extract_dates("5월 9일(금)까지 납부, 5월 9일(금)까지 제출")
|
| 52 |
+
# 동일 표면형은 1개로
|
| 53 |
+
surfaces = [d["ko"] for d in result]
|
| 54 |
+
assert surfaces.count("5월 9일(금)") == 1
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ── 시간 ─────────────────────────────────────────────────────────
|
| 58 |
+
def test_extract_times_ampm():
|
| 59 |
+
result = extract_times("오전 9시 출발")
|
| 60 |
+
assert len(result) == 1
|
| 61 |
+
assert result[0]["hour"] == 9
|
| 62 |
+
assert result[0]["ampm"] == "오전"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_extract_times_with_minute():
|
| 66 |
+
result = extract_times("오후 2시 30분")
|
| 67 |
+
assert result[0]["hour"] == 2
|
| 68 |
+
assert result[0]["minute"] == 30
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_extract_times_24h():
|
| 72 |
+
result = extract_times("회의 14:30 시작")
|
| 73 |
+
assert result[0]["hour"] == 14
|
| 74 |
+
assert result[0]["minute"] == 30
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ── 금액 ─────────────────────────────────────────────────────────
|
| 78 |
+
def test_extract_amounts_with_comma():
|
| 79 |
+
result = extract_amounts("참가비 15,000원")
|
| 80 |
+
assert result[0]["value"] == 15000
|
| 81 |
+
assert result[0]["ko"] == "15,000원"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_extract_amounts_korean_unit():
|
| 85 |
+
result = extract_amounts("5천원만 가져오세요")
|
| 86 |
+
assert any(a["value"] == 5000 for a in result)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_extract_amounts_no_match():
|
| 90 |
+
assert extract_amounts("그냥 평범한 통신문") == []
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ── 베트남어 포매터 ──────────────────────────────────────────────
|
| 94 |
+
def test_format_date_vi_with_year_weekday():
|
| 95 |
+
d = {"ko": "2026년 5월 14일(수)", "year": 2026, "month": 5, "day": 14, "weekday": "수"}
|
| 96 |
+
assert format_date(d, "vi") == "Ngày 14/5/2026 (Thứ Tư)"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_format_date_vi_md_only():
|
| 100 |
+
d = {"ko": "5월 9일(금)", "year": None, "month": 5, "day": 9, "weekday": "금"}
|
| 101 |
+
assert format_date(d, "vi") == "Ngày 9/5 (Thứ Sáu)"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_format_date_vi_relative():
|
| 105 |
+
d = {"ko": "내일", "year": None, "month": None, "day": None,
|
| 106 |
+
"weekday": None, "relative": "내일"}
|
| 107 |
+
assert format_date(d, "vi") == "Ngày mai"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_format_time_vi_morning():
|
| 111 |
+
t = {"ko": "오전 9시", "hour": 9, "minute": 0, "ampm": "오전"}
|
| 112 |
+
assert format_time(t, "vi") == "9 giờ sáng"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_format_amount_vi():
|
| 116 |
+
a = {"ko": "15,000원", "value": 15000, "currency": "KRW"}
|
| 117 |
+
assert format_amount(a, "vi") == "15,000 won"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ── 영어 포매터 (보조 시연용) ────────────────────────────────────
|
| 121 |
+
def test_format_date_en():
|
| 122 |
+
d = {"ko": "5월 14일(수)", "year": None, "month": 5, "day": 14, "weekday": "수"}
|
| 123 |
+
assert format_date(d, "en") == "May 14 (Wed)"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ── URL / 전화 ───────────────────────────────────────────────────
|
| 127 |
+
def test_extract_urls_http_https_www():
|
| 128 |
+
text = "신청은 https://example.kr/apply 에서, 자세한 내용은 www.school.go.kr 참조."
|
| 129 |
+
urls = extract_urls(text)
|
| 130 |
+
assert "https://example.kr/apply" in urls
|
| 131 |
+
assert "www.school.go.kr" in urls
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_extract_urls_dedupes():
|
| 135 |
+
text = "http://a.com 안내 http://a.com 재공지"
|
| 136 |
+
urls = extract_urls(text)
|
| 137 |
+
assert urls.count("http://a.com") == 1
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_extract_phones_dash_formats():
|
| 141 |
+
text = "교무실 02-2649-7232, 학교 849-7003, 신고 1588-0260, 휴대폰 010-1234-5678"
|
| 142 |
+
phones = extract_phones(text)
|
| 143 |
+
assert "02-2649-7232" in phones
|
| 144 |
+
assert "849-7003" in phones
|
| 145 |
+
assert "1588-0260" in phones
|
| 146 |
+
assert "010-1234-5678" in phones
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_extract_phones_does_not_match_amount_with_comma():
|
| 150 |
+
"""'15,000원' 같은 숫자에서 전화번호가 잘못 잡히면 안 됨."""
|
| 151 |
+
text = "참가비 15,000원, 문의 02-1234-5678"
|
| 152 |
+
phones = extract_phones(text)
|
| 153 |
+
assert "02-1234-5678" in phones
|
| 154 |
+
assert not any(p.startswith("15") or p.startswith("000") for p in phones)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ── 통합 진입점 ──────────────────────────────────────────────────
|
| 158 |
+
def test_extract_summary_regex_slots_full_flow():
|
| 159 |
+
text = "5월 14일(수) 오전 9시 출발. 참가비 15,000원."
|
| 160 |
+
out = extract_summary_regex_slots(text, "vi")
|
| 161 |
+
assert any(d["ko"] == "5월 14일(수)" for d in out["dates"])
|
| 162 |
+
assert all(d["source"] == "regex" for d in out["dates"])
|
| 163 |
+
assert any(t["ko"] == "오전 9시" for t in out["times"])
|
| 164 |
+
assert any(a["ko"] == "15,000원" for a in out["amounts"])
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_extract_summary_regex_slots_includes_urls_and_phones():
|
| 168 |
+
"""summary 슬롯에 urls/phones가 ko 그대로 통과 (NLLB 안 거침)."""
|
| 169 |
+
text = "신청 https://apply.school.kr 문의 02-2649-7232"
|
| 170 |
+
out = extract_summary_regex_slots(text, "vi")
|
| 171 |
+
assert any(u["ko"] == "https://apply.school.kr" for u in out["urls"])
|
| 172 |
+
assert any(p["ko"] == "02-2649-7232" for p in out["phones"])
|
| 173 |
+
# 보호: translated가 ko와 동일해야 한다
|
| 174 |
+
for slot in out["urls"] + out["phones"]:
|
| 175 |
+
assert slot["translated"] == slot["ko"]
|
| 176 |
+
assert slot["source"] == "regex"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_find_when_combines_date_and_time():
|
| 180 |
+
when = find_when_in_text("5월 14일(수) 오전 9시 출발", "vi")
|
| 181 |
+
assert "Ngày 14/5" in when
|
| 182 |
+
assert "9 giờ sáng" in when
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_find_amount_returns_korean_surface():
|
| 186 |
+
assert find_amount_in_text("참가비 15,000원", "vi") == "15,000원"
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_find_deadline_phrase_with_까지():
|
| 190 |
+
assert "5월 9일(금)까지" in find_deadline_in_text("5월 9일(금)까지 제출")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_find_deadline_not_broken_by_number_comma():
|
| 194 |
+
"""`15,000원 (...까지...)` 의 콤마에서 잘려서 '000원...'만 잡히던 회귀 방지."""
|
| 195 |
+
text = "참가비: 15,000원 (5월 9일(금)까지 스쿨뱅킹으로 납부)"
|
| 196 |
+
result = find_deadline_in_text(text)
|
| 197 |
+
assert result is not None
|
| 198 |
+
assert "5월 9일" in result
|
| 199 |
+
assert not result.lstrip().startswith("000")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ── 마크업 strip ────────────────────────────────────────────────
|
| 203 |
+
def test_strip_markers_leading_bullet():
|
| 204 |
+
assert strip_markers("■ 준비물: 도시락") == "준비물: 도시락"
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def test_strip_markers_multiple_chars():
|
| 208 |
+
assert strip_markers("▶▸ 안내사항") == "안내사항"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_strip_markers_preserves_inside():
|
| 212 |
+
"""문장 내부의 기호는 보존 (시작 부분만 제거)."""
|
| 213 |
+
assert strip_markers("■ 5월 14일 - 출발") == "5월 14일 - 출발"
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_strip_markers_no_op_when_clean():
|
| 217 |
+
assert strip_markers("도시락을 준비하세요") == "도시락을 준비하세요"
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ── 준비물 토큰 분해 ─────────────────────────────────────────────
|
| 221 |
+
def test_split_supply_tokens_comma_separated():
|
| 222 |
+
tokens = split_supply_tokens("도시락, 물통, 돗자리, 편한 운동화, 여벌 옷")
|
| 223 |
+
assert "도시락" in tokens
|
| 224 |
+
assert "물통" in tokens
|
| 225 |
+
assert "돗자리" in tokens
|
| 226 |
+
assert "편한 운동화" in tokens
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_split_supply_tokens_strips_prefix():
|
| 230 |
+
tokens = split_supply_tokens("준비물: 도시락, 물병을 준비해 주세요")
|
| 231 |
+
assert "도시락" in tokens
|
| 232 |
+
assert "물병" in tokens
|
backend/tests/test_translator_protection.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""URL/전화 placeholder 보호 단위 테스트.
|
| 2 |
+
|
| 3 |
+
NLLB 호출 자체는 모킹해서 마스킹 → 번역 → 복원 사이클만 검증.
|
| 4 |
+
실제 NLLB 모델은 무거우므로 _translate를 monkeypatch.
|
| 5 |
+
"""
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from app.services.translator import (
|
| 9 |
+
_is_url_or_phone,
|
| 10 |
+
_mask_protected_entities,
|
| 11 |
+
_restore_protected_entities,
|
| 12 |
+
translate_short_sentence,
|
| 13 |
+
translate_term,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ── 마스킹 / 복원 단위 ─────────────────────────────────────────
|
| 18 |
+
def test_mask_url_replaces_with_token():
|
| 19 |
+
masked, holders = _mask_protected_entities("신청은 https://apply.kr 에서")
|
| 20 |
+
assert "https://apply.kr" not in masked
|
| 21 |
+
assert "⟦P0⟧" in masked
|
| 22 |
+
assert holders == ["https://apply.kr"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_mask_phone_replaces_with_token():
|
| 26 |
+
masked, holders = _mask_protected_entities("문의 02-2649-7232")
|
| 27 |
+
assert "02-2649-7232" not in masked
|
| 28 |
+
assert "⟦P0⟧" in masked
|
| 29 |
+
assert holders == ["02-2649-7232"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_mask_url_and_phone_separate_tokens():
|
| 33 |
+
masked, holders = _mask_protected_entities(
|
| 34 |
+
"https://a.com 문의 02-1234-5678"
|
| 35 |
+
)
|
| 36 |
+
# 두 엔티티 각각 다른 인덱스
|
| 37 |
+
assert "⟦P0⟧" in masked and "⟦P1⟧" in masked
|
| 38 |
+
assert set(holders) == {"https://a.com", "02-1234-5678"}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_mask_no_entities_returns_text_unchanged():
|
| 42 |
+
masked, holders = _mask_protected_entities("그냥 평범한 통신문")
|
| 43 |
+
assert masked == "그냥 평범한 통신문"
|
| 44 |
+
assert holders == []
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_restore_returns_original_entities():
|
| 48 |
+
masked, holders = _mask_protected_entities("문의 02-2649-7232")
|
| 49 |
+
restored = _restore_protected_entities(masked, holders)
|
| 50 |
+
assert restored == "문의 02-2649-7232"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_restore_with_translated_token_intact():
|
| 54 |
+
"""NLLB 번역 결과에도 토큰이 살아남으면 복원 가능."""
|
| 55 |
+
holders = ["02-2649-7232"]
|
| 56 |
+
fake_translated = "Liên hệ ⟦P0⟧"
|
| 57 |
+
assert _restore_protected_entities(fake_translated, holders) == "Liên hệ 02-2649-7232"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── 슬롯 단위 가드 ────────────────────────────────────────────
|
| 61 |
+
def test_is_url_or_phone_true_for_url():
|
| 62 |
+
assert _is_url_or_phone("https://example.kr/path")
|
| 63 |
+
assert _is_url_or_phone("www.school.go.kr")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_is_url_or_phone_true_for_phone():
|
| 67 |
+
assert _is_url_or_phone("02-2649-7232")
|
| 68 |
+
assert _is_url_or_phone("1588-0260")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_is_url_or_phone_false_for_normal_text():
|
| 72 |
+
assert not _is_url_or_phone("도시락")
|
| 73 |
+
assert not _is_url_or_phone("5월 14일")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_translate_term_passthrough_for_url_phone(monkeypatch):
|
| 77 |
+
"""URL/전화는 어떤 언어든 번역 안 거치고 ko 그대로."""
|
| 78 |
+
# glossary 호출이 일어나면 안 됨 (URL/전화 가드가 그 위에서 차단)
|
| 79 |
+
called = []
|
| 80 |
+
monkeypatch.setattr(
|
| 81 |
+
"app.services.translator._get_glossary",
|
| 82 |
+
lambda: called.append("glossary") or [],
|
| 83 |
+
)
|
| 84 |
+
assert translate_term("https://apply.kr", "vi") == "https://apply.kr"
|
| 85 |
+
assert translate_term("02-2649-7232", "en") == "02-2649-7232"
|
| 86 |
+
assert called == []
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ── translate_short_sentence E2E (NLLB 모킹) ──────────────────
|
| 90 |
+
def test_translate_short_sentence_protects_url_through_nllb(monkeypatch):
|
| 91 |
+
"""URL이 NLLB 호출 후에도 원래 문자열로 복원돼야 한다."""
|
| 92 |
+
captured_input = []
|
| 93 |
+
|
| 94 |
+
def fake_translate(text, target_nllb="vie_Latn", max_length=512):
|
| 95 |
+
captured_input.append(text)
|
| 96 |
+
# NLLB는 마스킹된 토큰을 그대로 통과시킨다고 가정 (실제로도 ⟦…⟧ 안 깸)
|
| 97 |
+
return text.replace("문의", "Liên hệ").replace("신청은", "Đăng ký")
|
| 98 |
+
|
| 99 |
+
monkeypatch.setattr("app.services.translator._translate", fake_translate)
|
| 100 |
+
monkeypatch.setattr("app.services.translator._get_glossary", lambda: [])
|
| 101 |
+
monkeypatch.setattr(
|
| 102 |
+
"app.services.translator._sejong.find_glossary_hits",
|
| 103 |
+
lambda text, glossary, lang: [],
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
out = translate_short_sentence(
|
| 107 |
+
"신청은 https://apply.kr 에서, 문의 02-2649-7232",
|
| 108 |
+
"vi",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# 1) NLLB에 들어간 입력엔 URL/전화가 토큰으로 마스킹됨
|
| 112 |
+
assert "https://apply.kr" not in captured_input[0]
|
| 113 |
+
assert "02-2649-7232" not in captured_input[0]
|
| 114 |
+
assert "⟦P0⟧" in captured_input[0]
|
| 115 |
+
|
| 116 |
+
# 2) 최종 출력엔 URL/전화가 원래 문자열로 복원됨
|
| 117 |
+
assert "https://apply.kr" in out
|
| 118 |
+
assert "02-2649-7232" in out
|
| 119 |
+
assert "⟦P" not in out
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
backend:
|
| 3 |
+
build: ./backend
|
| 4 |
+
ports:
|
| 5 |
+
- "8000:8000"
|
| 6 |
+
volumes:
|
| 7 |
+
- ./backend:/app
|
| 8 |
+
- ./scripts:/app/scripts
|
| 9 |
+
- ./data:/app/external_data
|
| 10 |
+
- ./model:/app/external_model
|
| 11 |
+
- hf_cache:/root/.cache/huggingface
|
| 12 |
+
env_file:
|
| 13 |
+
- ./backend/.env
|
| 14 |
+
dns:
|
| 15 |
+
- 8.8.8.8
|
| 16 |
+
- 1.1.1.1
|
| 17 |
+
# LibreOffice ODT 변환 후 H2Orestart cleanup 버그로 segfault 발생.
|
| 18 |
+
# 코어 덤프 비활성화 (RLIMIT_CORE=0) — wsl-crashes 누적 → 디스크 마비 방지.
|
| 19 |
+
ulimits:
|
| 20 |
+
core: 0
|
| 21 |
+
restart: unless-stopped
|
| 22 |
+
|
| 23 |
+
volumes:
|
| 24 |
+
hf_cache:
|
model/classification/.gitkeep
ADDED
|
File without changes
|
model/classification/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
전체 파이프라인 (확정 버전)
|
| 2 |
+
|
| 3 |
+
┌──────────────────────────────────────────────────────────────────┐
|
| 4 |
+
│ 호스트 앱 → POST /notice/analyze │
|
| 5 |
+
│ body: HWP/PDF (multipart) or text (json) │
|
| 6 |
+
└──────────────────────────────────────────────────────────────────┘
|
| 7 |
+
↓
|
| 8 |
+
[1] 텍스트 변환 (services/parser.py — 신설)
|
| 9 |
+
· text → passthrough
|
| 10 |
+
· PDF → pdfplumber + table 분리
|
| 11 |
+
· HWP/HWPX → LibreOffice + pdfplumber
|
| 12 |
+
· 이미지 → 세종님 OCR (연휴 후 합류)
|
| 13 |
+
→ output: clean_text (str)
|
| 14 |
+
|
| 15 |
+
↓
|
| 16 |
+
[2] 정규식 슬롯 추출 (services/slot_extractor.py — 이미 구현)
|
| 17 |
+
· dates / times / amounts (이미 있음)
|
| 18 |
+
· urls / phones (NEW — 추가 필요)
|
| 19 |
+
· 통신문 전체 단위. summary용 재료.
|
| 20 |
+
→ output: regex_slots (dict)
|
| 21 |
+
|
| 22 |
+
↓
|
| 23 |
+
[3] 할일 추출 (services/extractor.py — 윤정님 v2 wrapper)
|
| 24 |
+
입력: clean_text
|
| 25 |
+
내부: 문장분리 → binary 분류(0.5컷) → 정규식(due_date/amount/action_hint)
|
| 26 |
+
출력: list[YunjeongTodo]
|
| 27 |
+
각 항목: { text, source, due_date, amount,
|
| 28 |
+
confidence, action_hint }
|
| 29 |
+
빈 리스트면 items 없음.
|
| 30 |
+
|
| 31 |
+
↓
|
| 32 |
+
[4] 카테고리 분류 (services/classifier.py — 경이님 6-class wrapper)
|
| 33 |
+
입력: 각 todo.text
|
| 34 |
+
출력: category ∈ {일정, 준비물, 제출, 비용, 건강·안전, 기타}
|
| 35 |
+
|
| 36 |
+
↓
|
| 37 |
+
[5] 번역 (services/translator.py — 이미 구현, 보강 필요)
|
| 38 |
+
슬롯 단위:
|
| 39 |
+
· dates/times/amounts → i18n formatter (deterministic, NLLB 안 거침)
|
| 40 |
+
· urls/phones → ko 그대로 (NEW)
|
| 41 |
+
· places/supplies → glossary lookup → 없으면 NLLB
|
| 42 |
+
본문 단위 (todo.text):
|
| 43 |
+
· URL/전화 ⟦P0⟧ 토큰 치환 → NLLB → 토큰 복원 (NEW, 보호)
|
| 44 |
+
· glossary injection → NLLB
|
| 45 |
+
|
| 46 |
+
↓
|
| 47 |
+
[6] 응답 빌드 (routers/notice.py)
|
| 48 |
+
summary = SummarySlots(
|
| 49 |
+
dates, times, amounts, urls, phones, # 정규식
|
| 50 |
+
places, supplies, deadlines # items에서 집계
|
| 51 |
+
)
|
| 52 |
+
items = [
|
| 53 |
+
AnalyzeItem(
|
| 54 |
+
text_ko = todo.text,
|
| 55 |
+
title_translated = ...번역...,
|
| 56 |
+
category = 경이(todo.text), # 주제 (6-class)
|
| 57 |
+
action_hint = todo.action_hint, # 행동 (신청/제출/...)
|
| 58 |
+
due_date = todo.due_date,
|
| 59 |
+
amount = todo.amount,
|
| 60 |
+
importance = todo.confidence,
|
| 61 |
+
...
|
| 62 |
+
)
|
| 63 |
+
for todo in yunjeong_todos
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
↓
|
| 67 |
+
[7] TTS (services/tts.py — 이미 구현)
|
| 68 |
+
슬롯 + 할일 합쳐 문장별 mp3 생성.
|
| 69 |
+
importance 내림차순 정렬.
|
| 70 |
+
|
| 71 |
+
↓
|
| 72 |
+
JSON 응답 → 호스트 앱
|
| 73 |
+
{
|
| 74 |
+
"summary": SummarySlots,
|
| 75 |
+
"items": [AnalyzeItem, ...],
|
| 76 |
+
"tts_url": "...",
|
| 77 |
+
...
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
### 카테고리 분류-경이님
|
| 82 |
+
|
| 83 |
+
`일정`, `준비물`, `제출`, `비용`, `건강·안전`, `기타`
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# 가장 중요한 핵심과제: 모델 성능 비교 (베이스라인 VS. 파인튜닝)
|
| 87 |
+
1. 조건: 베이스라인 모델, 파인튜닝한 모델에 들어가는 input data가 동일한 데이터셋 및 동일한 조건에서 두 모델의 성능을 비교. 다시 말해서, 기존에 있던 모델을 가지고 동일한 조건을 맞춰서 일정, 준비물, 제출, 비용, 건강·안전, 기타에 대한 분류 성능 점수가 나와야하고 파인튜닝한 모델을 동일한 조건으로 6가지 분류 성능 점수가 나와야 비교가 가능. 그래서 파인튜닝된 모델이 베이스라인모델보다 성능이 좋다라는 지표가 나와야 성능의 우수함을 입증할 수 있음. 근거 자료를 만들어야 함.
|
| 88 |
+
|
| 89 |
+
2. 두 모델(베이스라인 모델, 파인튜닝한 모델)에 들어가는 데이터는 답안지가 없기 때문에 accuracy가 아닌 그 모델의 맞는 평가 방식 및 성능 지표를 뽑아야 함. 사용하고자 하는 모델들의 기능을 자세한 설명 듣기.
|
| 90 |
+
|
| 91 |
+
예시로, Precision이라고 하면 10개 단어 중에 2개 단어만 맞췄다. 그래서 그 모델로 해서 모든 텍스트 데이터 돌아서 몇 프로 맞췄으니까 얘는 성능이 얼마다 라고 얘기하는 것도 있다.
|
| 92 |
+
|
| 93 |
+
==> 파인튜닝의 성능이 베이스라인 성능보다 좋은 쪽으로 모델이 나와야하고 그 모델에 맞는 평가 지표가 나와야 한다. 글씨로 정리하는 것 뿐만아니라 시각적인 도구를 활용해서 그래프 혹은 직선 사용 등으로 제시할 근거 자료가 필요.
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# 윤정님 데이터 정보들
|
| 97 |
+
|
| 98 |
+
-원본 데이터 : \data\galsan_txt
|
| 99 |
+
-전처리 파일 : file/preprocess_txt_to_jsonl.py
|
| 100 |
+
-���처리 후 데이터 : data/v2.1_notices_galsan.jsonl
|
| 101 |
+
|
| 102 |
+
-윤정님이 전처리 후 데이터로 모델 학습 시켰고 실 서비스에서
|
| 103 |
+
데이터 넣고 나오는 아웃풋을 보려면 predict.py를 돌려봐야
|
| 104 |
+
하고 그 predict.py의 아웃풋이 경이님 모델로 들어감.
|
| 105 |
+
|
| 106 |
+
-predict.py를 경이님 모델의 인풋으로 해서 넣어야 함.
|
| 107 |
+
results.append({
|
| 108 |
+
"text": sentence,
|
| 109 |
+
"source": source,
|
| 110 |
+
"due_date": extract_due_date(sentence),
|
| 111 |
+
"amount": extract_amount(sentence),
|
| 112 |
+
"confidence": round(confidence, 4),
|
| 113 |
+
"action_hint": extract_action_hint(sentence),
|
| 114 |
+
})
|
| 115 |
+
[
|
| 116 |
+
{
|
| 117 |
+
"text": "모국어를 사용하는 강사가 단계별로 친절히 가르치는 동영상 강의(VOD)를 PC나 모바일 기기로 접속하여 언제든지 원하는 장소에서 편리하게 학습할 수 있는 좋은 기회이오니, 한국어 학습이 필요한 다문화가정 학생 및 학부모(보호자) 모두 기한 내 신청하여 주시기 바랍니다.",
|
| 118 |
+
"source": "sample_pdfplumber.txt",
|
| 119 |
+
"due_date": null,
|
| 120 |
+
"amount": null,
|
| 121 |
+
"confidence": 0.9946,
|
| 122 |
+
"action_hint": "신청"
|
| 123 |
+
}
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
# 추천된 모델 및 기술 스택
|
| 127 |
+
|
| 128 |
+
추천1. KcELECTRA fine-tune
|
| 129 |
+
|
| 130 |
+
이유:
|
| 131 |
+
1. 데이터 양 충분 (700~1400 sentence) — KcELECTRA fine-tune 권장 sweet spot
|
| 132 |
+
2. 학교 도메인 어휘 OOV 문제 해결 (subword tokenization)
|
| 133 |
+
3. 윤정님 base 모델과 같은 koelectra-small ─ backbone 공유 가능
|
| 134 |
+
→ 백엔드 RAM 중복 로드 회피 (둘 다 base는 같고 head만 다름)
|
| 135 |
+
4. CPU 추론 가능 (small 변형이라 ~50ms/문장)
|
| 136 |
+
5. 윤정님이 이미 같은 모델로 학습 환경 셋업 완료 — 학습 코드 재활용
|
| 137 |
+
메모리 효율 트릭
|
| 138 |
+
|
| 139 |
+
두 모델이 같은 backbone 공유하면 RAM 중복 0
|
| 140 |
+
shared_encoder = AutoModel.from_pretrained("monologg/koelectra-small-v3-discriminator")
|
| 141 |
+
|
| 142 |
+
윤정_head = BinaryHead(shared_encoder) # 할일 추출 (binary)
|
| 143 |
+
경이_head = MulticlassHead(shared_encoder, 6) # 카테고리 (6-class)
|
| 144 |
+
이렇게 짜면 경이 모델 추가에 따른 RAM 증가가 head만큼(~수MB)으로 줄어듦. 이상적.
|
| 145 |
+
|
| 146 |
+
단계적 권장안
|
| 147 |
+
|
| 148 |
+
1주차: 경이님이 KcELECTRA fine-tune 시도
|
| 149 |
+
|
| 150 |
+
데이터: notice_sample_v3.csv + notices_galsan.jsonl 라벨 부분
|
| 151 |
+
GPU: Colab T4 무료로 충분 (~20분 학습)
|
| 152 |
+
검증: 갈산초 holdout F1 + cross-validation
|
| 153 |
+
|
| 154 |
+
2주차: 정확도 비교
|
| 155 |
+
|
| 156 |
+
simple (현재) vs KcELECTRA fine-tune
|
| 157 |
+
5%+ F1 향상 → KcELECTRA 채택, 그 미만 → simple 유지
|
| 158 |
+
simple은 항상 fallback으로 유지
|
| 159 |
+
|
| 160 |
+
3주차: 시연 통합
|
| 161 |
+
|
| 162 |
+
더 좋은 쪽으로 교체
|
| 163 |
+
선생님께 학습 코드/모델 공유로 재현성 보장
|
| 164 |
+
주의
|
| 165 |
+
시연 4주 임박이라 무리하면 안 됨:
|
| 166 |
+
|
| 167 |
+
KcELECTRA fine-tune 학습 자체는 빠르지만 검증 + 디버깅 시간 필요
|
| 168 |
+
simple v1이 이미 75% 정확도라 시연 박살 안 남
|
| 169 |
+
실패하면 simple 그대로 — 백업 명확히
|
| 170 |
+
경이님 부담:
|
| 171 |
+
|
| 172 |
+
학습 환경 셋업 (윤정님 코드 빌려쓰기로 부담 경감)
|
| 173 |
+
라벨 데이터 정제 (현재 v3.csv 라벨 품질 확인)
|
| 174 |
+
검증셋 분리 (갈산초 holdout)
|
| 175 |
+
한 줄 요약
|
| 176 |
+
데이터 충분 → KcELECTRA fine-tune 시도가 정답. 다만 simple v1을 fallback으로 항상 유지. 윤정님 backbone 공유하면 RAM 효율 + 학습 환경 재활용 가능.
|
| 177 |
+
|
| 178 |
+
경이님이 비교 실험 의지 있는 건 좋음. 다만 제대로 비교해야 가치 있고, 시연 4주 안 압박도 있으니 셋업이 중요.
|
| 179 |
+
|
| 180 |
+
# 비교 실험 설계 가이드
|
| 181 |
+
1. 후보 모델 선정 (3~4개가 최대)
|
| 182 |
+
모델 카테고리 학습 시간 강점
|
| 183 |
+
TF-IDF + LogReg 베이스라인 (필수) 수초 빠름
|
| 184 |
+
SBERT + LightGBM 임베딩 ML 분 의미 유사도
|
| 185 |
+
KcELECTRA-small fine-tune 한국어 BERT 20~30분 도메인 학습
|
| 186 |
+
KoBERT fine-tune 한국어 BERT 20~30분 한국어 특화
|
| 187 |
+
|
| 188 |
+
→ 3개 권장: TF-IDF (baseline) + SBERT + KcELECTRA. 4개는 시간 박살.
|
| 189 |
+
|
| 190 |
+
2. 공정 비교를 위한 절대 규칙
|
| 191 |
+
(a) 동일 train/val/test 분할 강제
|
| 192 |
+
|
| 193 |
+
scripts/split_dataset.py 만들어서 ONE TIME 실행:
|
| 194 |
+
random.seed(42) # 무조건 고정
|
| 195 |
+
labels = stratified_split(data, train=0.8, val=0.1, test=0.1)
|
| 196 |
+
|
| 197 |
+
split_v1.csv 라는 단일 파일로 저장 → 모든 모델이 같은 분할 사용
|
| 198 |
+
|
| 199 |
+
(b) Metric 통일
|
| 200 |
+
|
| 201 |
+
Macro F1 (메인) — 클래스 불균형 대비
|
| 202 |
+
Per-class F1 — "비용은 잘 잡는데 건강·안전은 못 잡는다" 같은 진단
|
| 203 |
+
Confusion matrix — 어디서 헷갈리는지 시각화
|
| 204 |
+
|
| 205 |
+
(c) Seed 고정 — numpy, torch, random 모두 42
|
| 206 |
+
|
| 207 |
+
추천2. 모델: klue/roberta-base
|
| 208 |
+
|
| 209 |
+
KoELECTRA(A단계)와 겹치지 않고, KLUE 벤치마크에서 한국어 분류 SOTA.
|
| 210 |
+
파인튜닝이 안정적이고 허깅페이스에서 바로 쓸 수 있음.
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
|
model/classification/README2.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 카테고리 분류 모델 — 경이님 작업 가이드
|
| 2 |
+
|
| 3 |
+
> 이 문서는 경이님이 작업 내용을 빠르게 이해하고 바로 실행할 수 있도록 작성되었습니다.
|
| 4 |
+
> 코드가 **왜** 이렇게 짜여졌는지, **무엇**을 하는지 중심으로 설명합니다.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 전체 구조 한눈에 보기
|
| 9 |
+
|
| 10 |
+
```
|
| 11 |
+
model/classification/
|
| 12 |
+
├── data/
|
| 13 |
+
│ ├── notice_sample_v3.csv ← 경이님이 라벨링한 학습 데이터 (150개)
|
| 14 |
+
│ └── split_v1.csv ← train/val/test 분할 (scripts 실행 후 생성됨)
|
| 15 |
+
├── src/
|
| 16 |
+
│ ├── predict.py ← 백엔드 진입점 (이게 핵심!)
|
| 17 |
+
│ ├── classifier_simple.py ← 베이스라인 모델 (TF-IDF + LogReg)
|
| 18 |
+
│ └── classifier_kcelectra.py ← KcELECTRA 추론 모듈
|
| 19 |
+
├── scripts/
|
| 20 |
+
│ ├── split_dataset.py ← 데이터 분할 (딱 한 번만 실행)
|
| 21 |
+
│ └── evaluate_compare.py ← 두 모델 성능 비교
|
| 22 |
+
├── notebooks/
|
| 23 |
+
│ ├── 01_train_kcelectra.ipynb ← GPU 학습 (Colab에서 실행)
|
| 24 |
+
│ └── 02_evaluate_compare.ipynb ← 성능 비교 시각화
|
| 25 |
+
├── checkpoints/
|
| 26 |
+
│ ├── simple_tfidf_logreg.pkl ← 베이스라인 모델 저장 파일 (학습 후 생성)
|
| 27 |
+
│ └── kcelectra-category/ ← KcELECTRA 파인튜닝 결과 (Colab 후 복사)
|
| 28 |
+
└── README2.md ← 지금 이 파일
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## 파이프라인에서 경이님 역할
|
| 34 |
+
|
| 35 |
+
전체 가정통신문 분석 시스템에서 경이님 모델은 **[4]번 단계**를 담당합니다.
|
| 36 |
+
|
| 37 |
+
```
|
| 38 |
+
[3] 윤정님 모델 (할일 추출)
|
| 39 |
+
↓
|
| 40 |
+
text: "체험학습 비용 20,000원을 납부해 주세요."
|
| 41 |
+
↓
|
| 42 |
+
[4] 경이님 모델 (카테고리 분류) ← 여기!
|
| 43 |
+
↓
|
| 44 |
+
category: "비용"
|
| 45 |
+
↓
|
| 46 |
+
[5] 번역 → [6] 응답 빌드 → [7] TTS
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
**윤정님이 추출한 문장들이 경이님 모델로 들어오고**, 경이님 모델이 각 문장을 6개 카테고리 중 하나로 분류합니다.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## 6가지 카테고리 설명
|
| 54 |
+
|
| 55 |
+
| 카테고리 | 의미 | 예시 |
|
| 56 |
+
|--------|------|------|
|
| 57 |
+
| **일정** | 날짜·시간·행사 관련 | "운동회는 10월 5일 오전 9시에..." |
|
| 58 |
+
| **준비물** | 챙겨야 할 물건 | "도시락과 물을 준비해 주세요." |
|
| 59 |
+
| **제출** | 서류·동의서 제출 | "동의서를 담임선생님께 제출해 주세요." |
|
| 60 |
+
| **비용** | 금액·납부 관련 | "급식비 65,000원을 납부해 주세요." |
|
| 61 |
+
| **건강·안전** | 건강·안전 지침 | "발열 증상 시 등교를 자제해 주세요." |
|
| 62 |
+
| **기타** | 위에 해당 없음 | "궁금한 사항은 담임선생님께 문의..." |
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## 백엔드 연결 구조 이해하기
|
| 67 |
+
|
| 68 |
+
백엔드 `backend/app/services/classifier.py`가 이렇게 호출합니다:
|
| 69 |
+
|
| 70 |
+
```python
|
| 71 |
+
from src.predict import predict_one
|
| 72 |
+
result = predict_one("납부할 급식비는 6만 5천원입니다.", model="simple")
|
| 73 |
+
# → {"category": "비용", "confidence": 0.87, "model_used": "simple"}
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
`predict.py`의 `predict_one()`이 **모든 모델의 단일 창구**입니다.
|
| 77 |
+
`model="simple"` → TF-IDF+LogReg 사용
|
| 78 |
+
`model="kcelectra"` → KcELECTRA 파인튜닝 모델 사용
|
| 79 |
+
`model="auto"` → KcELECTRA 체크포인트 있으면 사용, 없으면 simple로 자동 전환
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## 파일별 역할 상세 설명
|
| 84 |
+
|
| 85 |
+
### `data/notice_sample_v3.csv`
|
| 86 |
+
학습 데이터 파일입니다. 컬럼 2개 (`text`, `category`).
|
| 87 |
+
|
| 88 |
+
- 150개의 문장이 미리 라벨링 되어 있습니다
|
| 89 |
+
- 각 카테고리당 약 20~25개씩 균등하게 구성
|
| 90 |
+
- **더 많은 데이터를 추가할수록 모델 성능이 향상됩니다**
|
| 91 |
+
- 형식: `문장,카테고리` (맨 아래에 행 추가)
|
| 92 |
+
- 카테고리는 반드시 `일정`, `준비물`, `제출`, `비용`, `건강·안전`, `기타` 중 하나
|
| 93 |
+
|
| 94 |
+
### `src/classifier_simple.py` — 베이스라인 (TF-IDF + Logistic Regression)
|
| 95 |
+
|
| 96 |
+
**왜 TF-IDF인가?**
|
| 97 |
+
- TF-IDF는 각 단어가 문서에서 얼마나 중요한지 숫자로 나타냅니다
|
| 98 |
+
- "납부", "입금", "원" 같은 단어가 **비용** 카테고리에서 많이 나오면 높은 점수를 받음
|
| 99 |
+
- GPU 없이 CPU에서 수십 ms만에 실행 — 백엔드 서버 부담 없음
|
| 100 |
+
|
| 101 |
+
**왜 char_wb n-gram인가?**
|
| 102 |
+
- 한국어는 "납부해" "납부하여" "납부하시기" 등 동사 변형이 많음
|
| 103 |
+
- 글자 단위 2~4글자 조합(`ngram_range=(2,4)`)으로 형태소 변형 문제 해결
|
| 104 |
+
- 예: "납부" → "납부", "부하", "부해", "납부하" 등으로 분해해 학습
|
| 105 |
+
|
| 106 |
+
**사용법:**
|
| 107 |
+
```bash
|
| 108 |
+
cd model/classification
|
| 109 |
+
python src/classifier_simple.py # 학습 + 저장
|
| 110 |
+
python src/classifier_simple.py --eval # 테스트 평가
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
### `src/classifier_kcelectra.py` — KcELECTRA 추론 모듈
|
| 114 |
+
|
| 115 |
+
**왜 KcELECTRA인가?**
|
| 116 |
+
- 한국어 특화 사전학습 모델 (윤정님 모델과 동일한 backbone!)
|
| 117 |
+
- ELECTRA 구조: BERT보다 학습 효율이 2~3배 좋음
|
| 118 |
+
- `koelectra-small`: 메��리 사용량 적어 CPU 서버에서도 동작
|
| 119 |
+
|
| 120 |
+
**중요:** 이 파일은 **추론만** 합니다. 학습은 `notebooks/01_train_kcelectra.ipynb`에서.
|
| 121 |
+
|
| 122 |
+
학습이 끝나면 `checkpoints/kcelectra-category/` 폴더가 생깁니다.
|
| 123 |
+
이 폴더가 없으면 `is_ready()` 함수가 False를 반환하여 simple로 자동 전환됩니다.
|
| 124 |
+
|
| 125 |
+
### `src/predict.py` — 백엔드 진입점
|
| 126 |
+
|
| 127 |
+
**왜 이 파일이 중요한가?**
|
| 128 |
+
- 백엔드가 `from src.predict import predict_one`으로 이 함수를 호출
|
| 129 |
+
- 내부에서 어떤 모델을 쓸지 결정하는 로직 포함
|
| 130 |
+
- `model="auto"`로 설정하면 체크포인트 유무에 따라 자동 선택
|
| 131 |
+
|
| 132 |
+
**반환 형식:**
|
| 133 |
+
```python
|
| 134 |
+
{
|
| 135 |
+
"text": "납부할 급식비는 6만 5천원입니다.",
|
| 136 |
+
"category": "비용", # 최종 분류 결과
|
| 137 |
+
"confidence": 0.87, # 얼마나 확신하는지 (0~1)
|
| 138 |
+
"model_used": "simple", # 실제 사용된 모델
|
| 139 |
+
"probs": { # explain=True일 때만 포함
|
| 140 |
+
"일정": 0.02,
|
| 141 |
+
"비용": 0.87,
|
| 142 |
+
...
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### `scripts/split_dataset.py` — 데이터 분할
|
| 148 |
+
|
| 149 |
+
**왜 딱 한 번만 실행해야 하는가?**
|
| 150 |
+
- 베이스라인과 KcELECTRA가 **완전히 동일한** 데이터로 학습/평가해야 공정한 비교 가능
|
| 151 |
+
- 한 번 분할하면 `split_v1.csv`에 고정 저장
|
| 152 |
+
- 랜덤 시드 42로 고정 → 언제 실행해도 같은 결과
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
python scripts/split_dataset.py # 최초 1회 실행
|
| 156 |
+
python scripts/split_dataset.py --force # 강제 재생성 (비추천)
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
분할 비율: **Train 80% / Val 10% / Test 10%**
|
| 160 |
+
Stratified Split: 각 카테고리에서 균등하게 뽑음
|
| 161 |
+
|
| 162 |
+
### `scripts/evaluate_compare.py` — 성능 비교
|
| 163 |
+
|
| 164 |
+
두 모델을 같은 test 데이터로 평가하고 결과를 저장합니다.
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
python scripts/evaluate_compare.py
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
생성 파일:
|
| 171 |
+
- `data/eval_results_simple.json` — 베이스라인 상세 결과
|
| 172 |
+
- `data/eval_results_kcelectra.json` — KcELECTRA 상세 결과
|
| 173 |
+
- `data/eval_comparison_summary.csv` — 두 모델 비교 요약
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## 실행 순서 (처음부터 전부 하려면)
|
| 178 |
+
|
| 179 |
+
### Step 1. 데이터 분할 (딱 한 번)
|
| 180 |
+
```bash
|
| 181 |
+
cd c:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification
|
| 182 |
+
python scripts/split_dataset.py
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
### Step 2. 베이스라인 학습
|
| 186 |
+
```bash
|
| 187 |
+
python src/classifier_simple.py
|
| 188 |
+
```
|
| 189 |
+
`checkpoints/simple_tfidf_logreg.pkl` 파일이 생성됩니다.
|
| 190 |
+
|
| 191 |
+
### Step 3. KcELECTRA 파인튜닝 (Colab GPU 필요)
|
| 192 |
+
1. Google Colab 접속 → 런타임 → 런타임 유형 변경 → **GPU**
|
| 193 |
+
2. `notebooks/01_train_kcelectra.ipynb` 업로드
|
| 194 |
+
3. `data/notice_sample_v3.csv`와 `data/split_v1.csv` 업로드
|
| 195 |
+
4. 모든 셀 순서대로 실행 (~20분)
|
| 196 |
+
5. `checkpoints/kcelectra-category/` 폴더 다운로드
|
| 197 |
+
6. 로컬 `checkpoints/kcelectra-category/`에 붙여넣기
|
| 198 |
+
|
| 199 |
+
### Step 4. 성능 비교
|
| 200 |
+
```bash
|
| 201 |
+
python scripts/evaluate_compare.py
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
### Step 5. 시각화 확인
|
| 205 |
+
Jupyter에서 `notebooks/02_evaluate_compare.ipynb` 열어서 실행.
|
| 206 |
+
|
| 207 |
+
---
|
| 208 |
+
|
| 209 |
+
## 평가 지표 설명
|
| 210 |
+
|
| 211 |
+
### Macro F1 (메인 지표)
|
| 212 |
+
- 6개 카테고리 각각의 F1을 구한 뒤 **평균**
|
| 213 |
+
- 클래스 불균형에 강함 (특정 클래스가 많아도 편향 없음)
|
| 214 |
+
- **0.8 이상이면 좋은 성능**
|
| 215 |
+
|
| 216 |
+
### F1 Score = 2 × (Precision × Recall) / (Precision + Recall)
|
| 217 |
+
- **Precision (정밀도):** "비용이라고 예측한 것 중 실제로 비용인 비율"
|
| 218 |
+
- **Recall (재현율):** "실제 비용인 것 중 비용이라고 맞춘 비율"
|
| 219 |
+
- F1은 이 둘의 균형
|
| 220 |
+
|
| 221 |
+
### Confusion Matrix
|
| 222 |
+
행 = 실제 카테고리, 열 = 예측 카테고리
|
| 223 |
+
대각선이 클수록 좋음 (맞게 분류한 것들)
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
## KcELECTRA 채택 기준
|
| 228 |
+
|
| 229 |
+
> Simple 대비 **Macro F1이 5% 이상 향상**되면 KcELECTRA 채택
|
| 230 |
+
|
| 231 |
+
- ΔF1 ≥ +0.05 → KcELECTRA 채택, predict_one에서 `model="kcelectra"`로 변경
|
| 232 |
+
- ΔF1 < 0.05 → Simple 유지 (안정성 우선)
|
| 233 |
+
- Simple은 항상 fallback으로 유지
|
| 234 |
+
|
| 235 |
+
---
|
| 236 |
+
|
| 237 |
+
## 자주 묻는 질문
|
| 238 |
+
|
| 239 |
+
**Q. 베이스라인 학습이 안 되고 파일을 못 찾는다고 에러가 난다면?**
|
| 240 |
+
→ `cd model/classification` 후 실행하세요. 경로 기준이 `model/classification/`입니다.
|
| 241 |
+
|
| 242 |
+
**Q. 데이터를 더 추가하고 싶은데 어떻게 하나요?**
|
| 243 |
+
→ `data/notice_sample_v3.csv` 맨 아래에 `문장,카테고리` 형식으로 행 추가.
|
| 244 |
+
단, split_v1.csv가 없는 상태라면 추가 후 `split_dataset.py` 실행.
|
| 245 |
+
이미 split_v1.csv가 있다면 `--force`로 재분할.
|
| 246 |
+
|
| 247 |
+
**Q. KcELECTRA 학습이 CUDA OOM 에러가 난다면?**
|
| 248 |
+
→ `01_train_kcelectra.ipynb`의 `BATCH_SIZE = 16`을 `8`로 줄이세요.
|
| 249 |
+
|
| 250 |
+
**Q. predict_one이 항상 "기타"만 반환한다면?**
|
| 251 |
+
→ 베이스라인 모델(pkl 파일)이 없는 것. `python src/classifier_simple.py` 먼저 실행.
|
| 252 |
+
|
| 253 |
+
##4월30일##작업 완료 — feature/kyeongyi-classification 브랜치
|
| 254 |
+
model/classification/ 폴더에 다음 구조가 완성됐습니다:
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
model/classification/
|
| 258 |
+
├── data/
|
| 259 |
+
│ ├── notice_sample_v3.csv ← 새로 만든 학습 데이터 (150개, 6카테고리 균등)
|
| 260 |
+
│ ├── notices_labeled_v2.csv ← 기존 라벨 데이터 복원 (병합 학습에 사용)
|
| 261 |
+
│ └── notices_labeled_v2.jsonl ← 기존 원본 데이터 복원
|
| 262 |
+
├── src/
|
| 263 |
+
│ ├── predict.py ← 백엔드 진입점 (predict_one 함수)
|
| 264 |
+
│ ├── classifier_simple.py ← 베이스라인: TF-IDF + LogReg (CPU)
|
| 265 |
+
│ └── classifier_kcelectra.py ← KcELECTRA 추론 모듈
|
| 266 |
+
├── scripts/
|
| 267 |
+
│ ├── split_dataset.py ← 데이터 공정 분할 (딱 한 번 실행)
|
| 268 |
+
│ └── evaluate_compare.py ← 두 모델 성능 비교
|
| 269 |
+
├── notebooks/
|
| 270 |
+
│ ├── 01_train_kcelectra.ipynb ← GPU 학습 (Colab T4, ~20분)
|
| 271 |
+
│ └── 02_evaluate_compare.ipynb ← 성능 비교 시각화
|
| 272 |
+
├── checkpoints/ ← 학습 후 모델 파일 저장 위치
|
| 273 |
+
├── docs/
|
| 274 |
+
│ └── devlog_2026-04-30.md ← 오늘 개발일지
|
| 275 |
+
└── README2.md ← 상세 가이드
|
| 276 |
+
|
| 277 |
+
# 지금 바로 실행할 순서:
|
| 278 |
+
|
| 279 |
+
python scripts/split_dataset.py → 데이터 분할
|
| 280 |
+
python src/classifier_simple.py → 베이스라인 학습
|
| 281 |
+
Colab에서 notebooks/01_train_kcelectra.ipynb → KcELECTRA 파인튜닝 (GPU)
|
| 282 |
+
python scripts/evaluate_compare.py → 두 모델 성능 비교
|
| 283 |
+
notebooks/02_evaluate_compare.ipynb → 시각화 차트 생성 (발표 근거 자료)
|
| 284 |
+
백엔드가 호출하는 predict_one() 인터페이스는 기존과 완전히 호환되며, model="simple" / "kcelectra" / "auto" 세 가지 모드를 지원합니다. 자세한 설명은 README2.md와 devlog_2026-04-30.md를 참고.
|
model/classification/README3.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 윤정님(2026년 05월 04일):
|
| 2 |
+
|
| 3 |
+
-새로운 데이터 추가
|
| 4 |
+
model/extraction/data/train/test_data.jsonl 모델 통과 전 — {text, is_todo} 5,560행
|
| 5 |
+
data/processed/predict_output_testset.jsonl 모델 통과 후 — {text, source, due_date, amount, confidence, action_hint, true_is_todo} 5,330행 (정규식 필터 제외 230행)
|
| 6 |
+
|
| 7 |
+
# 로컬에서 비교 평가 실행
|
| 8 |
+
|
| 9 |
+
(ai_env) C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification>python scripts/evaluate_compare_v2_20260504.py
|
| 10 |
+
평가 시작 — split: test, 데이터: v4_20260504
|
| 11 |
+
|
| 12 |
+
[Simple] 분류 리포트
|
| 13 |
+
precision recall f1-score support
|
| 14 |
+
|
| 15 |
+
일정 0.79 0.85 0.81 13
|
| 16 |
+
준비물 0.86 0.75 0.80 8
|
| 17 |
+
제출 0.69 0.69 0.69 13
|
| 18 |
+
비용 1.00 0.90 0.95 10
|
| 19 |
+
건강·안전 0.75 0.82 0.78 11
|
| 20 |
+
기타 0.71 0.71 0.71 14
|
| 21 |
+
|
| 22 |
+
accuracy 0.78 69
|
| 23 |
+
macro avg 0.80 0.79 0.79 69
|
| 24 |
+
weighted avg 0.79 0.78 0.78 69
|
| 25 |
+
|
| 26 |
+
[kcelectra] 기존 JSON 재활용: eval_results_kcelectra_20260504.json
|
| 27 |
+
|
| 28 |
+
[저장] eval_results_simple_20260504.json
|
| 29 |
+
[저장] eval_results_kcelectra_20260504.json
|
| 30 |
+
[저장] eval_comparison_summary_20260504.csv
|
| 31 |
+
|
| 32 |
+
==================================================
|
| 33 |
+
성능 비교 결과
|
| 34 |
+
==================================================
|
| 35 |
+
Simple Macro F1 : 0.7919
|
| 36 |
+
KcELECTRA Macro F1 : 0.6938
|
| 37 |
+
Delta : -0.0981
|
| 38 |
+
>> Simple 유지 권장
|
model/classification/README4.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# README.md
|
| 2 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\README.md
|
| 3 |
+
|
| 4 |
+
전체 파이프라인 (확정 버전)
|
| 5 |
+
[4] 카테고리 분류 (services/classifier.py — 경이님 6-class wrapper)
|
| 6 |
+
입력: 각 todo.text
|
| 7 |
+
출력: category ∈ {일정, 준비물, 제출, 비용, 건강·안전, 기타}
|
| 8 |
+
|
| 9 |
+
- 카테고리 분류-경이님
|
| 10 |
+
|
| 11 |
+
`일정`, `준비물`, `제출`, `비용`, `건강·안전`, `기타`
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
- 가장 중요한 핵심과제: 모델 성능 비교 (베이스라인 VS. 파인튜닝)
|
| 15 |
+
1. 조건: 베이스라인 모델, 파인튜닝한 모델에 들어가는 input data가 동일한 데이터셋 및 동일한 조건에서 두 모델의 성능을 비교. 다시 말해서, 기존에 있던 모델을 가지고 동일한 조건을 맞춰서 일정, 준비물, 제출, 비용, 건강·안전, 기타에 대한 분류 성능 점수가 나와야하고 파인튜닝한 모델을 동일한 조건으로 6가지 분류 성능 점수가 나와야 비교가 가능. 그래서 파인튜닝된 모델이 베이스라인모델보다 성능이 좋다라는 지표가 나와야 성능의 우수함을 입증할 수 있음. 근거 자료를 만들어야 함.
|
| 16 |
+
|
| 17 |
+
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행은 이미 라벨링이 존재함. 그래서 사용하고 있는 모델들의 적절한 평가 및 성능 지표가 나와야 함. 사용하고자 하는 모델 기능들의 자세한 설명 보기.
|
| 18 |
+
|
| 19 |
+
예시로, Precision이라고 하면 10개 단어 중에 2개 단어만 맞췄다. 그래서 그 모델로 해서 모든 텍스트 데이터 돌아서 몇 프로 맞췄으니까 얘는 성능이 얼마다 라고 얘기하는 것도 있다.
|
| 20 |
+
|
| 21 |
+
==> 파인튜닝의 성능이 베이스라인 성능보다 좋은 쪽으로 모델이 나와야하고 그 모델에 맞는 평가 지표가 나와야 한다. 글씨로 정리하는 것 뿐만아니라 시각적인 도구를 활용해서 그래프 혹은 직선 사용 등으로 제시할 근거 자료가 필요.
|
| 22 |
+
|
| 23 |
+
# README2.md
|
| 24 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\README2.md
|
| 25 |
+
|
| 26 |
+
# README3.md
|
| 27 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\README3.md
|
| 28 |
+
|
| 29 |
+
# docs 개발일지
|
| 30 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\docs\devlog_2026-04-30.md
|
| 31 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\docs\devlog_2026-04-30_실행결과.md
|
| 32 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\docs\devlog_2026-05-02.md
|
| 33 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\docs\devlog_2026-05-04_자동라벨링.md
|
| 34 |
+
|
| 35 |
+
# data
|
| 36 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\data
|
| 37 |
+
-중요: 새로운 학습 데이터 C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\data\notice_sample_v5_clean_full_20260504.csv --> 주어진 5001행 데이터를 가지고 학습 시켜야 함.
|
| 38 |
+
|
| 39 |
+
나머지는 기존 데이터
|
| 40 |
+
|
| 41 |
+
# scripts
|
| 42 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\scripts
|
| 43 |
+
|
| 44 |
+
# src
|
| 45 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\src
|
| 46 |
+
|
| 47 |
+
# notebooks
|
| 48 |
+
C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\notebooks
|
| 49 |
+
-가상환경 설치 권장: tensorflow, torch
|
| 50 |
+
-C:\Users\kysop\Team_Project_Multiculture\multicultural-ai\model\classification\notebooks\03_train_kcelectra_v2_20260504 (1).ipynb 파일처럼 Colab GPU로 돌릴 수 있는 것은 주피터 노트북 형성해야 한다.
|
model/classification/scripts/auto_label_from_new_data_20260504.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
auto_label_from_new_data_20260504.py
|
| 3 |
+
=====================================
|
| 4 |
+
담당: 경이 (kyeongyi)
|
| 5 |
+
작성일: 2026-05-04
|
| 6 |
+
브랜치: feature/kyeongyi-classification
|
| 7 |
+
|
| 8 |
+
목적:
|
| 9 |
+
윤정님이 새로 추가한 대용량 데이터(5,560행)에는 'is_todo' 정보만 있고
|
| 10 |
+
'일정/준비물/제출/비용/건강·안전/기타' 라벨이 없다.
|
| 11 |
+
이 스크립트는 키워드 규칙과 슬롯(amount, due_date, action_hint)을 이용해
|
| 12 |
+
자동으로 카테고리를 부여하고, 기존 v3.csv와 합쳐 v4 학습 데이터를 생성한다.
|
| 13 |
+
|
| 14 |
+
왜 '자동 라벨링'을 썼나?
|
| 15 |
+
1. 새 데이터 5,560행을 사람이 수작업으로 라벨링하면 수십 시간 걸림.
|
| 16 |
+
2. 기존 라벨 데이터(v3.csv, 142행)만으로는 KcELECTRA 파인튜닝이 부족
|
| 17 |
+
(BERT 계열은 최소 클래스당 50~100개, 전체 300~1000개 이상 필요).
|
| 18 |
+
3. 키워드 기반 자동 라벨링 → 대량 데이터 확보 → KcELECTRA 재학습 → 성능 역전.
|
| 19 |
+
|
| 20 |
+
검증 방법:
|
| 21 |
+
- 기존 라벨이 있는 v3.csv를 이 규칙으로 다시 분류 → 규칙 정확도 확인.
|
| 22 |
+
- 규칙 정확도가 75% 이상이면 자동 라벨 데이터를 신뢰할 수 있다고 판단.
|
| 23 |
+
|
| 24 |
+
실행:
|
| 25 |
+
cd model/classification
|
| 26 |
+
python scripts/auto_label_from_new_data_20260504.py
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import re
|
| 31 |
+
import csv
|
| 32 |
+
import random
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from collections import Counter
|
| 35 |
+
|
| 36 |
+
random.seed(42) # 재현성 보장 — 누가 실행해도 동일한 분할·샘플 순서
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ──────────────────────────────────────────────────────────────────
|
| 40 |
+
# 1. 경로 설정
|
| 41 |
+
# ──────────────────────────────────────────────────────────────────
|
| 42 |
+
# 이 파일의 위치: model/classification/scripts/
|
| 43 |
+
# _BASE → model/classification/
|
| 44 |
+
_BASE = Path(__file__).parent.parent
|
| 45 |
+
|
| 46 |
+
DATA_DIR = _BASE / "data"
|
| 47 |
+
EXTRACT_BASE = _BASE.parent.parent / "model" / "extraction" # 윤정님 모델 폴더
|
| 48 |
+
|
| 49 |
+
# 입력 파일 (윤정님이 추가한 새 데이터)
|
| 50 |
+
SRC_TEST_DATA = EXTRACT_BASE / "data" / "train" / "test_data.jsonl"
|
| 51 |
+
SRC_PREDICT_OUT = EXTRACT_BASE / "data" / "processed" / "predict_output_testset.jsonl"
|
| 52 |
+
|
| 53 |
+
# 기존 경이님 라벨 데이터
|
| 54 |
+
SRC_V3_CSV = DATA_DIR / "notice_sample_v3.csv"
|
| 55 |
+
|
| 56 |
+
# 출력 파일
|
| 57 |
+
OUT_CSV = DATA_DIR / "notice_sample_v4_20260504.csv"
|
| 58 |
+
|
| 59 |
+
# 6가지 분류 카테고리
|
| 60 |
+
LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ──────────────────────────────────────────────────────────────────
|
| 64 |
+
# 2. 카테고리별 키워드 규칙
|
| 65 |
+
# ──────────────────────────────────────────────────────────────────
|
| 66 |
+
# 설계 원칙:
|
| 67 |
+
# (a) 우선순위 순서로 검사 — 먼저 매칭된 카테고리를 반환하고 중단.
|
| 68 |
+
# (b) 각 카테고리에서 가장 '강한' 신호(오탐 없는 단어)를 선택.
|
| 69 |
+
# (c) 한국어는 조사·어미 변형이 많으므로 어근만 사용 (예: "납부" → "납부해", "납부하여" 모두 포함).
|
| 70 |
+
# (d) 숫자 패턴은 정규표현식 사용 (예: "20,000원", "3만원" 등 다양한 금액 표현).
|
| 71 |
+
#
|
| 72 |
+
# 왜 이 순서인가?
|
| 73 |
+
# 비용 > 준비물 > 제출 > 건강·안전 > 일정 > 기타
|
| 74 |
+
# → "3만 원을 5월 15일까지 납부" 같은 문장은 '비용'이 더 강한 신호.
|
| 75 |
+
# → '일정'은 날짜 표현이 많아 오탐 위험이 있어 후순위.
|
| 76 |
+
# → '기타'는 규칙 없이 나머지를 다 받는 쓰레기통 역할.
|
| 77 |
+
|
| 78 |
+
RULES: list[tuple[str, list[str]]] = [
|
| 79 |
+
# ── 비용 ──
|
| 80 |
+
# 금액·납부 관련 표현이 명확하게 있는 경우.
|
| 81 |
+
# r"\d[\d,]*\s*원" : "65,000원", "3만원"은 이 정규식으로 포착.
|
| 82 |
+
("비용", [
|
| 83 |
+
"납부", # "납부해 주세요", "납부하여"
|
| 84 |
+
"입금", # "계좌로 입금"
|
| 85 |
+
"계좌이체", # "계좌이체해 주세요"
|
| 86 |
+
"급식비",
|
| 87 |
+
"참가비",
|
| 88 |
+
"수강료",
|
| 89 |
+
"교재비",
|
| 90 |
+
"버스비",
|
| 91 |
+
"이용료",
|
| 92 |
+
"수업료",
|
| 93 |
+
"구입비",
|
| 94 |
+
"지원금",
|
| 95 |
+
"면제", # "급식비 면제 신청"
|
| 96 |
+
"선납", # "선납해 주세요"
|
| 97 |
+
r"\d[\d,]*\s*원", # 숫자+원 (20,000원, 3만원 등)
|
| 98 |
+
]),
|
| 99 |
+
|
| 100 |
+
# ── 준비물 ──
|
| 101 |
+
# 챙겨야 할 물건·복장 관련.
|
| 102 |
+
# "마스크를" 처럼 뒤에 조사가 붙는 경우도 어근으로 포착.
|
| 103 |
+
("준비물", [
|
| 104 |
+
"지참", # "지참하시기 바랍니다"
|
| 105 |
+
"챙겨", # "챙겨주시기 바랍니다"
|
| 106 |
+
"챙기", # "챙기���기 바랍니다"
|
| 107 |
+
"준비해", # "준비해 주세요"
|
| 108 |
+
"준비물", # "학습 준비물"
|
| 109 |
+
"가져오", # "가져오세요"
|
| 110 |
+
"착용", # "착용하여 등교"
|
| 111 |
+
"신고 오", # "운동화를 신고 오세요"
|
| 112 |
+
"복장",
|
| 113 |
+
"도시락",
|
| 114 |
+
"우산",
|
| 115 |
+
"수영복",
|
| 116 |
+
"실내화",
|
| 117 |
+
"방한", # "방한용품"
|
| 118 |
+
"앞치마",
|
| 119 |
+
"고무장갑",
|
| 120 |
+
"배낭",
|
| 121 |
+
"여벌", # "여벌 옷"
|
| 122 |
+
"스케치북",
|
| 123 |
+
"색연필",
|
| 124 |
+
]),
|
| 125 |
+
|
| 126 |
+
# ── 제출 ──
|
| 127 |
+
# 서류·동의서·설문 등 무언가를 학교에 내야 하는 경우.
|
| 128 |
+
("제출", [
|
| 129 |
+
"제출", # "제출해 주세요", "제출하여"
|
| 130 |
+
"동의서", # "현장체험학습 동의서"
|
| 131 |
+
"신청서", # "급식 신청서"
|
| 132 |
+
"설문", # "온라인 설문에 응답"
|
| 133 |
+
"서류를",
|
| 134 |
+
"작성하여", # "작성하여 제출"
|
| 135 |
+
"응답해", # "설문에 응답해 주세요"
|
| 136 |
+
"기재", # "기재해 주세요"
|
| 137 |
+
"접수",
|
| 138 |
+
"첨부",
|
| 139 |
+
"등록", # "회원 등록"
|
| 140 |
+
"신청해", # "신청해 주세요"
|
| 141 |
+
"기한 내 신청",
|
| 142 |
+
"아이 편에", # "아이 편에 보내주시기"
|
| 143 |
+
"담임선생님께 보내",
|
| 144 |
+
]),
|
| 145 |
+
|
| 146 |
+
# ── 건강·안전 ──
|
| 147 |
+
# 신체 관련, 안전사고 예방, 감염병 관련.
|
| 148 |
+
("건강·안전", [
|
| 149 |
+
"발열",
|
| 150 |
+
"기침",
|
| 151 |
+
"증상",
|
| 152 |
+
"예방접종",
|
| 153 |
+
"백신",
|
| 154 |
+
"감염",
|
| 155 |
+
"방역",
|
| 156 |
+
"소독",
|
| 157 |
+
"위생",
|
| 158 |
+
"자가진단",
|
| 159 |
+
"결석",
|
| 160 |
+
"질병",
|
| 161 |
+
"코로나",
|
| 162 |
+
"독감",
|
| 163 |
+
"알레르기",
|
| 164 |
+
"안전사고",
|
| 165 |
+
"교통안전",
|
| 166 |
+
"헬멧",
|
| 167 |
+
"안전벨트",
|
| 168 |
+
"온열", # "온열 질환"
|
| 169 |
+
"응급",
|
| 170 |
+
"선별진료",
|
| 171 |
+
"PCR",
|
| 172 |
+
"진단키트",
|
| 173 |
+
"확진",
|
| 174 |
+
"수분 보충",
|
| 175 |
+
]),
|
| 176 |
+
|
| 177 |
+
# ── 일정 ──
|
| 178 |
+
# 날짜·시간·행사 관련. 비용/준비물/제출 보다 후순위인 이유:
|
| 179 |
+
# "3만 원을 5월 15일까지 납부" → 날짜가 있어도 '비용'이 정답.
|
| 180 |
+
# 날짜 패턴은 오탐이 많아 '강한' 단어와 함께 쓸 때만 신뢰.
|
| 181 |
+
("일정", [
|
| 182 |
+
r"\d+월\s*\d+일", # "5월 20일", "3 월 7 일"
|
| 183 |
+
r"\d{4}\.\s*\d+\.\s*\d+", # "2025.05.20"
|
| 184 |
+
r"\d+\.\s*\d+\.\s*\([월화수목금토일]\)", # "5.20.(화)"
|
| 185 |
+
"오전", # "오전 9시"
|
| 186 |
+
"오후",
|
| 187 |
+
"~까지", # "3월 20일~까지"
|
| 188 |
+
"부터 ", # "3월부터 "
|
| 189 |
+
"기간",
|
| 190 |
+
"주간",
|
| 191 |
+
"방학",
|
| 192 |
+
"개학",
|
| 193 |
+
"졸업식",
|
| 194 |
+
"입학식",
|
| 195 |
+
"운동회",
|
| 196 |
+
"수학여행",
|
| 197 |
+
"체험학습",
|
| 198 |
+
"현장학습",
|
| 199 |
+
"학부모 총회",
|
| 200 |
+
"공개수업",
|
| 201 |
+
"학예발표",
|
| 202 |
+
"체육대회",
|
| 203 |
+
"생태체험",
|
| 204 |
+
"시작합니다",
|
| 205 |
+
"출발합니다",
|
| 206 |
+
"진행됩니다",
|
| 207 |
+
"실시됩니다",
|
| 208 |
+
"열립니다",
|
| 209 |
+
"개최됩니다",
|
| 210 |
+
]),
|
| 211 |
+
]
|
| 212 |
+
# RULES에 없는 문장 → "기타"로 분류
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ──────────────────────────────────────────────────────────────────
|
| 216 |
+
# 3. 정규표현식 여부 판별 헬퍼
|
| 217 |
+
# ──────────────────────────────────────────────────────────────────
|
| 218 |
+
def _is_regex(pattern: str) -> bool:
|
| 219 |
+
"""문자열 안에 정규표현식 특수문자가 있으면 True."""
|
| 220 |
+
# \d, ^, $, |, ?, *, +, (, ), [, ], {, } 중 하나라도 있으면 regex
|
| 221 |
+
regex_chars = r"\d^$.|?*+()[]{}".replace(".", r"\.")
|
| 222 |
+
return bool(re.search(r"[\\^$.|?*+()\[\]{}]|\\d", pattern))
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ──────────────────────────────────────────────────────────────────
|
| 226 |
+
# 4. 핵심 분류 함수 (규칙 기반)
|
| 227 |
+
# ──────────────────────────────────────────────────────────────────
|
| 228 |
+
def label_by_keywords(text: str) -> str | None:
|
| 229 |
+
"""
|
| 230 |
+
텍스트에 키워드·패턴이 포함되면 해당 카테고리를 반환.
|
| 231 |
+
RULES 리스트 순서대로 검사 — 첫 매칭에서 즉시 반환.
|
| 232 |
+
없으면 None 반환 (→ 슬롯 기반 분류로 넘어감).
|
| 233 |
+
"""
|
| 234 |
+
for category, patterns in RULES:
|
| 235 |
+
for pat in patterns:
|
| 236 |
+
if _is_regex(pat):
|
| 237 |
+
if re.search(pat, text):
|
| 238 |
+
return category
|
| 239 |
+
else:
|
| 240 |
+
if pat in text:
|
| 241 |
+
return category
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def label_by_slots(
|
| 246 |
+
action_hint: str | None,
|
| 247 |
+
amount: str | None,
|
| 248 |
+
due_date: str | None,
|
| 249 |
+
) -> str | None:
|
| 250 |
+
"""
|
| 251 |
+
윤정님 추출 모델이 뽑은 슬롯 정보를 이용한 보조 분류.
|
| 252 |
+
키워드 규칙이 None을 반환했을 때만 호출.
|
| 253 |
+
|
| 254 |
+
논리:
|
| 255 |
+
- amount 있음 → 비용 관련 문장일 가능성이 높다
|
| 256 |
+
- action_hint = "제출"/"신청" → 제출 카테고리
|
| 257 |
+
- action_hint = "준비"/"지참" → 준비물 카테고리
|
| 258 |
+
- due_date 있음 + 비용/제출 키워드 없음 → 일정 관련
|
| 259 |
+
"""
|
| 260 |
+
if amount:
|
| 261 |
+
return "비용"
|
| 262 |
+
if action_hint in ("제출", "신청", "작성"):
|
| 263 |
+
return "제출"
|
| 264 |
+
if action_hint in ("준비", "지참", "착용"):
|
| 265 |
+
return "준비물"
|
| 266 |
+
if due_date:
|
| 267 |
+
# 날짜가 있으나 다른 강한 신호가 없으면 일정
|
| 268 |
+
return "일정"
|
| 269 |
+
return None
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def classify(
|
| 273 |
+
text: str,
|
| 274 |
+
action_hint: str | None = None,
|
| 275 |
+
amount: str | None = None,
|
| 276 |
+
due_date: str | None = None,
|
| 277 |
+
) -> str | None:
|
| 278 |
+
"""
|
| 279 |
+
최종 분류 함수.
|
| 280 |
+
1단계: 키워드 규칙 (빠르고 명확)
|
| 281 |
+
2단계: 슬롯 기반 (윤정님 추출 정보 활용)
|
| 282 |
+
모두 실패하면 None → 이 문장은 학습 데이터에서 제외 (노이즈 방지).
|
| 283 |
+
|
| 284 |
+
'기타'를 일부러 규칙에 넣지 않은 이유:
|
| 285 |
+
규칙으로 잡히지 않는 문장이 전부 기타가 되면 기타 데이터가
|
| 286 |
+
너무 많아지고, 오탐(실제론 일정인데 기타로 잡힌 것)도 섞임.
|
| 287 |
+
→ 기타는 별도 limit을 두지 않고 "나머지"로만 수집.
|
| 288 |
+
"""
|
| 289 |
+
label = label_by_keywords(text)
|
| 290 |
+
if label:
|
| 291 |
+
return label
|
| 292 |
+
label = label_by_slots(action_hint, amount, due_date)
|
| 293 |
+
if label:
|
| 294 |
+
return label
|
| 295 |
+
return None # 애매한 문장은 버린다
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ──────────────────────────────────────────────────────────────────
|
| 299 |
+
# 5. 데이터 로드 함수
|
| 300 |
+
# ──────────────────────────────────────────────────────────────────
|
| 301 |
+
def load_test_data() -> list[dict]:
|
| 302 |
+
"""
|
| 303 |
+
test_data.jsonl : {text, is_todo}
|
| 304 |
+
is_todo = True인 행만 사용.
|
| 305 |
+
이유: False인 행은 학교 공지 중 '할 일이 없는' 순수 안내 문장.
|
| 306 |
+
분류 모델의 목적(학부모가 해야 할 행동 분류)에 맞지 않아 제외.
|
| 307 |
+
"""
|
| 308 |
+
rows = []
|
| 309 |
+
with open(SRC_TEST_DATA, encoding="utf-8") as f:
|
| 310 |
+
for line in f:
|
| 311 |
+
line = line.strip()
|
| 312 |
+
if not line:
|
| 313 |
+
continue
|
| 314 |
+
d = json.loads(line)
|
| 315 |
+
if d.get("is_todo") is True:
|
| 316 |
+
rows.append({
|
| 317 |
+
"text": d["text"],
|
| 318 |
+
"action_hint": None, # 이 파일엔 슬롯 정보 없음
|
| 319 |
+
"amount": None,
|
| 320 |
+
"due_date": None,
|
| 321 |
+
})
|
| 322 |
+
return rows
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def load_predict_output() -> list[dict]:
|
| 326 |
+
"""
|
| 327 |
+
predict_output_testset.jsonl : {text, source, due_date, amount,
|
| 328 |
+
confidence, action_hint, true_is_todo}
|
| 329 |
+
|
| 330 |
+
필터 기준:
|
| 331 |
+
- true_is_todo = True : 명확하게 할 일인 문장
|
| 332 |
+
- confidence >= 0.7 : 윤정님 모델이 확신하는 문장
|
| 333 |
+
이 두 조건 중 하나라도 만족하면 사용.
|
| 334 |
+
슬롯(amount, due_date, action_hint)은 자동 라벨링 2단계(label_by_slots)에 활용.
|
| 335 |
+
"""
|
| 336 |
+
rows = []
|
| 337 |
+
with open(SRC_PREDICT_OUT, encoding="utf-8") as f:
|
| 338 |
+
for line in f:
|
| 339 |
+
line = line.strip()
|
| 340 |
+
if not line:
|
| 341 |
+
continue
|
| 342 |
+
d = json.loads(line)
|
| 343 |
+
is_todo = d.get("true_is_todo") is True
|
| 344 |
+
high_conf = d.get("confidence", 0) >= 0.7
|
| 345 |
+
if is_todo or high_conf:
|
| 346 |
+
rows.append({
|
| 347 |
+
"text": d["text"],
|
| 348 |
+
"action_hint": d.get("action_hint"),
|
| 349 |
+
"amount": d.get("amount"),
|
| 350 |
+
"due_date": d.get("due_date"),
|
| 351 |
+
})
|
| 352 |
+
return rows
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def load_v3_csv() -> list[tuple[str, str]]:
|
| 356 |
+
"""
|
| 357 |
+
기존에 경이님이 직접 라벨링한 notice_sample_v3.csv 로드.
|
| 358 |
+
컬럼: text, category
|
| 359 |
+
"""
|
| 360 |
+
rows = []
|
| 361 |
+
with open(SRC_V3_CSV, encoding="utf-8-sig", newline="") as f:
|
| 362 |
+
reader = csv.DictReader(f)
|
| 363 |
+
for row in reader:
|
| 364 |
+
text = row.get("text", "").strip()
|
| 365 |
+
cat = row.get("category", "").strip()
|
| 366 |
+
if text and cat in LABELS:
|
| 367 |
+
rows.append((text, cat))
|
| 368 |
+
return rows
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
# ─────────────────────────────────────────────────────��────────────
|
| 372 |
+
# 6. 규칙 검증 함수 — 기존 라벨 데이터로 규칙 정확도 측정
|
| 373 |
+
# ──────────────────────────────────────────────────────────────────
|
| 374 |
+
def validate_rules(v3_rows: list[tuple[str, str]]) -> float:
|
| 375 |
+
"""
|
| 376 |
+
v3.csv는 경이님이 직접 라벨링한 '정답 데이터'다.
|
| 377 |
+
이 정답 데이터에 자동 라벨링 규칙을 적용해 몇 %나 맞히는지 확인.
|
| 378 |
+
|
| 379 |
+
목표: 75% 이상 → 자동 라벨 데이터를 신뢰할 수 있다고 판단.
|
| 380 |
+
낮으면 규칙을 수정해야 함.
|
| 381 |
+
"""
|
| 382 |
+
correct = 0
|
| 383 |
+
total = 0
|
| 384 |
+
errors = []
|
| 385 |
+
|
| 386 |
+
for text, true_cat in v3_rows:
|
| 387 |
+
pred = classify(text)
|
| 388 |
+
if pred is None:
|
| 389 |
+
pred = "기타" # 규칙 미매칭 → 기타로 간주
|
| 390 |
+
total += 1
|
| 391 |
+
if pred == true_cat:
|
| 392 |
+
correct += 1
|
| 393 |
+
else:
|
| 394 |
+
errors.append((text[:40], true_cat, pred))
|
| 395 |
+
|
| 396 |
+
accuracy = correct / total if total > 0 else 0.0
|
| 397 |
+
|
| 398 |
+
print("\n[규칙 검증] v3.csv 기준 자동 라벨링 정확도")
|
| 399 |
+
print(f" 정답: {correct}/{total} = {accuracy:.1%}")
|
| 400 |
+
|
| 401 |
+
if errors:
|
| 402 |
+
print(f" 오분류 예시 (상위 10개):")
|
| 403 |
+
for txt, true, pred in errors[:10]:
|
| 404 |
+
print(f" [{true}→{pred}] {txt}")
|
| 405 |
+
|
| 406 |
+
if accuracy >= 0.75:
|
| 407 |
+
print(" [OK] 규칙 신뢰도 충분 (75% 이상) -> 자동 라벨 데이터 채택")
|
| 408 |
+
else:
|
| 409 |
+
print(" [경고] 규칙 신뢰도 부족 -- 규칙을 추가/수정할 것을 권장")
|
| 410 |
+
|
| 411 |
+
return accuracy
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
# ──────────────────────────────────────────────────────────────────
|
| 415 |
+
# 7. 라벨링 + 필터링
|
| 416 |
+
# ──────────────────────────────────────────────────────────────────
|
| 417 |
+
MIN_TEXT_LEN = 10 # 10글자 미만 단편 문장은 노이즈 가능성이 높아 제외
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def label_all(rows: list[dict]) -> list[tuple[str, str]]:
|
| 421 |
+
"""
|
| 422 |
+
rows 각각에 classify()를 적용해 (text, category) 쌍 반환.
|
| 423 |
+
- MIN_TEXT_LEN 미만 → 제외
|
| 424 |
+
- classify() 결과가 None → 제외 (애매한 문장)
|
| 425 |
+
"""
|
| 426 |
+
labeled: list[tuple[str, str]] = []
|
| 427 |
+
for r in rows:
|
| 428 |
+
text = r["text"].strip()
|
| 429 |
+
if len(text) < MIN_TEXT_LEN:
|
| 430 |
+
continue
|
| 431 |
+
cat = classify(text, r.get("action_hint"), r.get("amount"), r.get("due_date"))
|
| 432 |
+
if cat:
|
| 433 |
+
labeled.append((text, cat))
|
| 434 |
+
else:
|
| 435 |
+
# 규칙·슬롯 미매칭 문장은 '기타'로 넣되, 별도 카운터로 제한
|
| 436 |
+
labeled.append((text, "기타"))
|
| 437 |
+
return labeled
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
MAX_NEW_PER_LABEL = 120 # 새 데이터에서 카테고리당 최대 수집 수
|
| 441 |
+
# 이유: 너무 많으면 노이즈가 늘어나고, 클래스 불균형도 발생.
|
| 442 |
+
# 기존 v3(약 25/카테고리) + 신규(최대 120/카테고리) → 전체 약 900개 목표.
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def balance_new_data(
|
| 446 |
+
labeled: list[tuple[str, str]],
|
| 447 |
+
max_per: int,
|
| 448 |
+
) -> list[tuple[str, str]]:
|
| 449 |
+
"""
|
| 450 |
+
카테고리별로 max_per 개까지만 샘플링.
|
| 451 |
+
random.shuffle(seed=42)로 무작위 선택 → 재현 가능.
|
| 452 |
+
|
| 453 |
+
왜 균형이 필요한가?
|
| 454 |
+
KcELECTRA 파인튜닝 시 특정 클래스 데이터가 너무 많으면
|
| 455 |
+
모델이 그 클래스로 편향됨 (기존 문제와 동일한 class collapse 현상).
|
| 456 |
+
"""
|
| 457 |
+
buckets: dict[str, list[str]] = {l: [] for l in LABELS}
|
| 458 |
+
for text, cat in labeled:
|
| 459 |
+
if cat in buckets:
|
| 460 |
+
buckets[cat].append(text)
|
| 461 |
+
|
| 462 |
+
result: list[tuple[str, str]] = []
|
| 463 |
+
for cat, texts in buckets.items():
|
| 464 |
+
random.shuffle(texts)
|
| 465 |
+
for t in texts[:max_per]:
|
| 466 |
+
result.append((t, cat))
|
| 467 |
+
return result
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
# ──────────────────────────────────────────────────────────────────
|
| 471 |
+
# 8. 중복 제거
|
| 472 |
+
# ──────────────────────────────────────────────────────────────────
|
| 473 |
+
def remove_duplicates(rows: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
| 474 |
+
"""
|
| 475 |
+
동일 텍스트가 두 번 이상 나타나면 첫 번째만 유지.
|
| 476 |
+
왜 필요한가?
|
| 477 |
+
test_data.jsonl과 predict_output_testset.jsonl이 같은 원본에서
|
| 478 |
+
파생됐기 때문에 중복 문장이 존재할 수 있음.
|
| 479 |
+
중복이 있으면 test 세트에도 같은 문장이 들어가 평가가 부풀려짐.
|
| 480 |
+
"""
|
| 481 |
+
seen: set[str] = set()
|
| 482 |
+
out: list[tuple[str, str]] = []
|
| 483 |
+
for text, cat in rows:
|
| 484 |
+
if text not in seen:
|
| 485 |
+
seen.add(text)
|
| 486 |
+
out.append((text, cat))
|
| 487 |
+
return out
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
# ���─────────────────────────────────────────────────────────────────
|
| 491 |
+
# 9. 저장
|
| 492 |
+
# ──────────────────────────────────────────────────────────────────
|
| 493 |
+
def save_csv(rows: list[tuple[str, str]], path: Path) -> None:
|
| 494 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 495 |
+
with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
| 496 |
+
# quoting=QUOTE_ALL : 쉼표·줄바꿈 포함 텍스트도 안전하게 저장
|
| 497 |
+
writer = csv.writer(f, quoting=csv.QUOTE_ALL)
|
| 498 |
+
writer.writerow(["text", "category"])
|
| 499 |
+
for text, cat in rows:
|
| 500 |
+
writer.writerow([text, cat])
|
| 501 |
+
print(f"\n[저장] {path} ({len(rows)}행)")
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
# ──────────────────────────────────────────────────────────────────
|
| 505 |
+
# 10. 메인 실행
|
| 506 |
+
# ──────────────────────────────────────────────────────────────────
|
| 507 |
+
def main() -> None:
|
| 508 |
+
print("=" * 60)
|
| 509 |
+
print(" 자동 라벨링 파이프라인 시작 (2026-05-04)")
|
| 510 |
+
print("=" * 60)
|
| 511 |
+
|
| 512 |
+
# Step A: 기존 라벨 데이터 로드
|
| 513 |
+
v3_rows = load_v3_csv()
|
| 514 |
+
print(f"\n[A] 기존 v3.csv: {len(v3_rows)}개")
|
| 515 |
+
cnt_v3 = Counter(cat for _, cat in v3_rows)
|
| 516 |
+
for l in LABELS:
|
| 517 |
+
print(f" {l}: {cnt_v3.get(l, 0)}개")
|
| 518 |
+
|
| 519 |
+
# Step B: 규칙 검증 (v3.csv 기준)
|
| 520 |
+
rule_acc = validate_rules(v3_rows)
|
| 521 |
+
|
| 522 |
+
# Step C: 새 데이터 로드
|
| 523 |
+
print("\n[C] 새 데이터 로드")
|
| 524 |
+
test_rows = load_test_data()
|
| 525 |
+
predict_rows = load_predict_output()
|
| 526 |
+
print(f" test_data.jsonl (is_todo=True): {len(test_rows):,}개")
|
| 527 |
+
print(f" predict_output (필터 후): {len(predict_rows):,}개")
|
| 528 |
+
|
| 529 |
+
# Step D: 자동 라벨링
|
| 530 |
+
print("\n[D] 자동 라벨링 중...")
|
| 531 |
+
labeled_test = label_all(test_rows)
|
| 532 |
+
labeled_predict = label_all(predict_rows)
|
| 533 |
+
all_new = labeled_test + labeled_predict
|
| 534 |
+
print(f" 라벨링 완료: {len(all_new):,}개")
|
| 535 |
+
cnt_new = Counter(cat for _, cat in all_new)
|
| 536 |
+
for l in LABELS:
|
| 537 |
+
print(f" {l}: {cnt_new.get(l, 0)}개")
|
| 538 |
+
|
| 539 |
+
# Step E: 균형 조정 (카테고리당 최대 MAX_NEW_PER_LABEL개)
|
| 540 |
+
balanced = balance_new_data(all_new, MAX_NEW_PER_LABEL)
|
| 541 |
+
print(f"\n[E] 균형 조정 후: {len(balanced)}개")
|
| 542 |
+
cnt_bal = Counter(cat for _, cat in balanced)
|
| 543 |
+
for l in LABELS:
|
| 544 |
+
print(f" {l}: {cnt_bal.get(l, 0)}개")
|
| 545 |
+
|
| 546 |
+
# Step F: 기존 v3 + 새 데이터 병합 → 중복 제거
|
| 547 |
+
combined = v3_rows + balanced
|
| 548 |
+
combined = remove_duplicates(combined)
|
| 549 |
+
random.shuffle(combined) # 파일 내 순서 무작위화
|
| 550 |
+
print(f"\n[F] 최종 병합 (중복 제거 후): {len(combined)}개")
|
| 551 |
+
cnt_final = Counter(cat for _, cat in combined)
|
| 552 |
+
for l in LABELS:
|
| 553 |
+
print(f" {l}: {cnt_final.get(l, 0)}개")
|
| 554 |
+
|
| 555 |
+
# Step G: 저장
|
| 556 |
+
save_csv(combined, OUT_CSV)
|
| 557 |
+
|
| 558 |
+
# 최종 요약
|
| 559 |
+
print("\n" + "=" * 60)
|
| 560 |
+
print(" 완료 요약")
|
| 561 |
+
print("=" * 60)
|
| 562 |
+
print(f" 기존 v3.csv: {len(v3_rows):>4}개")
|
| 563 |
+
print(f" 새 데이터 (균형 후): {len(balanced):>4}개")
|
| 564 |
+
print(f" 최종 v4 (중복 제거): {len(combined):>4}개")
|
| 565 |
+
print(f" 규칙 정확도: {rule_acc:.1%}")
|
| 566 |
+
print(f" 출력: {OUT_CSV}")
|
| 567 |
+
print()
|
| 568 |
+
print("다음 단계:")
|
| 569 |
+
print(" python scripts/split_dataset.py --data v4_20260504 --force")
|
| 570 |
+
print(" python src/classifier_simple.py (베이스라인 재학습)")
|
| 571 |
+
print(" Colab에서 notebooks/03_train_kcelectra_v2_20260504.ipynb 실행")
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
if __name__ == "__main__":
|
| 575 |
+
main()
|
model/classification/scripts/evaluate_compare.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
베이스라인 vs KcELECTRA 성능 비교 스크립트
|
| 3 |
+
===========================================
|
| 4 |
+
담당: 경이
|
| 5 |
+
목적: 동일한 test 데이터로 두 모델의 성능을 비교하여 CSV·JSON으로 저장.
|
| 6 |
+
결과는 02_evaluate_compare.ipynb에서 시각화.
|
| 7 |
+
|
| 8 |
+
실행:
|
| 9 |
+
python scripts/evaluate_compare.py # test split 평가
|
| 10 |
+
python scripts/evaluate_compare.py --split val # val split 평가
|
| 11 |
+
|
| 12 |
+
결과 파일:
|
| 13 |
+
data/eval_results_simple.json
|
| 14 |
+
data/eval_results_kcelectra.json
|
| 15 |
+
data/eval_comparison_summary.csv
|
| 16 |
+
|
| 17 |
+
평가 지표:
|
| 18 |
+
- Macro F1 : 클래스 불균형 무관 전체 성능 (메인 지표)
|
| 19 |
+
- Per-class F1, Precision, Recall
|
| 20 |
+
- Confusion Matrix
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import json
|
| 25 |
+
import sys
|
| 26 |
+
from datetime import datetime
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import pandas as pd
|
| 30 |
+
from sklearn.metrics import (
|
| 31 |
+
classification_report,
|
| 32 |
+
confusion_matrix,
|
| 33 |
+
f1_score,
|
| 34 |
+
precision_score,
|
| 35 |
+
recall_score,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
_BASE = Path(__file__).parent.parent
|
| 39 |
+
sys.path.insert(0, str(_BASE))
|
| 40 |
+
|
| 41 |
+
from src.classifier_simple import load_pipeline, load_data
|
| 42 |
+
from src.classifier_kcelectra import predict_kcelectra, is_ready as kcelectra_ready
|
| 43 |
+
|
| 44 |
+
LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"]
|
| 45 |
+
OUT_DIR = _BASE / "data"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _fill_per_class_from_cm(res: dict) -> dict:
|
| 49 |
+
"""Colab 저장 JSON에 per_class가 없을 때 confusion_matrix에서 보완."""
|
| 50 |
+
if res.get("per_class"):
|
| 51 |
+
return res
|
| 52 |
+
cm = res.get("confusion_matrix")
|
| 53 |
+
labels = res.get("labels", LABELS)
|
| 54 |
+
if not cm:
|
| 55 |
+
return res
|
| 56 |
+
per_class = {}
|
| 57 |
+
for i, label in enumerate(labels):
|
| 58 |
+
tp = cm[i][i]
|
| 59 |
+
fp = sum(cm[r][i] for r in range(len(cm))) - tp
|
| 60 |
+
fn = sum(cm[i]) - tp
|
| 61 |
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 62 |
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 63 |
+
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
|
| 64 |
+
per_class[label] = {
|
| 65 |
+
"precision": round(precision, 4),
|
| 66 |
+
"recall": round(recall, 4),
|
| 67 |
+
"f1": round(f1, 4),
|
| 68 |
+
"support": sum(cm[i]),
|
| 69 |
+
}
|
| 70 |
+
res["per_class"] = per_class
|
| 71 |
+
return res
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def evaluate_simple(split: str = "test") -> dict:
|
| 75 |
+
texts, true_labels = load_data(split)
|
| 76 |
+
if not texts:
|
| 77 |
+
texts, true_labels = load_data("all")
|
| 78 |
+
|
| 79 |
+
pipe = load_pipeline()
|
| 80 |
+
pred_labels = pipe.predict(texts)
|
| 81 |
+
|
| 82 |
+
return _make_result("simple", true_labels, list(pred_labels))
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def evaluate_kcelectra(split: str = "test") -> dict:
|
| 86 |
+
# Colab에서 이미 평가한 JSON이 있으면 재활용 (torch 없는 환경에서도 비교 가능)
|
| 87 |
+
cached_json = OUT_DIR / "eval_results_kcelectra.json"
|
| 88 |
+
if cached_json.exists() and not kcelectra_ready():
|
| 89 |
+
print(f"[compare] Colab 결과 파일 사용: {cached_json.name}")
|
| 90 |
+
with open(cached_json, encoding="utf-8") as f:
|
| 91 |
+
result = json.load(f)
|
| 92 |
+
result = _fill_per_class_from_cm(result)
|
| 93 |
+
# per_class 보완된 내용을 다시 저장
|
| 94 |
+
with open(cached_json, "w", encoding="utf-8") as f:
|
| 95 |
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
| 96 |
+
return result
|
| 97 |
+
|
| 98 |
+
if not kcelectra_ready():
|
| 99 |
+
print("[compare] KcELECTRA 체크포인트 없음. 01_train_kcelectra.ipynb 먼저 실행하세요.")
|
| 100 |
+
return {}
|
| 101 |
+
|
| 102 |
+
texts, true_labels = load_data(split)
|
| 103 |
+
if not texts:
|
| 104 |
+
texts, true_labels = load_data("all")
|
| 105 |
+
|
| 106 |
+
pred_labels = [predict_kcelectra(t)["category"] for t in texts]
|
| 107 |
+
return _make_result("kcelectra", true_labels, pred_labels)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _make_result(model_name: str, true: list, pred: list) -> dict:
|
| 111 |
+
macro_f1 = f1_score(true, pred, labels=LABELS, average="macro", zero_division=0)
|
| 112 |
+
macro_pre = precision_score(true, pred, labels=LABELS, average="macro", zero_division=0)
|
| 113 |
+
macro_rec = recall_score(true, pred, labels=LABELS, average="macro", zero_division=0)
|
| 114 |
+
|
| 115 |
+
report = classification_report(
|
| 116 |
+
true, pred, labels=LABELS, output_dict=True, zero_division=0
|
| 117 |
+
)
|
| 118 |
+
cm = confusion_matrix(true, pred, labels=LABELS)
|
| 119 |
+
|
| 120 |
+
print(f"\n{'='*50}")
|
| 121 |
+
print(f"[{model_name}] 분류 리포트")
|
| 122 |
+
print(classification_report(true, pred, labels=LABELS, zero_division=0))
|
| 123 |
+
print(f"[{model_name}] Macro F1={macro_f1:.4f} Pre={macro_pre:.4f} Rec={macro_rec:.4f}")
|
| 124 |
+
print(f"[{model_name}] Confusion Matrix:\n{cm}")
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
"model": model_name,
|
| 128 |
+
"macro_f1": round(macro_f1, 4),
|
| 129 |
+
"macro_precision":round(macro_pre, 4),
|
| 130 |
+
"macro_recall": round(macro_rec, 4),
|
| 131 |
+
"per_class": {
|
| 132 |
+
label: {
|
| 133 |
+
"precision": round(report[label]["precision"], 4),
|
| 134 |
+
"recall": round(report[label]["recall"], 4),
|
| 135 |
+
"f1": round(report[label]["f1-score"], 4),
|
| 136 |
+
"support": report[label]["support"],
|
| 137 |
+
}
|
| 138 |
+
for label in LABELS if label in report
|
| 139 |
+
},
|
| 140 |
+
"confusion_matrix": cm.tolist(),
|
| 141 |
+
"labels": LABELS,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def save_and_compare(simple_res: dict, kcelectra_res: dict) -> None:
|
| 146 |
+
ts = datetime.now().strftime("%Y%m%d")
|
| 147 |
+
|
| 148 |
+
def _write_json(data: dict, canonical: str) -> None:
|
| 149 |
+
with open(OUT_DIR / canonical, "w", encoding="utf-8") as f:
|
| 150 |
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 151 |
+
stem = canonical.replace(".json", "")
|
| 152 |
+
with open(OUT_DIR / f"{stem}_{ts}.json", "w", encoding="utf-8") as f:
|
| 153 |
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 154 |
+
|
| 155 |
+
_write_json(simple_res, "eval_results_simple.json")
|
| 156 |
+
if kcelectra_res:
|
| 157 |
+
_write_json(kcelectra_res, "eval_results_kcelectra.json")
|
| 158 |
+
|
| 159 |
+
rows = []
|
| 160 |
+
for res in [simple_res, kcelectra_res]:
|
| 161 |
+
if not res:
|
| 162 |
+
continue
|
| 163 |
+
row = {
|
| 164 |
+
"model": res["model"],
|
| 165 |
+
"macro_f1": res["macro_f1"],
|
| 166 |
+
"macro_precision":res["macro_precision"],
|
| 167 |
+
"macro_recall": res["macro_recall"],
|
| 168 |
+
}
|
| 169 |
+
for label in LABELS:
|
| 170 |
+
if label in res.get("per_class", {}):
|
| 171 |
+
row[f"{label}_f1"] = res["per_class"][label]["f1"]
|
| 172 |
+
rows.append(row)
|
| 173 |
+
|
| 174 |
+
summary_df = pd.DataFrame(rows)
|
| 175 |
+
summary_df.to_csv(OUT_DIR / "eval_comparison_summary.csv", index=False, encoding="utf-8-sig")
|
| 176 |
+
summary_df.to_csv(OUT_DIR / f"eval_comparison_summary_{ts}.csv", index=False, encoding="utf-8-sig")
|
| 177 |
+
print(f"\n[compare] 결과 저장 완료 → {OUT_DIR}")
|
| 178 |
+
print("\n── 성능 요약 ──")
|
| 179 |
+
print(summary_df[["model", "macro_f1", "macro_precision", "macro_recall"]].to_string(index=False))
|
| 180 |
+
|
| 181 |
+
if kcelectra_res:
|
| 182 |
+
delta = kcelectra_res["macro_f1"] - simple_res["macro_f1"]
|
| 183 |
+
print(f"\n KcELECTRA vs Simple ΔMacro F1 = {delta:+.4f}")
|
| 184 |
+
if delta >= 0.05:
|
| 185 |
+
print(" → KcELECTRA 5%+ 향상: 채택 권장!")
|
| 186 |
+
else:
|
| 187 |
+
print(" → 5% 미만 향상: Simple 유지 고려")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def main(split: str = "test") -> None:
|
| 191 |
+
print(f"[compare] 평가 split: {split}")
|
| 192 |
+
|
| 193 |
+
simple_res = evaluate_simple(split)
|
| 194 |
+
kcelectra_res = evaluate_kcelectra(split)
|
| 195 |
+
|
| 196 |
+
save_and_compare(simple_res, kcelectra_res)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
if __name__ == "__main__":
|
| 200 |
+
parser = argparse.ArgumentParser()
|
| 201 |
+
parser.add_argument("--split", default="test", choices=["train", "val", "test", "all"])
|
| 202 |
+
args = parser.parse_args()
|
| 203 |
+
main(split=args.split)
|
model/classification/scripts/evaluate_compare_ensemble_20260505.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
evaluate_compare_ensemble_20260505.py
|
| 3 |
+
======================================
|
| 4 |
+
담당: 경이 (kyeongyi)
|
| 5 |
+
작성일: 2026-05-05
|
| 6 |
+
|
| 7 |
+
목적:
|
| 8 |
+
Simple + KcELECTRA 소프트 투표 앙상블 평가.
|
| 9 |
+
KcELECTRA 단독(v3, Macro F1 0.8545)이 목표 +5%에 0.71%p 미달하여
|
| 10 |
+
devlog 우선순위 3에 따라 앙상블 시도.
|
| 11 |
+
|
| 12 |
+
[비교 모델]
|
| 13 |
+
1. Simple : TF-IDF + Logistic Regression (baseline 0.8116)
|
| 14 |
+
2. KcELECTRA: v3 파인튜닝 단독 (0.8545)
|
| 15 |
+
3. Ensemble : KcELECTRA×0.7 + Simple×0.3 (목표: 0.8616+)
|
| 16 |
+
|
| 17 |
+
[앙상블 방식]
|
| 18 |
+
소프트 투표(Soft Voting) — 두 모델의 확률을 가중 합산 후 argmax
|
| 19 |
+
combined[i] = weight_kc × kc_prob[i] + (1-weight_kc) × s_prob[i]
|
| 20 |
+
|
| 21 |
+
[출력 파일 - data/20260505/]
|
| 22 |
+
eval_results_ensemble_20260505.json
|
| 23 |
+
eval_comparison_summary_ensemble_20260505.csv
|
| 24 |
+
|
| 25 |
+
실행:
|
| 26 |
+
cd model/classification
|
| 27 |
+
python scripts/evaluate_compare_ensemble_20260505.py
|
| 28 |
+
python scripts/evaluate_compare_ensemble_20260505.py --weight_kc 0.6
|
| 29 |
+
python scripts/evaluate_compare_ensemble_20260505.py --search_weights
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
import json
|
| 34 |
+
import pickle
|
| 35 |
+
import sys
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
import numpy as np
|
| 39 |
+
import pandas as pd
|
| 40 |
+
from sklearn.metrics import classification_report, confusion_matrix, f1_score
|
| 41 |
+
from sklearn.pipeline import Pipeline
|
| 42 |
+
|
| 43 |
+
_BASE = Path(__file__).parent.parent
|
| 44 |
+
sys.path.insert(0, str(_BASE / "src"))
|
| 45 |
+
|
| 46 |
+
SPLIT_CSV = _BASE / "data" / "split_v5_20260505.csv"
|
| 47 |
+
SIMPLE_PKL = _BASE / "checkpoints" / "simple_tfidf_logreg_v3_20260505.pkl"
|
| 48 |
+
KCELECTRA_CKPT = _BASE / "checkpoints" / "kcelectra-category-v3"
|
| 49 |
+
OUT_DIR = _BASE / "data" / "20260505"
|
| 50 |
+
|
| 51 |
+
# HF Hub fallback (로컬 체크포인트 없을 때)
|
| 52 |
+
_HF_REPO = "kysophia/kcelectra-category"
|
| 53 |
+
_HF_SUBFOLDER = "kcelectra-category-v3"
|
| 54 |
+
|
| 55 |
+
LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"]
|
| 56 |
+
_LABEL_TO_COL = {lbl: i for i, lbl in enumerate(LABELS)}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ──────────────────────────────────────────────────────────────────
|
| 60 |
+
# 데이터 로드
|
| 61 |
+
# ──────────────────────────────────────────────────────────────────
|
| 62 |
+
def load_split(split: str = "test") -> tuple[list[str], list[str]]:
|
| 63 |
+
if not SPLIT_CSV.exists():
|
| 64 |
+
raise FileNotFoundError(f"{SPLIT_CSV} 없음")
|
| 65 |
+
df = pd.read_csv(SPLIT_CSV, encoding="utf-8-sig")
|
| 66 |
+
df = df[df["split"] == split]
|
| 67 |
+
df = df[df["category"].isin(LABELS)]
|
| 68 |
+
return df["text"].tolist(), df["category"].tolist()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ──────────────────────────────────────────────────────────────────
|
| 72 |
+
# Simple 확률 행렬 (N, 6)
|
| 73 |
+
# ──────────────────────────────────────────────────────────────────
|
| 74 |
+
def _load_simple() -> Pipeline:
|
| 75 |
+
if not SIMPLE_PKL.exists():
|
| 76 |
+
raise FileNotFoundError(
|
| 77 |
+
f"{SIMPLE_PKL.name} 없음.\n"
|
| 78 |
+
" 먼저 실행: python scripts/evaluate_compare_v3_20260505.py"
|
| 79 |
+
)
|
| 80 |
+
with open(SIMPLE_PKL, "rb") as f:
|
| 81 |
+
return pickle.load(f)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_simple_proba(texts: list[str]) -> np.ndarray:
|
| 85 |
+
"""Simple predict_proba → LABELS 순서로 열 정렬한 (N, 6) 행렬."""
|
| 86 |
+
pipe = _load_simple()
|
| 87 |
+
raw = pipe.predict_proba(texts) # (N, K), pipe.classes_ 순서
|
| 88 |
+
classes = list(pipe.classes_)
|
| 89 |
+
out = np.zeros((len(texts), len(LABELS)))
|
| 90 |
+
for j, lbl in enumerate(LABELS):
|
| 91 |
+
if lbl in classes:
|
| 92 |
+
out[:, j] = raw[:, classes.index(lbl)]
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ──────────────────────────────────────────────────────────────────
|
| 97 |
+
# KcELECTRA 확률 행렬 (N, 6)
|
| 98 |
+
# ──────────────────────────────────────────────────────────────────
|
| 99 |
+
def _load_kcelectra_model():
|
| 100 |
+
"""(device, tokenizer, model, id2label) 반환. 로컬 우선, HF Hub fallback."""
|
| 101 |
+
try:
|
| 102 |
+
import torch
|
| 103 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 104 |
+
except ImportError:
|
| 105 |
+
raise ImportError("pip install torch transformers")
|
| 106 |
+
|
| 107 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 108 |
+
|
| 109 |
+
local_ready = (
|
| 110 |
+
KCELECTRA_CKPT.exists()
|
| 111 |
+
and (KCELECTRA_CKPT / "config.json").exists()
|
| 112 |
+
and any(
|
| 113 |
+
(KCELECTRA_CKPT / f).exists()
|
| 114 |
+
for f in ("pytorch_model.bin", "model.safetensors")
|
| 115 |
+
)
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
if local_ready:
|
| 119 |
+
tokenizer = AutoTokenizer.from_pretrained(str(KCELECTRA_CKPT))
|
| 120 |
+
model = AutoModelForSequenceClassification.from_pretrained(
|
| 121 |
+
str(KCELECTRA_CKPT), num_labels=len(LABELS), ignore_mismatched_sizes=True
|
| 122 |
+
)
|
| 123 |
+
src = str(KCELECTRA_CKPT)
|
| 124 |
+
else:
|
| 125 |
+
print(f"[kcelectra] 로컬 없음 → HF Hub 다운로드: {_HF_REPO}/{_HF_SUBFOLDER}")
|
| 126 |
+
tokenizer = AutoTokenizer.from_pretrained(_HF_REPO, subfolder=_HF_SUBFOLDER)
|
| 127 |
+
model = AutoModelForSequenceClassification.from_pretrained(
|
| 128 |
+
_HF_REPO, subfolder=_HF_SUBFOLDER, num_labels=len(LABELS)
|
| 129 |
+
)
|
| 130 |
+
src = f"{_HF_REPO}/{_HF_SUBFOLDER}"
|
| 131 |
+
|
| 132 |
+
model.to(device).eval()
|
| 133 |
+
print(f"[kcelectra] 모델 로드: {src} → device={device}")
|
| 134 |
+
|
| 135 |
+
labels_file = KCELECTRA_CKPT / "label2id.json"
|
| 136 |
+
if local_ready and labels_file.exists():
|
| 137 |
+
with open(labels_file, encoding="utf-8") as f:
|
| 138 |
+
label2id: dict[str, int] = json.load(f)
|
| 139 |
+
id2label = {v: k for k, v in label2id.items()}
|
| 140 |
+
else:
|
| 141 |
+
id2label = {i: lbl for i, lbl in enumerate(LABELS)}
|
| 142 |
+
|
| 143 |
+
return device, tokenizer, model, id2label
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def get_kcelectra_proba(texts: list[str], batch_size: int = 32) -> np.ndarray:
|
| 147 |
+
"""KcELECTRA softmax → LABELS 순서로 열 정렬한 (N, 6) 행렬. 배치 처리."""
|
| 148 |
+
import torch
|
| 149 |
+
|
| 150 |
+
device, tokenizer, model, id2label = _load_kcelectra_model()
|
| 151 |
+
out = np.zeros((len(texts), len(LABELS)))
|
| 152 |
+
|
| 153 |
+
with torch.no_grad():
|
| 154 |
+
for start in range(0, len(texts), batch_size):
|
| 155 |
+
batch = texts[start : start + batch_size]
|
| 156 |
+
enc = tokenizer(
|
| 157 |
+
batch,
|
| 158 |
+
return_tensors="pt",
|
| 159 |
+
truncation=True,
|
| 160 |
+
padding=True,
|
| 161 |
+
max_length=128,
|
| 162 |
+
).to(device)
|
| 163 |
+
probs = torch.softmax(model(**enc).logits, dim=-1).cpu().numpy()
|
| 164 |
+
for b_i, prob_row in enumerate(probs):
|
| 165 |
+
for k_i, p in enumerate(prob_row):
|
| 166 |
+
lbl = id2label.get(k_i, "기타")
|
| 167 |
+
col = _LABEL_TO_COL.get(lbl, _LABEL_TO_COL["기타"])
|
| 168 |
+
out[start + b_i, col] = p
|
| 169 |
+
|
| 170 |
+
return out
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ──────────────────────────────────────────────────────────────────
|
| 174 |
+
# 앙상블 평가
|
| 175 |
+
# ──────────────────────────────────────────────────────────────────
|
| 176 |
+
def evaluate_ensemble(
|
| 177 |
+
split: str,
|
| 178 |
+
weight_kc: float,
|
| 179 |
+
s_proba: np.ndarray,
|
| 180 |
+
kc_proba: np.ndarray,
|
| 181 |
+
true_labels: list[str],
|
| 182 |
+
) -> dict:
|
| 183 |
+
"""소프트 투표. s_proba / kc_proba를 받아 가중 합산 → 분류 리포트 반환."""
|
| 184 |
+
combined = weight_kc * kc_proba + (1.0 - weight_kc) * s_proba
|
| 185 |
+
pred_idx = combined.argmax(axis=1)
|
| 186 |
+
pred_labels = [LABELS[i] for i in pred_idx]
|
| 187 |
+
|
| 188 |
+
report = classification_report(
|
| 189 |
+
true_labels, pred_labels,
|
| 190 |
+
labels=LABELS, output_dict=True, zero_division=0,
|
| 191 |
+
)
|
| 192 |
+
cm = confusion_matrix(true_labels, pred_labels, labels=LABELS)
|
| 193 |
+
macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS,
|
| 194 |
+
average="macro", zero_division=0)
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
"model": "ensemble",
|
| 198 |
+
"version": f"kc{weight_kc:.1f}+s{1-weight_kc:.1f}",
|
| 199 |
+
"weight_kc": weight_kc,
|
| 200 |
+
"macro_f1": round(macro_f1, 4),
|
| 201 |
+
"macro_precision": round(report["macro avg"]["precision"], 4),
|
| 202 |
+
"macro_recall": round(report["macro avg"]["recall"], 4),
|
| 203 |
+
"per_class": {
|
| 204 |
+
lbl: {
|
| 205 |
+
"precision": round(report[lbl]["precision"], 4),
|
| 206 |
+
"recall": round(report[lbl]["recall"], 4),
|
| 207 |
+
"f1": round(report[lbl]["f1-score"], 4),
|
| 208 |
+
"support": report[lbl]["support"],
|
| 209 |
+
}
|
| 210 |
+
for lbl in LABELS
|
| 211 |
+
},
|
| 212 |
+
"confusion_matrix": cm.tolist(),
|
| 213 |
+
"labels": LABELS,
|
| 214 |
+
"split_used": split,
|
| 215 |
+
"data_version": "v5_20260505",
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ──────────────────────────────────────────────────────────────────
|
| 220 |
+
# val 세트 가중치 그리드 탐색
|
| 221 |
+
# ──────────────────────────────────────────────────────────────────
|
| 222 |
+
def search_best_weight() -> float:
|
| 223 |
+
"""val 세트에서 weight_kc 0.4~0.9 탐색 → 최적 weight 반환."""
|
| 224 |
+
print("\n[weight 탐색] val 세트 기준 KcELECTRA 가중치 최적화")
|
| 225 |
+
|
| 226 |
+
val_texts, val_labels = load_split("val")
|
| 227 |
+
print(f" val {len(val_texts)}건 확률 계산 중...")
|
| 228 |
+
val_s = get_simple_proba(val_texts)
|
| 229 |
+
val_kc = get_kcelectra_proba(val_texts)
|
| 230 |
+
|
| 231 |
+
print(f"\n {'weight_kc':>10s} {'val Macro F1':>14s}")
|
| 232 |
+
print(" " + "-" * 28)
|
| 233 |
+
|
| 234 |
+
best_w, best_f1 = 0.7, 0.0
|
| 235 |
+
for w in [0.4, 0.5, 0.6, 0.7, 0.8, 0.9]:
|
| 236 |
+
res = evaluate_ensemble("val", w, val_s, val_kc, val_labels)
|
| 237 |
+
mark = " ← best" if res["macro_f1"] > best_f1 else ""
|
| 238 |
+
print(f" {w:>10.1f} {res['macro_f1']:>14.4f}{mark}")
|
| 239 |
+
if res["macro_f1"] > best_f1:
|
| 240 |
+
best_f1, best_w = res["macro_f1"], w
|
| 241 |
+
|
| 242 |
+
print(f"\n 최적 weight_kc = {best_w} (val Macro F1 = {best_f1:.4f})")
|
| 243 |
+
return best_w
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ──────────────────────────────────────────────────────────────────
|
| 247 |
+
# 저장 + 비교 출력
|
| 248 |
+
# ──────────────────────────────────────────────────────────────────
|
| 249 |
+
def _summary_row(res: dict) -> dict:
|
| 250 |
+
row = {
|
| 251 |
+
"macro_f1": res.get("macro_f1", "-"),
|
| 252 |
+
"macro_precision": res.get("macro_precision", "-"),
|
| 253 |
+
"macro_recall": res.get("macro_recall", "-"),
|
| 254 |
+
}
|
| 255 |
+
for lbl in LABELS:
|
| 256 |
+
row[f"f1_{lbl}"] = res.get("per_class", {}).get(lbl, {}).get("f1", "-")
|
| 257 |
+
return row
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _build_result_from_proba(
|
| 261 |
+
proba: np.ndarray,
|
| 262 |
+
true_labels: list[str],
|
| 263 |
+
model_name: str,
|
| 264 |
+
split: str,
|
| 265 |
+
) -> dict:
|
| 266 |
+
"""확률 행렬 → 분류 리포트 dict (JSON 없을 때 fallback)."""
|
| 267 |
+
pred_labels = [LABELS[i] for i in proba.argmax(axis=1)]
|
| 268 |
+
report = classification_report(true_labels, pred_labels,
|
| 269 |
+
labels=LABELS, output_dict=True, zero_division=0)
|
| 270 |
+
macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS,
|
| 271 |
+
average="macro", zero_division=0)
|
| 272 |
+
cm = confusion_matrix(true_labels, pred_labels, labels=LABELS)
|
| 273 |
+
return {
|
| 274 |
+
"model": model_name,
|
| 275 |
+
"version": "v3",
|
| 276 |
+
"macro_f1": round(macro_f1, 4),
|
| 277 |
+
"macro_precision": round(report["macro avg"]["precision"], 4),
|
| 278 |
+
"macro_recall": round(report["macro avg"]["recall"], 4),
|
| 279 |
+
"per_class": {
|
| 280 |
+
lbl: {
|
| 281 |
+
"precision": round(report[lbl]["precision"], 4),
|
| 282 |
+
"recall": round(report[lbl]["recall"], 4),
|
| 283 |
+
"f1": round(report[lbl]["f1-score"], 4),
|
| 284 |
+
"support": report[lbl]["support"],
|
| 285 |
+
}
|
| 286 |
+
for lbl in LABELS
|
| 287 |
+
},
|
| 288 |
+
"confusion_matrix": cm.tolist(),
|
| 289 |
+
"labels": LABELS,
|
| 290 |
+
"split_used": split,
|
| 291 |
+
"data_version": "v5_20260505",
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def save_and_compare(simple_res: dict, kc_res: dict, ens_res: dict) -> None:
|
| 296 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 297 |
+
|
| 298 |
+
ens_path = OUT_DIR / "eval_results_ensemble_20260505.json"
|
| 299 |
+
with open(ens_path, "w", encoding="utf-8") as f:
|
| 300 |
+
json.dump(ens_res, f, ensure_ascii=False, indent=2)
|
| 301 |
+
print(f"\n[저장] {ens_path.name}")
|
| 302 |
+
|
| 303 |
+
label_e = f"Ensemble(kc{ens_res['weight_kc']:.1f}+s{1-ens_res['weight_kc']:.1f})"
|
| 304 |
+
rows = [
|
| 305 |
+
{"model": "Simple (TF-IDF+LR)", **_summary_row(simple_res)},
|
| 306 |
+
{"model": "KcELECTRA v3 (단독)", **_summary_row(kc_res)},
|
| 307 |
+
{"model": label_e, **_summary_row(ens_res)},
|
| 308 |
+
]
|
| 309 |
+
summary_path = OUT_DIR / "eval_comparison_summary_ensemble_20260505.csv"
|
| 310 |
+
pd.DataFrame(rows).to_csv(summary_path, index=False, encoding="utf-8-sig")
|
| 311 |
+
print(f"[저장] {summary_path.name}")
|
| 312 |
+
|
| 313 |
+
s_f1 = simple_res["macro_f1"]
|
| 314 |
+
kc_f1 = kc_res["macro_f1"]
|
| 315 |
+
e_f1 = ens_res["macro_f1"]
|
| 316 |
+
|
| 317 |
+
print("\n" + "=" * 60)
|
| 318 |
+
print(" 앙상블 성능 비교 (v5 데이터 - 4992행)")
|
| 319 |
+
print("=" * 60)
|
| 320 |
+
print(f" Simple Macro F1 : {s_f1:.4f} (baseline)")
|
| 321 |
+
print(f" KcELECTRA Macro F1 : {kc_f1:.4f} (Delta vs Simple: {kc_f1-s_f1:+.4f})")
|
| 322 |
+
print(f" Ensemble Macro F1 : {e_f1:.4f} (Delta vs Simple: {e_f1-s_f1:+.4f})")
|
| 323 |
+
print()
|
| 324 |
+
|
| 325 |
+
delta = e_f1 - s_f1
|
| 326 |
+
if delta >= 0.05:
|
| 327 |
+
print(" ★ 앙상블 5%+ 향상 달성! 채택 확정")
|
| 328 |
+
elif delta > 0:
|
| 329 |
+
print(f" ~ 앙상블 소폭 향상 ({delta:+.4f}) — --search_weights 또는 추가 튜닝 권장")
|
| 330 |
+
else:
|
| 331 |
+
print(" ✗ 앙상블이 Simple 미달 — weight_kc 조정 필요")
|
| 332 |
+
|
| 333 |
+
print("\n [카테고리별 F1 비교]")
|
| 334 |
+
print(f" {'카테고리':10s} {'Simple':>8s} {'KcELEC':>8s} {'Ensemble':>10s}")
|
| 335 |
+
print(" " + "-" * 42)
|
| 336 |
+
for lbl in LABELS:
|
| 337 |
+
s = simple_res["per_class"].get(lbl, {}).get("f1", 0.0)
|
| 338 |
+
k = kc_res["per_class"].get(lbl, {}).get("f1", 0.0)
|
| 339 |
+
e = ens_res["per_class"].get(lbl, {}).get("f1", 0.0)
|
| 340 |
+
best = " ★" if e > max(s, k) else (" " if e >= max(s, k) else " ")
|
| 341 |
+
print(f" {lbl:10s} {s:>8.4f} {k:>8.4f} {e:>10.4f}{best}")
|
| 342 |
+
|
| 343 |
+
print(f"\n[출력 폴더] {OUT_DIR}")
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
# ──────────────────────────────────────────��───────────────────────
|
| 347 |
+
# CLI
|
| 348 |
+
# ──────────────────────────────────────────────────────────────────
|
| 349 |
+
def main() -> None:
|
| 350 |
+
parser = argparse.ArgumentParser(
|
| 351 |
+
description="KcELECTRA + Simple 소프트 투표 앙상블 평가"
|
| 352 |
+
)
|
| 353 |
+
parser.add_argument("--split", default="test",
|
| 354 |
+
choices=["train", "val", "test"])
|
| 355 |
+
parser.add_argument("--weight_kc", type=float, default=0.7,
|
| 356 |
+
help="KcELECTRA 가중치 (0.0~1.0). 기본값 0.7")
|
| 357 |
+
parser.add_argument("--search_weights", action="store_true",
|
| 358 |
+
help="val 세트에서 0.4~0.9 그리드 탐색 후 최적값으로 test 평가")
|
| 359 |
+
args = parser.parse_args()
|
| 360 |
+
|
| 361 |
+
print(f"앙상블 평가 시작 — split: {args.split}, weight_kc: {args.weight_kc}")
|
| 362 |
+
|
| 363 |
+
# ① val 세트 가중치 탐색 (--search_weights)
|
| 364 |
+
weight_kc = args.weight_kc
|
| 365 |
+
if args.search_weights:
|
| 366 |
+
weight_kc = search_best_weight()
|
| 367 |
+
print(f"\n[test 평가] 최적 weight_kc={weight_kc} 적용")
|
| 368 |
+
|
| 369 |
+
# ② test 확률 행렬
|
| 370 |
+
texts, true_labels = load_split(args.split)
|
| 371 |
+
print(f"\n[데이터] {args.split} 세트 {len(texts)}건")
|
| 372 |
+
|
| 373 |
+
print("\n[Simple] 확률 행렬 계산 중...")
|
| 374 |
+
s_proba = get_simple_proba(texts)
|
| 375 |
+
|
| 376 |
+
print("[KcELECTRA] 확률 행렬 계산 중... (CPU면 수 분 소요)")
|
| 377 |
+
kc_proba = get_kcelectra_proba(texts)
|
| 378 |
+
|
| 379 |
+
# ③ 앙상블 평가
|
| 380 |
+
ens_res = evaluate_ensemble(args.split, weight_kc, s_proba, kc_proba, true_labels)
|
| 381 |
+
|
| 382 |
+
# ④ 단독 결과 — 기존 JSON 재활용, 없으면 확률 행렬에서 직접 계산
|
| 383 |
+
simple_json = OUT_DIR / "eval_results_simple_v3_20260505.json"
|
| 384 |
+
kc_json = OUT_DIR / "eval_results_kcelectra_v3_20260505.json"
|
| 385 |
+
|
| 386 |
+
if simple_json.exists():
|
| 387 |
+
with open(simple_json, encoding="utf-8") as f:
|
| 388 |
+
simple_res = json.load(f)
|
| 389 |
+
print(f"[simple] 기존 JSON 재활용: {simple_json.name}")
|
| 390 |
+
else:
|
| 391 |
+
simple_res = _build_result_from_proba(s_proba, true_labels, "simple", args.split)
|
| 392 |
+
|
| 393 |
+
if kc_json.exists():
|
| 394 |
+
with open(kc_json, encoding="utf-8") as f:
|
| 395 |
+
kc_res = json.load(f)
|
| 396 |
+
print(f"[kcelectra] 기존 JSON 재활용: {kc_json.name}")
|
| 397 |
+
else:
|
| 398 |
+
kc_res = _build_result_from_proba(kc_proba, true_labels, "kcelectra", args.split)
|
| 399 |
+
|
| 400 |
+
# ⑤ 저장 + 비교 출력
|
| 401 |
+
print("\n[앙상블] 분류 리포트")
|
| 402 |
+
pred_labels = [LABELS[i] for i in (weight_kc * kc_proba + (1 - weight_kc) * s_proba).argmax(axis=1)]
|
| 403 |
+
print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0))
|
| 404 |
+
|
| 405 |
+
save_and_compare(simple_res, kc_res, ens_res)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
if __name__ == "__main__":
|
| 409 |
+
main()
|
model/classification/scripts/evaluate_compare_v2_20260504.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
evaluate_compare_v2_20260504.py
|
| 3 |
+
================================
|
| 4 |
+
담당: 경이 (kyeongyi)
|
| 5 |
+
작성일: 2026-05-04
|
| 6 |
+
|
| 7 |
+
목적:
|
| 8 |
+
split_v2_20260504.csv (695개 확장 데이터) 기준으로
|
| 9 |
+
두 모델의 성능을 비교·저장한다.
|
| 10 |
+
|
| 11 |
+
[비교 모델]
|
| 12 |
+
1. Simple : TF-IDF + Logistic Regression (베이스라인)
|
| 13 |
+
2. KcELECTRA: 파인튜닝 모델 (03_train_kcelectra_v2_20260504.ipynb 실행 후 사용 가능)
|
| 14 |
+
|
| 15 |
+
[공정 비교 원칙]
|
| 16 |
+
- 두 모델 모두 split_v2_20260504.csv의 동일한 test 세트(69개)로 평가
|
| 17 |
+
- 학습 데이터도 동일한 train 세트(556개) 사용
|
| 18 |
+
|
| 19 |
+
[출력 파일 — 타임스탬프 포함]
|
| 20 |
+
data/eval_results_simple_20260504.json
|
| 21 |
+
data/eval_results_kcelectra_20260504.json (KcELECTRA 준비 후 생성)
|
| 22 |
+
data/eval_comparison_summary_20260504.csv
|
| 23 |
+
|
| 24 |
+
실행:
|
| 25 |
+
cd model/classification
|
| 26 |
+
python scripts/evaluate_compare_v2_20260504.py
|
| 27 |
+
python scripts/evaluate_compare_v2_20260504.py --split val # val 세트로 평가
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
import argparse
|
| 31 |
+
import json
|
| 32 |
+
import pickle
|
| 33 |
+
import sys
|
| 34 |
+
from datetime import datetime
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
|
| 37 |
+
import pandas as pd
|
| 38 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 39 |
+
from sklearn.linear_model import LogisticRegression
|
| 40 |
+
from sklearn.metrics import (
|
| 41 |
+
classification_report,
|
| 42 |
+
confusion_matrix,
|
| 43 |
+
f1_score,
|
| 44 |
+
)
|
| 45 |
+
from sklearn.pipeline import Pipeline
|
| 46 |
+
|
| 47 |
+
_BASE = Path(__file__).parent.parent
|
| 48 |
+
sys.path.insert(0, str(_BASE / "src"))
|
| 49 |
+
|
| 50 |
+
SPLIT_CSV = _BASE / "data" / "split_v2_20260504.csv"
|
| 51 |
+
SIMPLE_PKL = _BASE / "checkpoints" / "simple_tfidf_logreg_v2_20260504.pkl"
|
| 52 |
+
KCELECTRA_CKPT = _BASE / "checkpoints" / "kcelectra-category-v2"
|
| 53 |
+
|
| 54 |
+
DATA_DIR = _BASE / "data"
|
| 55 |
+
TS = "20260504" # 타임스탬프
|
| 56 |
+
|
| 57 |
+
LABELS = ["일정", "준비물", "제출", "비용", "건강·안전", "기타"]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ──────────────────────────────────────────────────────────────────
|
| 61 |
+
# 데이터 로드
|
| 62 |
+
# ──────────────────────────────────────────────────────────────────
|
| 63 |
+
def load_split(split: str = "test") -> tuple[list[str], list[str]]:
|
| 64 |
+
"""
|
| 65 |
+
split_v2_20260504.csv에서 지정한 split의 text와 category를 반환.
|
| 66 |
+
split: "train" | "val" | "test"
|
| 67 |
+
"""
|
| 68 |
+
if not SPLIT_CSV.exists():
|
| 69 |
+
raise FileNotFoundError(
|
| 70 |
+
f"{SPLIT_CSV} 없음 — split_dataset_v2_20260504.py 먼저 실행하세요."
|
| 71 |
+
)
|
| 72 |
+
df = pd.read_csv(SPLIT_CSV, encoding="utf-8-sig")
|
| 73 |
+
df = df[df["split"] == split]
|
| 74 |
+
df = df[df["category"].isin(LABELS)]
|
| 75 |
+
return df["text"].tolist(), df["category"].tolist()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ──────────────────────────────────────────────────────────────────
|
| 79 |
+
# Simple 모델 (TF-IDF + LogReg)
|
| 80 |
+
# ──────────────────────────────────────────────────────────────────
|
| 81 |
+
def train_simple() -> Pipeline:
|
| 82 |
+
"""
|
| 83 |
+
v2 train 데이터로 베이스라인 재학습.
|
| 84 |
+
|
| 85 |
+
왜 재학습이 필요한가?
|
| 86 |
+
기존 simple_tfidf_logreg.pkl은 v1 데이터(244개) 기준으로 학습됨.
|
| 87 |
+
v2 데이터(556개 train)로 재학습해야 동일 조건의 비교가 가능.
|
| 88 |
+
"""
|
| 89 |
+
texts, labels = load_split("train")
|
| 90 |
+
print(f"[simple] train 데이터: {len(texts)}개")
|
| 91 |
+
|
| 92 |
+
pipe = Pipeline([
|
| 93 |
+
("tfidf", TfidfVectorizer(
|
| 94 |
+
analyzer="char_wb",
|
| 95 |
+
ngram_range=(2, 4),
|
| 96 |
+
max_features=30_000,
|
| 97 |
+
sublinear_tf=True,
|
| 98 |
+
)),
|
| 99 |
+
("clf", LogisticRegression(
|
| 100 |
+
C=1.0,
|
| 101 |
+
max_iter=1000,
|
| 102 |
+
class_weight="balanced",
|
| 103 |
+
random_state=42,
|
| 104 |
+
solver="lbfgs",
|
| 105 |
+
)),
|
| 106 |
+
])
|
| 107 |
+
pipe.fit(texts, labels)
|
| 108 |
+
|
| 109 |
+
SIMPLE_PKL.parent.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
with open(SIMPLE_PKL, "wb") as f:
|
| 111 |
+
pickle.dump(pipe, f)
|
| 112 |
+
print(f"[simple] 모델 저장: {SIMPLE_PKL.name}")
|
| 113 |
+
return pipe
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _load_simple() -> Pipeline:
|
| 117 |
+
if SIMPLE_PKL.exists():
|
| 118 |
+
with open(SIMPLE_PKL, "rb") as f:
|
| 119 |
+
return pickle.load(f)
|
| 120 |
+
return train_simple()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def evaluate_simple(split: str = "test") -> dict:
|
| 124 |
+
"""Simple 모델 평가 → 결과 dict 반환."""
|
| 125 |
+
texts, true_labels = load_split(split)
|
| 126 |
+
pipe = _load_simple()
|
| 127 |
+
pred_labels = pipe.predict(texts)
|
| 128 |
+
|
| 129 |
+
report = classification_report(
|
| 130 |
+
true_labels, pred_labels,
|
| 131 |
+
labels=LABELS,
|
| 132 |
+
output_dict=True,
|
| 133 |
+
zero_division=0,
|
| 134 |
+
)
|
| 135 |
+
cm = confusion_matrix(true_labels, pred_labels, labels=LABELS)
|
| 136 |
+
macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS,
|
| 137 |
+
average="macro", zero_division=0)
|
| 138 |
+
|
| 139 |
+
print("\n[Simple] 분류 리포트")
|
| 140 |
+
print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0))
|
| 141 |
+
|
| 142 |
+
# 결과 구조 — 노트북 시각화와 호환되는 형식
|
| 143 |
+
result = {
|
| 144 |
+
"model": "simple",
|
| 145 |
+
"macro_f1": round(macro_f1, 4),
|
| 146 |
+
"macro_precision": round(report["macro avg"]["precision"], 4),
|
| 147 |
+
"macro_recall": round(report["macro avg"]["recall"], 4),
|
| 148 |
+
"per_class": {
|
| 149 |
+
lbl: {
|
| 150 |
+
"precision": round(report[lbl]["precision"], 4),
|
| 151 |
+
"recall": round(report[lbl]["recall"], 4),
|
| 152 |
+
"f1": round(report[lbl]["f1-score"], 4),
|
| 153 |
+
"support": report[lbl]["support"],
|
| 154 |
+
}
|
| 155 |
+
for lbl in LABELS
|
| 156 |
+
},
|
| 157 |
+
"confusion_matrix": cm.tolist(),
|
| 158 |
+
"labels": LABELS,
|
| 159 |
+
"split_used": split,
|
| 160 |
+
"data_version": "v4_20260504",
|
| 161 |
+
"train_size": len(load_split("train")[0]),
|
| 162 |
+
"test_size": len(texts),
|
| 163 |
+
}
|
| 164 |
+
return result
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ──────────────────────────────────────────────────────────────────
|
| 168 |
+
# KcELECTRA 모델
|
| 169 |
+
# ──────────────────────────────────────────────────────────────────
|
| 170 |
+
def _kcelectra_ready() -> bool:
|
| 171 |
+
"""
|
| 172 |
+
03_train_kcelectra_v2_20260504.ipynb 실행 후 생성되는 체크포인트 확인.
|
| 173 |
+
체크포인트 없으면 평가 스킵 — 에러 없이 진행.
|
| 174 |
+
"""
|
| 175 |
+
try:
|
| 176 |
+
import torch # noqa: F401
|
| 177 |
+
from transformers import AutoTokenizer # noqa: F401
|
| 178 |
+
except ImportError:
|
| 179 |
+
print("[kcelectra] torch/transformers 미설치 — KcELECTRA 평가 스킵")
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
required = [
|
| 183 |
+
KCELECTRA_CKPT / "config.json",
|
| 184 |
+
KCELECTRA_CKPT / "label2id.json",
|
| 185 |
+
]
|
| 186 |
+
model_file = (
|
| 187 |
+
(KCELECTRA_CKPT / "model.safetensors").exists()
|
| 188 |
+
or (KCELECTRA_CKPT / "pytorch_model.bin").exists()
|
| 189 |
+
)
|
| 190 |
+
return all(f.exists() for f in required) and model_file
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def evaluate_kcelectra(split: str = "test") -> dict:
|
| 194 |
+
"""
|
| 195 |
+
KcELECTRA 평가.
|
| 196 |
+
체크포인트 없거나 torch 미설치 → 빈 dict 반환 (스킵).
|
| 197 |
+
|
| 198 |
+
eval_results_kcelectra_20260504.json이 이미 있으면 재사용.
|
| 199 |
+
(Colab에서 학습 후 JSON만 복사해도 동작)
|
| 200 |
+
"""
|
| 201 |
+
json_path = DATA_DIR / f"eval_results_kcelectra_{TS}.json"
|
| 202 |
+
|
| 203 |
+
# JSON 재활용 (Colab에서 다운로드해서 data/ 에 넣은 경우)
|
| 204 |
+
if json_path.exists():
|
| 205 |
+
print(f"[kcelectra] 기존 JSON 재활용: {json_path.name}")
|
| 206 |
+
with open(json_path, encoding="utf-8") as f:
|
| 207 |
+
return json.load(f)
|
| 208 |
+
|
| 209 |
+
if not _kcelectra_ready():
|
| 210 |
+
print("[kcelectra] 체크포인트 없음 — 03_train_kcelectra_v2_20260504.ipynb 실행 후 재시도")
|
| 211 |
+
return {}
|
| 212 |
+
|
| 213 |
+
# 체크포인트가 있을 때만 실행
|
| 214 |
+
import torch
|
| 215 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 216 |
+
|
| 217 |
+
texts, true_labels = load_split(split)
|
| 218 |
+
|
| 219 |
+
with open(KCELECTRA_CKPT / "label2id.json", encoding="utf-8") as f:
|
| 220 |
+
label2id: dict[str, int] = json.load(f)
|
| 221 |
+
id2label = {v: k for k, v in label2id.items()}
|
| 222 |
+
|
| 223 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 224 |
+
tokenizer = AutoTokenizer.from_pretrained(str(KCELECTRA_CKPT))
|
| 225 |
+
model = AutoModelForSequenceClassification.from_pretrained(
|
| 226 |
+
str(KCELECTRA_CKPT), num_labels=len(LABELS), ignore_mismatched_sizes=True
|
| 227 |
+
).to(device)
|
| 228 |
+
model.eval()
|
| 229 |
+
|
| 230 |
+
pred_labels = []
|
| 231 |
+
with torch.no_grad():
|
| 232 |
+
for text in texts:
|
| 233 |
+
enc = tokenizer(text, return_tensors="pt",
|
| 234 |
+
truncation=True, padding=True, max_length=128).to(device)
|
| 235 |
+
logits = model(**enc).logits
|
| 236 |
+
idx = int(logits.argmax(dim=-1).item())
|
| 237 |
+
pred_labels.append(id2label.get(idx, "기타"))
|
| 238 |
+
|
| 239 |
+
report = classification_report(
|
| 240 |
+
true_labels, pred_labels,
|
| 241 |
+
labels=LABELS,
|
| 242 |
+
output_dict=True,
|
| 243 |
+
zero_division=0,
|
| 244 |
+
)
|
| 245 |
+
cm = confusion_matrix(true_labels, pred_labels, labels=LABELS)
|
| 246 |
+
macro_f1 = f1_score(true_labels, pred_labels, labels=LABELS,
|
| 247 |
+
average="macro", zero_division=0)
|
| 248 |
+
|
| 249 |
+
print("\n[KcELECTRA] 분류 리포트")
|
| 250 |
+
print(classification_report(true_labels, pred_labels, labels=LABELS, zero_division=0))
|
| 251 |
+
|
| 252 |
+
result = {
|
| 253 |
+
"model": "kcelectra",
|
| 254 |
+
"macro_f1": round(macro_f1, 4),
|
| 255 |
+
"macro_precision": round(report["macro avg"]["precision"], 4),
|
| 256 |
+
"macro_recall": round(report["macro avg"]["recall"], 4),
|
| 257 |
+
"per_class": {
|
| 258 |
+
lbl: {
|
| 259 |
+
"precision": round(report[lbl]["precision"], 4),
|
| 260 |
+
"recall": round(report[lbl]["recall"], 4),
|
| 261 |
+
"f1": round(report[lbl]["f1-score"], 4),
|
| 262 |
+
"support": report[lbl]["support"],
|
| 263 |
+
}
|
| 264 |
+
for lbl in LABELS
|
| 265 |
+
},
|
| 266 |
+
"confusion_matrix": cm.tolist(),
|
| 267 |
+
"labels": LABELS,
|
| 268 |
+
"split_used": split,
|
| 269 |
+
"data_version": "v4_20260504",
|
| 270 |
+
}
|
| 271 |
+
return result
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ──────────────────────────────────────────────────────────────────
|
| 275 |
+
# 저장 + 비교 출력
|
| 276 |
+
# ──────────────────────────────────────────────────────────────────
|
| 277 |
+
def save_and_compare(simple_res: dict, kcelectra_res: dict) -> None:
|
| 278 |
+
# Simple 결과 저장
|
| 279 |
+
simple_path = DATA_DIR / f"eval_results_simple_{TS}.json"
|
| 280 |
+
with open(simple_path, "w", encoding="utf-8") as f:
|
| 281 |
+
json.dump(simple_res, f, ensure_ascii=False, indent=2)
|
| 282 |
+
print(f"\n[저장] {simple_path.name}")
|
| 283 |
+
|
| 284 |
+
# KcELECTRA 결과 저장 (있을 때만)
|
| 285 |
+
kc_path = DATA_DIR / f"eval_results_kcelectra_{TS}.json"
|
| 286 |
+
if kcelectra_res:
|
| 287 |
+
with open(kc_path, "w", encoding="utf-8") as f:
|
| 288 |
+
json.dump(kcelectra_res, f, ensure_ascii=False, indent=2)
|
| 289 |
+
print(f"[저장] {kc_path.name}")
|
| 290 |
+
|
| 291 |
+
# 비교 요약 CSV
|
| 292 |
+
rows = [{"model": "Simple (TF-IDF + LR)", **_summary_row(simple_res)}]
|
| 293 |
+
if kcelectra_res:
|
| 294 |
+
rows.append({"model": "KcELECTRA (fine-tuned)", **_summary_row(kcelectra_res)})
|
| 295 |
+
|
| 296 |
+
summary_df = pd.DataFrame(rows)
|
| 297 |
+
summary_path = DATA_DIR / f"eval_comparison_summary_{TS}.csv"
|
| 298 |
+
summary_df.to_csv(summary_path, index=False, encoding="utf-8-sig")
|
| 299 |
+
print(f"[저장] {summary_path.name}")
|
| 300 |
+
|
| 301 |
+
# 채택 판정
|
| 302 |
+
print("\n" + "=" * 50)
|
| 303 |
+
print(" 성능 비교 결과")
|
| 304 |
+
print("=" * 50)
|
| 305 |
+
print(f" Simple Macro F1 : {simple_res['macro_f1']:.4f}")
|
| 306 |
+
if kcelectra_res:
|
| 307 |
+
delta = kcelectra_res["macro_f1"] - simple_res["macro_f1"]
|
| 308 |
+
print(f" KcELECTRA Macro F1 : {kcelectra_res['macro_f1']:.4f}")
|
| 309 |
+
print(f" Delta : {delta:+.4f}")
|
| 310 |
+
if delta >= 0.05:
|
| 311 |
+
print(" >> KcELECTRA 5%+ 향상: 채택 권장!")
|
| 312 |
+
elif delta >= 0:
|
| 313 |
+
print(" >> KcELECTRA 소폭 향상: 추가 데이터/튜닝 권장")
|
| 314 |
+
else:
|
| 315 |
+
print(" >> Simple 유지 권장")
|
| 316 |
+
else:
|
| 317 |
+
print(" KcELECTRA: 평가 미완료 (노트북 실행 후 재시도)")
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _summary_row(res: dict) -> dict:
|
| 321 |
+
row = {
|
| 322 |
+
"macro_f1": res.get("macro_f1", "-"),
|
| 323 |
+
"macro_precision": res.get("macro_precision", "-"),
|
| 324 |
+
"macro_recall": res.get("macro_recall", "-"),
|
| 325 |
+
}
|
| 326 |
+
for lbl in LABELS:
|
| 327 |
+
row[f"f1_{lbl}"] = res.get("per_class", {}).get(lbl, {}).get("f1", "-")
|
| 328 |
+
return row
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ──────────────────────────────────────────────────────────────────
|
| 332 |
+
# CLI
|
| 333 |
+
# ──────────────────────────────────────────────────────────────────
|
| 334 |
+
def main() -> None:
|
| 335 |
+
parser = argparse.ArgumentParser()
|
| 336 |
+
parser.add_argument("--split", default="test", choices=["train", "val", "test"])
|
| 337 |
+
parser.add_argument("--retrain", action="store_true",
|
| 338 |
+
help="Simple 모델 강제 재학습 (PKL 있어도 새로 학습)")
|
| 339 |
+
args = parser.parse_args()
|
| 340 |
+
|
| 341 |
+
print(f"평가 시작 — split: {args.split}, 데이터: v4_20260504")
|
| 342 |
+
|
| 343 |
+
# Simple 재학습 여부
|
| 344 |
+
if args.retrain and SIMPLE_PKL.exists():
|
| 345 |
+
SIMPLE_PKL.unlink()
|
| 346 |
+
print("[simple] 기존 PKL 삭제 → 재학습")
|
| 347 |
+
|
| 348 |
+
simple_res = evaluate_simple(args.split)
|
| 349 |
+
kcelectra_res = evaluate_kcelectra(args.split)
|
| 350 |
+
|
| 351 |
+
save_and_compare(simple_res, kcelectra_res)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
if __name__ == "__main__":
|
| 355 |
+
main()
|