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