commit 248ac1deeadfd60f1bbf8a05e64b72ca2663d1ac Author: root Date: Mon Apr 20 20:39:24 2026 +0900 fix: --pool=solo SIGSEGV 해결 및 전체 설정 정리 diff --git a/README.md b/README.md new file mode 100644 index 0000000..554a7c8 --- /dev/null +++ b/README.md @@ -0,0 +1,585 @@ +# VoiceScript — 음성 변환(STT) + 이미지 인식(OCR) 통합 툴 + +> **Debian OS + Docker Compose** 기반 자체 호스팅 서비스 +> faster-whisper(STT) + PaddleOCR 3.x / Ollama Vision(OCR) 듀얼 백엔드 + +--- + +## 목차 + +1. [기능 개요](#기능-개요) +2. [프로젝트 구조](#프로젝트-구조) +3. [시스템 요구사항](#시스템-요구사항) +4. [설치 전 필수 확인사항 ⚠️](#설치-전-필수-확인사항) +5. [환경 변수 설정](#환경-변수-설정) +6. [빌드 및 실행](#빌드-및-실행) +7. [Nginx 연동 SSL](#nginx-연동-ssl) +8. [Ollama 모델 준비](#ollama-모델-준비) +9. [운영 관리](#운영-관리) +10. [트러블슈팅 알려진 이슈](#트러블슈팅-알려진-이슈) +11. [API 엔드포인트](#api-엔드포인트) + +--- + +## 기능 개요 + +### 🎙 STT — 음성 텍스트 변환 +- **엔진**: [faster-whisper](https://github.com/SYSTRAN/faster-whisper) (OpenAI Whisper 최적화 포크) +- 지원 형식: `mp3` `wav` `m4a` `ogg` `flac` `aac` `mp4` `webm` `mkv` 등 +- VAD(무음 구간 자동 제거) 적용 +- 타임스탬프 세그먼트 분리 출력 +- TXT 파일 다운로드 + +### 🔍 OCR — 이미지 텍스트 인식 +- 지원 형식: `jpg` `png` `bmp` `tiff` `webp` `gif` +- **PaddleOCR 모드**: 로컬 실행, 표 구조 분석(PP-Structure), Excel 다운로드 +- **Ollama Vision 모드**: 기존 Ollama 서버 활용, 자연어 지시, 커스텀 프롬프트 + +### 🔐 인증 +- JWT 기반 로그인 (만료 시간 설정 가능) +- 모든 API 토큰 인증 필수 + +--- + +## 프로젝트 구조 + +``` +whisper-stt/ +│ +├── docker-compose.yml # 전체 서비스 정의 +│ +├── app/ +│ ├── Dockerfile # Python 3.11-slim + ffmpeg + PaddlePaddle 3.0.0 +│ ├── requirements.txt # Python 패키지 목록 +│ │ +│ ├── main.py # FastAPI 앱 (인증 + STT + OCR 엔드포인트) +│ ├── auth.py # JWT 인증 모듈 +│ ├── tasks.py # Celery STT 태스크 (faster-whisper) +│ ├── ocr_tasks.py # Celery OCR 태스크 (PaddleOCR / Ollama) +│ │ +│ └── static/ +│ └── index.html # 웹 프론트엔드 (로그인 + STT + OCR 탭) +│ +└── nginx/ # 참고용 (호스트 Nginx 사용 시 불필요) + ├── Dockerfile + └── nginx.conf +``` + +### 컨테이너 구성 + +``` +┌─────────────────────────────────────────┐ +│ 호스트 Nginx (SSL/certbot) │ +│ → 리버스 프록시 → 127.0.0.1:8800 │ +└─────────────────────────────────────────┘ + │ + ▼ +┌──────────────────┐ ┌──────────────────────┐ +│ whisper_app │ │ whisper_worker │ +│ FastAPI:8000 │ │ Celery (solo pool) │ +│ (포트 8800) │ │ STT + OCR 처리 │ +└────────┬─────────┘ └──────────┬───────────┘ + │ │ + └────────────┬────────────┘ + ▼ + ┌──────────────────┐ + │ whisper_redis │ + │ Redis:6379 │ + │ (작업 큐/결과) │ + └──────────────────┘ +``` + +--- + +## 시스템 요구사항 + +| 항목 | 최소 | 권장 | +|------|------|------| +| CPU | 4코어 | AMD 5825u 이상 | +| RAM | 8GB | 16GB (medium 모델 기준) | +| 디스크 | 20GB | 50GB 이상 | +| OS | Debian 11+ | Debian 12 (Bookworm) | +| Docker | 24.0+ | 최신 | +| Docker Compose | v2.0+ | 최신 (`version:` 필드 불필요) | + +### 의존 서비스 +- **Ollama**: 호스트에서 `11434` 포트로 실행 중이어야 함 (OCR Vision 모드 사용 시) + +--- + +## 설치 전 필수 확인사항 + +> ⚠️ 이 섹션을 건너뛰면 빌드 후 오류가 발생합니다. + +### 1. 호스트 IP 확인 — OLLAMA_URL 설정 + +`host.docker.internal`은 Linux에서 동작하지 않습니다. +반드시 실제 LAN IP를 확인하여 설정하세요. + +```bash +ip addr show | grep "inet " | grep -v 127.0.0.1 +``` + +`docker-compose.yml` 두 곳(app, worker) 모두 변경: +```yaml +- OLLAMA_URL=http://실제호스트IP:11434 +``` + +### 2. 인증 정보 변경 + +```yaml +# app, worker 두 서비스 모두 동일하게 변경 +- AUTH_USERNAME=원하는아이디 +- AUTH_PASSWORD=강력한비밀번호 +- JWT_SECRET=랜덤문자열 # openssl rand -hex 32 +``` + +```bash +# JWT 시크릿 생성 +openssl rand -hex 32 +``` + +### 3. 포트 충돌 확인 + +```bash +ss -tlnp | grep 8800 +``` + +충돌 시 `docker-compose.yml`에서 변경: +```yaml +ports: + - "원하는포트:8000" +``` + +### 4. 디스크 용량 확인 + +| 항목 | 크기 | 시점 | +|------|------|------| +| Whisper medium 모델 | ~1.5GB | 첫 STT 실행 시 자동 다운로드 | +| PaddleOCR korean 모델 | ~700MB | 첫 OCR 실행 시 자동 다운로드 | +| PaddlePaddle 3.0.0 | ~300MB | 빌드 시 | +| Docker 이미지 | ~3GB | 빌드 시 | + +```bash +df -h / +# 여유 공간 10GB 이상 권장 +``` + +### 5. Ollama 서버 실행 확인 + +```bash +curl http://localhost:11434/api/tags +# 응답 없으면 Ollama 미실행 상태 +``` + +### 6. Docker Compose v2 확인 + +```bash +docker compose version +# v2.x 이상이어야 함 (docker-compose가 아닌 docker compose) +``` + +--- + +## 환경 변수 설정 + +`docker-compose.yml`의 `app`과 `worker` 두 서비스에 **동일하게** 설정. + +### 인증 + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `AUTH_USERNAME` | `admin` | 로그인 아이디 | +| `AUTH_PASSWORD` | `changeme1234` | 로그인 비밀번호 **변경 필수** | +| `JWT_SECRET` | *(변경 필수)* | JWT 서명 키 | +| `JWT_EXPIRE_HOURS` | `12` | 토큰 유효 시간 (시간 단위) | + +### Whisper STT + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `WHISPER_MODEL` | `medium` | `tiny` `base` `small` `medium` `large-v3` | +| `WHISPER_DEVICE` | `cpu` | GPU 없는 경우 `cpu` | +| `WHISPER_COMPUTE_TYPE` | `int8` | CPU 최적화: `int8` 권장 | +| `WHISPER_LANGUAGE` | `ko` | 언어 고정. 비우면 자동 감지 | +| `WHISPER_BEAM_SIZE` | `5` | 정확도↑ vs 속도↓ | +| `WHISPER_INITIAL_PROMPT` | 비어있음 | 도메인 힌트 예: `"고객 상담 녹취록입니다."` | + +**모델별 성능 (5825u CPU 기준)** + +| 모델 | 크기 | 1분 변환 시간 | 한국어 정확도 | +|------|------|-------------|--------------| +| tiny | 75MB | ~5초 | 보통 | +| base | 145MB | ~10초 | 보통 | +| small | 484MB | ~30초 | 양호 | +| **medium** | **1.5GB** | **~90초** | **우수 ← 권장** | +| large-v3 | 3GB | ~5분+ | 최고 | + +### PaddleOCR + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `OCR_LANG` | `korean` | `korean` `en` `japan` `chinese_cht` `ch` | + +### Ollama OCR + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `OLLAMA_URL` | `http://192.168.0.126:11434` | **실제 호스트 IP로 변경 필수** | +| `OLLAMA_TIMEOUT` | `180` | 초 단위. 11b 이상 모델은 `300` 이상 권장 | + +### 파일 관리 + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| `MAX_UPLOAD_MB` | `500` | 업로드 최대 파일 크기 (MB) | +| `OUTPUT_KEEP_HOURS` | `48` | 결과 파일 보관 시간. `0`=무제한 | + +--- + +## 빌드 및 실행 + +```bash +# 1. 저장소 클론 +git clone http://gitea.byunc.com/byun/whisper-stt.git +cd whisper-stt + +# 2. 필수 설정 변경 (docker-compose.yml) +# - AUTH_USERNAME, AUTH_PASSWORD, JWT_SECRET +# - OLLAMA_URL (호스트 실제 IP) + +# 3. 빌드 및 시작 +docker compose up -d --build + +# 4. 빌드 후 모델 다운로드 완료까지 대기 +docker compose logs -f worker +# "[Whisper] 로드 완료" + "celery@... ready." 확인 +``` + +접속: +``` +http://서버IP:8800 +``` + +### 이후 코드 변경 시 재배포 + +```bash +# 코드만 변경된 경우 (재빌드 필요) +docker compose build --no-cache app worker +docker compose up -d + +# 환경변수만 변경된 경우 (재빌드 불필요) +docker compose up -d --force-recreate app worker + +# Docker 이미지 정리 (빌드 반복 후 용량 정리) +docker system prune -f +``` + +--- + +## Nginx 연동 SSL + +호스트 Nginx + certbot SSL 운용 중인 경우: + +```nginx +# /etc/nginx/sites-available/voicescript.conf + +server { + listen 443 ssl; + server_name stt.yourdomain.com; + + ssl_certificate /etc/letsencrypt/live/stt.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stt.yourdomain.com/privkey.pem; + + # ⚠️ 음성 파일 업로드를 위해 반드시 설정 (기본 1MB → 초과 시 413 에러) + client_max_body_size 500M; + client_body_timeout 300s; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + + location / { + proxy_pass http://127.0.0.1:8800; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name stt.yourdomain.com; + return 301 https://$host$request_uri; +} +``` + +```bash +sudo certbot --nginx -d stt.yourdomain.com +sudo nginx -t && sudo systemctl reload nginx +``` + +--- + +## Ollama 모델 준비 + +호스트에서 미리 pull: + +```bash +# 문서/표 특화 — 약 2GB ← 기본값, 권장 +ollama pull granite3.2-vision + +# OCR 전용 경량 — 약 2GB +ollama pull deepseek-ocr:3b + +# 범용 고정확도 — 약 8GB (RAM 16GB+ 필요) +ollama pull llama3.2-vision:11b + +# 최고 정확도 — 약 9GB (RAM 16GB+ 필요) +ollama pull richardyoung/olmocr2:7b-q8 +``` + +> **참고**: `granite3.2-vision`만 설치되어 있어도 즉시 사용 가능합니다. +> 큰 모델 사용 시 `OLLAMA_TIMEOUT=300` 이상으로 설정하세요. + +--- + +## 운영 관리 + +```bash +# 상태 확인 +docker compose ps + +# 로그 확인 +docker compose logs app --tail=30 +docker compose logs worker --tail=30 +docker compose logs -f # 전체 실시간 + +# 재시작 +docker compose restart + +# 중지 +docker compose down + +# 설정 변경 후 재시작 (재빌드 없이) +docker compose up -d --force-recreate app worker +``` + +### Docker 이미지 정리 + +빌드를 반복하면 dangling 이미지가 누적됩니다. + +```bash +docker system df # 사용량 확인 +docker system prune -f # 불필요한 이미지/컨테이너 정리 +docker compose down -v # 볼륨 포함 완전 초기화 (모델 재다운로드 필요) +``` + +### 볼륨 정보 + +| 볼륨 | 내용 | 삭제 시 영향 | +|------|------|------------| +| `whisper_models` | Whisper 모델 (~1.5GB) | 재다운로드 필요 | +| `paddle_models` | PaddleOCR 모델 (~700MB) | 재다운로드 필요 | +| `stt_data` | 업로드/결과 파일 | 데이터 손실 | +| `redis_data` | 작업 큐 상태 | 진행 중 작업 손실 | + +--- + +## 트러블슈팅 알려진 이슈 + +실제 배포 과정에서 겪은 오류와 해결 방법입니다. + +--- + +### ❌ `signal 11 (SIGSEGV)` — Worker 크래시 + +**원인**: faster-whisper 내부 CTranslate2 라이브러리가 Celery `prefork` 방식과 충돌 +**해결**: `docker-compose.yml` worker command에 `--pool=solo` 추가 + +```yaml +command: > + celery -A tasks worker + --loglevel=info + --pool=solo # ← 이 옵션이 핵심 + --max-tasks-per-child=50 + -Q stt,ocr +``` + +> `--pool=solo`는 포크 없이 메인 프로세스에서 직접 실행합니다. +> `--concurrency=1`이었으므로 성능 차이는 없습니다. + +--- + +### ❌ `No matching distribution found for paddlepaddle==2.6.1` + +**원인**: 미러에서 해당 버전 제거됨 +**해결**: `Dockerfile`에서 `3.0.0`으로 변경 + +```dockerfile +RUN pip install --no-cache-dir paddlepaddle==3.0.0 \ + -i https://pypi.tuna.tsinghua.edu.cn/simple +``` + +--- + +### ❌ `ValueError: password cannot be longer than 72 bytes` + +**원인**: `passlib[bcrypt]` 초기화 버그 +**해결**: `auth.py`에서 bcrypt 완전 제거, 직접 문자열 비교 방식 사용 +`requirements.txt`에서 `passlib` 줄 삭제 + +--- + +### ❌ `AttributeError: 'DisabledBackend'` + +**원인**: `from celery.result import AsyncResult` 사용 시 백엔드 설정 누락 +**해결**: `celery_app.AsyncResult()` 방식으로 변경 + +```python +# main.py +from tasks import celery_app +r = celery_app.AsyncResult(task_id) # ✅ +``` + +--- + +### ❌ `ModuleNotFoundError: No module named 'ocr_tasks'` + +**원인**: `celery_app.autodiscover_tasks(["ocr_tasks"])` 동작 안 함 +**해결**: `tasks.py`에서 직접 import + +```python +from ocr_tasks import ocr_task # noqa: F401 +``` + +--- + +### ❌ `Unknown argument: use_gpu` / `Unknown argument: show_log` + +**원인**: PaddleOCR 3.x에서 파라미터 제거됨 +**해결**: `ocr_tasks.py`에서 해당 파라미터 삭제 + +```python +_ocr_engine = PaddleOCR(use_angle_cls=True, lang=OCR_LANG) # ✅ +``` + +--- + +### ❌ `PaddleOCR.predict() got an unexpected keyword argument 'cls'` + +**원인**: PaddleOCR 3.x API 변경 +**해결**: `ocr(img, cls=True)` → `ocr(img)` + +--- + +### ❌ `'AnalysisConfig' object has no attribute 'set_optimization_level'` + +**원인**: PaddleOCR 3.x와 paddlepaddle 2.x 버전 불일치 +**해결**: paddlepaddle `3.0.0`으로 업그레이드 + +--- + +### ❌ `too many values to unpack (expected 2)` + +**원인**: PaddleOCR 3.x 결과 구조 변경 +**해결**: `rec_texts` / `rec_scores` 방식으로 파싱 + +```python +r = result[0] +texts = r.get("rec_texts", []) +scores = r.get("rec_scores", []) +``` + +--- + +### ❌ `MISCONF Redis is configured to save RDB snapshots` + +**원인**: 디스크 부족으로 Redis RDB 저장 실패 → 쓰기 차단 +**해결**: `docker-compose.yml` Redis command에 옵션 추가 + +```yaml +command: redis-server --stop-writes-on-bgsave-error no +``` + +--- + +### ❌ Ollama 연결 타임아웃 + +**원인**: `host.docker.internal`이 Linux에서 불안정 +**해결**: 실제 호스트 LAN IP로 변경 + +```yaml +- OLLAMA_URL=http://192.168.x.x:11434 +``` + +--- + +### ❌ STT 진행률 5%/15%에서 멈춤 + +| 단계 | 원인 | 대기 시간 | +|------|------|---------| +| 5% `모델 준비 중` | Whisper 모델 첫 다운로드 (~1.5GB) | 5~20분 | +| 15% `오디오 분석 중` | 첫 변환 시 내부 초기화 | 1~3분 | +| `변환 중... Xs / Xs` | 정상 진행 | 파일 길이에 비례 | + +```bash +# 진행 상황 실시간 확인 +docker compose logs worker -f +``` + +--- + +## API 엔드포인트 + +### 인증 + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/login` | 로그인 (`username`, `password` form) | +| `GET` | `/api/me` | 현재 사용자 확인 | + +### STT + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/transcribe` | 음성 파일 업로드 및 변환 시작 | +| `GET` | `/api/status/{task_id}` | 작업 진행 상태 조회 | +| `GET` | `/api/download/{filename}` | 결과 파일 다운로드 | + +### OCR + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/ocr` | 이미지 업로드 및 인식 시작 | + +**OCR 파라미터** + +| 파라미터 | 기본값 | 설명 | +|---------|--------|------| +| `file` | — | 이미지 파일 | +| `mode` | `text` | `text` \| `structure` | +| `backend` | `paddle` | `paddle` \| `ollama` | +| `ollama_model` | `granite3.2-vision` | Ollama 모델명 | +| `custom_prompt` | 비어있음 | Ollama 커스텀 프롬프트 | + +### 관리 + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| `POST` | `/api/cleanup` | 오래된 결과 파일 정리 | + +--- + +## 기술 스택 + +| 구성요소 | 버전 | 역할 | +|---------|------|------| +| Python | 3.11 | 런타임 | +| FastAPI | 0.115 | API 서버 | +| Celery | 5.4 (`--pool=solo`) | 비동기 태스크 큐 | +| Redis | 7 alpine | 메시지 브로커 | +| faster-whisper | 1.0.3 | STT 엔진 | +| PaddlePaddle | 3.0.0 | OCR 딥러닝 프레임워크 | +| PaddleOCR | 3.x | OCR 엔진 | +| httpx | 0.27+ | Ollama API 호출 | +| Ollama | 호스트 운용 | Vision 모델 서버 | diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..fd2eb75 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y \ + ffmpeg \ + libsndfile1 \ + libgomp1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libgl1 \ + libgles2 \ + libegl1 \ + wget \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . + +# PaddlePaddle CPU — PyPI 공식 서버 +RUN pip install --no-cache-dir paddlepaddle==3.0.0 + +# 나머지 패키지 +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /data/uploads /data/outputs + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..3efb0d9 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,146 @@ +""" +인증 모듈 — 다중 사용자 JSON 파일 기반 +/data/users.json 에 사용자 정보 저장 +관리자(admin)는 환경변수 AUTH_USERNAME/AUTH_PASSWORD 기준으로 초기화 +""" +import os, json, threading +from pathlib import Path +from datetime import datetime, timedelta + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt + +SECRET_KEY = os.getenv("JWT_SECRET", "fallback-secret-change-this") +ALGORITHM = "HS256" +EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "12")) +ADMIN_USERNAME = os.getenv("AUTH_USERNAME", "admin") +ADMIN_PASSWORD = os.getenv("AUTH_PASSWORD", "changeme1234") + +DATA_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads")).parent +USERS_FILE = DATA_DIR / "users.json" + +_lock = threading.Lock() +bearer = HTTPBearer(auto_error=False) + + +# ── 파일 I/O ─────────────────────────────────────────────────── +def _load() -> dict: + if not USERS_FILE.exists(): + return {} + with open(USERS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + +def _save(users: dict): + USERS_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(USERS_FILE, "w", encoding="utf-8") as f: + json.dump(users, f, ensure_ascii=False, indent=2) + + +# ── 초기화 (앱 시작 시 1회) ──────────────────────────────────── +def init_users(): + with _lock: + users = _load() + # 관리자 계정은 항상 env var 기준으로 동기화 + users[ADMIN_USERNAME] = { + "password": ADMIN_PASSWORD, + "role": "admin", + "permissions": {"stt": True, "ocr": True}, + } + _save(users) + + +# ── CRUD ────────────────────────────────────────────────────── +def authenticate(username: str, password: str): + """성공 시 user dict, 실패 시 None""" + with _lock: + users = _load() + u = users.get(username) + if not u or u["password"] != password: + return None + return {"username": username, **u} + +def get_user(username: str): + with _lock: + return _load().get(username) + +def list_users() -> dict: + with _lock: + users = _load() + # 비밀번호 마스킹 + return {k: {**{kk: vv for kk, vv in v.items() if kk != "password"}} + for k, v in users.items()} + +def create_user(username: str, password: str, permissions: dict) -> tuple: + with _lock: + users = _load() + if username in users: + return False, "이미 존재하는 사용자입니다" + users[username] = {"password": password, "role": "user", + "permissions": permissions} + _save(users) + return True, "사용자가 생성되었습니다" + +def update_user(username: str, permissions: dict, password: str = None) -> tuple: + if username == ADMIN_USERNAME: + return False, "기본 관리자 계정은 수정할 수 없습니다" + with _lock: + users = _load() + if username not in users: + return False, "사용자를 찾을 수 없습니다" + users[username]["permissions"] = permissions + if password: + users[username]["password"] = password + _save(users) + return True, "업데이트되었습니다" + +def delete_user(username: str) -> tuple: + if username == ADMIN_USERNAME: + return False, "기본 관리자 계정은 삭제할 수 없습니다" + with _lock: + users = _load() + if username not in users: + return False, "사용자를 찾을 수 없습니다" + del users[username] + _save(users) + return True, "삭제되었습니다" + + +# ── JWT ─────────────────────────────────────────────────────── +def create_access_token(username: str) -> str: + exp = datetime.utcnow() + timedelta(hours=EXPIRE_HOURS) + return jwt.encode({"sub": username, "exp": exp}, SECRET_KEY, algorithm=ALGORITHM) + + +# ── FastAPI 의존성 ──────────────────────────────────────────── +def require_auth(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> dict: + if credentials is None: + raise HTTPException(401, "인증이 필요합니다", + headers={"WWW-Authenticate": "Bearer"}) + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + if not username: + raise JWTError() + u = get_user(username) + if not u: + raise JWTError() + return {"username": username, **u} + except JWTError: + raise HTTPException(401, "토큰이 유효하지 않거나 만료되었습니다", + headers={"WWW-Authenticate": "Bearer"}) + +def require_admin(user: dict = Depends(require_auth)) -> dict: + if user.get("role") != "admin": + raise HTTPException(403, "관리자 권한이 필요합니다") + return user + +def require_stt(user: dict = Depends(require_auth)) -> dict: + if not user.get("permissions", {}).get("stt", False): + raise HTTPException(403, "STT 사용 권한이 없습니다") + return user + +def require_ocr(user: dict = Depends(require_auth)) -> dict: + if not user.get("permissions", {}).get("ocr", False): + raise HTTPException(403, "OCR 사용 권한이 없습니다") + return user diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a409dbf --- /dev/null +++ b/app/main.py @@ -0,0 +1,275 @@ +import os, uuid, time, glob, json +import httpx +import aiofiles +from pathlib import Path +from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from auth import (authenticate, create_access_token, init_users, + require_auth, require_admin, require_stt, require_ocr, + list_users, create_user, update_user, delete_user) +from tasks import celery_app, transcribe_task +from ocr_tasks import ocr_task + +app = FastAPI(title="VoiceScript API") + +UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/data/uploads") +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/data/outputs") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.0.126:11434") +MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_MB", "500")) * 1024 * 1024 +OUTPUT_KEEP_SECS = int(os.getenv("OUTPUT_KEEP_HOURS", "48")) * 3600 + +DATA_DIR = Path(UPLOAD_DIR).parent +SETTINGS_FILE = DATA_DIR / "settings.json" + +os.makedirs(UPLOAD_DIR, exist_ok=True) +os.makedirs(OUTPUT_DIR, exist_ok=True) + +AUDIO_EXT = {"mp3","mp4","wav","m4a","ogg","flac","aac","wma","webm","mkv","avi","mov"} +IMAGE_EXT = {"jpg","jpeg","png","bmp","tiff","tif","webp","gif"} + + +# ── 설정 I/O ───────────────────────────────────────────────── +def _load_settings() -> dict: + if not SETTINGS_FILE.exists(): + return {"stt_ollama_model": "", "ocr_ollama_model": "granite3.2-vision:latest"} + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + +def _save_settings(data: dict): + SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +# ════════════════════════════════════════════════════════════════ +# 시작 이벤트 +# ════════════════════════════════════════════════════════════════ +@app.on_event("startup") +async def on_startup(): + init_users() + _cleanup_outputs() + + +# ════════════════════════════════════════════════════════════════ +# 인증 +# ════════════════════════════════════════════════════════════════ +@app.post("/api/login") +def login(username: str = Form(...), password: str = Form(...)): + user = authenticate(username, password) + if not user: + raise HTTPException(401, "아이디 또는 비밀번호가 올바르지 않습니다") + return {"access_token": create_access_token(username), "token_type": "bearer"} + +@app.get("/api/me") +def me(user: dict = Depends(require_auth)): + return { + "username": user["username"], + "role": user.get("role", "user"), + "permissions": user.get("permissions", {"stt": False, "ocr": False}), + } + + +# ════════════════════════════════════════════════════════════════ +# STT +# ════════════════════════════════════════════════════════════════ +@app.post("/api/transcribe") +async def transcribe( + request: Request, + file: UploadFile = File(...), + use_ollama: str = Form("false"), + ollama_model: str = Form(""), + user: dict = Depends(require_stt), +): + _check_size(request) + ext = _ext(file.filename) + if ext not in AUDIO_EXT: + raise HTTPException(400, f"지원하지 않는 형식: {', '.join(sorted(AUDIO_EXT))}") + file_id = str(uuid.uuid4()) + save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}") + await _save(file, save_path) + + _use_ollama = use_ollama.lower() == "true" + # 모델 미지정 시 설정에서 가져옴 + if _use_ollama and not ollama_model.strip(): + ollama_model = _load_settings().get("stt_ollama_model", "") + + task = transcribe_task.delay(file_id, save_path, _use_ollama, ollama_model) + return {"task_id": task.id, "file_id": file_id, "filename": file.filename} + + +# ════════════════════════════════════════════════════════════════ +# OCR +# ════════════════════════════════════════════════════════════════ +@app.post("/api/ocr") +async def ocr( + request: Request, + file: UploadFile = File(...), + mode: str = Form("text"), + backend: str = Form("paddle"), + ollama_model: str = Form(""), + custom_prompt: str = Form(""), + user: dict = Depends(require_ocr), +): + _check_size(request) + ext = _ext(file.filename) + if ext not in IMAGE_EXT: + raise HTTPException(400, f"지원하지 않는 형식: {', '.join(sorted(IMAGE_EXT))}") + if mode not in ("text", "structure"): mode = "text" + if backend not in ("paddle", "ollama"): backend = "paddle" + + # 모델 미지정 시 설정에서 가져옴 + if backend == "ollama" and not ollama_model.strip(): + ollama_model = _load_settings().get("ocr_ollama_model", "granite3.2-vision:latest") + + file_id = str(uuid.uuid4()) + save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}") + await _save(file, save_path) + task = ocr_task.delay(file_id, save_path, mode, backend, ollama_model, custom_prompt) + return {"task_id": task.id, "file_id": file_id, + "filename": file.filename, "mode": mode, "backend": backend} + + +# ════════════════════════════════════════════════════════════════ +# 작업 상태 / 다운로드 +# ════════════════════════════════════════════════════════════════ +@app.get("/api/status/{task_id}") +def get_status(task_id: str, user: dict = Depends(require_auth)): + r = celery_app.AsyncResult(task_id) + if r.state == "PENDING": return {"state": "pending", "progress": 0, "message": "대기 중..."} + if r.state == "PROGRESS": m = r.info or {}; return {"state": "progress","progress": m.get("progress",0),"message": m.get("message","처리 중...")} + if r.state == "SUCCESS": return {"state": "success", "progress": 100, **r.result} + if r.state == "FAILURE": return {"state": "failure", "progress": 0, "message": str(r.info)} + return {"state": r.state.lower(), "progress": 0} + +@app.get("/api/download/{filename}") +def download(filename: str, user: dict = Depends(require_auth)): + if ".." in filename or "/" in filename: + raise HTTPException(400, "잘못된 파일명") + path = os.path.join(OUTPUT_DIR, filename) + if not os.path.exists(path): + raise HTTPException(404, "파일을 찾을 수 없습니다") + media = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + if filename.endswith(".xlsx") else "text/plain") + return FileResponse(path, media_type=media, filename=filename) + + +# ════════════════════════════════════════════════════════════════ +# Ollama 모델 목록 +# ════════════════════════════════════════════════════════════════ +@app.get("/api/ollama/models") +def ollama_models(user: dict = Depends(require_auth)): + try: + resp = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=8.0) + resp.raise_for_status() + models = [m["name"] for m in resp.json().get("models", [])] + return {"models": models, "connected": True} + except Exception as e: + return {"models": [], "connected": False, "error": str(e)} + + +# ════════════════════════════════════════════════════════════════ +# 설정 +# ════════════════════════════════════════════════════════════════ +@app.get("/api/settings") +def get_settings(user: dict = Depends(require_auth)): + return _load_settings() + +@app.post("/api/settings") +def save_settings_endpoint( + stt_ollama_model: str = Form(""), + ocr_ollama_model: str = Form(""), + user: dict = Depends(require_auth), +): + data = {"stt_ollama_model": stt_ollama_model, + "ocr_ollama_model": ocr_ollama_model} + _save_settings(data) + return {"ok": True, "settings": data} + + +# ════════════════════════════════════════════════════════════════ +# 관리자 — 사용자 관리 +# ════════════════════════════════════════════════════════════════ +@app.get("/api/admin/users") +def admin_list_users(user: dict = Depends(require_admin)): + return {"users": list_users()} + +@app.post("/api/admin/users") +def admin_create_user( + username: str = Form(...), + password: str = Form(...), + perm_stt: str = Form("false"), + perm_ocr: str = Form("false"), + user: dict = Depends(require_admin), +): + perms = {"stt": perm_stt.lower()=="true", "ocr": perm_ocr.lower()=="true"} + ok, msg = create_user(username, password, perms) + if not ok: + raise HTTPException(400, msg) + return {"ok": True, "message": msg} + +@app.put("/api/admin/users/{username}") +def admin_update_user( + username: str, + perm_stt: str = Form("false"), + perm_ocr: str = Form("false"), + password: str = Form(""), + user: dict = Depends(require_admin), +): + perms = {"stt": perm_stt.lower()=="true", "ocr": perm_ocr.lower()=="true"} + ok, msg = update_user(username, perms, password or None) + if not ok: + raise HTTPException(400, msg) + return {"ok": True, "message": msg} + +@app.delete("/api/admin/users/{username}") +def admin_delete_user(username: str, user: dict = Depends(require_admin)): + ok, msg = delete_user(username) + if not ok: + raise HTTPException(400, msg) + return {"ok": True, "message": msg} + + +# ════════════════════════════════════════════════════════════════ +# 정리 +# ════════════════════════════════════════════════════════════════ +@app.post("/api/cleanup") +def cleanup(user: dict = Depends(require_auth)): + return {"removed": _cleanup_outputs()} + + +# ════════════════════════════════════════════════════════════════ +# 유틸 +# ════════════════════════════════════════════════════════════════ +def _check_size(request: Request): + cl = request.headers.get("content-length") + if cl and int(cl) > MAX_UPLOAD_BYTES: + raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB") + +def _cleanup_outputs() -> int: + if OUTPUT_KEEP_SECS == 0: + return 0 + cutoff = time.time() - OUTPUT_KEEP_SECS + removed = 0 + for f in glob.glob(os.path.join(OUTPUT_DIR, "*")): + try: + if os.path.getmtime(f) < cutoff: + os.remove(f); removed += 1 + except: pass + return removed + +def _ext(fn): return fn.rsplit(".", 1)[-1].lower() if "." in fn else "" + +async def _save(file: UploadFile, path: str): + written = 0 + async with aiofiles.open(path, "wb") as f: + while chunk := await file.read(1024 * 1024): + written += len(chunk) + if written > MAX_UPLOAD_BYTES: + await f.close(); os.remove(path) + raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB") + await f.write(chunk) + +app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/app/ocr_tasks.py b/app/ocr_tasks.py new file mode 100644 index 0000000..c5bc231 --- /dev/null +++ b/app/ocr_tasks.py @@ -0,0 +1,288 @@ +""" +OCR Celery Tasks +- PaddleOCR 3.x 호환 (use_gpu/show_log/cls 파라미터 제거, 결과구조 변경 반영) +- backend="paddle" → PaddleOCR 로컬 실행 +- backend="ollama" → Ollama Vision API 호출 +""" +import os +import base64 + +import httpx +from celery import Celery +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/data/outputs") +OCR_LANG = os.getenv("OCR_LANG", "korean") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.0.126:11434") +OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "180")) + +celery_app = Celery("ocr_tasks", broker=REDIS_URL, backend=REDIS_URL) +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + task_track_started=True, + result_expires=3600, +) + +# PaddleOCR 싱글톤 +_ocr_engine = None +_struct_engine = None + +def get_ocr(): + global _ocr_engine + if _ocr_engine is None: + from paddleocr import PaddleOCR + print(f"[PaddleOCR] 로딩 (lang={OCR_LANG})") + # PaddleOCR 3.x: use_gpu/show_log 파라미터 제거됨 + _ocr_engine = PaddleOCR(use_angle_cls=True, lang=OCR_LANG) + print("[PaddleOCR] 완료") + return _ocr_engine + +def get_structure(): + global _struct_engine + if _struct_engine is None: + from paddleocr import PPStructure + print("[PPStructure] 로딩") + _struct_engine = PPStructure(table=True, ocr=True, lang=OCR_LANG) + print("[PPStructure] 완료") + return _struct_engine + + +# ════════════════════════════════════════════════════════════════ +# 메인 Task +# ════════════════════════════════════════════════════════════════ +@celery_app.task(bind=True, name="tasks.ocr_task", queue="ocr") +def ocr_task(self, file_id, image_path, mode="text", + backend="paddle", ollama_model="granite3.2-vision", custom_prompt=""): + self.update_state(state="PROGRESS", meta={"progress": 8, "message": "엔진 준비 중..."}) + try: + if backend == "ollama": + result = _run_ollama(self, file_id, image_path, mode, ollama_model, custom_prompt) + else: + result = _run_paddle(self, file_id, image_path, mode) + try: os.remove(image_path) + except: pass + return result + except Exception as e: + try: os.remove(image_path) + except: pass + raise Exception(f"OCR 실패: {str(e)}") + + +# ════════════════════════════════════════════════════════════════ +# Ollama 백엔드 +# ════════════════════════════════════════════════════════════════ +_OLLAMA_PROMPTS = { + "text": "이 이미지에서 모든 텍스트를 정확하게 추출해줘. 원본의 줄 구분과 단락 구조를 유지해줘.", + "structure": "이 이미지를 분석해서 표는 마크다운 표 형식으로, 나머지 텍스트는 원본 구조를 유지하며 추출해줘.", +} + +def _run_ollama(task, file_id, image_path, mode, ollama_model, custom_prompt): + task.update_state(state="PROGRESS", + meta={"progress": 15, "message": f"Ollama ({ollama_model}) 연결 중..."}) + with open(image_path, "rb") as f: + img_b64 = base64.b64encode(f.read()).decode() + prompt = custom_prompt.strip() or _OLLAMA_PROMPTS.get(mode, _OLLAMA_PROMPTS["text"]) + task.update_state(state="PROGRESS", meta={"progress": 30, "message": "모델 추론 중..."}) + try: + resp = httpx.post(f"{OLLAMA_URL}/api/chat", json={ + "model": ollama_model, + "messages": [{"role": "user", "content": prompt, "images": [img_b64]}], + "stream": False, + "options": {"temperature": 0.1}, + }, timeout=float(OLLAMA_TIMEOUT)) + resp.raise_for_status() + except httpx.ConnectError: + raise Exception(f"Ollama 서버 연결 실패 ({OLLAMA_URL})") + except httpx.TimeoutException: + raise Exception(f"Ollama 응답 시간 초과 ({OLLAMA_TIMEOUT}초). OLLAMA_TIMEOUT 값을 늘려주세요.") + + task.update_state(state="PROGRESS", meta={"progress": 85, "message": "결과 저장 중..."}) + full_text = resp.json().get("message", {}).get("content", "").strip() + if not full_text: + raise Exception("Ollama 빈 응답. 모델이 설치되어 있는지 확인하세요.") + + tables = _parse_md_tables(full_text) if mode == "structure" else [] + os.makedirs(OUTPUT_DIR, exist_ok=True) + txt_file = f"{file_id}_ocr.txt" + with open(os.path.join(OUTPUT_DIR, txt_file), "w", encoding="utf-8") as f: + f.write(f"# OCR 결과 (Ollama / {ollama_model})\n\n{full_text}") + xlsx_file = None + if tables: + xlsx_file = f"{file_id}_tables.xlsx" + _save_excel(tables, os.path.join(OUTPUT_DIR, xlsx_file)) + tables_html = [_md_table_to_html(t) for t in tables] + lines = [{"text": l, "confidence": 1.0, "bbox": []} + for l in full_text.splitlines() if l.strip()] + return { + "mode": mode, "backend": "ollama", "ollama_model": ollama_model, + "full_text": full_text, "lines": lines, "line_count": len(lines), + "txt_file": txt_file, + "tables": [{"html": h, "rows": len(t), + "cols": max(len(r) for r in t) if t else 0} + for h, t in zip(tables_html, tables)], + "xlsx_file": xlsx_file, + } + + +# ════════════════════════════════════════════════════════════════ +# PaddleOCR 백엔드 +# ════════════════════════════════════════════════════════════════ +def _run_paddle(task, file_id, image_path, mode): + import cv2 + img = cv2.imread(image_path) + if img is None: + raise ValueError("이미지를 읽을 수 없습니다") + os.makedirs(OUTPUT_DIR, exist_ok=True) + return _paddle_structure(task, file_id, img) if mode == "structure" \ + else _paddle_text(task, file_id, img) + + +def _paddle_text(task, file_id, img): + task.update_state(state="PROGRESS", meta={"progress": 30, "message": "텍스트 인식 중..."}) + # PaddleOCR 3.x: cls 파라미터 제거, 결과 구조 변경 + result = get_ocr().ocr(img) + task.update_state(state="PROGRESS", meta={"progress": 80, "message": "결과 정리 중..."}) + + lines = [] + if result and len(result) > 0: + r = result[0] + # PaddleOCR 3.x 결과 구조: dict with rec_texts, rec_scores + if isinstance(r, dict): + texts = r.get("rec_texts", []) + scores = r.get("rec_scores", []) + for text, conf in zip(texts, scores): + if text.strip(): + lines.append({"text": text, + "confidence": round(float(conf), 3), + "bbox": []}) + # 구버전 호환 (list of [bbox, (text, conf)]) + elif isinstance(r, list): + for item in r: + if item and len(item) == 2: + _, (text, conf) = item + if text.strip(): + lines.append({"text": text, + "confidence": round(float(conf), 3), + "bbox": []}) + + full_text = "\n".join(l["text"] for l in lines) + txt_file = f"{file_id}_ocr.txt" + with open(os.path.join(OUTPUT_DIR, txt_file), "w", encoding="utf-8") as f: + f.write(full_text) + return {"mode": "text", "backend": "paddle", + "full_text": full_text, "lines": lines, + "line_count": len(lines), "txt_file": txt_file, + "tables": [], "xlsx_file": None} + + +def _paddle_structure(task, file_id, img): + task.update_state(state="PROGRESS", meta={"progress": 20, "message": "레이아웃 분석 중..."}) + result = get_structure()(img) + task.update_state(state="PROGRESS", meta={"progress": 60, "message": "표 구조 추출 중..."}) + + text_blocks, tables_html, tables_data = [], [], [] + for region in result: + rtype = region.get("type", "").lower() + if rtype == "table": + html = region.get("res", {}).get("html", "") + if html: + tables_html.append(html) + tables_data.append(_html_table_to_list(html)) + elif rtype in ("text", "title", "figure_caption"): + for line in (region.get("res", []) or []): + if isinstance(line, (list, tuple)) and len(line) == 2: + _, (text, _conf) = line + text_blocks.append(text) + + full_text = "\n".join(text_blocks) + task.update_state(state="PROGRESS", meta={"progress": 80, "message": "Excel 생성 중..."}) + + xlsx_file = None + if tables_data: + xlsx_file = f"{file_id}_tables.xlsx" + _save_excel(tables_data, os.path.join(OUTPUT_DIR, xlsx_file)) + + txt_file = f"{file_id}_ocr.txt" + with open(os.path.join(OUTPUT_DIR, txt_file), "w", encoding="utf-8") as f: + f.write("# 텍스트\n\n" + full_text) + + lines = [{"text": t, "confidence": 1.0, "bbox": []} for t in text_blocks] + tables_meta = [{"html": h, "rows": len(d), + "cols": max(len(r) for r in d) if d else 0} + for h, d in zip(tables_html, tables_data)] + return {"mode": "structure", "backend": "paddle", + "full_text": full_text, "lines": lines, + "line_count": len(lines), "txt_file": txt_file, + "tables": tables_meta, "xlsx_file": xlsx_file} + + +# ════════════════════════════════════════════════════════════════ +# 공통 유틸 +# ════════════════════════════════════════════════════════════════ +def _parse_md_tables(text): + tables, current = [], [] + for line in text.splitlines(): + s = line.strip() + if s.startswith("|") and s.endswith("|"): + if all(c in "| -:" for c in s): continue + current.append([c.strip() for c in s.strip("|").split("|")]) + else: + if len(current) >= 2: tables.append(current) + current = [] + if len(current) >= 2: tables.append(current) + return tables + +def _md_table_to_html(table): + if not table: return "" + rows = "" + for i, row in enumerate(table): + tag = "th" if i == 0 else "td" + cells = "".join(f"<{tag}>{c}" for c in row) + rows += f"{cells}" + return f"{rows}
" + +def _html_table_to_list(html): + from html.parser import HTMLParser + class P(HTMLParser): + def __init__(self): + super().__init__() + self.rows, self._row, self._cell, self._in = [], [], [], False + def handle_starttag(self, tag, attrs): + if tag == "tr": self._row = [] + elif tag in ("td","th"): self._cell = []; self._in = True + def handle_endtag(self, tag): + if tag in ("td","th"): + self._row.append("".join(self._cell).strip()); self._in = False + elif tag == "tr": + if self._row: self.rows.append(self._row) + def handle_data(self, data): + if self._in: self._cell.append(data) + p = P(); p.feed(html); return p.rows + +def _save_excel(tables, path): + wb = openpyxl.Workbook() + wb.remove(wb.active) + for i, table in enumerate(tables, 1): + ws = wb.create_sheet(f"표 {i}") + thin = Side(style="thin", color="2A2A33") + bdr = Border(left=thin, right=thin, top=thin, bottom=thin) + for r_idx, row in enumerate(table, 1): + for c_idx, val in enumerate(row, 1): + cell = ws.cell(row=r_idx, column=c_idx, value=val) + cell.border = bdr + cell.alignment = Alignment(horizontal="center", + vertical="center", wrap_text=True) + if r_idx == 1: + cell.fill = PatternFill("solid", fgColor="1A1A2E") + cell.font = Font(color="00E5A0", bold=True, size=10) + else: + cell.font = Font(size=10) + for col in ws.columns: + w = max((len(str(c.value or "")) for c in col), default=8) + ws.column_dimensions[col[0].column_letter].width = min(w + 4, 40) + if not wb.sheetnames: wb.create_sheet("Sheet1") + wb.save(path) diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..65bc35e --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,21 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +python-multipart==0.0.9 +celery==5.4.0 +redis==5.0.8 +faster-whisper==1.0.3 +aiofiles==23.2.1 + +# 인증 (bcrypt 제거 — 직접 비교 방식 사용) +python-jose[cryptography]==3.3.0 + +# PaddleOCR 3.x +paddleocr>=3.0.0 +opencv-python-headless>=4.8.0 + +# Ollama API 호출 +httpx>=0.27.0 + +# Excel 출력 +openpyxl==3.1.2 +Pillow>=10.0.0 diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..773ca3a --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,1131 @@ + + + + + +VoiceScript — STT & OCR + + + + + + + +
+ +
+ + +
+
+

VoiceScript

+
+ + + +
+
+ + + + + +
+
+
+
파일 업로드
+
+ + 🎵 +
드래그하거나 클릭하여 선택
음성 또는 영상 파일
+
mp3 · wav · m4a · ogg · flac · aac · mp4 · webm
+
+
+ +
STT 엔진
+
+ + +
+ +
+
후처리 모델
+ +
+ 설정 페이지에서 기본 STT 모델을 지정하세요 +
+
+ + +
+
처리 중...0%
+
+
+
+
+
+
+
+
+
+
+
변환 결과
+
+
언어
+
길이
+
세그먼트
+ +
+
+ + +
+
+
📝
파일 업로드 후
변환을 시작하면
결과가 표시됩니다
+ +
+
+
+ + + +
+
+
+
+ + +
+
+
+
이미지 업로드
+
+ + 🖼 +
드래그하거나 클릭하여 선택
이미지 파일
+
jpg · png · bmp · tiff · webp · gif
+
+
+
+ +
OCR 엔진
+
+ + +
+ +
+
Vision 모델
+ + ▶ 커스텀 프롬프트 + +
+ +
인식 모드
+
+ + +
+
일반 텍스트와 글자를 인식합니다
+ + +
+
처리 중...0%
+
+ +
+
+
+
+
인식 결과
+
+
+
모드
+
엔진
+
+
+
+ + + +
+
+
🔍
이미지 업로드 후
인식을 시작하면
결과가 표시됩니다
+ +
+
+
+
+
📊
표 구조 분석 모드를
선택하면 표를
추출할 수 있습니다
+
+
+ + + + +
+
+
+
+ + +
+
+
+

설정

+ + +
+ +
+

🎙 STT Ollama 후처리 기본 모델

+
+
+ Whisper 변환 후 Ollama로 교정할 때 사용할 기본 모델 + STT 페이지에서 모델 미선택 시 이 모델이 사용됩니다 +
+
+ +
+ +
+

🔍 OCR Ollama 기본 모델

+
+
+ OCR에서 Ollama Vision 엔진 선택 시 사용할 기본 모델 + OCR 페이지에서 모델 미선택 시 이 모델이 사용됩니다 +
+
+ +
+ +
+ + +
+
+
+ + +
+
+

👤 사용자 관리

+ + +
+
+

사용자 목록

+ +
+ + + + + + + + + + + +
사용자명역할STTOCR관리
+
+ + +
+

신규 사용자 추가

+
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+
+
+
+
+ + + + + + + diff --git a/app/tasks.py b/app/tasks.py new file mode 100644 index 0000000..cb9952f --- /dev/null +++ b/app/tasks.py @@ -0,0 +1,155 @@ +import os +import httpx +from celery import Celery +from ocr_tasks import ocr_task # noqa: F401 — worker에 등록 + +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +MODEL_SIZE = os.getenv("WHISPER_MODEL", "medium") +DEVICE = os.getenv("WHISPER_DEVICE", "cpu") +COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8") +LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ko") or None +BEAM_SIZE = int(os.getenv("WHISPER_BEAM_SIZE", "5")) +INITIAL_PROMPT = os.getenv("WHISPER_INITIAL_PROMPT", "") or None +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/data/outputs") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.0.126:11434") +OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "180")) + +celery_app = Celery("whisper_tasks", broker=REDIS_URL, backend=REDIS_URL) +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + task_track_started=True, + result_expires=3600, +) + +_model = None + +def get_model(): + global _model + if _model is None: + from faster_whisper import WhisperModel + print(f"[Whisper] 로딩: {MODEL_SIZE} / {DEVICE} / {COMPUTE_TYPE}") + _model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE) + print("[Whisper] 로드 완료") + return _model + + +# ── Ollama 후처리 ───────────────────────────────────────────── +def _ollama_postprocess(text: str, model: str) -> str: + """Whisper 결과를 Ollama로 후처리 (문장부호·맞춤법·자연스러운 문장)""" + if not model or not text.strip(): + return text + prompt = ( + "다음은 음성 인식으로 추출된 텍스트입니다. " + "내용은 절대 변경하지 말고, 문장 부호를 추가하고 자연스럽게 다듬어줘. " + "결과 텍스트만 출력하고 설명은 하지 마.\n\n" + f"{text}" + ) + try: + resp = httpx.post( + f"{OLLAMA_URL}/api/chat", + json={ + "model": model, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + "options": {"temperature": 0.1}, + }, + timeout=float(OLLAMA_TIMEOUT), + ) + resp.raise_for_status() + result = resp.json().get("message", {}).get("content", "").strip() + return result if result else text + except Exception as e: + print(f"[Ollama 후처리 실패] {e} — 원본 텍스트 사용") + return text + + +# ════════════════════════════════════════════════════════════════ +# STT Celery Task +# ════════════════════════════════════════════════════════════════ +@celery_app.task(bind=True, name="tasks.transcribe_task", queue="stt") +def transcribe_task( + self, + file_id: str, + audio_path: str, + use_ollama: bool = False, + ollama_model: str = "", +): + self.update_state(state="PROGRESS", meta={"progress": 5, "message": "모델 준비 중..."}) + try: + model = get_model() + self.update_state(state="PROGRESS", meta={"progress": 15, "message": "오디오 분석 중..."}) + + segments_gen, info = model.transcribe( + audio_path, + language=LANGUAGE, + beam_size=BEAM_SIZE, + initial_prompt=INITIAL_PROMPT, + vad_filter=True, + vad_parameters=dict(min_silence_duration_ms=500), + word_timestamps=False, + ) + + self.update_state(state="PROGRESS", meta={"progress": 30, "message": "텍스트 변환 중..."}) + + segments, parts = [], [] + duration = info.duration + + for seg in segments_gen: + segments.append({"start": round(seg.start,2), + "end": round(seg.end,2), + "text": seg.text.strip()}) + parts.append(seg.text.strip()) + if duration > 0: + pct = 30 + int((seg.end / duration) * 50) + self.update_state( + state="PROGRESS", + meta={"progress": min(pct, 80), + "message": f"변환 중... {seg.end:.0f}s / {duration:.0f}s"}, + ) + + raw_text = "\n".join(parts) + full_text = raw_text + + # Ollama 후처리 + if use_ollama and ollama_model: + self.update_state(state="PROGRESS", + meta={"progress": 85, + "message": f"Ollama({ollama_model}) 후처리 중..."}) + full_text = _ollama_postprocess(raw_text, ollama_model) + + self.update_state(state="PROGRESS", meta={"progress": 95, "message": "파일 저장 중..."}) + os.makedirs(OUTPUT_DIR, exist_ok=True) + output_filename = f"{file_id}.txt" + + with open(os.path.join(OUTPUT_DIR, output_filename), "w", encoding="utf-8") as f: + f.write(f"# 변환 결과\n# 언어: {info.language} | 재생 시간: {duration:.1f}초") + if use_ollama and ollama_model: + f.write(f" | Ollama 후처리: {ollama_model}") + f.write("\n\n## 전체 텍스트\n\n" + full_text + "\n\n") + f.write("## 타임스탬프별 세그먼트\n\n") + for seg in segments: + f.write(f"[{_fmt(seg['start'])} → {_fmt(seg['end'])}] {seg['text']}\n") + + try: os.remove(audio_path) + except: pass + + return { + "text": full_text, + "raw_text": raw_text, + "segments": segments, + "language": info.language, + "duration": round(duration, 1), + "output_file": output_filename, + "ollama_used": use_ollama and bool(ollama_model), + "ollama_model": ollama_model if (use_ollama and ollama_model) else "", + } + + except Exception as e: + raise Exception(f"변환 실패: {str(e)}") + + +def _fmt(s): + m, sec = divmod(int(s), 60) + return f"{m:02d}:{sec:02d}" diff --git a/auth.py b/auth.py new file mode 100755 index 0000000..034987e --- /dev/null +++ b/auth.py @@ -0,0 +1,45 @@ +import os +from datetime import datetime, timedelta + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt + +SECRET_KEY = os.getenv("JWT_SECRET", "fallback-secret-change-this") +ALGORITHM = "HS256" +EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "12")) + +AUTH_USERNAME = os.getenv("AUTH_USERNAME", "admin") +AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "changeme1234") + +bearer = HTTPBearer(auto_error=False) + + +def authenticate(username: str, password: str) -> bool: + return username == AUTH_USERNAME and password == AUTH_PASSWORD + + +def create_access_token(username: str) -> str: + expire = datetime.utcnow() + timedelta(hours=EXPIRE_HOURS) + return jwt.encode({"sub": username, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM) + + +def require_auth(credentials: HTTPAuthorizationCredentials = Depends(bearer)): + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="인증이 필요합니다", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None or username != AUTH_USERNAME: + raise JWTError() + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="토큰이 유효하지 않거나 만료되었습니다", + headers={"WWW-Authenticate": "Bearer"}, + ) + return username diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99ffd7d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,113 @@ +services: + redis: + image: redis:7-alpine + container_name: whisper_redis + restart: unless-stopped + # RDB 스냅샷 저장 실패 시에도 쓰기 허용 (Celery 브로커 용도) + command: redis-server --stop-writes-on-bgsave-error no + environment: + - TZ=Asia/Seoul + volumes: + - redis_data:/data + networks: + - whisper_net + + app: + build: + context: ./app + dockerfile: Dockerfile + container_name: whisper_app + restart: unless-stopped + ports: + - "8800:8000" # 호스트 Nginx가 리버스 프록시 + environment: + - TZ=Asia/Seoul + + # ── 인증 (반드시 변경) ────────────────────────────── + - AUTH_USERNAME=byun + - AUTH_PASSWORD=admin + - JWT_SECRET=your-very-secret-key-change-this + - JWT_EXPIRE_HOURS=12 + + # ── Whisper STT ───────────────────────────────────── + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_DIR=/data/uploads + - OUTPUT_DIR=/data/outputs + - WHISPER_MODEL=medium # tiny/base/small/medium/large-v3 + - WHISPER_DEVICE=cpu + - WHISPER_COMPUTE_TYPE=int8 + - WHISPER_LANGUAGE=ko + - WHISPER_BEAM_SIZE=5 + - WHISPER_INITIAL_PROMPT= # 예: "고객 상담 녹취록입니다." + + # ── 파일 관리 ──────────────────────────────────────── + - MAX_UPLOAD_MB=500 + - OUTPUT_KEEP_HOURS=48 + + # ── PaddleOCR ──────────────────────────────────────── + - OCR_LANG=korean # korean/en/japan/chinese_cht/ch + + # ── Ollama OCR ─────────────────────────────────────── + # 호스트 실제 LAN IP 사용 (host.docker.internal은 Linux에서 불안정) + - OLLAMA_URL=http://192.168.0.126:11434 + - OLLAMA_TIMEOUT=600 # 11b 이상 모델은 300 이상 권장 + + volumes: + - stt_data:/data + - whisper_models:/root/.cache/huggingface + - paddle_models:/root/.paddlex + depends_on: + - redis + networks: + - whisper_net + + worker: + build: + context: ./app + dockerfile: Dockerfile + container_name: whisper_worker + restart: unless-stopped + # --pool=solo : CTranslate2(faster-whisper)가 prefork 방식과 충돌(SIGSEGV) 발생 + # solo 모드로 포크 없이 실행하여 해결 + # --max-tasks-per-child=50 : Whisper/Paddle 모델 메모리 누수 방지 + command: > + celery -A tasks worker + --loglevel=info + --pool=solo + --max-tasks-per-child=50 + -Q stt,ocr + environment: + - TZ=Asia/Seoul + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_DIR=/data/uploads + - OUTPUT_DIR=/data/outputs + - WHISPER_MODEL=medium + - WHISPER_DEVICE=cpu + - WHISPER_COMPUTE_TYPE=int8 + - WHISPER_LANGUAGE=ko + - WHISPER_BEAM_SIZE=5 + - WHISPER_INITIAL_PROMPT= + - MAX_UPLOAD_MB=500 + - OUTPUT_KEEP_HOURS=48 + - OCR_LANG=korean + - OLLAMA_URL=http://192.168.0.126:11434 + - OLLAMA_TIMEOUT=600 + - JWT_SECRET=your-very-secret-key-change-this + volumes: + - stt_data:/data + - whisper_models:/root/.cache/huggingface + - paddle_models:/root/.paddlex + depends_on: + - redis + networks: + - whisper_net + +volumes: + redis_data: + stt_data: + whisper_models: + paddle_models: + +networks: + whisper_net: + driver: bridge diff --git a/docker-compose.yml.bak b/docker-compose.yml.bak new file mode 100644 index 0000000..b5a2605 --- /dev/null +++ b/docker-compose.yml.bak @@ -0,0 +1,141 @@ + +# ════════════════════════════════════════════════════════════════════ +# VoiceScript — 주요 설정 체크리스트 +# 빌드 전에 아래 항목을 반드시 확인하세요 +# ════════════════════════════════════════════════════════════════════ +# +# ✅ 필수 변경 +# AUTH_USERNAME / AUTH_PASSWORD / JWT_SECRET +# +# 🔧 환경에 맞게 조정 +# TZ → 타임존 (기본: Asia/Seoul) +# WHISPER_MODEL → tiny/base/small/medium/large-v3 +# 5825u + 16GB RAM 기준: medium 권장 +# MAX_UPLOAD_MB → 업로드 최대 크기 (기본: 500MB) +# OUTPUT_KEEP_HOURS → 결과 파일 보관 시간 (기본: 48h, 0=삭제 안 함) +# JWT_EXPIRE_HOURS → 로그인 세션 유지 시간 (기본: 12h) +# +# 🌐 Ollama 설정 +# OLLAMA_URL → 같은 호스트이므로 host.docker.internal:11434 그대로 사용 +# OLLAMA_TIMEOUT → 큰 모델(11b+) 사용 시 늘려주세요 (기본: 180초) +# +# ════════════════════════════════════════════════════════════════════ + +services: + redis: + image: redis:7-alpine + container_name: whisper_redis + restart: unless-stopped + environment: + - TZ=Asia/Seoul + volumes: + - redis_data:/data + networks: + - whisper_net + + app: + build: + context: ./app + dockerfile: Dockerfile + container_name: whisper_app + restart: unless-stopped + ports: + - "8800:8000" # 호스트 Nginx가 프록시 → 외부 직접 접근 차단 + environment: + # ── 타임존 ────────────────────────────────────────── + - TZ=Asia/Seoul # 로그·파일 타임스탬프에 영향 + + # ── 인증 (반드시 변경) ────────────────────────────── + - AUTH_USERNAME=byun + - AUTH_PASSWORD=admin + - JWT_SECRET=your-very-secret-key-change-this + - JWT_EXPIRE_HOURS=12 # 로그인 세션 유지 시간 (1~720 사이) + + # ── Whisper STT ───────────────────────────────────── + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_DIR=/data/uploads + - OUTPUT_DIR=/data/outputs + - WHISPER_MODEL=medium # tiny/base/small/medium/large-v3 + - WHISPER_DEVICE=cpu # 5825u = CPU (GPU 없음) + - WHISPER_COMPUTE_TYPE=int8 # CPU 최적화: int8 권장 + - WHISPER_LANGUAGE=ko # 한국어 고정 (다국어 필요 시 비워두면 자동 감지) + - WHISPER_BEAM_SIZE=5 # 정확도↑ vs 속도↓, 기본 5 권장 + - WHISPER_INITIAL_PROMPT= # 한국어 인식 힌트 (예: "안녕하세요. 통화 내용입니다.") + # 도메인 특화 단어가 있으면 여기에 넣으면 정확도 향상 + + # ── 파일 관리 ──────────────────────────────────────── + - MAX_UPLOAD_MB=500 # 업로드 최대 파일 크기 (MB) + - OUTPUT_KEEP_HOURS=48 # 결과 파일 보관 시간 (0=무제한, 디스크 관리 주의) + + # ── PaddleOCR ──────────────────────────────────────── + - OCR_LANG=korean # korean/en/japan/chinese_cht/ch + - OCR_USE_GPU=false + + # ── Ollama OCR ─────────────────────────────────────── + # 같은 Debian 호스트의 Ollama(11434) → host.docker.internal 사용 + - OLLAMA_URL=http://192.168.0.126:11434 + - OLLAMA_TIMEOUT=180 # 초 단위, llama3.2-vision:11b 이상은 300 이상 권장 + + volumes: + - stt_data:/data + - whisper_models:/root/.cache/huggingface + - paddle_models:/root/.paddleocr + extra_hosts: + - "host.docker.internal:host-gateway" # Linux에서 host 참조 필수 + depends_on: + - redis + networks: + - whisper_net + + worker: + build: + context: ./app + dockerfile: Dockerfile + container_name: whisper_worker + restart: unless-stopped + # --max-tasks-per-child: N개 태스크 처리 후 워커 재시작 → 메모리 누수 방지 + # Whisper + PaddleOCR 모델이 메모리에 계속 쌓이는 것을 막아줌 + command: > + celery -A tasks worker --pool=solo + --loglevel=info + --concurrency=1 + --max-tasks-per-child=50 + -Q stt,ocr + environment: + - TZ=Asia/Seoul + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_DIR=/data/uploads + - OUTPUT_DIR=/data/outputs + - WHISPER_MODEL=medium + - WHISPER_DEVICE=cpu + - WHISPER_COMPUTE_TYPE=int8 + - WHISPER_LANGUAGE=ko + - WHISPER_BEAM_SIZE=5 + - WHISPER_INITIAL_PROMPT= + - MAX_UPLOAD_MB=500 + - OUTPUT_KEEP_HOURS=48 + - OCR_LANG=korean + - OCR_USE_GPU=false + - OLLAMA_URL=http://192.168.0.126:11434 + - OLLAMA_TIMEOUT=180 + - JWT_SECRET=your-very-secret-key-change-this + volumes: + - stt_data:/data + - whisper_models:/root/.cache/huggingface + - paddle_models:/root/.paddleocr + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - redis + networks: + - whisper_net + +volumes: + redis_data: + stt_data: + whisper_models: + paddle_models: + +networks: + whisper_net: + driver: bridge diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..b846f21 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/nginx.conf diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..c888bc2 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,43 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + # 업로드 파일 크기 제한 (통화 녹음 파일 고려) + client_max_body_size 500M; + client_body_timeout 300s; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + upstream fastapi { + server app:8000; + } + + server { + listen 80; + server_name _; + + # 큰 파일 업로드 버퍼 + client_body_buffer_size 10M; + + location / { + proxy_pass http://fastapi; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 대용량 업로드를 위한 타임아웃 + proxy_connect_timeout 60s; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + } +}