Introduce INFORMATIONAL_WARNING_CODES frozenset; verdict assembly now only
downgrades to «С ограничениями» when verdict-relevant warnings exist.
NO_ENGINEERING_NEARBY still appears in warnings[] for display but a clean
residential parcel (Ж-zone, no ЗОУИТ) correctly receives verdict_label=«Можно».
Add `velocity_by_room: dict[str, float] | None` to `MarketMetrics` — per-bucket
unit velocity (ед./мес) derived from the existing `sold_by_room` ROLLUP data that
`_query_sales_window` already returns. No new SQL required.
Thread per-bucket velocity through `_demand_only_overlay` via the new
`_FORECAST_TO_METRIC_BUCKETS` constant that maps each forecast bucket to its
market_metrics room-bucket keys. "80+ м²" sums "4" + "5+" keys. Fallback to
aggregate `unit_velocity` when `velocity_by_room` is None (thin-data path).
Previously `base_pace` was identical for all 5 room-buckets, so §9.4 norm and §9.2
base_pace cancelled out in pace/max_pace and ranking was driven purely by §9.5
macro_coef (segment steepness proxy). Now §9.2 reflects real per-bucket observed
demand from objective_lots.contract_date data.
Callers of `compute_market_metrics` that don't use `velocity_by_room` are unaffected
(the new field is additive to the frozen dataclass). All existing callers verified —
none construct `MarketMetrics` directly except the one production site.
- Rename top_poi → items in poi-score.json to match PoiScoreResponse TS type
(mock was cast as PoiScoreResponse but had wrong field name → items undefined
at runtime in MOCK_POI_SCORE mode → PoiList2Gis crashed at [...items].sort)
- Recompute all score_contribution values using backend formula
(weight / _MAX_STRAIGHT_SCORE * 100, _MAX_STRAIGHT_SCORE=0.315) and
poi_weighted_score=19.9 (was 72, which was inconsistent with the new normalization)
- Add assert result.poi_weighted_score == 0.0 to test_routing_decay_empty_db
to match the straight-line empty-db assertion
- Remove stale comment in PoiList2Gis.tsx saying normalization needs fixing in
site-finder-api.ts (already done backend-side in this PR)
_GEO_WEIGHT_UNKNOWN was 0.1, which equals exp(−6.9/3)≈0.10 (weight of a
confirmed-far project at ~6.9 km). Projects beyond that distance got a
weight *below* 0.1, meaning unknown-coordinate projects outweighed
confirmed-far ones — an inversion of the documented intent.
Lowered to 0.05 (≈ exp(−3) = exp(−9 km / scale)), restoring the correct
hierarchy: confirmed-close > confirmed-far > unknown. Updated TestGeoWeight
(hardcoded 0.1 expectation) and TestCannibalizationTrueMode (overlap ×
floor comment/value) accordingly. Added two new assertions in TestGeoWeight
that enforce the hierarchy monotonically and verify unknown < exp(−6.9/3).
_INN_RE now requires an explicit ИНН/inn keyword anchor (case-insensitive) within
~20 chars before a 10/12-digit block. Bare digit sequences without the keyword are
no longer candidates — eliminates false-positives on large monetary amounts such as
1 200 000 000 (which coincidentally passes the ФНС checksum). Checksum validation
is kept as a second gate to avoid redacting e.g. «ИНН 1234567890» with bad digits.
_inn_repl updated to use match.group(1) (digit-only capture group) instead of the
full match that now also includes the keyword prefix.
7 new regression tests in test_redaction.py: bare large numbers not redacted,
keyword-cued real INNs (10/12 digit) still redacted, bad-checksum + keyword left
intact, latin «inn:» accepted.
Replace whole-HTML re.search for status with a 3-level block-scoped strategy:
1. CSS badge classes (status/badge/tag/chip/label) — highest precision.
2. Proximity to block labelled «Статус» via _find_text_near.
3. Full blocks scan where sold/reserved keywords always beat free —
preventing «в продаже» from nav/similar-flats sections from
misclassifying sold flats as free.
Add _STATUS_KW_RE and _classify_status_kw at module level with full
morphological coverage: продан/продана/продано, реализован[аоы]?,
забронирован[аоы]?, свободн[аоы]?.
Add 31 tests in test_domrf_catalog_parse.py covering all three
extraction levels plus regression for fem. word-form «Квартира продана».
_YEAR_PERIODS ('год') → month=1 collided with Q1 ('I квартал') → month=1
for the same region/year: both produced obs_date=YYYY-01-01, so the second
series in the XML silently overwrote the first in the in-memory by_key dict.
Fix: add _emiss_period_granularity() and extend the dedup key from
(region, obs_date) to (region, obs_date, granularity). 'year' and 'quarter'
are now distinct slots, so both rows survive the parser and reach the upsert.
Test: test_yearly_and_q1_both_survive_dedup verifies len==2 with both
values present; test_period_granularity covers all classification branches.
_count_full_years treated units=0 as a valid observation, so a series
where fill_month_grid zero-filled every month still accumulated 3 full
years and passed the _MIN_FULL_YEARS guard. Zero-filled months carry no
seasonal signal, so they must be skipped in the year counter — the same
way None values already were.
Fix: skip v==0 alongside v is None in _count_full_years.
Add four tests: zero-filled 36-month series → n_full_years=0/applied=False;
partial-coverage years (only 6 non-zero months/year) → not counted as full;
real non-zero series still passes guard; normalize_demand on zero-filled
SalesSeries returns series unchanged.
Add `deal_count_months: int | None = None` to `compute_report_confidence`.
When provided, threads it as suffix into `_factor_from_count` so the
deal_count ConfidenceFactor note reads «7 сделок за 6 мес — мало» instead
of the windowless «7 сделок — мало». Existing callers unaffected (default None).
Tests: two new cases in TestComputeReportConfidence — with/without window.
Backend now computes poi_weighted_score and per-POI score_contribution in
0..100 range. Normalization denominator = sum of top-7 category weights / 100
(straight-line mode) or sum of top-7 category weights (routing-decay mode).
Frontend stops reconstructing score from raw weight × 100.
Внутренний recommendation→product_scoring контракт-ключ был мислейблом: величина —
темп распродажи нежилого (sell-through, прокси ликвидности/спроса), а НЕ доля нежилого
в объёме застройки. Переименован ключ + исправлены reason/docstring/комментарии у
потребителя _score_commercial. Числовая логика не изменена. Ключ внутренний (нет
frontend/schema/openapi-потребителей) → rename контракт-безопасен. pytest 171 passed.
Closes#1635
#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
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
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
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