gendesign/backend/app/core/config.py
Light1YT bc1da8eac9
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m52s
CI / backend-tests (pull_request) Successful in 11m34s
fix(gate): area-gate ЗОУИТ utility-easement blocker (sub-17) — снять over-block
ZOUIT_OVERLAP_SUB17 (единственный subcategory-blocker = охранная зона
ЛЭП/газа/трубопровода) был ХАРД-блокером на ЛЮБОЕ пересечение → блокировал
434/574 (75.6%) участков как «нельзя строить МКД». Прод-замер: реальное
покрытие участка этими зонами median=6%, p90=47%, только 1/17 >60% — тонкие
инж-коридоры. По РФ охранная зона ограничивает застройку ВНУТРИ полосы, а не
весь участок (МКД сажается на незанятой части). Хард-блок на 6%-покрытии неверен
и (после фикса резолва зон #1891) прятал financial_estimate у ~90% участков.

Фикс: area-gate.
- quarter_dump_lookup._get_zouit_overlaps + _get_cad_zouit_overlaps: добавлен
  coverage_pct (доля площади участка под зоной, в geography м², NULLIF-guard).
- gate_verdict.compute_gate_verdict: sub-17 (и cad network/blocker) АГРЕГИРУЮТСЯ
  (sum-capped, консервативно к блоку) → блокер ТОЛЬКО если покрытие >
  settings.gate_zouit_engineering_blocker_min_coverage (0.6, env-tunable), иначе
  WARNING ZOUIT_ENGINEERING_PARTIAL с % покрытия. Больше нет 4× дубль-блокеров.
  СЗЗ/OKN/прочие ЗОУИТ — без изменений (warnings). main_vri/резидентность — без
  изменений.
- coverage_pct отсутствует (legacy/synthetic) → трактуем как 0 → warning
  (документировано: дамп всегда даёт coverage; ложный хард-блок дороже).

Тесты: +помесь gate/aggregation/coverage (131 combined passed); правка
_make_zouit_row (7-я колонка coverage_pct) в tests/test_quarter_dump_lookup.py.
Полный бэкенд локально: 2 failed → fixed → перепроверка. ruff чисто. Схема не
менялась (gate_verdict/overlaps — dict[str,Any], api-types regen не нужен).

Refs #1881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:45:59 +05:00

258 lines
19 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 = ""
# ИРД-слой в 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
# 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()