fix: --pool=solo SIGSEGV 해결 및 전체 설정 정리
This commit is contained in:
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")
|
||||
Reference in New Issue
Block a user