You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

174 lines
6.3 KiB
Python

# backend/app/main.py
import asyncio
import contextlib
import logging
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings
from .db import engine, init_db
from .routers import (
_test,
approvals,
automation,
calendar,
connectors,
dashboard,
inbox,
journey,
llm,
mail,
notify,
ops,
people,
tasks,
tree,
worker,
)
from .routers import (
auth as auth_router,
)
from .routers import (
settings as settings_router,
)
settings = get_settings()
health_router = APIRouter() # 내부 prefix 없음. /health → /api/health
def _register_event_bus() -> None:
"""전역 event_bus 에 evaluator/suggester 핸들러 등록(앱 런타임 전용).
테스트는 lifespan 을 띄우지 않으므로 전역 버스에 구독자가 없어 결정적이다."""
from sqlmodel import Session
from .automation import evaluator, suggester
from .automation.event_bus import bus
def session_factory():
return Session(engine)
evaluator.register(bus, session_factory)
suggester.register(bus, session_factory)
# phase-14: 결재 승인(approval.executed) → 승인된 심부름 이어 실행
from .worker.main import register_event_subscribers
register_event_subscribers(bus, session_factory)
async def _auto_sync_loop() -> None:
"""앱 내장 자동 풀링: 주기적으로 연결된 real 계정(메일·일정)을 증분 sync.
blocking httpx/DB 작업이라 to_thread 로 실행해 이벤트 루프를 막지 않는다."""
from .worker.sync_jobs import run_auto_triage, run_periodic_sync
log = logging.getLogger("ari.autosync")
interval = max(5, settings.auto_sync_interval_seconds)
await asyncio.sleep(15) # 기동 직후 한 번(초기 연결 sync 와 겹치지 않게 약간 지연)
while True:
try:
results = await asyncio.to_thread(run_periodic_sync)
new = sum(r.get("upserted", 0) for r in results)
if new:
log.info("auto-sync: %d new records across %d accounts", new, len(results))
except asyncio.CancelledError:
raise
except Exception:
log.exception("auto-sync failed; retrying next interval")
# 새 메일 자동 정리(백그라운드 LLM 분석) — 사용자가 직접 누르지 않아도 카드가 채워진다.
if settings.auto_triage_enabled:
try:
done = await asyncio.to_thread(run_auto_triage, settings.auto_triage_batch)
if done:
log.info("auto-triage: %d mails analyzed", done)
except asyncio.CancelledError:
raise
except Exception:
log.exception("auto-triage failed; retrying next interval")
await asyncio.sleep(interval)
@asynccontextmanager
async def lifespan(app: FastAPI):
from .observability.logging import configure_logging
configure_logging(settings.log_level)
init_db() # 개발 편의(운영은 alembic). 테이블 보장.
_register_event_bus()
# 설정 페이지의 LLM 오버레이(app_setting)를 인메모리로 적재(앱 런타임 전용).
from sqlmodel import Session
from . import runtime_config
with Session(engine) as _s:
runtime_config.load_from_db(_s)
# 재시작/리로드로 'syncing' 에 멈춘 계정을 복구(안 하면 주기 sync 가 영원히 건너뜀).
from .worker.sync_jobs import purge_spam, recover_stuck_syncing
if (_recovered := recover_stuck_syncing()):
logging.getLogger("ari.autosync").info(
"recovered %d stuck-syncing account(s) on startup", _recovered
)
# 이미 저장된 스팸 일괄 제거(이후 sync 는 스팸을 아예 저장하지 않음).
if (_purged := purge_spam()):
logging.getLogger("ari.autosync").info("purged %d spam mail(s) on startup", _purged)
sync_task = asyncio.create_task(_auto_sync_loop()) if settings.auto_sync_enabled else None
try:
yield
finally:
if sync_task:
sync_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await sync_task
app = FastAPI(title="아리 Ari API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.frontend_origin.split(",")],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
# phase-15: 요청 컨텍스트(request_id) + 접근 로그 + 메트릭
from .observability.middleware import RequestContextMiddleware # noqa: E402
app.add_middleware(RequestContextMiddleware)
# health: 라우터 내부 prefix 없이 "/health" 로 정의 → prefix="/api" 등록 시 /api/health.
@health_router.get("/health")
def health():
return {"status": "ok"}
# 모든 라우터를 prefix="/api" 로 등록(라우터 내부 prefix 없음).
app.include_router(health_router, prefix="/api", tags=["health"])
app.include_router(people.router, prefix="/api", tags=["people"])
app.include_router(tree.router, prefix="/api", tags=["tree"])
app.include_router(tasks.router, prefix="/api", tags=["tasks"])
app.include_router(inbox.router, prefix="/api", tags=["inbox"])
app.include_router(dashboard.router, prefix="/api", tags=["dashboard"])
app.include_router(llm.router, prefix="/api", tags=["llm"])
# phase-7: 자율성 코어
app.include_router(approvals.router, prefix="/api", tags=["approvals"])
app.include_router(automation.router, prefix="/api", tags=["automation"])
# phase-8: 일정 + 회의
app.include_router(calendar.router, prefix="/api", tags=["calendar"])
# phase-9: 메일 + 알림
app.include_router(mail.router, prefix="/api", tags=["mail"])
app.include_router(notify.router, prefix="/api", tags=["notify"])
# phase-12: 여정
app.include_router(journey.router, prefix="/api", tags=["journey"])
app.include_router(connectors.router, prefix="/api", tags=["connectors"])
app.include_router(worker.router, prefix="/api", tags=["worker"])
app.include_router(auth_router.router, prefix="/api", tags=["auth"])
app.include_router(settings_router.router, prefix="/api", tags=["settings"])
app.include_router(ops.router, prefix="/api", tags=["ops"])
# 테스트 전용 리셋(ARI_ALLOW_TEST_RESET=1 가드, 운영 403)
app.include_router(_test.router, prefix="/api", tags=["test"])