fix: --pool=solo SIGSEGV 해결 및 전체 설정 정리

This commit is contained in:
root
2026-04-20 20:39:24 +09:00
commit 248ac1deea
13 changed files with 2979 additions and 0 deletions

585
README.md Normal file
View File

@@ -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 모델 서버 |

34
app/Dockerfile Normal file
View File

@@ -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"]

146
app/auth.py Normal file
View File

@@ -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

275
app/main.py Normal file
View File

@@ -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")

288
app/ocr_tasks.py Normal file
View File

@@ -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}</{tag}>" for c in row)
rows += f"<tr>{cells}</tr>"
return f"<table>{rows}</table>"
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)

21
app/requirements.txt Normal file
View File

@@ -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

1131
app/static/index.html Normal file

File diff suppressed because it is too large Load Diff

155
app/tasks.py Normal file
View File

@@ -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}"

45
auth.py Executable file
View File

@@ -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

113
docker-compose.yml Normal file
View File

@@ -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

141
docker-compose.yml.bak Normal file
View File

@@ -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

2
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,2 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf

43
nginx/nginx.conf Normal file
View File

@@ -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;
}
}
}