Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,216 @@
"""Точка входа 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)