gendesign/backend/app/core/config.py
Light1YT 8a3bae0cda
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m46s
CI / backend-tests (pull_request) Successful in 11m55s
feat(analyze): per-category OSRM routing (foot vs driving) (#39 A3)
Walk-relevant POIs (school/shop/park/kindergarten/pharmacy/stops) now route
via a FOOT OSRM graph (osrm-walk service), car-relevant POIs (mall/hospital +
unknown) keep the DRIVING graph. Validation showed driving overstated
pedestrian-proximity distance — median 1.6-2.9x straight-line (#39).

- config: osrm_walk_local_url + osrm_walk_categories (frozenset, 9 walk cats)
- osrm_client_local: base_url override on get_road_distances_m (default unchanged)
- _apply_osrm_road_distances: split POIs by category, per-group OSRM call with
  independent graceful fallback (one server down -> its group keeps straight-line),
  in-place write-back by original index; never raises; flag-OFF byte-identical
- docker-compose: osrm-walk service (foot graph, internal, mem_limit 1.5g)
- build_osrm.sh: CAR=0 gate for foot-only refresh (doesn't touch live car graph)
- tests: per-category split, per-group fallback, asymmetric intra-group write-back

Still flag-gated (use_osrm_distances OFF) — enabling is a product decision.
Refs #39
2026-06-27 04:30:25 +05:00

310 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import warnings
from typing import Annotated
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
database_url: str = "postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign"
redis_url: str = "redis://localhost:6379/0"
cors_origins: list[str] = ["http://localhost:3000"]
# GlitchTip error tracking (Sentry-compatible self-hosted).
# Формат DSN: https://<key>@errors.gendsgn.ru/<project_id>
# Пустая строка / None = SDK не инициализируется (no-op).
glitchtip_dsn: str | None = None
glitchtip_traces_sample_rate: float = 0.05
environment: str = "dev"
# Test-mode flag (env TESTING=1). СТРОГО default False — в проде RBAC-гейт
# (app/main.py rbac_guard) активен. True только в pytest (tests/conftest.py),
# где запросы идут по app мимо Caddy и не несут X-Authenticated-User.
testing: bool = False
# ── §25.3 «own portfolio» (#1169) ─────────────────────────────────────────
# Целочисленные DOM.РФ developer-id «наших» застройщиков (env OWN_DEVELOPER_IDS,
# comma-separated → list[int]; пусто/не задано → []). Источник «текущих» проектов
# для движка каннибализации §25.3: domrf_kn_objects фильтруется по этим id (по
# числовому префиксу composite dev_id '<devId>_<suffix>', см.
# site_finder/own_portfolio.py).
#
# СТРОГО default [] и НИ ОДНОГО реального id в коде — конфигурируется только через
# окружение прод-контейнера. Пустой список = «текущих» проектов нет: §25.3 own-
# portfolio деградирует ИЗЯЩНО (get_own_portfolio вернёт только manual/future-строки,
# а при их отсутствии — пустой список; PR2-движок честно отдаёт прокси/None вместо
# фабрикации), а НЕ падает.
#
# NoDecode: отключает дефолтный JSON-предпарсинг сложного типа (pydantic-settings для
# list[int] иначе ждёт JSON-литерал '[1,2]' и падает на «человеческой» строке
# '12345,67890'). С NoDecode сырое env-значение доходит до _parse_own_developer_ids
# (mode='before'), где и парсится comma-separated.
own_developer_ids: Annotated[list[int], NoDecode] = []
@field_validator("own_developer_ids", mode="before")
@classmethod
def _parse_own_developer_ids(cls, value: object) -> object:
"""Распарсить OWN_DEVELOPER_IDS из comma-separated env-строки в list[int].
pydantic-settings по умолчанию ждёт для list[int] JSON-литерал ('[1,2]'),
поэтому «человеческую» строку '12345,67890' нужно распарсить вручную. Правила:
• пусто / не задано / '' → [] (graceful default, §25.3 деградирует изящно);
'12345, 67890 ,42' → [12345, 67890, 42] (trim + пропуск пустых токенов);
• уже list (тесты/JSON-env) → нормализуем элементы в int as-is.
Нечисловой токен → ValueError (явная ошибка конфигурации, а не тихий дроп).
"""
if value is None:
return []
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return []
return [int(tok.strip()) for tok in stripped.split(",") if tok.strip()]
if isinstance(value, list | tuple):
return [int(item) for item in value]
return value
@model_validator(mode="after")
def _promote_legacy_sentry_dsn(self) -> "Settings":
"""Backward-compat: legacy SENTRY_DSN → glitchtip_dsn ТОЛЬКО для self-hosted.
Старый VPS .env.runtime мог содержать SENTRY_DSN=https://...@sentry.io/...
(legacy SaaS Sentry). Промоутить его НЕЛЬЗЯ — события пойдут в чужой проект.
Принимаем только URLs указывающие на наш errors.gendsgn.ru host.
"""
if not self.glitchtip_dsn:
legacy = os.getenv("SENTRY_DSN")
if legacy:
if "errors.gendsgn.ru" in legacy:
warnings.warn(
"SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. "
"Auto-promoting for backward compat.",
DeprecationWarning,
stacklevel=2,
)
self.glitchtip_dsn = legacy
else:
warnings.warn(
f"SENTRY_DSN points to non-GlitchTip host "
f"({legacy.split('@', 1)[-1][:40]}...) — ignoring. "
"Set GLITCHTIP_DSN explicitly.",
UserWarning,
stacklevel=2,
)
return self
# External APIs (Stage 2)
rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1"
overpass_url: str = "https://overpass-api.de/api/interpreter"
# Scraper schedule (наш.дом.рф kn-API).
# Crontab format: "minute hour day_of_month month day_of_week" (Celery crontab).
# Default: каждый понедельник в окне 04:0005:00 по МСК.
scrape_kn_cron: str = "15 4 * * mon"
# Random delay window in seconds added on top of scheduled start so the worker
# does not hit DOM.РФ at exactly the same minute every cycle. 0 = disabled.
scrape_kn_jitter_seconds: int = 1800
# Default region(s) for scheduled sweeps. Comma-separated.
scrape_kn_default_regions: str = "66"
# Path to a pre-captured Playwright storage_state.json (committed in repo,
# used by worker to skip cold-start WAF challenge).
scrape_kn_state_path: str = "data/playwright_state.json"
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
# Empty string = endpoint disabled.
scrape_admin_token: str = ""
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
# Settings оставлены deprecated на случай отката (можно удалить позже).
scrape_nspd_cron: str = "30 3 20 2,5,8,11 *" # DEPRECATED
scrape_nspd_default_regions: str = "66" # DEPRECATED
scrape_nspd_rate_ms: int = 600 # DEPRECATED — see nspd_lite_rate_ms
# Objective.ru API (api.objctv.ru) — платная аналитика первички/вторички.
# Лежит наряду с DOM.РФ kn + rosreestr CSV как 3-й источник истины с
# самой богатой моделью (per-flat per-day, escrow/банк-долг, ЕГРН-ИНН).
# Получить ключ: личный закрытый ApiKey от Объектива (тариф per-территория).
# Tank-схема:
# 1. GET /Users/User/GetToken?apiKey=<KEY> → JSON с Bearer-токеном
# 2. GET /v2/Report/GetReport?... + Authorization: Bearer <token>
objective_api_key: str | None = None
objective_base_url: str = "https://api.objctv.ru"
# Группа = «настройка территории» внутри Объектива. Чаще всего совпадает с
# городом/областью. Доступные группы определяются тарифом ключа.
objective_default_group: str = "Екатеринбург"
# Список групп для еженедельного auto-sync (csv). Эмпирически проверено
# 2026-05-10 что эти группы доступны на нашем тарифе:
# - "Свердловская область" → 1626 corp_sum/3д (= ЕКБ + спутники + регион)
# - "Челябинск" → 355
# - "Тюмень" → 985
# - "Пермь" → 423
# Стратегия Variant B (включить наш sync параллельно Антоновому ETL):
# Антон тянет только Екб (303k lots). Наш sync даёт +30k Свердл.обл
# за пределами Екб + ~700k квартир УрФО (Челябинск, Тюмень, Пермь).
# Итог в PG: ~1М квартир уральского + западносибирского рынка.
# Между группами task делает паузу _OBJECTIVE_INTER_GROUP_DELAY = 30с
# чтобы не упереться в rate-limit Объектива.
objective_sync_groups: str = "Свердловская область,Челябинск,Тюмень,Пермь"
# Токен Bearer короткоживущий (предположительно 30 мин — уточнить).
# Кешируем в Redis с этим TTL (с запасом 5 мин).
objective_token_ttl_seconds: int = 25 * 60
# Расписание sync — еженедельно по вторникам в 06:00 МСК.
# ПОЧЕМУ ВТОРНИК (а не понедельник как раньше): Антон тянет в понедельник,
# к утру вторника его SQLite уже обновлён — наш sync захватывает свежий
# snapshot за тот же отчётный период.
objective_sync_cron: str = "0 6 * * tue"
# NSPD lite (urllib через WAF) — feature toggle для переключения с
# Playwright-based nspd_kn.py на минималистичный nspd_lite.py.
# 2026-05-11: эмпирически проверено что urllib проходит WAF nspd.gov.ru
# (TLS-fingerprint stdlib не блокируется). Старый Playwright-путь
# остаётся как fallback на случай возврата WAF-проблем.
use_nspd_lite: bool = True
# Default rate-limit для nspd_lite fetcher (мс между запросами).
nspd_lite_rate_ms: int = 600
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
# На проде монтируется bind-mount-ом docker-compose:
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
# Триггерится вручную через POST /api/v1/admin/scrape/objective.
# Для локальной разработки — указать абсолютный путь к скачанному snapshot:
# OBJECTIVE_ANTON_SQLITE_PATH=C:/Users/user/source/repos/gendesign/sf_anton_snapshot.db
objective_anton_sqlite_path: str = "/data/anton-sqlite/analysis.db"
# Cross-load ETL tradein→gendesign (#976 950-E5).
# Прямой psycopg-коннект к tradein-postgres через gendesign_shared network.
# Пример: postgresql://gendesign_reader:<pw>@tradein-postgres:5432/tradein
# Если пусто — ETL отключён (warn-log, задача возвращает {"disabled": true}).
tradein_database_url: str = ""
# OpenRouteService API (https://openrouteservice.org/dev/#/signup).
# Free tier: 2000 запросов/день. Используется в /parcels/{cad}/isochrones.
# Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
openrouteservice_api_key: str = ""
# ── OSRM road-distance в /analyze (#39 A2) ────────────────────────────────
# Реальное дорожное расстояние центроид→POI из локального OSRM-сервиса
# (self-hosted, /table GET с annotations=distance → метры) вместо
# straight-line ST_Distance в POI-скоринге.
#
# use_osrm_distances СТРОГО default False: включение сдвигает ВСЕ POI-score
# на ~20-30% (дорога длиннее прямой), что есть осознанное ПОЗДНЕЕ решение
# (после валидации), а НЕ этот деплой. При OFF /analyze байт-в-байт как
# сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
# битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
use_osrm_distances: bool = False
# URL локального OSRM (docker-compose service `osrm`, DRIVING-граф). /table:
# GET {url}/table/v1/driving/{coords}?sources=0&annotations=distance
osrm_local_url: str = "http://osrm:5000"
# Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
# сервисе — при превышении → fallback на straight-line.
osrm_distance_timeout_s: float = 12.0
# ── Per-category OSRM routing (#39 A3) ────────────────────────────────────
# Валидация показала: DRIVING-расстояние ПЕРЕОЦЕНИВАЕТ пешеходно-релевантные
# POI (школа/магазин/парк) — медиана 1.62.9× straight-line. Фикс: walk-POI
# маршрутизируем по FOOT-графу (отдельный сервис `osrm-walk`), car-POI — по
# существующему DRIVING-графу.
#
# URL локального OSRM с FOOT-графом (docker-compose service `osrm-walk`):
# GET {url}/table/v1/foot/{coords}?sources=0&annotations=distance
osrm_walk_local_url: str = "http://osrm-walk:5000"
# Категории POI, маршрутизируемые по FOOT-графу (пешая доступность). Всё, чего
# НЕТ в этом множестве — incl. `shop_mall`, `hospital` и любая неизвестная/None
# категория — идёт через DRIVING-граф как безопасный дефолт (drive-side).
osrm_walk_categories: frozenset[str] = frozenset(
{
"shop_small",
"pharmacy",
"shop_supermarket",
"kindergarten",
"school",
"park",
"bus_stop",
"tram_stop",
"metro_stop",
}
)
# ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
# parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
# ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
# zone_regulation_cache (#1102). Override через env ENABLE_IRD_ANALYZE=false остаётся
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
enable_ird_analyze: bool = True
# РИАСУРТ Свердл gate в analyze (#108, multi-city scaling): поле `gate.riasurt` в ответе
# analyze — пересечения участка с зонами РИАСУРТ Свердл (тер.зона/функц.зона/красные линии/
# СЗЗ/ЗСО/затопление/КРТ). ТОЛЬКО для участков в агломерации ЕКБ, НЕ в самом ЕКБ-сити
# (is_in_aglomeration_but_not_ekb). По умолчанию OFF: таблица riasurt_sverdl наполняется
# post-deploy harvest'ом на уточнённых bbox МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO).
# Override через env ENABLE_RIASURT_GATE=true.
enable_riasurt_gate: bool = False
# РИАСУРТ Свердл harvest kill-switch (#108 review): ежеквартальный beat
# `harvest_all_riasurt_sverdl` прогоняет grid-walk по MO_BBOXES. Эти bbox — ПЛЕЙСХОЛДЕРЫ
# (грубые ±6 км вокруг центров МО), реальные административные границы резолвятся post-deploy.
# По умолчанию OFF, иначе beat на следующем тике дёргает WMS с мусорными bbox.
# Включать ТОЛЬКО после замены MO_BBOXES на реальные границы. Override через
# env ENABLE_RIASURT_HARVEST=true. Single-MO harvest_riasurt_sverdl_for_mo не гейтится
# (callable вручную для smoke-тестирования конкретного bbox).
enable_riasurt_harvest: bool = False
# Реальный ПЗЗ-градрегламент зоны (КСИТ/max_far, высота, этажность, %застройки,
# min площадь ЗУ) в ответе analyze: поле `nspd_zoning` дополняется числовыми
# предельными параметрами из zone_regulation_cache (Route 2 — coordinate-based
# resolver get_or_fetch_zone_regulation, cache-first + bounded live geoportal на
# miss). Кэш прогрет для ЕКБ (33 зоны), поэтому live-вызовы редки и закапаны
# коротким timeout'ом. Hot-path-safe: любой сбой/таймаут → поля None, analyze не
# падает. По умолчанию ON; выключение (env ENABLE_ZONING_REGULATION_IN_ANALYZE=false)
# полностью пропускает резолв — поведение analyze без изменений.
enable_zoning_regulation_in_analyze: bool = True
# Единый bounded-timeout (сек) для live geoportal-вызовов резолвера ПЗЗ-регламента в
# hot-пути analyze (#1850). Раньше было два расходящихся значения: parcels.py=3s,
# ird_analyze.py=4s для ОДНОГО и того же резолвера. Унифицировано в 4s (более безопасный
# запас). Оба колл-сайта работают cache-first (+ мемоизация coord→zone_index), поэтому
# живые вызовы редки. Дефолт клиента EKBGeoportalClient (20s) слишком долог для sync-хэндлера.
# Override через env GEOPORTAL_TIMEOUT_S.
geoportal_timeout_s: int = 4
# Area-gate для blocker'а «инженерная/утилитарная охранная зона» в gate_verdict.
# ЗОУИТ охранной зоны инж.сети (ЛЭП/газ/трубопровод/тепло/электро) в РФ ограничивает
# застройку ВНУТРИ полосы (отступы, запрет капстроя над линией), а НЕ стерилизует весь
# участок — МКД сажается на необременённом остатке. Поэтому такая зона блокирует МКД
# ТОЛЬКО когда покрывает > этой доли площади участка (фактически стерилизует его);
# ниже порога — warning «учесть при посадке». Default 0.6: на проде ЕКБ медиана покрытия
# ~6%, p90 ~47% → блокирует лишь экстремальный хвост (1/17 участков). Override через env
# GATE_ZOUIT_ENGINEERING_BLOCKER_MIN_COVERAGE.
gate_zouit_engineering_blocker_min_coverage: float = 0.6
# ── LLM infrastructure (#960) ────────────────────────────────────────────
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
# fallback (см. app/services/llm/client.py).
#
# llm_enabled СТРОГО default False: пока секреты не настроены в проде, клиент
# НЕ делает сетевых вызовов вообще (guard #2 в client.complete). Включать только
# после того как OPENAI_API_KEY заведён в окружение прод-контейнера И принято
# решение по §19 data-residency (провайдер внешний — данные покидают РФ).
llm_enabled: bool = False
# Ключ читается ТОЛЬКО отсюда (env OPENAI_API_KEY). Нигде в коде/тестах нет
# литерала ключа. None = ключ не задан → клиент ведёт себя как при llm_enabled=False.
openai_api_key: str | None = None
llm_model: str = "gpt-4o-mini"
llm_base_url: str = "https://api.openai.com/v1"
# Таймаут одного HTTP-вызова к провайдеру (сек). Воркер не должен висеть.
llm_timeout_s: float = 30.0
llm_max_output_tokens: int = 1024
# Верхняя граница числа LLM-вызовов в рамках одной логической операции
# (граддок-extraction / chat-turn) — защита от случайного цикла у консьюмера.
llm_max_calls_per_request: int = 4
# Бюджетный потолок (USD) — пока только логируется как оценка (tokens→$).
# None = без потолка. Жёсткий enforcement добавит консьюмер при необходимости.
llm_daily_cost_cap_usd: float | None = None
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
llm_max_retries: int = 2
settings = Settings()