from __future__ import annotations import logging import hmac from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi import Request from fastapi.responses import JSONResponse from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from app.api.routes import router as api_router from app.core.config import get_settings from app.engine.detector import PlagiarismDetector from app.jobs.store import JobStore logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() if not settings.api_key.strip(): logging.critical( "API_KEY is empty: all /v1 endpoints are unauthenticated. " "Do not expose unpublished manuscripts publicly until client key rollout is complete." ) app.state.settings = settings app.state.detector = PlagiarismDetector(settings=settings) app.state.job_store = JobStore() import threading app.state.detector_lock = threading.Lock() logging.info( "Engine ready: version=%s, corpus_size=%d", settings.engine_version, app.state.detector.corpus_size, ) yield def rebuild_detector(app: FastAPI) -> int: """코퍼스 변경 후 인덱스 재빌드. 호출 시점에 lock으로 보호.""" settings = app.state.settings with app.state.detector_lock: app.state.detector = PlagiarismDetector(settings=settings) return app.state.detector.corpus_size _settings = get_settings() app = FastAPI( title="O2O 저작권 침해 여부 탐지 API", description=( "오투오 1단계 산출물 - 콘텐츠 표절 여부 AI 탐지 모듈. " "본 응답 스키마는 커뮤니케이션북스(아카이빙) 및 바이칼AI(분석 보고서) 통합 기준." ), version="1.0.0", lifespan=lifespan, root_path=_settings.root_path, ) app.include_router(api_router) @app.middleware("http") async def optional_api_key_auth(request: Request, call_next): """API_KEY가 설정된 운영 환경에서만 API 인증을 강제한다.""" configured = _settings.api_key.strip() public_paths = {"/", "/v1/health", "/docs", "/openapi.json", "/redoc"} if configured and request.url.path not in public_paths and request.url.path.startswith("/v1"): supplied = request.headers.get("x-api-key", "") if not hmac.compare_digest(configured, supplied): return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"}) return await call_next(request) _STATIC_DIR = Path(__file__).resolve().parent / "static" if _STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") @app.get("/", include_in_schema=False) async def root() -> FileResponse: """검토 콘솔 페이지 — 컴북스 측이 브라우저에서 직접 엔진 성능 확인.""" index = _STATIC_DIR / "index.html" if index.exists(): return FileResponse(str(index)) from fastapi.responses import JSONResponse return JSONResponse({"service": "o2o-plagiarism-api", "docs": "/docs"}) def main() -> None: """`.env` 의 HOST/PORT/LOG_LEVEL/RELOAD 를 읽어 서버 기동. 사용법: python -m app.main # .env 따라 실행 PORT=9000 python -m app.main # 환경변수 override """ import uvicorn settings = get_settings() uvicorn.run( "app.main:app", host=settings.host, port=settings.port, log_level=settings.log_level, reload=settings.reload, root_path=settings.root_path, forwarded_allow_ips="*", # 리버스 프록시(Apache) 헤더 신뢰 proxy_headers=True, ) if __name__ == "__main__": main()