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
useCustomPois mutations инвалидировали ['analyze', cad], а такой key
ни один useQuery не регистрирует. Canonical keys: ['parcel-analyze', cad,
horizon] и ['parcel-poi-score', cad]. Все 3 onSuccess были silent no-ops
→ скор+breakdown stale после правок кастомных POI.
Patch: analyzeKey → ['parcel-analyze', parcelCad] (prefix-only, TanStack
covers all horizons), добавлен poiScoreKey, оба инвалидируются вместе.
82/82 frontend тестов зелёные.
Closes#1241
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
Конкурентные analyze делили один cancelledRef: новая мутация ставила
ref=false и воскрешала спящий poll-цикл предыдущей — лишние fetch-status/
POST analyze и setFetchingState(null) затирали баннер активного запроса.
Patch: shared boolean ref → per-call AbortController, signal проброшен
в apiFetch/apiFetchWithStatus, добавлен abortableSleep чтобы цикл
просыпался мгновенно на cancel. setFetchingState(null) guarded
signal.aborted — зомби-цикл не может перезаписать состояние актуальной
мутации. 82/82 frontend тестов зелёные.
Closes#1242
Литерал 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
UNIQUE (obj_id, doc_type, doc_num, file_url) в м.112 без NULLS NOT
DISTINCT, doc_num nullable. В PG NULL != NULL в уникальном индексе →
для документов с docNum=null (документированный частый вход:
extract_documents:125-134 → doc_num=None) ON CONFLICT-арбитр в
scrapers/documents.py:59 НИКОГДА не матчился → каждый ежедневный sweep
domrf_kn.py:1903 молча вставлял новую строку-дубль вместо UPDATE.
Таблица росла ×N прогонов; дубли уходили в analytics_queries.py:1068.
Миграция 140:
1. Дедуп существующих NULL-дублей: ROW_NUMBER() PARTITION BY все 4 ключа
ORDER BY scraped_at DESC, id DESC → оставляем самую свежую, удаляем
остальные WHERE rn>1 AND doc_num IS NULL.
2. DROP старого autonamed constraint'а IF EXISTS.
3. ADD uq_domrf_kn_documents_logical UNIQUE NULLS NOT DISTINCT (...) с
именованным guard по pg_constraint.conname (паттерн м.125 → idempotent).
Caller scrapers/documents.py НЕ меняется — после миграции ON CONFLICT-
арбитр начнёт матчить NULL-doc_num строки автоматически. Прецедент того
же класса: м.110 (uq_infra_dedupe), м.125 (uq_supply_layers_logical).
Closes#1206
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
В complete-loop'е min(...,30) применялся ТОЛЬКО к exponential backoff'у
(else-ветка). Серверный Retry-After уходил в time.sleep как есть.
_parse_retry_after принимает любое число секунд ("86400".isdigit() → True),
а provider'ы шлют Retry-After до 86400с при quota-exhaustion (OpenAI,
CDN-503). Эффект: time.sleep блокирует поток anyio-threadpool на часы
(до 48ч при llm_max_retries=2). Async-консьюмер (chat.py) мостится через
run_in_threadpool — поток держит токен пула (~40 потоков, общий с sync
Depends(get_db)) и DB-сессию → пул исчерпывается → стопор приложения.
Patch: вынес кап на module-level _MAX_BACKOFF_S=30s, применяю к ОБЕИМ
веткам (Retry-After И exp.backoff). raw_wait логируется отдельно для
наблюдаемости (видно когда провайдер просил больше).
Новый юнит-тест test_rate_limited_retry_after_capped: provider шлёт
retry_after=86400 → time.sleep вызывается с 30 (не 86400). 14/14
client тестов + 55/55 LLM-suite зелёные. ruff clean.
Closes#1209
_price_sensitivity передавал сырое admin-имя ('Кировский') в _elasticity_coef,
который фильтрует objective_corpus_room_month.district по МИКРО-вокабуляру
(Втузгородок, ЖБИ, …) → регрессия получала 0 точек → всегда FALLBACK_ELASTICITY.
§9.2 district-level эластичность молча НЕ считалась в /analyze-пути (только
'Академический' совпадал в обоих вокабулярах случайно).
Fix: вызываем resolve_objective_districts() в _price_sensitivity и передаём
список микро через новый kwarg districts=[…] в _elasticity_coef. Резолвер
None ('не определён' / нет чистых алиасов) → пустой список → EKB-wide
регрессия. _elasticity_coef расширен с back-compat: districts=None →
legacy путь по district_name (другой caller в analytics_queries —
отдельный bug class, вне scope).
5 новых юнит-тестов TestPriceSensitivityDistrictResolution: admin→micros в
SQL bind, None→EKB-wide, regression preserved post-resolve, graceful.
76/76 market_metrics + 156/156 elasticity/sensitivity тестов зелёные.
ruff + psycopg v3 grep clean.
Closes#1211
Section 1 mini-map (/site-finder/analysis/[cad] — единственная карта
страницы) оборачивал SiteMap в height:320 + overflow:hidden. SiteMap
рендерит карту фиксированной высотой 420px + ПОД ней (flow) кнопку
"Добавить POI", легенду и CpLayerControlPanel с тумблерами
competitors/pipeline/risk/opportunity/redlines. Нижние 100px карты и
ВСЕ контролы обрезались clip-обёрткой → недостижимы кликом.
visibleMarketLayers init = {"competitors"} — единственный способ
включить остальные слои (#999 zone-of-risk, #958 §12.1-13 opportunity
parcels / red lines) — те самые скрытые чекбоксы → слои физически
невозможно было включить на странице анализа, хотя данные пробрасывались
props'ами ради них.
Patch (Вариант A из issue):
- SiteMap.tsx: новый prop mapHeight?: number (default 420 — backward-
compat для /site-finder/[cad]).
- MiniMap.tsx: снято height:320 + overflow:hidden; передаём mapHeight=280
(компактнее но не клипает); только width-constraint (maxWidth 400)
на обёртке.
2 новых vitest: mapHeight=280 пробрасывается в SiteMap (mocked stub),
clipping-стили отсутствуют. 82/82 frontend тестов зелёные. tsc + lint clean.
Closes#1218
_SALES_WINDOW_SQL делал GROUP BY ROLLUP (rooms_int), rooms_int nullable
(ETL пишет NULL для «неопределённого типа», sales_series.py:399 явно
обрабатывает None). Проданный лот с rooms_int IS NULL даёт ДВЕ строки
rooms_int IS NULL (NULL-группа + grand-total итог), неразличимые в
Python (оба if r["rooms_int"] is None).
MixedAggregate-план PG16 эмитит grand-total ПЕРВЫМ (среди hash-строк),
NULL-группа после → loop затирает units_total частичным счётом (живой
тест на PG16: 2000 → 200). Эффект: unit_velocity / absorption_rate
занижены, months_of_supply завышен → base_pace в demand_supply_forecast
неверный (recommendation.py:586) → reports/scoring врёт.
Patch:
- SQL: добавить GROUPING(rooms_int) AS is_total (=1 для grand-total).
- Python: ветвить по is_total, NULL-комнатную группу класть в
by_room['unknown'] (отдельный бакет), аккумулировать через +=
вместо assign (защита от будущих NULL-вариантов).
- Тесты: моки получили "is_total" поле (1 для grand-total, 0 иначе).
71/71 market_metrics тестов зелёные. ruff clean.
Closes#1214
_ACTIVE_STATUSES = frozenset({"sales", "construction"}) — английский словарь
никогда не совпадал с domrf_kn_objects.site_status, который scraper берёт
СЫРЫМ из siteStatus дом.рф (domrf_kn.py:316). Реальные prod-значения
русские: «Строящиеся»/«Сданные».
Прод-аудит:
- data/sql/105_add_sales_started_flag.sql фильтрует по 'Строящиеся' (~1322 строки).
- partial index 66_indexes_recommend.sql использует те же.
- analytics_queries.py, MarketTab.tsx, CompetitorTable.tsx — все на русских.
Эффект: у ВСЕХ Competitor в POST /parcels/{cad}/competitors is_active=False
и CompetitorsSummary.active_count=0 при любых данных — типизированный
контракт систематически врал.
Patch: _ACTIVE_STATUSES = frozenset({"Строящиеся"}). Заодно обновил два
unit-теста которые кодировали баг (использовали "sales"/"construction"
в моках, тестировали логику против сломанного словаря). Теперь моки
матчат реальную prod-форму.
51/51 competitors-тестов зелёные. ruff clean.
Closes#1213
_L3_FUTURE_SQL применял volatile-фильтры (ready_dt > horizon, free_flats
<= threshold) в WHERE ВНУТРИ CTE с DISTINCT ON (obj_id) ORDER BY
snapshot_date DESC. Это значит фильтр применялся ДО DISTINCT ON →
бралась «последний снапшот, ПРОШЕДШИЙ фильтр», а не последний в принципе.
Эффект на проде: объект, когда-то бывший «объявлен, не продаётся»
(free_flats=0/NULL), остаётся в L3 future-supply даже после открытия
продаж — свежий снапшот с free_flats=180 отфильтрован, взят старый
с free_flats=0. Двойной счёт с L1/L2 (open + future одного объекта),
stale flat_count/ready_dt в supply_layers и форсайте.
Patch: разделил фильтры на стабильные (в CTE: region_cd, district_name)
и volatile (во внешнем WHERE после DISTINCT ON: ready_dt, free_flats).
Зеркало паттерна L2-CTE (там volatile уже снаружи). Семантика теперь
матчит docstring: «свежий снапшот, затем фильтр».
62/62 supply_layers тестов зелёные. ruff clean. SQL psycopg v3
(CAST(:x AS interval)) уже корректен.
Closes#1212
Два concurrency-бага в scrape_kn:
1. Lock value="1" без owner-токена + TTL=30мин = заявленной длительности
sweep'а (нулевой запас). Sweep > TTL → lock истекает, второй sweep
стартует, первый при завершении безусловным r.delete сносит ЧУЖОЙ
lock → возможен третий параллельный.
2. SIGKILL/redeploy mid-sweep: finally не выполняется, lock живёт до
30 мин. worker_ready метит run 'zombie' и enqueue'ит resume_kn_run
БЕЗ countdown, который ловит lock_held и возвращает skipped без
retry → run теряется до недельного beat.
Patch:
- _region_lock: value = uuid4().hex; release через Lua check-and-delete
(`if GET == ARGV[1] then DEL else 0 end`) — не сносим чужой lock.
- _LOCK_TTL_SECONDS 30 → 45 мин (полтора max sweep duration, запас).
- resume_kn_run: при lock_held → raise self.retry(countdown=300,
max_retries=12) → ~час окно для подхвата вместо silent skip.
- scrape_kn_region (scheduled) намеренно остаётся skipped — beat
поднимет в следующий weekly tick (другая семантика).
11 новых юнит-тестов (token uniqueness, Lua guard, retry-not-skip,
existing behaviors). 16/16 scrape_kn тестов зелёные. ruff clean.
Closes#1216
В WAF-ветке `nspd_geo` worker спит до 240с (4 мин при consecutive_waf=8),
heartbeat коммитится ДО сна. Старый `cleanup_zombies` (`* * * * *`) с
порогом `INTERVAL '2 minutes'` для status IN ('running', 'paused')
гарантированно ре-enqueue'ил **живой** WAF-job:
- Два worker'а параллельно долбили забаненный NSPD-сервис, углубляя бан.
- SELECT pending без `FOR UPDATE SKIP LOCKED` → дубли запросов.
- Counter'ы done/failed двух инстансов затираются друг другом.
Бонус-баг: status='paused' (после 8 WAF подряд) воскресал через минуту,
аннулируя WAF-защиту.
Patch: разделил пороги через module-level константы и параметризованный
SQL (CAST(:x AS interval) per psycopg v3 rule):
- `_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"` — выше max WAF backoff (4 мин).
- `_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"` — реальная WAF-пауза.
Beat-расписание (1 мин tick) не тронуто. WAF-логика / heartbeat-in-loop
(вариант B) / FOR UPDATE SKIP LOCKED (вариант «идеальный») вне scope.
4 новых юнит-теста (test_nspd_geo.py): psycopg v3 guard, оба порога
биндятся, два предиката вместо одного, paused > running.
18/18 nspd_geo тестов зелёные. ruff clean.
Closes#1215
audit_log_middleware зарегистрирован через app.middleware("http") (main.py:92)
→ async dispatch на event loop. После `await call_next(...)` response уже
отдан, но БЛОКИРУЕТ event loop пока идёт sync DB round-trip (SessionLocal/
execute/commit). Это останавливает ВСЕ другие запросы на loop'е до завершения
INSERT'а. При исчерпании connection pool (default 5+10, sync analyze держит
сессию ~15с inline-wait) checkout ждёт pool_timeout=30с НА loop'е → весь API,
включая /health, замирает.
Каждый аудируемый запрос: analyze, КАЖДЫЙ 2с-poll GET /forecast, export,
insight-write. На активной демо-сессии — десятки таких в минуту.
Fix: write_audit_row выполняется через fastapi.concurrency.run_in_threadpool
— sync IO в worker-потоке, event loop свободен для других запросов. Никаких
изменений в самой write_audit_row — она остаётся sync def с собственным
try/except + rollback.
16/16 audit-middleware тестов зелёные (поведение для caller'а не изменилось).
Closes#1202
`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
recommend_mix кладёт в bucket поля elasticity / elasticity_r2 / elasticity_n /
elasticity_source / velocity_source (analytics_queries.py:2592-2606), но
RecommendBucket их не объявляет и не имеет extra='allow' → Pydantic v2 молча
выкидывает их из response. Фронт (RecommendVelocityPanel.tsx:68
`r.elasticity ?? elasticity`) при движении price-слайдера всегда падает на
ГЛОБАЛЬНУЮ эластичность — live what-if расходится и с серверным
months_to_sellout (тот считается per-bucket), и с UI-таблицей «Эластичность
по сегментам».
Patch: добавлены 5 опциональных полей в схему. Совместимость: фронт уже
читает их с `?? globalFallback`, поэтому non-breaking. Producer/consumer-
контракт восстановлен. 113/113 recommend-тестов зелёные.
Closes#1204
`_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
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
`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
`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
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
Два внешних 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
- listings + offer_price_history SELECT для gendesign_reader
- fail-fast DO-блок: RAISE EXCEPTION если роль не существует (м.101 не применён)
- НЕ грантятся listing_sources/snapshots/events (внутренний ETL, не нужен SF)
- Контракт: consumer фильтрует vtorichka канон-предикатом сам
Closes#1191
- Удалён параметр 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) (параметрическую форму)
- Создан 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.