Files
hyungi_document_server/app/main.py
Hyungi Ahn 4b695332b9 feat: implement Phase 2 core features
- Add document CRUD API (list/get/upload/update/delete with auth)
  - Upload saves to Inbox + auto-enqueues processing pipeline
  - Delete defaults to DB-only, explicit flag for file deletion
- Add hybrid search API (FTS 0.4 + trigram 0.2 + vector 0.4 weighted)
  - Modes: fts, trgm, vector, hybrid (default)
  - Vector search gracefully degrades if GPU unavailable
- Add Inbox file watcher (5min interval, new file + hash change detection)
- Register documents/search routers and file_watcher scheduler in main.py
- Add IVFFLAT vector index migration (lists=50, with tuning guide)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:49:12 +09:00

113 lines
3.6 KiB
Python

"""hyungi_Document_Server — FastAPI 엔트리포인트"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select, text
from api.auth import router as auth_router
from api.documents import router as documents_router
from api.search import router as search_router
from api.setup import router as setup_router
from core.config import settings
from core.database import async_session, engine, init_db
from models.user import User
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행되는 lifespan 핸들러"""
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from workers.file_watcher import watch_inbox
from workers.queue_consumer import consume_queue
# 시작: DB 연결 확인
await init_db()
# APScheduler: 백그라운드 작업
scheduler = AsyncIOScheduler()
scheduler.add_job(consume_queue, "interval", minutes=1, id="queue_consumer")
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
scheduler.start()
yield
# 종료: 스케줄러 → DB 순서로 정리
scheduler.shutdown(wait=False)
await engine.dispose()
app = FastAPI(
title="hyungi_Document_Server",
description="Self-hosted PKM 웹 애플리케이션 API",
version="2.0.0",
lifespan=lifespan,
)
# ─── 라우터 등록 ───
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
app.include_router(search_router, prefix="/api/search", tags=["search"])
# TODO: Phase 3~4에서 추가
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
# app.include_router(dashboard.router, prefix="/api/dashboard", tags=["dashboard"])
# app.include_router(export.router, prefix="/api/export", tags=["export"])
# ─── 셋업 미들웨어: 유저 0명이면 /setup으로 리다이렉트 ───
SETUP_BYPASS_PREFIXES = (
"/api/setup", "/setup", "/health", "/docs", "/openapi.json", "/redoc",
)
@app.middleware("http")
async def setup_redirect_middleware(request: Request, call_next):
path = request.url.path
# 바이패스 경로는 항상 통과
if any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
return await call_next(request)
# 유저 존재 여부 확인
try:
async with async_session() as session:
result = await session.execute(select(func.count(User.id)))
user_count = result.scalar()
if user_count == 0:
return RedirectResponse(url="/setup")
except Exception:
pass # DB 연결 실패 시 통과 (health에서 확인 가능)
return await call_next(request)
# ─── 셋업 페이지 라우트 (API가 아닌 HTML 페이지) ───
@app.get("/setup")
async def setup_page_redirect(request: Request):
"""셋업 위자드 페이지로 포워딩"""
from api.setup import setup_page
from core.database import get_session
async for session in get_session():
return await setup_page(request, session)
@app.get("/health")
async def health_check():
"""헬스체크 — DB 연결 상태 포함"""
db_ok = False
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception:
pass
return {
"status": "ok" if db_ok else "degraded",
"version": "2.0.0",
"database": "connected" if db_ok else "disconnected",
}