/
keorlov
/
collsim
Обзор
Документация
Войти
/
keorlov
/
collsim
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app.py
772 строки
36 KB
Konstantin Orlov
wip
14 июл 2026, 13:16
14 июл 2026, 13:16
a03c721
Код
Авторство
О чём код?
"""Flask API для запуска симуляции.""" from flask import Flask, request, jsonify from waitress import serve import logging import argparse import json import os import threading from datetime import datetime from pathlib import Path from typing import Optional from engine import SimulationEngine from config_loader import config, merge_config try: from flask_cors import CORS HAS_CORS = True except ImportError: HAS_CORS = False logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = Flask(__name__) # Enable CORS for all origins (adjust for production) if HAS_CORS: CORS(app, origins="*", methods=["GET", "POST", "DELETE", "OPTIONS"]) logger.info("CORS enabled") else: logger.warning("flask-cors not installed, CORS disabled") # JSON-backed simulation store _DEFAULT_STORE_PATH = Path(__file__).resolve().parent / "simulations.json" _STORE_PATH = Path(os.environ.get("COLLSIM_STORE_PATH") or _DEFAULT_STORE_PATH) _simulation_store: dict[int, dict] = {} _simulation_counter = 0 _store_lock = threading.Lock() def _load_store() -> None: """Загружает сохранённые симуляции из JSON файла при старте.""" global _simulation_counter if not _STORE_PATH.exists(): logger.info(f"No store file at {_STORE_PATH}, starting fresh") return try: with open(_STORE_PATH, "r", encoding="utf-8") as f: data = json.load(f) sims = data.get("simulations", {}) _simulation_store.update({int(k): v for k, v in sims.items()}) _simulation_counter = max( data.get("counter", 0), max(_simulation_store.keys(), default=-1) + 1, ) logger.info( f"Loaded {len(_simulation_store)} simulations from {_STORE_PATH}, " f"next id={_simulation_counter}" ) except (json.JSONDecodeError, OSError) as e: logger.warning(f"Failed to load store from {_STORE_PATH}: {e}") def _save_store() -> None: """Атомарно сохраняет store в JSON файл. Должна вызываться под _store_lock.""" tmp_path = _STORE_PATH.with_suffix(_STORE_PATH.suffix + ".tmp") clean_sims = {} for k, v in _simulation_store.items(): clean = {sk: sv for sk, sv in v.items() if sk not in ("user_events", "baseline_events")} clean_sims[str(k)] = clean payload = { "counter": _simulation_counter, "simulations": clean_sims, } with open(tmp_path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False) os.replace(tmp_path, _STORE_PATH) _load_store() def _get_next_simulation_id() -> int: global _simulation_counter with _store_lock: sim_id = _simulation_counter _simulation_counter += 1 return sim_id def _run_simulation_pair(user_params: dict, baseline_params: dict, seed: Optional[int] = None, days: Optional[int] = None) -> dict: """Запускает две симуляции: пользовательскую и базовую (waiting only).""" # User strategy user_config = merge_config(config, user_params) if user_params else config user_engine = SimulationEngine(seed=seed, config_override=user_config) user_engine.run(days=days) user_metrics = user_engine.get_metrics_dict() user_delinquent = user_engine.get_delinquent_clients_dict() # Baseline strategy: if baseline_params is empty/null, use waiting-only (zero budgets) if baseline_params is not None and len(baseline_params) > 0: baseline_config = merge_config(config, baseline_params) else: baseline_config = merge_config(config, {"budgets": {"daily_sms": 0, "daily_call": 0}}) baseline_engine = SimulationEngine(seed=seed, config_override=baseline_config) baseline_engine.run(days=days) baseline_metrics = baseline_engine.get_metrics_dict() baseline_delinquent = baseline_engine.get_delinquent_clients_dict() return { "user": user_metrics, "baseline": baseline_metrics, "user_delinquent": user_delinquent, "baseline_delinquent": baseline_delinquent, "user_events": user_engine.get_client_events_dict(), "baseline_events": baseline_engine.get_client_events_dict(), } @app.route("/simulate", methods=["POST", "OPTIONS"]) def simulate() -> tuple: """ Запускает симуляцию с пользовательской стратегией и базовой (waiting only). Request body (JSON): user_params (dict, optional): Переопределения параметров для пользовательской стратегии. baseline_params (dict, optional): Переопределения параметров для базовой стратегии. simulation_id (int, optional): ID симуляции для перезапуска. Если не указан, создаёт новую. days (int, optional): Количество дней симуляции. seed (int, optional): Seed для воспроизводимости. name (str, optional): Название симуляции. По умолчанию пустая строка. Returns: JSON с simulation_id и метриками обеих стратегий. """ if request.method == "OPTIONS": return jsonify({"status": "ok"}) data = request.get_json(silent=True) or {} simulation_id = data.get("simulation_id") user_params = data.get("user_params", {}) baseline_params = data.get("baseline_params", {}) days = data.get("days") seed = data.get("seed") name = data.get("name", "") logger.info(f"Simulation request: id={simulation_id}, days={days}, seed={seed}, name={name!r}") try: if simulation_id is not None: # Resimulate existing with _store_lock: if simulation_id not in _simulation_store: return jsonify({"status": "error", "message": f"Simulation {simulation_id} not found"}), 404 # Use stored params if not provided stored = _simulation_store[simulation_id] if not user_params: user_params = stored.get("user_params", {}) if not baseline_params: baseline_params = stored.get("baseline_params", {}) if days is None: days = stored.get("days") if seed is None: seed = stored.get("seed") if not name: name = stored.get("name", "") created_at = stored.get("created_at", "") logger.info(f"Resimulating id={simulation_id}") else: # Create new simulation simulation_id = _get_next_simulation_id() created_at = datetime.now().isoformat() logger.info(f"Creating new simulation id={simulation_id}") metrics = _run_simulation_pair(user_params, baseline_params, seed, days) # Store simulation with _store_lock: _simulation_store[simulation_id] = { "simulation_id": simulation_id, "name": name, "created_at": created_at, "user_params": user_params, "baseline_params": baseline_params, "days": days, "seed": seed, "user_metrics": metrics["user"], "baseline_metrics": metrics["baseline"], "user_delinquent": metrics["user_delinquent"], "baseline_delinquent": metrics["baseline_delinquent"], "user_events": {str(k): v for k, v in metrics["user_events"].items()}, "baseline_events": {str(k): v for k, v in metrics["baseline_events"].items()}, } _save_store() return jsonify({ "status": "success", "simulation_id": simulation_id, "name": name, "created_at": created_at, "user_metrics": metrics["user"], "baseline_metrics": metrics["baseline"] }) except Exception as e: logger.exception("Simulation failed") return jsonify({"status": "error", "message": str(e)}), 500 @app.route("/simulate/<int:simulation_id>", methods=["GET"]) def get_simulation(simulation_id: int) -> tuple: """Получает результаты симуляции по ID.""" with _store_lock: if simulation_id not in _simulation_store: return jsonify({"status": "error", "message": f"Simulation {simulation_id} not found"}), 404 sim = _simulation_store[simulation_id] return jsonify({ "status": "success", "simulation_id": simulation_id, "name": sim.get("name", ""), "created_at": sim.get("created_at", ""), "user_params": sim["user_params"], "baseline_params": sim["baseline_params"], "days": sim["days"], "seed": sim["seed"], "user_metrics": sim["user_metrics"], "baseline_metrics": sim["baseline_metrics"] }) @app.route("/simulation_clients/<int:simulation_id>", methods=["GET"]) def get_simulation_clients(simulation_id: int) -> tuple: """ Возвращает список клиентов с просрочкой для пользовательской или базовой стратегии. Query params: strategy: 'user' или 'baseline' (по умолчанию 'user') offset: смещение для пагинации (по умолчанию 0) limit: количество клиентов на страницу (по умолчанию 50) Returns: JSON с полями: total, offset, limit, clients """ with _store_lock: if simulation_id not in _simulation_store: return jsonify({"status": "error", "message": f"Simulation {simulation_id} not found"}), 404 sim = _simulation_store[simulation_id] strategy = request.args.get("strategy", "user") try: offset = max(0, int(request.args.get("offset", 0))) limit = max(1, min(500, int(request.args.get("limit", 50)))) except (ValueError, TypeError): offset, limit = 0, 50 if strategy == "baseline": all_clients = sim.get("baseline_delinquent", []) else: all_clients = sim.get("user_delinquent", []) total = len(all_clients) clients = all_clients[offset:offset + limit] return jsonify({ "status": "success", "simulation_id": simulation_id, "strategy": strategy, "total": total, "offset": offset, "limit": limit, "clients": clients, }) @app.route("/simulation_client_events/<int:simulation_id>/<int:client_id>", methods=["GET"]) def get_simulation_client_events(simulation_id: int, client_id: int) -> tuple: """ Возвращает события (день за днём) для конкретного клиента. Query params: strategy: 'user' или 'baseline' (по умолчанию 'user') """ with _store_lock: if simulation_id not in _simulation_store: return jsonify({"status": "error", "message": f"Simulation {simulation_id} not found"}), 404 sim = _simulation_store[simulation_id] strategy = request.args.get("strategy", "user") if strategy == "baseline": events_map = sim.get("baseline_events") else: events_map = sim.get("user_events") if events_map is None: return jsonify({ "status": "no_data", "message": "События недоступны. Перезапустите симуляцию для получения данных.", "simulation_id": simulation_id, "strategy": strategy, "client_id": client_id, "events": [], }) events = events_map.get(str(client_id), []) return jsonify({ "status": "success", "simulation_id": simulation_id, "strategy": strategy, "client_id": client_id, "events": events, }) @app.route("/simulation_results/<int:simulation_id>", methods=["GET"]) def get_simulation_results(simulation_id: int) -> tuple: """ Возвращает полные результаты симуляции: параметры, метрики по дням и агрегированные данные. Используется другими AI-агентами для анализа результатов. """ with _store_lock: if simulation_id not in _simulation_store: return jsonify({"status": "error", "message": f"Simulation {simulation_id} not found"}), 404 sim = _simulation_store[simulation_id] user_metrics = sim.get("user_metrics", []) baseline_metrics = sim.get("baseline_metrics", []) def aggregate(metrics): if not metrics: return None last = metrics[-1] total_recovered = sum(m.get("recovered_today", 0) for m in metrics) total_spent = sum(m.get("daily_budget_spent", 0) for m in metrics) total_new = sum(m.get("new_clients", 0) for m in metrics) total_cured = sum(m.get("cured_today", 0) for m in metrics) total_sold = sum(m.get("sold_today", 0) for m in metrics) total_inflow_soft = sum(m.get("inflow_soft", 0) for m in metrics) total_outflow_cured = sum(m.get("outflow_cured", 0) for m in metrics) total_outflow_hard = sum(m.get("outflow_hard", 0) for m in metrics) total_spent_sms = sum(m.get("spent_sms", 0) for m in metrics) total_spent_call = sum(m.get("spent_call", 0) for m in metrics) total_recovered_sms = sum(m.get("recovered_sms", 0) for m in metrics) total_recovered_call = sum(m.get("recovered_call", 0) for m in metrics) total_recovered_waiting = sum(m.get("recovered_waiting", 0) for m in metrics) return { "final_day": last["day"], "final_npl_pct": last["npl_pct"], "final_npl_balance": last["npl_balance"], "final_past_due_balance": last.get("past_due_balance", 0), "final_total_balance": last["total_balance"], "final_current_count": last["current_count"], "final_soft_count": last["soft_count"], "final_hard_count": last["hard_count"], "final_closed_count": last["closed_count"], "final_sold_count": last["sold_count"], "final_cost_of_risk": last.get("cost_of_risk", 0), "total_cost_of_risk": sum(m.get("cost_of_risk", 0) for m in metrics), "total_cession_losses": sum(m.get("cession_losses", 0) for m in metrics), "total_interest_income": sum(m.get("interest_income_today", 0) for m in metrics), "total_recovered": total_recovered, "total_spent": total_spent, "net_recovery": total_recovered - total_spent, "total_new_clients": total_new, "total_cured": total_cured, "total_sold": total_sold, "total_inflow_soft": total_inflow_soft, "total_outflow_cured": total_outflow_cured, "total_outflow_hard": total_outflow_hard, "total_spent_sms": total_spent_sms, "total_spent_call": total_spent_call, "total_recovered_sms": total_recovered_sms, "total_recovered_call": total_recovered_call, "total_recovered_waiting": total_recovered_waiting, "total_sms_count": sum(m.get("sms_count", 0) for m in metrics), "total_call_count": sum(m.get("call_count", 0) for m in metrics), "total_waiting_count": sum(m.get("waiting_count", 0) for m in metrics), "final_dpd_1_30_count": last.get("dpd_1_30_count", 0), "final_dpd_31_60_count": last.get("dpd_31_60_count", 0), "final_dpd_61_89_count": last.get("dpd_61_89_count", 0), } return jsonify({ "status": "success", "simulation_id": simulation_id, "params": { "user": sim.get("user_params"), "baseline": sim.get("baseline_params"), "days": sim.get("days"), "seed": sim.get("seed"), "name": sim.get("name", ""), "created_at": sim.get("created_at", ""), }, "user": { "metrics": user_metrics, "aggregate": aggregate(user_metrics), }, "baseline": { "metrics": baseline_metrics, "aggregate": aggregate(baseline_metrics), } }) @app.route("/simulate/<int:simulation_id>", methods=["DELETE"]) def dispose_simulation(simulation_id: int) -> tuple: """Удаляет симуляцию из памяти (освобождает ресурсы).""" with _store_lock: if simulation_id not in _simulation_store: return jsonify({"status": "error", "message": f"Simulation {simulation_id} not found"}), 404 del _simulation_store[simulation_id] _save_store() logger.info(f"Disposed simulation {simulation_id}") return jsonify({"status": "success", "message": f"Simulation {simulation_id} disposed"}) @app.route("/simulations", methods=["GET"]) def list_simulations() -> tuple: """Список всех сохранённых симуляций.""" with _store_lock: sims = [] for sid, s in _simulation_store.items(): user_metrics = s.get("user_metrics", []) baseline_metrics = s.get("baseline_metrics", []) user_npl = user_metrics[-1]["npl_pct"] if user_metrics else None baseline_npl = baseline_metrics[-1]["npl_pct"] if baseline_metrics else None sims.append({ "simulation_id": sid, "name": s.get("name", ""), "created_at": s.get("created_at", ""), "days": s["days"], "seed": s["seed"], "user_npl": user_npl, "baseline_npl": baseline_npl, }) return jsonify({"status": "success", "simulations": sims}) @app.route("/simulation_params", methods=["GET"]) def get_simulation_params() -> tuple: """ Возвращает все параметры симуляции с описаниями, дефолтами и примерами. Используется другими AI-агентами для формирования запросов. """ params = { "simulation": { "description": "Общие настройки симуляции", "fields": { "days": { "type": "int", "description": "Количество дней симуляции", "default": 365, "min": 1, "max": 10000, "example": 365 }, "optimization_mode": { "type": "string", "description": "Режим оптимизации collection strategy: greedy (максимизация EV) или uplift (максимизация прироста p_base)", "default": "greedy", "enum": ["greedy", "uplift"], "example": "greedy" }, "gini": { "type": "float", "description": "Коэффициент Джини для расчёта p_adj. Определяет разброс вероятностей по клиентам.", "default": 0.35, "min": 0.0, "max": 1.0, "example": 0.35 }, "cure_recovery_rate": { "type": "float", "description": "Доля баланса, которую клиент платит при cure (возвращении в current). Сумма вычитается из баланса и идёт в recovered_today.", "default": 0.05, "min": 0.0, "max": 1.0, "example": 0.05 }, "hard_cure_rate": { "type": "float", "description": "Дневная вероятность реструктуризации hard кредита (возврат в soft, dpd=89)", "default": 0.002, "min": 0.0, "max": 1.0, "example": 0.002 } } }, "portfolio": { "description": "Настройки портфеля", "fields": { "initial_clients": { "type": "int", "description": "Начальное количество клиентов в портфеле", "default": 50000, "min": 100, "max": 10000000, "example": 50000 }, "daily_growth_rate": { "type": "float", "description": "Ежедневный прирост портфеля (доля от текущего размера)", "default": 0.00016, "min": 0.0, "max": 0.01, "example": 0.00016 } } }, "client_types": { "description": "Типы клиентов и их параметры", "fields": { "chronic": { "type": "object", "description": "Хронические неплательщики — систематически пропускают платежи", "fields": { "share": {"type": "float", "description": "Доля в портфеле", "default": 0.35, "min": 0.0, "max": 1.0}, "hazard_multiplier": {"type": "float", "description": "Множитель hazard rate", "default": 2.5, "min": 0.0, "max": 5.0} } }, "forgetful": { "type": "object", "description": "Забывчивые клиенты — случайные пропуски платежей", "fields": { "share": {"type": "float", "description": "Доля в портфеле", "default": 0.65, "min": 0.0, "max": 1.0}, "hazard_multiplier": {"type": "float", "description": "Множитель hazard rate", "default": 0.6, "min": 0.0, "max": 5.0} } } } }, "products": { "description": "Кредитные продукты", "fields": { "mortgage": { "type": "object", "description": "Ипотека — долгосрочные кредиты под заложенную недвижимость", "fields": { "share": {"type": "float", "description": "Доля в портфеле", "default": 0.45, "min": 0.0, "max": 1.0}, "avg_balance": {"type": "int", "description": "Средний баланс кредита (₽)", "default": 3500000, "min": 10000}, "term_days": {"type": "int", "description": "Срок кредита (дни)", "default": 7300, "min": 30}, "hazard_mult": {"type": "float", "description": "Множитель hazard rate", "default": 0.2, "min": 0.0, "max": 5.0}, "daily_interest_rate": {"type": "float", "description": "Дневная ставка начисления процентов для просроченных кредитов", "default": 0.0003, "min": 0.0, "max": 0.1}, "nim_annual_rate": {"type": "float", "description": "Годовая NIM ставка (доход банка от current кредитов)", "default": 0.08, "min": 0.0, "max": 1.0} } }, "potreb": { "type": "object", "description": "Потребительский кредит — среднесрочные кредиты наличными", "fields": { "share": {"type": "float", "description": "Доля в портфеле", "default": 0.30, "min": 0.0, "max": 1.0}, "avg_balance": {"type": "int", "description": "Средний баланс кредита (₽)", "default": 300000, "min": 10000}, "term_days": {"type": "int", "description": "Срок кредита (дни)", "default": 1825, "min": 30}, "hazard_mult": {"type": "float", "description": "Множитель hazard rate", "default": 1.2, "min": 0.0, "max": 5.0}, "daily_interest_rate": {"type": "float", "description": "Дневная ставка начисления процентов для просроченных кредитов", "default": 0.0005, "min": 0.0, "max": 0.1}, "nim_annual_rate": {"type": "float", "description": "Годовая NIM ставка (доход банка от current кредитов)", "default": 0.15, "min": 0.0, "max": 1.0} } }, "card": { "type": "object", "description": "Кредитная карта — revolving кредит с возможностью случайного закрытия", "fields": { "share": {"type": "float", "description": "Доля в портфеле", "default": 0.15, "min": 0.0, "max": 1.0}, "avg_balance": {"type": "int", "description": "Средний баланс кредита (₽)", "default": 80000, "min": 1000}, "term_days": {"type": "null", "description": "Срок кредита (null = revolving)", "default": None}, "hazard_mult": {"type": "float", "description": "Множитель hazard rate", "default": 1.8, "min": 0.0, "max": 5.0}, "daily_close_prob": {"type": "float", "description": "Вероятность закрытия карты в день", "default": 0.0005, "min": 0.0, "max": 0.1}, "daily_interest_rate": {"type": "float", "description": "Дневная ставка начисления процентов для просроченных кредитов", "default": 0.0006, "min": 0.0, "max": 0.1}, "nim_annual_rate": {"type": "float", "description": "Годовая NIM ставка (доход банка от current кредитов)", "default": 0.20, "min": 0.0, "max": 1.0} } }, "auto": { "type": "object", "description": "Автокредит — кредит на покупку автомобиля", "fields": { "share": {"type": "float", "description": "Доля в портфеле", "default": 0.10, "min": 0.0, "max": 1.0}, "avg_balance": {"type": "int", "description": "Средний баланс кредита (₽)", "default": 1500000, "min": 10000}, "term_days": {"type": "int", "description": "Срок кредита (дни)", "default": 1825, "min": 30}, "hazard_mult": {"type": "float", "description": "Множитель hazard rate", "default": 0.5, "min": 0.0, "max": 5.0}, "daily_interest_rate": {"type": "float", "description": "Дневная ставка начисления процентов для просроченных кредитов", "default": 0.0004, "min": 0.0, "max": 0.1}, "nim_annual_rate": {"type": "float", "description": "Годовая NIM ставка (доход банка от current кредитов)", "default": 0.12, "min": 0.0, "max": 1.0} } } } }, "hazard": { "description": "Параметры hazard rate (вероятность перехода current -> soft)", "fields": { "base_daily_rate": { "type": "float", "description": "Базовая дневная вероятность дефолта. Умножается на множители продукта и типа клиента.", "default": 0.0012, "min": 0.0, "max": 1.0, "example": 0.0012 } } }, "channels": { "description": "Каналы взыскания и их параметры", "fields": { "waiting": { "type": "object", "description": "Ожидание — естественное взыскание без контакта", "fields": { "cost": {"type": "int", "description": "Стоимость контакта (₽)", "default": 0, "min": 0}, "p_base": {"type": "float", "description": "Базовая вероятность погашения", "default": 0.001, "min": 0.0, "max": 1.0} } }, "sms": { "type": "object", "description": "SMS-уведомления", "fields": { "cost": {"type": "int", "description": "Стоимость SMS (₽)", "default": 5, "min": 0}, "p_base": {"type": "float", "description": "Базовая вероятность погашения после SMS", "default": 0.004, "min": 0.0, "max": 1.0} } }, "call": { "type": "object", "description": "Телефонный звонок", "fields": { "cost": {"type": "int", "description": "Стоимость звонка (₽)", "default": 50, "min": 0}, "p_base": {"type": "float", "description": "Базовая вероятность погашения после звонка", "default": 0.015, "min": 0.0, "max": 1.0} } } } }, "budgets": { "description": "Дневные бюджеты на каналы взыскания", "fields": { "daily_sms": { "type": "int", "description": "Дневной бюджет на SMS (₽). 0 = без SMS.", "default": 10000, "min": 0, "example": 10000 }, "daily_call": { "type": "int", "description": "Дневной бюджет на звонки (₽). 0 = без звонков.", "default": 25000, "min": 0, "example": 25000 } } }, "legal_limits": { "description": "Юридические лимиты на контакты", "fields": { "max_calls_per_week": { "type": "int", "description": "Максимум звонков в неделю на клиента", "default": 2, "min": 0, "max": 10, "example": 2 }, "max_sms_per_week": { "type": "int", "description": "Максимум SMS в неделю на клиента", "default": 4, "min": 0, "max": 10, "example": 4 } } }, "hard_collection": { "description": "Параметры hard collection (продажа долга)", "fields": { "cession_dpd": { "type": "int", "description": "DPD для передачи в hard collection (дни)", "default": 360, "min": 90, "max": 1000, "example": 360 }, "cession_rate": { "type": "float", "description": "Ставка выкупа долга (доля от баланса). Например, 0.04 = 4% от баланса.", "default": 0.04, "min": 0.0, "max": 1.0, "example": 0.04 } } }, "initial_state": { "description": "Параметры начального среза портфеля (тёплый старт)", "fields": { "soft_share": { "type": "float", "description": "Доля портфеля в просрочке DPD 1-89 на старте", "default": 0.038, "min": 0.0, "max": 1.0, "example": 0.038 }, "hard_share": { "type": "float", "description": "Доля портфеля в просрочке DPD 90+ на старте (целевой NPL)", "default": 0.048, "min": 0.0, "max": 1.0, "example": 0.048 }, "hard_dpd_max": { "type": "int", "description": "Максимальный стартовый DPD для hard кредитов (меньше cession_dpd)", "default": 350, "min": 90, "max": 1000, "example": 350 } } }, "_meta": { "description": "Метаинформация об endpoint", "endpoint": "/simulation_params", "method": "GET", "usage": "Вызовите этот endpoint для получения полной схемы параметров. Используйте дефолтные значения для базовой стратегии (budgets.daily_sms=0, budgets.daily_call=0).", "notes": [ "Сумма share по client_types должна быть = 1.0", "Сумма share по products должна быть = 1.0", "Для baseline стратегии установите budgets.daily_sms=0 и budgets.daily_call=0", "p_base определяет вероятность погашения при контакте через указанный канал", "gini определяет разброс p_adj по клиентам: 0 = все одинаковые, 1 = максимальный разброс" ] } } return jsonify(params) @app.route("/health", methods=["GET"]) def health() -> tuple: """Health check endpoint.""" return jsonify({"status": "healthy"}) def run_app(host: str = "0.0.0.0", port: int = 5000) -> None: """ Запускает Flask приложение через Waitress. Args: host: Хост для биндинга. Используйте '0.0.0.0' для всех интерфейсов. port: Порт для биндинга. """ logger.info(f"Starting server on {host}:{port}") serve(app, host=host, port=port, threads=4) def main(): parser = argparse.ArgumentParser(description="CollSim Backend") parser.add_argument("--host", default="0.0.0.0", help="Host to bind (0.0.0.0 for all interfaces)") parser.add_argument("--port", type=int, default=5000, help="Port to bind") args = parser.parse_args() run_app(args.host, args.port) if __name__ == "__main__": main()