"""Точка входа FastAPI-приложения LeadRadar. Запуск: uvicorn app.main:app --host 0.0.0.0 --port 8000 или: python -m app.main """ from __future__ import annotations import asyncio import contextlib import logging from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from . import config from .auth import ensure_creds from .db import store from .routers import ( auth_routes, dashboard_routes, discovery_routes, events_routes, ml_routes, processing_routes, projects_routes, settings_routes, tg_routes, ) from .services import fts as fts_svc from .services import ml_client from .services import projects as proj_svc from .services import rates as rates_svc from .services.leads import notify_tick_stats, tick_storage from .services.telegram import register_main_loop, tg logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s") log = logging.getLogger("leadradar") async def _storage_loop() -> None: """Каждые 30 секунд: правила хранения (архив/очистка), напоминания, сердцебиение Telegram.""" while True: try: stats = tick_storage() await notify_tick_stats(stats) await proj_svc.check_reminders() await tg.heartbeat() except Exception: # noqa: BLE001 log.exception("storage loop error") await asyncio.sleep(30) async def _fts_loop() -> None: """Полнотекстовые индексы пересобираем раз в сутки (FTS — снимок).""" while True: await asyncio.sleep(24 * 3600) try: fts_svc.rebuild() except Exception: # noqa: BLE001 log.exception("fts rebuild error") async def _rates_loop() -> None: """Курсы ЦБ РФ: проверяем раз в 30 минут, тянем не чаще 1 раза в 6 часов.""" while True: try: if rates_svc.should_fetch(): ok = await rates_svc.refresh_rates() if not ok: log.warning("rates fetch failed (повтор через 30 минут)") except Exception: # noqa: BLE001 log.exception("rates loop error") await asyncio.sleep(30 * 60) async def _pipeline_loop() -> None: """Фоновый воркер очереди входящих: разбирает сообщения каждые 2 секунды.""" from .services.pipeline import pump_once while True: try: await pump_once() except Exception: # noqa: BLE001 log.exception("pipeline worker error") await asyncio.sleep(2) async def _discovery_loop() -> None: """Фоновый воркер Discovery: поиск → оценка → авто-вступление (раз в 5 c).""" from .services.discovery_worker import tick while True: try: await tick() except Exception: # noqa: BLE001 log.exception("discovery worker error") await asyncio.sleep(5) async def _ml_sync_loop() -> None: """Отправка событий обучения в ML-сервис + кэш его статуса (каждые 10 c).""" while True: try: await ml_client.flush_outbox() await ml_client.refresh_status() except Exception: # noqa: BLE001 log.exception("ml sync error") await asyncio.sleep(10) async def _suggest_loop() -> None: """Раз в 3 минуты пробуем предложить колонки по «Неразобранному» (кулдаун 20 мин).""" from .services.suggest import suggest_from_inbox while True: try: await suggest_from_inbox(force=False) except Exception: # noqa: BLE001 log.exception("suggest loop error") await asyncio.sleep(180) async def _tg_sweep_loop() -> None: """Раз в 30 с: страховочная догонялка непрочитанного по включённым каналам (событие могло потеряться при рестарте/разрыве соединения).""" from .services.telegram import tg as tg_svc while True: await asyncio.sleep(30) try: await tg_svc.realtime_sweep() except Exception: # noqa: BLE001 log.exception("tg realtime sweep error") @contextlib.asynccontextmanager async def lifespan(app: FastAPI): config.ensure_dirs() store.init() ensure_creds() register_main_loop(asyncio.get_running_loop()) tasks = [ asyncio.create_task(_storage_loop()), asyncio.create_task(_rates_loop()), asyncio.create_task(_fts_loop()), asyncio.create_task(_pipeline_loop()), asyncio.create_task(_discovery_loop()), asyncio.create_task(_ml_sync_loop()), asyncio.create_task(_suggest_loop()), asyncio.create_task(_tg_sweep_loop()), asyncio.create_task(tg.auto_resume()), ] # полнотекстовый поиск: пробуем установить расширение и собрать индекс if fts_svc.install_extension(): fts_svc.rebuild() # первичное обновление курсов из ЦБ (если источник cbr) if store.get_setting("rateSource") == "cbr": try: await rates_svc.refresh_rates() except Exception: # noqa: BLE001 log.warning("initial rates fetch failed; продолжаем с мок-курсами") try: yield finally: for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptions=True) await tg.disconnect() store.close() app = FastAPI(title="LeadRadar", version="1.2.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], # локальный dev-режим; в проде замените на свой origin allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # API for mod in (auth_routes, tg_routes, dashboard_routes, projects_routes, settings_routes, events_routes, discovery_routes, ml_routes, processing_routes): app.include_router(mod.router) @app.get("/api/health") def health() -> dict: return {"ok": True, "service": "leadradar"} # Статика фронтенда (собранный dist). Если папки нет — API живёт отдельно. _DIST = config.FRONTEND_DIST if _DIST.is_dir(): assets = _DIST / "assets" if assets.is_dir(): app.mount("/assets", StaticFiles(directory=str(assets)), name="assets") @app.get("/{full_path:path}", include_in_schema=False) async def spa(full_path: str): candidate = (_DIST / full_path).resolve() if full_path and candidate.is_file() and candidate.is_relative_to(_DIST): return FileResponse(str(candidate)) index = _DIST / "index.html" if index.exists(): return FileResponse(str(index)) return JSONResponse({"detail": "фронтенд не собран — используйте npm run dev"}, status_code=200) if __name__ == "__main__": import uvicorn uvicorn.run("app.main:app", host=config.HOST, port=config.PORT, reload=False)