fix: --pool=solo SIGSEGV 해결 및 전체 설정 정리
This commit is contained in:
585
README.md
Normal file
585
README.md
Normal 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
34
app/Dockerfile
Normal 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
146
app/auth.py
Normal 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
275
app/main.py
Normal 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
288
app/ocr_tasks.py
Normal 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
21
app/requirements.txt
Normal 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
1131
app/static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
155
app/tasks.py
Normal file
155
app/tasks.py
Normal 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
45
auth.py
Executable 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
113
docker-compose.yml
Normal 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
141
docker-compose.yml.bak
Normal 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
2
nginx/Dockerfile
Normal file
@@ -0,0 +1,2 @@
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
43
nginx/nginx.conf
Normal file
43
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user