# 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"])