Commit graph

1125 commits

Author SHA1 Message Date
b6fa4a2773 fix(parcels): guard NULL geom + None lat/lon в isochrones/poi-score (#1201)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m27s
`cad_parcels_geom` — VIEW над `cad_parcels` где `geom` nullable (~964 prod-
строк с NULL geom задокументировано в analyze:1504-1507). Без фильтра
`geom IS NOT NULL` в UNION-ветках:
- NULL-строка из ранней ветки (`cad_quarters_geom`) ПЕРЕКРЫВАЕТ валидную
  геометрию из поздней (`cad_parcels_geom`) — LIMIT 1 берёт первую попавшуюся.
- ST_Centroid(NULL) → NULL → coord_row.lat = NULL → float(None) → TypeError
  → 500 (вместо честного 404).

Guard `if not coord_row` НЕ ловил эту дыру: RowMapping с None-полями
truthy. Зеркало паттерна из analyze (parcels.py:1418-1438) для обоих
эндпоинтов (`/isochrones` + `/poi-score`):
1. `AND geom IS NOT NULL` во ВСЕХ ветках UNION.
2. Belt-and-suspenders: явная проверка `coord_row.get("lat") is None`
   на случай топологически битого polygon'а (NOT-NULL geom, NULL centroid).

26 analyze/isochrones/poi-score юнит-тестов зелёные. ruff clean.

Closes #1201
2026-06-13 10:13:01 +05:00
f12c03588d fix(llm): close §19 redaction regex gaps for bare phone / CAPS name / bare SNILS (#1207)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-worker (push) Successful in 2m54s
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m25s
Deploy / build-backend (push) Successful in 1m46s
Deploy / deploy (push) Successful in 1m10s
`_PHONE_RE` требовал префикс +7/8 + хотя бы один разделитель → copy-paste форма
«79221234567»/«89221234567» (самая частая) уходила в OpenAI как есть. `_FULLNAME_RE`
требовал строчные после первой буквы → стандарт ЕГРН/паспортных выписок «ИВАНОВ
ИВАН ИВАНОВИЧ» не матчил. СНИЛС без разделителей не покрывался вообще (`_SNILS_RE`
ждёт формат «NNN-NNN-NNN NN»). Для chat-пути scrub — единственный барьер перед
OpenAI (SafePayload.text без allowlist'а), при llm_enabled=True PII покидает РФ
(нарушение §19/152-ФЗ).

Patch:
- _PHONE_BARE_RE: (?<!\d)[78]\d{10}(?!\d) — 11-значные с префиксом 7/8.
- _SNILS_BARE_RE: (?<!\d)\d{11}(?!\d) — любые 11-значные без разделителей.
- _FULLNAME_RE: альтернатива в каждом слове — Titlecase | CAPS-2+.
- Конфликт phone-bare vs SNILS-bare разрешён порядком в _PII_PATTERNS: phone
  раньше SNILS (7/8-префикс семантически точнее).
- INN (ровно 10/12) с SNILS-bare не пересекается по длине.

8 новых юнит-тестов покрывают все три дыры + конфликт-резолюцию. 24/24 redaction
тестов + 55/55 LLM-suite зелёные.

Closes #1207
2026-06-13 09:56:58 +05:00
c8000d1089 test(parcels): integration EXPLAIN-gate для analyze hot-path SQL (#1198)
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m24s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m46s
Deploy / build-worker (push) Successful in 2m55s
CI / backend-tests (pull_request) Successful in 6m20s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m10s
PR #1195 ввёл CTE alias `overlaps` (PG keyword для time-period operator)
→ каждый POST /analyze падал с syntax error ~10ч до hotfix PR #1197. Mock-БД
в tests/api/v1/test_*.py не поймала: MagicMock на db.execute() не парсит SQL.

Расширяет существующий phantom-column gate (tests/integration/) на
_neighbors_summary и parcel_ird_overlaps SQL:

1. _NEIGHBORS_SUMMARY_SQL вынесен из inline-text в module-level constant
   в parcels.py (паттерн как в best_layouts/ird_overlay_lookup/krt_lookup).
   Заодно сконвертирован на canonical CAST(:wkt AS text) — backend.md rule.
2. EXPLAIN-тесты через phantom_check_session (SSH-туннель к prod-PG) —
   skip без TEST_DATABASE_URL, run в спец CI job.
3. Compile-time guards (всегда работают, без БД):
   - CTE-alias не должен быть PG-keyword (overlaps/user/current_date/select/where)
   - SQL не должен содержать :bind::type (psycopg v3 antipattern)

Поймал бы:
- #1195 (CTE alias overlaps) — оба EXPLAIN-тест + compile-time keyword guard
- :type recurring class — compile-time parametrized guard
- Phantom column / typo PostGIS function — EXPLAIN parses + plans

Refactor backward-compatible: _neighbors_summary использует ту же константу,
behavior идентичен. 26 unit-тестов parcels analyze продолжают зелёные.

Refs #1198
2026-06-13 09:43:49 +05:00
31c5974c72 fix(forecasting): нормализовать dict-district в build_site_finder_report (#1130 follow-up)
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m26s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-backend (push) Successful in 1m45s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m12s
`analyze["district"]` в этой кодовой базе — dict вида
{"district_name": "Верх-Исетский", "dist_to_center": 0.0, "median_price_per_m2": ...}.
Штатный caller (`workers/tasks/forecast.py:123`) явно извлекает `district_name`:
`district = row.district or analyze["district"]["district_name"]`. Но новые callers
(тесты, расширения чата, ad-hoc эндпоинты) легко передают сырой dict без знания этой
конвенции — тогда внутри §9.x-слоёв compute_market_metrics(district=<dict>) падает
с TypeError: unhashable type: 'dict' в forecast_request_cache.wrapper,
`_safe_call` это проглатывает → секции future_market.forecasts_by_horizon=[] и
scenarios.by_scenario={} тихо остаются пустыми (silent degrade, не 500).

Добавлен `_normalize_district(district)` — pure-нормализация на входе оркестратора:
  - str → как есть;
  - None → None;
  - dict с district_name (непустая строка) → извлекаем;
  - dict без district_name / с пустым / неподдерживаемый тип → None + logger.warning.

7 unit-тестов в test_orchestrator.py::TestNormalizeDistrict (все варианты входов).
Не меняет поведение штатного caller'а (str → str), только защищает от случайных
dict-callers.

Discovered through: #1130 Phase A (мой первый тестовый скрипт со скормленным
сырым `analyze["district"]` dict выдал forecasts.n_horizons=0 + 15 TypeError'ов
в _safe_call). Закрывает чип task_4a4aa3bb.

Refs #1130
2026-06-13 09:24:55 +05:00
2b23a4b24b fix(parcels): rename CTE overlaps → overlap_rows (#1196 regression of #1195)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m12s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m25s
Deploy / build-backend (push) Successful in 1m45s
`OVERLAPS` — это PostgreSQL keyword (binary operator для time-periods —
`(start1,end1) OVERLAPS (start2,end2)`). Парсер ругается на `overlaps AS (...)`
в WITH-блоке с

  ERROR: syntax error at or near "overlaps"

→ exception → транзакция в aborted state → весь analyze пайплайн ниже
получает InFailedSqlTransaction (ird_overlays, persist, ird block) → фронт
видит data_available=False, neighbors=[]. Воспроизводится на КАЖДОМ
/analyze с момента мерджа PR #1195.

Mock-БД в pytest tests/api/v1/ синтаксис не проверяет — отсюда регрессия
прошла CI. Прямой psql-replay на проде:
  WITH neighbors AS (...), overlaps AS (...) → syntax error
  WITH neighbors AS (...), overlap_rows AS (...) → ok

Минимальная переименование CTE alias (4 строки: 2 в SQL + 1 в SELECT
column alias + 1 в Python `row["overlap_rows"]`). Семантика идентична PR #1195.

Memory: future-proofing — добавить интеграционный тест с реальной PG для
analyze hot-path (отдельный issue, не блокер этого hotfix).

Refs #1196
2026-06-13 08:59:45 +05:00
49b85ab1d6 perf(site-finder): air-quality TTL-кэш + neighbors_summary 2→1 SQL (#1130 Phase B)
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m24s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
CI / changes (push) Successful in 6s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m24s
Phase B продолжает hot-path latency cut'ы analyze_parcel поверх Phase A (PR #1194):

1. get_air_quality_cached в weather_cache (третий TTL-слот рядом с forecast/climate):
   - hit-TTL 1h (hourly bucket Open-Meteo), negative-TTL 5min.
   - timeout 5s → 2s.
   - single-flight под per-cache threading.Lock.
   - Снимает 0.26с на cache-hit и до 5с при DNS-fail (тот же антипаттерн,
     что был у forecast/climate до Phase A).

2. _neighbors_summary: 2 SQL → 1 statement.
   - WITH neighbors AS (... LIMIT 30), overlaps AS (... LIMIT 5)
   - + COALESCE(json_agg(row_to_json(...)), '[]'::json) в основном SELECT.
   - Один round-trip вместо двух (~-47 ms на каждый analyze).
   - Семантика идентична: те же ST_DWithin(geom, point, 100m) для neighbors,
     ST_Intersects + ST_Area для overlaps. Anti-fakes guards (_COST_PER_M2_MIN/MAX,
     float() try/except, >50 m² overlap) сохранены.

Формат возвращаемых dict не меняется (frontend контракт). 5 новых юнит-тестов в
test_weather_cache.py (TestAirQualityCache: hot-hit, negative, empty-hourly,
separate-from-weather, ttl-expires). 7 файлов API-тестов обновили patch-цель
_fetch_air_quality_sync → get_air_quality_cached.

Refs #1130
2026-06-12 22:42:32 +05:00
97ff0c9e81 perf(site-finder): TTL-кэш Open-Meteo в analyze hot-path (#1130 Phase A)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 6m28s
CI / backend-tests (pull_request) Successful in 6m26s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-worker (push) Successful in 4m51s
Deploy / deploy (push) Successful in 1m24s
Два внешних HTTP-вызова в analyze (_fetch_weather_sync + _fetch_seasonal_weather_sync)
сидели без кэша. На проде в private network с restricted egress DNS до *.open-meteo.com
фейлит и каждый replay висит полный timeout (5s + 15s = до 20с лишних на analyze).
Профиль cProfile одного replay: 2.38с в httpx + 1.73с в SSL recv.

Вынес логику в app/services/weather_cache.py:
- TTL hit: forecast 6h, climate normals 7d. Negative-cache: 5min (DNS-fail не повторяет
  timeout на каждый analyze, восстановление подхватывается через ~5 минут).
- Ключ — округлённые до 0.01° (~1 км) координаты.
- Single-flight: per-cache threading.Lock + check-then-fetch-then-store.
- Тайм-ауты сокращены: forecast 5s→2s, climate 15s→3s.

Контракт для caller'а не изменился: None по-прежнему допустим (env_ok-флаг в
_build_environmental_score). 7 файлов API-тестов обновили patch-цели на новые имена
get_weather_cached / get_seasonal_weather_cached. 11 новых юнит-тестов в
tests/services/test_weather_cache.py (single-flight через threading.Barrier(16), TTL
expire через monkeypatch _now, изоляция forecast vs climate).

Refs #1130
2026-06-12 19:58:07 +05:00
d64a57cfcd Merge pull request 'fix(tradein): guard listing_segment во всех оценочных читателях listings — novostroyki вне comps (#1186)' (#1192) from fix/tradein-segment-guards into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 32s
Deploy Trade-In / build-backend (push) Successful in 1m2s
Deploy Trade-In / deploy (push) Successful in 40s
Reviewed-on: #1192
2026-06-12 09:06:28 +00:00
6d4dbd3e9d Merge pull request 'feat(market): GRANT listings + offer_price_history → gendesign_reader (#1191)' (#1193) from feat/market-grant-listings-reader into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been cancelled
Deploy Trade-In / test (push) Has been cancelled
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-browser (push) Has been cancelled
Deploy Trade-In / deploy (push) Has been cancelled
Reviewed-on: #1193
2026-06-12 09:06:18 +00:00
9ffadb9ea6 feat(market): GRANT listings → gendesign_reader — unit-level доступ SF (#1191)
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 6s
- listings + offer_price_history SELECT для gendesign_reader
- fail-fast DO-блок: RAISE EXCEPTION если роль не существует (м.101 не применён)
- НЕ грантятся listing_sources/snapshots/events (внутренний ETL, не нужен SF)
- Контракт: consumer фильтрует vtorichka канон-предикатом сам

Closes #1191
2026-06-12 12:04:41 +03:00
a9a489bba5 refactor(tradein): убрать dead listing_segment param из _fetch_anchor_comps + негативные guard-asserts (review fixup, #1186)
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
- Удалён параметр listing_segment: str | None = None из сигнатуры _fetch_anchor_comps
  (guard хардкожен в SQL-блоках Tier A/C, параметр не использовался)
- Убраны seg_counts / target_segment в caller-блоке SAME-BUILDING ANCHOR (~L2174)
- Docstring Tier C обновлён: «вторичка-канон guard (#1186): NULL = legacy вторичка»
- test_segment_guard_1186.py: добавлены 4 негативных assert'а:
  · no bare = 'vtorichka' без IS NULL OR
  · no deprecated <> / != 'novostroyki' form
  · _fetch_anchor_comps не содержит listing_segment в сигнатуре
  · Tier C не использует CAST(:segment AS text) (параметрическую форму)
2026-06-12 12:01:00 +03:00
b70ae46885 fix(tradein): guard listing_segment во всех читателях listings — novostroyki вне comps (#1186)
Канон-предикат: (listing_segment IS NULL OR listing_segment = 'vtorichka')
NULL = legacy вторичка до миграции 011 (sweep'ы были secondary-only) — отбрасывать нельзя.

Затронуты все SELECT-пути, влияющие на оценку и корректирующие коэффициенты:
- estimator.py: _COMMON_WHERE (Tier S/H), Tier W inline, Tier A anchor (добавлен впервые),
  Tier C anchor (заменён с параметрического CAST(:segment) на канон — прежний вариант
  отбрасывал NULL-строки при segment='vtorichka').
- asking_to_sold_ratio.py: ask_side, ask_global (_REDERIVE_SQL); bounds (_REDERIVE_BOUNDS_SQL);
  ask_tiered, ask_side_all, ask_global (_REDERIVE_TIERED_SQL) — 6 мест.
- sber_index.py: reconciliation-запрос (диагностический asking-median).

Не затронуты: скрейперы/scrape_pipeline, search matview (catalog display, не оценка),
admin stats, geocoding, cian price history, matching — обосновано в инвентаризации.

Тесты: новый test_segment_guard_1186.py (статика SQL + поведение mock); обновлён
test_asking_to_sold_ratio.py (subset-тест нормализует guard, добавленный в refresh но
отсутствующий в 080-seed).
2026-06-12 11:52:09 +03:00
9b06377df8 Merge pull request 'feat(sf): cross-load ETL tradein→gendesign newbuilding_listings — вариант A (#976)' (#1185) from feat/sf-newbuilding-crossload into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Successful in 27s
Deploy Trade-In / test (push) Successful in 32s
Deploy Trade-In / build-browser (push) Successful in 24s
Deploy / build-frontend (push) Successful in 29s
Deploy Trade-In / build-backend (push) Successful in 25s
Deploy Trade-In / deploy (push) Successful in 44s
Deploy / build-backend (push) Successful in 7m12s
Deploy / build-worker (push) Successful in 7m18s
Deploy / deploy (push) Successful in 1m47s
Reviewed-on: #1185
2026-06-12 08:29:54 +00:00
04da30b57a fix(sf): psql stdin-bootstrap пароля gendesign_reader + COALESCE-protect внешних id (review fixup, #976)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m21s
- Создан tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql:
  set_config GUC + format('%L') + \o /dev/null — зеркальный паттерн
  ops/db-bootstrap/set_tradein_fdw_password.sql. Пароль не попадает
  в stdout/logs (leak-protected).
- deploy-tradein.yml: шаг bootstrap переписан на stdin-redirect
  (< ops/db-bootstrap/set_gendesign_reader_password.sql) вместо сломанного
  -c "ALTER ROLE ... PASSWORD :'TRADEIN_READER_PASSWORD'", которое давало
  syntax error at ':' (psql не интерполирует :'var' внутри -c строки).
- newbuilding_crossload.py ON CONFLICT DO UPDATE: yandex_jk_id и
  cian_internal_house_id → COALESCE(EXCLUDED.col, table.col), чтобы
  cian-запись не затирала yandex_jk_id NULL'ом и наоборот.
- Тест test_upsert_sql_coalesce_external_ids проверяет наличие COALESCE
  в DO UPDATE секции для обоих id.
2026-06-12 11:26:40 +03:00
1a4778d006 feat(sf): cross-load ETL tradein→gendesign newbuilding_listings — вариант A (#976)
- docker-compose.prod.yml: backend + worker добавлены в сеть gendesign_shared
  (нужен прямой TCP к tradein-postgres для psycopg ETL)
- tradein-mvp/data/sql/101_gendesign_reader_role.sql: роль gendesign_reader
  (LOGIN, SELECT на houses/house_sources/houses_price_dynamics/
   house_reliability_checks/house_reviews; пароль через TRADEIN_READER_PASSWORD
   bootstrap в deploy-tradein.yml)
- .forgejo/workflows/deploy-tradein.yml: bootstrap ALTER ROLE gendesign_reader
  PASSWORD из env после миграций; warn (не exit 1) если переменная не задана
- backend/app/core/config.py: settings.tradein_database_url (env TRADEIN_DATABASE_URL)
- backend/app/services/etl/newbuilding_crossload.py: run_crossload() — psycopg
  server-side cursor, batch 500, UPSERT ON CONFLICT (source, ext_house_id),
  SAVEPOINT per-row; disabled-режим если url пуст
- backend/app/workers/tasks/etl_newbuilding_crossload.py: Celery task
- backend/app/workers/beat_schedule.py: nightly 03:30 МСК (00:30 UTC)
- backend/app/api/v1/admin_scrape.py: POST /admin/scrape/newbuilding-crossload
- backend/tests/services/test_newbuilding_crossload.py: 10 тестов без реальной БД

needs-human: TRADEIN_READER_PASSWORD → tradein .env.runtime;
             TRADEIN_DATABASE_URL → gendesign backend/.env.runtime
2026-06-12 11:18:47 +03:00
7a06839a0d Merge pull request 'feat(tradein): отдельный scraper-контейнер — scheduler вне API-процесса' (#1183) from feat/tradein-scraper-container into main
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 2m14s
Deploy Trade-In / build-browser (push) Successful in 4m45s
Deploy Trade-In / deploy (push) Successful in 1m30s
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / test (push) Successful in 35s
Reviewed-on: #1183
2026-06-12 08:01:23 +00:00
07b06617fd test(tradein): убрать мёртвый monkeypatch в test_scheduler_main (review fixup)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
2026-06-12 10:56:27 +03:00
72a4c122c4 feat(tradein): отдельный scraper-контейнер — scheduler вне API-процесса (#1182)
Причина: docker restart tradein-backend (при каждом API-деплое) убивал бегущие
sweep'ы avito/cian/rosreestr — они могут идти часами, и их прерывание середине
батча создавало zombie-runs и пропуски данных.

Что сделано:
- scheduler_main.py — standalone entrypoint для tradein-scraper; SIGTERM/SIGINT
  отменяют scheduler_loop чисто; GlitchTip init без Starlette/FastAPI интеграций.
- docker-compose.prod.yml — новый сервис scraper (тот же image, команда python -m
  app.scheduler_main, только tradein-net); SCHEDULER_ENABLE=false в backend.
- deploy-tradein.yml — селективный up -d --no-deps: scraper пересоздаётся только
  при изменениях scraper/infra путей (SCRAPER_CHANGED), не при каждом backend-деплое.
- tests/test_scheduler_main.py — импорт, kill-switch _should_run(), чистая отмена _run().
2026-06-12 10:50:50 +03:00
d7960276b1 chore(deploy): wire OWN_DEVELOPER_IDS into prod env for §25.3 own-portfolio (#1169)
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-worker (push) Successful in 32s
Deploy / build-frontend (push) Successful in 30s
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 35s
Deploy / deploy (push) Successful in 1m3s
Mirror the LLM_ENABLED wiring for OWN_DEVELOPER_IDS (own-portfolio
cannibalization, #1169): forward vars.OWN_DEVELOPER_IDS into the SSH deploy
(env block + envs list) and write it to backend/.env.runtime ONLY when
non-empty (conditional, like OPENAI_API_KEY/LLM_ENABLED). Non-sensitive
(public companyGroup ids) → actions variable. Unset → cannibalization keeps
the proxy (dormant). No literal id in the workflow (refs only).

Captures in the pipeline the value already set live on prod via .env.runtime
(OWN_DEVELOPER_IDS=6208 = PRINZIP, verified against domrf_kn → 28 projects).
Owner must set Forgejo Variable OWN_DEVELOPER_IDS=6208 for full pipeline
management; meanwhile the .env.runtime line holds (survives git reset).

Refs #1169
2026-06-09 18:14:12 +05:00
c9720a6212 feat(forecasting): activate §25.3 cannibalization unit-mix axis (4/4), gated (#1169)
All checks were successful
Deploy / build-worker (push) Successful in 3m1s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m32s
CI / backend-tests (pull_request) Successful in 6m24s
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m1s
Deploy / deploy (push) Successful in 1m25s
Feed candidate_unit_mix into _build_cannibalization (mirrors how #1169 fed
candidate_release_month from the launch window), completing §25.3 to all 4 axes:
class + price + timing + unit-mix, plus geo weight.

- candidate mix from recommend_mix "buckets[].share_pct" (same rule-based
  квартирография as §22 product_tz), extracted + normalized to {bucket: share}.
- _canonical_room_bucket folds recommend_mix RU labels ("1-к 30-45", "80+ м²")
  and manual own_planned_project Latin keys ("1k") into one room-count space —
  without it the L1 similarity would silently be 0 (disjoint keys).
- recommend_mix is HEAVY, so it's GATED: derived only when the own-portfolio has
  >=1 project with a non-empty unit_mix; get_own_portfolio fetched once in
  compute_special_indices and threaded into _build_cannibalization (no double
  fetch). With OWN_DEVELOPER_IDS unset (portfolio empty) → zero added cost on the
  hot §22 report path.
- Graceful (recommend_mix None/empty/raises → axis excluded, None-not-0),
  deterministic. Unit-mix only fires for manual-future own-projects with a mix
  (domrf-current carry unit_mix=None) — expected narrowness, documented.

205 tests; ruff + mypy clean. Scope: special_indices.py + test only; no deps.

Refs #1169
2026-06-09 16:09:29 +05:00
645b3c14a3 fix(chat): clean квартирография rendering in chat_system prompt (v2, #957)
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Successful in 6m24s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 3m5s
Deploy / deploy (push) Successful in 1m49s
CI / backend-tests (push) Successful in 6m24s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m8s
Live chat rendered the recommended unit-mix as a garbled rank-hyphen-type
list ("2-1-к", "3-2-к" = "rank 2: 1-к"). Add rule 6 to chat_system@v2:
list квартирография/segments by clean TYPE (студия / 1-к / 2-к / 3-к /
80+ м²) + share %, no ordinal-number-hyphen-type concatenation. Bumped
prompt version 1->2 (versioned-prompt convention). Test locks v2 + the rule.

Refs #957
2026-06-09 15:07:14 +05:00
98ecdecb5b chore(deploy): wire OPENAI_API_KEY + LLM_ENABLED into prod env for LLM chat
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-worker (push) Successful in 28s
Deploy / build-backend (push) Successful in 30s
Deploy / build-frontend (push) Successful in 28s
Deploy / deploy (push) Successful in 1m1s
Forward OPENAI_API_KEY (Forgejo secret) and LLM_ENABLED (Forgejo actions
variable) into the SSH deploy and write them to backend/.env.runtime ONLY
when non-empty, so the LLM chat (#960/#957) stays dormant
(llm_enabled=False, openai_api_key=None) until BOTH are set in Forgejo.
Mirrors the OBJECTIVE_API_KEY wiring (env block + envs list + idempotent
.env.runtime grep-sed-or-printf), but guarded by [ -n ] so an unset
secret/var writes nothing. No literal key (refs only). Compose header
documents both as optional. .env.runtime is shared by backend+worker+beat.

Refs #960
2026-06-09 13:51:41 +05:00
df9d54b532 feat(site-finder): add opportunity-ЗУ + red-line map layers (§12.1-13, #958)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m40s
Deploy / build-worker (push) Has been skipped
Deploy / deploy (push) Successful in 1m4s
Render the §12.1-13 geometry already exposed on /analyze but previously
dropped by the MiniMap adapter:
- Перспективные ЗУ (nspd_opportunity_parcels.geom_wkt) — viz-3 polygons
- Красные линии застройки (nspd_red_lines.geom_wkt) — warn dashed lines

Wired as toggles in the existing CpLayerControlPanel (Рынок group),
reusing wkt.ts (extended with LINESTRING/MULTILINESTRING for red lines).
Empty/invalid geometry renders nothing gracefully; popups RU plain-text.

§22 forecast (future_market/special_indices), ППТ-ПМТ planning polygons
and future-ЖК points carry no map-able geometry on the frontend yet — left
as backend follow-ups, not faked.

Refs #958
2026-06-09 07:19:53 +00:00
cd48a095c0 feat(forecasting): activate §25.3 cannibalization timing axis from launch window (#1169)
Some checks failed
Deploy / build-worker (push) Successful in 2m32s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m32s
Deploy / deploy (push) Has been cancelled
Derive candidate_release_month = report-as-of (date.today()) + §25.1 Launch
Window peak-deficit horizon, threaded into _build_cannibalization so the timing
overlap axis activates against own-portfolio release_month (near-in-time own
projects raise cannibalization risk). Launch Window now computed once in
compute_special_indices and reused (no double-compute). Launch Window
unavailable -> candidate_release_month None -> timing axis gracefully excluded
(None-not-0); cannibalization still scores on class/price/geo. Adds stdlib
_add_months helper (year-boundary safe, no new dep). Deterministic. 168 tests.

§25.3 now: class+price+timing+geo active; unit-mix remains phase-2.

Refs #1169
2026-06-09 07:10:59 +00:00
f9e045028c feat(macro): land construction price index (СМР) via open Rosstat xlsx (#946)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 6m27s
Deploy / build-frontend (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / backend-tests (push) Successful in 6m29s
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m43s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m15s
Closes the last #946 gap (СМР). Fetches «Индексы цен производителей на
строительную продукцию по РФ» from the OPEN Rosstat xlsx
(rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_MM-YYYY.xlsx),
parses the monthly «к предыдущему месяцу» section → macro_indicator
(indicator_type='construction_price_index', region='rf', monthly, %).

The reCAPTCHA-gated fedstat dataGrid.do path is DELIBERATELY NOT used —
the open Rosstat source needs no captcha. EMISS id=31108 documented as a
prod-only SDMX alternative (not wired).

URL is date-derived with bounded look-back (filename changes monthly;
_fetch_construction_latest walks current month back to 6, first 200 wins;
year-boundary handled, exhaustion raises, 404 breaks fast — no hang).
Wired into rosstat_macro_sync with per-source try/except (can't break
demography/income) + SAVEPOINT-per-row. None-not-0 on blank months.
33 tests (incl look-back + year-boundary + graceful), ruff clean.

Note: no construction-cost channel in macro_coefficient yet (demand-side
only) — series is ingested/available for future cost-side use + display.

Refs #946
2026-06-09 11:28:05 +05:00
4adfef2ad0 feat(site-finder): grounded parcel-chat panel UI (#958)
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 45s
CI / frontend-tests (pull_request) Successful in 42s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m43s
Deploy / deploy (push) Successful in 1m4s
CI / changes (pull_request) Successful in 6s
Chat panel on the analysis screen consuming POST /api/v1/chat/ask:
preset-intent quick-buttons (summary/what_to_build/why_forecast/risks/
scenarios), history-aware turns (cap 10), advisory badge + grounded_in
provenance + llm_used marker. Plain-text answer rendering (no HTML
injection); loading/pending/error states with retry. TanStack useMutation
(no useEffect-for-HTTP), tokens-only, Lucide icons, TS strict. Types inline
(codegen backend unreachable in env); no new dependency.

Refs #958
2026-06-08 18:08:01 +05:00
fceaaf9a2c feat(chat): LLM tool-loop + §19 redaction wiring for #957 (Step 2+3)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m41s
CI / backend-tests (push) Successful in 6m24s
CI / backend-tests (pull_request) Successful in 6m23s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m12s
Add the LLM prose-composition path for the parcel-forecast chat, layered
over the deterministic Step-1 fallback which stays the safety net.

- chat/tools.py: 5 read-only section tools (exec_summary, product_recommendation,
  forecast, risks, scenarios) — pure slices of the loaded report dict, no DB/
  recompute, graceful on missing sections. market_now (raw analyze blob) and meta
  are deliberately NOT exposed -> highest-PII data cannot reach the LLM.
- chat/safe_payload.py: the §19 gate — single place that builds the outbound
  SafePayload from a section-aggregate allowlist; honors is_confidential hard-block.
- chat/orchestrator.py: manual tool-call loop with call-cap/termination, real
  grounded_in provenance; any LLMResult.ok=False (disabled/timeout/rate_limited/
  redaction_refused/call_cap/provider_error/empty) degrades to the deterministic answer.
- llm/prompts.py: versioned chat_system@v1 — answer only from sections, never
  fabricate numbers, advisory tone, decline out-of-scope.
- api/v1/chat.py: branch on settings.llm_enabled; sync complete bridged via
  run_in_threadpool. Default-off -> deterministic path, no provider built.
- Tests: fake provider only (no network), planted-secret redaction-boundary +
  per-reason fallback + call-cap + numbers-from-report coverage.

Refs #957
2026-06-08 17:45:01 +05:00
4847a2eb01 feat(chat): deterministic §22 chat foundation — POST /api/v1/chat/ask (no LLM)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Successful in 6m23s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m43s
Deploy / deploy (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 7m39s
Step 1 of #957. Answers parcel questions by reading the already-persisted
§22 SiteFinderReport (latest_run_for, schema "1.0", read-only) and returning
templated RU answers with engine numbers verbatim (§16, never fabricated).
Intent routing (explicit or RU keyword match) -> per-section renderers;
graceful on partial/missing sections and pending (no run / DB error) without
500s. Works with llm_enabled=False (llm_used always False); LLM composition
is Step 2. Mounted off /api/v1/chat so rbac_guard auto-requires an authed
known user.

Refs #957
2026-06-08 17:21:51 +05:00
347203dfda feat(forecasting): §25.3 TRUE own-portfolio cannibalization overlap (#1169 PR2)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 6m26s
CI / backend-tests (pull_request) Successful in 6m21s
Deploy / changes (push) Successful in 6s
Deploy / build-worker (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m41s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m47s
Upgrade the §25.3 cannibalization index from a same-class-competitor proxy to
true own-portfolio overlap: score the recommended candidate segment against the
developer's own portfolio (get_own_portfolio, #1169 PR1) across axes —
audience/class (ordinal distance), price ₽/м² (interval overlap), unit-mix
(L1 similarity), timing (half-life decay) — geo-weighted by haversine proximity
to the parcel, aggregated by geo-weighted soft-max (the strongest nearby
cannibalizer dominates, not a diluting mean). Empty portfolio -> labelled proxy
fallback, confidence forced low, never presented as the true index.

Pure scoring fns unit-tested without DB; None-not-0 on missing axes; thin/
only-current portfolio -> low confidence + §26 note; deterministic (sorted
tie-breaks, no RNG). class+price+geo active now; unit-mix+timing plumbed via
optional params for a follow-up that wires them from the orchestrator horizon.
ruff + mypy clean; 151 special-index tests pass (964 forecasting dir, no regr).

Refs #1169
2026-06-08 16:47:18 +05:00
df34e55ab4 feat(site-finder): own-portfolio data source for §25.3 cannibalization (#1169 PR1)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m12s
Foundation PR: unified "our projects" source the §25.3 overlap engine (PR2)
will consume. Two origins normalized to OwnProject (class/timing/price/unit-mix):
- current <- domrf_kn_objects filtered by settings.own_developer_ids (numeric
  prefix of composite dev_id; empty -> [] graceful, no DB hit, no hardcoded id)
- future  <- new manual-entry own_planned_project entity (migration 148)

Adds OWN_DEVELOPER_IDS config (comma-sep -> list[int], default []),
own_planned_project table (range/unit_mix CHECKs via IMMUTABLE helper, generated
geom), /api/v1/own-projects CRUD (created_by from X-Authenticated-User), and
get_own_portfolio(db). Per-source graceful degradation; psycopg-v3 CAST clean.

Does not touch special_indices.py or parcels.py (out of scope).

Refs #1169
2026-06-08 16:21:53 +05:00
4af7ba5a40 feat(llm): foundational LLM infra package with §19 redaction + deterministic fallback (#960)
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m20s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m13s
CI / changes (push) Successful in 5s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Optional external-OpenAI layer over the deterministic forecasting engine. Gated by
llm_enabled (default False) so prod makes no network calls until deliberately enabled.
Allowlist-first SafePayload contract + is_confidential hard-block + RU-PII regex scrub
(mandatory on the external path). Abstract LLMProvider seam (is_external) for a future
RU-hosted provider. Sync httpx core (Celery-friendly); tool/function-calling pass-through;
timeout + bounded 429/5xx retry + per-request call cap, all degrading to fallback.
Raw httpx (no openai SDK -> no pyproject/lock drift). 47 tests, ruff + mypy clean.

Refs #960
2026-06-08 15:44:16 +05:00
4b2e7d9af8 fix(db): sync location.demand_index comment to city-relative (#948)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m27s
CI / backend-tests (pull_request) Successful in 6m26s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m52s
Deploy / build-worker (push) Successful in 13m20s
Deploy / deploy (push) Successful in 1m18s
The demand_index DB column comment still said "насыщающее преобразование"
(saturating) after #1167 switched it to city-relative normalization —
exactly the misleading-comment class that hid the original bug. Migration
147 updates COMMENT ON COLUMN (idempotent, comment-only, no data DDL).

Refs #948
2026-06-08 14:04:51 +05:00
379af88424 fix(site_finder): make Location demand_index city-relative (#948)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m22s
demand_index used a fixed clamp01(velocity/50); on a live prod refresh
all 8 ЕКБ districts sold ≥50/mo so it saturated to 1.0 everywhere — zero
discrimination between districts. Redesign to mirror infra_index:
normalize each district's unit_velocity against the city reference (MAX
district velocity per refresh run), so demand always discriminates and
self-calibrates as the market grows (no magic constant to rot).

- normalize_demand(velocity, *, city_reference_velocity), pure + graceful
  (None stays None; reference<=0 -> honest 0.0, no ZeroDivisionError)
- refresh_locations now two-pass: collect velocities (one
  compute_market_metrics per district, no O(n^2)), derive city reference,
  normalize + upsert; SAVEPOINT-per-row and counters preserved
- remove _DEMAND_SATURATION_UPM constant; log city_reference_velocity
- tests: rewrite demand normalization + add end-to-end city-relative
  suite incl. discrimination regression guarding the all-1.0 prod bug

Refs #948
2026-06-08 13:58:12 +05:00
8da1c00138 feat(location): district-level Location entity + indices (#948 part B)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m11s
CI / backend-tests (push) Successful in 6m27s
CI / backend-tests (pull_request) Successful in 6m22s
Promote district to a first-class `location` entity (ТЗ §8.2), ADDITIVE — no
district->FK refactor. New `location` table keyed by district_name (joinable by
string), carrying 4 normalized [0,1] indices (NULL when no data, never 0):
- infra: _district_poi_score / _city_avg_poi_score (per-district POI aggregate)
- competition: market_metrics.overstock_index (available/stuck competing supply
  — orthogonal to demand; NOT sell-through, which is market-heat correlated w/ demand)
- demand: market_metrics.unit_velocity (saturating /50)
- future_supply: future_supply_pressure.index (passthrough, already 0..1)

- data/sql/146_location.sql: idempotent table + UNIQUE(district_name) + range
  CHECK + centroid GIST
- services/site_finder/locations.py: compute_location_indices (reuses forecast
  per-district fns) + refresh_locations (SAVEPOINT per-row, CAST, ON CONFLICT)
- workers/tasks/location_refresh.py + beat (Mon 07:00 MSK, after supply-layers)
- api/v1/locations.py: read-only GET list + GET by name (analyst+admin via rbac,
  frontend pilot-gated)
- tests: 34 (normalization 0..1/null, competition⊥demand orthogonality, idempotent
  upsert, read API list/by-name/404)

Part of #948 (Part A insight shipped #1164).
2026-06-08 13:28:19 +05:00
6400ebde24 feat(insights): manual-entry Insight entity CRUD + §19 audit (#948 part A)
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m22s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m32s
Insight = аналитик пишет free-form intel про локацию/участок с пометкой
«непублично» (is_confidential, §7.13/§8.9). CRUD под /api/v1/insights;
created_by из X-Authenticated-User (не из тела — спуфинг автора невозможен).
Зеркало custom_pois: raw-SQL service + Pydantic v2 schemas, sync def handlers
(threadpool, off event loop).

- data/sql/145_insight.sql: table insight (idempotent, geom GENERATED из lon/lat,
  индексы district/cad_num/is_confidential/created_by/GIST); аддитивно расширяет
  CHECK audit_log.action += insight_write (тот же constraint ck_audit_log_action,
  to_regclass-guarded, superset — не ломает #962-аудит; НЕ правит applied 144).
- audit_middleware.classify_path: method-aware insight_write для POST/PUT/DELETE
  /api/v1/insights (GET-чтения не аудируются); обратно совместимо с parcels.
- ACL: backend rbac_guard hard-блокирует только /api/v1/admin/*; pilot отсекается
  frontend RouteGuard + Caddy (как parcels/custom_pois). is_confidential — стораемая
  пометка без per-record backend-гейта (analyst видит, per #962-политике).
- tests: 28 insight (CRUD+filters+required+#261 commit) + 5 audit. 86 зелёных, ruff.

Part B (Location first-class, §8.2) — отдельный follow-up.

Refs #948.
2026-06-08 12:41:39 +05:00
86828d0388 feat(rbac): add analyst role + §19 audit-log middleware (#962)
Some checks failed
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy Trade-In / changes (push) Successful in 7s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 36s
Deploy / build-backend (push) Successful in 1m39s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 48s
Deploy / build-worker (push) Successful in 2m41s
Deploy / deploy (push) Failing after 4s
Deploy Trade-In / deploy (push) Successful in 47s
CI / backend-tests (push) Successful in 6m38s
CI / backend-tests (pull_request) Successful in 6m30s
EPIC18/§19. analyst sees everything (deals, insights, exports, site-finder,
analytics, concept) EXCEPT admin/data-management. Enforcement is backend-hard
(the existing rbac_guard already 403s any non-admin role on /api/v1/admin/*, so
adding the role auto-blocks it) + frontend (deny_paths via /me) + audit.

§19 audit: new best-effort HTTP middleware logs the sensitive actions
(analyze / forecast / forecast-export) to a new audit_log table after the
response. Audit failures never break or delay-fail the request (2-layer
try/except + finally close). Registered INNER to rbac_guard so only authorized
requests are audited (a 403 short-circuits before audit). classify_path matches
export before forecast (anchored).

- auth/roles.yaml: analyst role (paths /**, deny admin-mgmt) + analysttest QA user
- core/auth.py (+ tradein mirror): Role Literal += analyst
- core/audit_middleware.py (new) + main.py registration
- data/sql/144_audit_log.sql (idempotent; auto-applies)
- tests: analyst rbac (403 admin / 200 parcels) + 11 audit cases

No data-level ACL (analyst sees data per policy) -> no #948 dependency. No new
deps. parcels.py untouched. Real analyst logins still need adding to
caddy/users.caddy.snippet (devops).

Refs #962.
2026-06-08 12:16:19 +05:00
25e21c2bff feat(macro): CBR inflation (ИПЦ YoY) -> macro_indicator + activate §9.5 channel (#946)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
CI / backend-tests (push) Successful in 6m30s
CI / backend-tests (pull_request) Successful in 6m32s
fedstat ИПЦ is reCAPTCHA-blocked; CBR publishes inflation openly. Add
fetch_inflation + parse_inflation_xlsx (CBR UniDbQuery DownloadExcel/132934,
monthly % г/г, region=rf, source=cbr) to cbr_macro.py; upsert
indicator_type=inflation_yoy via the existing cbr_macro_sync task (per-series
guard, SAVEPOINT-per-row, CAST not ::, ON CONFLICT on the PK).

Surface inflation_yoy in MonthlyMacro (frozen, carry-forward) and ACTIVATE the
reserved §9.5 inflation channel (macro_coefficient f_inflation: level-vs-4%-target
nudge, non-positive to avoid double-counting f_rate, excluded from
_RATE_DRIVEN_FACTORS). Channel was DEGRADED (no data) -> now BACKED + consumed;
_CONF_HIGH_MIN_BACKED 4->5. Deterministic (§16/§26); renorm claims the reserved
0.08 slice as designed. Live-verified (2026-04 5.58%); 194 macro + 902 forecasting
tests green. No migration, no new deps.

Refs #946.
2026-06-08 11:41:14 +05:00
b0fe292a63 fix(tradein): working cian ЖК-url resolver via cat.php SERP (#972)
All checks were successful
CI / changes (push) Successful in 6s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
Deploy Trade-In / test (push) Successful in 31s
Deploy Trade-In / build-backend (push) Successful in 1m20s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 46s
The legacy resolve_cian_zhk_url hit /zhk/<id>/ which now 404s, leaving the
318 geo-matched cian houses (house_sources.ext_id set, cian_zhk_url NULL)
unfetchable -> the newbuilding-enrichment backfill matched 0 houses.

Add resolve_cian_zhk_url_via_search(nb_id): fetch the cat.php newbuilding SERP
and extract the canonical zhk-<slug>.cian.ru url, ANCHORED on the
<h1 data-name="Title"> header anchor (NOT naive first-match — the SERP carries
promo/recommendation zhk-* links before the title that would otherwise resolve
the wrong ЖК and silently corrupt enrichment). Validated against the real
2.81MB prod SERP + an adversarial poisoned-recommendation test.

Wire into newbuilding_enrich_backfill: broaden selection to "has ext_id OR
cian_zhk_url", resolve+persist the url under a SAVEPOINT before enriching,
rate-limited + resumable + idempotent. Keep the old resolver (deprecated).

Bounded prod proof (5 houses, direct): 5/5 urls resolved+persisted, 3/5 fully
enriched (+18 price_dynamics, +3 reliability); the 2 misses were direct-mode
anti-bot on the 2nd fetch. Full 318-run gated on the cian mobile proxy
(mproxy.site) being restored. code-reviewer APPROVE (SQL/idempotency) +
resolver hardened against wrong-ЖК. 19 tests green.

Refs #972.
2026-06-08 11:13:06 +05:00
8206a0b067 perf(forecast): per-request memoization cache for §22 cold build (#1129)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m49s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m45s
Deploy / deploy (push) Successful in 1m10s
Cold §22 forecast measured ~215-233s on prod: §9.x layers re-execute the same
horizon/segment-invariant DB loads with identical args hundreds of times per
report (profiled: get_competitors x69, market_metrics x124, get_monthly_macro
x290). Add a per-report ContextVar cache (forecast_cache(), opened once in the
orchestrator) + @cached(key_builder) on the expensive §9.x loaders so each
unique load runs ONCE and reuses the same frozen, read-only instance.

Output is byte-identical (memoized producers are frozen dataclasses / read-only
Pydantic, callers never mutate; cache is per-report, discarded on exit; no-op
outside the report build). No concurrency, no signature changes.

- forecast_request_cache.py: ContextVar cache + cached() decorator (no-op
  outside context, reentrant, _MISS sentinel for cached None)
- @cached on competitors/future_supply/market_metrics/macro_series/
  sales_series/macro_coefficient/demand_normalization/regression loaders
- orchestrator: wrap build_site_finder_report in forecast_cache()
- 58 tests: key discrimination (call-counting regression guard), no-op-outside,
  per-report isolation, reentrancy, frozen-producer canary, amplification proof
  (real get_monthly_macro xN->1)

code-reviewer APPROVE (keys correct, mutation-safe, output identical). 1265
forecast/cache tests green. No new deps. Refs #1129.
2026-06-08 05:26:27 +00:00
f5dcd9dc2b feat(sf): врезка pat_subzones в analyze ИРД-блок (#1158)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m27s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m30s
Deploy / deploy (push) Successful in 1m18s
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 17:59:50 +00:00
3921180d07 fix(sf): ST_MakeValid genplan geom + backfill 881 invalid polygons (#1157)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 17:59:04 +00:00
515ac89eaa fix(sf): робастный путь к koltsovo JSON в pat-loader (#1155)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m9s
Deploy / build-worker (push) Successful in 3m23s
Deploy / deploy (push) Successful in 1m8s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 15:34:10 +00:00
08acc25c43 Merge pull request 'feat(sf): OCR pipeline izjatie EKB Tesseract rus land_reservation 1062' (#1153) from feat/izyatie-ocr-pipeline into main
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 3m8s
Deploy / build-worker (push) Successful in 4m0s
Deploy / deploy (push) Successful in 1m34s
Reviewed-on: #1153
2026-06-07 15:21:20 +00:00
fe67e60d48 feat(sf): граддокументация (статус+ПАГЕ-реквизиты) из planning_projects в analyze (#1154)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m33s
Deploy / deploy (push) Successful in 1m9s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 15:15:03 +00:00
5da4271235 feat(sf): OCR-пайплайн изъятия ЕКБ (Tesseract rus) → land_reservation (#1062)
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m19s
- Dockerfile: добавлены tesseract-ocr tesseract-ocr-rus в runner apt-слой
- pyproject.toml: pymupdf>=1.24 (рендер PDF без poppler) + pytesseract>=0.3.13
- izyatie_client.py: list_izyatie_documents() + fetch_pdf() с екатеринбург.рф
- izyatie_ocr.py: ocr_pdf_text() (PyMuPDF→PIL→Tesseract rus) + extract_izyatie_records()
  с нормализацией OCR-шума (пробелы в кад-номерах, кирилл. О→0, б→6)
- izyatie_ocr_ingest.py: Celery task → land_reservation UPSERT (SAVEPOINT per-row)
- beat_schedule.py: izyatie-ocr-ingest-weekly (пятница 07:00 МСК)
- 33 теста, все зелёные; без реального Tesseract/сети в CI
2026-06-07 18:07:06 +03:00
7af87c338c feat(sf): вшить parcel_okn_objects (okn_objects) в ИРД-блок analyze (#1152)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m34s
Deploy / deploy (push) Successful in 1m9s
Deploy / build-backend (push) Successful in 2m13s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 15:00:44 +00:00
9736192359 feat(sf): подзоны ПАТ Кольцово → pat_subzones (#1150)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m25s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m26s
Deploy / deploy (push) Successful in 1m11s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 14:43:07 +00:00
e2faab801c fix(sf): pravo.gov66 law_type числовой код =1 Постановление (#1149)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m21s
Deploy / deploy (push) Successful in 1m15s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 14:17:52 +00:00
a6e100b52a fix(sf): корректное urlencode form-data в okn_egrkn_client (#1147)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m14s
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 14:13:34 +00:00
bcb1285341 feat(tradein): backfill task for cian newbuilding enrichment (#972)
All checks were successful
Deploy Trade-In / changes (push) Successful in 7s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 46s
Deploy Trade-In / test (push) Successful in 37s
Deploy Trade-In / build-backend (push) Successful in 45s
950-E1: app/tasks/newbuilding_enrich_backfill.py selects houses linked to
ext_source='cian_newbuilding' with a fetchable cian_zhk_url and runs the existing
fetch_newbuilding + save_newbuilding_enrichment over each, populating
houses_price_dynamics + house_reliability_checks (+ house_reviews when present).

- Idempotent + resumable: skips already-enriched houses; force= re-run dup-safe
  (price_dynamics UPSERT via dim_key, reliability dedup guard with id-DESC
  tiebreaker, reviews ON CONFLICT (source,ext_review_id)).
- Anti-bot aware: get_scraper_delay('cian') + jitter, SAVEPOINT-per-house
  (one captcha/failure logs + continues, never aborts the batch).
- limit= for bounded proof runs; sizing counters (total/fetchable/pending).

Fix cian_newbuilding._extract_transport_rate: cian transportAccessibilityRate
drifted to a nested dict and broke save_newbuilding_enrichment's CAST bind on
LIVE data (cannot adapt type 'dict') — coerce dict/float/bad -> int|None
(bool excluded before int). Fixes the live enrichment crash, not just backfill.

house_reviews stays ~0 for now: cian reviews are in a separate XHR, not the ЖК
initialState — extending fetch_newbuilding for them is a documented follow-up.

Tested: 8 new unit tests; isolated-DB proof-run landed 6 price_dynamics + 1
reliability row, idempotency proven across re-runs. Refs #972.
2026-06-07 14:09:24 +00:00