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

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

275
app/main.py Normal file
View File

@@ -0,0 +1,275 @@
import os, uuid, time, glob, json
import httpx
import aiofiles
from pathlib import Path
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends, Form, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel
from auth import (authenticate, create_access_token, init_users,
require_auth, require_admin, require_stt, require_ocr,
list_users, create_user, update_user, delete_user)
from tasks import celery_app, transcribe_task
from ocr_tasks import ocr_task
app = FastAPI(title="VoiceScript API")
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/data/uploads")
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/data/outputs")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.0.126:11434")
MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_MB", "500")) * 1024 * 1024
OUTPUT_KEEP_SECS = int(os.getenv("OUTPUT_KEEP_HOURS", "48")) * 3600
DATA_DIR = Path(UPLOAD_DIR).parent
SETTINGS_FILE = DATA_DIR / "settings.json"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)
AUDIO_EXT = {"mp3","mp4","wav","m4a","ogg","flac","aac","wma","webm","mkv","avi","mov"}
IMAGE_EXT = {"jpg","jpeg","png","bmp","tiff","tif","webp","gif"}
# ── 설정 I/O ─────────────────────────────────────────────────
def _load_settings() -> dict:
if not SETTINGS_FILE.exists():
return {"stt_ollama_model": "", "ocr_ollama_model": "granite3.2-vision:latest"}
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def _save_settings(data: dict):
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# ════════════════════════════════════════════════════════════════
# 시작 이벤트
# ════════════════════════════════════════════════════════════════
@app.on_event("startup")
async def on_startup():
init_users()
_cleanup_outputs()
# ════════════════════════════════════════════════════════════════
# 인증
# ════════════════════════════════════════════════════════════════
@app.post("/api/login")
def login(username: str = Form(...), password: str = Form(...)):
user = authenticate(username, password)
if not user:
raise HTTPException(401, "아이디 또는 비밀번호가 올바르지 않습니다")
return {"access_token": create_access_token(username), "token_type": "bearer"}
@app.get("/api/me")
def me(user: dict = Depends(require_auth)):
return {
"username": user["username"],
"role": user.get("role", "user"),
"permissions": user.get("permissions", {"stt": False, "ocr": False}),
}
# ════════════════════════════════════════════════════════════════
# STT
# ════════════════════════════════════════════════════════════════
@app.post("/api/transcribe")
async def transcribe(
request: Request,
file: UploadFile = File(...),
use_ollama: str = Form("false"),
ollama_model: str = Form(""),
user: dict = Depends(require_stt),
):
_check_size(request)
ext = _ext(file.filename)
if ext not in AUDIO_EXT:
raise HTTPException(400, f"지원하지 않는 형식: {', '.join(sorted(AUDIO_EXT))}")
file_id = str(uuid.uuid4())
save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}")
await _save(file, save_path)
_use_ollama = use_ollama.lower() == "true"
# 모델 미지정 시 설정에서 가져옴
if _use_ollama and not ollama_model.strip():
ollama_model = _load_settings().get("stt_ollama_model", "")
task = transcribe_task.delay(file_id, save_path, _use_ollama, ollama_model)
return {"task_id": task.id, "file_id": file_id, "filename": file.filename}
# ════════════════════════════════════════════════════════════════
# OCR
# ════════════════════════════════════════════════════════════════
@app.post("/api/ocr")
async def ocr(
request: Request,
file: UploadFile = File(...),
mode: str = Form("text"),
backend: str = Form("paddle"),
ollama_model: str = Form(""),
custom_prompt: str = Form(""),
user: dict = Depends(require_ocr),
):
_check_size(request)
ext = _ext(file.filename)
if ext not in IMAGE_EXT:
raise HTTPException(400, f"지원하지 않는 형식: {', '.join(sorted(IMAGE_EXT))}")
if mode not in ("text", "structure"): mode = "text"
if backend not in ("paddle", "ollama"): backend = "paddle"
# 모델 미지정 시 설정에서 가져옴
if backend == "ollama" and not ollama_model.strip():
ollama_model = _load_settings().get("ocr_ollama_model", "granite3.2-vision:latest")
file_id = str(uuid.uuid4())
save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}")
await _save(file, save_path)
task = ocr_task.delay(file_id, save_path, mode, backend, ollama_model, custom_prompt)
return {"task_id": task.id, "file_id": file_id,
"filename": file.filename, "mode": mode, "backend": backend}
# ════════════════════════════════════════════════════════════════
# 작업 상태 / 다운로드
# ════════════════════════════════════════════════════════════════
@app.get("/api/status/{task_id}")
def get_status(task_id: str, user: dict = Depends(require_auth)):
r = celery_app.AsyncResult(task_id)
if r.state == "PENDING": return {"state": "pending", "progress": 0, "message": "대기 중..."}
if r.state == "PROGRESS": m = r.info or {}; return {"state": "progress","progress": m.get("progress",0),"message": m.get("message","처리 중...")}
if r.state == "SUCCESS": return {"state": "success", "progress": 100, **r.result}
if r.state == "FAILURE": return {"state": "failure", "progress": 0, "message": str(r.info)}
return {"state": r.state.lower(), "progress": 0}
@app.get("/api/download/{filename}")
def download(filename: str, user: dict = Depends(require_auth)):
if ".." in filename or "/" in filename:
raise HTTPException(400, "잘못된 파일명")
path = os.path.join(OUTPUT_DIR, filename)
if not os.path.exists(path):
raise HTTPException(404, "파일을 찾을 수 없습니다")
media = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
if filename.endswith(".xlsx") else "text/plain")
return FileResponse(path, media_type=media, filename=filename)
# ════════════════════════════════════════════════════════════════
# Ollama 모델 목록
# ════════════════════════════════════════════════════════════════
@app.get("/api/ollama/models")
def ollama_models(user: dict = Depends(require_auth)):
try:
resp = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=8.0)
resp.raise_for_status()
models = [m["name"] for m in resp.json().get("models", [])]
return {"models": models, "connected": True}
except Exception as e:
return {"models": [], "connected": False, "error": str(e)}
# ════════════════════════════════════════════════════════════════
# 설정
# ════════════════════════════════════════════════════════════════
@app.get("/api/settings")
def get_settings(user: dict = Depends(require_auth)):
return _load_settings()
@app.post("/api/settings")
def save_settings_endpoint(
stt_ollama_model: str = Form(""),
ocr_ollama_model: str = Form(""),
user: dict = Depends(require_auth),
):
data = {"stt_ollama_model": stt_ollama_model,
"ocr_ollama_model": ocr_ollama_model}
_save_settings(data)
return {"ok": True, "settings": data}
# ════════════════════════════════════════════════════════════════
# 관리자 — 사용자 관리
# ════════════════════════════════════════════════════════════════
@app.get("/api/admin/users")
def admin_list_users(user: dict = Depends(require_admin)):
return {"users": list_users()}
@app.post("/api/admin/users")
def admin_create_user(
username: str = Form(...),
password: str = Form(...),
perm_stt: str = Form("false"),
perm_ocr: str = Form("false"),
user: dict = Depends(require_admin),
):
perms = {"stt": perm_stt.lower()=="true", "ocr": perm_ocr.lower()=="true"}
ok, msg = create_user(username, password, perms)
if not ok:
raise HTTPException(400, msg)
return {"ok": True, "message": msg}
@app.put("/api/admin/users/{username}")
def admin_update_user(
username: str,
perm_stt: str = Form("false"),
perm_ocr: str = Form("false"),
password: str = Form(""),
user: dict = Depends(require_admin),
):
perms = {"stt": perm_stt.lower()=="true", "ocr": perm_ocr.lower()=="true"}
ok, msg = update_user(username, perms, password or None)
if not ok:
raise HTTPException(400, msg)
return {"ok": True, "message": msg}
@app.delete("/api/admin/users/{username}")
def admin_delete_user(username: str, user: dict = Depends(require_admin)):
ok, msg = delete_user(username)
if not ok:
raise HTTPException(400, msg)
return {"ok": True, "message": msg}
# ════════════════════════════════════════════════════════════════
# 정리
# ════════════════════════════════════════════════════════════════
@app.post("/api/cleanup")
def cleanup(user: dict = Depends(require_auth)):
return {"removed": _cleanup_outputs()}
# ════════════════════════════════════════════════════════════════
# 유틸
# ════════════════════════════════════════════════════════════════
def _check_size(request: Request):
cl = request.headers.get("content-length")
if cl and int(cl) > MAX_UPLOAD_BYTES:
raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB")
def _cleanup_outputs() -> int:
if OUTPUT_KEEP_SECS == 0:
return 0
cutoff = time.time() - OUTPUT_KEEP_SECS
removed = 0
for f in glob.glob(os.path.join(OUTPUT_DIR, "*")):
try:
if os.path.getmtime(f) < cutoff:
os.remove(f); removed += 1
except: pass
return removed
def _ext(fn): return fn.rsplit(".", 1)[-1].lower() if "." in fn else ""
async def _save(file: UploadFile, path: str):
written = 0
async with aiofiles.open(path, "wb") as f:
while chunk := await file.read(1024 * 1024):
written += len(chunk)
if written > MAX_UPLOAD_BYTES:
await f.close(); os.remove(path)
raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB")
await f.write(chunk)
app.mount("/", StaticFiles(directory="static", html=True), name="static")