_compute_confidence docstring claimed "stub до G1/G2/D1/D2" — те эшелоны уже
отгружены, формулировка устарела. Это намеренно отдельная метрика: надёжность
site-finder скоринга (coverage POI/район/рынок), НЕ форсайтный §15
confidence_engine (надёжность прогноза спроса/цены над форсайт-входами,
которых в analyze hot-path нет). Поведение не меняется — резолв документацией.
Closes#1668
Replace bare metro_block placeholder ({"nearest_top3": None}, blocked on a
never-merged 22h metro scraper) with a direct KNN query against osm_poi_ekb
category='metro_stop' (loaded by poi_loader). Returns 3 nearest stations with
name + rounded distance_m; empty list when none nearby (vs None on error),
SAVEPOINT + try/except like adjacent SF-B5 blocks. No frontend consumer yet.
Closes#1667
#1244 (security): внешние/скрейпинг-строки (comm_name из DOM.РФ, headline/usp_text)
с ведущим = + - @ \t \r писались как есть → openpyxl сохранял как формулы
(data_type='f'), исполнялись при открытии в Excel/LibreOffice. _sanitize_formula
префиксует такие строки апострофом (OWASP CSV-injection escape); числа/даты/bool
не трогаются. _write_kv labels тоже санитизируются. Подтверждено на openpyxl 3.1.5.
#1245 (concurrency): async ask() вызывал sync get_report_for_chat() (sync SQLAlchemy
тянет крупный JSONB §22-отчёт) напрямую — блокировал event loop, в отличие от
LLM-ветки (run_in_threadpool). Обёрнуто в run_in_threadpool.
Closes#1244Closes#1245
API отвергал ?horizon=24 (422), хотя ТЗ §12.1 называет 6/12/18/24, а движок
УЖЕ считает 24 на каждом ране: _DEFAULT_HORIZONS=(6,12,18,24) во всех 6 точках
стека (orchestrator/forecast-task/demand_supply_forecast/scenarios/
special_indices/report_assembler), PIPELINE_HORIZON_MONTHS=24.
_hidden_release_fraction клампит h/18→1.0 на 24 (без переполнения),
future_supply._horizon_weight расширяет окно чисто — скрытых ≤18 потолков нет.
Чистое расширение валидатора-enum, не новая математика.
Backend: _ALLOWED_FORECAST_HORIZONS → {6,12,18,24}, Query/docstring/error-msg.
Frontend: HorizonSelector HORIZONS=[6,12,18,24] (тип horizon=number, union не нужен;
прочие потребители data-driven через meta.horizons/forecasts_by_horizon).
Тесты: API принимает 24/отвергает 30; движок-тесты доказывают h=24 осмыслен
(поля посчитаны, demand(24)>demand(18), hidden созрел, индексы в диапазонах).
Closes#944 (Q1 горизонт 24)
parcel_ird_overlaps ловил только (OperationalError, ProgrammingError).
При D9b-wiring в analyze malformed WKT в ST_GeomFromText давал PostGIS
ERROR → SQLAlchemy DataError, который пробивал try/except → analyze
падал вместо graceful-degrade.
- ird_overlay_lookup: + DataError в imports и в except tuple.
- quarter_dump_lookup: симметричное расширение в _get_engineering_*.
- test_ird_overlay_lookup: parametrized test_graceful_when_db_fails
покрывает 3 класса (Operational/Programming + DataError для WKT).
Closes#1095
DemandSupplyForecast.as_dict() не эмитил 'confounded'/'is_confounded_window',
report_assembler._confounded() всегда возвращал False и §15 confounded_window
factor в compute_report_confidence был мёртв: 48-мес окна, пересекающие
2024-07-01 шок никогда не тянули report confidence к 'low' и шок не назывался
в rationale.
Patch: добавлено confounded: bool в DemandSupplyForecast (от §9.5 macro_coef
OR §9.6 rate_sensitivity), exposed в as_dict(). _confounded() уже использовал
.get() defensively — блокер был в producer'е.
+3 теста: contract на real DemandSupplyForecast.as_dict(), end-to-end
assemble_report → confounded_window factor surfaces at level=low, weakest-link
тянет overall к 'low'. 61 report_assembler + 1034 forecasting тестов зелёные.
Closes#1222
domrf_kn_objects/sales_agg хранят ~3 snapshot'а на obj_id (weekly UPSERT,
retention=none). JOIN без latest-фильтра считал каждый ЖК N раз:
sold_median тянулся старыми perc → sat_factor занижен, гейт n>=5
проходил уже при 2 ЖК (n=6), sat_n завышен ~×N в scope. Сданные ЖК
просачивались через старые снапшоты со статусом 'Строящиеся'.
Patch: AND a.snapshot_date = (SELECT MAX(snapshot_date) FROM ...) для
обеих таблиц (зеркало #1210/#1212). COUNT(*) → COUNT(DISTINCT a.obj_id)
defensive. 29 recommend_mix + 8 saturation тестов зелёные.
Closes#1221
Loop UPSERT в sync_noise_sources_to_db без begin_nested: один замкнутый
3-точечный natural=water way [A,B,A] даёт POLYGON((A,B,A)), PostGIS
отвергает (< 4 точек в ring) → outer tx rollback + raise → весь weekly
noise/water/utility sync падает, тот же way отравляет каждый прогон.
- Оборачиваем каждый UPSERT в `with db.begin_nested():` + per-row
try/except → logger.warning + skipped++ (канон pzz_loader.py:111).
- В _way_to_polygon_wkt проверяем итоговое кольцо ≥ 4 точек (fail-safe).
- Outer except: добавлен logger.exception для видимости.
Closes#1231
UPDATE без проверки rowcount затрагивал 0 строк для несуществующего
job_type; get_one возвращал hardcoded _fallback. PUT отвечал 200,
админ считал настройку сохранённой — но она терялась (новый job_type
до seed-миграции, опечатка в path).
Patch: update() проверяет result.rowcount: при 0 делает INSERT с
переданными колонками (непереданные → дефолты таблицы из м.81).
INSERT использует ON CONFLICT (job_type) DO NOTHING + повторный
UPDATE как защита от гонки. Поведение для существующих строк не
изменилось. 8 новых тестов (6 service + 2 API).
Closes#1223
ProductScore.reason для price_feasibility (§14.2) называл основу платежа
'субсид. ставка', но compute_affordability с #981 DoD перешёл на рыночный
прокси key_rate + 4.5 п.п. (~19%, rate_kind='key_rate_proxy'); субсидированный
путь не вызывается. Метка делала рыночный платёж в §22-отчёте похожим на
льготный и противоречила affordability.degraded_reason в том же payload.
Текстовый фикс: 'рыночная ставка key_rate + спред, §7.9' — число платежа
было корректно, advisory-описание теперь совпадает.
Closes#1225
Degenerate price band (own_min==own_max или c_lo==c_hi, оба разрешены
CHECK 148 и Pydantic) внутри другой вилки возвращали 0.0 вместо 1.0:
фильтр w>0 выкидывал нулевую ширину → 0/positive=0.0. Это рвало
докстринг 'полное накрытие узкого = 1.0' и давало разрыв:
[148k,152k]→1.0 vs [150k,150k]→0.0, занижая среднее каннибализации.
Patch: вырожденные ширины обрабатываются ДО нормирования.
lo<=hi → точка внутри другой вилки → 1.0, вне → 0.0. +inf-обе-премиум
ветка перенесена в начало (избежать inf-inf=nan). +7 новых тестов в
TestPriceOverlap. 220 special_indices тестов зелёные.
Closes#1224
Celery conf.timezone=Europe/Moscow трактует crontab MSK-relative, но
ekburg-permits, pzz-sync, refresh-quarter-price-index и
nspd-harvest-stale-quarters были UTC-числа и бежали 3ч раньше.
Особенно refresh-quarter-price-index стрелял в 02:00 МСК — ДО
refresh-ekb-districts-medians в 04:00 МСК — ломая контракт
"runs after medians settled". Switched to MSK targets:
ekburg-permits 05:00 1-го, pzz-sync 03:00 1-го, refresh-quarter-price-index
05:00 5-го, nspd-harvest-stale-quarters 04:00 Пн. ruff clean.
Closes#1233
begin_nested() лишь RELEASE'ит SAVEPOINT — без db.commit() все UPDATE
цен откатывались при db.close() в Celery task. Зеркалит правильный
паттерн scrape_catalog_objects (domrf_catalog_object.py:460): try-
commit/except-rollback в конце функции. 50 catalog тестов зелёные.
Closes#1227
Литерал regex был разорван переносом строки внутри одной пары '...':
ветки 'Донбасской\\n' и 'Лумумбы\\n' содержали NL+8 пробелов
и никогда не матчили однострочные planning_projects.full_name (PG ~ —
POSIX ARE без (?x)). Geom-only КРТ-fallback молча терял ППТ по этим двум
топонимам.
Patch: вынес паттерн в _KRT_TOPONYM_REGEX через Python adjacent-literal
concat, склеил в SQL через +. Все 17 веток в одной строке.
9 krt_lookup тестов зелёные.
Closes#1230
scrape_objective.sync_objective_group инкрементировал n_requests и reports_ok
ДО HTTP-стрима client.stream_report(...). Любая ошибка стрима (ObjectiveAuthError,
ObjectiveAPIError — оба ⊂ RuntimeError ⊂ Exception, ijson-сбой, обрыв сети)
проглатывалась generic except Exception в lots_pf-ветке без инкремента
reports_failed и без re-raise. Внешний except (ObjectiveAuthError|APIError)
был НЕДОСТИЖИМ для lots_pf. _finish_run(status='done' if reports_failed==0)
помечал run УСПЕШНЫМ при полностью провалившемся главном 600МБ отчёте «Лоты».
objective_lots/history оставались stale молча, админка показывала last_run_at,
downstream supply-layers L2 видел 'done', rows_lots=0.
Patch (зеркалит corp_sum — там паттерн уже правильный):
- n_requests/reports_ok перенесены ИЗ pre-HTTP в after-success (после
успешного commit'а внутри `with stream_report(...)`).
- В except Exception lots_pf-ветке: n_requests += 1 + reports_failed += 1
+ logger.exception (с traceback). Graceful (no re-raise) сохранён —
sibling jobs продолжают, но статус run теперь честный.
- corp_sum ветка не тронута. _finish_run сигнатура не меняется.
5 новых регрессионных тестов: success keeps reports_ok=1, auth-error /
api-error / generic Exception в lots_pf инкрементируют reports_failed (не
ok), _finish_run status='failed' при reports_failed>0. ruff clean.
Closes#1220
objective_lots.district хранит МИКРО-вокабуляр ('Уралмаш', 'ЖБИ', ...).
_query_artificial_demand фильтровал сырым АДМИН-именем ('Кировский' с
forecast.py:123) → ol.district='Кировский' = 0 строк → n_sold=0 → §25.5
Artificial Demand 'unavailable' с ложной причиной «нет проданных лотов»
в каждом district-scoped отчёте. Тот же класс бага, что #1211 в
_price_sensitivity.
Patch: импорт resolve_objective_districts + замена сырого
`ol.district = CAST(:district AS text)` на зеркальный паттерн
sales_series._SOURCE_B_SQL / market_metrics._SALES_WINDOW_SQL:
(CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[])))
Сигнатура _query_artificial_demand / _build_artificial_demand НЕ меняется
— caller остаётся admin-aware на входе.
+5 новых тестов (TestArtificialDemandDistrictResolution: резолвер вызван,
микро в bind, n_sold>0 после фикса), 6 обновлённых SQL-тестов. 21 passed
artificial_demand + 1030 forecasting тестов зелёные. ruff clean.
Closes#1205
domrf_kn_flats версионируется (UNIQUE(id, snapshot_date), м.50), scraper
UPSERT per snapshot — то же что для domrf_kn_objects (которое в L3 supply
после #1212 берём только latest). _AVG_PRICE_SQL фильтра snapshot_date НЕ
имел → AVG усреднял ИСТОРИЮ цен (stale на растущем рынке) → UI-поле
Competitor.avg_price_per_m2 + вход _price_similarity получали устаревшую
цену. COUNT '%прод%' множил sold ×N снапшотов → raw_sold/flat_count кратно
завышен → попадал в гард-нейтраль 0.5 или искажал stage_at_horizon как
×N-завышенный sold_pct.
Patch: WHERE f.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_flats).
Зеркало паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON
(уже было latest). 51/51 competitors-тестов зелёные.
Closes#1210
Fallback flat_id в _norm_flat использовал abs(hash(elem)) % (2**63 - 1).
В CPython hash(str) РАНДОМИЗИРОВАН per-process (PYTHONHASHSEED нигде в
репо не зафиксирован — uvicorn/celery не выставляют его). Эффект:
- При resume упавшего sweep (resume_kn_run в новом процессе воркера) до
10 объектов после checkpoint перечитываются. Квартиры без flatId
получают ДРУГОЙ hash-id → ON CONFLICT (id, snapshot_date) не
срабатывает → дубли строк одной квартиры в одном snapshot.
- То же при повторном прогоне за ту же дату — каждый раз новый id.
- Дубли инфлируют все агрегации (units_sold/price медианы, supply_layers).
Patch:
- abs(hash(elem)) → int.from_bytes(sha256(elem)[:8], "big") % (2**63 - 1).
- sha256 стабилен между процессами/перезапусками. 8 байт → BIGINT-fit.
6 новых юнит-тестов (test_domrf_kn_normalize.py): formula matches sha256,
stable across calls, distinct elems→distinct ids, flatId wins over
fallback, no-id→None, BIGINT-fit. ruff clean.
Closes#1208