Merge remote-tracking branch 'forgejo-backend/main' into fix/sf-feedback-batch

This commit is contained in:
bot-backend 2026-06-18 10:01:43 +03:00
commit bcd24923b8
190 changed files with 12669 additions and 3165 deletions

View file

@ -15,23 +15,22 @@ name: CI
# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см. # FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см.
# .github/workflows/ci.yml как образец service-блока. # .github/workflows/ci.yml как образец service-блока.
on: on:
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709).
# WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым
# PR триггерил ДВА прогона на ОДИН SHA: push-событие (github.ref=refs/heads/<branch>)
# и pull_request-событие (github.ref=refs/pull/<N>/merge). Разный github.ref →
# разные concurrency-группы (см. ниже) → прогоны НЕ отменяют друг друга → 2× job
# при и так дефицитных раннерах. В bot-пайплайне каждый коммит идёт через PR, так
# что pull_request гейтит его полностью; push-прогон был чистым дублем.
# Trade-off: push в feature-ветку БЕЗ открытого PR не получит CI до открытия PR
# (бот открывает PR сразу после первого push) — приемлемо.
pull_request: pull_request:
branches: [main] branches: [main]
push:
branches:
# Mirror the bot-PR / feature-branch flow в .claude/rules/git-pr.md:
# PR получает gate, прямые пуши в feature-ветки — тоже.
- "feat/**"
- "fix/**"
- "refactor/**"
- "chore/**"
- "docs/**"
- "perf/**"
- "test/**"
- "ci/**"
- "hotfix/**"
concurrency: concurrency:
# Теперь, когда остался только pull_request, github.ref стабилен на весь PR
# (refs/pull/<N>/merge) → новый push в ветку PR отменяет предыдущий незавершённый
# прогон ЭТОГО PR (cancel-in-progress) вместо накопления параллельных.
group: ci-${{ github.workflow }}-${{ github.ref }} group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@ -89,6 +88,19 @@ jobs:
curl -LsSf https://astral.sh/uv/install.sh | sh curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Cache uv packages
# Кросс-прогонный кэш скачанных/собранных wheel'ов (~/.cache/uv по умолч.).
# `uv sync --frozen` без него каждый прогон тянет весь geo-стек заново —
# доминирующая часть времени job (#1709). Ключ по uv.lock; continue-on-error
# чтобы сбой cache-бэкенда раннера НИКОГДА не ронял gate.
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-
- name: Install system deps for geo + WeasyPrint - name: Install system deps for geo + WeasyPrint
# libpq/gdal/proj/geos — geo-стек (geopandas/shapely/pyproj). # libpq/gdal/proj/geos — geo-стек (geopandas/shapely/pyproj).
# libcairo2/libpango* — нативные либы WeasyPrint: с ними PDF-тесты # libcairo2/libpango* — нативные либы WeasyPrint: с ними PDF-тесты
@ -230,6 +242,16 @@ jobs:
curl -LsSf https://astral.sh/uv/install.sh | sh curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Cache uv packages
# См. backend-tests: кросс-прогонный кэш ~/.cache/uv, тот же ключ по uv.lock.
uses: actions/cache@v4
continue-on-error: true
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('backend/uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-
- name: Install system deps for geo + WeasyPrint - name: Install system deps for geo + WeasyPrint
# app.main транзитивно тянет geo/PDF-модули. На macOS-dev импорт схемы # app.main транзитивно тянет geo/PDF-модули. На macOS-dev импорт схемы
# проходит и без этих либ, но на ubuntu ставим как backend-tests # проходит и без этих либ, но на ubuntu ставим как backend-tests
@ -239,9 +261,12 @@ jobs:
sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \ sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \
libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libpango-1.0-0 libpangoft2-1.0-0
- name: Install backend deps (uv sync --frozen) - name: Install backend deps (uv sync --frozen --no-dev)
working-directory: backend working-directory: backend
run: uv sync --frozen # --no-dev: этот job только дампит app.openapi() (нужен runtime app.main).
# pytest/ruff/coverage не используются → не ставим dev-группу (быстрее).
# Dockerfile тоже собирает с --no-dev → импорт app.main гарантированно ок.
run: uv sync --frozen --no-dev
- name: Install frontend deps (npm ci) - name: Install frontend deps (npm ci)
working-directory: frontend working-directory: frontend

View file

@ -47,6 +47,7 @@ jobs:
- '.forgejo/workflows/deploy-tradein.yml' - '.forgejo/workflows/deploy-tradein.yml'
scraper: scraper:
- 'tradein-mvp/backend/app/services/scrapers/**' - 'tradein-mvp/backend/app/services/scrapers/**'
- 'tradein-mvp/backend/app/services/scrape_pipeline.py'
- 'tradein-mvp/backend/app/services/scheduler.py' - 'tradein-mvp/backend/app/services/scheduler.py'
- 'tradein-mvp/backend/app/scheduler_main.py' - 'tradein-mvp/backend/app/scheduler_main.py'
- 'tradein-mvp/backend/app/tasks/**' - 'tradein-mvp/backend/app/tasks/**'

3
.gitignore vendored
View file

@ -81,3 +81,6 @@ sf_anton_snapshot.db-*
*.csv *.csv
*.csv.zip *.csv.zip
!backend/db/init/*.sql !backend/db/init/*.sql
# Log cruft at repo root
debug.log

102
README.md
View file

@ -1,6 +1,12 @@
# Generative Design + Site Finder # GenDesign — Site Finder + Trade-In «Мера»
Две продуктовые линии в одной воронке: **Site Finder** (AI-подбор инвестиционных земельных участков) + **Generative Design** (автоматическая генерация концепций застройки) для девелоперов РФ. Целевой регион Discovery — Свердловская обл. (ЕКБ, ПЗЗ, МСК-66). Несколько продуктовых линий для девелоперов и риелторов РФ в одном монорепо:
- **Site Finder v2 «Foresight»** — AI-подбор инвестиционных земельных участков + прогноз продуктовой стратегии («GG-форсайт»: спрос/предложение, сценарии, что строить, gate-вердикт по ПЗЗ/ЗОУИТ/ОКН).
- **Trade-In «Мера»** — оценка выкупной стоимости вторичного жилья (адрес → оценка по аналогам и реальным сделкам, PDF-отчёт, white-label). `tradein-mvp/`, отдельный пайплайн.
- **Generative Design** — автоматическая генерация концепций застройки (на ранней стадии).
Целевой регион Discovery — Свердловская обл. (ЕКБ, ПЗЗ, МСК-66).
## North Star ## North Star
@ -14,16 +20,23 @@
| Слой | Статус | | Слой | Статус |
|---|---| |---|---|
| **Site Finder v2 «Foresight»** (`/site-finder`) — анализ участка по cad_num, карта by-bbox, forecast-движок | ✅ live |
| ↳ Forecast-движок (`/parcels/{cad}/forecast` + `/analyze`): спрос/предложение, сценарии, confidence, affordability, rate-sensitivity, продуктовое ТЗ | ✅ (`backend/app/services/forecasting/*`, фронт `Section6Forecast` + `ScenariosBlock` + `ForecastConfidenceBlock`) |
| ↳ Gate-вердикт ПЗЗ/ЗОУИТ/ОКН (`gate_verdict`) + ИРД-overlay/analyze | ✅ backend (`gate_verdict.py`, `ird_overlay_lookup.py`, `ird_analyze.py`); фронт `GateVerdictBanner` |
| ↳ Конкуренты / best-layouts / изохроны (`/parcels/{cad}/competitors`, `/best-layouts`, `/isochrones`) | ✅ live |
| **Trade-In «Мера»** (`/trade-in`) — оценка вторички по аналогам + реальным сделкам, PDF, white-label | ✅ live (`tradein-mvp/`, отдельный стек/пайплайн) |
| ↳ Скрейперы аналогов: Avito / Cian / Domclick / Yandex.Realty (+ детальные парсеры, browser-fetcher) | ✅ |
| ↳ SberIndex time-adjust (city-level вторичный индекс цен) + Rosreestr ДКП-сделки + ДаДата/геокодер | ✅ |
| Аналитический калькулятор `/analytics/recommend` (Tier 3 per-bucket эластичность, кадастр vs рынок) | ✅ | | Аналитический калькулятор `/analytics/recommend` (Tier 3 per-bucket эластичность, кадастр vs рынок) | ✅ |
| DOM.РФ kn-API скрейпер (442 строящихся ЖК Свердл, sale_graph + infra + photos) | ✅ Celery beat + admin UI | | DOM.РФ kn-API скрейпер (строящиеся ЖК Свердл, sale_graph + infra + photos) | ✅ Celery beat + admin UI |
| NSPD geo bulk-fetcher (rosreestr2coord lite, без Playwright) | ✅ admin UI с per-target логами | | NSPD geo bulk-fetcher (rosreestr2coord lite, без Playwright) | ✅ admin UI с per-target логами |
| Rosreestr CSV-сделки 2024Q1—2026Q1 (9 квартирных партиций, 7M строк) | ✅ | | Rosreestr CSV-сделки (квартирные партиции, ~7M строк, 27 мес истории) | ✅ |
| NSPD кадастровые кварталы + здания (709 cad-кварталов, 10 590 cad_buildings ЕКБ) | ✅ Celery beat + admin UI | | NSPD кадастровые кварталы + здания ЕКБ | ✅ Celery beat + admin UI |
| OSM ekb_districts_geom (PostGIS-полигоны 8 районов ЕКБ) | ✅ | | OSM ekb_districts_geom (PostGIS-полигоны районов ЕКБ) | ✅ |
| Objective ETL (Anton SQLite → PG, ports на psycopg v3) | ✅ admin UI | | Objective ETL (Anton SQLite → PG, ports на psycopg v3) | ✅ admin UI |
| Obsidian Self-hosted LiveSync vault на `obsidian.gendsgn.ru` (268 entities из KG) | ✅ multi-device sync | | Uptime Kuma мониторинг (`status.gendsgn.ru`, отдельный стек) | ✅ |
| Concept page (`/concept`) — Stage 1 Generative | 🟡 skeleton | | Obsidian Self-hosted LiveSync vault на `obsidian.gendsgn.ru` | ✅ multi-device sync |
| Site Finder (`/site-finder`) — Stage 2 | 🔴 заглушка | | Concept page (`/concept`) — Generative Design | 🟡 skeleton |
**Backlog:** [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) (трекаемые задачи с PR-связью) + [Obsidian vault](https://obsidian.gendsgn.ru) (планы, ADR, research — см. ниже про разделение). **Backlog:** [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) (трекаемые задачи с PR-связью) + [Obsidian vault](https://obsidian.gendsgn.ru) (планы, ADR, research — см. ниже про разделение).
@ -56,8 +69,13 @@ curl http://localhost:8000/health # → {"status":"ok","environment":"dev"}
Структура монорепо: Структура монорепо:
``` ```
backend/ FastAPI app, services (analytics_queries, scrapers, generative, site_finder), workers (Celery) backend/ FastAPI app, services (analytics_queries, scrapers, generative, site_finder, forecasting), workers (Celery)
frontend/ Next.js app router (analytics, admin, concept, site-finder) frontend/ Next.js app router (analytics, admin, concept, site-finder + forecast/scenarios компоненты)
tradein-mvp/ Trade-In «Мера» — standalone стек (FastAPI + Next.js + Postgres + Caddy), скрейперы вторички, PDF-оценка
site-finder/ standalone pipeline-скрипты SF v1 (0112_*.py загрузка/скоринг) + server.py (Leaflet UI на SQLite-кэше)
auth/ roles.yaml — RBAC (single source of truth, bind-mount в main + tradein backend)
caddy/ users.caddy.snippet — basic-auth snippet для Caddy
preview/ статичные HTML-макеты (analytics / landing / monitoring / site-finder)
data/sql/ ETL артефакты — миграции схемы, загрузчики rosreestr/anton/nspd data/sql/ ETL артефакты — миграции схемы, загрузчики rosreestr/anton/nspd
ops/ backup.sh + deploy скрипты ops/ backup.sh + deploy скрипты
scripts/ утилиты (migrate_kg_to_obsidian.py, setup-couchdb.sh, cleanup_ghosts.py) scripts/ утилиты (migrate_kg_to_obsidian.py, setup-couchdb.sh, cleanup_ghosts.py)
@ -65,10 +83,11 @@ docs/ стратегические снапшоты (акту
memory/ legacy JSONL knowledge graph (deprecated, не обновляется — данные в Obsidian) memory/ legacy JSONL knowledge graph (deprecated, не обновляется — данные в Obsidian)
docker-compose.prod.yml main стек (backend, frontend, postgres, redis, worker, beat, caddy) docker-compose.prod.yml main стек (backend, frontend, postgres, redis, worker, beat, caddy)
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026) .forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
├── ci.yml lint (ruff) + mypy + pytest на PR ├── ci.yml lint (ruff) + mypy + pytest на PR
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy ├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн) ├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations)
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне └── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
.github/workflows/ (остаточные — только obsidian-стек на GitHub) .github/workflows/ (остаточные — только obsidian-стек на GitHub)
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap) └── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
@ -92,6 +111,35 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
--- ---
## Site Finder v2 «Foresight» — что доступно
Анализ инвестиционного участка по кадастровому номеру: карта (`/parcels/by-bbox`), запуск анализа (`POST /parcels/{cad}/analyze`), прогнозный движок (`GET /parcels/{cad}/forecast` + экспорт). Бизнес-логика — `backend/app/services/site_finder/*` и `backend/app/services/forecasting/*`.
- **Forecast-движок** (`forecasting/orchestrator.py`): прогноз спроса/предложения (`demand_supply_forecast`), сценарии (`scenarios`), оценка уверенности (`confidence_engine`), доступность (`affordability`), чувствительность к ставке (`rate_sensitivity`), продуктовый скоринг и «что строить» (`product_scoring`, `what_to_build`). Фронт: `Section6Forecast`, `ScenariosBlock`, `ForecastChart`, `ForecastConfidenceBlock`, `ForecastHorizonsBlock`, `ForecastProductTzBlock`.
- **Gate-вердикт**: проверка регуляторных ограничений ПЗЗ/ЗОУИТ/ОКН (`gate_verdict.py`, `pzz_loader`, `zone_regulation`, `okn_lookup`) → баннер вердикта на фронте (`GateVerdictBanner`).
- **ИРД** (исходно-разрешительная документация): overlay-lookup + анализ на backend (`ird_overlay_lookup.py`, `ird_analyze.py`).
- **Соседство/контекст**: конкуренты (`POST /parcels/{cad}/competitors`), лучшие планировки (`/best-layouts` + PDF), изохроны (`/isochrones`), POI-скоринг, КРТ/резервирование/network-обременения.
`site-finder/` (отдельная папка) — это **standalone-пайплайн SF v1**: скрипты `01_…12_*.py` (загрузка участков, fetch POI, скоринг v1/v2, отчёты) + `server.py` (Leaflet-UI поверх SQLite-кэша POI). Это исследовательский прекурсор прод-движка в `backend/`, не путать с прод-API.
---
## Trade-In «Мера» — оценка вторички
Отдельный продукт в `tradein-mvp/` (standalone стек: FastAPI + Next.js 15 + Postgres + Caddy, свой `deploy-tradein.yml`). Дефолтный бренд переименован «Trade-In» → **«Мера»** (white-label-бренды клиентов резолвятся по slug из таблицы `brands`; URL-пути `/trade-in/*` и code-идентификаторы сохранены).
**Поток:** адрес → геокодер (ДаДата) → подбор аналогов в радиусе (PostGIS `ST_DWithin`) → оценка → PDF-отчёт (WeasyPrint, layout по референсу «Брусника.Обмен»).
- **Источники аналогов**: скрейперы Avito / Cian / Domclick / Yandex.Realty (`backend/app/services/scrapers/*`, с детальными парсерами и `browser_fetcher`).
- **Реальные сделки**: Rosreestr ДКП-поллинг (`rosreestr_poll.py`).
- **Time-adjust**: SberIndex city-level вторичный индекс цен (`sber_index.py`) — приведение разновременных аналогов/сделок к текущему месяцу.
- **Оценка** (`estimator.py`): SQL-агрегация поверх listings + deals, Tukey-фильтр выбросов (1.5×IQR), медиана/Q1/Q3 + confidence; больше **не** mock.
- **API**: `POST /api/v1/trade-in/estimate`, `GET …/estimate/{id}`, `GET …/estimate/{id}/pdf`. Multi-tenant: `/api/v1/brand/{slug}`, RBAC через `auth/roles.yaml`.
Подробнее — [`tradein-mvp/README.md`](tradein-mvp/README.md) и [`tradein-mvp/DEPLOY.md`](tradein-mvp/DEPLOY.md).
---
## Production хостинг ## Production хостинг
**Beget Cloud, Москва, 2 vCPU / 4 GB / 40 GB NVMe.** Стоимость ~1 140 ₽/мес сервер + ~17 ₽/мес домен. **Beget Cloud, Москва, 2 vCPU / 4 GB / 40 GB NVMe.** Стоимость ~1 140 ₽/мес сервер + ~17 ₽/мес домен.
@ -132,20 +180,16 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
--- ---
## Roadmap (12 недель) ## Текущий фокус (июнь 2026)
| Stage | Сроки | Что | Acceptance | Исходный 12-недельный roadmap (Stage 0 → 2c, апрель–июнь) пройден: скелет/CI/прод, NSPD-скрейпер + per-bucket эластичность, ПКК-данные и скоринг участков — всё ✅. Site Finder вышел из стадии «заглушка» в боевой v2-движок, параллельно запущен Trade-In. Активные направления:
|---|---|---|---|
| **0** ✅ | нед. 1 | Скелет монорепо, CI, прод | curl /health = ok |
| **Sprint 1** ✅ | конец апр 2026 | NSPD-скрейпер + per-bucket эластичность + кадастр cross-check + Sentry release tracking | recommend_mix Tier 3 в проде |
| **1a** | нед. 23 | Shapely-сервис, отступы, API-контракт `/concept` ([#5](https://git.gendsgn.ru/lekss361/gendesign/issues/5)) | GeoJSON → 3 заглушки |
| **1b** | нед. 45 | Greedy-алгоритм, 3 стратегии, STRtree ([#12](https://git.gendsgn.ru/lekss361/gendesign/issues/12)) | 3 варианта ≤15с на 1 га |
| **1c** | нед. 67 | ТЭП + финмодель + PDF/Excel/DXF ([#6](https://git.gendsgn.ru/lekss361/gendesign/issues/6)) | Продаваемый артефакт |
| **2a** | нед. 89 | ПКК-парсер + sales_tracker ([#7](https://git.gendsgn.ru/lekss361/gendesign/issues/7)) | ≥1000 участков ЕКБ |
| **2b** | нед. 1011 | Скоринг + API + UI Site Finder ([#8](https://git.gendsgn.ru/lekss361/gendesign/issues/8)) | Карта с цветовой градацией |
| **2c** | нед. 12 | Site Finder ↔ Generative + реестр ПО ([#9](https://git.gendsgn.ru/lekss361/gendesign/issues/9)) | 3 платящих пилота |
Аналитический backlog: [#17](https://git.gendsgn.ru/lekss361/gendesign/issues/17) velocity-anomaly, [#18](https://git.gendsgn.ru/lekss361/gendesign/issues/18) ekb_districts median refresh, [#19](https://git.gendsgn.ru/lekss361/gendesign/issues/19) competitor-360, [#20](https://git.gendsgn.ru/lekss361/gendesign/issues/20) mortgage-rate sensitivity, [#21](https://git.gendsgn.ru/lekss361/gendesign/issues/21) cad_buildings filters в Site Finder. - **Site Finder v2 «Foresight»** ([#944](https://git.gendsgn.ru/lekss361/gendesign/issues/944) GG-форсайт) — ИИ-прогноз продуктовой стратегии девелопера: спрос/предложение, сценарии, confidence, продуктовое ТЗ. Движок и фронт-секции отгружены, продолжается доведение точности и покрытия.
- **ИРД** ([#1067](https://git.gendsgn.ru/lekss361/gendesign/issues/1067)) — исходно-разрешительная документация: overlay + analyze на участке.
- **Trade-In «Мера»** — оценка вторички на реальных данных (скрейперы Avito/Cian/Domclick/Yandex + Rosreestr ДКП + SberIndex), расширение покрытия аналогов и detail-парсинга.
- **Generative Design** (`/concept`) — на ранней стадии (skeleton), доводится после стабилизации SF/Trade-In.
Трекаемый backlog и приоритеты — [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) с лейблами `scope/*`, `вторичка`, `status/*`. North Star — см. секцию выше.
--- ---
@ -212,6 +256,16 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
**Workflow:** тривиально (typo, 1-line) → main session; single-domain → профильный worker; cross-domain → `tech-analyst` сначала. Worker → `code-reviewer` → коммит → push → PR в Forgejo. Branch + PR обязательны, никаких direct push в main. **Workflow:** тривиально (typo, 1-line) → main session; single-domain → профильный worker; cross-domain → `tech-analyst` сначала. Worker → `code-reviewer` → коммит → push → PR в Forgejo. Branch + PR обязательны, никаких direct push в main.
**Автономный bot-loop.** Помимо ручных subagent'ов есть набор автономных персон (`.claude/agents/auto-*.md`, status `draft`), которые крутятся каждая в отдельном Claude Code-окне на `/loop` и двигают задачи через лейблы `status/*` (ready → wip → review → qa → done):
- `auto-analyst` — декомпозирует work-items из vault/feedback в actionable Forgejo issues.
- `auto-backend` / `auto-frontend` — claim issue `scope/*` → ветка + код + push + PR (`Refs #N`, не `Closes`).
- `auto-code-reviewer` — читает diff, выносит verdict, мерджит при APPROVE (merge-authority).
- `auto-qa-tester` — Playwright golden-path по `status/qa`, закрывает issue на `status/done`.
- `auto-resolver` — снимает блокеры `needs-human`, используя capabilities, которых нет у headless-ботов (dev-IP, куки, SSH на прод, прямой доступ к БД).
`stale-claims.yml` авто-снимает протухшие claim-метки. Контракт claim/state-transition — `.claude/agents/_autonomous_pickup.md`.
--- ---
## Полезные ссылки ## Полезные ссылки

View file

@ -5,14 +5,13 @@ from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
# Import the models package so every ORM model registers on Base.metadata.
# Add new model modules in app/models/__init__.py as they appear.
import app.models # noqa: F401
from alembic import context from alembic import context
from app.core.config import settings from app.core.config import settings
from app.core.db import Base from app.core.db import Base
# Import models so they register on Base.metadata.
# Add new model modules here as they appear.
from app.models import parcel # noqa: F401
config = context.config config = context.config
# Inject runtime DB URL. # Inject runtime DB URL.

View file

@ -138,7 +138,7 @@ def leads_stats(
WITH window_leads AS ( WITH window_leads AS (
SELECT * SELECT *
FROM prinzip_leads FROM prinzip_leads
WHERE created_at >= NOW() - (:m || ' months')::interval WHERE created_at >= NOW() - make_interval(months => :m)
) )
SELECT SELECT
(SELECT COUNT(*) FROM prinzip_leads) AS leads_total, (SELECT COUNT(*) FROM prinzip_leads) AS leads_total,
@ -149,8 +149,20 @@ def leads_stats(
2 2
) AS conv_pct_window, ) AS conv_pct_window,
(SELECT COUNT(DISTINCT source) FROM prinzip_leads) AS sources_total, (SELECT COUNT(DISTINCT source) FROM prinzip_leads) AS sources_total,
(SELECT SUM(deal_price) FROM prinzip_deals) AS revenue_total, (
(SELECT COUNT(*) FROM prinzip_deals) AS deals_total SELECT SUM(d.deal_price)
FROM prinzip_deals d
WHERE d.deal_id IN (
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
)
) AS revenue_total,
(
SELECT COUNT(*)
FROM prinzip_deals d
WHERE d.deal_id IN (
SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL
)
) AS deals_total
FROM window_leads FROM window_leads
""" """
), ),

View file

@ -15,7 +15,7 @@ import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -268,7 +268,7 @@ def revoke_task(
def list_failures( def list_failures(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
run_id: int | None = None, run_id: int | None = None,
limit: int = 50, limit: int = Query(default=50, ge=1),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Per-request failure log for manual browser verification.""" """Per-request failure log for manual browser verification."""
where = "" where = ""
@ -314,7 +314,7 @@ def list_logs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
run_id: int | None = None, run_id: int | None = None,
since_id: int | None = None, since_id: int | None = None,
limit: int = 200, limit: int = Query(default=200, ge=0),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Per-run progress events. Use since_id to poll incrementally: """Per-run progress events. Use since_id to poll incrementally:
pass the highest log_id seen returns only newer rows.""" pass the highest log_id seen returns only newer rows."""
@ -483,7 +483,7 @@ def trigger_objective_etl(
@router.get("/objective/runs") @router.get("/objective/runs")
def list_objective_runs( def list_objective_runs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
limit: int = 20, limit: int = Query(default=20, ge=0),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
rows = ( rows = (
db.execute( db.execute(
@ -889,14 +889,20 @@ def bulk_enqueue_geo(
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo") geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
# Валидируем ВСЕ thematic_ids ДО любых сайд-эффектов (создание jobs / apply_async),
# иначе невалидный id в середине списка приводит к partial execution: для предыдущих
# валидных id строки в nspd_geo_jobs уже созданы и задачи улетели в очередь geo,
# а клиент получает 400 без идемпотентного отката (#1562).
invalid_ids = [tid for tid in payload.thematic_ids if tid not in _THEMATIC_META]
if invalid_ids:
raise HTTPException(
status_code=400,
detail=f"thematic_id={invalid_ids} не поддерживается (допустимы: 1, 2, 5)",
)
jobs_summary: list[dict[str, Any]] = [] jobs_summary: list[dict[str, Any]] = []
for thematic_id in payload.thematic_ids: for thematic_id in payload.thematic_ids:
if thematic_id not in _THEMATIC_META:
raise HTTPException(
status_code=400,
detail=f"thematic_id={thematic_id} не поддерживается (допустимы: 1, 2, 5)",
)
meta = _THEMATIC_META[thematic_id] meta = _THEMATIC_META[thematic_id]
# 1) Собрать cad-номера # 1) Собрать cad-номера
@ -967,7 +973,7 @@ def bulk_enqueue_geo(
@router.get("/geo/jobs") @router.get("/geo/jobs")
def list_geo_jobs( def list_geo_jobs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
limit: int = 30, limit: int = Query(default=30, ge=0),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Список последних geo-jobs (для UI dashboard).""" """Список последних geo-jobs (для UI dashboard)."""
rows = ( rows = (
@ -1087,7 +1093,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
def list_all_runs( def list_all_runs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
scraper_type: str | None = None, scraper_type: str | None = None,
limit: int = 30, limit: int = Query(default=30, ge=0),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Унифицированный список прогонов (kn + nspd + objective). """Унифицированный список прогонов (kn + nspd + objective).
@ -1145,7 +1151,7 @@ def list_all_logs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
scraper_type: str | None = None, scraper_type: str | None = None,
run_id: int | None = None, run_id: int | None = None,
limit: int = 200, limit: int = Query(default=200, ge=0),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log.""" """Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
where: list[str] = [] where: list[str] = []
@ -1278,9 +1284,7 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
] ]
def _classify_freshness( def _classify_freshness(age_days: float | None, fresh_days: float, stale_days: float) -> str:
age_days: float | None, fresh_days: float, stale_days: float
) -> str:
"""fresh / stale / critical / unknown по возрасту последнего успешного прогона.""" """fresh / stale / critical / unknown по возрасту последнего успешного прогона."""
if age_days is None: if age_days is None:
return "unknown" return "unknown"
@ -1416,7 +1420,7 @@ def scrape_freshness(
@router.get("/runs") @router.get("/runs")
def list_runs( def list_runs(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
limit: int = 20, limit: int = Query(default=20, ge=0),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
rows = ( rows = (
db.execute( db.execute(

View file

@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
import httpx import httpx
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
from fastapi.concurrency import run_in_threadpool
from shapely import wkt as _shp_wkt from shapely import wkt as _shp_wkt
from shapely.geometry import Polygon from shapely.geometry import Polygon
from sqlalchemy import text from sqlalchemy import text
@ -77,6 +78,7 @@ from app.services.site_finder.quarter_dump_lookup import (
get_quarter_dump_data, get_quarter_dump_data,
make_empty_result, make_empty_result,
) )
from app.services.site_finder.riasurt_lookup import parcel_riasurt_gate
from app.services.site_finder.saturation import compute_district_saturation from app.services.site_finder.saturation import compute_district_saturation
from app.services.site_finder.velocity import compute_velocity from app.services.site_finder.velocity import compute_velocity
from app.services.site_finder.weight_profiles import ( from app.services.site_finder.weight_profiles import (
@ -145,10 +147,17 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
def _score_label(s: float) -> str: def _score_label(s: float) -> str:
"""Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ.""" """Текстовая интерпретация POI-score по эмпирическим порогам ЕКБ.
Шкала: <5 плохо, 5-15 средне, 15-25 хорошо, >=25 отлично.
"""
if s < SCORE_THRESHOLDS["плохо"]:
return "плохо"
if s < SCORE_THRESHOLDS["средне"]: if s < SCORE_THRESHOLDS["средне"]:
return "плохо" if s < SCORE_THRESHOLDS["плохо"] else "средне" return "средне"
return "хорошо" if s < SCORE_THRESHOLDS["отлично"] else "отлично" if s < SCORE_THRESHOLDS["хорошо"]:
return "хорошо"
return "отлично"
def _confidence_label(c: float) -> str: def _confidence_label(c: float) -> str:
@ -821,11 +830,20 @@ def _compute_confidence(
market_trend: dict[str, Any] | None, market_trend: dict[str, Any] | None,
zoning: dict[str, Any], zoning: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""X2 (#48) — composite confidence score 0..1 + caveats. """X2 (#48) — composite confidence score 0..1 + caveats для site-finder analyze.
Stub-версия (до реализации G1/G2/D1/D2): использует сигналы которые уже Это НАДЁЖНОСТЬ САМОГО SITE-FINDER СКОРИНГА (coverage/свежесть входных
доступны на main. Композитный балл = avg of subscore'ов; caveats — list сигналов: POI, район, рынок, конкуренты, источник геометрии) отдельная
конкретных проблем для UI ("Нет данных N, score K ненадёжен"). by-design метрика, НЕ форсайтный §15 `forecasting.confidence_engine`
(`compute_report_confidence`). §15 оценивает надёжность ПРОГНОЗА
спроса/цены и работает над форсайт-входами (coverage/confounded/
special_indices/sales_series), которых в analyze hot-path нет поэтому
analyze намеренно держит свою лёгкую coverage-метрику, а не зовёт §15.
Композитный балл = avg of subscore'ов; caveats — list конкретных проблем
для UI ("Нет данных N, score K ненадёжен"). См. #1668 (решение: оставить
раздельно by-design; «stub до G1/G2/D1/D2» устаревшая формулировка,
те эшелоны уже отгружены, их confidence сюда не вплетается намеренно).
""" """
caveats: list[str] = [] caveats: list[str] = []
subscores: dict[str, float] = {} subscores: dict[str, float] = {}
@ -2177,8 +2195,44 @@ def analyze_parcel(
except Exception as e: except Exception as e:
logger.warning("red_lines_block query failed for %s: %s", cad_num, e) logger.warning("red_lines_block query failed for %s: %s", cad_num, e)
# B5-4) Metro placeholder — заполнится после merge 22h metro scraper # B5-4) Metro — ближайшие 3 станции метро к участку.
metro_block: dict[str, Any] = {"nearest_top3": None} # Данные уже в osm_poi_ekb (category='metro_stop', грузятся poi_loader.py:44),
# скрапер не нужен — прямой KNN-запрос по geom <-> centroid участка.
# nearest_top3=[] (НЕ None) когда метро не найдено: отличаем "посчитано, рядом
# нет" от "не реализовано". None только при ошибке запроса.
metro_block: dict[str, Any] = {"nearest_top3": []}
try:
with db.begin_nested():
metro_rows = (
db.execute(
text("""
SELECT name,
ST_Distance(
m.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS dist_m
FROM osm_poi_ekb m
WHERE m.category = 'metro_stop'
ORDER BY m.geom <-> ST_Centroid(ST_GeomFromText(:wkt, 4326))
LIMIT 3
"""),
{"wkt": geom_wkt},
)
.mappings()
.all()
)
metro_block = {
"nearest_top3": [
{
"name": mr["name"],
"distance_m": round(float(mr["dist_m"])) if mr["dist_m"] is not None else None,
}
for mr in metro_rows
]
}
except Exception as e:
logger.warning("metro_block query failed for %s: %s", cad_num, e)
metro_block = {"nearest_top3": None}
# B5-5) District price ranges из objective_lots (SF-B5) # B5-5) District price ranges из objective_lots (SF-B5)
district_price_block: dict[str, Any] = { district_price_block: dict[str, Any] = {
@ -2921,7 +2975,75 @@ def analyze_parcel(
"risks": risks_block, "risks": risks_block,
} }
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs. # #1561: forecast/ird/developer_attribution дописываются в result_payload ДО persist —
# иначе persist_analysis_run сериализует jsonb-снимок (repository.py:124) и коммитит
# (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
# (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
# §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
# jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
# enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
# ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
# посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
# передаётся в persist by-reference; если enqueue провалится после persist —
# перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
# снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
# источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
if settings.enable_ird_analyze:
try:
result_payload["ird"] = build_ird_analyze_block(
db, geom_wkt, centroid_lon, centroid_lat, cad_num
)
except Exception:
logger.warning(
"ird block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
# РИАСУРТ Свердл gate (#108, multi-city scaling): для участков АГЛОМЕРАЦИИ ЕКБ
# (окраины — Берёзовский/В.Пышма/Среднеуральск/Арамиль/Сысерть), НЕ ЕКБ-сити (66:41).
# Flag-gated (default off): таблица riasurt_sverdl наполняется post-deploy harvest'ом на
# уточнённых bbox МО. Полностью graceful (parcel_riasurt_gate сам отдаёт пустой gate при
# отсутствии таблицы / вне агломерации) — сбой не меняет успех/форму ответа (extra="allow").
# Ключ добавляется только когда gate применим (applicable=True) — иначе не шумим.
if settings.enable_riasurt_gate:
try:
_riasurt_gate = parcel_riasurt_gate(db, geom_wkt, cad_num)
if _riasurt_gate.get("applicable"):
result_payload.setdefault("gate", {})["riasurt"] = _riasurt_gate
except Exception:
logger.warning(
"riasurt gate failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
# Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
# (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
# резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
# сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
# try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
# добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
try:
_dev_attr = get_developer_attribution(db, cad_num)
if _dev_attr is not None:
result_payload["developer_attribution"] = _dev_attr.model_dump()
except Exception:
logger.warning(
"developer_attribution block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs — ПОСЛЕ дописывания
# forecast/ird/developer_attribution, чтобы jsonb-снимок совпадал с live-ответом (#1561).
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет # Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию. # форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
# district денормализуем из result["district"]["district_name"] (для фильтрации # district денормализуем из result["district"]["district_name"] (для фильтрации
@ -2952,20 +3074,22 @@ def analyze_parcel(
created_by=x_authenticated_user, created_by=x_authenticated_user,
) )
# §22-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue после persist. # §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run —
# Таска `forecast_site_finder_report` читает только что сохранённый analyze-1.0 # единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue
# ран и в фоне (~30-180s) считает §22 SiteFinderReport ('1.0'). analyze НЕ ждёт # должен случиться только после того, как ран закоммичен: иначе фоновая таска
# её — возвращаемся сразу. Celery/Redis down НЕ должен валить ответ (он уже успешен: # `forecast_site_finder_report` (~30-180s) прочтёт latest_run_for и не найдёт свежий
# ран (None/старый) → форсайт молча не посчитается, ретраев нет (#1561-followup).
# Best-effort fire-and-forget: Celery/Redis down НЕ валит ответ (он уже успешен,
# frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch. # frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте. # Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
try: try:
from app.workers.tasks.forecast import forecast_site_finder_report from app.workers.tasks.forecast import forecast_site_finder_report
forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user) forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
except Exception: except Exception:
# Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory, # Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
# клиент узнаёт по status="unavailable" и не будет зря поллить /forecast. # клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
# persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана).
logger.warning( logger.warning(
"forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected", "forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
cad_num, cad_num,
@ -2974,40 +3098,6 @@ def analyze_parcel(
) )
result_payload["forecast"] = {"status": "unavailable", "horizon": horizon} result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
# источники 2-3 — live-зависимость от внешнего геопортала в hot-пути. Полностью graceful —
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
if settings.enable_ird_analyze:
try:
result_payload["ird"] = build_ird_analyze_block(
db, geom_wkt, centroid_lon, centroid_lat, cad_num
)
except Exception:
logger.warning(
"ird block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
# Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
# (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
# резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
# сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
# try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
# добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
try:
_dev_attr = get_developer_attribution(db, cad_num)
if _dev_attr is not None:
result_payload["developer_attribution"] = _dev_attr.model_dump()
except Exception:
logger.warning(
"developer_attribution block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
return result_payload return result_payload
@ -3249,8 +3339,10 @@ async def get_parcel_competitors(
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
участка с рассчитанным velocity_per_month за указанный time_window. участка с рассчитанным velocity_per_month за указанный time_window.
""" """
# sync get_competitors (несколько db.execute, competitors.py:518) мостится через
# run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
try: try:
return get_competitors(db=db, cad_num=cad_num, request=body) return await run_in_threadpool(get_competitors, db=db, cad_num=cad_num, request=body)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@ -3303,8 +3395,10 @@ async def get_parcel_best_layouts(
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
× objective_complex_mapping). × objective_complex_mapping).
""" """
# sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
# run_in_threadpool — иначе sync DB-IO блокирует event loop.
try: try:
return get_best_layouts(db=db, cad_num=cad_num, request=body) return await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@ -3322,9 +3416,12 @@ async def get_parcel_best_layouts_pdf(
Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера. Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
""" """
# sync get_best_layouts (DB-IO) + render_layout_tz_pdf (CPU-bound WeasyPrint
# write_pdf, сотни мс) мостятся через run_in_threadpool — иначе блокируют event loop.
try: try:
response = get_best_layouts(db=db, cad_num=cad_num, request=body) response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
pdf_bytes = render_layout_tz_pdf( pdf_bytes = await run_in_threadpool(
render_layout_tz_pdf,
response, response,
cad_num=cad_num, cad_num=cad_num,
radius_km=body.radius_km, radius_km=body.radius_km,

View file

@ -111,16 +111,25 @@ def get_photo(
data = _fetch_upstream(upstream) data = _fetch_upstream(upstream)
if data: if data:
src = _persist_original(obj_id, file_id, photo_name, data) src = _persist_original(obj_id, file_id, photo_name, data)
# Record the original immediately so a failed thumbnail does not
# orphan the on-disk file and trigger an eternal re-fetch.
db.execute(
text(
"UPDATE domrf_kn_photos"
" SET local_path = :lp, downloaded_at = NOW()"
" WHERE obj_id = :o AND obj_file_id = :f"
),
{"lp": str(src), "o": obj_id, "f": file_id},
)
db.commit()
generated = make_thumbnail(src) generated = make_thumbnail(src)
if generated and generated.exists(): if generated and generated.exists():
db.execute( db.execute(
text( text(
"UPDATE domrf_kn_photos" "UPDATE domrf_kn_photos SET thumb_path = :tp"
" SET local_path = :lp, thumb_path = :tp,"
" downloaded_at = NOW()"
" WHERE obj_id = :o AND obj_file_id = :f" " WHERE obj_id = :o AND obj_file_id = :f"
), ),
{"lp": str(src), "tp": str(generated), "o": obj_id, "f": file_id}, {"tp": str(generated), "o": obj_id, "f": file_id},
) )
db.commit() db.commit()
return FileResponse(str(generated), media_type="image/webp", headers=headers) return FileResponse(str(generated), media_type="image/webp", headers=headers)

View file

@ -344,7 +344,7 @@ def estimate_pdf(
if row is None: if row is None:
raise HTTPException(status_code=404, detail="estimate not found") raise HTTPException(status_code=404, detail="estimate not found")
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): if row.expires_at.astimezone(UTC) < datetime.now(tz=UTC):
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)") raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
analogs = [AnalogLot(**a) for a in (row.analogs or [])] analogs = [AnalogLot(**a) for a in (row.analogs or [])]

View file

@ -190,6 +190,23 @@ class Settings(BaseSettings):
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058). # возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
enable_ird_analyze: bool = True 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
# ── LLM infrastructure (#960) ──────────────────────────────────────────── # ── LLM infrastructure (#960) ────────────────────────────────────────────
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не # ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный # зависит от LLM — при любом сбое/выключенности возвращается детерминированный

View file

@ -59,6 +59,7 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate, traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0, profiles_sample_rate=0.0,
send_default_pii=False, send_default_pii=False,
before_send=scrub_sensitive_query,
before_send_transaction=scrub_sensitive_query, before_send_transaction=scrub_sensitive_query,
integrations=[ integrations=[
StarletteIntegration(), StarletteIntegration(),

View file

@ -0,0 +1,15 @@
"""ORM model registry.
Importing this package loads every SQLAlchemy model module so the classes
register on `Base.metadata`. Alembic (alembic/env.py) imports `app.models`
to populate `target_metadata` for autogenerate / drift detection.
Add new ORM model modules to the imports below as they appear. Tables managed
by raw SQL (data/sql/*.sql, e.g. cad_parcels / cad_parcels_geom) have no ORM
model by design and intentionally stay out of Base.metadata.
"""
from app.core.db import Base
from app.models import job_settings, parcel # noqa: F401
__all__ = ["Base"]

View file

@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text, text
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@ -20,6 +20,8 @@ class JobSetting(Base):
max_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False) max_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False)
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False) max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False) extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=text("now()"), nullable=False
)
updated_by: Mapped[str | None] = mapped_column(Text, nullable=True) updated_by: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)

View file

@ -1,29 +1,20 @@
"""SQLAlchemy + GeoAlchemy2 ORM models. """SQLAlchemy ORM models for parcel data.
Stage 2a: real Parcel model. Geometry stored in WGS84 (EPSG:4326); NB: реальные данные участков живут в таблицах ``cad_parcels`` /
project to МСК-66 via pyproj when computing distances/areas. ``cad_parcels_geom`` (см. ``data/sql/92_cad_bulk_layers.sql`` и
``data/sql/83_cad_parcels_geom.sql``) и читаются через сырые PostGIS-запросы
(``app.services.site_finder.filters``, ``app.api.v1.parcels``), не через ORM.
Прежняя ORM-модель ``Parcel`` (таблица ``parcels``) удалена: соответствующего
DDL в ``data/sql/`` нет, ни один ORM-запрос её не использовал, а её колонки
(в т.ч. ``geometry(POLYGON, 4326)``) расходились с реальной схемой
``cad_parcels.geom`` которую миграция 93 уже перевела на ``MultiPolygon``,
т.к. НСПД отдаёт многоконтурные участки. Единственным её потребителем был
alembic autogenerate (``alembic/env.py``), для которого она порождала
фантомный ``CREATE TABLE parcels``.
Модуль сохранён (его импортирует ``alembic/env.py``), чтобы новые ORM-модели
регистрировались на ``Base.metadata`` именно отсюда.
""" """
from datetime import datetime from app.core.db import Base # noqa: F401 (re-export для регистрации будущих моделей)
from geoalchemy2 import Geometry
from sqlalchemy import JSON, DateTime, Float, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
class Parcel(Base):
__tablename__ = "parcels"
id: Mapped[str] = mapped_column(String, primary_key=True)
cadastral_number: Mapped[str] = mapped_column(String, unique=True, index=True)
vri: Mapped[str] = mapped_column(String, index=True)
area_sqm: Mapped[float] = mapped_column(Float)
address: Mapped[str | None] = mapped_column(String, nullable=True)
geometry: Mapped[object] = mapped_column(Geometry("POLYGON", srid=4326))
enrichment: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)

View file

@ -15,7 +15,7 @@ from __future__ import annotations
from enum import StrEnum from enum import StrEnum
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2). # История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
# Кэпируем длину, чтобы payload не раздувался до подключения LLM. # Кэпируем длину, чтобы payload не раздувался до подключения LLM.
@ -84,10 +84,21 @@ class ChatAskRequest(BaseModel):
) )
history: list[ChatTurn] | None = Field( history: list[ChatTurn] | None = Field(
default=None, default=None,
max_length=_HISTORY_MAX_TURNS,
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)", description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
) )
@field_validator("history")
@classmethod
def _cap_history(cls, v: list[ChatTurn] | None) -> list[ChatTurn] | None:
"""Graceful-усечение: кэпируем хвост до последних _HISTORY_MAX_TURNS ходов.
НЕ отклоняем длинный диалог 422 (контракт обещает усечение, не отказ)
оставляем самые свежие ходы, отбрасывая старые с головы.
"""
if v is not None and len(v) > _HISTORY_MAX_TURNS:
return v[-_HISTORY_MAX_TURNS:]
return v
class ChatAskResponse(BaseModel): class ChatAskResponse(BaseModel):
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст). """Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).

View file

@ -12,7 +12,9 @@ class ConceptInput(BaseModel):
housing_class: Literal["econom", "comfort", "business"] = "comfort" housing_class: Literal["econom", "comfort", "business"] = "comfort"
target_floors: int = Field(9, ge=1, le=30) target_floors: int = Field(9, ge=1, le=30)
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise" development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
land_cost_rub: float | None = Field(None, description="Optional land cost for financial model") land_cost_rub: float | None = Field(
None, ge=0, description="Optional land cost for financial model"
)
class TEAP(BaseModel): class TEAP(BaseModel):

View file

@ -302,10 +302,18 @@ class NSPDBulkClient:
try: try:
data = await self._get_json(NSPD_SEARCH_URL, params=params) data = await self._get_json(NSPD_SEARCH_URL, params=params)
except (NspdBulkWafError, NspdBulkRateLimitError, NspdBulkServerError):
# 403 WAF / 429 / 5xx+ServiceException — НЕ «квартал не найден».
# Пробрасываем как есть: caller (autoretry) ретраит квартал, WAF
# останавливает harvest. Раньше подстрочная классификация по str(e)
# с подмешанным body_preview могла ложно проглотить 5xx как 404.
raise
except NspdBulkError as e: except NspdBulkError as e:
# 404 или пустой ответ → возвращаем пустой snapshot # Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
# Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
# ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
err_str = str(e) err_str = str(e)
if "HTTP 404" in err_str or "HTTP 400" in err_str: if err_str.startswith("HTTP 404:") or err_str.startswith("HTTP 400:"):
logger.info( logger.info(
"search_by_quarter: quarter %s not found (404/400), returning empty" "search_by_quarter: quarter %s not found (404/400), returning empty"
" (category_id=%s)", " (category_id=%s)",
@ -342,8 +350,23 @@ class NSPDBulkClient:
for m in raw_meta: for m in raw_meta:
cat_id = m.get("categoryId") cat_id = m.get("categoryId")
total = m.get("totalCount") total = m.get("totalCount")
if cat_id is not None and total is not None: if cat_id is None or total is None:
continue
# NSPD изредка отдаёт categoryId/totalCount нечисловой/float-строкой
# ('20.0') или иным типом → голый int() кинул бы ValueError/TypeError,
# который НЕ подкласс NspdBulkError и завалил бы всю Phase 1 квартала.
# Зеркалит защитный try/except в schemas/nspd_bulk.py и
# list_objects_in_building. Битую meta-запись просто пропускаем.
try:
meta_counts[int(cat_id)] = int(total) meta_counts[int(cat_id)] = int(total)
except (ValueError, TypeError):
logger.warning(
"search_by_quarter: non-numeric meta entry quarter=%s"
" categoryId=%r totalCount=%r — skipping",
quarter,
cat_id,
total,
)
logger.info( logger.info(
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d", "search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
@ -416,7 +439,19 @@ class NSPDBulkClient:
} }
data = await self._get_json(url, params=params) data = await self._get_json(url, params=params)
raw_features: list[dict[str, Any]] = (data or {}).get("features") or [] # NSPD/GeoServer изредка отдаёт валидный JSON, но не объект (list/str —
# Bug_Nspd_Geo_Str_Object). Тогда `(data or {})` вернул бы сам truthy
# data, а .get("features") кинул бы AttributeError (не NspdBulkError →
# уронил бы ячейку grid-walk без сигнала). Унифицируем guard с
# search_by_quarter: не-dict трактуем как пустой ответ.
if not isinstance(data, dict):
logger.warning(
"wms_feature_info: non-dict JSON response layer=%d type=%s — returning empty",
layer_id,
type(data).__name__,
)
return []
raw_features: list[dict[str, Any]] = data.get("features") or []
return [NSPDBulkFeature.model_validate(f) for f in raw_features] return [NSPDBulkFeature.model_validate(f) for f in raw_features]
# ── 3. get_features_in_bbox_grid ───────────────────────────────────────── # ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
@ -473,9 +508,39 @@ class NSPDBulkClient:
seen_ids: set[str] = set() seen_ids: set[str] = set()
results: list[dict] = [] results: list[dict] = []
# Issue #252-mirror: считаем server-side провалы и успешные ячейки, чтобы
# отличить «слой реально пуст» (ok_cells>0, 0 features) от «слой/IP лёг»
# (все ячейки 5xx/WAF). Раньше любой Exception молча падал на DEBUG и метод
# отдавал [] → в БД писался ложный tz_count=0 без layer_failed-сигнала.
server_errors = 0
ok_cells = 0
first_server_error: NspdBulkServerError | None = None
for idx, cell_result in enumerate(cell_results): for idx, cell_result in enumerate(cell_results):
if isinstance(cell_result, NspdBulkWafError):
# 403 WAF (бан IP) — по docstring должен ОСТАНОВИТЬ harvest, не
# маскироваться пустым результатом. Пробрасываем немедленно.
logger.warning(
"get_features_in_bbox_grid: layer=%d cell=%d WAF 403 — aborting grid-walk: %s",
layer_id,
idx,
cell_result,
)
raise cell_result
if isinstance(cell_result, NspdBulkServerError):
server_errors += 1
if first_server_error is None:
first_server_error = cell_result
logger.debug(
"get_features_in_bbox_grid: layer=%d cell=%d server error: %s",
layer_id,
idx,
cell_result,
)
continue
if isinstance(cell_result, Exception): if isinstance(cell_result, Exception):
# Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
# считаем server-side fail (иначе сеть ложно triggers layer_failed).
logger.debug( logger.debug(
"get_features_in_bbox_grid: layer=%d cell=%d error: %s", "get_features_in_bbox_grid: layer=%d cell=%d error: %s",
layer_id, layer_id,
@ -483,6 +548,7 @@ class NSPDBulkClient:
cell_result, cell_result,
) )
continue continue
ok_cells += 1
for feature in cell_result: for feature in cell_result:
fid = str(feature.id) if feature.id is not None else "" fid = str(feature.id) if feature.id is not None else ""
if fid and fid in seen_ids: if fid and fid in seen_ids:
@ -497,6 +563,21 @@ class NSPDBulkClient:
} }
) )
# Если БЫЛИ server-side провалы И ни одна ячейка не прошла — слой/NSPD лёг
# целиком. Возврат [] здесь означал бы ложный tz_count=0 («зонирование
# отсутствует»). Пробрасываем server-error, чтобы caller отличил сбой от
# реально пустого слоя (мирроринг _grid_walk_category.layer_failed).
if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
logger.warning(
"get_features_in_bbox_grid: layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
"(%d server errors, 0 ok cells) — raising вместо ложного пустого результата",
layer_id,
grid_n,
grid_n,
server_errors,
)
raise first_server_error
logger.info( logger.info(
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d", "get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
layer_id, layer_id,

View file

@ -64,8 +64,15 @@ def _jsonb_param(value: Any) -> str:
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы; jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
json.dumps(..., ensure_ascii=False) кириллица как есть (зеркало pzz_loader). json.dumps(..., ensure_ascii=False) кириллица как есть (зеркало pzz_loader).
allow_nan=False (#1580): дефолтный allow_nan=True выводит нестандартные литералы
NaN/Infinity/-Infinity, которые JSONB-парсер PostgreSQL отвергает ("invalid input
syntax for type json") → INSERT падает, а broad-except в persist_analysis_run
проглатывает это и теряет ран молча. С allow_nan=False json.dumps бросает ValueError
ДО SQL провал детерминирован и виден в logger.exception (с traceback), а не
маскируется под невнятную psycopg-ошибку синтаксиса.
""" """
return json.dumps(jsonable_encoder(value), ensure_ascii=False) return json.dumps(jsonable_encoder(value), ensure_ascii=False, allow_nan=False)
def persist_analysis_run( def persist_analysis_run(

View file

@ -24,11 +24,12 @@ list rather than silently ignored.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -54,6 +55,11 @@ _SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)
# as unsupported. # as unsupported.
_SUPPORTED_SUBJECT = "66" _SUPPORTED_SUBJECT = "66"
# ARN period_value format (matches mv_ddu_price_indicator.period_value, e.g.
# '2026-Q1'). Used to reject malformed bounds whose lexicographic comparison
# against well-formed period_value would silently drop rows.
_PERIOD_RE = re.compile(r"^\d{4}-Q[1-4]$")
def _f(value: Any) -> float | None: def _f(value: Any) -> float | None:
if value is None: if value is None:
@ -148,6 +154,50 @@ def get_ddu_indicator(
if clean_buckets: if clean_buckets:
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))" bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
params["buckets"] = clean_buckets params["buckets"] = clean_buckets
else:
# Client narrowed by area buckets but every id is out of range (0..6)
# → honour the explicit narrowing with an empty result, never silently
# widen back to all buckets. Mirrors the `not subject_ok` branch above.
notes.append(
f"areaRanges={area_ranges} вне диапазона 0..6 "
f"(0=все площади, 1..6=диапазоны м²) — нет подходящих площадей."
)
return {
"meta": {
"market": "primary_ddu",
"region_code": int(_SUPPORTED_SUBJECT),
"calculation_method": method,
"period_type": "Q",
},
"table": [],
"graph": [],
"notes": notes,
}
# Validate period bounds against the documented ARN 'YYYY-QN' format before
# binding them. period_value is compared lexicographically (it is text); a
# malformed bound ('foo', '2026') would silently drop rows, so drop the bad
# bound and explain it in notes (this endpoint's convention is notes, not 422).
if period_from and not _PERIOD_RE.match(period_from):
notes.append(
f"periodFrom={period_from!r} не в формате 'YYYY-QN' (напр. '2025-Q2') "
f"— граница проигнорирована."
)
period_from = None
if period_to and not _PERIOD_RE.match(period_to):
notes.append(
f"periodTo={period_to!r} не в формате 'YYYY-QN' (напр. '2026-Q1') "
f"— граница проигнорирована."
)
period_to = None
# Inverted range (from > to) yields an empty table with no signal otherwise.
# Lexicographic comparison is correct here because the format is zero-padded
# 'YYYY-QN'.
if period_from and period_to and period_from > period_to:
notes.append(
f"periodFrom={period_from!r} > periodTo={period_to!r} — границы "
f"перепутаны (диапазон инвертирован), результат пуст."
)
period_filter = "" period_filter = ""
if period_from: if period_from:
@ -184,8 +234,10 @@ def get_ddu_indicator(
.mappings() .mappings()
.all() .all()
) )
except OperationalError: except (ProgrammingError, OperationalError):
# Most likely the MV does not exist yet (migration 152 not applied). # Most likely the MV does not exist yet (migration 152 not applied).
# A missing relation is SQLSTATE 42P01 (UndefinedTable) → ProgrammingError;
# OperationalError is kept for connection-level failures.
logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)") logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
raise raise

View file

@ -186,7 +186,7 @@ def detect_velocity_anomalies(
AVG(realised) FILTER (WHERE rn <= :recent_window) AS recent_mean, AVG(realised) FILTER (WHERE rn <= :recent_window) AS recent_mean,
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean, AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std, STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std,
COUNT(*) FILTER (WHERE rn > :recent_window) AS prior_n COUNT(realised) FILTER (WHERE rn > :recent_window) AS prior_n
FROM ranked FROM ranked
GROUP BY obj_id, n_months GROUP BY obj_id, n_months
), ),

View file

@ -79,46 +79,83 @@ def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]: def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
"""source: 'portfolio' (что строится) or 'deals' (реально покупают).""" """source: 'portfolio' (что строится) or 'deals' (реально покупают).
Оба источника возвращают одни и те же 5 area-бакетов, выровненных с осью
QuartirographyChart ("Студии 15-30" / "1-к 30-45" / "2-к 45-60" /
"3-к 60-80" / "80+ м²").
Портфель: domrf_flat_area_distribution (RF-wide snapshot, region_id=0 т.к.
API DOM.РФ игнорирует ?regionId на этом эндпоинте). Бакеты DOM.РФ (FROM_0_TO_25
FROM_100) маппируются на chart-бакеты по средней площади:
FROM_0_TO_25 (~22 м²) "Студии 15-30"
FROM_25_TO_35 (~31 м², мелкие однушки) "1-к 30-45"
FROM_35_TO_45 (~40 м²) "1-к 30-45"
FROM_45_TO_55 (~50 м²) "2-к 45-60"
FROM_55_TO_70 (~62 м²) "3-к 60-80"
FROM_70_TO_85 (~77 м²) "3-к 60-80"
FROM_85_TO_100 (~92 м²) "80+ м²"
FROM_100 (100+ м²) "80+ м²"
"""
if source == "portfolio": if source == "portfolio":
rows = ( rows = (
db.execute( db.execute(
text( text(
""" """
SELECT room_count_type, flat_count, area_sqm, percent WITH latest AS (
FROM domrf_region_aggregates SELECT MAX(snapshot_date) AS snap
WHERE region_id = :region_id FROM domrf_flat_area_distribution
AND snapshot_date = ( WHERE region_id = 0
SELECT MAX(snapshot_date) ),
FROM domrf_region_aggregates mapped AS (
WHERE region_id = :region_id SELECT
) CASE area_bucket
AND room_count_type <> 'TOTAL' WHEN 'FROM_0_TO_25' THEN '1-Студия'
ORDER BY CASE room_count_type WHEN 'FROM_25_TO_35' THEN '2-1-к'
WHEN 'ONE' THEN 1 WHEN 'FROM_35_TO_45' THEN '2-1-к'
WHEN 'TWO' THEN 2 WHEN 'FROM_45_TO_55' THEN '3-2-к'
WHEN 'THREE' THEN 3 WHEN 'FROM_55_TO_70' THEN '4-3-к'
WHEN 'FOUR' THEN 4 WHEN 'FROM_70_TO_85' THEN '4-3-к'
END WHEN 'FROM_85_TO_100' THEN '5-80+ м²'
WHEN 'FROM_100' THEN '5-80+ м²'
END AS chart_bucket,
flat_count,
area_sqm
FROM domrf_flat_area_distribution
CROSS JOIN latest
WHERE region_id = 0
AND room_count_type = 'TOTAL'
AND snapshot_date = latest.snap
)
SELECT chart_bucket,
SUM(flat_count)::bigint AS flat_count,
SUM(area_sqm) AS area_sqm
FROM mapped
WHERE chart_bucket IS NOT NULL
GROUP BY chart_bucket
ORDER BY chart_bucket
""" """
), ),
{"region_id": region_id}, {},
) )
.mappings() .mappings()
.all() .all()
) )
_pretty = {
"1-Студия": "Студии 15-30",
"2-1-к": "1-к 30-45",
"3-2-к": "2-к 45-60",
"4-3-к": "3-к 60-80",
"5-80+ м²": "80+ м²",
}
total_flats = sum(r["flat_count"] or 0 for r in rows) or 1
return [ return [
{ {
"bucket": { "bucket": _pretty[r["chart_bucket"]],
"ONE": "1-к", "flat_count": int(r["flat_count"] or 0),
"TWO": "2-к",
"THREE": "3-к",
"FOUR": "4+",
}.get(r["room_count_type"], r["room_count_type"]),
"flat_count": r["flat_count"],
"area_sqm": _f(r["area_sqm"]), "area_sqm": _f(r["area_sqm"]),
"percent": r["percent"], "percent": round((r["flat_count"] or 0) * 100 / total_flats, 1),
"avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None, "avg_area": (_f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None),
} }
for r in rows for r in rows
] ]
@ -147,8 +184,7 @@ def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[s
-- ('2025-07-01' расширял «recent»-окно каждую неделю по мере -- ('2025-07-01' расширял «recent»-окно каждую неделю по мере
-- доливки ETL новых report_months перекос в сторону всё -- доливки ETL новых report_months перекос в сторону всё
-- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL. -- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
AND period_start_date >= NOW() AND period_start_date >= NOW() - make_interval(months => :months_window)
- (:months_window || ' months')::INTERVAL
), ),
bucketed AS ( bucketed AS (
SELECT CASE SELECT CASE
@ -1132,7 +1168,7 @@ def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]
""" """
SELECT month, source, leads, engaged, converted, conv_pct SELECT month, source, leads, engaged, converted, conv_pct
FROM prinzip_funnel_monthly FROM prinzip_funnel_monthly
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
ORDER BY month DESC, leads DESC ORDER BY month DESC, leads DESC
""" """
), ),
@ -1166,7 +1202,7 @@ def prinzip_funnel_by_source(db: Session, months: int = 12) -> list[dict[str, An
SUM(converted) AS converted, SUM(converted) AS converted,
ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
FROM prinzip_funnel_monthly FROM prinzip_funnel_monthly
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date WHERE month >= (CURRENT_DATE - make_interval(months => :months))::date
GROUP BY source GROUP BY source
ORDER BY leads DESC ORDER BY leads DESC
""" """
@ -1327,8 +1363,7 @@ _BUCKET_SQL = text(
AND deal_count > 0 AND deal_count > 0
AND (area / deal_count) BETWEEN 15 AND 200 AND (area / deal_count) BETWEEN 15 AND 200
AND price_per_sqm BETWEEN 30000 AND 1000000 AND price_per_sqm BETWEEN 30000 AND 1000000
AND period_start_date >= NOW() AND period_start_date >= NOW() - make_interval(months => :months_window)
- (:months_window || ' months')::INTERVAL
), ),
bucketed AS ( bucketed AS (
SELECT CASE SELECT CASE
@ -1958,7 +1993,7 @@ def _elasticity_coef(
{where_district} {where_district}
{where_class} {where_class}
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0 AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
AND crm.report_month >= NOW() - (:ew || ' months')::interval AND crm.report_month >= NOW() - make_interval(months => :ew)
) )
SELECT SELECT
regr_slope(y, x) AS slope, regr_slope(y, x) AS slope,
@ -2049,7 +2084,7 @@ def _elasticity_per_bucket_coef(
{where_class} {where_class}
AND crm.deals_total_count > 0 AND crm.deals_total_count > 0
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0 AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
AND crm.report_month >= NOW() - (:ew || ' months')::interval AND crm.report_month >= NOW() - make_interval(months => :ew)
) )
SELECT bucket, SELECT bucket,
regr_slope(y, x) AS slope, regr_slope(y, x) AS slope,
@ -2632,9 +2667,7 @@ def recommend_mix(
else: else:
# Rosreestr fallback aggregate: district+class deals / months / n_comp = # Rosreestr fallback aggregate: district+class deals / months / n_comp =
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес). # среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
market_vel_pm = ( market_vel_pm = (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
(total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
)
warnings.append( warnings.append(
f"Нет objective-данных для района/класса — темп по rosreestr ÷ " f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)." f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
@ -2786,6 +2819,25 @@ def recommend_mix(
if b["bucket"] != top_bucket_name: if b["bucket"] != top_bucket_name:
b["share_pct"] = round(b["share_pct"] * scale, 1) b["share_pct"] = round(b["share_pct"] * scale, 1)
# #1576: success-boost изменил share_pct → средневзвешенная цена
# должна пересчитаться под новые доли, иначе weighted_avg_price
# остаётся от ДО-boost микса (пробел в фиксе #1359, который
# обновлял только units/revenue). price_median_per_m2 уже включает
# combined_price_factor (line 2743), поэтому домножать НЕ нужно —
# это согласовано с per-bucket revenue ниже. Веса = area_avg×share,
# независимо от area_total_m2, поэтому вне ветки area_total_m2.
wnum = sum(
b["_area_avg_raw"] * b["share_pct"] * b["price_median_per_m2"]
for b in buckets
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
)
wden = sum(
b["_area_avg_raw"] * b["share_pct"]
for b in buckets
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
)
weighted_avg_price = round(wnum / wden, 2) if wden > 0 else None
# #1359: success-boost изменил share_pct → перераспределяем # #1359: success-boost изменил share_pct → перераспределяем
# units/revenue/months_to_sellout и агрегаты под новые доли, # units/revenue/months_to_sellout и агрегаты под новые доли,
# иначе share_pct рассогласуется с units_planned/revenue/sellout # иначе share_pct рассогласуется с units_planned/revenue/sellout

View file

@ -247,21 +247,11 @@ async def harvest_quarter(
db.commit() db.commit()
update_progress(done_progress) update_progress(done_progress)
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
# Выполняем после основного grid-walk (Phase 2-3). Требует bbox квартала.
quarter_bbox = quarter_bbox_3857(db, quarter)
if quarter_bbox is not None:
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
try:
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
tz_count = _save_territorial_zones(db, quarter, tz_features)
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
# ── Phase 4: quarter stats + auto-heal geom из snapshot ───────────────── # ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
# Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
# кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
# пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
# Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS] stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
if stats_features: if stats_features:
upsert_quarter_stats(db, quarter, stats_features[0]) upsert_quarter_stats(db, quarter, stats_features[0])
@ -276,6 +266,22 @@ async def harvest_quarter(
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e) logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
db.commit() db.commit()
# ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
# Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
# auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
# получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
quarter_bbox = quarter_bbox_3857(db, quarter)
if quarter_bbox is not None:
update_progress({"phase": "territorial_zones_started", "quarter": quarter})
try:
tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
tz_count = _save_territorial_zones(db, quarter, tz_features)
logger.info(
"harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
)
except Exception as e:
logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем # Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) — # сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в # per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
@ -344,6 +350,11 @@ async def _grid_walk_category(
grid_points = generate_grid_click_points(bbox, grid_size=grid_size, tile_size=tile_size) grid_points = generate_grid_click_points(bbox, grid_size=grid_size, tile_size=tile_size)
# Bug #1584: считаем discovered ТОЛЬКО для таблицы запрошенного layer_id, а не
# sum(stats.values()). Иначе skipped/чужекатегорийные features завышают счётчик,
# ошибочно приписываясь к parcels/buildings вызывающим (harvest_quarter:230-233).
layer_count_key = "parcels" if layer_id == CAT_PARCEL else "buildings"
discovered_cads: set[str] = set() discovered_cads: set[str] = set()
upserted = 0 upserted = 0
requests = 0 requests = 0
@ -411,7 +422,7 @@ async def _grid_walk_category(
try: try:
with db.begin_nested(): with db.begin_nested():
stats = upsert_features(db, [feature], source="wms_grid_walk") stats = upsert_features(db, [feature], source="wms_grid_walk")
upserted += sum(stats.values()) upserted += stats[layer_count_key]
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"_grid_walk_category: upsert failed cad=%s layer=%d: %s", "_grid_walk_category: upsert failed cad=%s layer=%d: %s",
@ -544,9 +555,7 @@ async def backfill_parcel_geom(
# Один сбойный квартал не валит весь backfill — лог + продолжаем. # Один сбойный квартал не валит весь backfill — лог + продолжаем.
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо, # (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
# caller-task ловит и не ретраит, как в bulk_harvest.) # caller-task ловит и не ретраит, как в bulk_harvest.)
logger.warning( logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e)
"backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e
)
db.rollback() db.rollback()
continue continue
@ -1423,7 +1432,7 @@ def upsert_quarter_stats(
"cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")), "cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")),
"sum_land_area": _safe_numeric(raw_opts.get("sum_land_area")), "sum_land_area": _safe_numeric(raw_opts.get("sum_land_area")),
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_area")), "sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_area")),
"date_cr": raw_opts.get("date_cr"), "date_cr": _parse_nspd_date(raw_opts.get("date_cr")),
"real_srid": _safe_int(raw_opts.get("real_srid")), "real_srid": _safe_int(raw_opts.get("real_srid")),
"raw_props": json.dumps(raw_opts, ensure_ascii=False), "raw_props": json.dumps(raw_opts, ensure_ascii=False),
}, },
@ -1466,6 +1475,21 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict])
zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone") zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone")
permitted_use = props.get("permitted_use") or props.get("vri") permitted_use = props.get("permitted_use") or props.get("vri")
# cad_territorial_zones.geom — geography(MultiPolygon, 4326).
# Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString →
# geography-INSERT fail → SAVEPOINT откат → строка дропается молча.
# Зеркалит фильтр upsert_zouit (~1196).
geom_type = geom.get("type") if isinstance(geom, dict) else None
if geom_type not in ("Polygon", "MultiPolygon"):
if geom_type:
logger.info(
"_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)"
" — geom=NULL",
zone_id,
geom_type,
)
geom_geojson = None
try: try:
# begin_nested() требует активной outer-транзакции для SAVEPOINT. # begin_nested() требует активной outer-транзакции для SAVEPOINT.
# SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом # SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом
@ -1484,9 +1508,11 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict])
CAST(:permitted_use AS text), CAST(:permitted_use AS text),
CAST(:raw_props AS jsonb), CAST(:raw_props AS jsonb),
CASE WHEN CAST(:geom AS text) IS NOT NULL CASE WHEN CAST(:geom AS text) IS NOT NULL
THEN ST_Transform( THEN ST_Multi(
ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857), ST_Transform(
4326 ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857),
4326
)
)::geography )::geography
ELSE NULL ELSE NULL
END END

View file

@ -7,12 +7,16 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Радиус сферы Web Mercator (EPSG:3857), м. Нужен для инверсии Y → широта.
_WEB_MERCATOR_R = 6378137.0
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None: def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857. """Получить bbox квартала из cad_quarters_geom в EPSG:3857.
@ -56,8 +60,19 @@ def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, f
# geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors # geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для # от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
# broken — snapshot phase даст 20 per cat, что норм для MVP. # broken — snapshot phase даст 20 per cat, что норм для MVP.
width = bbox[2] - bbox[0] #
height = bbox[3] - bbox[1] # Bug #1629: EPSG:3857 (Web Mercator) — конформная, НЕ равнопротяжённая
# проекция. Линейный масштаб = sec(lat); на широте ЕКБ (~56-60°N) фактор
# ≈1.8-2.0, т.е. 1 наземный метр ≈ 1.8-2.0 единиц 3857. Пороги ниже заданы
# в НАЗЕМНЫХ метрах, поэтому переводим протяжённость bbox из единиц 3857 в
# наземные метры умножением на cos(lat) (lat берём из центра bbox через
# инверсию Y). Иначе верхняя граница 10000 отсекала бы реальные крупные/
# вытянутые кварталы (10000 единиц 3857 ≈ всего ~5050 м земли на широте ЕКБ).
y_mid = (bbox[1] + bbox[3]) / 2.0
lat_mid = 2.0 * math.atan(math.exp(y_mid / _WEB_MERCATOR_R)) - math.pi / 2.0
ground_scale = math.cos(lat_mid) # ground_m = mercator_units * cos(lat)
width = (bbox[2] - bbox[0]) * ground_scale
height = (bbox[3] - bbox[1]) * ground_scale
if width < 100 or height < 100 or width > 10000 or height > 10000: if width < 100 or height < 100 or width > 10000 or height > 10000:
logger.warning( logger.warning(
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm " "quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "

View file

@ -151,8 +151,8 @@ def _fmt_number(value: Any) -> str | None:
if isinstance(value, int): if isinstance(value, int):
return _fmt_thousands(value) return _fmt_thousands(value)
if isinstance(value, float): if isinstance(value, float):
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None),
return str(value) return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
if value == int(value): if value == int(value):
return _fmt_thousands(value) return _fmt_thousands(value)
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31'). # Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
@ -251,7 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
if summary: if summary:
lines.append(str(summary)) lines.append(str(summary))
if len(sections_used) == 1 and not any( if not any(
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary") section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
): ):
lines.append("Раздел рекомендации продукта в отчёте пуст.") lines.append("Раздел рекомендации продукта в отчёте пуст.")
@ -266,16 +266,21 @@ def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
future = report.get("future_market") future = report.get("future_market")
if isinstance(future, dict): if isinstance(future, dict):
sections_used.append("future_market") future_emitted = False # секцию в provenance кладём только если выведена строка (#1630)
horizons = future.get("forecasts_by_horizon") horizons = future.get("forecasts_by_horizon")
if isinstance(horizons, list) and horizons: if isinstance(horizons, list) and horizons:
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.") lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
future_emitted = True
future_supply = future.get("future_supply") future_supply = future.get("future_supply")
if isinstance(future_supply, dict) and future_supply: if isinstance(future_supply, dict) and future_supply:
lines.append("Учтено давление будущего предложения (выходящие проекты).") lines.append("Учтено давление будущего предложения (выходящие проекты).")
future_emitted = True
summary = future.get("summary") summary = future.get("summary")
if summary: if summary:
lines.append(str(summary)) lines.append(str(summary))
future_emitted = True
if future_emitted:
sections_used.append("future_market")
else: else:
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок")) lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))

View file

@ -165,6 +165,18 @@ def orchestrate_chat(
continue continue
# Нет tool_calls → финальная проза. # Нет tool_calls → финальная проза.
# Проверяем finish_reason: 'length' / 'content_filter' означают обрезанный
# или отфильтрованный ответ — неполная проза клиенту не отдаётся (LLMResult
# docstring §64: «консьюмер ОБЯЗАН проверять даже при ok=True»).
if result.finish_reason not in (None, "stop", "end_turn", "tool_calls"):
logger.warning(
"chat: non-stop finish_reason=%r for cad=%s run=%s → deterministic fallback",
result.finish_reason,
cad_num,
run_id,
)
return _deterministic(report_dict, message, f"finish_reason:{result.finish_reason}")
answer = (result.content or "").strip() answer = (result.content or "").strip()
if not answer: if not answer:
# Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем. # Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем.

View file

@ -94,6 +94,7 @@ def find_match_candidates(
objective_distinct AS ( objective_distinct AS (
SELECT DISTINCT project_name SELECT DISTINCT project_name
FROM objective_corpus_room_month FROM objective_corpus_room_month
WHERE group_name = 'Екатеринбург'
) )
SELECT SELECT
d.obj_id, d.obj_id,
@ -168,7 +169,7 @@ def auto_apply_matches(
len(auto), len(auto),
len(review), len(review),
) )
return {"auto_accepted": 0, "review_queue": len(review), "skipped": 0} return {"auto_accepted": len(auto), "review_queue": len(review), "skipped": 0}
inserted = 0 inserted = 0
skipped = 0 skipped = 0

View file

@ -61,7 +61,9 @@ _FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария # Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт # (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит # список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
# основного горизонта как сводный показатель сценария. # основного горизонта как сводный показатель. При fallback на иной горизонт ячейка
# несёт «(гор. N мес)» через _scenario_deficit_cell, чтобы шапка «(12 мес)» не лгала
# (#1590).
_PRIMARY_HORIZON_MONTHS: int = 12 _PRIMARY_HORIZON_MONTHS: int = 12
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка». # RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
@ -140,7 +142,7 @@ def _fmt(value: Any) -> Any:
return _sanitize_formula(value) return _sanitize_formula(value)
if isinstance(value, int): if isinstance(value, int):
return value return value
if isinstance(value, (dict, list)): if isinstance(value, dict | list):
return _sanitize_formula(str(value)) return _sanitize_formula(str(value))
return value return value
@ -208,6 +210,48 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None return None
def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
"""Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес первый с не-None
дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
при fallback на чужой горизонт. Нет дефицита None (#1590, зеркало report_pdf.py).
"""
forecasts = _as_list(payload.get("forecasts"))
primary = next(
(
f
for f in forecasts
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
),
None,
)
if primary is not None and primary.get("deficit_index") is not None:
return _PRIMARY_HORIZON_MONTHS
for f in forecasts:
if isinstance(f, dict) and f.get("deficit_index") is not None:
h = f.get("horizon_months")
return h if isinstance(h, int) else None
return None
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой
горизонт помечаем ячейку реальным горизонтом, чтобы число под общей шапкой
«(12 мес)» не читалось как 12-месячное. Дефицит None отдаём как есть ( "").
Зеркалит report_pdf._scenario_deficit_cell (#1590).
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка # Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
# (мутируют переданный лист). Не ходят в БД/сеть. # (мутируют переданный лист). Не ходят в БД/сеть.
@ -481,12 +525,14 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row += 1 row += 1
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ── # ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
# _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
row = _write_title(ws, row, "Сводка по сценариям") row = _write_title(ws, row, "Сводка по сценариям")
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"] headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
table_rows: list[list[Any]] = [] table_rows: list[list[Any]] = []
for name, payload in by_scenario.items(): for name, payload in by_scenario.items():
data = _as_dict(payload) data = _as_dict(payload)
table_rows.append([name, _scenario_deficit_index(data), data.get("advisory")]) table_rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
_write_table(ws, row, headers, table_rows) _write_table(ws, row, headers, table_rows)
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH]) _set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])

View file

@ -10,7 +10,7 @@
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из
`report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря, `report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря,
`_fmt` строковое форматирование ячейки/None"", `_level_ru`, `_as_dict`/`_as_list`, `_fmt` строковое форматирование ячейки/None"", `_level_ru`, `_as_dict`/`_as_list`,
`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы `_future_supply_pairs`, `_scenario_deficit_cell`) + переиспользуем его named-константы
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные (`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО docx-специфичные
микро-билдеры (заголовки/абзацы/таблицы python-docx). микро-билдеры (заголовки/абзацы/таблицы python-docx).
@ -44,7 +44,7 @@ from app.services.exporters.report_pdf import (
_future_supply_pairs, _future_supply_pairs,
_level_ru, _level_ru,
_normalize, _normalize,
_scenario_deficit_index, _scenario_deficit_cell,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -76,7 +76,8 @@ _TABLE_STYLE: str = "Table Grid"
_COMPETITORS_TOP_N: int = 5 _COMPETITORS_TOP_N: int = 5
# Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария # Основной продуктовый горизонт (мес) — подпись столбца сводного дефицита сценария
# (зеркало report_md._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index). # (зеркало report_md._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
# при fallback на чужой горизонт ячейка несёт «(гор. N мес)», #1590).
_PRIMARY_HORIZON_MONTHS: int = 12 _PRIMARY_HORIZON_MONTHS: int = 12
@ -272,7 +273,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None:
rate_str = ( rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
) )
rows.append([name, _scenario_deficit_index(data), rate_str, data.get("advisory")]) rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
headers = [ headers = [
"Сценарий", "Сценарий",

View file

@ -39,6 +39,7 @@ from app.services.exporters.report_pdf import (
_future_supply_pairs, _future_supply_pairs,
_level_ru, _level_ru,
_normalize, _normalize,
_scenario_deficit_horizon,
_scenario_deficit_index, _scenario_deficit_index,
) )
@ -109,10 +110,7 @@ def _md_table(headers: list[str], rows: list[list[Any]]) -> str:
if not rows: if not rows:
empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |" empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |"
return "\n".join([head, sep, empty]) return "\n".join([head, sep, empty])
body = [ body = ["| " + " | ".join(_md_cell(cell) for cell in row) + " |" for row in rows]
"| " + " | ".join(_md_cell(cell) for cell in row) + " |"
for row in rows
]
return "\n".join([head, sep, *body]) return "\n".join([head, sep, *body])
@ -123,10 +121,17 @@ def _md_kv_table(data: dict[str, Any]) -> str:
def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str: def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str:
"""Список «**метка:** значение» построчно (для коротких карточек meta). PURE.""" """Список «**метка:** значение» построчно (для коротких карточек meta). PURE.
Переводы строк в значении сворачиваем в пробел (по аналогии с `_md_cell`):
иначе многострочный value (напр. product_tz.summary) разорвал бы буллет.
"""
if not pairs: if not pairs:
return _NO_DATA return _NO_DATA
return "\n".join(f"- **{label}:** {_fmt(value)}" for label, value in pairs) return "\n".join(
f"- **{label}:** {_fmt(value).replace(chr(10), ' ').replace(chr(13), ' ')}"
for label, value in pairs
)
def _join_horizons(values: list[Any]) -> Any: def _join_horizons(values: list[Any]) -> Any:
@ -243,6 +248,22 @@ def _build_future_market(report: dict[str, Any]) -> str:
) )
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. Зеркало report_pdf._scenario_deficit_cell: при fallback `_scenario_deficit_index`
на чужой горизонт (нет 12-мес прогноза) помечаем число реальным горизонтом, чтобы под
общей шапкой «(12 мес)» оно не читалось как 12-месячное. Дефицит None как есть ( "").
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
def _build_scenarios(report: dict[str, Any]) -> str: def _build_scenarios(report: dict[str, Any]) -> str:
"""§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path.""" """§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
scenarios = _as_dict(report.get("scenarios")) scenarios = _as_dict(report.get("scenarios"))
@ -252,12 +273,8 @@ def _build_scenarios(report: dict[str, Any]) -> str:
for name, payload in by_scenario.items(): for name, payload in by_scenario.items():
data = _as_dict(payload) data = _as_dict(payload)
rate_path = _as_dict(data.get("rate_path")) rate_path = _as_dict(data.get("rate_path"))
rate_str = ( rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
)
rows.append(
[name, _scenario_deficit_index(data), rate_str, data.get("advisory")]
)
headers = [ headers = [
"Сценарий", "Сценарий",
@ -266,9 +283,7 @@ def _build_scenarios(report: dict[str, Any]) -> str:
"Advisory", "Advisory",
] ]
return ( return (
f"## {_TITLE_SCENARIOS}\n\n" f"## {_TITLE_SCENARIOS}\n\n{_fmt(scenarios.get('summary'))}\n\n{_md_table(headers, rows)}"
f"{_fmt(scenarios.get('summary'))}\n\n"
f"{_md_table(headers, rows)}"
) )
@ -290,9 +305,7 @@ def _build_product_tz(report: dict[str, Any]) -> str:
if isinstance(m, dict) if isinstance(m, dict)
] ]
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)] usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
reason_rows = [ reason_rows = [[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)]
[r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict)
]
return ( return (
f"## {_TITLE_PRODUCT_TZ}\n\n" f"## {_TITLE_PRODUCT_TZ}\n\n"
@ -444,7 +457,9 @@ def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None:
parts: list[str] = [] parts: list[str] = []
for name in ordered: for name in ordered:
label = _TG_SCENARIO_RU.get(name, str(name)) label = _TG_SCENARIO_RU.get(name, str(name))
deficit = _scenario_deficit_index(_as_dict(by_scenario.get(name))) # _scenario_deficit_cell сам помечает «(гор. N мес)» при fallback на чужой горизонт,
# чтобы метка периода в чате не лгала (см. _build_scenarios).
deficit = _scenario_deficit_cell(_as_dict(by_scenario.get(name)))
parts.append(f"{label} {_fmt(deficit)}") parts.append(f"{label} {_fmt(deficit)}")
return " · ".join(parts) if parts else None return " · ".join(parts) if parts else None
@ -504,8 +519,20 @@ def render_report_telegram_summary(report: Any) -> str:
# Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии). # Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии).
scenario_line = _tg_scenario_deficit_line(data) scenario_line = _tg_scenario_deficit_line(data)
if scenario_line is not None: if scenario_line is not None:
horizons = _as_list(meta.get("horizons")) # Метку «(гор. 12 мес)» ставим, только если ВСЕ выведенные дефициты реально взяты
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else "" # с основного 12-мес горизонта; при fallback на чужой горизонт у сценария метка
# «(гор. N мес)» уже стоит в самой строке (см. _scenario_deficit_cell), а общая
# метка молчит, чтобы не лгать про период (баг #1590).
by_scenario = _as_dict(_as_dict(data.get("scenarios")).get("by_scenario"))
with_deficit = [
_as_dict(payload)
for payload in by_scenario.values()
if _scenario_deficit_index(_as_dict(payload)) is not None
]
all_primary = bool(with_deficit) and all(
_scenario_deficit_horizon(p) == _PRIMARY_HORIZON_MONTHS for p in with_deficit
)
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if all_primary else ""
lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}") lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}")
# Рекомендованный класс продукта (если задан). # Рекомендованный класс продукта (если задан).

View file

@ -458,6 +458,9 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» есть `payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» есть
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет None ( ""). `_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет None ( "").
NB: эту функцию импортируют report_md/docx/pptx НЕ менять сигнатуру (вернёт скаляр).
Для подписи фактического горизонта используй `_scenario_deficit_horizon`.
""" """
forecasts = _as_list(payload.get("forecasts")) forecasts = _as_list(payload.get("forecasts"))
primary = next( primary = next(
@ -476,6 +479,49 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None return None
def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
"""Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес первый с не-None
дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
при fallback на чужой горизонт. Нет дефицита None. NB: сигнатура
`_scenario_deficit_index` (импортируется в report_md/docx/pptx) НЕ меняется это
отдельная чистая функция-зеркало.
"""
forecasts = _as_list(payload.get("forecasts"))
primary = next(
(
f
for f in forecasts
if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
),
None,
)
if primary is not None and primary.get("deficit_index") is not None:
return _PRIMARY_HORIZON_MONTHS
for f in forecasts:
if isinstance(f, dict) and f.get("deficit_index") is not None:
h = f.get("horizon_months")
return h if isinstance(h, int) else None
return None
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой
горизонт помечаем ячейку реальным горизонтом, чтобы число под общей шапкой
«(12 мес)» не читалось как 12-месячное. Дефицит None отдаём как есть ( "").
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
def _build_scenarios(report: dict[str, Any]) -> str: def _build_scenarios(report: dict[str, Any]) -> str:
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful.""" """Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
scenarios = _as_dict(report.get("scenarios")) scenarios = _as_dict(report.get("scenarios"))
@ -484,9 +530,9 @@ def _build_scenarios(report: dict[str, Any]) -> str:
rows: list[list[Any]] = [] rows: list[list[Any]] = []
for name, payload in by_scenario.items(): for name, payload in by_scenario.items():
data = _as_dict(payload) data = _as_dict(payload)
rows.append([name, _scenario_deficit_index(data), data.get("advisory")]) rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"] headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
return f""" return f"""
<div class="section" id="scenarios"> <div class="section" id="scenarios">
<h2>{html.escape(_TITLE_SCENARIOS)}</h2> <h2>{html.escape(_TITLE_SCENARIOS)}</h2>

View file

@ -12,7 +12,7 @@
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из DRY: PURE-хелперы нормализации/форматирования НЕ дублируем импортируем из
`report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря, `report_pdf` (там они уже есть: `_normalize` приём инстанса ИЛИ `as_dict()`-словаря,
`_fmt` строковое форматирование значения/None"", `_level_ru`, `_as_dict`/`_as_list`, `_fmt` строковое форматирование значения/None"", `_level_ru`, `_as_dict`/`_as_list`,
`_scenario_deficit_index`) + переиспользуем его named-константы `_scenario_deficit_cell`) + переиспользуем его named-константы
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные (`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные
микро-билдеры (слайды/буллеты/таблица python-pptx). микро-билдеры (слайды/буллеты/таблица python-pptx).
@ -46,7 +46,7 @@ from app.services.exporters.report_pdf import (
_fmt, _fmt,
_level_ru, _level_ru,
_normalize, _normalize,
_scenario_deficit_index, _scenario_deficit_cell,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -78,7 +78,8 @@ _LAYOUT_TITLE_CONTENT: int = 1
_LAYOUT_TITLE_ONLY: int = 5 _LAYOUT_TITLE_ONLY: int = 5
# Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария # Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария
# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index). # (зеркало report_docx._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
# при fallback на чужой горизонт буллет несёт «(гор. N мес)», #1590).
_PRIMARY_HORIZON_MONTHS: int = 12 _PRIMARY_HORIZON_MONTHS: int = 12
# Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно). # Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно).
@ -235,7 +236,7 @@ def _build_scenarios(prs: _PptxPresentation, report: dict[str, Any]) -> None:
rate_str = ( rate_str = (
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH
) )
deficit = _fmt(_scenario_deficit_index(data)) deficit = _fmt(_scenario_deficit_cell(data))
bullets.append( bullets.append(
(str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}") (str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}")
) )

View file

@ -166,12 +166,14 @@ def generate_snapshot_pdf(
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "" area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else ""
poi_items = _build_poi_items(poi_rows, limit=7) poi_items = _build_poi_items(poi_rows, limit=7)
# Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC; # Конкуренты — берём топ N БЛИЖАЙШИХ. competitor_rows приходят отсортированными
# переупорядочиваем по distance_m для удобства чтения) # по flat_count DESC (крупнейшие ЖК), поэтому сначала пересортировываем весь
# список по distance_m ASC, и лишь затем срезаем N — иначе в блок попадали бы
# 5 крупнейших из радиуса, а не непосредственное конкурентное окружение пятна.
competitors_display = sorted( competitors_display = sorted(
competitor_rows[:competitors_limit], competitor_rows,
key=lambda r: float(r.get("distance_m") or 0), key=lambda r: float(r.get("distance_m") or 0),
) )[:competitors_limit]
competitors_ctx: list[dict[str, Any]] = [ competitors_ctx: list[dict[str, Any]] = [
{ {
"comm_name": r.get("comm_name"), "comm_name": r.get("comm_name"),

View file

@ -58,7 +58,11 @@ def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
for lot in lots: for lot in lots:
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else "" date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else ""
dom_val = str(lot.days_on_market) if lot.days_on_market is not None else "" dom_val = str(lot.days_on_market) if lot.days_on_market is not None else ""
floor_val = f"{lot.floor}/{lot.total_floors}" if lot.floor and lot.total_floors else "" floor_val = (
f"{lot.floor}/{lot.total_floors}"
if lot.floor is not None and lot.total_floors is not None
else ""
)
label = "Дата сделки" if is_deal else "В продаже" label = "Дата сделки" if is_deal else "В продаже"
_ = label # used for header only _ = label # used for header only
rows.append( rows.append(

View file

@ -294,8 +294,10 @@ def compute_affordability(
усреднение НЕнулевых месяцев). None платёж None (graceful). усреднение НЕнулевых месяцев). None платёж None (graceful).
monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate, monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
months=_ANNUITY_TERM_MONTHS). months=_ANNUITY_TERM_MONTHS).
payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) платёж на payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h] + спред)
каждом горизонте сценарной ставки (None rate_path поле None). платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
4.5 п.п.); None rate_path поле None (#1639).
Graceful: нет ставки/цены платёж None; ставка 0 аннуитет деградирует к Graceful: нет ставки/цены платёж None; ставка 0 аннуитет деградирует к
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно. principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
@ -304,8 +306,10 @@ def compute_affordability(
db: SQLAlchemy sync Session. db: SQLAlchemy sync Session.
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан). spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
price_per_m2: цена /м² (None берём сегментную среднюю reuse-ом). price_per_m2: цена /м² (None берём сегментную среднюю reuse-ом).
rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario; rate_path: сценарный {horizon: КЛЮЧЕВАЯ ставка ЦБ %} для payment_at_scenario
None payment_at_scenario None. (контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
monthly_payment_rub); None payment_at_scenario None.
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2). ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B). price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
@ -337,7 +341,18 @@ def compute_affordability(
if rate_path is not None: if rate_path is not None:
scenario: dict[int, float] = {} scenario: dict[int, float] = {}
for horizon, scenario_rate in rate_path.items(): for horizon, scenario_rate in rate_path.items():
payment = _annuity(principal, scenario_rate, _ANNUITY_TERM_MONTHS) # rate_path[h] = КЛЮЧЕВАЯ ставка ЦБ сценария (#952/#984 контракт:
# demand_supply_forecast.py:586, scenarios.py — конверт key_rate), НЕ
# рыночная. Приводим к той же рыночной базе, что и monthly_payment_rub:
# key_rate + калиброванный спред (_current_market_rate, строка 251).
# Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
# ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
market_scenario_rate = (
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
if scenario_rate is not None
else None
)
payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS)
if payment is not None: if payment is not None:
scenario[horizon] = payment scenario[horizon] = payment
payment_at_scenario = scenario payment_at_scenario = scenario

View file

@ -367,9 +367,12 @@ def _build_rationale(
if advisory_capped and level == _ADVISORY_CEILING: if advisory_capped and level == _ADVISORY_CEILING:
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина. # Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
# _F_ADVISORY_CAP-фактор уже проговорён в base — исключаем его ноту из «также»,
# иначе advisory-cap-сообщение дублируется (частый all-high случай).
other = [f.note for f in drag if f.name != _F_ADVISORY_CAP]
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»" base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
if notes: if other:
base += "; также " + _join_notes(notes) base += "; также " + _join_notes(other)
return base + "." return base + "."
if not notes: if not notes:
@ -409,6 +412,7 @@ def compute_report_confidence(
*, *,
component_confidences: Sequence[ComponentConfidence] | None = None, component_confidences: Sequence[ComponentConfidence] | None = None,
deal_count: int | None = None, deal_count: int | None = None,
deal_count_months: int | None = None,
analog_count: int | None = None, analog_count: int | None = None,
domrf_coverage: float | None = None, domrf_coverage: float | None = None,
history_months: int | None = None, history_months: int | None = None,
@ -439,6 +443,8 @@ def compute_report_confidence(
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737: 'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
имя сервиса проносится сквозь стек в RU-метку фактора). имя сервиса проносится сквозь стек в RU-метку фактора).
deal_count: число сделок за окно (None нет данных, тянет в low). deal_count: число сделок за окно (None нет данных, тянет в low).
deal_count_months: окно наблюдения для deal_count (мес) добавляет «за N мес»
в ноту фактора («7 сделок за 6 мес мало»). None нота без периода.
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count). analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
domrf_coverage: доля domrfobjective [0,1] (главный sparse-риск проекта). domrf_coverage: доля domrfobjective [0,1] (главный sparse-риск проекта).
history_months: глубина ряда (мес). history_months: глубина ряда (мес).
@ -453,6 +459,7 @@ def compute_report_confidence(
# ── 1. Сырые счётчики качества данных → факторы (только заданные) ────────── # ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
if deal_count is not None: if deal_count is not None:
_deal_suffix = f"за {deal_count_months} мес" if deal_count_months is not None else ""
factors.append( factors.append(
_factor_from_count( _factor_from_count(
_F_DEAL_COUNT, _F_DEAL_COUNT,
@ -460,6 +467,7 @@ def compute_report_confidence(
high_at=_DEAL_COUNT_HIGH, high_at=_DEAL_COUNT_HIGH,
low_below=_DEAL_COUNT_LOW, low_below=_DEAL_COUNT_LOW,
unit="сделок", unit="сделок",
suffix=_deal_suffix,
) )
) )
if analog_count is not None: if analog_count is not None:

View file

@ -36,9 +36,10 @@
МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard graceful, без усиления шума на тонких данных): МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard graceful, без усиления шума на тонких данных):
сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить
случайным шумом одного-двух наблюдений: случайным шумом одного-двух наблюдений:
нужно _MIN_FULL_YEARS полных лет (по умолчанию 2) иначе у каждого месяца нужно _MIN_FULL_YEARS полных лет (по умолчанию 2) считаем только по
< 2 наблюдений, «сезонность» неотличима от шума ВСЕ факторы = 1.0 (ряд NON-ZERO месяцам (fill_month_grid ставит units=0 для «нет сделок», но 0 не
возвращается без изменений); несёт сезонного сигнала); иначе у каждого месяца < 2 наблюдений, «сезонность»
неотличима от шума ВСЕ факторы = 1.0 (ряд возвращается без изменений);
месяц без наблюдений его фактор = 1.0 (нет базы для оценки); месяц без наблюдений его фактор = 1.0 (нет базы для оценки);
overall_mean 0 (пустой/нулевой ряд) ВСЕ факторы = 1.0 (нет масштаба, и overall_mean 0 (пустой/нулевой ряд) ВСЕ факторы = 1.0 (нет масштаба, и
защита от деления на ноль); защита от деления на ноль);
@ -114,7 +115,8 @@ def seasonal_factors(
None-значения в values пропускаются (нет наблюдения не подмешиваем 0). None-значения в values пропускаются (нет наблюдения не подмешиваем 0).
GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума): GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума):
полных лет < min_full_years ВСЕ факторы 1.0, applied=False; полных лет (по NON-ZERO месяцам) < min_full_years ВСЕ факторы 1.0,
applied=False (fix #1638: zero-filled месяцы не считаются за наблюдения);
overall_mean 0 ВСЕ факторы 1.0 (нет масштаба / защита от /0); overall_mean 0 ВСЕ факторы 1.0 (нет масштаба / защита от /0);
месяц без наблюдений ИЛИ со средним 0 его фактор 1.0. месяц без наблюдений ИЛИ со средним 0 его фактор 1.0.
@ -167,15 +169,20 @@ def seasonal_factors(
def _count_full_years(months: list[date], values: list[float | int | None]) -> int: def _count_full_years(months: list[date], values: list[float | int | None]) -> int:
"""Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 наблюдение) в ряду. """Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 NON-ZERO наблюдение).
Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев это Считаем по годам, где есть НЕНУЛЕВОЕ наблюдение в КАЖДОМ из 12 месяцев это
«полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки «полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки
наблюдением НЕ считаются. PURE. и нулевые значения (zero-filled месяцы из fill_month_grid) наблюдением НЕ
считаются: у ряда с units=0 нет сезонного сигнала для оценки факторов. PURE.
Это исправляет баг #1638: fill_month_grid выставляет units=0 для отсутствующих
месяцев (не None), поэтому старый guard, пропускавший только None, насчитывал
«полные годы» на фактически пустом ряду.
""" """
months_seen: dict[int, set[int]] = {} months_seen: dict[int, set[int]] = {}
for d, v in zip(months, values, strict=False): for d, v in zip(months, values, strict=False):
if v is None: if v is None or float(v) == 0.0:
continue continue
months_seen.setdefault(d.year, set()).add(d.month) months_seen.setdefault(d.year, set()).add(d.month)
return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR) return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR)

View file

@ -174,9 +174,10 @@ _MORTG_MAX_X_PCT: float = 25.0
# дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш). # дифференциация). Overlay недоступен/сбой → None (НЕ 0 — нет данных ≠ нет ниш).
_DIFF_TARGET_USP: float = 3.0 _DIFF_TARGET_USP: float = 3.0
# commercial: commercial_share_pct ∈ [0,100] (§10.4 реализованная доля нежилого) → /100. # commercial: commercial_sell_through_pct ∈ [0,100] (§10.4 темп распродажи нежилого,
# Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё): overlay.commercial.available=False # sell-through, прокси спроса) → /100. Сигнал ДЕГРАДИРОВАН по дизайну (objective ~ жильё):
# → скор None (НЕ 0). available=True → share/100 как прокси силы спроса на коммерцию. # overlay.commercial.available=False → скор None (НЕ 0). available=True → sell_through/100
# как прокси силы спроса на коммерцию.
# confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] → # confidence (МЕТА-скор): маппинг confidence-меток вкладывающих сервисов в [0,1] →
# усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 — # усредняем с долей доступных скоров (data-quality). high=1.0 / medium=0.6 / low=0.25 —
@ -463,21 +464,24 @@ def _score_differentiation(
def _score_commercial( def _score_commercial(
commercial: dict[str, Any] | None, commercial: dict[str, Any] | None,
) -> tuple[float | None, Confidence, str]: ) -> tuple[float | None, Confidence, str]:
"""commercial ← recommendation §10.4 сигнал → share_pct/100. ДЕГРАДИРУЕТ в None. PURE. """commercial ← recommendation §10.4 сигнал → sell_through_pct/100. ДЕГРАДИРУЕТ в None. PURE.
overlay.commercial.available=True commercial_share_pct/100 (прокси спроса на overlay.commercial.available=True commercial_sell_through_pct/100 (темп распродажи
нежилое). available=False / None (objective ~ жильё, тонко) None (НЕ 0-как-заглушка). нежилого, sell-through, прокси спроса). available=False / None (objective ~ жильё, тонко)
confidence наследуется из сигнала (или 'low'). None (НЕ 0-как-заглушка). confidence наследуется из сигнала (или 'low').
""" """
if not commercial or not commercial.get("available"): if not commercial or not commercial.get("available"):
return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)." return None, "low", "Коммерческий сигнал §10.4 недоступен (нет данных по нежилому)."
share_pct = commercial.get("commercial_share_pct") sell_through_pct = commercial.get("commercial_sell_through_pct")
if share_pct is None: if sell_through_pct is None:
return None, "low", "Коммерческий сигнал §10.4 недоступен (доля не измерена)." return None, "low", "Коммерческий сигнал §10.4 недоступен (темп распродажи не измерен)."
raw_conf = commercial.get("confidence", "low") raw_conf = commercial.get("confidence", "low")
conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low" conf: Confidence = raw_conf if raw_conf in ("high", "medium", "low") else "low"
value = _clamp01(float(share_pct) / 100.0) value = _clamp01(float(sell_through_pct) / 100.0)
reason = f"Реализованная доля коммерции ~{round(float(share_pct), 1)}% (§10.4, прокси спроса)." reason = (
f"Темп распродажи нежилого ~{round(float(sell_through_pct), 1)}% (§10.4, прокси "
"ликвидности/спроса на нежилое; НЕ доля нежилого в объёме застройки)."
)
return value, conf, reason return value, conf, reason
@ -818,7 +822,10 @@ def _competitor_signal(
) )
return None, None return None, None
weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not None] weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not None]
return weights, len(response.competitors) # #1595: count считаем по тем же конкурентам, у которых есть relevance_weight, иначе при
# частичных данных (None-веса) count > len(weights) → density завышена, future_competition
# занижен. В проде get_competitors всегда задаёт вес (число), но поле допускает None (мок).
return weights, len(weights)
def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None: def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:

View file

@ -28,9 +28,16 @@ LIVE-ENDPOINT-SAFE: НИКОГДА не бросает — на любом то
mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты mode="demand_only" (cad_num=None): БЕЗ геометрии участка supply/конкуренты
НЕизмеримы считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default НЕизмеримы считаем ТОЛЬКО темп спроса (§9.2 × §9.4 × §9.5) per default
room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace room-bucket, ранжируем DESC по темпу. `deficit_index` здесь = pace/max_pace
(0,1] это ПРОКСИ относительной силы спроса, НЕ supply-based дефицит; (0,1]; `balance_units=None`, confidence='low', обязательный warning что supply
`balance_units=None`, confidence='low', обязательный warning что supply НЕ учтён. НЕ учтён. НИКОГДА не фабрикуем предложение/конкурентов из воздуха.
НИКОГДА не фабрикуем предложение/конкурентов из воздуха. #1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через
MarketMetrics.velocity_by_room (ед./мес из objective_lots per комнатность).
Маппинг forecast_bucket metric-ключи: _FORECAST_TO_METRIC_BUCKETS.
"80+ м²" = сумма bucket'ов "4" + "5+" (оба крупные форматы). Если данных
по конкретному bucket'у нет — fallback на агрегатный unit_velocity, что
лучше чем фабрикация 0. §9.4 norm.coefficient по-прежнему без per-bucket
фита (β фитится district×obj_class), поэтому он одинаков между bucket'ами
и СОКРАЩАЕТСЯ в pace/max_pace это честно задокументировано.
ИМПОРТЫ §9.x ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет ИМПОРТЫ §9.x ЛОКАЛЬНЫЕ (внутри функций), чтобы избежать import-cycle: пакет
`forecasting/__init__` тянет `affordability`, который импортит `forecasting/__init__` тянет `affordability`, который импортит
@ -68,6 +75,18 @@ _FORECAST_TO_LIVE_BUCKET: dict[str, str] = {
# дефолтной сетке room-bucket'ов §9.7 и для map_room_bucket_inverse). # дефолтной сетке room-bucket'ов §9.7 и для map_room_bucket_inverse).
_LIVE_TO_FORECAST_BUCKET: dict[str, str] = {v: k for k, v in _FORECAST_TO_LIVE_BUCKET.items()} _LIVE_TO_FORECAST_BUCKET: dict[str, str] = {v: k for k, v in _FORECAST_TO_LIVE_BUCKET.items()}
# #1593: forecast room-bucket → список ключей MarketMetrics.velocity_by_room.
# Ключи velocity_by_room — вокабуляр _room_bucket() из market_metrics
# ("студия","1","2","3","4","5+"). "80+ м²" накрывает 4-комнатные и 5+
# (большие квартиры), поэтому суммируем оба бакета.
_FORECAST_TO_METRIC_BUCKETS: dict[str, list[str]] = {
"Студии 15-30": ["студия"],
"1-к 30-45": ["1"],
"2-к 45-60": ["2"],
"3-к 60-80": ["3"],
"80+ м²": ["4", "5+"],
}
# Live target_class (вокабуляр schemas.recommend ClassLiteral, «человеческий» # Live target_class (вокабуляр schemas.recommend ClassLiteral, «человеческий»
# регистр) → forecast obj_class (3 массовых класса §9.7 _DEFAULT_CLASSES). None и # регистр) → forecast obj_class (3 массовых класса §9.7 _DEFAULT_CLASSES). None и
# незнакомое → None (вызывающий тогда отдаёт движку дефолтную сетку классов). # незнакомое → None (вызывающий тогда отдаёт движку дефолтную сетку классов).
@ -565,9 +584,14 @@ def _demand_only_overlay(
БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса БЕЗ геометрии участка supply/конкуренты НЕизмеримы. Считаем темп спроса
pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient pace = §9.2 unit_velocity × §9.4 norm.coefficient × §9.5 macro.coefficient
per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace per дефолтный room-bucket, ранжируем DESC. deficit_index = pace/max_pace
(0,1] ПРОКСИ относительной силы спроса (НЕ supply-based дефицит). (0,1]. balance_units=None, confidence='low', обязательный warning. НИКОГДА не
balance_units=None, confidence='low', обязательный warning. НИКОГДА не
фабрикуем supply. Graceful []. фабрикуем supply. Graceful [].
#1593 (ИСПРАВЛЕНО): §9.2 base_pace теперь per-bucket через
MarketMetrics.velocity_by_room. §9.4 norm.coefficient одинаков между
bucket'ами (β без room_bucket фита) и СОКРАЩАЕТСЯ в pace/max_pace — это
задокументировано, не скрыто. Ранкинг теперь отражает реальные per-bucket
темпы спроса из objective_lots, а не только §9.5 macro_coef.
""" """
# Локальные импорты — избегаем import-cycle (см. module docstring). # Локальные импорты — избегаем import-cycle (см. module docstring).
from app.services.forecasting.demand_normalization import compute_demand_normalization from app.services.forecasting.demand_normalization import compute_demand_normalization
@ -581,9 +605,10 @@ def _demand_only_overlay(
] ]
mapped_class = map_class(target_class) mapped_class = map_class(target_class)
# Один раз на вызов: §9.2 наблюдаемый темп (base_pace) + hold-last-rate для §9.4. # Один раз на вызов: §9.2 наблюдаемый темп (aggregate + per-bucket) + hold-last-rate.
metrics = compute_market_metrics(db, district=district) metrics = compute_market_metrics(db, district=district)
base_pace = metrics.unit_velocity base_pace = metrics.unit_velocity # агрегатный fallback (используется если нет per-bucket)
vel_by_room = metrics.velocity_by_room # #1593: per-bucket из objective_lots
macro = get_monthly_macro(db) macro = get_monthly_macro(db)
rate_future = hold_last_rate(macro, [horizon_months]).get(horizon_months) rate_future = hold_last_rate(macro, [horizon_months]).get(horizon_months)
@ -609,17 +634,38 @@ def _demand_only_overlay(
room_bucket=forecast_bucket, room_bucket=forecast_bucket,
district=district, district=district,
) )
# §9.4 нормализация под будущий режим ставки (β внутри; rate_future None → # §9.2 #1593: per-bucket velocity из velocity_by_room (objective_lots
# деградирует к нейтрали внутри себя, передаём 0.0 как placeholder). # per комнатность). _FORECAST_TO_METRIC_BUCKETS даёт список metric-ключей
norm = compute_demand_normalization( # для данного forecast_bucket ("80+ м²" = "4"+"5+"). Суммируем ед./мес по
db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0 # бакетам. Честный 0 при известных бакетах — НЕ fallback. Fallback на
) # агрегатный base_pace только когда velocity_by_room=None (пустая
# выборка) или bucket неизвестен в _FORECAST_TO_METRIC_BUCKETS.
metric_keys = _FORECAST_TO_METRIC_BUCKETS.get(forecast_bucket, [])
if vel_by_room is not None and metric_keys:
bucket_velocity: float = sum(vel_by_room.get(k, 0.0) for k in metric_keys)
# Если нет продаж ни в одном из ключей — честный 0 (НЕ fallback к
# aggregate: лучше показать реальный 0 чем маскировать его aggregate).
bucket_base_pace: float = bucket_velocity
else:
# vel_by_room None (пустая выборка) или неизвестный forecast_bucket →
# aggregate fallback сохраняет graceful поведение.
bucket_base_pace = float(base_pace)
# §9.4 нормализация под будущий режим ставки (β внутри). rate_future None
# (hold_last_rate не дал ставку) → НЕ применяем §9.4: 0.0-placeholder дал бы
# delta=rate_window_avg → exp(β·delta) и клэмп к _NORM_MAX (макс. аплифт),
# а НЕ нейтраль. Честная нейтраль при отсутствии будущей ставки = коэф. 1.0.
if rate_future is not None:
norm_coefficient = compute_demand_normalization(
db, spec=spec, rate_future=rate_future
).coefficient
else:
norm_coefficient = 1.0
# §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket. # §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket.
profile: dict[str, Any] = {"room_bucket": forecast_bucket} profile: dict[str, Any] = {"room_bucket": forecast_bucket}
if mapped_class is not None: if mapped_class is not None:
profile["obj_class"] = mapped_class profile["obj_class"] = mapped_class
macro_coef = compute_macro_coefficient(db, segment_profile=profile) macro_coef = compute_macro_coefficient(db, segment_profile=profile)
pace = base_pace * norm.coefficient * macro_coef.coefficient pace = bucket_base_pace * norm_coefficient * macro_coef.coefficient
paces.append((live_bucket, mapped_class, pace)) paces.append((live_bucket, mapped_class, pace))
max_pace = max((p for _, _, p in paces), default=0.0) max_pace = max((p for _, _, p in paces), default=0.0)
@ -635,7 +681,10 @@ def _demand_only_overlay(
demand_only=True, demand_only=True,
) )
# DESC по темпу; deficit_index = pace/max_pace ∈ (0,1] (ПРОКСИ, НЕ supply-based). # DESC по темпу; deficit_index = pace/max_pace ∈ (0,1].
# #1593: base_pace теперь per-bucket из velocity_by_room → ранкинг отражает
# реальный спрос по комнатности. §9.4 norm одинаков между bucket'ами и
# сокращается в pace/max_pace (β без per-bucket фита — честно задокументировано).
paces.sort(key=lambda t: t[2], reverse=True) paces.sort(key=lambda t: t[2], reverse=True)
ranked_segments: list[dict[str, Any]] = [ ranked_segments: list[dict[str, Any]] = [
{ {
@ -649,12 +698,13 @@ def _demand_only_overlay(
] ]
logger.info( logger.info(
"forecast-overlay demand_only: district=%s horizon=%d ranked=%d base_pace=%.3f " "forecast-overlay demand_only: district=%s horizon=%d ranked=%d "
"(ADVISORY, supply НЕ учтён)", "base_pace_agg=%.3f vel_by_room=%s (ADVISORY, supply НЕ учтён, #1593 per-bucket)",
district, district,
horizon_months, horizon_months,
len(ranked_segments), len(ranked_segments),
base_pace, base_pace,
vel_by_room,
) )
return _enrich_overlay( return _enrich_overlay(
db, db,
@ -698,13 +748,15 @@ def _overlay(
def _commercial_signal( def _commercial_signal(
db: Session, district: str | None, horizon_months: int db: Session, district: str | None, horizon_months: int
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful. """§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind= Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
"нежилое")`. objective покрывает в основном жильё выборка обычно тонкая. Тогда "нежилое")`. objective покрывает в основном жильё выборка обычно тонкая. Тогда
возвращаем degraded-honest {available: False, caveat, advisory} НЕ фабрикуем число. возвращаем degraded-honest {available: False, caveat, advisory} НЕ фабрикуем число.
Если данных достаточно ( _COMMERCIAL_MIN_LOTS лотов) советующая оценка доли Если данных достаточно ( _COMMERCIAL_MIN_LOTS лотов) советующая оценка ТЕМПА
коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason. РАСПРОДАЖИ нежилого: sell_through_pct = проданные ÷ (проданные+доступные)·100
(market_metrics §-определение) прокси ликвидности/спроса на нежилое, НЕ доля
нежилого в объёме застройки + §16-подобный reason.
НИКОГДА не бросает: любой сбой движка/импорта degraded-honest None-сигнал. НИКОГДА не бросает: любой сбой движка/импорта degraded-honest None-сигнал.
Args: Args:
@ -747,22 +799,26 @@ def _commercial_signal(
) )
return {"available": False, "caveat": caveat, "advisory": True} return {"available": False, "caveat": caveat, "advisory": True}
# Достаточно данных: советующая оценка реализованной доли коммерции (прокси). # Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (прокси
share_pct = round(sell_through, 1) # ликвидности/спроса), НЕ доли нежилого в объёме застройки (#1635).
sell_through_pct = round(sell_through, 1)
confidence = confidence if confidence in ("high", "medium", "low") else "low" confidence = confidence if confidence in ("high", "medium", "low") else "low"
return { return {
"available": True, "available": True,
"premise_kind": _COMMERCIAL_PREMISE_KIND, "premise_kind": _COMMERCIAL_PREMISE_KIND,
"commercial_share_pct": share_pct, # #1635: ключ = ТЕМП РАСПРОДАЖИ нежилого (sell_through, прокси
# ликвидности/спроса), НЕ доля нежилого в объёме застройки.
"commercial_sell_through_pct": sell_through_pct,
"n_lots": n_lots, "n_lots": n_lots,
"confidence": confidence, "confidence": confidence,
"reason": { "reason": {
"why": ( "why": (
f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} " f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)." f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
), ),
"drivers": [ "drivers": [
{"factor": "sell_through_pct", "value": share_pct, "direction": "+"}, {"factor": "sell_through_pct", "value": sell_through_pct, "direction": "+"},
{"factor": "n_lots", "value": n_lots, "direction": "+"}, {"factor": "n_lots", "value": n_lots, "direction": "+"},
], ],
"rejected": [], "rejected": [],
@ -780,14 +836,14 @@ def _as_int(value: Any) -> int | None:
Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой Защита §10.4 от мок/мусор-атрибутов (MagicMock < int бросил бы TypeError): любой
нечисловой/bool/сбойный вход None degraded-honest путь, без падения. нечисловой/bool/сбойный вход None degraded-honest путь, без падения.
""" """
if isinstance(value, bool) or not isinstance(value, (int, float)): if isinstance(value, bool) or not isinstance(value, int | float):
return None return None
return int(value) return int(value)
def _as_float(value: Any) -> float | None: def _as_float(value: Any) -> float | None:
"""Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE.""" """Безопасно привести значение к float (None/нечисловое → None). Graceful. PURE."""
if isinstance(value, bool) or not isinstance(value, (int, float)): if isinstance(value, bool) or not isinstance(value, int | float):
return None return None
return float(value) return float(value)

View file

@ -54,7 +54,7 @@ import numpy as np
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import get_monthly_macro from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
from app.services.forecasting.sales_series import ( from app.services.forecasting.sales_series import (
SegmentSpec, SegmentSpec,
@ -672,7 +672,7 @@ def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity
def _fit_to_sensitivity( def _fit_to_sensitivity(
fit: DistributedLagFit, *, segment: dict[str, str | None] fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
) -> RateSensitivity: ) -> RateSensitivity:
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract. """Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
@ -684,9 +684,16 @@ def _fit_to_sensitivity(
r2 / n_obs fit.r2 / fit.n r2 / n_obs fit.r2 / fit.n
confidence 'regression' "medium" (gated-OK but advisory-grade) | confidence 'regression' "medium" (gated-OK but advisory-grade) |
'fallback' "low" 'fallback' "low"
Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded, confounded passed in by the caller (computed from the ACTUAL fit window
shrinkage_weight) have no analogue in a district×class distributed-lag fit via is_confounded_window #1636). The §9.6 production path
(no room×area bucketing here) None / sensible defaults. PURE. OR-aggregates this with §9.5 macro_coefficient.confounded in
demand_supply_forecast._series_confounded шок-фактор (#1222).
The 48-мес regression window overlaps the 2024-07-01 shock long
after the 12-мес macro window stops doing so, so hardcoding
False here silently dropped the shock signal on this channel.
Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have
no analogue in a district×class distributed-lag fit (no room×area bucketing here)
None / sensible defaults. PURE.
BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier
Σ_j β_j on Δln the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT Σ_j β_j on Δln the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT
@ -705,7 +712,7 @@ def _fit_to_sensitivity(
r2=fit.r2, r2=fit.r2,
n_obs=fit.n, n_obs=fit.n,
shrinkage_weight=0.0, shrinkage_weight=0.0,
confounded=False, confounded=confounded,
confidence=confidence, confidence=confidence,
phrase=fit.phrase, phrase=fit.phrase,
) )
@ -778,4 +785,32 @@ def compute_rate_regime_sensitivity(
) )
return _insufficient_sensitivity(segment) return _insufficient_sensitivity(segment)
return _fit_to_sensitivity(fit, segment=segment) # #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits
# over the same macro grid as compute_district_rate_regression (get_monthly_macro,
# months_back); re-reading it here is a cache hit (same args). The 48-мес window
# crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this
# is exactly the channel that was silently never raising the shock flag (#1222).
confounded = _macro_window_confounded(db, months_back=months_back)
return _fit_to_sensitivity(fit, segment=segment, confounded=confounded)
def _macro_window_confounded(db: Session, *, months_back: int) -> bool:
"""True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636).
Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded
(PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что
использует compute_district_rate_regression cache-hit, без лишнего запроса.
Пустая сетка / сбой False (нет окна нечего конфаундить), НЕ crash.
"""
try:
months = [m.month for m in get_monthly_macro(db, months_back=months_back)]
except Exception:
logger.exception(
"rate_regime_sensitivity: macro window read for confounded-flag failed "
"(months_back=%d) → treating as not confounded",
months_back,
)
return False
if not months:
return False
return is_confounded_window(min(months), max(months))

View file

@ -189,10 +189,10 @@ def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | Non
""" """
if supply_layers is not None: if supply_layers is not None:
coverage = supply_layers.get("domrf_coverage") coverage = supply_layers.get("domrf_coverage")
if isinstance(coverage, (int, float)) and not isinstance(coverage, bool): if isinstance(coverage, int | float) and not isinstance(coverage, bool):
return _clamp_fraction(float(coverage)) return _clamp_fraction(float(coverage))
pct = analyze.get("market_data_coverage_pct") pct = analyze.get("market_data_coverage_pct")
if isinstance(pct, (int, float)) and not isinstance(pct, bool): if isinstance(pct, int | float) and not isinstance(pct, bool):
return _clamp_fraction(float(pct) / 100.0) return _clamp_fraction(float(pct) / 100.0)
return None return None
@ -223,6 +223,19 @@ def _history_months(
return None return None
def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months`
именно за это окно считается n_sold. Нет None (#990 пропускает суффикс «за N мес»).
"""
if market_metrics is not None:
window = market_metrics.get("window_months")
if isinstance(window, int) and window > 0:
return window
return None
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool: def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE. """Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
@ -303,10 +316,10 @@ def _primary_deficit_index(forecasts: Sequence[dict[str, Any]]) -> float | None:
) )
if primary is not None and primary.get("deficit_index") is not None: if primary is not None and primary.get("deficit_index") is not None:
di = primary["deficit_index"] di = primary["deficit_index"]
return float(di) if isinstance(di, (int, float)) and not isinstance(di, bool) else None return float(di) if isinstance(di, int | float) and not isinstance(di, bool) else None
for f in forecasts: for f in forecasts:
di = f.get("deficit_index") di = f.get("deficit_index")
if isinstance(di, (int, float)) and not isinstance(di, bool): if isinstance(di, int | float) and not isinstance(di, bool):
return float(di) return float(di)
return None return None
@ -324,10 +337,10 @@ def _primary_months_of_inventory(forecasts: Sequence[dict[str, Any]]) -> float |
) )
if primary is not None and primary.get("months_of_inventory") is not None: if primary is not None and primary.get("months_of_inventory") is not None:
moi = primary["months_of_inventory"] moi = primary["months_of_inventory"]
return float(moi) if isinstance(moi, (int, float)) and not isinstance(moi, bool) else None return float(moi) if isinstance(moi, int | float) and not isinstance(moi, bool) else None
for f in forecasts: for f in forecasts:
moi = f.get("months_of_inventory") moi = f.get("months_of_inventory")
if isinstance(moi, (int, float)) and not isinstance(moi, bool): if isinstance(moi, int | float) and not isinstance(moi, bool):
return float(moi) return float(moi)
return None return None
@ -352,7 +365,7 @@ def _overall_score(product_scores: dict[str, Any] | None) -> float | None:
if not isinstance(product_scores, dict): if not isinstance(product_scores, dict):
return None return None
overall = product_scores.get("overall") overall = product_scores.get("overall")
if isinstance(overall, (int, float)) and not isinstance(overall, bool): if isinstance(overall, int | float) and not isinstance(overall, bool):
return float(overall) return float(overall)
return None return None
@ -398,14 +411,26 @@ def _market_now_summary(
parts: list[str] = [] parts: list[str] = []
if market_metrics is not None: if market_metrics is not None:
velocity = market_metrics.get("unit_velocity") velocity = market_metrics.get("unit_velocity")
if isinstance(velocity, (int, float)) and not isinstance(velocity, bool): if isinstance(velocity, int | float) and not isinstance(velocity, bool):
parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес") parts.append(f"абсорбция ~{round(float(velocity), 1)} ед./мес")
avg_price = analyze.get("market_avg_price_per_m2") avg_price = analyze.get("market_avg_price_per_m2")
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool): if isinstance(avg_price, int | float) and not isinstance(avg_price, bool):
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " ")) parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
n_comp = _analog_count(analyze, market_metrics) # #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
if n_comp is not None: # всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
parts.append(f"{n_comp} ЖК-конкурентов рядом") # Метка честно следует источнику: obj_count → «в выборке района», локальный fallback
# из analyze (competitors_total / len(competitors)) → «рядом».
if market_metrics is not None and isinstance(market_metrics.get("obj_count"), int):
parts.append(f"{market_metrics['obj_count']} ЖК в выборке района")
else:
n_local: int | None = None
pulse = analyze.get("market_pulse")
if isinstance(pulse, dict) and isinstance(pulse.get("competitors_total"), int):
n_local = pulse["competitors_total"]
elif isinstance(analyze.get("competitors"), list):
n_local = len(analyze["competitors"])
if n_local is not None:
parts.append(f"{n_local} ЖК-конкурентов рядом")
if not parts: if not parts:
return None return None
return "Текущий рынок: " + ", ".join(parts) + "." return "Текущий рынок: " + ", ".join(parts) + "."
@ -616,6 +641,7 @@ def _build_confidence(
market_metrics, future_supply, forecasts, product_scores, special_indices market_metrics, future_supply, forecasts, product_scores, special_indices
), ),
deal_count=_deal_count(analyze, market_metrics), deal_count=_deal_count(analyze, market_metrics),
deal_count_months=_deal_count_months(market_metrics),
analog_count=_analog_count(analyze, market_metrics), analog_count=_analog_count(analyze, market_metrics),
domrf_coverage=_domrf_coverage(analyze, supply_layers), domrf_coverage=_domrf_coverage(analyze, supply_layers),
history_months=_history_months(market_metrics, forecasts), history_months=_history_months(market_metrics, forecasts),

View file

@ -143,7 +143,12 @@ _GEO_WEIGHT_SCALE_KM: float = 3.0
# НЕ исключаем проект и НЕ даём полный вес — низкий floor, чтобы он мог сигналить, но # НЕ исключаем проект и НЕ даём полный вес — низкий floor, чтобы он мог сигналить, но
# не доминировал над проектом с подтверждённой близостью. None-not-0 дух (есть проект, # не доминировал над проектом с подтверждённой близостью. None-not-0 дух (есть проект,
# но геопривязка слабая → down-weight, а не выкидываем). # но геопривязка слабая → down-weight, а не выкидываем).
_GEO_WEIGHT_UNKNOWN: float = 0.1 #
# ВАЖНО: значение НИЖЕ exp(6.9/3)≈0.10 (вес подтверждённо дальнего ~6.9 км проекта),
# иначе «координаты неизвестны» даёт бо́льший вес чем «подтверждённо далеко» — инверсия.
# При scale=3 км: exp(9/3)=exp(3)≈0.050. Значение 0.05 ≈ «условные 9 км»: проект без
# координат не перебивает подтверждённо дальний (>6.9 км), но сохраняет ненулевой сигнал.
_GEO_WEIGHT_UNKNOWN: float = 0.05
# Минимум доступных осей пересечения для пары кандидат↔наш проект, чтобы считать оценку # Минимум доступных осей пересечения для пары кандидат↔наш проект, чтобы считать оценку
# надёжной. < этого (напр. только класс) → пара low-confidence (сигнал есть, но тонкий). # надёжной. < этого (напр. только класс) → пара low-confidence (сигнал есть, но тонкий).
@ -1023,9 +1028,7 @@ def _launch_window_horizon(launch_window: SpecialIndex) -> int | None:
return horizon if isinstance(horizon, int) else None return horizon if isinstance(horizon, int) else None
def _candidate_release_month( def _candidate_release_month(launch_window: SpecialIndex, *, as_of: date) -> date | None:
launch_window: SpecialIndex, *, as_of: date
) -> date | None:
"""Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE. """Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE.
= as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает = as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает
@ -1288,9 +1291,7 @@ def _build_cannibalization_true(
contributions: list[tuple[float, OwnProject, _PairOverlap]] = [] contributions: list[tuple[float, OwnProject, _PairOverlap]] = []
for own in portfolio: for own in portfolio:
distance_km = _own_distance_km(centroid, own) distance_km = _own_distance_km(centroid, own)
price_overlap = _price_overlap( price_overlap = _price_overlap(candidate_band, own.price_min_per_m2, own.price_max_per_m2)
candidate_band, own.price_min_per_m2, own.price_max_per_m2
)
pair = _own_portfolio_overlap( pair = _own_portfolio_overlap(
class_overlap=_class_overlap(spec.obj_class, own.obj_class), class_overlap=_class_overlap(spec.obj_class, own.obj_class),
price_overlap=price_overlap, price_overlap=price_overlap,
@ -1315,7 +1316,7 @@ def _build_cannibalization_true(
# Топ-вкладчики (сильнейшие каннибализаторы) — детерминированно по signal DESC, # Топ-вкладчики (сильнейшие каннибализаторы) — детерминированно по signal DESC,
# tie-break по имени проекта (стабильно, без RNG/порядка set). # tie-break по имени проекта (стабильно, без RNG/порядка set).
contributions.sort(key=lambda t: (-t[0], t[1].name)) contributions.sort(key=lambda t: (-t[0], t[1].name))
top = contributions[: _COMPETITOR_TOP_N] top = contributions[:_COMPETITOR_TOP_N]
confidence = _cap_confidence(_portfolio_confidence(portfolio)) confidence = _cap_confidence(_portfolio_confidence(portfolio))
return SpecialIndex( return SpecialIndex(
key=KEY_CANNIBALIZATION, key=KEY_CANNIBALIZATION,
@ -1382,9 +1383,7 @@ def _axes_available_summary(pairs: Sequence[_PairOverlap]) -> dict[str, int]:
return summary return summary
def _contribution_detail( def _contribution_detail(signal: float, own: OwnProject, pair: _PairOverlap) -> dict[str, Any]:
signal: float, own: OwnProject, pair: _PairOverlap
) -> dict[str, Any]:
"""Explainability-карточка одного топ-каннибализатора. PURE.""" """Explainability-карточка одного топ-каннибализатора. PURE."""
return { return {
"name": own.name, "name": own.name,
@ -1689,7 +1688,13 @@ def compute_special_indices(
indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS} indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
confidence = _min_confidence([idx.confidence for idx in indices.values()]) # confidence = MIN по ДОСТУПНЫМ индексам (контракт SpecialIndices / docstring выше).
# Недоступный индекс (_unavailable → value=None, confidence='low') НЕ участвует: его
# 'low' — артефакт деградации, а не сигнал низкой уверенности доступных индексов
# (#1592: _min_confidence отбрасывает только None, поэтому фильтруем здесь по value).
confidence = _min_confidence(
[idx.confidence for idx in indices.values() if idx.value is not None]
)
n_available = sum(1 for idx in indices.values() if idx.value is not None) n_available = sum(1 for idx in indices.values() if idx.value is not None)
logger.info( logger.info(

View file

@ -22,6 +22,7 @@ import logging
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__; # ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
# импорт из модуля удовлетворяет strict no-implicit-reexport. # импорт из модуля удовлетворяет strict no-implicit-reexport.
from ezdxf.enums import TextEntityAlignment
from ezdxf.filemanagement import new as ezdxf_new from ezdxf.filemanagement import new as ezdxf_new
from shapely.geometry import Polygon from shapely.geometry import Polygon
@ -43,13 +44,33 @@ _LAYER_BUILDINGS = "BUILDINGS"
_LABEL_HEIGHT_M = 2.0 _LABEL_HEIGHT_M = 2.0
def _polygon_points(poly: Polygon) -> list[tuple[float, float]]: def _ring_points(coords: object) -> list[tuple[float, float]]:
"""Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки).""" """Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
coords = list(poly.exterior.coords) pts = list(coords)
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам. # Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
if len(coords) > 1 and coords[0] == coords[-1]: if len(pts) > 1 and pts[0] == pts[-1]:
coords = coords[:-1] pts = pts[:-1]
return [(float(x), float(y)) for x, y in coords] return [(float(x), float(y)) for x, y in pts]
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
замкнутой полилинией на том же слое иначе легальные вырезы (двор, сервитут,
охранная зона) терялись бы и заливались сплошняком.
"""
msp.add_lwpolyline(
_ring_points(poly.exterior.coords),
close=True,
dxfattribs={"layer": layer},
)
for interior in poly.interiors:
msp.add_lwpolyline(
_ring_points(interior.coords),
close=True,
dxfattribs={"layer": layer},
)
def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes: def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
@ -71,16 +92,8 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
msp = doc.modelspace() msp = doc.modelspace()
# Участок и пятно застройки — из метрической геометрии Parcel. # Участок и пятно застройки — из метрической геометрии Parcel.
msp.add_lwpolyline( _add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
_polygon_points(parcel.polygon_m), _add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
close=True,
dxfattribs={"layer": _LAYER_PARCEL},
)
msp.add_lwpolyline(
_polygon_points(parcel.buildable_m),
close=True,
dxfattribs={"layer": _LAYER_BUILDABLE},
)
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта. # Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
features = variant.buildings_geojson.get("features", []) features = variant.buildings_geojson.get("features", [])
@ -91,18 +104,17 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
if footprint is None: if footprint is None:
continue continue
section_count += 1 section_count += 1
msp.add_lwpolyline( _add_polygon(msp, footprint, _LAYER_BUILDINGS)
_polygon_points(footprint),
close=True,
dxfattribs={"layer": _LAYER_BUILDINGS},
)
centroid = footprint.centroid centroid = footprint.centroid
label = str(_feature_section_id(feature, section_count)) label = str(_feature_section_id(feature, section_count))
text = msp.add_text( text = msp.add_text(
label, label,
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M}, dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
) )
text.set_placement((float(centroid.x), float(centroid.y))) text.set_placement(
(float(centroid.x), float(centroid.y)),
align=TextEntityAlignment.MIDDLE_CENTER,
)
stream = io.BytesIO() stream = io.BytesIO()
doc.write(stream, fmt="bin") doc.write(stream, fmt="bin")

View file

@ -30,7 +30,7 @@ from shapely.strtree import STRtree
from app.schemas.concept import ConceptInput, ConceptVariant from app.schemas.concept import ConceptInput, ConceptVariant
from app.services.generative import financial, teap from app.services.generative import financial, teap
from app.services.generative.geometry import Parcel from app.services.generative.geometry import Parcel, ParcelGeometryError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -191,11 +191,25 @@ def place_strategy(
parcel: Parcel, parcel: Parcel,
payload: ConceptInput, payload: ConceptInput,
spec: StrategySpec, spec: StrategySpec,
) -> ConceptVariant: ) -> ConceptVariant | None:
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.""" """Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
"""
floors = _resolve_floors(payload.target_floors, spec.floors_factor) floors = _resolve_floors(payload.target_floors, spec.floors_factor)
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP) coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
footprints = _greedy_place(parcel, spec, coverage_cap) footprints = _greedy_place(parcel, spec, coverage_cap)
if not footprints:
logger.warning(
"strategy=%s placed 0 sections (footprint %.0fx%.0f m not buildable) — отбраковка",
spec.name,
spec.section_w,
spec.section_d,
)
return None
teap_result = teap.compute_teap( teap_result = teap.compute_teap(
footprints=footprints, footprints=footprints,
@ -220,8 +234,22 @@ def place_strategy(
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]: def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).""" """Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
Вырожденные стратегии (нулевое размещение) отбраковываются в результат попадают
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
:class:`ParcelGeometryError` (API мапит в 422) лучше отказ, чем пустой/лживый ответ.
"""
variants = [
variant
for spec in _STRATEGIES
if (variant := place_strategy(parcel, payload, spec)) is not None
]
if not variants:
raise ParcelGeometryError(
"ни одна стратегия размещения не вместила секцию — участок слишком узкий/мелкий"
)
logger.info( logger.info(
"placed all strategies: %s", "placed all strategies: %s",
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants), ", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),

View file

@ -58,6 +58,10 @@ class LLMResult:
fallback_used: True если результат сигнал к детерминированному fallback. fallback_used: True если результат сигнал к детерминированному fallback.
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/ reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
redaction_refused/provider_error/call_cap/no_api_key). None при ok. redaction_refused/provider_error/call_cap/no_api_key). None при ok.
finish_reason: причина завершения от провайдера (stop/length/content_filter/
tool_calls/). None при fallback. Консьюмер ОБЯЗАН проверять её даже при
ok=True: 'length'/'content_filter' ответ обрезан/отфильтрован (частичный
или пустой content) и не является полноценным результатом.
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback). prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
model: модель, ответившая на запрос ("" при fallback). model: модель, ответившая на запрос ("" при fallback).
""" """
@ -67,6 +71,7 @@ class LLMResult:
tool_calls: list[ToolCall] = field(default_factory=list) tool_calls: list[ToolCall] = field(default_factory=list)
fallback_used: bool = False fallback_used: bool = False
reason: str | None = None reason: str | None = None
finish_reason: str | None = None
prompt_tokens: int = 0 prompt_tokens: int = 0
completion_tokens: int = 0 completion_tokens: int = 0
model: str = "" model: str = ""
@ -81,6 +86,7 @@ class LLMResult:
ok=True, ok=True,
content=resp.content, content=resp.content,
tool_calls=list(resp.tool_calls), tool_calls=list(resp.tool_calls),
finish_reason=resp.finish_reason,
prompt_tokens=resp.prompt_tokens, prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens, completion_tokens=resp.completion_tokens,
model=resp.model, model=resp.model,

View file

@ -91,6 +91,21 @@ class LLMProvider(ABC):
# ── OpenAI (external) ───────────────────────────────────────────────────────── # ── OpenAI (external) ─────────────────────────────────────────────────────────
def _coerce_token_count(value: Any) -> int:
"""usage-токены → int, толерантно к мусору (str "abc"/None/dict → 0).
OpenAI обычно отдаёт int, но через внешний прокси/нестандартный провайдер поле
может прийти нечисловым. int() на таком значении бросил бы ValueError/TypeError
мимо LLM*-контракта (его ловит client._call_with_retries) и пробил бы инвариант
«complete никогда не падает наружу» (#1601). Невалидный токен-счётчик — не повод
ронять ответ: деградируем до 0.
"""
try:
return int(value or 0)
except (ValueError, TypeError):
return 0
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse: def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls).""" """Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
choices = data.get("choices") or [] choices = data.get("choices") or []
@ -114,8 +129,8 @@ def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> Prov
content=message.get("content"), content=message.get("content"),
tool_calls=tool_calls, tool_calls=tool_calls,
finish_reason=choice.get("finish_reason"), finish_reason=choice.get("finish_reason"),
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0), prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
completion_tokens=int(usage.get("completion_tokens", 0) or 0), completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
model=str(data.get("model") or fallback_model), model=str(data.get("model") or fallback_model),
) )

View file

@ -24,6 +24,7 @@ from __future__ import annotations
import logging import logging
import re import re
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@ -77,10 +78,68 @@ _PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя. # пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС. # Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)") _PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
# Телефон РФ «локальный»: 10 значащих цифр БЕЗ кода страны, начинаются с мобильного
# «9», сгруппированы 9XX XXX XX XX через пробел/дефис (#1641). _PHONE_RE требует
# префикс +7/8, _PHONE_BARE_RE — ровно 11 слитных цифр → формат «922 123 45 67» /
# «922-123-45-67» проходил мимо. Якорь «9» + фиксированная группировка 3-3-2-2 не
# пересекается с паспортом (4+6) / СНИЛС (3-3-3 2). Ставится ПОСЛЕ префиксных форм,
# чтобы local-часть «+7 912 …» сначала ушла как полноценный phone.
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
# Email. # Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b") _EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам. # ИНН: требуется контекстный якорь — маркер «ИНН» / «inn» (регистронезависимо) не
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)") # далее ~20 символов перед 10/12-значным блоком цифр (#1640). Голые цифры без якоря
# НЕ редактируются — это исключает легитимные суммы (1 200 000 000 = 10 цифр) и
# прочие числа, которые случайно совпадают с длиной ИНН.
# «Прокладка» (?:[^\d\n]{0,20}) позволяет «ИНН физлица 500100732259» (слово между
# маркером и числом), но не перешагивает через строки или другие цифровые блоки.
# Группа 1 захватывает только цифры — передаётся в _inn_checksum_valid.
# Границы слова (#1640 follow-up): (?<!\w) / (?!\w) вокруг цифрового блока гарантируют,
# что блок не является частью более длинного алфавитно-цифрового токена.
# \b не подходит: Python \w включает цифры и буквы, поэтому \b между буквой и цифрой
# отсутствует (оба — \w). Lookaround'ы (?<!\w)/(?!\w) дают нужную границу «не-\w перед/после».
# (?<!\d) заменён на (?<!\w): закрывает случай «ref7707083893» (alpha-префикс).
# (?!\d) заменён на (?!\w): закрывает случай «7707083893more» (alpha-суффикс).
_INN_RE = re.compile(r"(?i:инн|inn)\s*[:№]?\s*(?:[^\d\n]{0,20})(?<!\w)(\d{12}|\d{10})(?!\w)")
def _inn_checksum_valid(token: str) -> bool:
"""Проверить контрольные цифры ИНН по алгоритму ФНС.
10-значный (юрлицо): одна контрольная цифра (последняя).
12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я).
Веса стандартные коэффициенты ФНС.
Вызывается из _inn_repl с group(1) только цифровой блок без keyword-префикса.
Дополнительный gate поверх контекстного якоря (_INN_RE): отфильтровывает
синтетические 10/12-значные числа, у которых первые цифры случайно дают верную
контрольную сумму.
"""
digits = [int(c) for c in token]
if len(digits) == 10:
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
control = sum(w * d for w, d in zip(weights, digits[:9], strict=True)) % 11 % 10
return control == digits[9]
if len(digits) == 12:
w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
c1 = sum(w * d for w, d in zip(w1, digits[:10], strict=True)) % 11 % 10
c2 = sum(w * d for w, d in zip(w2, digits[:11], strict=True)) % 11 % 10
return c1 == digits[10] and c2 == digits[11]
return False
def _inn_repl(match: re.Match[str]) -> str:
"""re.sub callback: редактировать ИНН только при валидной контрольной сумме.
_INN_RE захватывает цифры в group(1) keyword-префикс в group(0). Для замены
нужна только часть с цифрами (group 1). Если checksum не проходит возвращаем
исходную строку целиком (keyword + цифры), не удаляем ничего.
"""
digits = match.group(1)
return "[REDACTED:inn]" if _inn_checksum_valid(digits) else match.group(0)
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует # СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается # формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ # с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
@ -97,18 +156,22 @@ _FULLNAME_RE = re.compile(
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b" r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
) )
# (regex, placeholder-kind). Применяются последовательно в этом порядке. # (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
# Применяются последовательно в этом порядке.
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные # Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а). # с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( _Repl = str | Callable[[re.Match[str]], str]
(_SNILS_RE, "snils"), _PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
(_PASSPORT_RE, "passport"), (_SNILS_RE, "snils", "[REDACTED:snils]"),
(_PHONE_RE, "phone"), (_PASSPORT_RE, "passport", "[REDACTED:passport]"),
(_PHONE_BARE_RE, "phone"), (_PHONE_RE, "phone", "[REDACTED:phone]"),
(_EMAIL_RE, "email"), (_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
(_INN_RE, "inn"), (_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
(_SNILS_BARE_RE, "snils"), (_EMAIL_RE, "email", "[REDACTED:email]"),
(_FULLNAME_RE, "name"), (_INN_RE, "inn", _inn_repl),
(_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
(_FULLNAME_RE, "name", "[REDACTED:name]"),
) )
@ -121,9 +184,16 @@ def scrub_text(value: str) -> str:
if not value: if not value:
return value return value
redacted = value redacted = value
for pattern, kind in _PII_PATTERNS: placeholder = "[REDACTED:%s]"
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted) for pattern, kind, repl in _PII_PATTERNS:
if n: # n из subn для callback-repl (ИНН) считает ВСЕ совпадения, включая кандидаты,
# которые callback вернул без изменений (не прошли checksum). Поэтому реальное
# число замен берём по приросту числа плейсхолдеров — корректно и для str, и
# для callback, без утечки самого PII-значения в лог.
before_count = redacted.count(placeholder % kind)
redacted = pattern.sub(repl, redacted)
n = redacted.count(placeholder % kind) - before_count
if n > 0:
# Логируем ТОЛЬКО kind и количество — без самого PII-значения. # Логируем ТОЛЬКО kind и количество — без самого PII-значения.
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind) logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
return redacted return redacted
@ -135,7 +205,7 @@ def _scrub_value(value: Any) -> Any:
return scrub_text(value) return scrub_text(value)
if isinstance(value, dict): if isinstance(value, dict):
return {k: _scrub_value(v) for k, v in value.items()} return {k: _scrub_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)): if isinstance(value, list | tuple):
scrubbed = [_scrub_value(v) for v in value] scrubbed = [_scrub_value(v) for v in value]
return type(value)(scrubbed) return type(value)(scrubbed)
return value return value

View file

@ -24,6 +24,7 @@ import logging
import re import re
import sqlite3 import sqlite3
from collections.abc import Callable from collections.abc import Callable
from contextlib import closing
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -466,13 +467,12 @@ def get_sqlite_info(sqlite_path: str | Path) -> dict[str, Any]:
info["size_bytes"] = st.st_size info["size_bytes"] = st.st_size
info["modified_at"] = st.st_mtime # epoch seconds info["modified_at"] = st.st_mtime # epoch seconds
try: try:
c = sqlite3.connect(p) with closing(sqlite3.connect(p)) as c:
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0] info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
info["corp_room_month"] = c.execute("SELECT COUNT(*) FROM objective_corp_month").fetchone()[ info["corp_room_month"] = c.execute(
0 "SELECT COUNT(*) FROM objective_corp_month"
] ).fetchone()[0]
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0] info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
c.close()
except sqlite3.Error as e: except sqlite3.Error as e:
info["error"] = str(e) info["error"] = str(e)
return info return info

View file

@ -11,6 +11,7 @@ opens the original DOM.РФ URL (we don't need to mirror originals).
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from pathlib import Path from pathlib import Path
from PIL import Image, ImageOps from PIL import Image, ImageOps
@ -36,21 +37,40 @@ def make_thumbnail(
) -> Path | None: ) -> Path | None:
"""Generate a WebP cover-thumbnail next to src. Returns thumb path on success. """Generate a WebP cover-thumbnail next to src. Returns thumb path on success.
Skips if thumb already exists and overwrite=False. Returns None on any error Skips if thumb already exists, is not stale (dst mtime >= src mtime) and
overwrite=False. A stale thumb (src rewritten after thumb, e.g. original
re-downloaded on size mismatch) is regenerated. Returns None on any error
(logged) so callers can keep going with the next file. (logged) so callers can keep going with the next file.
""" """
if not src.exists(): if not src.exists():
return None return None
dst = thumb_path_for(src) dst = thumb_path_for(src)
if dst.exists() and not overwrite: if dst.exists() and not overwrite:
return dst # Existence alone is not freshness: if src was rewritten after dst was
# generated, the thumb is stale and must be regenerated. Only skip when
# the thumb is at least as new as the source.
try:
if dst.stat().st_mtime >= src.stat().st_mtime:
return dst
except OSError as e:
# stat failed (race / removed) — fall through and try to regenerate.
logger.warning("thumbnail freshness check %s failed: %s", src, e)
try: try:
with Image.open(src) as im: with Image.open(src) as im:
im = ImageOps.exif_transpose(im) im = ImageOps.exif_transpose(im)
im = im.convert("RGB") im = im.convert("RGB")
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS) im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
dst.parent.mkdir(parents=True, exist_ok=True) dst.parent.mkdir(parents=True, exist_ok=True)
im.save(dst, format="WEBP", quality=quality, method=4) # Atomic write: encode to a sibling temp file, then os.replace() so a
# crash/OOM mid-encode never leaves a truncated .webp at the canonical
# path (which dst.exists() would later treat as a valid cached thumb).
tmp = dst.with_name(f".{dst.name}.tmp")
try:
im.save(tmp, format="WEBP", quality=quality, method=4)
os.replace(tmp, dst)
except BaseException:
tmp.unlink(missing_ok=True)
raise
return dst return dst
except Exception as e: except Exception as e:
logger.warning("thumbnail %s failed: %s", src, e) logger.warning("thumbnail %s failed: %s", src, e)

View file

@ -71,6 +71,38 @@ STATUS_FREE = "free"
STATUS_SOLD = "sold" STATUS_SOLD = "sold"
STATUS_RESERVED = "reserved" STATUS_RESERVED = "reserved"
# Паттерны для извлечения статуса (issue #1609).
# Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?.
_STATUS_KW_RE = re.compile(
r"\s*продаже|свободн[аоы]?|free"
r"|продан[аоы]?|реализован[аоы]?|sold"
r"|забронирован[аоы]?|бронь|reserved)",
re.IGNORECASE | re.UNICODE,
)
# CSS-классы, характерные для статус-бейджей в Next.js/Tailwind.
# Требуем слово «status» как отдельный токен внутри hyphen-separated CSS-класса
# (напр. «status-badge», «flat-status-tag», «object-status»).
# Предыдущий паттерн «status|badge|tag|chip|label» был слишком широк —
# срабатывал на любой generic элемент с классом «tag»/«chip»/«label»/«badge»,
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
def _classify_status_kw(matched_text: str) -> str | None:
"""Классифицировать совпавшее ключевое слово в STATUS_*.
sold/reserved проверяются ПЕРВЫМИ они специфичнее "в продаже" и никогда
не появляются в навигационных блоках страницы, в отличие от «в продаже».
"""
s = matched_text.lower()
if any(kw in s for kw in ("продан", "реализован", "sold")):
return STATUS_SOLD
if any(kw in s for kw in ("бронь", "забронирован", "reserved")):
return STATUS_RESERVED
if any(kw in s for kw in ("продаже", "свободн", "free")):
return STATUS_FREE
return None
# ── HTML fetching ───────────────────────────────────────────────────────────── # ── HTML fetching ─────────────────────────────────────────────────────────────
@ -157,6 +189,28 @@ class _TextCollector(HTMLParser):
extraction известных структур страницы каталога. extraction известных структур страницы каталога.
""" """
# HTML5 void-элементы: не имеют закрывающего тега → handle_endtag не вызывается.
# Если пушить их в стек/буфер, чужой handle_endtag поп'ает чужой фрейм →
# рассинхрон стека, текст блоков теряется (issue #1608).
_VOID_TAGS = frozenset(
{
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
)
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._stack: list[tuple[str, dict[str, str]]] = [] self._stack: list[tuple[str, dict[str, str]]] = []
@ -165,11 +219,15 @@ class _TextCollector(HTMLParser):
self._buf: list[str] = [] self._buf: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in self._VOID_TAGS:
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
attr_dict = {k: (v or "") for k, v in attrs} attr_dict = {k: (v or "") for k, v in attrs}
self._stack.append((tag, attr_dict)) self._stack.append((tag, attr_dict))
self._buf.append("") # начало нового буфера для этого тега self._buf.append("") # начало нового буфера для этого тега
def handle_endtag(self, _tag: str) -> None: def handle_endtag(self, _tag: str) -> None:
if _tag in self._VOID_TAGS:
return # void-теги фрейм не открывали — нечего поп'ать (issue #1608)
if not self._stack: if not self._stack:
return return
tag, attr_dict = self._stack.pop() tag, attr_dict = self._stack.pop()
@ -239,8 +297,11 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры. # Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
# Price: "7 890 000 ₽" или "7 890 000 руб" # Price: "7 890 000 ₽" или "7 890 000 руб"
# Negative lookahead (?!\s*[/]) исключает цену за м² ("217 835 ₽/м²"),
# которая на странице обычно выше полной стоимости и иначе матчилась бы
# первой → отбрасывалась санити-границей → price_rub=NULL (issue #1645).
price_match = re.search( price_match = re.search(
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)", r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)(?!\s*[/])",
html, html,
re.UNICODE, re.UNICODE,
) )
@ -267,20 +328,66 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
except ValueError: except ValueError:
pass pass
# Status: ищем характерные слова рядом с "статус" или в badge # Status extraction (issue #1609 fix).
status_match = re.search( #
r"\s*продаже|свободна|free|продано|sold|забронирована|бронь|reserved)", # Проблема: re.search по всему HTML находит "в продаже" из навигации
html, # ("другие квартиры в продаже") РАНЬШЕ, чем реальный статус-бейдж →
re.IGNORECASE | re.UNICODE, # проданная/забронированная квартира ошибочно маркируется как free.
) #
if status_match: # Стратегия (3 уровня, от точного к широкому):
s = status_match.group(1).lower() #
if any(kw in s for kw in ("продаже", "свободна", "free")): # 1. Блоки с CSS-классом, содержащим "status"/"badge"/"tag"/"chip"/"label" —
result["status"] = STATUS_FREE # типовые имена для статус-бейджей в Next.js/Tailwind.
elif any(kw in s for kw in ("продано", "sold")): # 2. Ближайший блок ПОСЛЕ лейбла "Статус" (proximity-поиск в blocks).
result["status"] = STATUS_SOLD # 3. Полный перебор всех blocks: sold/reserved побеждают free
elif any(kw in s for kw in ("бронь", "забронирована", "reserved")): # (они никогда не появляются в навигации, в отличие от "в продаже").
result["status"] = STATUS_RESERVED #
# Морфоварианты: продан / продана / продано / продан → продан[аоы]?
# Аналогично: забронирован[аоы]? / реализован[аоы]? (синоним для sold).
# Уровень 1: блок с CSS-классом типа badge/status/tag
status_from_badge: str | None = None
for cls, block_text in blocks:
if _STATUS_BADGE_CLS_RE.search(cls):
m = _STATUS_KW_RE.search(block_text)
if m:
status_from_badge = _classify_status_kw(m.group(1))
if status_from_badge:
break
if status_from_badge:
result["status"] = status_from_badge
else:
# Уровень 2: блок сразу после лейбла "Статус"
status_label_value = _find_text_near(blocks, r"^статус$")
status_from_label: str | None = None
if status_label_value:
m2 = _STATUS_KW_RE.search(status_label_value)
if m2:
status_from_label = _classify_status_kw(m2.group(1))
if status_from_label:
result["status"] = status_from_label
else:
# Уровень 3: перебор всех blocks — sold/reserved побеждают free.
# Если встретили sold/reserved — сразу break (не появляются в nav).
# Если только free — запоминаем как кандидата (может быть перебит).
free_candidate: bool = False
sold_reserved_found: str | None = None
for _cls, block_text in blocks:
m3 = _STATUS_KW_RE.search(block_text)
if not m3:
continue
classified = _classify_status_kw(m3.group(1))
if classified in (STATUS_SOLD, STATUS_RESERVED):
sold_reserved_found = classified
break # точнее nav-текстов, дальше не ищем
if classified == STATUS_FREE:
free_candidate = True # продолжаем — вдруг sold встретится позже
if sold_reserved_found:
result["status"] = sold_reserved_found
elif free_candidate:
result["status"] = STATUS_FREE
# Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ" # Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
finishing_match = re.search( finishing_match = re.search(
@ -483,7 +590,18 @@ async def scrape_one_flat(
outcome["fields_extracted"] = len([v for v in data.values() if v is not None]) outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data) outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
# success отражает прохождение пайплайна fetch+parse без исключения; реально
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
# ненайденном ods_id / пустом парсе (fields_extracted==0) — issue #1610.
outcome["success"] = True outcome["success"] = True
if not outcome["updated"]:
logger.warning(
"catalog scrape ods_id=%s: fetched+parsed but DB row NOT updated "
"(fields=%d, ods_id missing or all-NULL parse)",
ods_id,
outcome["fields_extracted"],
)
logger.info( logger.info(
"catalog scrape ods_id=%s: fields=%d updated=%s", "catalog scrape ods_id=%s: fields=%d updated=%s",
ods_id, ods_id,
@ -512,11 +630,15 @@ async def scrape_catalog_batch(
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem). jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
Returns: Returns:
{total, success, failed, fields_total} {total, success, updated, failed, fields_total}
- success: прошли fetch+parse без исключения
- updated: реально затронули строку в БД (issue #1610) — отражает
фактическое число записанных квартир, в отличие от success
""" """
stats: dict[str, Any] = { stats: dict[str, Any] = {
"total": len(flats), "total": len(flats),
"success": 0, "success": 0,
"updated": 0,
"failed": 0, "failed": 0,
"fields_total": 0, "fields_total": 0,
} }
@ -550,6 +672,8 @@ async def scrape_catalog_batch(
if outcome["success"]: if outcome["success"]:
stats["success"] += 1 stats["success"] += 1
stats["fields_total"] += outcome["fields_extracted"] stats["fields_total"] += outcome["fields_extracted"]
if outcome["updated"]:
stats["updated"] += 1
else: else:
stats["failed"] += 1 stats["failed"] += 1
@ -565,9 +689,10 @@ async def scrape_catalog_batch(
raise raise
logger.info( logger.info(
"scrape_catalog_batch done: total=%d success=%d failed=%d fields_total=%d", "scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
stats["total"], stats["total"],
stats["success"], stats["success"],
stats["updated"],
stats["failed"], stats["failed"],
stats["fields_total"], stats["fields_total"],
) )

View file

@ -115,6 +115,30 @@ def _to_int(v: Any) -> int | None:
return None return None
def _to_float(v: Any) -> float | None:
"""Coerce a DOM.РФ numeric value into float. None / bool / non-numeric / empty → None.
Accepts int, float, and numeric strings ('45.2'). Mirror of _to_int for area/price."""
if v is None or isinstance(v, bool):
return None
if isinstance(v, int | float):
f = float(v)
if f != f or f in (float("inf"), float("-inf")): # NaN / ±inf guard
return None
return f
if isinstance(v, str):
s = v.strip()
if not s:
return None
try:
f = float(s)
except (ValueError, OverflowError):
return None
if f != f or f in (float("inf"), float("-inf")):
return None
return f
return None
def _to_date(v: Any) -> date | None: def _to_date(v: Any) -> date | None:
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS', """Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'. 'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
@ -414,8 +438,13 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
# Derive price_per_m2 when API returns price_rub but omits pricePerSquareMeter. # Derive price_per_m2 when API returns price_rub but omits pricePerSquareMeter.
# Covers cases where the table endpoint has the flat price but no pre-computed m² rate. # Covers cases where the table endpoint has the flat price but no pre-computed m² rate.
if price_per_m2 is None and price_rub is not None and total_area and total_area > 0: # Coerce оба операнда в float ДО сравнения/деления: API иногда отдаёт totalArea/price
price_per_m2 = round(price_rub / total_area, 2) # строкой ('45.2'), и `total_area > 0` на str роняло бы _norm_flat с TypeError
# → весь run падал бы в status='failed' (#1644).
price_rub_num = _to_float(price_rub)
total_area_num = _to_float(total_area)
if price_per_m2 is None and price_rub_num is not None and total_area_num and total_area_num > 0:
price_per_m2 = round(price_rub_num / total_area_num, 2)
logger.info( logger.info(
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s", "derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
price_per_m2, price_per_m2,
@ -1432,34 +1461,78 @@ def _place_str(region_code: int) -> str:
return str(region_code) return str(region_code)
OBJECTS_PAGE_SIZE = 500
async def fetch_objects_for_status( async def fetch_objects_for_status(
sess: BrowserSession, place: str, status: int sess: BrowserSession, place: str, status: int
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999.""" """Fetch ALL objects for a given (place, objStatus), пагинируя по страницам.
payload = await sess.get_json(
PATH_OBJECTS, Раньше делали единственный запрос с limit=999999 и доверяли допущению, что сервер
{ вернёт всё. Если WAF/прокси DOM.РФ обрезает гигантский limit до своего max, хвост
"offset": 0, объектов молча терялся (не скрейпился), а run всё равно рапортовал status='done' (#1605).
"limit": 999999, Теперь идём страницами по OBJECTS_PAGE_SIZE и аккумулируем, пока не наберём total
"sortField": "default", (из payload) либо страница не вернётся короче запрошенной / пустой.
"sortType": "desc", """
"place": place, rows: list[dict[str, Any]] = []
"objStatus": status, offset = 0
}, total: int | None = None
while True:
payload = await sess.get_json(
PATH_OBJECTS,
{
"offset": offset,
"limit": OBJECTS_PAGE_SIZE,
"sortField": "default",
"sortType": "desc",
"place": place,
"objStatus": status,
},
)
page = _extract_list(payload)
if total is None:
total = _extract_total(payload)
rows.extend(page)
# Стоп-условия: пустая страница, недобор до размера страницы (последняя),
# либо набрали заявленный total. total может быть None (сервер его не отдал) —
# тогда полагаемся на размер страницы как сигнал конца.
if not page or len(page) < OBJECTS_PAGE_SIZE:
break
if total is not None and len(rows) >= total:
break
offset += OBJECTS_PAGE_SIZE
logger.info(
"kn/object place=%s status=%d -> %d/%s rows (paginated, page=%d)",
place,
status,
len(rows),
total,
OBJECTS_PAGE_SIZE,
) )
rows = _extract_list(payload) if total is not None and len(rows) < total:
total = _extract_total(payload) logger.warning(
logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total) "kn/object place=%s status=%d: получено %d < total=%d — возможен недобор хвоста",
place,
status,
len(rows),
total,
)
return rows return rows
async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]: async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]:
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened).""" """Fetch flat-table for one object, return flat rows (entrance/floor flattened).
try:
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id}) On HTTP / WAF / non-JSON errors raises caller (_fetch_flats_safe) ловит и кладёт
except Exception as e: Exception в result-tuple, который result-loop отдаёт в _classify_and_log
logger.warning("flats fetch obj=%s failed: %s", obj_id, e) kn_scrape_failures. Раньше try/except здесь возвращал [] на ЛЮБУЮ ошибку, из-за чего
return [] провал /portal/table (429, 5xx, WAF-challenge) не попадал в журнал отказов, а run
рапортовал ложную полноту по квартирам (#1643). Поведение теперь как у остальных
fetch_* endpoint'ов, которые исключения не глотают.
"""
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]} # Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
return _flatten_table(payload) return _flatten_table(payload)

View file

@ -83,7 +83,7 @@ _EKB_LAT_MAX = 57.5
# Паттерн для разбора первого числа из строки координат # Паттерн для разбора первого числа из строки координат
# (ячейки могут содержать несколько точек через пробелы или запятую-десятичный разделитель) # (ячейки могут содержать несколько точек через пробелы или запятую-десятичный разделитель)
_COORD_FIRST_RE = re.compile(r"[\d]+[.,][\d]+") _COORD_FIRST_RE = re.compile(r"[\d]+(?:[.,][\d]+)?")
def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None: def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None:
@ -375,26 +375,28 @@ class EkburgPermitsClient:
Пропускает листы «Справочник», «Лист1» и неизвестные. Пропускает листы «Справочник», «Лист1» и неизвестные.
""" """
wb = load_workbook(BytesIO(content), read_only=True, data_only=True) wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
try:
for sheet_name in wb.sheetnames:
if sheet_name.lower() in _SKIP_SHEETS:
continue
for sheet_name in wb.sheetnames: permit_type = _detect_permit_type(sheet_name)
if sheet_name.lower() in _SKIP_SHEETS: if permit_type is None:
continue logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
continue
permit_type = _detect_permit_type(sheet_name) sheet = wb[sheet_name]
if permit_type is None: data_start = _detect_header_row(sheet)
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year) logger.info(
continue "Parsing sheet %r (%s) year=%d, data starts at row %d",
sheet_name,
sheet = wb[sheet_name] permit_type,
data_start = _detect_header_row(sheet) year,
logger.info( data_start,
"Parsing sheet %r (%s) year=%d, data starts at row %d", )
sheet_name, yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
permit_type, finally:
year, wb.close()
data_start,
)
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
def _parse_sheet( def _parse_sheet(
self, self,

View file

@ -143,6 +143,45 @@ _GRID_WALK_LAYERS: frozenset[str] = frozenset(
} }
) )
# ── РИАСУРТ Свердл (folderId 1224) — агломерация ЕКБ, multi-city scaling (#108) ──
# РИАСУРТ Свердл агрегирует данные от МАЛЫХ МО Свердл (Берёзовский, В.Пышма,
# Среднеуральск, Арамиль, Сысерть) — НЕ от ЕКБ-сити (тот не интегрирован в ФГИС ТП).
# Слои на той же NSPD aeggis/v4 WMS-инфраструктуре, что и федеральные LAYERS, но
# layerId из регионального каталога (84xxxx/85xxxx). 14 ключевых слоёв (см. issue #108).
#
# layerId → (topic, человекочитаемое название). topic группирует слой для analyze-gate
# (тер.зона/функц.зона/красные линии/СЗЗ/ЗСО/затопление/КРТ и т.п.).
RIASURT_SVERDL_LAYERS: dict[int, tuple[str, str]] = {
# ПЗЗ / Планирование
845274: ("territorial_zone", "Территориальные зоны"),
846381: ("functional_zone", "Функциональные зоны"),
844759: ("red_lines", "Красные линии"),
844774: ("setback_lines", "Линии отступа от красных линий"),
845298: ("ppt_oks_boundary", "Границы ППТ ОКС"),
# Инженерные ЗОУИТ
846365: ("szz", "Санитарно-защитная зона (СЗЗ)"),
846369: ("san_gap_engineering", "Санитарный разрыв инженерных коммуникаций"),
845392: ("szo", "Зоны санитарной охраны питьевого водоснабжения (ЗСО)"),
846373: ("water_protection", "Водоохранные зоны"),
# Risk
845425: ("flood_zone", "Зоны затопления / подтопления"),
846378: ("emergency_alert", "Зона экстренного оповещения"),
844795: ("emergency_risk", "Территории риска ЧС"),
# Opportunity
844478: ("krt", "Территории КРТ"),
846379: ("oez", "Особые экономические зоны (ОЭЗ)"),
}
def riasurt_layer_topic(layer_id: int) -> str:
"""Topic (бакет для analyze-gate) по layerId РИАСУРТ Свердл.
Неизвестный layerId 'other' (graceful; не падаем на новых слоях каталога).
"""
entry = RIASURT_SVERDL_LAYERS.get(layer_id)
return entry[0] if entry else "other"
# Default rate limit (мс между запросами) — баланс между скоростью и WAF # Default rate limit (мс между запросами) — баланс между скоростью и WAF
DEFAULT_RATE_MS = 600 DEFAULT_RATE_MS = 600
@ -612,6 +651,58 @@ class NSPDClient:
) )
return deduped return deduped
# ── 3c. get_riasurt_sverdl_in_bbox (РИАСУРТ Свердл bulk, #108) ───────────
def get_riasurt_sverdl_in_bbox(
self,
bbox_3857: tuple[float, float, float, float],
layers: list[int] | None = None,
*,
grid_n: int = 7,
step_m: float = 50.0,
) -> dict[int, list[NSPDFeature]]:
"""Bulk WMS GetFeatureInfo по слоям РИАСУРТ Свердл в пределах bbox.
Для каждого layerId делает grid-walk (``get_features_in_bbox_grid``) area/
linear слои (зоны, линии) под-возвращаются single-pixel probe, поэтому grid.
Слой, который упал (WAF / сеть), логируется и пропускается (его ключ = []),
остальные слои продолжают частичный harvest лучше полного провала по агломерации.
Args:
bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры) bbox одного МО.
layers: список layerId РИАСУРТ. None все 14 ключевых
(``RIASURT_SVERDL_LAYERS``). Неизвестный layerId всё равно
запрашивается (topic='other' на стороне классификатора).
grid_n / step_m: параметры grid-walk (см. get_features_in_bbox_grid).
Returns:
dict[layerId, list[NSPDFeature]]. Ключи все запрошенные layerId
(пустой list если слой пуст / упал). Стабильная форма для caller'а.
"""
layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys())
result: dict[int, list[NSPDFeature]] = {}
for layer_id in layer_ids:
try:
feats = self.get_features_in_bbox_grid(
layer_id, bbox_3857, grid_n=grid_n, step_m=step_m
)
except (NspdLiteError, NspdLiteWafError) as exc:
logger.warning(
"get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем",
layer_id,
exc,
)
result[layer_id] = []
continue
result[layer_id] = feats
logger.info(
"get_riasurt_sverdl_in_bbox: layer=%d topic=%s count=%d",
layer_id,
riasurt_layer_topic(layer_id),
len(feats),
)
return result
# ── 4. list_layers ────────────────────────────────────────────────────── # ── 4. list_layers ──────────────────────────────────────────────────────
def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]: def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]:
@ -924,6 +1015,7 @@ __all__ = [
"LAYERS", "LAYERS",
"NSPD_THEMES", "NSPD_THEMES",
"NSPD_WMS_BASE", "NSPD_WMS_BASE",
"RIASURT_SVERDL_LAYERS",
"THEME_ARN", "THEME_ARN",
"THEME_PKK", "THEME_PKK",
"NSPDClient", "NSPDClient",
@ -935,4 +1027,5 @@ __all__ = [
"QuarterDump", "QuarterDump",
"bbox_around_point_m", "bbox_around_point_m",
"lonlat_to_3857", "lonlat_to_3857",
"riasurt_layer_topic",
] ]

View file

@ -329,25 +329,37 @@ def denorm_dump(
features: плоский list из features_json JSONB (уже декодированный Python list). features: плоский list из features_json JSONB (уже декодированный Python list).
Returns: Returns:
dict {"parcels": N, "buildings": M, "errors": K} количество обработанных строк. dict {"parcels": N, "buildings": M, "errors": K, "skipped": S}
количество обработанных строк. ``errors`` только реальные сбои UPSERT;
``skipped`` штатные пропуски feature без cad_num.
""" """
snapshot_date = datetime.date.today().isoformat() snapshot_date = datetime.date.today().isoformat()
parcels_n = 0 parcels_n = 0
buildings_n = 0 buildings_n = 0
errors_n = 0 errors_n = 0
skipped_n = 0
for feat in features: for feat in features:
layer = feat.get("layer", "") layer = feat.get("layer", "")
try: try:
if layer == "parcels": if layer == "parcels":
if denorm_parcel_feature( # denorm_parcel_feature возвращает False и при штатном пропуске
# (нет cad_num), и при реальном сбое UPSERT — различаем их здесь,
# чтобы не завышать error-счётчик прогона.
props = feat.get("properties") or {}
if not (props.get("cad_num") or props.get("cadastral_number")):
skipped_n += 1
elif denorm_parcel_feature(
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
): ):
parcels_n += 1 parcels_n += 1
else: else:
errors_n += 1 errors_n += 1
elif layer == "buildings": elif layer == "buildings":
if denorm_building_feature( props = feat.get("properties") or {}
if not (props.get("cad_num") or props.get("cadastral_number")):
skipped_n += 1
elif denorm_building_feature(
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
): ):
buildings_n += 1 buildings_n += 1
@ -360,10 +372,16 @@ def denorm_dump(
db.commit() db.commit()
logger.info( logger.info(
"denorm_dump quarter=%s parcels=%d buildings=%d errors=%d", "denorm_dump quarter=%s parcels=%d buildings=%d errors=%d skipped=%d",
quarter_cad, quarter_cad,
parcels_n, parcels_n,
buildings_n, buildings_n,
errors_n, errors_n,
skipped_n,
) )
return {"parcels": parcels_n, "buildings": buildings_n, "errors": errors_n} return {
"parcels": parcels_n,
"buildings": buildings_n,
"errors": errors_n,
"skipped": skipped_n,
}

View file

@ -113,7 +113,17 @@ def fetch_geoportal(
try: try:
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r: with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
body = r.read().decode("utf-8", "ignore") body = r.read().decode("utf-8", "ignore")
return json.loads(body) try:
return json.loads(body)
except json.JSONDecodeError as e:
# HTTP 200, но тело — не JSON. Это WAF/прокси-челлендж (HTML или
# пустое тело) с кодом 200 вместо 403/429. Та же transient-ситуация,
# что и явный WAF → NspdLiteWafError, чтобы caller сделал backoff
# (harvest_quarter autoretry_for=(NspdLiteWafError,)), а не пометил
# строку постоянным harvest_error.
raise NspdLiteWafError(
f"HTTP 200 but non-JSON body (WAF challenge?): {body[:300]}"
) from e
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else "" body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
if e.code in (403, 429): if e.code in (403, 429):
@ -162,6 +172,12 @@ def fetch_via_rosreestr2coord(
_ = delay # silence unused — см. docstring выше _ = delay # silence unused — см. docstring выше
try: try:
from rosreestr2coord.parser import Area from rosreestr2coord.parser import Area
from rosreestr2coord.request.exceptions import (
HTTPErrorException,
HTTPForbiddenException,
RequestException,
TimeoutException,
)
except ImportError as e: except ImportError as e:
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
@ -170,19 +186,33 @@ def fetch_via_rosreestr2coord(
# denied при первом fetch. # denied при первом fetch.
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех). # Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз. # Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
a = Area( #
code=cad_num, # NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
area_type=area_type, # (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
timeout=timeout, # отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
with_log=False,
use_cache=False,
media_path="/tmp/rosreestr2coord",
)
try: try:
a = Area(
code=cad_num,
area_type=area_type,
timeout=timeout,
with_log=False,
use_cache=False,
media_path="/tmp/rosreestr2coord",
)
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную # dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
# строку. Default в v5 = True → строка → крах в `_persist_target` который # строку. Default в v5 = True → строка → крах в `_persist_target` который
# ожидает dict с `.get("properties")` etc. # ожидает dict с `.get("properties")` etc.
return a.to_geojson_poly(dumps=False) return a.to_geojson_poly(dumps=False)
except Exception as e: except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e) # WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
return None # HTTPErrorException) и таймауты — transient. Поднимаем NspdLiteWafError,
# чтобы воркер process_nspd_geo_job сделал backoff (exponential, инкремент
# waf_blocked_count, пауза после серии WAF), а не пометил цель 'done' с 0
# features (false run-status). См. nspd_geo.py:464.
raise NspdLiteWafError(f"rosreestr2coord WAF/transient for {cad_num}: {e}") from e
except RequestException as e:
# Прочие ошибки запроса (RequestException, в т.ч. is_error_response с
# сообщением об ошибке от NSPD) — не отличить от transient WAF надёжно,
# но это НЕ легитимное 'участок не найден' (то возвращает feature=None →
# None без исключения). Классифицируем как ошибку, не как пустой результат.
raise NspdLiteError(f"rosreestr2coord request failed for {cad_num}: {e}") from e

View file

@ -69,6 +69,35 @@ _CHECK_TYPE_ALIASES: dict[str, list[str]] = {
"declaration": ["declaration", "hasDeclaration", "declarationFlg"], "declaration": ["declaration", "hasDeclaration", "declarationFlg"],
} }
# Строковые флаги, которые сторонний API может прислать вместо bool.
# Схема payload не верифицирована (см. docstring), поэтому приводим явно.
_TRUE_STRINGS = {"true", "1", "yes", "y", "да", "passed", "ok"}
_FALSE_STRINGS = {"false", "0", "no", "n", "нет", "failed", "not_passed"}
def _coerce_flag(value: Any) -> bool | None:
"""Привести значение флага проверки к bool либо None (UNKNOWN).
bool(value) ломается на строках ('false'/'0'/'нет' True) и не отличает
отсутствие данных от False. Возвращаем None, если значение нераспознаваемо
вызывающий код НЕ должен фабриковать False для UNKNOWN.
"""
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
s = value.strip().lower()
if s in _TRUE_STRINGS:
return True
if s in _FALSE_STRINGS:
return False
return None
return None
_UPSERT_CHECKS_SQL = text( _UPSERT_CHECKS_SQL = text(
""" """
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at) INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
@ -106,14 +135,21 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
for field, value in data.items(): for field, value in data.items():
ct = _CHECK_FIELD_MAP.get(field) ct = _CHECK_FIELD_MAP.get(field)
if ct and ct not in found: if ct and ct not in found:
found[ct] = bool(value) flag = _coerce_flag(value)
if flag is not None:
found[ct] = flag
# Также проверить canonical names напрямую # Также проверить canonical names напрямую
for ct in CHECK_TYPES: for ct in CHECK_TYPES:
if ct not in found and ct in data: if ct not in found and ct in data:
found[ct] = bool(data[ct]) flag = _coerce_flag(data[ct])
if flag is not None:
found[ct] = flag
if found: if found:
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
for ct in CHECK_TYPES: for ct in CHECK_TYPES:
results.append({"check_type": ct, "passed": found.get(ct, False)}) if ct in found:
results.append({"check_type": ct, "passed": found[ct]})
return results return results
# dict не содержит известных полей — попробуем как list-формат ниже # dict не содержит известных полей — попробуем как list-формат ниже
logger.warning( logger.warning(
@ -128,11 +164,24 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
continue continue
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type") ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
if ct_raw and str(ct_raw) in CHECK_TYPES: if ct_raw and str(ct_raw) in CHECK_TYPES:
passed_raw = item.get("passed") or item.get("value") or item.get("status") # Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
found_list[str(ct_raw)] = bool(passed_raw) # Берём первый ключ, который реально присутствует в item.
if "passed" in item:
passed_raw = item["passed"]
elif "value" in item:
passed_raw = item["value"]
elif "status" in item:
passed_raw = item["status"]
else:
passed_raw = None
flag = _coerce_flag(passed_raw)
if flag is not None:
found_list[str(ct_raw)] = flag
if found_list: if found_list:
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
for ct in CHECK_TYPES: for ct in CHECK_TYPES:
results.append({"check_type": ct, "passed": found_list.get(ct, False)}) if ct in found_list:
results.append({"check_type": ct, "passed": found_list[ct]})
return results return results
logger.warning( logger.warning(
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3] "domrf obj_checks: list payload has no recognisable check items: %s", data[:3]

View file

@ -298,9 +298,7 @@ def _parse_value(raw: str) -> Decimal | None:
raw = (raw or "").strip() raw = (raw or "").strip()
if not raw or raw in {"-", "", "", "..."}: if not raw or raw in {"-", "", "", "..."}:
return None return None
cleaned = ( cleaned = raw.replace(" ", "").replace("", "").replace(" ", "").replace(",", ".")
raw.replace(" ", "").replace("", "").replace(" ", "").replace(",", ".")
)
try: try:
return Decimal(cleaned) return Decimal(cleaned)
except (InvalidOperation, ValueError): except (InvalidOperation, ValueError):
@ -399,9 +397,7 @@ def parse_dataset_rows(
# ── HTTP fetch (thin) ─────────────────────────────────────────────────────────── # ── HTTP fetch (thin) ───────────────────────────────────────────────────────────
def _http_get( def _http_get(client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES) -> bytes:
client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES
) -> bytes:
"""GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах. """GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах.
Raises: Raises:
@ -622,9 +618,7 @@ _MONTH_NAME_TO_NUM: dict[str, int] = {
"декабрь": 12, "декабрь": 12,
} }
# Годовой под-период (некоторые индикаторы): «значение показателя за год», «год». # Годовой под-период (некоторые индикаторы): «значение показателя за год», «год».
_YEAR_PERIODS: frozenset[str] = frozenset( _YEAR_PERIODS: frozenset[str] = frozenset({"год", "значение показателя за год", "за год"})
{"год", "значение показателя за год", "за год"}
)
@dataclass(frozen=True) @dataclass(frozen=True)
@ -673,7 +667,14 @@ class EmissRow:
"""Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss'). """Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss').
Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут
свою frequency (quarterly/monthly) и source контракт upsert'а у них иной. свою frequency (quarterly/monthly), source и period_type контракт upsert'а у
них иной.
period_type: гранулярность под-периода ('year' | 'quarter' | 'month' | 'unknown').
Берётся из _emiss_period_granularity(PERIOD). Необходима как часть PK
macro_indicator (migration 163), чтобы годовой агрегат ('год' 'year') и
Q1 ('I квартал' 'quarter') за один год не перезаписывали друг друга при
ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01) (#1606).
""" """
indicator_type: str indicator_type: str
@ -683,6 +684,7 @@ class EmissRow:
unit: str unit: str
frequency: str frequency: str
comment: str comment: str
period_type: str = "unknown"
def _emiss_period_to_month(period: str) -> int | None: def _emiss_period_to_month(period: str) -> int | None:
@ -704,6 +706,25 @@ def _emiss_period_to_month(period: str) -> int | None:
return None return None
def _emiss_period_granularity(period: str) -> str:
"""PERIOD-строку fedstat → гранулярность ('year' | 'quarter' | 'month').
Используется как третья компонента ключа дедупликации в parse_emiss_sdmx, чтобы
годовой ряд ('год') и квартальный Q1 ('I квартал') не перезаписывали друг друга при
одинаковых (region, obs_date=YYYY-01-01) (#1606).
Возвращает 'unknown' для нераспознанных под-периодов (на практике такие ряды
пропускаются ещё до этого вызова через _emiss_period_to_month None).
"""
p = (period or "").strip().lower()
if p in _YEAR_PERIODS:
return "year"
if p in _QUARTER_START_MONTH:
return "quarter"
if p in _MONTH_NAME_TO_NUM:
return "month"
return "unknown"
def _series_concepts(series: ET.Element) -> tuple[dict[str, str], dict[str, str]]: def _series_concepts(series: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
"""Извлечь (SeriesKey, Attributes) concept→value словари из <generic:Series>. """Извлечь (SeriesKey, Attributes) concept→value словари из <generic:Series>.
@ -736,7 +757,10 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
не среди spec.targets ряд пропускается. PERIOD (Attributes) стартовый месяц не среди spec.targets ряд пропускается. PERIOD (Attributes) стартовый месяц
под-периода; год из <Obs><Time>; obs_date = date(year, month, 1). Значение под-периода; год из <Obs><Time>; obs_date = date(year, month, 1). Значение
из <Obs><ObsValue value=...> через _parse_value (запятая-десятичный, разделители). из <Obs><ObsValue value=...> через _parse_value (запятая-десятичный, разделители).
3. Дубли по (region, obs_date) схлопываются (последний выигрывает) PK всё равно один. 3. Дубли по (region, obs_date, granularity) схлопываются (последний выигрывает).
Ключ трёхкомпонентный гранулярность ('year'/'quarter'/'month') предотвращает
перезапись годового ряда ('год' obs_date YYYY-01-01) Q1-рядом ('I квартал'
тот же YYYY-01-01) при совместном их присутствии в одном SDMX-ответе (#1606).
Никогда не бросает на данных: битые ячейки/периоды/годы наблюдение пропускается. Никогда не бросает на данных: битые ячейки/периоды/годы наблюдение пропускается.
""" """
@ -759,7 +783,10 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
return [] return []
target_by_okato = {t.oktmo: t for t in spec.targets} target_by_okato = {t.oktmo: t for t in spec.targets}
by_key: dict[tuple[str, date], EmissRow] = {} # Ключ: (region, obs_date, granularity) — три компоненты предотвращают коллизию
# годового ряда ('год' → YYYY-01-01, granularity='year') и Q1 ('I квартал' →
# тот же YYYY-01-01, granularity='quarter') при их совместном присутствии в SDMX (#1606).
by_key: dict[tuple[str, date, str], EmissRow] = {}
for series in dataset.findall("g:Series", _SDMX_NS): for series in dataset.findall("g:Series", _SDMX_NS):
key, attrs = _series_concepts(series) key, attrs = _series_concepts(series)
@ -767,15 +794,17 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
target = target_by_okato.get(okato) target = target_by_okato.get(okato)
if target is None: if target is None:
continue continue
month = _emiss_period_to_month(attrs.get("PERIOD", "")) period_raw = attrs.get("PERIOD", "")
month = _emiss_period_to_month(period_raw)
if month is None: if month is None:
logger.warning( logger.warning(
"EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск", "EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск",
spec.indicator_id, spec.indicator_id,
attrs.get("PERIOD"), period_raw,
okato, okato,
) )
continue continue
granularity = _emiss_period_granularity(period_raw)
for obs in series.findall("g:Obs", _SDMX_NS): for obs in series.findall("g:Obs", _SDMX_NS):
time_el = obs.find("g:Time", _SDMX_NS) time_el = obs.find("g:Time", _SDMX_NS)
val_el = obs.find("g:ObsValue", _SDMX_NS) val_el = obs.find("g:ObsValue", _SDMX_NS)
@ -788,7 +817,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
if value is None: if value is None:
continue continue
obs_date = date(int(yr), month, 1) obs_date = date(int(yr), month, 1)
by_key[(target.region, obs_date)] = EmissRow( by_key[(target.region, obs_date, granularity)] = EmissRow(
indicator_type=spec.indicator_type, indicator_type=spec.indicator_type,
region=target.region, region=target.region,
obs_date=obs_date, obs_date=obs_date,
@ -796,6 +825,7 @@ def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRo
unit=spec.unit, unit=spec.unit,
frequency=spec.frequency, frequency=spec.frequency,
comment=spec.comment, comment=spec.comment,
period_type=granularity,
) )
return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))] return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]
@ -948,9 +978,7 @@ def _parse_year_cell(raw: Any) -> int | None:
return None return None
def parse_construction_index_xlsx( def parse_construction_index_xlsx(content: bytes, spec: XlsxIndicatorSpec) -> list[MacroRow]:
content: bytes, spec: XlsxIndicatorSpec
) -> list[MacroRow]:
"""Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow. """Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow.
Алгоритм (форма листа см. блок выше): Алгоритм (форма листа см. блок выше):

View file

@ -227,13 +227,14 @@ class BrowserSession:
""" """
if self._context is None: if self._context is None:
raise RuntimeError("BrowserSession not bootstrapped") raise RuntimeError("BrowserSession not bootstrapped")
await jitter_sleep(200, 500) # Lighter throttle for static assets. async with self._sem:
self._request_count += 1 await jitter_sleep(200, 500) # Lighter throttle for static assets.
resp = await self._context.request.get( self._request_count += 1
url, resp = await self._context.request.get(
headers={"Authorization": self.auth} if self.auth else {}, url,
) headers={"Authorization": self.auth} if self.auth else {},
if resp.status != 200: )
body = await resp.text() if resp.status != 200:
raise RuntimeError(f"binary http {resp.status}: {body[:200]}") body = await resp.text()
return await resp.body() raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
return await resp.body()

View file

@ -488,17 +488,82 @@ _AVG_PRICE_SQL = text("""
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python # Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие # заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным. # непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
#
# #1615: velocity обогащается из ДВУХ источников (см. _COMPETITORS_SQL mapped CTE) —
# явного objective_complex_mapping И спатиально-именного nearest_cx gap-fill. Ценовой
# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
_OBJECTIVE_PRICE_FALLBACK_SQL = text(""" _OBJECTIVE_PRICE_FALLBACK_SQL = text("""
WITH primary_price AS (
SELECT
cm.domrf_obj_id AS obj_id,
ol.price_per_m2_rub AS price_per_m2_rub
FROM objective_complex_mapping cm
JOIN objective_lots ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND ol.price_per_m2_rub IS NOT NULL
),
nearest_cx AS (
SELECT DISTINCT ON (o.obj_id)
o.obj_id,
c.id AS complex_id
FROM domrf_kn_objects o
JOIN complexes c
ON c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND c.canonical_name IS NOT NULL
AND EXISTS (
SELECT 1 FROM objective_lots ol
WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
)
AND ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
CAST(:velocity_match_radius_m AS float)
)
AND (
lower(btrim(o.comm_name)) = lower(btrim(c.canonical_name))
OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
)
WHERE o.obj_id = ANY(:obj_ids)
AND o.latitude IS NOT NULL
AND o.longitude IS NOT NULL
AND o.comm_name IS NOT NULL
AND btrim(o.comm_name) <> ''
AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
ORDER BY o.obj_id,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
) ASC
),
gapfill_price AS (
SELECT
nc.obj_id AS obj_id,
ol.price_per_m2_rub AS price_per_m2_rub
FROM nearest_cx nc
JOIN objective_lots ol
ON ol.complex_id = nc.complex_id
AND ol.price_per_m2_rub IS NOT NULL
)
SELECT SELECT
cm.domrf_obj_id AS obj_id, p.obj_id,
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub) percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_per_m2_rub)
AS median_price_per_m2 AS median_price_per_m2
FROM objective_complex_mapping cm FROM (
JOIN objective_lots ol SELECT obj_id, price_per_m2_rub FROM primary_price
ON ol.project_name = cm.objective_complex_name UNION ALL
WHERE cm.domrf_obj_id = ANY(:obj_ids) SELECT obj_id, price_per_m2_rub FROM gapfill_price
AND ol.price_per_m2_rub IS NOT NULL ) p
GROUP BY cm.domrf_obj_id GROUP BY p.obj_id
""") """)
@ -648,7 +713,13 @@ def get_competitors(
if missing_price_ids: if missing_price_ids:
try: try:
obj_price_rows = ( obj_price_rows = (
db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids}) db.execute(
_OBJECTIVE_PRICE_FALLBACK_SQL,
{
"obj_ids": missing_price_ids,
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
},
)
.mappings() .mappings()
.all() .all()
) )

View file

@ -7,6 +7,7 @@ Pure function — no DB dependencies, consumes already-fetched data from analyze
from __future__ import annotations from __future__ import annotations
import re
from typing import Literal, TypedDict from typing import Literal, TypedDict
from app.services.site_finder.network_obremenenie import ( from app.services.site_finder.network_obremenenie import (
@ -16,11 +17,28 @@ from app.services.site_finder.network_obremenenie import (
# ── Zone matching ───────────────────────────────────────────────────────────── # ── Zone matching ─────────────────────────────────────────────────────────────
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5) # Regex для zone_code из PKK6 / pzz_zones_ekb: "Ж", "Ж-1", "Жс", "ЖС-2" и т.д.
RESIDENTIAL_ZONE_PREFIXES = ("Ж-", "Ж1", "Ж2", "Ж3", "Ж4", "Ж5") # Кейс-инсенситив для защиты от "ж-1" в нестандартных источниках.
# Fallback по zone_name (lowercase substring) # NSPD quarter-dump в zone_code кладёт кадастровый рег.номер (напр. "66:41-7.14") —
# он никогда не начинается на "Ж", поэтому этот regex не даст false positive.
_RESIDENTIAL_CODE_RE = re.compile(r"^[Жж]", re.UNICODE)
# Fallback по zone_name (lowercase substring) — "жилая", "жилой", "жилая зона" и т.д.
RESIDENTIAL_KEYWORDS = ("жил",) RESIDENTIAL_KEYWORDS = ("жил",)
# NSPD territorial zones layer 875838: subcategory (int) → тип зоны.
# Значения получены из реальных данных nspd_quarter_dumps (ЕКБ, 2026-02):
# subcategory=1 — 39 объектов — Жилые зоны малоэтажной застройки (ИЖС/таунхаусы)
# subcategory=2 — 221 объект — Жилые зоны (МКД разрешён, крупнейшая группа ЕКБ)
# subcategory=3 — 126 объектов — Зоны смешанного использования (МКД по ПЗЗ ЕКБ допустим)
# subcategory=4 — 77 объектов — Общественно-деловые
# subcategory=5 — 112 объектов — Производственные
# subcategory=6 — 92 объекта — Рекреационные
# subcategory=8 — 16 объектов — Специальные
# subcategory=10 — 113 объектов — Промышленные/транспортные
# subcategory=1 исключён намеренно: ИЖС-зона МКД не допускает.
NSPD_RESIDENTIAL_SUBCATEGORIES: frozenset[int] = frozenset({2, 3})
# ── ЗОУИТ taxonomy ──────────────────────────────────────────────────────────── # ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода). # Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
@ -45,6 +63,11 @@ WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-з
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py # Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
ENGINEERING_NEARBY_THRESHOLD_M = 200 ENGINEERING_NEARBY_THRESHOLD_M = 200
# Warning codes that are purely informational — they appear in warnings[] for the user
# but do NOT downgrade the final verdict from «Можно» to «С ограничениями» (#1617).
# A clean residential parcel with only these warnings still gets verdict_label="Можно".
INFORMATIONAL_WARNING_CODES: frozenset[str] = frozenset({"NO_ENGINEERING_NEARBY"})
# ── TypedDicts ──────────────────────────────────────────────────────────────── # ── TypedDicts ────────────────────────────────────────────────────────────────
@ -71,19 +94,46 @@ class GateVerdict(TypedDict):
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
def is_residential_zone(zone_code: str | None, zone_name: str | None) -> bool: def is_residential_zone(
zone_code: str | None,
zone_name: str | None,
raw_props: dict | None = None,
) -> bool:
"""Check if ПЗЗ zone permits residential (МКД) building. """Check if ПЗЗ zone permits residential (МКД) building.
Checks zone_code prefix first (exact ПЗЗ codes), then zone_name lowercase Detection strategy (in priority order):
substring match as fallback for non-standard zone labels.
1. zone_code regex «^Ж» ПЗЗ коды PKK6/pzz_zones_ekb: "Ж-1", "Жс", "ЖС-2" и т.д.
NSPD quarter-dump кладёт в zone_code кадастровый рег.номер ("66:41-7.14")
он не начинается на «Ж», ложных срабатываний нет.
2. NSPD subcategory из raw_props единственный надёжный тип-дискриминатор в
реальных данных nspd_quarter_dumps (layer 875838, ЕКБ 2026-02):
subcategory=2 ("Жилые зоны") и subcategory=3 ("Смешанное использование")
допускают МКД по ПЗЗ ЕКБ. subcategory=1 (ИЖС) намеренно исключён.
3. zone_name substring «жил» fallback для нестандартных источников.
""" """
if zone_code: # 1. PKK6-style zone_code: starts with Ж (case-insensitive)
if any(zone_code.startswith(p) for p in RESIDENTIAL_ZONE_PREFIXES): if zone_code and _RESIDENTIAL_CODE_RE.match(zone_code):
return True return True
# 2. NSPD subcategory from raw_props (primary discriminator for quarter-dump data)
if raw_props:
sub_raw = raw_props.get("subcategory")
if sub_raw is not None:
try:
if int(sub_raw) in NSPD_RESIDENTIAL_SUBCATEGORIES:
return True
except (ValueError, TypeError):
pass
# 3. zone_name substring fallback
if zone_name: if zone_name:
zn_lower = zone_name.lower() zn_lower = zone_name.lower()
if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS): if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS):
return True return True
return False return False
@ -134,7 +184,11 @@ def compute_gate_verdict(
# Check 1 — ПЗЗ residential zone # Check 1 — ПЗЗ residential zone
checks.append("ПЗЗ зональность") checks.append("ПЗЗ зональность")
if nspd_zoning: if nspd_zoning:
if not is_residential_zone(nspd_zoning.get("zone_code"), nspd_zoning.get("zone_name")): if not is_residential_zone(
nspd_zoning.get("zone_code"),
nspd_zoning.get("zone_name"),
nspd_zoning.get("raw_props"),
):
zone_label = ( zone_label = (
nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна" nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна"
) )
@ -259,12 +313,17 @@ def compute_gate_verdict(
) )
) )
# Final verdict # Final verdict.
# Informational warnings (e.g. NO_ENGINEERING_NEARBY) appear in warnings[] for display
# but do NOT by themselves downgrade a clean parcel to «С ограничениями» (#1617).
verdict_relevant_warnings = [
w for w in warnings if w["code"] not in INFORMATIONAL_WARNING_CODES
]
can_build: bool | Literal["unknown"] can_build: bool | Literal["unknown"]
if blockers: if blockers:
can_build = False can_build = False
label = "Нельзя" label = "Нельзя"
elif warnings: elif verdict_relevant_warnings:
can_build = True can_build = True
label = "С ограничениями" label = "С ограничениями"
else: else:

View file

@ -79,7 +79,7 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
Returns: Returns:
list[{project_type, doc_status_name, is_active, paga_number, paga_date, list[{project_type, doc_status_name, is_active, paga_number, paga_date,
subject, doc_full_name}]. subject, doc_full_name}].
- ``is_active``: doc_status_name == 'Действующий'. - ``is_active``: doc_status_name == 'действующий' (регистронезависимо).
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось. - ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет. - ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
Отсортировано: сначала действующие, внутри группы по paga_date desc (свежие первыми). Отсортировано: сначала действующие, внутри группы по paga_date desc (свежие первыми).
@ -97,7 +97,10 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
for r in rows: for r in rows:
full_name: str | None = r["full_name"] full_name: str | None = r["full_name"]
paga_number, paga_date, subject = _parse_paga(full_name) paga_number, paga_date, subject = _parse_paga(full_name)
is_active = r["doc_status_name"] == "Действующий" status_name: str | None = r["doc_status_name"]
# Регистронезависимо: harvest пишет doc_status_name из WFS без нормализации,
# DDL/COMMENT документируют хранимое значение как lowercase ('действующий').
is_active = (status_name or "").strip().lower() == "действующий"
result.append( result.append(
{ {
"project_type": r["project_type"], "project_type": r["project_type"],

View file

@ -65,6 +65,7 @@ def _market_metrics_key(
premise_kind, premise_kind,
) )
Confidence = Literal["high", "medium", "low"] Confidence = Literal["high", "medium", "low"]
# Лот считается «зависшим» (overstock), если он в продаже дольше этого числа # Лот считается «зависшим» (overstock), если он в продаже дольше этого числа
@ -111,6 +112,9 @@ class MarketMetrics:
demand_concentration: float | None # Херфиндаль продаж по комнатности (0..1) demand_concentration: float | None # Херфиндаль продаж по комнатности (0..1)
price_sensitivity: float | None # эластичность цена↔спрос (slope, обычно < 0) price_sensitivity: float | None # эластичность цена↔спрос (slope, обычно < 0)
price_sensitivity_source: str | None # 'regression' | 'fallback' | None price_sensitivity_source: str | None # 'regression' | 'fallback' | None
# #1593: ед./мес per room-bucket (ключи — вокабуляр _room_bucket():
# "студия","1","2","3","4","5+"). None если нет данных в окне.
velocity_by_room: dict[str, float] | None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return { return {
@ -136,6 +140,8 @@ class MarketMetrics:
"demand_concentration": _round_or_none(self.demand_concentration, 3), "demand_concentration": _round_or_none(self.demand_concentration, 3),
"price_sensitivity": _round_or_none(self.price_sensitivity, 4), "price_sensitivity": _round_or_none(self.price_sensitivity, 4),
"price_sensitivity_source": self.price_sensitivity_source, "price_sensitivity_source": self.price_sensitivity_source,
# velocity_by_room намеренно не сериализуется: это внутренний
# pipeline-атрибут (потребляется recommendation.py напрямую).
} }
@ -409,6 +415,15 @@ def compute_market_metrics(
liquidity = _liquidity_index(sold_by_room) liquidity = _liquidity_index(sold_by_room)
overstock = _overstock_index(n_long_unsold, n_available) overstock = _overstock_index(n_long_unsold, n_available)
demand_conc = _demand_concentration(sold_by_room) demand_conc = _demand_concentration(sold_by_room)
# #1593: per-bucket velocity — ед./мес по каждой комнатности. Ключи зеркалят
# _room_bucket() ("студия","1","2","3","4","5+"). При has_sample=False нет
# смысла делить 0 лотов → None (graceful, зеркало unit_velocity поведения).
# window_months > 0 защищает от ZeroDivisionError (зеркало _monthly_rate()).
vel_by_room: dict[str, float] | None = (
{bkt: float(cnt) / float(window_months) for bkt, cnt in sold_by_room.items()}
if has_sample and sold_by_room and window_months > 0
else None
)
# ── price_sensitivity — reuse analytics_queries._elasticity_coef ─────────── # ── price_sensitivity — reuse analytics_queries._elasticity_coef ───────────
price_sensitivity, price_sensitivity_source = _price_sensitivity( price_sensitivity, price_sensitivity_source = _price_sensitivity(
@ -450,6 +465,7 @@ def compute_market_metrics(
demand_concentration=demand_conc, demand_concentration=demand_conc,
price_sensitivity=price_sensitivity, price_sensitivity=price_sensitivity,
price_sensitivity_source=price_sensitivity_source, price_sensitivity_source=price_sensitivity_source,
velocity_by_room=vel_by_room,
) )

View file

@ -44,6 +44,21 @@ CATEGORY_WEIGHTS: dict[str, float] = {
"default": 1.0, "default": 1.0,
} }
# ── Нормировочные константы (для вывода score_contribution / poi_weighted_score в 0..100) ──
#
# Теоретический максимум суммы весов top-7 POI при идеальном расположении:
# straight-line: w_i = cat_weight_i / (0+100) → max_sum = Σ(top7 cat_weights) / 100
# routing-decay: w_i = cat_weight_i * decay(0) = cat_weight_i → max_sum = Σ(top7 cat_weights)
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
# straight-line mode: каждый POI берётся с множителем 1/(d+100); при d=0 → /100
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
# routing-decay mode: decay ∈ [0,1], при t=0 decay=1.0 → max = cat_weight
_MAX_ROUTING_SCORE: float = _TOP7_WEIGHT_SUM # = 31.5
class PoiScoreItem(BaseModel): class PoiScoreItem(BaseModel):
"""Один POI в ranked-ответе.""" """Один POI в ranked-ответе."""
@ -52,12 +67,17 @@ class PoiScoreItem(BaseModel):
category: str category: str
distance_m: float distance_m: float
weight: float weight: float
# Вклад данного POI в суммарный балл, в процентах от 0..100.
# Позволяет фронтенду рендерить «долю» без знания формулы нормировки.
score_contribution: float
address: str | None address: str | None
class PoiScoreResponse(BaseModel): class PoiScoreResponse(BaseModel):
cad_num: str cad_num: str
radius_m: int radius_m: int
# Суммарный взвешенный балл инфраструктуры, нормированный в диапазон 0..100.
poi_weighted_score: float
top_poi: list[PoiScoreItem] top_poi: list[PoiScoreItem]
@ -155,6 +175,7 @@ def compute_poi_weighted_top7(
category=category, category=category,
distance_m=round(distance_m, 1), distance_m=round(distance_m, 1),
weight=round(weight, 6), weight=round(weight, 6),
score_contribution=0.0, # заполним после нормировки
address=address, address=address,
) )
) )
@ -163,9 +184,18 @@ def compute_poi_weighted_top7(
items.sort(key=lambda x: x.weight, reverse=True) items.sort(key=lambda x: x.weight, reverse=True)
top_items = items[:top_n] top_items = items[:top_n]
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_STRAIGHT_SCORE) * 100, клэмп [0, 100]
raw_sum = sum(i.weight for i in top_items)
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0), 1)
# score_contribution — доля данного POI в общем балле (0..100)
for item in top_items:
item.score_contribution = round((item.weight / _MAX_STRAIGHT_SCORE) * 100.0, 1)
return PoiScoreResponse( return PoiScoreResponse(
cad_num=cad_num, cad_num=cad_num,
radius_m=radius_m, radius_m=radius_m,
poi_weighted_score=poi_weighted_score,
top_poi=top_items, top_poi=top_items,
) )
@ -303,7 +333,9 @@ def compute_poi_routing_decay(
) )
if not rows: if not rows:
return PoiScoreResponse(cad_num=cad_num, radius_m=radius_m, top_poi=[]) return PoiScoreResponse(
cad_num=cad_num, radius_m=radius_m, poi_weighted_score=0.0, top_poi=[]
)
destinations = [(float(r["lon"]), float(r["lat"])) for r in rows] destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]
@ -311,9 +343,7 @@ def compute_poi_routing_decay(
times_min: list[float | None] times_min: list[float | None]
routing_used = False routing_used = False
try: try:
times_min = ors_client.matrix_durations_min( times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)
lon, lat, destinations, profile=profile
)
routing_used = True routing_used = True
except ors_client.OrsUnavailableError as exc: except ors_client.OrsUnavailableError as exc:
logger.info( logger.info(
@ -355,13 +385,25 @@ def compute_poi_routing_decay(
category=category, category=category,
distance_m=round(distance_m, 1), distance_m=round(distance_m, 1),
weight=round(weight, 6), weight=round(weight, 6),
score_contribution=0.0, # заполним после нормировки
address=address, address=address,
) )
) )
items.sort(key=lambda x: x.weight, reverse=True) items.sort(key=lambda x: x.weight, reverse=True)
top_items = items[:top_n]
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_ROUTING_SCORE) * 100, клэмп [0, 100]
raw_sum = sum(i.weight for i in top_items)
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_ROUTING_SCORE) * 100.0), 1)
# score_contribution — доля данного POI в общем балле (0..100)
for item in top_items:
item.score_contribution = round((item.weight / _MAX_ROUTING_SCORE) * 100.0, 1)
return PoiScoreResponse( return PoiScoreResponse(
cad_num=cad_num, cad_num=cad_num,
radius_m=radius_m, radius_m=radius_m,
top_poi=items[:top_n], poi_weighted_score=poi_weighted_score,
top_poi=top_items,
) )

View file

@ -10,8 +10,14 @@
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
source_key целое и не совпадает форматом. Сопоставление две дешёвые ветки: source_key целое и не совпадает форматом. Сопоставление две дешёвые ветки:
1) ``doc_ref = source_key::text`` (прямое равенство); 1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823). 2) ``doc_full_name ILIKE '%' || doc_ref || '%'`` (fallback для slug-ов вида
ppt2018_22823: длинное WFS-описание содержит короткий slug парсера; LIKE-метасимволы
в doc_ref экранируются ESCAPE '\').
Дедуп: best-effort OR-JOIN может зацепить несколько строк ``ekb_ppt_tep`` на один
``planning_projects`` (точная ветка + ILIKE-fallback). ``DISTINCT ON (source_key)``
держит контракт «одна строка = один ППТ/ПМТ-документ», отдавая приоритет точной ветке.
Граф вызова: build_ird_analyze_block parcel_ppt_tep JOIN planning_projects ekb_ppt_tep. Граф вызова: build_ird_analyze_block parcel_ppt_tep JOIN planning_projects ekb_ppt_tep.
Graceful: нет таблицы / пустой WKT / БД-ошибка []. Зеркалит стиль ``planning_lookup.py``. Graceful: нет таблицы / пустой WKT / БД-ошибка []. Зеркалит стиль ``planning_lookup.py``.
@ -33,26 +39,49 @@ logger = logging.getLogger(__name__)
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326). # ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type). # CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
_PPT_TEP_OVERLAP_SQL = text( _PPT_TEP_OVERLAP_SQL = text(
""" r"""
SELECT SELECT
pp.project_type, project_type,
pp.doc_status_name, doc_status_name,
pp.full_name, full_name,
pp.doc_full_name, doc_full_name,
pp.source_key, source_key,
t.doc_ref, doc_ref,
t.zone_balance, zone_balance,
t.tep, tep,
t.phasing, phasing,
t.source_pdf, source_pdf,
t.fetched_at fetched_at
FROM planning_projects pp FROM (
JOIN ekb_ppt_tep t ON ( -- DISTINCT ON (source_key): один ППТ/ПМТ-документ = одна строка (контракт docstring).
t.doc_ref = CAST(pp.source_key AS text) -- Точная ветка (doc_ref = source_key) приоритетна над slug-fallback (ILIKE).
OR (pp.doc_full_name IS NOT NULL AND t.doc_ref ILIKE '%' || pp.doc_full_name || '%') SELECT DISTINCT ON (pp.source_key)
) pp.project_type,
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326)) pp.doc_status_name,
ORDER BY pp.dmd_actual_year DESC NULLS LAST, pp.project_type pp.full_name,
pp.doc_full_name,
pp.source_key,
pp.dmd_actual_year,
t.doc_ref,
t.zone_balance,
t.tep,
t.phasing,
t.source_pdf,
t.fetched_at
FROM planning_projects pp
JOIN ekb_ppt_tep t ON (
t.doc_ref = CAST(pp.source_key AS text)
OR (
pp.doc_full_name IS NOT NULL
AND pp.doc_full_name ILIKE
'%' || replace(replace(replace(t.doc_ref, '\', '\\'), '%', '\%'), '_', '\_')
|| '%' ESCAPE '\'
)
)
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
ORDER BY pp.source_key, (t.doc_ref = CAST(pp.source_key AS text)) DESC
) deduped
ORDER BY dmd_actual_year DESC NULLS LAST, project_type
""" """
) )

View file

@ -74,13 +74,16 @@ class BuildingMatch:
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96). # Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает # Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по # (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на # ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно # Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка # широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см. # кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type. # (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
_RESOLVE_CAD_SQL = text(""" _RESOLVE_CAD_SQL = text("""
SELECT SELECT
b.cad_num, b.cad_num,
@ -97,13 +100,13 @@ _RESOLVE_CAD_SQL = text("""
) AS distance_m ) AS distance_m
FROM cad_buildings b FROM cad_buildings b
WHERE b.geom IS NOT NULL WHERE b.geom IS NOT NULL
ORDER BY b.geom <-> ST_SetSRID( ORDER BY b.geom::geography <-> ST_SetSRID(
ST_MakePoint( ST_MakePoint(
CAST(:lon AS double precision), CAST(:lon AS double precision),
CAST(:lat AS double precision) CAST(:lat AS double precision)
), ),
4326 4326
) )::geography
LIMIT 1 LIMIT 1
""") """)
@ -119,8 +122,9 @@ def resolve_cad_for_domrf(
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
footprint max_dist_m. Точка внутри площадного footprint distance 0. принимаем матч, если расстояние до footprint max_dist_m. Точка внутри
площадного footprint distance 0.
Returns: Returns:
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в

View file

@ -22,7 +22,7 @@ from __future__ import annotations
import logging import logging
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import DatabaseError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -41,8 +41,11 @@ def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> None:
else: else:
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}")) db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
db.commit() db.commit()
except OperationalError as e: except DatabaseError as e:
if concurrently and "cannot refresh materialized view" in str(e).lower(): # PostgreSQL emits "CONCURRENTLY cannot be used when the materialized
# view ... is not populated" (matview.c, SQLSTATE 55000), which psycopg3
# surfaces as InternalError (a DatabaseError sibling of OperationalError).
if concurrently and "concurrently cannot be used" in str(e).lower():
logger.warning( logger.warning(
"%s: CONCURRENTLY failed (MV likely not populated), " "%s: CONCURRENTLY failed (MV likely not populated), "
"falling back to non-concurrent refresh", "falling back to non-concurrent refresh",

View file

@ -0,0 +1,154 @@
"""РИАСУРТ Свердл gate-lookup для analyze_parcel (#108, multi-city scaling).
Читает ``riasurt_sverdl`` (наполняется harvest-task riasurt_sverdl_harvest.py) и отдаёт зоны
РИАСУРТ Свердл, пересекающие участок: тер.зона / функц.зона / красные линии / СЗЗ / ЗСО /
затопление / КРТ. Только для участков агломерации ЕКБ (ОКРАИНЫ Берёзовский, В.Пышма,
Среднеуральск, Арамиль, Сысерть), НЕ для ЕКБ-сити (тот не покрыт РИАСУРТ Свердл).
Gate-форма (``response["gate"]["riasurt"]``) собирается из topic-бакетов:
tier / func_zone / red_lines / szz / szo / flood_zone / krt + raw-список overlaps.
Graceful: нет таблицы (pre-migration) / пустой WKT / БД-ошибка пустой gate (analyze не падает).
Зеркалит стиль ``ird_overlay_lookup.py``.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Кадастровый район ЕКБ-сити — 66:41. Агломерация (окраины) — прочие районы 66:xx
# (Берёзовский 66:35, В.Пышма/Среднеуральск 66:62, Арамиль/Сысерть 66:25, ...).
_EKB_CITY_PREFIX = "66:41"
# Пустой gate-результат — sentinel (НЕ мутировать).
RIASURT_EMPTY_GATE: dict[str, Any] = {
"applicable": False,
"tier": None,
"func_zone": None,
"red_lines": False,
"szz": False,
"szo": False,
"flood_zone": False,
"krt": False,
"overlaps": [],
}
# Пересечения участка с зонами РИАСУРТ. geom в БД — EPSG:3857; участок передаём WKT в 4326 и
# трансформируем в 3857 для ST_Intersects по GIST idx_riasurt_sverdl_geom. CAST psycopg v3.
_RIASURT_OVERLAP_SQL = text(
"""
SELECT source_layer_id, layer_topic, mo_name, obshnz, description
FROM riasurt_sverdl
WHERE ST_Intersects(
geom,
ST_Transform(ST_GeomFromText(CAST(:parcel_wkt AS text), 4326), 3857)
)
ORDER BY layer_topic, source_layer_id
"""
)
def is_in_aglomeration_but_not_ekb(cad: str | None) -> bool:
"""True если участок в агломерации ЕКБ, но НЕ в самом ЕКБ-сити (66:41).
РИАСУРТ Свердл покрывает только окраины агломерации (Берёзовский, В.Пышма,
Среднеуральск, Арамиль, Сысерть) кадастровые районы 66:xx, кроме 66:41.
ЕКБ-сити (66:41) и участки вне Свердл (не 66:*) False.
Args:
cad: кадастровый номер участка ('66:35:...'). None/пусто False.
Returns:
bool.
"""
if not cad:
return False
cad = cad.strip()
if not cad.startswith("66:"):
return False
return not cad.startswith(_EKB_CITY_PREFIX + ":") and cad != _EKB_CITY_PREFIX
def parcel_riasurt_gate(
db: Session,
parcel_wkt: str | None,
cad: str | None,
) -> dict[str, Any]:
"""Gate-блок РИАСУРТ Свердл для участка агломерации ЕКБ.
Если участок не в агломерации (или в ЕКБ-сити) RIASURT_EMPTY_GATE (applicable=False).
Иначе ST_Intersects по riasurt_sverdl и сборка topic-бакетов.
Args:
db: сессия.
parcel_wkt: геометрия участка WKT (EPSG:4326). None пустой gate.
cad: кадастровый номер (для is_in_aglomeration_but_not_ekb).
Returns:
dict с полями tier / func_zone / red_lines / szz / szo / flood_zone / krt + overlaps.
Пусто при отсутствии участка / таблицы / пересечений / вне агломерации.
"""
if not parcel_wkt or not is_in_aglomeration_but_not_ekb(cad):
return dict(RIASURT_EMPTY_GATE)
try:
rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError, DataError) as exc:
logger.warning("parcel_riasurt_gate: таблица/запрос недоступны, skip: %s", exc)
return dict(RIASURT_EMPTY_GATE)
except Exception as exc:
logger.warning("parcel_riasurt_gate: неожиданная ошибка, skip: %s", exc)
return dict(RIASURT_EMPTY_GATE)
gate: dict[str, Any] = {
"applicable": True,
"tier": None, # description зоны topic='territorial_zone'
"func_zone": None, # description зоны topic='functional_zone'
"red_lines": False,
"szz": False,
"szo": False,
"flood_zone": False,
"krt": False,
"overlaps": [],
}
for r in rows:
topic = r["layer_topic"]
gate["overlaps"].append(
{
"source_layer_id": r["source_layer_id"],
"layer_topic": topic,
"mo_name": r["mo_name"],
"obshnz": r["obshnz"],
"description": r["description"],
}
)
if topic == "territorial_zone" and gate["tier"] is None:
gate["tier"] = r["description"] or r["obshnz"]
elif topic == "functional_zone" and gate["func_zone"] is None:
gate["func_zone"] = r["description"] or r["obshnz"]
elif topic == "red_lines":
gate["red_lines"] = True
elif topic == "szz":
gate["szz"] = True
elif topic == "szo":
gate["szo"] = True
elif topic == "flood_zone":
gate["flood_zone"] = True
elif topic == "krt":
gate["krt"] = True
return gate
__all__ = [
"RIASURT_EMPTY_GATE",
"is_in_aglomeration_but_not_ekb",
"parcel_riasurt_gate",
]

View file

@ -0,0 +1,95 @@
"""Refresh helper for the sales-tracker MVs (Issue #61).
Two independent materialized views built from the Объектив sales-tracker
("шахматки") snapshots (objective_lots / objective_lots_history), created by
data/sql/164_mv_sales_tracker_velocity_absorption.sql:
1. mv_sales_tracker_velocity_by_district per (district, month) sold/total/
avg-sold-price. Feeds the Site Finder Velocity Score (4th scoring criterion).
2. mv_sales_tracker_absorption_curves cumulative sold% as f(months from
sales_start_date) per (rooms_int, area_bucket). Foundation for recommend_mix
+ sellout forecast.
The two MVs do not depend on each other, so refresh order is irrelevant; both
are refreshed in the same call.
Scheduled via Celery beat hardcoded entry in workers/beat_schedule.py
('mv-sales-tracker-refresh-weekly', Mon 04:30 MSK).
Usage example (manual, via psql-connected shell or admin endpoint):
from app.services.site_finder.sales_tracker_mv_refresh import refresh_sales_tracker_mvs
counts = refresh_sales_tracker_mvs(db)
# logs: "mv_sales_tracker_velocity_by_district refreshed: 70 rows", etc.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.exc import DatabaseError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_MV_NAMES: tuple[str, ...] = (
"mv_sales_tracker_velocity_by_district",
"mv_sales_tracker_absorption_curves",
)
def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> int:
"""Run REFRESH MATERIALIZED VIEW [CONCURRENTLY] <mv_name>, return row count.
Falls back to non-concurrent on the known "cannot refresh concurrently"
error (MV empty or no UNIQUE index should not happen in prod since the
migration creates the UNIQUE index and populates the MV, but provides a
safe recovery path for first-run / post-recreation edge cases).
"""
try:
if concurrently:
db.execute(text(f"REFRESH MATERIALIZED VIEW CONCURRENTLY {mv_name}"))
else:
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
db.commit()
except DatabaseError as e:
# PostgreSQL emits "CONCURRENTLY cannot be used when the materialized
# view ... is not populated" (matview.c, SQLSTATE 55000), surfaced by
# psycopg3 as an InternalError (a DatabaseError sibling).
if concurrently and "concurrently cannot be used" in str(e).lower():
logger.warning(
"%s: CONCURRENTLY failed (MV likely not populated), "
"falling back to non-concurrent refresh",
mv_name,
)
db.rollback()
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
db.commit()
else:
raise
row = db.execute(text(f"SELECT COUNT(*) FROM {mv_name}")).first()
count = int(row[0]) if row else 0
logger.info("%s refreshed: %d rows", mv_name, count)
return count
def refresh_sales_tracker_mvs(db: Session, *, concurrently: bool = True) -> dict[str, int]:
"""Refresh both sales-tracker MVs.
Args:
db: SQLAlchemy Session (sync).
concurrently: When True, uses REFRESH CONCURRENTLY (non-blocking
readers continue). Requires the per-MV UNIQUE indexes
(mv_sales_tracker_velocity_by_district_pk,
mv_sales_tracker_absorption_curves_pk) and the MVs to be already
populated. Pass False only for first populate or after recreation.
Returns:
Mapping mv_name -> row count after refresh (for observability).
"""
counts: dict[str, int] = {}
for mv_name in _MV_NAMES:
counts[mv_name] = _refresh_mv(db, mv_name, concurrently=concurrently)
return counts

View file

@ -81,9 +81,14 @@ _EKB_REGION_CODE: int = 66
# Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations). # Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations).
_L2_MEDIUM_MIN_COVERAGE: float = 0.6 _L2_MEDIUM_MIN_COVERAGE: float = 0.6
# L2/L3 граница (включительно в L2, исключительно в L3) — половинно-открытые интервалы:
# L2 (hidden) : ready_dt IS NULL OR ready_dt <= now + L2_NEAR_HORIZON [now, now+12mo]
# L3 (future) : ready_dt IS NOT NULL AND ready_dt > now + L3_FUTURE_HORIZON (now+12mo, ∞)
# Оба используют одно и то же значение 12 мес., но по разные стороны границы — нет overlap.
_L2_NEAR_HORIZON_MONTHS: int = 12 # L2 берёт объекты с ready_dt ≤ сейчас+12мес (или NULL)
# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже # L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже
# почти-на-рынке предложение (ловится L1/L2), не «future». # почти-на-рынке предложение (ловится L1/L2), не «future».
_L3_FUTURE_HORIZON_MONTHS: int = 12 _L3_FUTURE_HORIZON_MONTHS: int = 12 # L3 берёт объекты с ready_dt > сейчас+12мес
# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго # L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго
# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт. # 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт.
@ -215,6 +220,10 @@ _L1_OPEN_SQL = text(
# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим; # free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим;
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с # documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно. # free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
#
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
_L2_HIDDEN_SQL = text( _L2_HIDDEN_SQL = text(
""" """
WITH latest AS ( WITH latest AS (
@ -222,7 +231,8 @@ _L2_HIDDEN_SQL = text(
o.obj_id, o.obj_id,
o.district_name, o.district_name,
o.flat_count, o.flat_count,
o.free_flats o.free_flats,
o.ready_dt
FROM domrf_kn_objects o FROM domrf_kn_objects o
WHERE o.region_cd = :region_cd WHERE o.region_cd = :region_cd
AND o.district_name IS NOT NULL AND o.district_name IS NOT NULL
@ -240,6 +250,8 @@ _L2_HIDDEN_SQL = text(
GREATEST(0, COALESCE(flat_count, 0) - free_flats) GREATEST(0, COALESCE(flat_count, 0) - free_flats)
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units ) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
FROM latest FROM latest
WHERE ready_dt IS NULL
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
GROUP BY district_name GROUP BY district_name
""" """
) )
@ -368,7 +380,11 @@ def compute_layer2_hidden(
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic). source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
units_estimate всегда 0 (clamp). Пустые данные []. Не бросает. units_estimate всегда 0 (clamp). Пустые данные []. Не бросает.
""" """
params: dict[str, Any] = {"region_cd": _EKB_REGION_CODE, "district": district} params: dict[str, Any] = {
"region_cd": _EKB_REGION_CODE,
"district": district,
"near_horizon": f"{_L2_NEAR_HORIZON_MONTHS} months",
}
rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2) rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2)
out: list[SupplyLayerRow] = [] out: list[SupplyLayerRow] = []

View file

@ -16,9 +16,13 @@ Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd),
Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date). Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date).
Linkage: domrf_kn_objects.obj_id Linkage: domrf_kn_objects.obj_id
objective_complex_mapping.domrf_obj_id objective_complex_mapping.domrf_obj_id (gated: is_reviewed/manual/score0.85)
objective_complex_mapping.objective_complex_name objective_complex_mapping.objective_complex_name
objective_corpus_room_month.project_name objective_corpus_room_month.project_name
OBJ-2 (#307): маппинги фильтруются по confidence (_MAPPING_CONFIDENCE_GATE) —
unreviewed low-score auto-matches (#1331/#1333 backfill) исключаются как
false-positive risk.
""" """
from __future__ import annotations from __future__ import annotations
@ -40,6 +44,24 @@ _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0
# пытаемся rosreestr_fallback. # пытаемся rosreestr_fallback.
_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50 _OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50
# OBJ-2 (#307): gate objective_complex_mapping by confidence перед использованием
# в velocity. Fuzzy-trgm backfill (#1331/#1333) добавил ~115 auto-matched строк с
# is_reviewed=false и низким match_score (вплоть до 0.50-0.625) — false-positive
# risk, который раздувал/искажал velocity конкурентов.
#
# Принимаем mapping только если:
# - is_reviewed = TRUE (человек подтвердил), ИЛИ
# - match_method = 'manual' (ручной маппинг, score обычно NULL), ИЛИ
# - match_score >= 0.85 (AUTO_ACCEPT_THRESHOLD — high-confidence auto,
# vault: fuzzy_trgm 0.85+ надёжен для auto-use).
#
# Строгий gate только на is_reviewed=true дал бы 2 строки из 303 → обнулил бы
# velocity-покрытие; 0.85-порог сохраняет 264/303 EKB-маппингов, отбрасывая 39
# низкоуверенных. Совпадает с AUTO_ACCEPT_THRESHOLD из objective_backfill.py.
_MAPPING_CONFIDENCE_GATE: str = (
"(cm.is_reviewed = TRUE OR cm.match_method = 'manual' OR cm.match_score >= 0.85)"
)
@dataclass(frozen=True) @dataclass(frozen=True)
class VelocityResult: class VelocityResult:
@ -195,7 +217,7 @@ def compute_velocity(
sales_rows = ( sales_rows = (
db.execute( db.execute(
text( text(
""" f"""
WITH all_competitors AS ( WITH all_competitors AS (
SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id
), ),
@ -204,6 +226,7 @@ def compute_velocity(
cm.objective_complex_name cm.objective_complex_name
FROM objective_complex_mapping cm FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id = ANY(:obj_ids) WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND {_MAPPING_CONFIDENCE_GATE}
) )
SELECT SELECT
ac.obj_id, ac.obj_id,
@ -311,12 +334,13 @@ def compute_velocity(
bucket_rows = ( bucket_rows = (
db.execute( db.execute(
text( text(
""" f"""
WITH mapped AS ( WITH mapped AS (
SELECT cm.domrf_obj_id AS obj_id, SELECT cm.domrf_obj_id AS obj_id,
cm.objective_complex_name cm.objective_complex_name
FROM objective_complex_mapping cm FROM objective_complex_mapping cm
WHERE cm.domrf_obj_id = ANY(:obj_ids) WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND {_MAPPING_CONFIDENCE_GATE}
) )
SELECT SELECT
m.obj_id, m.obj_id,

View file

@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
+ _DASH + _DASH
+ r"\s*" + r"\s*"
+ _NUM + _NUM
+ r"\s*этаж", + r"(?:\s*этаж\w*)?",
re.IGNORECASE, re.IGNORECASE,
) )
# высота — 'предельная высота ... 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО) # высота — 'предельная высота ... 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)

View file

@ -130,19 +130,28 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
if not daily.get("time"): if not daily.get("time"):
return None return None
t_max = daily.get("temperature_2m_max") or [] # Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
t_min = daily.get("temperature_2m_min") or [] # при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
precip = daily.get("precipitation_sum") or [] # TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
uv = daily.get("uv_index_max") or [] t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
wind_d = daily.get("winddirection_10m_dominant") or [] t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
wind_s = daily.get("windspeed_10m_max") or [] precip = [v for v in (daily.get("precipitation_sum") or []) if v is not None]
uv = [v for v in (daily.get("uv_index_max") or []) if v is not None]
wind_d = [v for v in (daily.get("winddirection_10m_dominant") or []) if v is not None]
wind_s = [v for v in (daily.get("windspeed_10m_max") or []) if v is not None]
# Circular mean направления ветра (vector sum) — избегает jump 359→1 # Circular mean направления ветра (vector sum) — избегает jump 359→1.
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None) # Если нет ни одного валидного сэмпла — dominant=None (не 0.0° = север).
y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None) if wind_d:
dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0 x = sum(math.cos(math.radians(d)) for d in wind_d)
y = sum(math.sin(math.radians(d)) for d in wind_d)
dominant_deg: float | None = (math.degrees(math.atan2(y, x)) + 360) % 360
else:
dominant_deg = None
rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"] rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
wind_label = rose[round(dominant / 45) % 8] wind_label: str | None = (
rose[round(dominant_deg / 45) % 8] if dominant_deg is not None else None
)
return { return {
"forecast_days": len(daily.get("time", [])), "forecast_days": len(daily.get("time", [])),
@ -156,7 +165,9 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
"precipitation_days": sum(1 for p in precip if p and p > 0.5), "precipitation_days": sum(1 for p in precip if p and p > 0.5),
"uv_index_max": round(max(uv), 1) if uv else None, "uv_index_max": round(max(uv), 1) if uv else None,
"wind": { "wind": {
"dominant_direction_deg": round(dominant), "dominant_direction_deg": (
round(dominant_deg) if dominant_deg is not None else None
),
"dominant_direction_label": wind_label, "dominant_direction_label": wind_label,
"max_speed_m_s": round(max(wind_s), 1) if wind_s else None, "max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
}, },
@ -226,13 +237,21 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
if not vals["t_max"]: if not vals["t_max"]:
seasons[season] = None seasons[season] = None
continue continue
# t_min/precip накапливаются НЕЗАВИСИМО от t_max (раздельные None-guard'ы
# выше) — при непустом t_max и all-null t_min/precip эти списки пусты, и
# sum()/len() даст ZeroDivisionError, min([]) — ValueError (#1578). Метрики
# по пустому списку → None вместо падения всего сезонного ответа.
t_min = vals["t_min"]
precip = vals["precip"]
seasons[season] = { seasons[season] = {
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1), "avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1), "avg_t_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
"max_t_c": round(max(vals["t_max"]), 1), "max_t_c": round(max(vals["t_max"]), 1),
"min_t_c": round(min(vals["t_min"]), 1), "min_t_c": round(min(t_min), 1) if t_min else None,
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1), "avg_precip_per_day_mm": (
"total_precip_mm": round(sum(vals["precip"]), 0), round(sum(precip) / len(precip), 1) if precip else None
),
"total_precip_mm": round(sum(precip), 0) if precip else 0,
"days_observed": len(vals["t_max"]), "days_observed": len(vals["t_max"]),
} }
return { return {

View file

@ -137,6 +137,16 @@ def _build_beat_schedule_from_db() -> dict:
"options": {"queue": "celery"}, "options": {"queue": "celery"},
} }
# РИАСУРТ Свердл (#108): региональные градостроительные зоны агломерации ЕКБ меняются
# медленно → ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). NB: harvest_all_*
# пока на ПЛЕЙСХОЛДЕР-bbox 5 МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO) — фактический
# прод-прогон только после уточнения границ.
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"options": {"queue": "celery"},
}
return schedule return schedule
@ -192,6 +202,14 @@ def _build_beat_schedule_fallback() -> dict:
"options": {"queue": "celery"}, "options": {"queue": "celery"},
} }
# РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
# NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
schedule["riasurt-sverdl-harvest-quarterly"] = {
"task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
"schedule": _parse_cron("0 4 1 1,4,7,10 *"),
"options": {"queue": "celery"},
}
return schedule return schedule
@ -506,4 +524,37 @@ def build_beat_schedule() -> dict:
"options": {"queue": "celery"}, "options": {"queue": "celery"},
} }
# Refresh mv_layout_velocity (#1666) — питает «лучшие планировки» best_layouts (#113).
# До этого MV рефрешился ТОЛЬКО ручным вызовом refresh_layout_velocity() → в проде
# данные молча устаревали. REFRESH ... CONCURRENTLY (non-blocking) по mv_layout_velocity.
#
# Воскресенье 03:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
# Намеренно ВНЕ monday-кластера тяжёлых site_finder-рефрешей (scrape_kn 04:15,
# ird 05:00, gknspecial 05:30, supply-layers 06:00, genplan 06:30, location 07:00) и
# вне воскресного okn-objects (04:30) — час запаса, не конкурирует за БД/CPU.
# MV агрегирует темпы вымывания планировок (меняются медленно) → еженедельно хватает.
# Техническая infra-задача, не в job_settings (как refresh-quarter-price-index / supply-layers).
schedule["refresh-layout-velocity"] = {
"task": "tasks.refresh_layout_velocity.refresh_layout_velocity",
"schedule": _parse_cron("0 3 * * sun"), # 03:00 MSK, воскресенье
"options": {"queue": "celery"},
}
# Sales-tracker MVs (#61) — питают Site Finder Velocity Score (4-й критерий) +
# recommend_mix / sellout-forecast. Оба MV (mv_sales_tracker_velocity_by_district,
# mv_sales_tracker_absorption_curves) рефрешатся CONCURRENTLY (non-blocking, требуют
# unique-индексы из миграции 161). Источник — objective_lots / objective_lots_history
# (Объектив-шахматки), наполняются objective_sync (Mon 04:15 МСК по умолчанию).
#
# Понедельник 04:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233) —
# ПОСЛЕ objective_sync (04:15), чтобы агрегаты считались по свежему снапшоту; в
# окне до тяжёлого monday-кластера site_finder-рефрешей (ird 05:00, gknspecial 05:30,
# supply-layers 06:00). Refresh лёгкий (~6с на 1.1M lots). Техническая infra-задача,
# не в job_settings (как refresh-quarter-price-index / refresh-layout-velocity).
schedule["mv-sales-tracker-refresh-weekly"] = {
"task": "tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs",
"schedule": _parse_cron("30 4 * * mon"), # 04:30 MSK, понедельник
"options": {"queue": "celery"},
}
return schedule return schedule

View file

@ -62,6 +62,8 @@ celery_app = Celery(
"app.workers.tasks.ekburg_permits_sync", "app.workers.tasks.ekburg_permits_sync",
"app.workers.tasks.cbr_macro_sync", "app.workers.tasks.cbr_macro_sync",
"app.workers.tasks.rosstat_macro_sync", "app.workers.tasks.rosstat_macro_sync",
"app.workers.tasks.refresh_quarter_price_index",
"app.workers.tasks.etl_newbuilding_crossload",
"app.workers.tasks.supply_layers_refresh", "app.workers.tasks.supply_layers_refresh",
"app.workers.tasks.location_refresh", "app.workers.tasks.location_refresh",
"app.workers.tasks.forecast", "app.workers.tasks.forecast",
@ -79,6 +81,9 @@ celery_app = Celery(
"app.workers.tasks.pat_subzones_load", "app.workers.tasks.pat_subzones_load",
"app.workers.tasks.izyatie_ocr_ingest", "app.workers.tasks.izyatie_ocr_ingest",
"app.workers.tasks.developer_registry_refresh", "app.workers.tasks.developer_registry_refresh",
"app.workers.tasks.refresh_layout_velocity",
"app.workers.tasks.riasurt_sverdl_harvest",
"app.workers.tasks.mv_sales_tracker_refresh",
], ],
) )
celery_app.conf.timezone = "Europe/Moscow" celery_app.conf.timezone = "Europe/Moscow"

View file

@ -8,8 +8,10 @@ Handlers регистрируются через Celery signals декорато
worker_process_init dispose SQLAlchemy engine в каждом prefork child-процессе, worker_process_init dispose SQLAlchemy engine в каждом prefork child-процессе,
чтобы избежать shared TCP-сокетов к PostgreSQL после fork(). чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
worker_ready при рестарте воркера находит все 'running'/'paused' записи worker_ready при рестарте воркера находит 'running'/'paused' записи
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume. kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
Для nspd_geo 'paused' (WAF-пауза) применяется 30-минутный cooldown
(_ZOMBIE_PAUSED_THRESHOLD), чтобы редеплой не аннулировал WAF-защиту.
""" """
import logging import logging
@ -147,10 +149,18 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
# старых runs сохранена в nspd_scrape_runs — никаких side-effects. # старых runs сохранена в nspd_scrape_runs — никаких side-effects.
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через # NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' / 'paused' jobs # nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' jobs — на
# — на worker_ready по определению нет активных воркеров, всё running == # worker_ready по определению нет активных воркеров, всё running ==
# zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых # zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых
# за минуту до редеплоя и оставляло их вечно висеть. # за минуту до редеплоя и оставляло их вечно висеть.
#
# 'paused' (consecutive_waf>=8 — NSPD-WAF забанил IP VPS) НЕ ре-enqueue'им
# безусловно: иначе каждый рестарт/редеплой воркера мгновенно аннулировал бы
# WAF-cooldown и worker снова долбил бы забаненный сервис. Применяем тот же
# 30-минутный порог, что и периодический cleanup_zombies
# (_ZOMBIE_PAUSED_THRESHOLD), переиспользуя константу чтобы избежать дрейфа.
from app.workers.tasks.nspd_geo import _ZOMBIE_PAUSED_THRESHOLD
db = SessionLocal() db = SessionLocal()
geo_resume_jobs: list[int] = [] geo_resume_jobs: list[int] = []
try: try:
@ -161,10 +171,16 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
UPDATE nspd_geo_jobs UPDATE nspd_geo_jobs
SET status = 'queued', SET status = 'queued',
error = COALESCE(error, 'auto-resume at worker_ready') error = COALESCE(error, 'auto-resume at worker_ready')
WHERE status IN ('running', 'paused') WHERE status = 'running'
OR (
status = 'paused'
AND heartbeat_at
< NOW() - CAST(:paused_threshold AS interval)
)
RETURNING job_id RETURNING job_id
""" """
) ),
{"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD},
) )
.mappings() .mappings()
.all() .all()

View file

@ -41,19 +41,21 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет # psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123): # синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
# (indicator_type, region, obs_date, value, source, frequency, unit, comment, # (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
# updated_at) PK (indicator_type, region, obs_date). # comment, updated_at). PK (indicator_type, region, obs_date, period_type).
# CBR-ряды используют period_type='unknown' (литерал) — они не несут sub-period
# granularity и различаются по obs_date.
UPSERT_KEY_RATE_SQL = text( UPSERT_KEY_RATE_SQL = text(
""" """
INSERT INTO macro_indicator ( INSERT INTO macro_indicator (
indicator_type, region, obs_date, value, indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment source, frequency, unit, comment
) VALUES ( ) VALUES (
'key_rate', 'rf', CAST(:d AS date), CAST(:v AS numeric), 'key_rate', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric),
'cbr', 'daily', '%', 'CBR key rate' 'cbr', 'daily', '%', 'CBR key rate'
) )
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value, value = EXCLUDED.value,
updated_at = now() updated_at = now()
""" """
@ -61,16 +63,17 @@ UPSERT_KEY_RATE_SQL = text(
# Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'. # Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'.
# obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx). # obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx).
# period_type='unknown' — месячный ряд без sub-period granularity.
UPSERT_INFLATION_SQL = text( UPSERT_INFLATION_SQL = text(
""" """
INSERT INTO macro_indicator ( INSERT INTO macro_indicator (
indicator_type, region, obs_date, value, indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment source, frequency, unit, comment
) VALUES ( ) VALUES (
'inflation_yoy', 'rf', CAST(:d AS date), CAST(:v AS numeric), 'inflation_yoy', 'rf', CAST(:d AS date), 'unknown', CAST(:v AS numeric),
'cbr', 'monthly', '%', 'CBR inflation YoY (ИПЦ, % г/г)' 'cbr', 'monthly', '%', 'CBR inflation YoY (ИПЦ, % г/г)'
) )
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value, value = EXCLUDED.value,
updated_at = now() updated_at = now()
""" """

View file

@ -0,0 +1,52 @@
"""Celery task: refresh the sales-tracker MVs (Issue #61).
Scheduled via hardcoded beat entry in workers/beat_schedule.py:
'mv-sales-tracker-refresh-weekly' weekly on Monday at 04:30 MSK.
Refreshes (both CONCURRENTLY, non-blocking):
- mv_sales_tracker_velocity_by_district (Site Finder Velocity Score, 4th criterion)
- mv_sales_tracker_absorption_curves (recommend_mix + sellout forecast foundation)
Both MVs are built from the Объектив sales-tracker ("шахматки") snapshots
(objective_lots / objective_lots_history). Source data refreshes via the
objective_sync beat job, so a weekly MV refresh keeps the aggregates current.
MV-source migration: data/sql/164_mv_sales_tracker_velocity_absorption.sql.
"""
from __future__ import annotations
import logging
from typing import Any
from app.core.db import SessionLocal
from app.services.site_finder.sales_tracker_mv_refresh import refresh_sales_tracker_mvs
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(
bind=True,
name="tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs",
max_retries=2,
)
def refresh_sales_tracker_mvs_task(self: Any) -> dict[str, Any]:
"""REFRESH both sales-tracker MVs (#61).
Both MVs are refreshed CONCURRENTLY (non-blocking, require their UNIQUE
indexes created by migration 161); the service falls back to non-concurrent
if an MV is found unpopulated (first-run edge case).
Returns result dict for the Celery task result store / logging.
"""
db = SessionLocal()
try:
counts = refresh_sales_tracker_mvs(db, concurrently=True)
logger.info("refresh_sales_tracker_mvs: completed, rows=%s", counts)
return {"status": "ok", "rows": counts}
except Exception as e:
logger.exception("refresh_sales_tracker_mvs failed: %s", e)
raise
finally:
db.close()

View file

@ -51,6 +51,16 @@ WAF_BACKOFF_BASE_S = 30
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу. # нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes" _ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
_ZOMBIE_PAUSED_THRESHOLD = "30 minutes" _ZOMBIE_PAUSED_THRESHOLD = "30 minutes"
# Issue #1655: jobs, застрявшие в 'queued' после ре-enqueue (worker_ready /
# cleanup_zombies ставят 'queued' + apply_async), но чьё broker-сообщение
# потерялось (flush брокера, crash до pickup, purge очереди) — раньше никто
# не ре-reap'ил. cleanup_zombies матчил только 'running'/'paused'.
# Матчим только 'queued' c НЕ-NULL stale heartbeat_at: свежесозданный job
# (enqueue_geo_job) имеет heartbeat_at IS NULL (нет дефолта в схеме 77_) — его
# initial apply_async ещё в полёте, ре-reap'ить рано. Порог = running-порог
# (6 мин): сообщение либо взято воркером (→ status станет 'running' через claim),
# либо потеряно — 6 мин достаточно, чтобы отличить потерю от задержки очереди.
_ZOMBIE_QUEUED_THRESHOLD = "6 minutes"
# ── Helpers для job/target lifecycle ──────────────────────────────────────── # ── Helpers для job/target lifecycle ────────────────────────────────────────
@ -79,8 +89,19 @@ def _log(
pass pass
def _start_job(db: Session, job_id: int) -> None: def _start_job(db: Session, job_id: int) -> bool:
db.execute( """Атомарный claim job'а: переводит в 'running' ТОЛЬКО если он ещё не 'running'.
Issue #1621: дублирующие task-сообщения на один job_id (worker_ready resume +
cleanup_zombies beat одновременно, либо overlap контейнеров при rolling-redeploy,
либо два prefork-child при --concurrency>1) раньше оба безусловно делали
UPDATE'running' и оба входили в while-loop двойные WAF-хиты + затёртые счётчики.
Теперь claim атомарен: `WHERE job_id=:id AND status <> 'running'` + RETURNING.
Если строка не вернулась кто-то уже держит claim, второй worker должен выйти.
Returns True если claim получен, False если job уже 'running' (или не найден).
"""
claimed = db.execute(
text( text(
""" """
UPDATE nspd_geo_jobs UPDATE nspd_geo_jobs
@ -89,12 +110,17 @@ def _start_job(db: Session, job_id: int) -> None:
heartbeat_at = NOW(), heartbeat_at = NOW(),
updated_at = NOW() updated_at = NOW()
WHERE job_id = :id WHERE job_id = :id
AND status <> 'running'
RETURNING job_id
""" """
), ),
{"id": job_id}, {"id": job_id},
) ).scalar_one_or_none()
db.commit() db.commit()
if claimed is None:
return False
_log(db, job_id, "info", "start", "job started/resumed") _log(db, job_id, "info", "start", "job started/resumed")
return True
def _heartbeat(db: Session, job_id: int, **counts: int) -> None: def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
@ -385,7 +411,15 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
if not job: if not job:
return {"error": "job_not_found", "job_id": job_id} return {"error": "job_not_found", "job_id": job_id}
_start_job(db, job_id) # Атомарный claim (issue #1621): если job уже 'running', значит другое
# task-сообщение на тот же job_id уже в работе — выходим, не дублируя
# WAF-хиты и не затирая счётчики параллельным циклом.
if not _start_job(db, job_id):
logger.info(
"process_nspd_geo_job: job=%s already claimed (running) — skipping duplicate",
job_id,
)
return {"job_id": job_id, "skipped": True, "reason": "already_running"}
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings. # Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска. # Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
if job["rate_ms"]: if job["rate_ms"]:
@ -637,6 +671,10 @@ def cleanup_zombies() -> dict[str, Any]:
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) - status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин)
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис. сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
- status='queued' c НЕ-NULL heartbeat старше _ZOMBIE_QUEUED_THRESHOLD (6 мин)
ре-enqueue'нутый job, чьё broker-сообщение потерялось (issue #1655).
heartbeat_at IS NOT NULL отсекает свежесозданные jobs (initial apply_async
ещё в полёте) их heartbeat ставится только при первом _start_job.
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
не матчит WHERE-clause. не матчит WHERE-clause.
@ -661,12 +699,22 @@ def cleanup_zombies() -> dict[str, Any]:
AND heartbeat_at AND heartbeat_at
< NOW() - CAST(:paused_threshold AS interval) < NOW() - CAST(:paused_threshold AS interval)
) )
OR (
-- issue #1655: ре-enqueue'нутый job застрял в 'queued'
-- (потерянное broker-сообщение). heartbeat_at IS NOT NULL
-- отсекает свежесозданные jobs с ещё-в-полёте apply_async.
status = 'queued'
AND heartbeat_at IS NOT NULL
AND heartbeat_at
< NOW() - CAST(:queued_threshold AS interval)
)
RETURNING job_id RETURNING job_id
""" """
), ),
{ {
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD, "running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD, "paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
"queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
}, },
) )
.mappings() .mappings()

View file

@ -153,9 +153,12 @@ def import_anton_objective(
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}") _finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
except Exception: except Exception:
pass pass
# Дополним результат полезной диагностикой # Логируем полезную диагностику, но НЕ возвращаем dict —
# re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
# рассинхронился с objective_scrape_runs.status='failed' (#1623).
info = get_sqlite_info(sqlite_path_eff) info = get_sqlite_info(sqlite_path_eff)
return {"run_id": run_id, "error": "sqlite_not_found", "sqlite_info": info} logger.error("sqlite_not_found diagnostics for run=%s: %s", run_id, info)
raise
except Exception as e: except Exception as e:
logger.exception("import_anton_objective failed: %s", e) logger.exception("import_anton_objective failed: %s", e)

View file

@ -70,7 +70,10 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
bbox = _geojson_bbox_3857(qfeat.geometry) bbox = _geojson_bbox_3857(qfeat.geometry)
if bbox is None: if bbox is None:
continue continue
n_quarters += 1 # Staging-счётчик квартала: прибавляем к итогам ТОЛЬКО после успешного
# commit (строка ниже). Иначе при сбое commit + rollback откатятся
# незакоммиченные UPSERT'ы, а отчёт завысит число persisted фич (#1624).
q_features = 0
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items(): for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
layer_id = LAYERS.get(layer_key) layer_id = LAYERS.get(layer_key)
if layer_id is None: if layer_id is None:
@ -94,10 +97,13 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
feature=feat, feature=feat,
fetched_at=fetched_at, fetched_at=fetched_at,
): ):
n_features += 1 q_features += 1
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при # Durable per-quarter commit: длинный grid-walk не теряет прогресс при
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ). # краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
db.commit() db.commit()
# Commit прошёл → фичи квартала реально persisted, учитываем в отчёте.
n_quarters += 1
n_features += q_features
except Exception as exc: except Exception as exc:
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc) logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
db.rollback() # сбросить незакоммиченный tx квартала перед следующим db.rollback() # сбросить незакоммиченный tx квартала перед следующим

View file

@ -0,0 +1,47 @@
"""Celery task: refresh mv_layout_velocity (питает «лучшие планировки» best_layouts).
Scheduled via hardcoded beat entry in workers/beat_schedule.py:
'refresh-layout-velocity' weekly on Sunday at 03:00 MSK.
Стоит в «ночном» окне воскресенья, отдельно от monday-кластера тяжёлых
site_finder-рефрешей (supply-layers / ird / gknspecial / cbr и т.д.), чтобы
не конкурировать с ними за БД/CPU.
Issue: #1666 (рефреш не был подключён в beat → данные best_layouts молча
устаревали в проде). MV-источник: #113 (PR B, mv_layout_velocity → best_layouts).
"""
from __future__ import annotations
import logging
from typing import Any
from app.core.db import SessionLocal
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
@celery_app.task(
bind=True,
name="tasks.refresh_layout_velocity.refresh_layout_velocity",
max_retries=2,
)
def refresh_layout_velocity_task(self: Any) -> dict[str, Any]:
"""REFRESH MATERIALIZED VIEW mv_layout_velocity (best_layouts, #113 / #1666).
MV рефрешится CONCURRENTLY (non-blocking, требует unique-индекс
mv_layout_velocity_pk); сервис сам падает в non-concurrent при unpopulated MV.
Returns result dict for Celery task result store / logging.
"""
db = SessionLocal()
try:
count = refresh_layout_velocity(db, concurrently=True)
logger.info("refresh_layout_velocity: completed, mv rows=%d", count)
return {"status": "ok", "mv_layout_velocity_rows": count}
except Exception as e:
logger.exception("refresh_layout_velocity failed: %s", e)
raise
finally:
db.close()

View file

@ -0,0 +1,271 @@
"""Celery task: harvest РИАСУРТ Свердл (folderId 1224) → ``riasurt_sverdl`` (#108).
14 ключевых слоёв РИАСУРТ Свердл (ПЗЗ/планирование, инженерные ЗОУИТ, risk, opportunity)
по агломерации ЕКБ ОКРАИНЫ (Берёзовский, В.Пышма, Среднеуральск, Арамиль, Сысерть),
НЕ ЕКБ-сити (тот не интегрирован в ФГИС ТП). Foundation для multi-city scaling Site Finder.
Источник NSPD aeggis/v4 WMS (тот же, что федеральные слои), layerId из регионального
каталога РИАСУРТ (см. ``nspd_client.RIASURT_SVERDL_LAYERS``). Bulk-метод клиента
``get_riasurt_sverdl_in_bbox`` grid-walk'ит каждый слой в bbox одного МО.
Mirror conventions (ird_harvest.py / backend.md):
SessionLocal() + try/finally close; logger (не print).
SAVEPOINT per-row (``with db.begin_nested():``) одна битая фича не валит батч; commit в конце.
CAST(:x AS type) НИКОГДА :x::type (psycopg v3). Геометрия 3857 MULTIPOLYGON.
geom_msk66 generated STORED в БД (миграция 163), здесь НЕ пишем.
Сбой одного слоя/МО не валит весь прогон.
Расписание ежеквартально (beat_schedule.py: riasurt-sverdl-harvest-quarterly): региональные
градостроительные зоны меняются медленно.
ВНИМАНИЕ: bbox 5 МО ПЛЕЙСХОЛДЕРЫ (см. MO_BBOXES TODO). Точные bbox резолвятся ПОСЛЕ
деплоя через NSPD search по границам МО / ручную выгрузку. Текущие значения грубые
приближения в EPSG:3857 (Web Mercator), достаточные для smoke, НО НЕ для прод-harvest.
"""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import SessionLocal
from app.services.scrapers.nspd_client import (
RIASURT_SVERDL_LAYERS,
NSPDClient,
riasurt_layer_topic,
)
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── bbox 5 МО агломерации ЕКБ (EPSG:3857) ────────────────────────────────────
# TODO(#108): ПЛЕЙСХОЛДЕРЫ — грубые приближения вокруг центров МО (±~6 км в 3857).
# Точные административные bbox резолвятся ПОСЛЕ деплоя (NSPD search по границе МО /
# ручная выгрузка границ из РИАСУРТ). НЕ запускать harvest_all_riasurt_sverdl на проде
# до уточнения — иначе grid-walk промахнётся мимо застройки. Формат: (xmin, ymin, xmax, ymax).
#
# Центры (приблизительно, WGS84 → 3857):
# Берёзовский ~56.91N 60.81E В.Пышма ~56.97N 60.58E
# Среднеуральск ~56.99N 60.48E Арамиль ~56.69N 60.83E
# Сысерть ~56.50N 60.82E
_PLACEHOLDER_HALF_M = 6000.0 # ±6 км вокруг центра — заведомо грубо, заменить на реальные границы
MO_BBOXES: dict[str, tuple[float, float, float, float]] = {
# TODO(#108): заменить плейсхолдеры на реальные административные bbox МО.
"Берёзовский": (6770000.0, 7710000.0, 6782000.0, 7722000.0),
"Верхняя Пышма": (6745000.0, 7720000.0, 6757000.0, 7732000.0),
"Среднеуральск": (6734000.0, 7723000.0, 6746000.0, 7735000.0),
"Арамиль": (6772000.0, 7677000.0, 6784000.0, 7689000.0),
"Сысерть": (6771000.0, 7639000.0, 6783000.0, 7651000.0),
}
# UPSERT фичи РИАСУРТ. Геометрия НСПД (GeoJSON 3857) → ST_GeomFromGeoJSON → SetSRID 3857 →
# ST_Multi (приводим Polygon к MULTIPOLYGON под колонку). geom_msk66 — generated в БД.
# Нет стабильного natural-ключа дедупа у РИАСУРТ-фич → INSERT без ON CONFLICT; перед
# harvest_all очищаем строки по mo_name (re-harvest квартала идемпотентен на уровне МО).
_INSERT_SQL = text(
"""
INSERT INTO riasurt_sverdl (
source_layer_id, layer_topic, mo_name, obshnz, description,
raw_props, geom, fetched_at
) VALUES (
CAST(:source_layer_id AS integer),
CAST(:layer_topic AS text),
CAST(:mo_name AS text),
CAST(:obshnz AS text),
CAST(:description AS text),
CAST(:raw_props AS jsonb),
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geojson AS text)), 3857)),
CAST(:fetched_at AS timestamptz)
)
"""
)
_DELETE_MO_SQL = text("DELETE FROM riasurt_sverdl WHERE mo_name = CAST(:mo_name AS text)")
def _extract_obshnz(props: dict[str, Any]) -> str | None:
"""reg_numb_border / индекс зоны из properties фичи (когда есть)."""
opts = props.get("options") or {}
val = (
props.get("reg_numb_border")
or opts.get("reg_numb_border")
or props.get("obshnz")
or opts.get("obshnz")
)
return str(val) if val is not None else None
def _extract_description(props: dict[str, Any]) -> str | None:
"""Человекочитаемое название/описание фичи."""
opts = props.get("options") or {}
val = (
props.get("label")
or props.get("name")
or props.get("descr")
or opts.get("label")
or opts.get("descr")
)
return str(val) if val is not None else None
def _insert_feature(
db: Session,
*,
layer_id: int,
mo_name: str,
feature: Any,
fetched_at: str,
) -> bool:
"""INSERT одной РИАСУРТ-фичи под SAVEPOINT. True если записана.
Пропускает фичи без geometry одна битая не валит батч.
"""
geom = feature.geometry
if not geom:
return False
props = feature.properties or {}
try:
with db.begin_nested():
db.execute(
_INSERT_SQL,
{
"source_layer_id": layer_id,
"layer_topic": riasurt_layer_topic(layer_id),
"mo_name": mo_name,
"obshnz": _extract_obshnz(props),
"description": _extract_description(props),
"raw_props": json.dumps(props, ensure_ascii=False),
"geojson": json.dumps(geom),
"fetched_at": fetched_at,
},
)
return True
except Exception as exc:
logger.warning(
"riasurt_sverdl: insert failed layer=%s mo=%s: %s",
layer_id,
mo_name,
exc,
)
return False
@celery_app.task(
name="tasks.riasurt_sverdl_harvest.harvest_riasurt_sverdl_for_mo",
rate_limit="1/s",
)
def harvest_riasurt_sverdl_for_mo(
mo_name: str,
mo_bbox: list[float] | tuple[float, float, float, float],
layers: list[int] | None = None,
) -> dict[str, int]:
"""Harvest 14 слоёв РИАСУРТ Свердл в bbox одного МО → riasurt_sverdl.
Перед записью очищает прошлые строки этого МО (re-harvest идемпотентен на уровне МО
у РИАСУРТ-фич нет стабильного natural-ключа дедупа).
Args:
mo_name: имя МО (Берёзовский / Верхняя Пышма / ...). Денормализуется в строки.
mo_bbox: (xmin, ymin, xmax, ymax) в EPSG:3857.
layers: список layerId РИАСУРТ. None все 14 ключевых.
Returns:
{'features': M, 'layers': L} записано фич / обработано слоёв.
"""
fetched_at = datetime.now(UTC).isoformat()
bbox = (
float(mo_bbox[0]),
float(mo_bbox[1]),
float(mo_bbox[2]),
float(mo_bbox[3]),
)
client = NSPDClient()
db = SessionLocal()
n_features = 0
n_layers = 0
try:
per_layer = client.get_riasurt_sverdl_in_bbox(bbox, layers)
# Идемпотентность на уровне МО: чистим прошлый проход перед записью нового.
with db.begin_nested():
db.execute(_DELETE_MO_SQL, {"mo_name": mo_name})
for layer_id, feats in per_layer.items():
n_layers += 1
for feat in feats:
if _insert_feature(
db,
layer_id=layer_id,
mo_name=mo_name,
feature=feat,
fetched_at=fetched_at,
):
n_features += 1
db.commit()
logger.info(
"riasurt_sverdl: МО=%s слоёв=%d фич=%d",
mo_name,
n_layers,
n_features,
)
except Exception:
db.rollback()
logger.exception("riasurt_sverdl: harvest_riasurt_sverdl_for_mo упал, mo=%s", mo_name)
raise
finally:
db.close()
return {"features": n_features, "layers": n_layers}
@celery_app.task(name="tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl")
def harvest_all_riasurt_sverdl(layers: list[int] | None = None) -> dict[str, int]:
"""Прогон по всем 5 МО агломерации ЕКБ (MO_BBOXES) → riasurt_sverdl.
Вызывает harvest_riasurt_sverdl_for_mo синхронно по каждому МО (rate_limit самой
per-MO задачи действует при .delay()-вызове; здесь синхронный прогон каждый МО
последовательно, чтобы один beat-тик не запускал 5 параллельных grid-walk'ов).
Args:
layers: список layerId. None все 14 ключевых (RIASURT_SVERDL_LAYERS).
Returns:
{'mo': N, 'features': M} обработано МО / суммарно записано фич.
"""
if not settings.enable_riasurt_harvest:
logger.warning(
"RIASURT harvest disabled — set ENABLE_RIASURT_HARVEST=true after configuring "
"real MO_BBOXES (#108)"
)
return {"mo": 0, "features": 0}
n_mo = 0
n_features = 0
for mo_name, bbox in MO_BBOXES.items():
try:
res = harvest_riasurt_sverdl_for_mo(mo_name, bbox, layers)
n_features += int(res.get("features", 0))
n_mo += 1
except Exception:
logger.exception("riasurt_sverdl: МО %s упал — продолжаем остальные", mo_name)
continue
logger.info(
"riasurt_sverdl: harvest_all завершён, МО=%d (из %d) фич=%d",
n_mo,
len(MO_BBOXES),
n_features,
)
return {"mo": n_mo, "features": n_features}
__all__ = [
"MO_BBOXES",
"harvest_all_riasurt_sverdl",
"harvest_riasurt_sverdl_for_mo",
]
# Layers catalog re-export (для удобства тестов / интроспекции).
_RIASURT_LAYER_COUNT = len(RIASURT_SVERDL_LAYERS)

View file

@ -47,21 +47,23 @@ from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет # psycopg v3: CAST(:x AS type) — НИКОГДА :x::type (SQLAlchemy+psycopg3 роняет
# синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 123): # синтаксис на ::). Контракт колонок совпадает с macro_indicator (migration 163):
# (indicator_type, region, obs_date, value, source, frequency, unit, comment, # (indicator_type, region, obs_date, period_type, value, source, frequency, unit,
# updated_at) PK (indicator_type, region, obs_date). source='rosstat', # comment, updated_at). PK (indicator_type, region, obs_date, period_type).
# frequency='yearly' (текущие ряды Росстата — годовые: obs_date = 1 января года). # Не-ЕМИСС источники (open-data, СМР) используют period_type='unknown' (литерал) —
# они различаются по obs_date без sub-period granularity.
# source='rosstat', frequency='yearly' (текущие ряды Росстата — годовые).
UPSERT_ROSSTAT_SQL = text( UPSERT_ROSSTAT_SQL = text(
""" """
INSERT INTO macro_indicator ( INSERT INTO macro_indicator (
indicator_type, region, obs_date, value, indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment source, frequency, unit, comment
) VALUES ( ) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date), CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric), 'unknown', CAST(:v AS numeric),
'rosstat', 'yearly', CAST(:unit AS text), CAST(:comment AS text) 'rosstat', 'yearly', CAST(:unit AS text), CAST(:comment AS text)
) )
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value, value = EXCLUDED.value,
source = EXCLUDED.source, source = EXCLUDED.source,
frequency = EXCLUDED.frequency, frequency = EXCLUDED.frequency,
@ -71,20 +73,23 @@ UPSERT_ROSSTAT_SQL = text(
""" """
) )
# ЕМИСС-ряды (source='emiss'): frequency параметризована (quarterly для доходов, # ЕМИСС-ряды (source='emiss'): period_type параметризован — 'year' | 'quarter' |
# monthly для ИПЦ когда добавится) — в отличие от open-data, где она фикс 'yearly'. # 'month' (из _emiss_period_granularity). Это ключевое отличие от не-ЕМИСС источников:
# Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — НИКОГДА :x::type (psycopg v3). # период ЕМИСС-ряда несёт granularity, необходимую для разделения годового агрегата
# ('год' → 'year') и Q1 ('I квартал' → 'quarter'), оба с obs_date=YYYY-01-01 (#1606).
# frequency параметризована (quarterly для доходов, monthly для ИПЦ когда добавится).
# CAST(:x AS type) — НИКОГДА :x::type (psycopg v3).
UPSERT_EMISS_SQL = text( UPSERT_EMISS_SQL = text(
""" """
INSERT INTO macro_indicator ( INSERT INTO macro_indicator (
indicator_type, region, obs_date, value, indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment source, frequency, unit, comment
) VALUES ( ) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date), CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric), CAST(:period_type AS text), CAST(:v AS numeric),
'emiss', CAST(:freq AS text), CAST(:unit AS text), CAST(:comment AS text) 'emiss', CAST(:freq AS text), CAST(:unit AS text), CAST(:comment AS text)
) )
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value, value = EXCLUDED.value,
source = EXCLUDED.source, source = EXCLUDED.source,
frequency = EXCLUDED.frequency, frequency = EXCLUDED.frequency,
@ -96,19 +101,19 @@ UPSERT_EMISS_SQL = text(
# Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл), # Индекс цен на СМР — открытый xlsx Росстата: source='rosstat' (rosstat.gov.ru-файл),
# НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'), # НО frequency='monthly' (в отличие от open-data демографии — там фикс 'yearly'),
# поэтому frequency параметризована. MacroRow не несёт frequency-поля, подставляем # поэтому frequency параметризована. MacroRow не несёт frequency/period_type-полей;
# литералом в bind. Тот же PK и ON CONFLICT-контракт. CAST(:x AS type) — psycopg v3. # подставляем литералами. CAST(:x AS type) — psycopg v3.
UPSERT_ROSSTAT_MONTHLY_SQL = text( UPSERT_ROSSTAT_MONTHLY_SQL = text(
""" """
INSERT INTO macro_indicator ( INSERT INTO macro_indicator (
indicator_type, region, obs_date, value, indicator_type, region, obs_date, period_type, value,
source, frequency, unit, comment source, frequency, unit, comment
) VALUES ( ) VALUES (
CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date), CAST(:itype AS text), CAST(:region AS text), CAST(:d AS date),
CAST(:v AS numeric), 'unknown', CAST(:v AS numeric),
'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text) 'rosstat', 'monthly', CAST(:unit AS text), CAST(:comment AS text)
) )
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET ON CONFLICT (indicator_type, region, obs_date, period_type) DO UPDATE SET
value = EXCLUDED.value, value = EXCLUDED.value,
source = EXCLUDED.source, source = EXCLUDED.source,
frequency = EXCLUDED.frequency, frequency = EXCLUDED.frequency,
@ -185,9 +190,15 @@ def _upsert_monthly_rows(db: Session, rows: list[MacroRow]) -> int:
def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int: def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
"""Апсертит EmissRow в macro_indicator (source='emiss', frequency per-row). """Апсертит EmissRow в macro_indicator (source='emiss', frequency+period_type per-row).
SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает SAVEPOINT per-row, чтобы один битый ряд не откатывал всю транзакцию. Возвращает
число успешных upsert'ов.""" число успешных upsert'ов.
period_type (из r.period_type) часть нового PK (migration 163): позволяет
годовому агрегату ('year') и Q1 ('quarter') за один год коexist в таблице без
взаимной перезаписи (#1606).
"""
upserted = 0 upserted = 0
for r in rows: for r in rows:
try: try:
@ -198,6 +209,7 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
"itype": r.indicator_type, "itype": r.indicator_type,
"region": r.region, "region": r.region,
"d": r.obs_date, "d": r.obs_date,
"period_type": r.period_type,
"v": r.value, "v": r.value,
"freq": r.frequency, "freq": r.frequency,
"unit": r.unit, "unit": r.unit,
@ -207,10 +219,11 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int:
upserted += 1 upserted += 1
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"upsert emiss %s/%s@%s=%s failed: %s", "upsert emiss %s/%s@%s[%s]=%s failed: %s",
r.indicator_type, r.indicator_type,
r.region, r.region,
r.obs_date, r.obs_date,
r.period_type,
r.value, r.value,
e, e,
) )

View file

@ -401,11 +401,28 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
) )
skipped_fresh = set(rows) skipped_fresh = set(rows)
if skipped_fresh: if skipped_fresh:
# Корректируем targets_total — skipped quarters не входят в прогресс # Корректируем targets_total — skipped quarters не входят в прогресс.
# Bug #1654: блок выполняется при КАЖДОМ вызове (resume re-enqueue'ит
# тот же enqueue_cadastre_harvest), а уже обработанные кварталы тоже
# получают свежий cad_quarter_stats.fetched_at → попадают в
# skipped_fresh повторно. Поэтому:
# • targets_skipped — идемпотентный SET (= число свежих сейчас),
# а не cumulative INCREMENT (иначе раздувается на каждый resume);
# • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке
# кварталы (len(quarters) skipped_fresh). При resume уже
# обработанные попадают в skipped_fresh, поэтому к остатку
# добавляем уже учтённый прогресс (done + failed), иначе total
# занижается и _maybe_finish_job помечает job 'done' после первого
# доработанного квартала (#1654-followup). GREATEST с самим
# прогрессом сохраняет монотонность (total не уменьшается).
db.execute( db.execute(
text( text(
"UPDATE cadastre_jobs SET targets_total = :new_total, " "UPDATE cadastre_jobs SET "
"targets_skipped = COALESCE(targets_skipped, 0) + :sk " "targets_total = GREATEST("
":new_total + COALESCE(targets_done, 0) + COALESCE(targets_failed, 0), "
"COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)"
"), "
"targets_skipped = :sk "
"WHERE job_id = :id" "WHERE job_id = :id"
), ),
{ {

View file

@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня # Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today). # (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
# При :force = true фильтр снимается — грузим все объекты последнего snapshot. # При :force = true фильтр снимается — грузим все объекты последнего snapshot.
#
# LIMIT :max_objects: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
# max_objects=None (force "Загрузить все" без явного потолка) грузим ВСЕ строки
# последнего snapshot, а не молча режем до _DEFAULT_MAX_OBJECTS.
_SELECT_TARGETS_SQL = text( _SELECT_TARGETS_SQL = text(
""" """
SELECT obj_id, snapshot_date SELECT obj_id, snapshot_date
@ -74,10 +78,12 @@ def scrape_kn_catalog_objects(
Args: Args:
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл. region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
max_objects: Максимум объектов за один run. Default 300. max_objects: Максимум объектов за один run. Если не задан: при force=True
force: Если True игнорирует фильтр "уже сегодня обновлён" и грузит лимита нет (грузим все), при force=False _DEFAULT_MAX_OBJECTS (300).
все объекты последнего snapshot (admin "Загрузить все"). По умолчанию force: Если True игнорирует фильтр "уже сегодня обновлён" и (при не
False пропускает то, что уже скраплено сегодня. заданном max_objects) снимает лимит, грузя ВСЕ объекты последнего
snapshot (admin "Загрузить все"). По умолчанию False пропускает
то, что уже скраплено сегодня, и режет batch до 300.
Returns: Returns:
dict с ключами: region_code, snapshot_date, obj_ids_count, dict с ключами: region_code, snapshot_date, obj_ids_count,
@ -95,7 +101,15 @@ def scrape_kn_catalog_objects(
""" """
from app.services.scrapers.domrf_catalog_object import scrape_catalog_objects from app.services.scrapers.domrf_catalog_object import scrape_catalog_objects
limit = max_objects if max_objects is not None else _DEFAULT_MAX_OBJECTS # Явный max_objects всегда уважается. Без него:
# force=True ("Загрузить все") → лимита нет (limit=None → SQL LIMIT NULL = все строки);
# force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_OBJECTS.
if max_objects is not None:
limit: int | None = max_objects
elif force:
limit = None
else:
limit = _DEFAULT_MAX_OBJECTS
db = SessionLocal() db = SessionLocal()
try: try:
@ -136,11 +150,11 @@ def scrape_kn_catalog_objects(
snapshot_date_val: date = rows[0]["snapshot_date"] snapshot_date_val: date = rows[0]["snapshot_date"]
logger.info( logger.info(
"scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%d force=%s", "scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%s force=%s",
region_code, region_code,
snapshot_date_val, snapshot_date_val,
len(obj_ids), len(obj_ids),
limit, "ALL" if limit is None else limit,
force, force,
) )

View file

@ -0,0 +1,321 @@
"""Spike — contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.
Forgejo issue #299 (Phase 1 exploration SPIKE). **This is a throwaway probe, NOT
production wiring**: it answers one question is Potrace contour tracing good
enough to turn floor-plan rasters into compact, readable SVG for the planning UI
catalog and PDF reports? No ML, no room semantics, no DOM.RF coupling.
WHAT IT MEASURES
----------------
For each input raster the pipeline runs:
1. load (Pillow) grayscale (``L``)
2. binarise: pixels below ``--threshold`` (0-255) become ink (black), the rest
become paper (white). Optional ``--invert`` for light-on-dark plans.
3. write a 1-bit PBM bitmap (Potrace's native input) to the output dir
4. ``potrace -s`` SVG (centerline-free, filled-contour tracing)
5. record size metrics: raster bytes, svg bytes, ratio (raster / svg)
It also (optionally, ``--render-back``) rasterises the produced SVG back to PNG
via ``rsvg-convert`` so a human can eyeball SVG-vs-original side by side and
judge whether wall/room contours survived the round trip.
WHY POTRACE IS SOURCE-AGNOSTIC
------------------------------
Potrace traces the boundary between ink and paper regions of a bitmap. It does
not care where the raster came from a Wikimedia line-drawing plan, a scanned
blueprint, or a DOM.RF UI tile all reduce to "dark strokes on a light field"
after thresholding. So the Phase 1 question ("are the contours traceable and how
much do they compress?") is validly answered on any representative floor-plan
rasters; see the spike doc for the explicit DOM.RF caveat.
USAGE
-----
# vectorise every raster in a folder, collect a metrics table
uv run python backend/scripts/spike_plan_vectorize.py \
--in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out
# also render the SVGs back to PNG for visual QA
uv run python backend/scripts/spike_plan_vectorize.py \
--in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out --render-back
Requires the ``potrace`` binary on PATH (``brew install potrace``) and, for
``--render-back``, ``rsvg-convert`` (``brew install librsvg``). Pillow ships with
the backend deps.
"""
from __future__ import annotations
import argparse
import logging
import shutil
import statistics
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from PIL import Image
logger = logging.getLogger("spike_plan_vectorize")
_RASTER_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"}
_POTRACE_TIMEOUT_S = 120
_RSVG_TIMEOUT_S = 120
@dataclass(frozen=True)
class VectorizeResult:
"""One raster's trip through the pipeline."""
name: str
raster_bytes: int
svg_bytes: int
svg_path: Path
rendered_path: Path | None
@property
def ratio(self) -> float:
"""Compression ratio raster / svg (>1 means SVG is smaller)."""
return self.raster_bytes / self.svg_bytes if self.svg_bytes else 0.0
def metrics_line(self) -> str:
return (
f"{self.name:<44} "
f"raster={self.raster_bytes:>9}B "
f"svg={self.svg_bytes:>8}B "
f"ratio={self.ratio:>6.2f}x"
)
def _require_binary(name: str) -> str:
"""Resolve an external binary on PATH or exit with a clear message."""
path = shutil.which(name)
if path is None:
logger.error("required binary %r not found on PATH", name)
raise SystemExit(f"{name!r} not found — install it (e.g. `brew install {name}`) and retry")
return path
def raster_to_pbm(src: Path, pbm_path: Path, *, threshold: int, invert: bool) -> None:
"""Load a raster, grayscale + threshold it, and write a 1-bit PBM bitmap.
Potrace consumes 1-bit bitmaps (PBM/PGM/BMP). We binarise with a fixed
threshold so the spike's behaviour is deterministic and explainable — black
(ink) is what Potrace traces, white is background. ``invert`` flips the test
for light-stroke-on-dark plans.
"""
with Image.open(src) as im:
gray = im.convert("L")
# point(): pixel < threshold → ink (0), else paper (255). Pillow's "1" mode
# then packs to a true 1-bit bitmap that Potrace reads natively.
cutoff = threshold
if invert:
bitmap = gray.point(lambda px: 0 if px >= cutoff else 255).convert("1")
else:
bitmap = gray.point(lambda px: 0 if px < cutoff else 255).convert("1")
bitmap.save(pbm_path)
def pbm_to_svg(potrace_bin: str, pbm_path: Path, svg_path: Path) -> None:
"""Trace a PBM bitmap to SVG with ``potrace -s`` (SVG backend)."""
try:
subprocess.run(
[potrace_bin, "-s", "-o", str(svg_path), str(pbm_path)],
check=True,
capture_output=True,
timeout=_POTRACE_TIMEOUT_S,
)
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.decode("utf-8", "replace").strip()
logger.error("potrace failed for %s: %s", pbm_path.name, stderr)
raise
def svg_to_png(rsvg_bin: str, svg_path: Path, png_path: Path, *, width: int) -> None:
"""Render an SVG back to PNG via ``rsvg-convert`` for visual QA.
We composite onto an explicit white background. Potrace's SVG is black
filled paths on a *transparent* canvas; without ``--background-color=white``
rsvg renders the black fill onto transparency and a flattening viewer shows
a solid black tile. White matches the real catalog/PDF use case anyway.
"""
try:
subprocess.run(
[
rsvg_bin,
"-w",
str(width),
"--background-color=white",
"-o",
str(png_path),
str(svg_path),
],
check=True,
capture_output=True,
timeout=_RSVG_TIMEOUT_S,
)
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.decode("utf-8", "replace").strip()
logger.error("rsvg-convert failed for %s: %s", svg_path.name, stderr)
raise
def vectorize_one(
src: Path,
out_dir: Path,
*,
potrace_bin: str,
rsvg_bin: str | None,
threshold: int,
invert: bool,
render_width: int,
) -> VectorizeResult:
"""Run the full PNG/JPG → PBM → SVG (→ PNG) pipeline for a single raster."""
stem = src.stem
pbm_path = out_dir / f"{stem}.pbm"
svg_path = out_dir / f"{stem}.svg"
raster_to_pbm(src, pbm_path, threshold=threshold, invert=invert)
pbm_to_svg(potrace_bin, pbm_path, svg_path)
rendered_path: Path | None = None
if rsvg_bin is not None:
rendered_path = out_dir / f"{stem}.rendered.png"
svg_to_png(rsvg_bin, svg_path, rendered_path, width=render_width)
return VectorizeResult(
name=src.name,
raster_bytes=src.stat().st_size,
svg_bytes=svg_path.stat().st_size,
svg_path=svg_path,
rendered_path=rendered_path,
)
def discover_rasters(in_dir: Path) -> list[Path]:
"""Return sorted raster files in ``in_dir`` (non-recursive)."""
return sorted(
p for p in in_dir.iterdir() if p.is_file() and p.suffix.lower() in _RASTER_SUFFIXES
)
def summarise(results: list[VectorizeResult]) -> None:
"""Print a per-file metrics table plus min/median/max compression."""
if not results:
logger.warning("no results to summarise")
return
print("\n=== per-raster metrics ===")
for r in results:
print(r.metrics_line())
ratios = sorted(r.ratio for r in results)
print("\n=== compression summary ===")
print(f"samples : {len(ratios)}")
print(f"min ratio : {min(ratios):.2f}x")
print(f"median ratio : {statistics.median(ratios):.2f}x")
print(f"max ratio : {max(ratios):.2f}x")
total_raster = sum(r.raster_bytes for r in results)
total_svg = sum(r.svg_bytes for r in results)
agg_ratio = total_raster / total_svg if total_svg else float("inf")
print(
f"aggregate : {total_raster}B raster -> {total_svg}B svg " f"({agg_ratio:.2f}x overall)"
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Spike: contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.",
)
parser.add_argument(
"--in-dir",
type=Path,
required=True,
help="folder containing input rasters (PNG/JPG/...)",
)
parser.add_argument(
"--out-dir",
type=Path,
required=True,
help="folder for output .pbm/.svg (and .rendered.png with --render-back)",
)
parser.add_argument(
"--threshold",
type=int,
default=128,
help="grayscale ink/paper cutoff 0-255 (default 128)",
)
parser.add_argument(
"--invert",
action="store_true",
help="treat light strokes on a dark field as ink",
)
parser.add_argument(
"--render-back",
action="store_true",
help="rasterise each SVG back to PNG via rsvg-convert for visual QA",
)
parser.add_argument(
"--render-width",
type=int,
default=900,
help="width in px for --render-back output (default 900)",
)
return parser
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
args = build_parser().parse_args(argv)
in_dir: Path = args.in_dir
out_dir: Path = args.out_dir
if not in_dir.is_dir():
logger.error("input dir does not exist: %s", in_dir)
return 2
if not 0 <= args.threshold <= 255:
logger.error("--threshold must be 0-255, got %d", args.threshold)
return 2
out_dir.mkdir(parents=True, exist_ok=True)
potrace_bin = _require_binary("potrace")
rsvg_bin = _require_binary("rsvg-convert") if args.render_back else None
rasters = discover_rasters(in_dir)
if not rasters:
logger.error("no raster files found in %s", in_dir)
return 1
logger.info("found %d raster(s) in %s", len(rasters), in_dir)
results: list[VectorizeResult] = []
failed = 0
for src in rasters:
try:
results.append(
vectorize_one(
src,
out_dir,
potrace_bin=potrace_bin,
rsvg_bin=rsvg_bin,
threshold=args.threshold,
invert=args.invert,
render_width=args.render_width,
)
)
logger.info("vectorized %s", src.name)
except Exception:
# Log + continue: one bad raster must not abort the batch, but we
# never swallow silently — the traceback is recorded and the file
# is counted as a failure in the final tally.
failed += 1
logger.exception("failed to vectorize %s", src.name)
summarise(results)
if failed:
logger.warning("%d/%d raster(s) failed", failed, len(rasters))
return 0 if results else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -23,6 +23,7 @@ from app.services.scrapers.rosstat_emiss import (
OKATO_SVERDLOVSK_OBLAST, OKATO_SVERDLOVSK_OBLAST,
EmissIndicatorSpec, EmissIndicatorSpec,
RegionTarget, RegionTarget,
_emiss_period_granularity,
_emiss_period_to_month, _emiss_period_to_month,
parse_emiss_sdmx, parse_emiss_sdmx,
) )
@ -59,17 +60,78 @@ def test_period_year_and_unknown() -> None:
assert _emiss_period_to_month("") is None assert _emiss_period_to_month("") is None
def test_period_granularity() -> None:
"""_emiss_period_granularity корректно классифицирует все под-периоды (#1606)."""
assert _emiss_period_granularity("год") == "year"
assert _emiss_period_granularity("значение показателя за год") == "year"
assert _emiss_period_granularity("за год") == "year"
assert _emiss_period_granularity("I квартал") == "quarter"
assert _emiss_period_granularity("IV квартал") == "quarter"
assert _emiss_period_granularity("январь") == "month"
assert _emiss_period_granularity("декабрь") == "month"
assert _emiss_period_granularity("3 неделя") == "unknown"
assert _emiss_period_granularity("") == "unknown"
def test_yearly_and_q1_both_survive_dedup() -> None:
"""Годовой ряд ('год') и Q1 ('I квартал') за один год — оба остаются (#1606).
До фикса: оба давали obs_date=YYYY-01-01, ключ (region, obs_date) был один
второй по XML-порядку перезаписывал первого. После фикса: ключ трёхкомпонентный
(region, obs_date, granularity) 'year' и 'quarter' разные слоты.
"""
xml = (
'<GenericData xmlns="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message"'
' xmlns:generic="http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic">'
"<DataSet>"
# годовой ряд за 2023 — значение 48000
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="год"/>'
"</generic:Attributes>"
"<generic:Obs><generic:Time>2023</generic:Time>"
'<generic:ObsValue value="48000"/></generic:Obs></generic:Series>'
# Q1 того же 2023 — значение 42000; obs_date тоже 2023-01-01
"<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>"
"<generic:Obs><generic:Time>2023</generic:Time>"
'<generic:ObsValue value="42000"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>"
)
rows = parse_emiss_sdmx(xml, INCOME_PER_CAPITA_SPEC)
# Оба наблюдения должны присутствовать — разные гранулярности
assert len(rows) == 2, f"ожидались 2 строки, получили {len(rows)}: {rows}"
values = {r.value for r in rows}
assert Decimal("48000") in values, "годовое наблюдение потеряно"
assert Decimal("42000") in values, "Q1-наблюдение потеряно"
# obs_date у обоих одинаковый (это нормально — коллизия теперь на стороне DB-upsert)
assert all(r.obs_date == date(2023, 1, 1) for r in rows)
# period_type: годовой → 'year', Q1 → 'quarter' (часть PK в macro_indicator, migration 163)
period_types = {r.period_type for r in rows}
assert period_types == {
"year",
"quarter",
}, f"ожидались period_type year+quarter, получили {period_types}"
# ── real-fixture extraction (income id=57039) ───────────────────────────────────── # ── real-fixture extraction (income id=57039) ─────────────────────────────────────
def test_income_extracts_sverdlovsk_only() -> None: def test_income_extracts_sverdlovsk_only() -> None:
"""Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет.""" """Из реальной SDMX-выгрузки извлекается ТОЛЬКО Свердл (ОКАТО 65) — РФ/ЦФО/Адыгея нет.
Все строки квартальные period_type='quarter' (часть PK macro_indicator, migration 163).
"""
rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC) rows = parse_emiss_sdmx(_load("emiss_income_57039.xml"), INCOME_PER_CAPITA_SPEC)
assert rows, "ожидались строки по Свердл" assert rows, "ожидались строки по Свердл"
assert {r.region for r in rows} == {"sverdl"} assert {r.region for r in rows} == {"sverdl"}
assert all(r.indicator_type == "income_per_capita" for r in rows) assert all(r.indicator_type == "income_per_capita" for r in rows)
assert all(r.unit == "руб" for r in rows) assert all(r.unit == "руб" for r in rows)
assert all(r.frequency == "quarterly" for r in rows) assert all(r.frequency == "quarterly" for r in rows)
assert all(
r.period_type == "quarter" for r in rows
), "квартальные строки должны иметь period_type='quarter'"
def test_income_concrete_values_and_dates() -> None: def test_income_concrete_values_and_dates() -> None:
@ -154,13 +216,13 @@ def test_unknown_period_series_skipped() -> None:
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>' '<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="3 неделя"/>' '<generic:Attributes><generic:Value concept="PERIOD" value="3 неделя"/>'
"</generic:Attributes>" "</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>' "<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="999"/></generic:Obs></generic:Series>' '<generic:ObsValue value="999"/></generic:Obs></generic:Series>'
"<generic:Series><generic:SeriesKey>" "<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>' '<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>' '<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>" "</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>' "<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="50000"/></generic:Obs></generic:Series>' '<generic:ObsValue value="50000"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>" "</DataSet></GenericData>"
) )
@ -179,7 +241,7 @@ def test_comma_decimal_value_parsed() -> None:
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>' '<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="январь"/>' '<generic:Attributes><generic:Value concept="PERIOD" value="январь"/>'
"</generic:Attributes>" "</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>' "<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="113,2"/></generic:Obs></generic:Series></DataSet>' '<generic:ObsValue value="113,2"/></generic:Obs></generic:Series></DataSet>'
"</GenericData>" "</GenericData>"
) )
@ -199,13 +261,13 @@ def test_dedup_last_wins() -> None:
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>' '<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>' '<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>" "</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>' "<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="100"/></generic:Obs></generic:Series>' '<generic:ObsValue value="100"/></generic:Obs></generic:Series>'
"<generic:Series><generic:SeriesKey>" "<generic:Series><generic:SeriesKey>"
'<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>' '<generic:Value concept="s_OKATO" value="65000000000"/></generic:SeriesKey>'
'<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>' '<generic:Attributes><generic:Value concept="PERIOD" value="I квартал"/>'
"</generic:Attributes>" "</generic:Attributes>"
'<generic:Obs><generic:Time>2024</generic:Time>' "<generic:Obs><generic:Time>2024</generic:Time>"
'<generic:ObsValue value="200"/></generic:Obs></generic:Series>' '<generic:ObsValue value="200"/></generic:Obs></generic:Series>'
"</DataSet></GenericData>" "</DataSet></GenericData>"
) )

View file

@ -32,6 +32,7 @@ from app.services.exporters.excel import (
_SHEET_SUMMARY, _SHEET_SUMMARY,
_fmt, _fmt,
_sanitize_formula, _sanitize_formula,
_scenario_deficit_cell,
_scenario_deficit_index, _scenario_deficit_index,
export_report_xlsx, export_report_xlsx,
) )
@ -313,6 +314,53 @@ class TestContractKeysWritten:
assert di is not None assert di is not None
assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках" assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках"
def test_scenario_deficit_cell_annotates_fallback_horizon(self) -> None:
"""#1590: при fallback на горизонт != 12 ячейка несёт «(гор. N мес)»."""
# Сценарий без 12-мес прогноза — только 6-мес.
payload_6mo = ScenarioForecast(
scenario="base", # type: ignore[arg-type]
rate_path={6: 18.0},
forecasts=[
DemandSupplyForecast(
segment={"obj_class": "комфорт"},
horizon_months=6,
base_pace_units_per_mo=8.0,
demand_norm_coefficient=1.0,
macro_coefficient=1.0,
projected_demand_units=410.0,
open_units=300,
hidden_release_units=80.0,
future_online_units=20.0,
projected_supply_units=380.0,
balance_units=30.0,
balance_ratio=1.08,
deficit_index=0.22,
months_of_inventory=5.5,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],
advisory=True,
confidence="low",
)
],
advisory=True,
).as_dict()
cell_value = _scenario_deficit_cell(payload_6mo)
# Должна быть строка с аннотацией горизонта, не голое число.
assert isinstance(cell_value, str), "ожидается строка с аннотацией горизонта"
assert "гор. 6 мес" in cell_value, f"ожидалось «гор. 6 мес» в '{cell_value}'"
assert "0.22" in cell_value or "0.22" in str(cell_value)
def test_scenario_deficit_cell_no_annotation_for_primary_horizon(self) -> None:
"""#1590: при 12-мес горизонте ячейка возвращает скаляр без аннотации."""
payload_12mo = _scenario("base", deficit_12mo=0.34)
cell_value = _scenario_deficit_cell(payload_12mo)
# Для основного горизонта — голое число, не строка с «(гор. N мес)».
assert not isinstance(
cell_value, str
), f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'"
assert cell_value == 0.34
def test_overall_score_in_cells(self) -> None: def test_overall_score_in_cells(self) -> None:
data = _full_report().as_dict() data = _full_report().as_dict()
overall = data["scoring"]["overall"] overall = data["scoring"]["overall"]
@ -466,10 +514,6 @@ class TestFormulaInjection:
payload = export_report_xlsx(report) payload = export_report_xlsx(report)
wb = _reload(payload) wb = _reload(payload)
formula_cells = [ formula_cells = [
c c for ws in wb.worksheets for col in ws.iter_cols() for c in col if c.data_type == "f"
for ws in wb.worksheets
for col in ws.iter_cols()
for c in col
if c.data_type == "f"
] ]
assert not formula_cells, f"ни одна ячейка не должна быть формулой: {formula_cells}" assert not formula_cells, f"ни одна ячейка не должна быть формулой: {formula_cells}"

View file

@ -269,11 +269,14 @@ class TestPaymentAtScenario:
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0}) res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
assert res.payment_at_scenario is not None assert res.payment_at_scenario is not None
principal = 120_000.0 * _REF_AREA_M2 principal = 120_000.0 * _REF_AREA_M2
# rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной
# базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания
# выражаем символически — тест переживёт перекалибровку спреда.
assert res.payment_at_scenario[6] == pytest.approx( assert res.payment_at_scenario[6] == pytest.approx(
_annuity(principal, 8.0, _ANNUITY_TERM_MONTHS) _annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
) )
assert res.payment_at_scenario[12] == pytest.approx( assert res.payment_at_scenario[12] == pytest.approx(
_annuity(principal, 20.0, _ANNUITY_TERM_MONTHS) _annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
) )
# Выше ставка → выше платёж на этом горизонте. # Выше ставка → выше платёж на этом горизонте.
assert res.payment_at_scenario[12] > res.payment_at_scenario[6] assert res.payment_at_scenario[12] > res.payment_at_scenario[6]

View file

@ -342,6 +342,26 @@ class TestComputeReportConfidence:
# Полностью JSON-сериализуем (контракт для экспортёров/чата). # Полностью JSON-сериализуем (контракт для экспортёров/чата).
assert json.loads(json.dumps(d, ensure_ascii=False)) == d assert json.loads(json.dumps(d, ensure_ascii=False)) == d
def test_deal_count_note_carries_window_months(self) -> None:
# #1637: deal_count_months → нота «за N мес» в факторе (и в rationale).
res = compute_report_confidence(
deal_count=7,
deal_count_months=6,
advisory=False,
)
dc_factor = next(f for f in res.factors if f.name == "deal_count")
assert "за 6 мес" in dc_factor.note
assert "7 сделок" in dc_factor.note
# Структурная причина тоже содержит период (через ноту фактора-виновника).
assert "за 6 мес" in res.rationale
def test_deal_count_note_without_window_has_no_period(self) -> None:
# deal_count_months=None (по умолчанию) → нота без «за N мес» (backward compat).
res = compute_report_confidence(deal_count=7, advisory=False)
dc_factor = next(f for f in res.factors if f.name == "deal_count")
assert "7 сделок" in dc_factor.note
assert "за" not in dc_factor.note
def test_ignores_garbage_component_confidence(self) -> None: def test_ignores_garbage_component_confidence(self) -> None:
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно. # Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
res = compute_report_confidence( res = compute_report_confidence(

View file

@ -138,6 +138,50 @@ class TestSeasonalFactors:
assert adj.applied is False assert adj.applied is False
assert all(f == 1.0 for f in adj.factors.values()) assert all(f == 1.0 for f in adj.factors.values())
# ── fix #1638: zero-filled series must fail the min-years guard ───────────
def test_zero_filled_36m_fails_min_years_guard(self) -> None:
"""Ряд 36 мес, все units=0 (zero-fill от fill_month_grid) → applied=False.
До fix #1638 _count_full_years считал нулевые значения за наблюдения
и возвращал n_full_years=3 guard пропускал overall_mean==0 спасал
от деления, но прохождение guard'а было семантически неверным.
После fix: 0 не считается наблюдением n_full_years=0 applied=False.
"""
months = _months(36)
adj = seasonal_factors(months, [0] * 36)
assert (
adj.n_full_years == 0
), f"expected 0 full years on all-zero series, got {adj.n_full_years}"
assert adj.applied is False
assert all(f == 1.0 for f in adj.factors.values())
def test_mixed_zero_and_real_partial_years_fails_guard(self) -> None:
"""2 года месяцев, но только часть месяцев ненулевые — не 2 полных года.
Сценарий: новый ЖК, продажи только в нескольких месяцах каждого года
(остальные zero-fill). _count_full_years должен не считать это «полным годом».
"""
months = _months(24) # 2 календарных года
# Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес.
units = [10 if d.month <= 6 else 0 for d in months]
adj = seasonal_factors(months, units)
assert (
adj.n_full_years == 0
), f"partial-coverage years should not count as full, got {adj.n_full_years}"
assert adj.applied is False
def test_real_nonzero_series_passes_guard_and_applies(self) -> None:
"""Ряд с реальными ненулевыми данными за 2+ лет проходит guard и применяется.
Страховка: fix #1638 не должен ломать штатный путь с настоящими данными.
"""
months = _months(36)
units = _seasonal_units(months) # baseline×паттерн, все > 0
adj = seasonal_factors(months, units)
assert adj.n_full_years == 3
assert adj.applied is True
def test_month_with_no_observations_factor_one(self) -> None: def test_month_with_no_observations_factor_one(self) -> None:
# Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст # Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст
# (None). Чтобы пройти year-guard и реально дойти до per-month ветки, # (None). Чтобы пройти year-guard и реально дойти до per-month ветки,
@ -239,3 +283,17 @@ class TestNormalizeDemand:
units = _seasonal_units(months) units = _seasonal_units(months)
result = normalize_demand(_make_series(months, units)) result = normalize_demand(_make_series(months, units))
assert all(isinstance(u, int) and u >= 0 for u in result.units) assert all(isinstance(u, int) and u >= 0 for u in result.units)
def test_zero_filled_series_returned_unchanged(self) -> None:
"""fix #1638: zero-filled SalesSeries (все units=0) возвращается без изменений.
До fix: guard считал 3 «полных года» на нулях adjustment.applied=True
(пройдя через overall_mean==0 check) или мог пройти guard и вернуть
идентичный ряд через нейтральные факторы. После fix: n_full_years=0
applied=False функция возвращает тот же объект (short-circuit).
"""
months = _months(36)
series = _make_series(months, [0] * 36)
result = normalize_demand(series)
assert result is series, "zero-filled series must be returned as-is (no adjustment)"
assert result.units == [0] * 36

View file

@ -409,7 +409,7 @@ class TestScoreDifferentiation:
class TestScoreCommercial: class TestScoreCommercial:
def test_available_share_scaled(self) -> None: def test_available_share_scaled(self) -> None:
signal = {"available": True, "commercial_share_pct": 40.0, "confidence": "medium"} signal = {"available": True, "commercial_sell_through_pct": 40.0, "confidence": "medium"}
value, conf, _r = _score_commercial(signal) value, conf, _r = _score_commercial(signal)
assert value == pytest.approx(0.4) assert value == pytest.approx(0.4)
assert conf == "medium" assert conf == "medium"
@ -417,7 +417,7 @@ class TestScoreCommercial:
def test_in_range(self) -> None: def test_in_range(self) -> None:
for share in (0.0, 25.0, 100.0): for share in (0.0, 25.0, 100.0):
value, _c, _r = _score_commercial( value, _c, _r = _score_commercial(
{"available": True, "commercial_share_pct": share, "confidence": "low"} {"available": True, "commercial_sell_through_pct": share, "confidence": "low"}
) )
assert value is not None assert value is not None
assert 0.0 <= value <= 1.0 assert 0.0 <= value <= 1.0
@ -439,7 +439,7 @@ class TestScoreCommercial:
def test_bad_confidence_falls_back_low(self) -> None: def test_bad_confidence_falls_back_low(self) -> None:
value, conf, _r = _score_commercial( value, conf, _r = _score_commercial(
{"available": True, "commercial_share_pct": 10.0, "confidence": "garbage"} {"available": True, "commercial_sell_through_pct": 10.0, "confidence": "garbage"}
) )
assert value is not None assert value is not None
assert conf == "low" assert conf == "low"
@ -728,7 +728,7 @@ def _patch_all(
overlay: Any = None, overlay: Any = None,
) -> Any: ) -> Any:
"""Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом).""" """Контекст-менеджер: патчит ВСЕ 8 backing-сервисов их return_value (или дефолтом)."""
commercial = {"available": True, "commercial_share_pct": 30.0, "confidence": "medium"} commercial = {"available": True, "commercial_sell_through_pct": 30.0, "confidence": "medium"}
forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)] forecast_rv = [forecast if forecast is not None else _forecast_stub(0.5)]
competitors_rv = ( competitors_rv = (
competitors if competitors is not None else _competitors_response_stub([0.4, 0.2]) competitors if competitors is not None else _competitors_response_stub([0.4, 0.2])

View file

@ -329,9 +329,13 @@ class TestDemandSupplyGraceful:
# ── build_forecast_overlay: demand_only (cad_num=None) ───────────────────────── # ── build_forecast_overlay: demand_only (cad_num=None) ─────────────────────────
def _mk_metrics(unit_velocity: float | None) -> MagicMock: def _mk_metrics(
unit_velocity: float | None,
velocity_by_room: dict[str, float] | None = None,
) -> MagicMock:
m = MagicMock() m = MagicMock()
m.unit_velocity = unit_velocity m.unit_velocity = unit_velocity
m.velocity_by_room = velocity_by_room
return m return m
@ -453,6 +457,71 @@ class TestDemandOnlyOverlay:
assert out["ranked_segments"] == [] assert out["ranked_segments"] == []
assert out["warnings"] assert out["warnings"]
def test_per_bucket_velocity_drives_ranking(self) -> None:
"""#1593: velocity_by_room даёт реальные per-bucket §9.2-темпы.
При одинаковом §9.5 macro_coef=1.0 ранкинг должен определяться
исключительно per-bucket velocity_by_room, а не aggregate base_pace.
Студии ("студия") самый высокий темп первые в ранкинге.
"""
# velocity_by_room: студии продаются в 5× быстрее чем 1-к, остальные ≈ 0.
vel_by_room = {
"студия": 10.0, # "Студии 15-30" → "студия"
"1": 2.0, # "1-к 30-45" → "1"
"2": 1.0, # "2-к 45-60" → "2"
"3": 0.5, # "3-к 60-80" → "3"
"4": 0.1, # "80+ м²" → "4" + "5+"
"5+": 0.1,
}
with (
patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=vel_by_room)),
patch(_GET_MACRO, return_value=[]),
patch(_HOLD, return_value={12: 18.0}),
patch(_NORM, return_value=_mk_coef(1.0)),
patch(_MACRO, return_value=_mk_coef(1.0)), # нейтральный §9.5 (изолируем §9.2)
):
out = build_forecast_overlay(
MagicMock(),
district="Ленинский",
cad_num=None,
horizon_months=12,
target_class=None,
)
buckets = [s["bucket"] for s in out["ranked_segments"]]
# При одинаковом macro_coef=1.0 ранкинг = velocity_by_room:
# студии (10.0) > 1-к (2.0) > 2-к (1.0) > 3-к (0.5) > 80+ (0.1+0.1=0.2)
assert buckets[0] == "1-Студия", f"ожидали студии первыми, получили {buckets}"
assert buckets[1] == "2-1-к", f"ожидали 1-к вторыми, получили {buckets}"
assert buckets[-1] == "5-80+ м²", f"ожидали 80+ м² последними, получили {buckets}"
# deficit_index нормирован к 1.0 для топа
assert out["ranked_segments"][0]["deficit_index"] == pytest.approx(1.0)
def test_fallback_to_aggregate_when_no_velocity_by_room(self) -> None:
"""#1593: при velocity_by_room=None возвращаемся к aggregate base_pace.
Ranking в этом случае определяется §9.5 macro_coef (старое поведение),
но не крашит и не фабрикует данные.
"""
macro_coeffs = iter([_mk_coef(c) for c in (0.5, 1.0, 1.5, 1.2, 0.8)])
with (
patch(_METRICS, return_value=_mk_metrics(4.0, velocity_by_room=None)),
patch(_GET_MACRO, return_value=[]),
patch(_HOLD, return_value={12: 18.0}),
patch(_NORM, return_value=_mk_coef(1.0)),
patch(_MACRO, side_effect=lambda *a, **k: next(macro_coeffs)),
):
out = build_forecast_overlay(
MagicMock(),
district="Ленинский",
cad_num=None,
horizon_months=12,
target_class=None,
)
# Graceful: 5 сегментов, deficit_index ∈ (0, 1].
assert len(out["ranked_segments"]) == 5
assert max(s["deficit_index"] for s in out["ranked_segments"]) == pytest.approx(1.0)
def test_five_default_room_buckets_ranked(self) -> None: def test_five_default_room_buckets_ranked(self) -> None:
with ( with (
patch(_METRICS, return_value=_mk_metrics(4.0)), patch(_METRICS, return_value=_mk_metrics(4.0)),
@ -744,7 +813,7 @@ class TestCommercialSignal:
out = _commercial_signal(MagicMock(), "Ленинский", 12) out = _commercial_signal(MagicMock(), "Ленинский", 12)
assert out is not None assert out is not None
assert out["available"] is True assert out["available"] is True
assert out["commercial_share_pct"] == 42.5 assert out["commercial_sell_through_pct"] == 42.5
assert out["n_lots"] == 120 assert out["n_lots"] == 120
assert out["confidence"] == "medium" assert out["confidence"] == "medium"
assert out["reason"]["advisory"] is True assert out["reason"]["advisory"] is True

View file

@ -625,14 +625,22 @@ def _launch_window_index(horizon: int | None) -> SpecialIndex:
"""Готовый Launch Window SpecialIndex с заданным best_horizon_months (None → unavail).""" """Готовый Launch Window SpecialIndex с заданным best_horizon_months (None → unavail)."""
if horizon is None: if horizon is None:
return SpecialIndex( return SpecialIndex(
key=KEY_LAUNCH_WINDOW, value=None, label=None, confidence="low", key=KEY_LAUNCH_WINDOW,
value=None,
label=None,
confidence="low",
detail={"reason": "deficit None на всех горизонтах"}, detail={"reason": "deficit None на всех горизонтах"},
method=_METHOD_UNAVAILABLE, advisory=True, method=_METHOD_UNAVAILABLE,
advisory=True,
) )
return SpecialIndex( return SpecialIndex(
key=KEY_LAUNCH_WINDOW, value=0.8, label=f"{horizon} мес", confidence="medium", key=KEY_LAUNCH_WINDOW,
value=0.8,
label=f"{horizon} мес",
confidence="medium",
detail={"best_horizon_months": horizon, "deficit_by_horizon": {}}, detail={"best_horizon_months": horizon, "deficit_by_horizon": {}},
method="deficit_peak_scan", advisory=True, method="deficit_peak_scan",
advisory=True,
) )
@ -679,11 +687,39 @@ class TestGeoWeight:
def test_unknown_distance_floor_weight(self) -> None: def test_unknown_distance_floor_weight(self) -> None:
# нет координат → низкий floor (НЕ 0, НЕ 1): проект сигналит, но не доминирует. # нет координат → низкий floor (НЕ 0, НЕ 1): проект сигналит, но не доминирует.
assert _geo_weight(None) == pytest.approx(0.1) # #1633: понижено с 0.1 до 0.05 — старое значение 0.1 совпадало с весом
# подтверждённо дальнего проекта (~6.9 км), создавая инверсию.
assert _geo_weight(None) == pytest.approx(0.05)
def test_negative_distance_clamped(self) -> None: def test_negative_distance_clamped(self) -> None:
assert _geo_weight(-5.0) == pytest.approx(1.0) assert _geo_weight(-5.0) == pytest.approx(1.0)
def test_near_project_weight_exceeds_far_project_weight(self) -> None:
# #1633: ближний проект ВСЕГДА перебивает дальний (монотонность).
near = _geo_weight(1.0)
far = _geo_weight(10.0)
assert near > far
def test_confirmed_far_project_weight_below_unknown(self) -> None:
# #1633: подтверждённо дальний (>6.9 км) должен иметь вес НИЖЕ неизвестного.
# До фикса: exp(-6.9/3)≈0.10 == _GEO_WEIGHT_UNKNOWN (0.10) — инверсия/паритет.
# После фикса: _GEO_WEIGHT_UNKNOWN=0.05, exp(-6.9/3)≈0.10 > 0.05 (правильно).
# А при ещё бо́льшем расстоянии (10 км, ≈0.036) — тем более выше 0.05.
# Здесь проверяем что неизвестный вес НИЖЕ вполне подтверждённо БЛИЗКОГО (1 км),
# и что подтверждённо ДАЛЬНИЙ (10 км) не превышает неизвестный (чтобы сигналил,
# но скромно — оба низкие; инверсия устранена).
import math
unknown = _geo_weight(None)
confirmed_near = _geo_weight(1.0) # ≈ 0.72
confirmed_far = _geo_weight(10.0) # ≈ 0.036
# confirmed_near >> unknown >> confirmed_far (правильная иерархия).
assert confirmed_near > unknown
assert unknown > confirmed_far
# Числовая граница: exp(-6.9/3) ≈ 0.10 — вес "just-confirmed-far";
# unknown (0.05) строго ниже этого порога.
assert unknown < math.exp(-6.9 / 3.0)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# §25.3 own-portfolio overlap — пара (среднее доступных осей) + агрегация (soft-max) # §25.3 own-portfolio overlap — пара (среднее доступных осей) + агрегация (soft-max)
@ -745,16 +781,25 @@ class TestAggregateOverlap:
def test_takes_max_signal_not_mean(self) -> None: def test_takes_max_signal_not_mean(self) -> None:
# один сильный (0.9) + два слабых (0.1) → агрегат = 0.9 (soft-max), не среднее. # один сильный (0.9) + два слабых (0.1) → агрегат = 0.9 (soft-max), не среднее.
strong = _own_portfolio_overlap( strong = _own_portfolio_overlap(
class_overlap=0.9, price_overlap=0.9, class_overlap=0.9,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0, price_overlap=0.9,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
) )
weak1 = _own_portfolio_overlap( weak1 = _own_portfolio_overlap(
class_overlap=0.1, price_overlap=0.1, class_overlap=0.1,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0, price_overlap=0.1,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
) )
weak2 = _own_portfolio_overlap( weak2 = _own_portfolio_overlap(
class_overlap=0.1, price_overlap=0.1, class_overlap=0.1,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0, price_overlap=0.1,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
) )
assert _aggregate_overlap([strong, weak1, weak2]) == pytest.approx(0.9) assert _aggregate_overlap([strong, weak1, weak2]) == pytest.approx(0.9)
@ -762,19 +807,28 @@ class TestAggregateOverlap:
# сильное пересечение, но далеко (geo 0.1) → signal 0.05; близкое слабое # сильное пересечение, но далеко (geo 0.1) → signal 0.05; близкое слабое
# (overlap 0.3, geo 1.0 → 0.3) перебивает. Агрегат = 0.3. # (overlap 0.3, geo 1.0 → 0.3) перебивает. Агрегат = 0.3.
distant_strong = _own_portfolio_overlap( distant_strong = _own_portfolio_overlap(
class_overlap=1.0, price_overlap=1.0, class_overlap=1.0,
unit_mix_overlap=None, timing_overlap=None, geo_weight=0.1, price_overlap=1.0,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=0.1,
) )
near_weak = _own_portfolio_overlap( near_weak = _own_portfolio_overlap(
class_overlap=0.3, price_overlap=0.3, class_overlap=0.3,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0, price_overlap=0.3,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
) )
assert _aggregate_overlap([distant_strong, near_weak]) == pytest.approx(0.3) assert _aggregate_overlap([distant_strong, near_weak]) == pytest.approx(0.3)
def test_all_none_signals_is_none(self) -> None: def test_all_none_signals_is_none(self) -> None:
empty = _own_portfolio_overlap( empty = _own_portfolio_overlap(
class_overlap=None, price_overlap=None, class_overlap=None,
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0, price_overlap=None,
unit_mix_overlap=None,
timing_overlap=None,
geo_weight=1.0,
) )
assert _aggregate_overlap([empty, empty]) is None assert _aggregate_overlap([empty, empty]) is None
@ -955,9 +1009,7 @@ class TestArtificialDemandSQL:
def test_sql_reads_mortgage_signal_columns(self, _identity_resolver: Any) -> None: def test_sql_reads_mortgage_signal_columns(self, _identity_resolver: Any) -> None:
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4}) db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
with _identity_resolver: with _identity_resolver:
_query_artificial_demand( _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
db, district=None, obj_class=None, premise_kind="квартира"
)
sql = _executed_sql(db) sql = _executed_sql(db)
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots). # сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
assert "encumbrance_type" in sql assert "encumbrance_type" in sql
@ -1007,9 +1059,7 @@ class TestArtificialDemandSQL:
# #1205: district=None → has_district=False, districts=[]. EKB-wide. # #1205: district=None → has_district=False, districts=[]. EKB-wide.
db = _mock_db_one({"n_sold": 200, "n_mortgage": 80}) db = _mock_db_one({"n_sold": 200, "n_mortgage": 80})
with _identity_resolver: with _identity_resolver:
_query_artificial_demand( _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
db, district=None, obj_class=None, premise_kind="квартира"
)
p = _executed_params(db) p = _executed_params(db)
assert p["has_district"] is False assert p["has_district"] is False
assert p["districts"] == [] assert p["districts"] == []
@ -1527,7 +1577,9 @@ class TestComputeSpecialIndicesGraceful:
# Центроид участка для гео-веса (lon, lat) в окрестностях ЕКБ. # Центроид участка для гео-веса (lon, lat) в окрестностях ЕКБ.
_CENTROID = (60.6000, 56.8000) _CENTROID = (60.6000, 56.8000)
_CAND_SPEC = SegmentSpec( _CAND_SPEC = SegmentSpec(
obj_class="комфорт", room_bucket="2-к 45-60", district="Академический", obj_class="комфорт",
room_bucket="2-к 45-60",
district="Академический",
price_bucket=PRICE_BUCKET_COMFORT, price_bucket=PRICE_BUCKET_COMFORT,
) )
@ -1625,7 +1677,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
# Launch Window резолвится (пик h=24) → candidate_release_month выведен → # Launch Window резолвится (пик h=24) → candidate_release_month выведен →
# тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0). # тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0).
own = _own( own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], "Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
release_month=_DERIVED_CANDIDATE_MONTH, release_month=_DERIVED_CANDIDATE_MONTH,
) )
card = _timing_card([own]) card = _timing_card([own])
@ -1639,12 +1693,24 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
# near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года # near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года
# позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value. # позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value.
near = _timing_card( near = _timing_card(
[_own("Близкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1], [
release_month=_DERIVED_CANDIDATE_MONTH)] _own(
"Близкий-во-времени",
lon=_CENTROID[0],
lat=_CENTROID[1],
release_month=_DERIVED_CANDIDATE_MONTH,
)
]
) )
far = _timing_card( far = _timing_card(
[_own("Далёкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1], [
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48))] _own(
"Далёкий-во-времени",
lon=_CENTROID[0],
lat=_CENTROID[1],
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48),
)
]
) )
near_v = near.indices[KEY_CANNIBALIZATION].value near_v = near.indices[KEY_CANNIBALIZATION].value
far_v = far.indices[KEY_CANNIBALIZATION].value far_v = far.indices[KEY_CANNIBALIZATION].value
@ -1657,7 +1723,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
# классу/цене/гео (не падает, не фабрикует тайминг). # классу/цене/гео (не падает, не фабрикует тайминг).
forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)] forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
own = _own( own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], "Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
release_month=date(2027, 1, 1), # есть дата, но кандидатной нет release_month=date(2027, 1, 1), # есть дата, но кандидатной нет
) )
db = MagicMock() db = MagicMock()
@ -1686,10 +1754,16 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None: def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None:
# Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict. # Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict.
portfolio = [ portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], _own(
release_month=_DERIVED_CANDIDATE_MONTH), "Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], release_month=_DERIVED_CANDIDATE_MONTH
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85, ),
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6)), _own(
"Наш-Б",
obj_class="комфорт+",
lon=60.65,
lat=56.85,
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6),
),
] ]
first = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict() first = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
second = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict() second = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
@ -1703,8 +1777,14 @@ class TestCannibalizationTrueMode:
# наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0. # наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.
portfolio = [ portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]), _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0, _own(
lon=60.9, lat=57.1), "Наш-Б",
obj_class="бизнес",
price_min=200_000.0,
price_max=240_000.0,
lon=60.9,
lat=57.1,
),
] ]
card = _cannibalization_card(portfolio) card = _cannibalization_card(portfolio)
can = card.indices[KEY_CANNIBALIZATION] can = card.indices[KEY_CANNIBALIZATION]
@ -1717,8 +1797,14 @@ class TestCannibalizationTrueMode:
def test_top_contributor_surfaced(self) -> None: def test_top_contributor_surfaced(self) -> None:
portfolio = [ portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]), _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0, _own(
lon=60.9, lat=57.1), "Наш-Б",
obj_class="бизнес",
price_min=200_000.0,
price_max=240_000.0,
lon=60.9,
lat=57.1,
),
] ]
card = _cannibalization_card(portfolio) card = _cannibalization_card(portfolio)
can = card.indices[KEY_CANNIBALIZATION] can = card.indices[KEY_CANNIBALIZATION]
@ -1742,20 +1828,24 @@ class TestCannibalizationTrueMode:
assert near_v > far_v assert near_v > far_v
def test_no_geometry_still_computes_via_other_axes(self) -> None: def test_no_geometry_still_computes_via_other_axes(self) -> None:
# нет центроида → гео-вес на floor (0.1), но класс+цена дают overlap → индекс есть. # нет центроида → гео-вес на floor (_GEO_WEIGHT_UNKNOWN=0.05), класс+цена дают
# overlap → индекс есть (None-not-0). #1633: floor снижен 0.1→0.05.
card = _cannibalization_card([_own("Без-гео")], centroid=None) card = _cannibalization_card([_own("Без-гео")], centroid=None)
can = card.indices[KEY_CANNIBALIZATION] can = card.indices[KEY_CANNIBALIZATION]
assert can.method == "own_portfolio_overlap" assert can.method == "own_portfolio_overlap"
assert can.detail["has_geometry"] is False assert can.detail["has_geometry"] is False
# overlap 1.0 × geo floor 0.1 = 0.1. # overlap 1.0 × geo floor 0.05 = 0.05.
assert can.value == pytest.approx(0.1) assert can.value == pytest.approx(0.05)
def test_unit_mix_and_timing_axes_when_supplied(self) -> None: def test_unit_mix_and_timing_axes_when_supplied(self) -> None:
# caller передаёт квартирографию/тайминг кандидата → оси становятся доступны. # caller передаёт квартирографию/тайминг кандидата → оси становятся доступны.
db = MagicMock() db = MagicMock()
own_proj = _own( own_proj = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], "Наш-А",
unit_mix={"studio": 0.5, "1k": 0.5}, release_month=date(2026, 6, 1), lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5},
release_month=date(2026, 6, 1),
) )
with ( with (
patch(f"{_MOD}.get_own_portfolio", return_value=[own_proj]), patch(f"{_MOD}.get_own_portfolio", return_value=[own_proj]),
@ -1764,7 +1854,9 @@ class TestCannibalizationTrueMode:
from app.services.forecasting.special_indices import _build_cannibalization from app.services.forecasting.special_indices import _build_cannibalization
idx = _build_cannibalization( idx = _build_cannibalization(
db, spec=_CAND_SPEC, cad_num="66:41:0303161:123", db,
spec=_CAND_SPEC,
cad_num="66:41:0303161:123",
candidate_unit_mix={"studio": 0.5, "1k": 0.5}, candidate_unit_mix={"studio": 0.5, "1k": 0.5},
candidate_release_month=date(2026, 6, 1), candidate_release_month=date(2026, 6, 1),
) )
@ -1777,8 +1869,16 @@ class TestCannibalizationTrueMode:
# наш проект без класса И без цены → ни класс, ни цена не считаются; # наш проект без класса И без цены → ни класс, ни цена не считаются;
# тайминг/квартирография тоже None → пара без осей → индекс unavailable (НЕ 0). # тайминг/квартирография тоже None → пара без осей → индекс unavailable (НЕ 0).
card = _cannibalization_card( card = _cannibalization_card(
[_own("Пустой", obj_class=None, price_min=None, price_max=None, [
lon=_CENTROID[0], lat=_CENTROID[1])] _own(
"Пустой",
obj_class=None,
price_min=None,
price_max=None,
lon=_CENTROID[0],
lat=_CENTROID[1],
)
]
) )
can = card.indices[KEY_CANNIBALIZATION] can = card.indices[KEY_CANNIBALIZATION]
assert can.value is None assert can.value is None
@ -1809,9 +1909,9 @@ class TestCannibalizationThinPortfolioConfidence:
assert "future" in can.detail["confidence_note"] assert "future" in can.detail["confidence_note"]
def test_single_project_is_low_data_scarce(self) -> None: def test_single_project_is_low_data_scarce(self) -> None:
can = _cannibalization_card( can = _cannibalization_card([_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]).indices[
[_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])] KEY_CANNIBALIZATION
).indices[KEY_CANNIBALIZATION] ]
assert can.confidence == "low" assert can.confidence == "low"
assert "данных мало" in can.detail["confidence_note"] assert "данных мало" in can.detail["confidence_note"]
@ -1857,8 +1957,14 @@ class TestCannibalizationDeterminism:
def test_same_inputs_identical_output(self) -> None: def test_same_inputs_identical_output(self) -> None:
portfolio = [ portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]), _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0, _own(
lon=60.9, lat=57.1), "Наш-Б",
obj_class="бизнес",
price_min=200_000.0,
price_max=240_000.0,
lon=60.9,
lat=57.1,
),
_own("Наш-В", obj_class="комфорт+", lon=60.65, lat=56.85), _own("Наш-В", obj_class="комфорт+", lon=60.65, lat=56.85),
] ]
first = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict() first = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
@ -1932,7 +2038,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
# recommend_mix отдаёт микс кандидата (RU-подписи), наш проект — manual латиница с # recommend_mix отдаёт микс кандидата (RU-подписи), наш проект — manual латиница с
# ТЕМ ЖЕ распределением по комнатности → ось квартирографии = 1.0 (через канон). # ТЕМ ЖЕ распределением по комнатности → ось квартирографии = 1.0 (через канон).
own = _own( own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], "Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3}, unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
) )
card = _unitmix_card( card = _unitmix_card(
@ -1951,21 +2059,30 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
# Все прочие оси (класс/цена/гео/тайминг) идентичны → разница только в квартирографии. # Все прочие оси (класс/цена/гео/тайминг) идентичны → разница только в квартирографии.
rec = _recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0}) rec = _recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0})
similar = _unitmix_card( similar = _unitmix_card(
[_own("Похожий", lon=_CENTROID[0], lat=_CENTROID[1], [
unit_mix={"studio": 0.5, "1k": 0.5})], _own(
"Похожий",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5},
)
],
recommend_return=rec, recommend_return=rec,
) )
dissimilar = _unitmix_card( dissimilar = _unitmix_card(
[_own("Непохожий", lon=_CENTROID[0], lat=_CENTROID[1], [
unit_mix={"2k": 0.5, "3k": 0.5})], _own(
"Непохожий", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"2k": 0.5, "3k": 0.5}
)
],
recommend_return=rec, recommend_return=rec,
) )
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail[ sim_axis = similar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
"top_contributors" "unit_mix"
][0]["axes"]["unit_mix"] ]
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail[ dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
"top_contributors" "unit_mix"
][0]["axes"]["unit_mix"] ]
assert sim_axis > dis_axis assert sim_axis > dis_axis
# и итоговое значение каннибализации выше при похожем миксе. # и итоговое значение каннибализации выше при похожем миксе.
assert ( assert (
@ -1977,7 +2094,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
# recommend_mix вернул None → микс None → ось квартирографии исключена (None-not-0), # recommend_mix вернул None → микс None → ось квартирографии исключена (None-not-0),
# каннибализация всё равно считается по классу/цене/тайм/гео. # каннибализация всё равно считается по классу/цене/тайм/гео.
own = _own( own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], "Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5}, unit_mix={"studio": 0.5, "1k": 0.5},
) )
card = _unitmix_card([own], recommend_return=None) card = _unitmix_card([own], recommend_return=None)
@ -1990,7 +2109,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_axis_excluded_when_recommend_empty_buckets(self) -> None: def test_axis_excluded_when_recommend_empty_buckets(self) -> None:
# recommend_mix отдал пустые buckets (тонкие данные) → микс None → ось исключена. # recommend_mix отдал пустые buckets (тонкие данные) → микс None → ось исключена.
own = _own( own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], "Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5}, unit_mix={"studio": 0.5, "1k": 0.5},
) )
card = _unitmix_card([own], recommend_return={"buckets": []}) card = _unitmix_card([own], recommend_return={"buckets": []})
@ -2001,7 +2122,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_axis_excluded_when_recommend_raises_no_crash(self) -> None: def test_axis_excluded_when_recommend_raises_no_crash(self) -> None:
# recommend_mix БРОСИЛ → graceful None → ось исключена, карточка цела (НЕ crash). # recommend_mix БРОСИЛ → graceful None → ось исключена, карточка цела (НЕ crash).
own = _own( own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], "Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5}, unit_mix={"studio": 0.5, "1k": 0.5},
) )
card = _unitmix_card([own], recommend_side_effect=RuntimeError("heavy query boom")) card = _unitmix_card([own], recommend_side_effect=RuntimeError("heavy query boom"))
@ -2015,7 +2138,10 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
# ЧЕСТНОСТЬ (own_portfolio PR1): current-проект несёт unit_mix=None → даже при # ЧЕСТНОСТЬ (own_portfolio PR1): current-проект несёт unit_mix=None → даже при
# валидном recommend-миксе кандидата ось не считается (нечего сравнивать). # валидном recommend-миксе кандидата ось не считается (нечего сравнивать).
own = _own( own = _own(
"Текущий", source="current", lon=_CENTROID[0], lat=_CENTROID[1], "Текущий",
source="current",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix=None, unit_mix=None,
) )
card = _unitmix_card( card = _unitmix_card(
@ -2029,7 +2155,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_all_four_axes_active_together(self) -> None: def test_all_four_axes_active_together(self) -> None:
# class + price + timing + unit_mix ВСЕ доступны на одном проекте → n_axes == 4. # class + price + timing + unit_mix ВСЕ доступны на одном проекте → n_axes == 4.
own = _own( own = _own(
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], "Наш-А",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 0.5, "1k": 0.5}, unit_mix={"studio": 0.5, "1k": 0.5},
release_month=_DERIVED_CANDIDATE_MONTH, # совпадает с выведенным тайм. кандидата release_month=_DERIVED_CANDIDATE_MONTH, # совпадает с выведенным тайм. кандидата
) )
@ -2045,28 +2173,34 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_unit_mix_axis_deterministic_identical_as_dict(self) -> None: def test_unit_mix_axis_deterministic_identical_as_dict(self) -> None:
# Детерминизм (§16): одинаковые входы → идентичный as_dict (с активной осью). # Детерминизм (§16): одинаковые входы → идентичный as_dict (с активной осью).
portfolio = [ portfolio = [
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], _own(
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3}), "Наш-А",
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85, lon=_CENTROID[0],
unit_mix={"1k": 0.6, "2k": 0.4}), lat=_CENTROID[1],
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
),
_own(
"Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85, unit_mix={"1k": 0.6, "2k": 0.4}
),
] ]
rec = _recommend_mix_result( rec = _recommend_mix_result({"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0})
{"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0} first = (
_unitmix_card(list(portfolio), recommend_return=rec)
.indices[KEY_CANNIBALIZATION]
.as_dict()
)
second = (
_unitmix_card(list(portfolio), recommend_return=rec)
.indices[KEY_CANNIBALIZATION]
.as_dict()
) )
first = _unitmix_card(list(portfolio), recommend_return=rec).indices[
KEY_CANNIBALIZATION
].as_dict()
second = _unitmix_card(list(portfolio), recommend_return=rec).indices[
KEY_CANNIBALIZATION
].as_dict()
assert first == second assert first == second
# подтверждаем, что ось реально участвовала (не пустой детерминизм). # подтверждаем, что ось реально участвовала (не пустой детерминизм).
assert first["detail"]["axes_available"]["unit_mix"] == 2 assert first["detail"]["axes_available"]["unit_mix"] == 2
def test_recommend_mix_called_with_expected_args(self) -> None: def test_recommend_mix_called_with_expected_args(self) -> None:
# cost/корректность: recommend_mix зовётся с district+target_class+cad_num кандидата. # cost/корректность: recommend_mix зовётся с district+target_class+cad_num кандидата.
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
unit_mix={"studio": 1.0})
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0})) rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
db = MagicMock() db = MagicMock()
with ( with (
@ -2091,8 +2225,7 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
def test_no_district_skips_recommend_mix(self) -> None: def test_no_district_skips_recommend_mix(self) -> None:
# нет района → recommend_mix НЕ зовётся (район обязателен) → ось исключена, без вызова. # нет района → recommend_mix НЕ зовётся (район обязателен) → ось исключена, без вызова.
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
unit_mix={"studio": 1.0})
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0})) rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
db = MagicMock() db = MagicMock()
with ( with (
@ -2155,10 +2288,22 @@ class TestCannibalizationUnitMixGate:
# Портфель только из current/domrf (unit_mix=None) → ось квартирографии всё равно # Портфель только из current/domrf (unit_mix=None) → ось квартирографии всё равно
# исключилась бы → тяжёлый recommend_mix НЕ должен вызываться (call_count == 0). # исключилась бы → тяжёлый recommend_mix НЕ должен вызываться (call_count == 0).
portfolio = [ portfolio = [
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1], _own(
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH), "Текущий-1",
_own("Текущий-2", source="current", lon=_CENTROID[0], lat=_CENTROID[1], source="current",
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH), lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix=None,
release_month=_DERIVED_CANDIDATE_MONTH,
),
_own(
"Текущий-2",
source="current",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix=None,
release_month=_DERIVED_CANDIDATE_MONTH,
),
] ]
card, rec_mock, _ = _gated_card(portfolio) card, rec_mock, _ = _gated_card(portfolio)
# Гейт сработал: тяжёлый запрос НЕ выполнен. # Гейт сработал: тяжёлый запрос НЕ выполнен.
@ -2179,8 +2324,13 @@ class TestCannibalizationUnitMixGate:
# Есть future-проект с unit_mix → ось может внести вклад → recommend_mix зовётся # Есть future-проект с unit_mix → ось может внести вклад → recommend_mix зовётся
# РОВНО один раз (не O(n), не дважды), и get_own_portfolio фетчится РОВНО один раз. # РОВНО один раз (не O(n), не дважды), и get_own_portfolio фетчится РОВНО один раз.
portfolio = [ portfolio = [
_own("Будущий-А", source="future", lon=_CENTROID[0], lat=_CENTROID[1], _own(
unit_mix={"studio": 1.0}), "Будущий-А",
source="future",
lon=_CENTROID[0],
lat=_CENTROID[1],
unit_mix={"studio": 1.0},
),
] ]
card, rec_mock, portfolio_mock = _gated_card(portfolio) card, rec_mock, portfolio_mock = _gated_card(portfolio)
# Тяжёлый запрос — ровно один доп. вызов на отчёт. # Тяжёлый запрос — ровно один доп. вызов на отчёт.
@ -2197,8 +2347,7 @@ class TestCannibalizationUnitMixGate:
# Даже когда гейт пропускает recommend_mix, get_own_portfolio всё равно фетчится # Даже когда гейт пропускает recommend_mix, get_own_portfolio всё равно фетчится
# РОВНО один раз (портфель нужен и для гейта, и для самой каннибализации). # РОВНО один раз (портфель нужен и для гейта, и для самой каннибализации).
portfolio = [ portfolio = [
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1], _own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix=None),
unit_mix=None),
] ]
_, rec_mock, portfolio_mock = _gated_card(portfolio) _, rec_mock, portfolio_mock = _gated_card(portfolio)
assert rec_mock.call_count == 0 assert rec_mock.call_count == 0
@ -2207,8 +2356,9 @@ class TestCannibalizationUnitMixGate:
def test_empty_mix_dict_does_not_trigger_recommend_mix(self) -> None: def test_empty_mix_dict_does_not_trigger_recommend_mix(self) -> None:
# Пустой dict unit_mix={} (грязь) — НЕ «есть микс» → recommend_mix не зовётся. # Пустой dict unit_mix={} (грязь) — НЕ «есть микс» → recommend_mix не зовётся.
portfolio = [ portfolio = [
_own("Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1], _own(
unit_mix={}), "Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={}
),
] ]
_, rec_mock, _ = _gated_card(portfolio) _, rec_mock, _ = _gated_card(portfolio)
assert rec_mock.call_count == 0 assert rec_mock.call_count == 0

View file

@ -210,3 +210,125 @@ def test_safe_payload_defaults_are_empty_and_non_confidential() -> None:
assert p.text == "" assert p.text == ""
assert p.fields == {} assert p.fields == {}
assert p.is_confidential is False assert p.is_confidential is False
# ── #1640: INN context-guard — large bare numbers must NOT be redacted ────────
def test_large_sum_10_digits_not_redacted_without_inn_keyword() -> None:
"""Слитная 10-значная сумма (1 200 000 000 руб) без якоря «ИНН» — НЕ редактируется.
Регрессия #1640: старый _INN_RE ловил любые 10/12 цифр → «1200000000» уходило в
[REDACTED:inn]. Новый паттерн требует маркер ИНН/inn перед числом.
"""
text = "стоимость участка составила 1200000000 рублей по договору"
out = scrub_text(text)
assert out == text, f"bare large sum was incorrectly redacted: {out!r}"
def test_large_sum_12_digits_not_redacted_without_inn_keyword() -> None:
"""12-значная слитная сумма без якоря — НЕ редактируется."""
text = "капитализация компании 123456789012 рублей"
out = scrub_text(text)
assert out == text
def test_round_billion_not_redacted() -> None:
"""Круглые суммы без контекста — НЕ редактируются."""
for amount in ("5000000000", "1000000000", "9876543210"):
out = scrub_text(f"сумма {amount} руб")
assert "[REDACTED:inn]" not in out, f"amount {amount!r} was falsely redacted"
def test_inn_with_keyword_is_redacted() -> None:
"""Валидный ИНН с явным маркером «ИНН» — редактируется."""
out = scrub_text("ИНН 7707083893 зарегистрирован в ЕГРЮЛ")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_inn_12_with_keyword_is_redacted() -> None:
"""Валидный 12-значный ИНН физлица с маркером — редактируется."""
out = scrub_text("ИНН физлица 500100732259 подтверждён")
assert "[REDACTED:inn]" in out
assert "500100732259" not in out
def test_inn_keyword_latin_case_insensitive() -> None:
"""Маркер 'inn' (латиница, любой регистр) тоже активирует редакцию."""
out = scrub_text("inn: 7707083893")
assert "[REDACTED:inn]" in out
def test_inn_bad_checksum_with_keyword_not_redacted() -> None:
"""Число с маркером «ИНН», но не прошедшее checksum, НЕ редактируется.
Предотвращает ложные срабатывания на «ИНН 1234567890» (неверная контрольная цифра).
"""
out = scrub_text("ИНН 1234567890 (некорректный)")
assert "[REDACTED:inn]" not in out
assert "1234567890" in out
# ── #1640 follow-up: word-boundary guards ((?<!\w)/(?!\w)) ───────────────────
def test_inn_digits_alpha_prefix_not_redacted() -> None:
"""Цифры ИНН как суффикс буквенно-цифрового токена НЕ редактируются.
Регрессия: (?<!\\d) не защищал от «ИНН ref7707083893» 'f' не цифра, старый
lookaround пропускал. Новый (?<!\\w) блокирует любой word-char перед цифровым блоком.
"""
out = scrub_text("ИНН ref7707083893")
assert "[REDACTED:inn]" not in out
assert "7707083893" in out
def test_inn_digits_alpha_suffix_not_redacted() -> None:
"""Цифры ИНН как префикс буквенно-цифрового токена НЕ редактируются.
Регрессия: старый (?!\\d) не блокировал буквенный суффикс «7707083893more»
совпадало, т.к. 'm' не цифра. Новый (?!\\w) закрывает alpha-суффикс.
"""
out = scrub_text("ИНН 7707083893more")
assert "[REDACTED:inn]" not in out
assert "7707083893" in out
def test_inn_digits_embedded_in_alphanumeric_token_not_redacted() -> None:
"""ИНН-подобный блок цифр внутри алфавитно-цифрового токена НЕ редактируется.
«ИНН ref7707083893xyz» цифры с обеих сторон окружены word-char'ами.
"""
out = scrub_text("ИНН ref7707083893xyz")
assert "[REDACTED:inn]" not in out
def test_inn_alpha_prefix_on_12digit_block_not_redacted() -> None:
"""12-значный валидный ИНН с буквенным префиксом (без пробела) НЕ редактируется.
«ИНН ABC500100732259» 'C' является word-char, (?<!\\w) блокирует совпадение.
"""
out = scrub_text("ИНН ABC500100732259")
assert "[REDACTED:inn]" not in out
assert "500100732259" in out
def test_inn_standalone_with_punctuation_is_redacted() -> None:
"""Валидный ИНН, отделённый знаком препинания (не word-char), редактируется.
Двоеточие, запятая, скобка не \\w (?<!\\w) пропускает. Это корректное поведение.
"""
out = scrub_text("ИНН:7707083893 в реестре")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_inn_standalone_at_end_of_string_is_redacted() -> None:
"""Валидный ИНН в конце строки без символов после редактируется.
Конец строки не \\w (?!\\w) пропускает. Базовый smoke.
"""
out = scrub_text("ИНН 7707083893")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out

View file

@ -0,0 +1,261 @@
"""Тесты для parse_catalog_flat() из domrf_catalog.py (issue #1609).
Покрывает:
- _classify_status_kw классификация ключевых слов
- Статус: sold (все морфоварианты: продан/продана/продано/реализована)
- Статус: reserved (забронирована/забронировано/бронь)
- Статус: free (в продаже/свободна)
- Уровень 1: badge-класс CSS sold берётся из badge, а не из nav "в продаже"
- Уровень 2: proximity "Статус" следующий блок
- Уровень 3: sold/reserved побеждают free, даже если free встретился раньше в blocks
- Нет статуса result не содержит ключ "status"
"""
from __future__ import annotations
import pytest
from app.services.scrapers.domrf_catalog import (
STATUS_FREE,
STATUS_RESERVED,
STATUS_SOLD,
_classify_status_kw,
parse_catalog_flat,
)
# ── _classify_status_kw ───────────────────────────────────────────────────────
@pytest.mark.parametrize(
"text,expected",
[
# sold — все морфоварианты
("продан", STATUS_SOLD),
("продана", STATUS_SOLD),
("продано", STATUS_SOLD),
("Квартира продана", STATUS_SOLD),
("ПРОДАНО", STATUS_SOLD),
("реализована", STATUS_SOLD),
("реализовано", STATUS_SOLD),
("sold", STATUS_SOLD),
# reserved
("забронирована", STATUS_RESERVED),
("забронировано", STATUS_RESERVED),
("бронь", STATUS_RESERVED),
("reserved", STATUS_RESERVED),
# free
("в продаже", STATUS_FREE),
("свободна", STATUS_FREE),
("свободно", STATUS_FREE),
("free", STATUS_FREE),
# нераспознанные
("какой-то текст", None),
("", None),
],
)
def test_classify_status_kw(text: str, expected: str | None) -> None:
assert _classify_status_kw(text) == expected
# ── Вспомогательные конструкторы HTML-фрагментов ──────────────────────────────
def _html_with_badge(badge_text: str, nav_text: str = "другие квартиры в продаже") -> str:
"""HTML с типичной структурой DOM.РФ: nav-блок + статус-бейдж."""
return (
"<!doctype html><html><body>"
# Навигационный блок — появляется раньше статуса в документе
f'<nav><a href="/catalog">Все квартиры</a><span>{nav_text}</span></nav>'
# Статус-бейдж с CSS-классом "status-badge"
f'<div class="status-badge">{badge_text}</div>'
"<p>Площадь 45 м²</p>"
"</body></html>"
)
def _html_with_label(label: str, value: str, nav_text: str = "") -> str:
"""HTML с лейблом «Статус» и значением в следующем блоке."""
nav = f"<span>{nav_text}</span>" if nav_text else ""
return (
"<!doctype html><html><body>"
f"{nav}"
f"<span>{label}</span>"
f"<span>{value}</span>"
"</body></html>"
)
def _html_blocks_only(blocks: list[tuple[str, str]]) -> str:
"""Генерирует HTML из списка (css_class, text) без badge-классов."""
parts = ["<!doctype html><html><body>"]
for cls, text in blocks:
parts.append(f'<div class="{cls}">{text}</div>')
parts.append("</body></html>")
return "".join(parts)
# ── Уровень 1: badge CSS-класс ────────────────────────────────────────────────
def test_status_badge_sold_wins_over_nav_free() -> None:
"""Проданная квартира: badge=продана, nav=в продаже → status=sold (#1609 core bug)."""
html = _html_with_badge("Продана", nav_text="другие квартиры в продаже")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_status_badge_prodano() -> None:
"""Морфовариант 'продано' в badge → sold."""
html = _html_with_badge("Продано")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_status_badge_reserved() -> None:
"""Забронирована в badge → reserved."""
html = _html_with_badge("Забронирована")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_RESERVED
def test_status_badge_free() -> None:
"""В продаже в badge (нет nav-коллизии) → free."""
html = _html_with_badge("В продаже", nav_text="")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_FREE
def test_status_badge_realizovana() -> None:
"""'Реализована' (синоним sold) в badge → sold."""
html = _html_with_badge("Реализована")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
# ── Уровень 2: proximity "Статус" ────────────────────────────────────────────
def test_status_label_proximity_sold() -> None:
"""Блок 'Статус' → следующий блок 'Продана' → sold."""
html = _html_with_label("Статус", "Продана")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_status_label_proximity_free() -> None:
"""Блок 'Статус' → следующий блок 'В продаже' → free."""
html = _html_with_label("Статус", "В продаже")
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_FREE
# ── Уровень 3: blocks scan, sold/reserved > free ─────────────────────────────
def test_status_level3_sold_beats_earlier_free() -> None:
"""Без badge-класса и без label: free-блок раньше, sold-блок позже → sold побеждает."""
# Нет badge-класса: используем нейтральные классы
html = _html_blocks_only(
[
("nav-link", "другие квартиры в продаже"), # free — но это нав
("info-card", "Квартира продана"), # sold — специфичнее
]
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_status_level3_reserved_beats_earlier_free() -> None:
"""Без badge: free раньше, reserved позже → reserved побеждает."""
html = _html_blocks_only(
[
("nav", "в продаже"),
("info", "Забронировано"),
]
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_RESERVED
def test_status_level3_free_only() -> None:
"""Без badge: только free-блок (без sold/reserved) → free."""
html = _html_blocks_only(
[
("info", "В продаже"),
]
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_FREE
def test_status_no_keywords() -> None:
"""HTML без статусных слов → ключ 'status' отсутствует в результате."""
html = "<!doctype html><html><body><p>Площадь 45 м²</p></body></html>"
result = parse_catalog_flat(html)
assert "status" not in result
# ── Регрессия: продана (ж.р.) не сбивает free-баг ────────────────────────────
def test_prodana_feminine_classified_as_sold() -> None:
"""'Квартира продана' (ж.р., feminine) → sold, а не free (issue #1609)."""
# Симулируем реальную страницу: nav содержит "в продаже" раньше
html = (
"<!doctype html><html><body>"
"<header><a>Все квартиры в продаже</a></header>"
'<div class="flat-status-tag">Квартира продана</div>'
"</body></html>"
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
def test_generic_label_tag_not_treated_as_badge() -> None:
"""Элемент с классом 'label' или 'tag' (без слова 'status') НЕ должен
активировать Уровень 1 (#1686 follow-up: regex был слишком широк).
Страница: generic <span class="label"> с текстом 'В продаже' в шапке,
реальный статус-бейдж с классом 'status-badge' содержит 'Продана'.
Уровень 1 должен выбрать только блок status-badge, а не label-шапку.
"""
html = (
"<!doctype html><html><body>"
# generic label в шапке — НЕ должен активировать Level-1 badge match
'<span class="label">В продаже</span>'
'<span class="tag">Новостройка</span>'
'<span class="chip">ЕКБ</span>'
'<span class="badge">Акция</span>'
# настоящий статус-бейдж — должен быть подхвачен Level-1
'<div class="status-badge">Продана</div>'
"</body></html>"
)
result = parse_catalog_flat(html)
# Level-1 должен взять status-badge (Продана → sold),
# generic label/tag/chip/badge не должны перебить правильный результат
assert result.get("status") == STATUS_SOLD
def test_full_page_sold_flat_no_false_free() -> None:
"""Реалистичный HTML проданной квартиры: навигация 'в продаже', бейдж 'продано'."""
html = (
"<!doctype html><html><head><title>Квартира</title></head><body>"
"<nav>"
' <a href="/catalog">Каталог квартир в продаже</a>'
' <span class="breadcrumb">Квартиры в продаже</span>'
"</nav>"
'<main class="flat-page">'
' <h1 class="flat-title">Квартира 2-комнатная</h1>'
' <div class="price-block">4 500 000 ₽</div>'
' <div class="status-label">Продано</div>'
' <div class="flat-info">Площадь 55 м² Подъезд 2</div>'
"</main>"
'<section class="similar">'
" <h2>Похожие квартиры в продаже</h2>"
"</section>"
"</body></html>"
)
result = parse_catalog_flat(html)
assert result.get("status") == STATUS_SOLD
# Цена тоже должна распознаться
assert result.get("price_rub") == 4_500_000

View file

@ -275,6 +275,7 @@ class TestAsDict:
demand_concentration=0.3333333, demand_concentration=0.3333333,
price_sensitivity=-1.234567, price_sensitivity=-1.234567,
price_sensitivity_source="regression", price_sensitivity_source="regression",
velocity_by_room={"1": 1.5, "2": 0.5},
) )
d = m.as_dict() d = m.as_dict()
assert d["absorption_rate"] == 0.0476 assert d["absorption_rate"] == 0.0476
@ -304,6 +305,7 @@ class TestAsDict:
demand_concentration=None, demand_concentration=None,
price_sensitivity=None, price_sensitivity=None,
price_sensitivity_source=None, price_sensitivity_source=None,
velocity_by_room=None,
) )
d = m.as_dict() d = m.as_dict()
assert d["absorption_rate"] is None assert d["absorption_rate"] is None
@ -421,6 +423,14 @@ class TestComputeMarketMetricsShape:
assert m.price_sensitivity == -1.4 assert m.price_sensitivity == -1.4
assert m.price_sensitivity_source == "regression" assert m.price_sensitivity_source == "regression"
assert m.confidence == "high" assert m.confidence == "high"
# #1593: velocity_by_room — per-bucket ед./мес из sold_by_room.
# _FULL_SALES: rooms_int=1 (30 ед.), rooms_int=2 (20 ед.), rooms_int=0 (10 ед.)
# window_months=6 → "1": 30/6=5.0, "2": 20/6≈3.333, "студия": 10/6≈1.667
assert m.velocity_by_room is not None
assert set(m.velocity_by_room.keys()) == {"1", "2", "студия"}
assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)
def test_elasticity_reuse_invoked_with_district(self) -> None: def test_elasticity_reuse_invoked_with_district(self) -> None:
db = _mock_db(_FULL_STOCK, _FULL_SALES) db = _mock_db(_FULL_STOCK, _FULL_SALES)
@ -516,9 +526,7 @@ class TestPriceSensitivityDistrictResolution:
случайно совпадал в обоих вокабулярах). случайно совпадал в обоих вокабулярах).
""" """
def test_admin_resolves_to_micros_in_elasticity_call( def test_admin_resolves_to_micros_in_elasticity_call(self, _patch_resolver: MagicMock) -> None:
self, _patch_resolver: MagicMock
) -> None:
# Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде). # Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
_patch_resolver.side_effect = lambda _db, d: ( _patch_resolver.side_effect = lambda _db, d: (
["Втузгородок", "ЖБИ"] if d == "Кировский" else None ["Втузгородок", "ЖБИ"] if d == "Кировский" else None
@ -542,9 +550,7 @@ class TestPriceSensitivityDistrictResolution:
assert value == -1.2 assert value == -1.2
assert source == "regression" assert source == "regression"
def test_resolver_none_passes_empty_list_ekb_wide( def test_resolver_none_passes_empty_list_ekb_wide(self, _patch_resolver: MagicMock) -> None:
self, _patch_resolver: MagicMock
) -> None:
# Резолвер None ('не определён' / нет чистых алиасов) → передаём [] → # Резолвер None ('не определён' / нет чистых алиасов) → передаём [] →
# _elasticity_coef уходит в EKB-wide ветку (без district-фильтра). # _elasticity_coef уходит в EKB-wide ветку (без district-фильтра).
_patch_resolver.side_effect = lambda _db, _d: None _patch_resolver.side_effect = lambda _db, _d: None
@ -555,9 +561,7 @@ class TestPriceSensitivityDistrictResolution:
_args, kwargs = elast.call_args _args, kwargs = elast.call_args
assert kwargs["districts"] == [] assert kwargs["districts"] == []
def test_none_district_skips_resolver_and_elasticity( def test_none_district_skips_resolver_and_elasticity(self, _patch_resolver: MagicMock) -> None:
self, _patch_resolver: MagicMock
) -> None:
# district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни # district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни
# резолвер, ни _elasticity_coef. # резолвер, ни _elasticity_coef.
db = MagicMock() db = MagicMock()
@ -600,6 +604,45 @@ class TestPriceSensitivityDistrictResolution:
assert source is None assert source is None
class TestVelocityByRoom:
"""#1593: MarketMetrics.velocity_by_room — per-bucket ед./мес."""
def test_velocity_by_room_computed_from_sold_by_room(self) -> None:
# sold_by_room: "1" → 30, "2" → 20, "студия" → 10 ед. за 6 мес.
db = _mock_db(_FULL_STOCK, _FULL_SALES)
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
m = compute_market_metrics(db, district="Автовокзал", window_months=6)
assert m.velocity_by_room is not None
assert m.velocity_by_room["1"] == pytest.approx(30.0 / 6)
assert m.velocity_by_room["2"] == pytest.approx(20.0 / 6)
assert m.velocity_by_room["студия"] == pytest.approx(10.0 / 6)
def test_velocity_by_room_none_when_no_sample(self) -> None:
# n_lots=0 → выборка пуста → velocity_by_room None (зеркало unit_velocity).
empty_stock = {
"n_lots": 0,
"n_sold": 0,
"n_available": 0,
"obj_count": 0,
"n_long_unsold": 0,
}
db = _mock_db(empty_stock, [])
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
m = compute_market_metrics(db, district="ПустойРайон")
assert m.velocity_by_room is None
def test_velocity_by_room_none_when_no_by_room_data(self) -> None:
# Только grand-total строка (нет разбивки по комнатности) → sold_by_room пустой
# → velocity_by_room None.
sales_total_only = [
{"units_sold_window": 5, "area_sold_window": 200.0, "rooms_int": None, "is_total": 1}
]
db = _mock_db(_FULL_STOCK, sales_total_only)
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
m = compute_market_metrics(db, district="Тихий")
assert m.velocity_by_room is None
class TestComputeMarketMetricsThinData: class TestComputeMarketMetricsThinData:
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash.""" """Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
@ -623,6 +666,7 @@ class TestComputeMarketMetricsThinData:
assert m.liquidity_index is None assert m.liquidity_index is None
assert m.overstock_index is None assert m.overstock_index is None
assert m.demand_concentration is None assert m.demand_concentration is None
assert m.velocity_by_room is None
assert m.confidence == "low" assert m.confidence == "low"
def test_stock_none_row_no_crash(self) -> None: def test_stock_none_row_no_crash(self) -> None:

View file

@ -0,0 +1,146 @@
"""Тесты riasurt_lookup (#108) — is_in_aglomeration_but_not_ekb + parcel_riasurt_gate.
БД мокается фейк-сессией, возвращающей заранее заданные mapping-строки. Проверяем
агломерация-гейтинг (ЕКБ-сити исключён), topic-бакеты, graceful при ошибке БД.
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.exc import ProgrammingError
from app.services.site_finder import riasurt_lookup
from app.services.site_finder.riasurt_lookup import (
is_in_aglomeration_but_not_ekb,
parcel_riasurt_gate,
)
_WKT = "POLYGON((60 56,60.1 56,60.1 56.1,60 56.1,60 56))"
def test_is_in_aglomeration_excludes_ekb_city() -> None:
"""ЕКБ-сити (66:41) → False."""
assert is_in_aglomeration_but_not_ekb("66:41:0204016:10") is False
assert is_in_aglomeration_but_not_ekb("66:41") is False
def test_is_in_aglomeration_includes_outskirts() -> None:
"""Окраины (66:35 Берёзовский, 66:62 В.Пышма, 66:25 Сысерть) → True."""
assert is_in_aglomeration_but_not_ekb("66:35:0101001:5") is True
assert is_in_aglomeration_but_not_ekb("66:62:1234567:1") is True
assert is_in_aglomeration_but_not_ekb("66:25:0000001:99") is True
def test_is_in_aglomeration_rejects_non_sverdl_and_empty() -> None:
"""Не-Свердл (не 66:*) и пусто → False."""
assert is_in_aglomeration_but_not_ekb("50:21:0000001:1") is False
assert is_in_aglomeration_but_not_ekb(None) is False
assert is_in_aglomeration_but_not_ekb("") is False
class _Row(dict):
"""mapping-строка: поддерживает r["key"]."""
class _Result:
def __init__(self, rows: list[_Row]) -> None:
self._rows = rows
def mappings(self) -> _Result:
return self
def all(self) -> list[_Row]:
return self._rows
class _FakeDB:
def __init__(self, rows: list[_Row] | Exception) -> None:
self._rows = rows
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
if isinstance(self._rows, Exception):
raise self._rows
return _Result(self._rows)
def test_gate_not_applicable_for_ekb_city() -> None:
"""ЕКБ-сити → applicable=False, БД не дёргается."""
db = _FakeDB([])
gate = parcel_riasurt_gate(db, _WKT, "66:41:0204016:10") # type: ignore[arg-type]
assert gate["applicable"] is False
assert gate["overlaps"] == []
def test_gate_buckets_topics() -> None:
"""Пересечения раскладываются по topic-бакетам."""
rows = [
_Row(
source_layer_id=845274,
layer_topic="territorial_zone",
mo_name="Берёзовский",
obshnz="Ж-1",
description="Зона застройки",
),
_Row(
source_layer_id=846381,
layer_topic="functional_zone",
mo_name="Берёзовский",
obshnz=None,
description="Жилая функц.зона",
),
_Row(
source_layer_id=844759,
layer_topic="red_lines",
mo_name="Берёзовский",
obshnz=None,
description=None,
),
_Row(
source_layer_id=846365,
layer_topic="szz",
mo_name="Берёзовский",
obshnz=None,
description=None,
),
_Row(
source_layer_id=845425,
layer_topic="flood_zone",
mo_name="Берёзовский",
obshnz=None,
description=None,
),
_Row(
source_layer_id=844478,
layer_topic="krt",
mo_name="Берёзовский",
obshnz=None,
description=None,
),
]
db = _FakeDB(rows)
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
assert gate["applicable"] is True
assert gate["tier"] == "Зона застройки"
assert gate["func_zone"] == "Жилая функц.зона"
assert gate["red_lines"] is True
assert gate["szz"] is True
assert gate["szo"] is False
assert gate["flood_zone"] is True
assert gate["krt"] is True
assert len(gate["overlaps"]) == 6
def test_gate_graceful_on_db_error() -> None:
"""Ошибка БД (pre-migration) → пустой gate, не падаем."""
db = _FakeDB(ProgrammingError("stmt", {}, Exception("no table")))
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
assert gate == riasurt_lookup.RIASURT_EMPTY_GATE
def test_gate_empty_wkt_returns_empty() -> None:
"""Нет WKT → пустой gate."""
db = _FakeDB([])
gate = parcel_riasurt_gate(db, None, "66:35:0101001:5") # type: ignore[arg-type]
assert gate["applicable"] is False

View file

@ -304,12 +304,15 @@ def test_denorm_dump_empty_features() -> None:
db = _make_mock_session() db = _make_mock_session()
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[]) counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
assert counts == {"parcels": 0, "buildings": 0, "errors": 0} assert counts == {"parcels": 0, "buildings": 0, "errors": 0, "skipped": 0}
db.commit.assert_called_once() db.commit.assert_called_once()
def test_denorm_dump_no_cad_num_counted_as_error() -> None: def test_denorm_dump_no_cad_num_counted_as_skipped() -> None:
"""Parcel без cad_num → denorm_parcel_feature returns False → errors += 1.""" """Parcel без cad_num → pre-check в denorm_dump → skipped += 1, не errors.
denorm_parcel_feature не вызывается вовсе пропуск штатный, не сбой UPSERT.
"""
db = _make_mock_session() db = _make_mock_session()
feature: dict[str, Any] = { feature: dict[str, Any] = {
"layer": "parcels", "layer": "parcels",
@ -319,4 +322,5 @@ def test_denorm_dump_no_cad_num_counted_as_error() -> None:
} }
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature]) counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
assert counts["parcels"] == 0 assert counts["parcels"] == 0
assert counts["errors"] == 1 assert counts["errors"] == 0
assert counts["skipped"] == 1

View file

@ -77,7 +77,12 @@ def test_find_match_candidates_returns_candidates() -> None:
def test_auto_apply_matches_dry_run_no_inserts() -> None: def test_auto_apply_matches_dry_run_no_inserts() -> None:
"""dry_run=True возвращает счётчики без обращения к БД (execute не вызывается).""" """dry_run=True возвращает projected-счётчики без обращения к БД.
auto_accepted = сколько кандидатов БЫЛО БЫ принято (preview), а не 0
смысл dry-run в admin-endpoint'е именно показать оператору объём перед
реальным insert. execute/commit при этом не вызываются.
"""
mock_db = MagicMock() mock_db = MagicMock()
candidates = [ candidates = [
@ -88,8 +93,9 @@ def test_auto_apply_matches_dry_run_no_inserts() -> None:
result = auto_apply_matches(mock_db, candidates, dry_run=True) result = auto_apply_matches(mock_db, candidates, dry_run=True)
assert result["auto_accepted"] == 0 assert result["auto_accepted"] == 1 # projected: 1 кандидат >= AUTO_ACCEPT_THRESHOLD
assert result["review_queue"] == 2 assert result["review_queue"] == 2
assert result["skipped"] == 0
mock_db.execute.assert_not_called() mock_db.execute.assert_not_called()
mock_db.commit.assert_not_called() mock_db.commit.assert_not_called()

Some files were not shown because too many files have changed in this diff Show more