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