## 철학 수정 (실측 기반)
gemma-4-26b-a4b-it-8bit MLX 실측:
- full query_analyze.txt (prompt_tok=2406) → 10.5초
- max_tokens 축소 무효 (모델 자연 EOS 조기 종료)
- 쿼리 길이 영향 거의 없음 (프롬프트 자체가 지배)
→ 800ms timeout 가정은 13배 초과. 동기 호출 완전히 불가능.
따라서 QueryAnalyzer는 "즉시 실행하는 기능" → "미리 준비해두는 기능"으로
포지셔닝 변경. retrieval 경로에서 analyzer 동기 호출 **금지**.
## 구조
```
query → retrieval (항상 즉시)
↘ trigger_background_analysis (fire-and-forget)
→ analyze() [5초+] → cache 저장
다음 호출 (동일 쿼리) → get_cached() 히트 → Phase 2 파이프라인 활성화
```
## 변경 사항
### app/prompts/query_analyze.txt
- 5971 chars → 2403 chars (40%)
- 예시 4개 → 1개, 규칙 설명 축약
- 목표 prompt_tok 2406 → ~600 (1/4)
### app/services/search/query_analyzer.py
- LLM_TIMEOUT_MS 800 → 5000 (background이므로 여유 OK)
- PROMPT_VERSION v1 → v2 (cache auto-invalidate)
- get_cached / set_cached 유지 — retrieval 경로 O(1) 조회
- trigger_background_analysis(query) 신규 — 동기 함수, 즉시 반환, task 생성
- _PENDING set으로 task 참조 유지 (premature GC 방지)
- _INFLIGHT set으로 동일 쿼리 중복 실행 방지
- prewarm_analyzer() 신규 — startup에서 15~20 쿼리 미리 분석
- DEFAULT_PREWARM_QUERIES: 평가셋 fixed 7 + 법령 3 + 뉴스 2 + 실무 3
### app/api/search.py
- 기존 sync analyzer 호출 완전 제거
- analyze=True → get_cached(q) 조회만 O(1)
- hit: query_analysis 활용 (Phase 2.2/2.3 파이프라인 조건부 활성화)
- miss: trigger_background_analysis(q) + 기존 경로 그대로
- timing["analyze_ms"] 제거 (경로에 LLM 호출 없음)
- notes에 analyzer cache_hit/cache_miss 상태 기록
- debug.query_analysis는 cache hit 시에만 채워짐
### app/main.py
- lifespan startup에 prewarm_analyzer() background task 추가
- 논블로킹 — 앱 시작 막지 않음
- delay_between=0.5로 MLX 부하 완화
## 기대 효과
- cold 요청 latency: 기존 Phase 1.3 그대로 (회귀 0)
- warm 요청 + prewarmed: cache hit → query_analysis 활용
- 예상 cache hit rate: 초기 70~80% (prewarm) + 사용 누적
- Phase 2.2/2.3 multilingual/filter 기능은 cache hit 시에만 동작
## 참조
- memory: feedback_analyzer_async_only.md (영구 룰 저장)
- plan: ~/.claude/plans/zesty-painting-kahan.md ("철학 수정" 섹션)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
150 lines
5.4 KiB
Python
150 lines
5.4 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.dashboard import router as dashboard_router
|
|
from api.documents import router as documents_router
|
|
from api.news import router as news_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 핸들러"""
|
|
import asyncio
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from services.search.query_analyzer import prewarm_analyzer
|
|
from workers.daily_digest import run as daily_digest_run
|
|
from workers.file_watcher import watch_inbox
|
|
from workers.law_monitor import run as law_monitor_run
|
|
from workers.mailplus_archive import run as mailplus_run
|
|
from workers.news_collector import run as news_collector_run
|
|
from workers.queue_consumer import consume_queue
|
|
|
|
# 시작: DB 연결 확인
|
|
await init_db()
|
|
|
|
# NAS 마운트 확인 (NFS 미마운트 시 로컬 빈 디렉토리에 쓰는 것 방지)
|
|
from pathlib import Path
|
|
nas_check = Path(settings.nas_mount_path) / "PKM"
|
|
if not nas_check.is_dir():
|
|
raise RuntimeError(
|
|
f"NAS 마운트 확인 실패: {nas_check} 디렉토리 없음. "
|
|
f"NFS 마운트 상태를 확인하세요."
|
|
)
|
|
|
|
# APScheduler: 백그라운드 작업
|
|
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
|
# 상시 실행
|
|
scheduler.add_job(consume_queue, "interval", minutes=1, id="queue_consumer")
|
|
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
|
|
# 일일 스케줄 (KST)
|
|
scheduler.add_job(law_monitor_run, CronTrigger(hour=7), id="law_monitor")
|
|
scheduler.add_job(mailplus_run, CronTrigger(hour=7), id="mailplus_morning")
|
|
scheduler.add_job(mailplus_run, CronTrigger(hour=18), id="mailplus_evening")
|
|
scheduler.add_job(daily_digest_run, CronTrigger(hour=20), id="daily_digest")
|
|
scheduler.add_job(news_collector_run, "interval", hours=6, id="news_collector")
|
|
scheduler.start()
|
|
|
|
# Phase 2.1 (async 구조): QueryAnalyzer prewarm.
|
|
# 대표 쿼리 15~20개를 background task로 분석해 cache 적재.
|
|
# 첫 사용자 요청부터 cache hit rate 70~80% 목표.
|
|
# 논블로킹 — startup을 막지 않음. MLX 부하 완화 위해 delay_between=0.5.
|
|
prewarm_task = asyncio.create_task(prewarm_analyzer())
|
|
prewarm_task.add_done_callback(
|
|
lambda t: t.exception() and None # 예외는 query_analyzer 내부에서 로깅
|
|
)
|
|
|
|
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"])
|
|
|
|
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
|
|
app.include_router(news_router, prefix="/api/news", tags=["news"])
|
|
|
|
# TODO: Phase 5에서 추가
|
|
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
|
|
# 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",
|
|
}
|