backend-tests падал СИСТЕМНО на каждом PR на
test_auto_apply_matches_dry_run_no_inserts (assert 1 == 0).
Root cause: аудит-коммит 14f3ef20 (#1660) намеренно изменил
auto_apply_matches — dry_run теперь возвращает projected-счётчики
(auto_accepted=len(auto), сколько БЫ приняли — смысл preview в
admin_etl endpoint'е), но тест в том же коммите не обновили, он
остался с assert auto_accepted == 0.
Функция корректна (dry_run делает ранний return до db.execute/commit —
execute.assert_not_called() валиден). Правлю устаревшую ассерцию:
== 0 → == 1 (кандидат 0.95 >= AUTO_ACCEPT_THRESHOLD 0.85), + assert skipped == 0.
Полный сьют: 3277 passed, 0 failed.
Refs #1709
backend-tests и openapi-codegen-check тратят ~90% времени не на работу
(дамп схемы + pytest), а на пересборку окружения с нуля каждый прогон.
Доминанта — 'uv sync --frozen' тянет весь geo/WeasyPrint-стек заново, т.к.
~/.cache/uv не кэшировался между прогонами (npm уже кэширован setup-node).
Изменения:
- Cache uv (~/.cache/uv) через actions/cache в обоих job, ключ по backend/uv.lock,
restore-keys для частичного хита. continue-on-error → сбой cache-бэкенда
раннера не ломает gate.
- openapi-codegen-check: 'uv sync --frozen --no-dev' — job только импортирует
app.main для дампа OpenAPI, dev-группа (pytest/ruff/coverage) не нужна.
Dockerfile тоже --no-dev, поэтому импорт гарантирован.
Refs #1709
openapi-codegen-check падал СИСТЕМНО на каждом backend-PR: committed
frontend/src/lib/api-types.ts отставал от backend OpenAPI на 2 поля,
добавленных в POI-score схемы (PR ~#1486 normalize POI weighted score),
но не перегенерённых:
- PoiScoreItem.score_contribution
- PoiScoreResponse.poi_weighted_score
Контент взят 1:1 из authoritative CI-диффа (openapi-typescript + prettier).
Возвращает gate openapi-codegen-check в зелёное для всех PR.
Refs #1709
Каждый коммит в ветку с открытым PR триггерил ДВА прогона ci.yml на один
SHA: push-событие (github.ref=refs/heads/<branch>) и pull_request-событие
(github.ref=refs/pull/<N>/merge). Разный github.ref → разные concurrency-
группы → прогоны не отменяли друг друга → 2x нагрузка на и так дефицитные
2 раннера (CI-шторм 2026-06-17, деплой tradein #1699 простоял 17 мин).
В bot-пайплайне каждый коммит идёт через PR, поэтому pull_request гейтит
его полностью; push-прогон был чистым дублем. Оставляю только pull_request:
теперь github.ref стабилен на весь PR и concurrency cancel-in-progress
корректно отменяет superseded-прогоны при новом пуше.
Refs #1709
Adds period_type TEXT NOT NULL DEFAULT 'unknown' to macro_indicator and
widens the PRIMARY KEY to (indicator_type, region, obs_date, period_type).
Before: yearly aggregate ('год'→obs_date YYYY-01-01) and Q1 ('I квартал'→
same date) shared the same PK slot → ON CONFLICT DO UPDATE made them
overwrite each other despite the in-memory dedup fix in #1687.
After: each EmissRow carries period_type from _emiss_period_granularity
('year'/'quarter'/'month'); DB ON CONFLICT targets include it, so
yearly+Q1 rows genuinely coexist.
Non-EMISS sources (CBR, rosstat open-data, domrf) use period_type='unknown'
(literal in SQL) — they are disambiguated by obs_date already, so the
wider PK is backward-compatible. All ON CONFLICT clauses in
cbr_macro_sync.py and rosstat_macro_sync.py updated accordingly.
Migration: 163_emiss_pk_period_type.sql (idempotent BEGIN/COMMIT).
Tests: 49 passed (emiss + rosstat suite), ruff clean, py_compile OK.
Replace (?<!\d)/(?!\d) lookarounds on the digit block in _INN_RE with
(?<!\w)/(?!\w). The old (?<!\d) did not block alpha-prefixed tokens
(e.g. «ИНН ref7707083893»), and (?!\d) did not block alpha-suffixed
tokens (e.g. «ИНН 7707083893more»). \b is unsuitable here because
Python \w covers both letters and digits, so there is no \b boundary
between an alpha char and a digit char. The (?<!\w)/(?!\w) pair
correctly anchors the 10/12-digit INN block to non-word boundaries on
both sides. Context anchor and checksum gate from #1640 are unchanged.
Adds 6 regression tests covering: alpha-prefix, alpha-suffix,
embedded mid-token, 12-digit alpha-prefix, punctuation separator
(should match), and end-of-string (should match).
excel.py рендерил _scenario_deficit_index (скаляр) в таблицу сценариев под
шапкой «(12 мес)» — при fallback на другой горизонт подпись лгала (то же что
docx/pptx до 764db617). Добавлены _scenario_deficit_horizon и
_scenario_deficit_cell (зеркало report_pdf.py/764db617): при fallback ячейка
несёт «(гор. N мес)». _build_scenarios_sheet переключён на _scenario_deficit_cell.
Тесты: два новых кейса — fallback (6 мес → аннотация) и primary (12 мес → скаляр).
- admin_scrape.py /failures limit: ge=0 → ge=1 (returning 0 rows is nonsensical for a list endpoint)
- orchestrator.py: check result.finish_reason after ok=True; 'length'/'content_filter' and any
other non-stop reason triggers deterministic fallback with reason=finish_reason:<value>
(LLMResult docstring mandated this but no consumer enforced it)
(:bind || ' months')::interval is a psycopg v3 SyntaxError — the driver
sees '::' immediately after the bind placeholder token and chokes.
Replace all 6 occurrences (admin_leads.py + analytics_queries.py) with
make_interval(months => :bind) which is unambiguous to the parser and
semantically identical.
When all winddirection_10m_dominant samples were None (or the key was
absent), atan2(0, 0) produced 0.0° (due north) as a fabricated value.
Root causes:
1. [None, None, ...] is truthy → entered circular-mean branch, but
filtered sums x=y=0 → atan2(0,0)=0 → 0.0°
2. Empty list → else 0.0 branch → same fabrication
Fix: filter None from wind_d before aggregation (consistent with t_max /
t_min / uv). Only compute circular mean when at least one valid sample
exists; otherwise dominant_direction_deg and dominant_direction_label are
None. Adds TestWindDirectionAllNone covering all-None, missing key, valid
samples sanity, and mixed None+valid cases.
_STATUS_BADGE_CLS_RE was too broad: `status|badge|tag|chip|label` matched
generic UI elements (e.g. <span class="label">, <span class="chip">) that
are unrelated to the sale-status badge, risking picking the wrong block.
Narrowed to `(?<![a-z])status(?![a-z])` — requires the literal token
"status" as a hyphen-delimited component of the CSS class (matches
`status-badge`, `flat-status-tag`, `object-status` but not bare `label`,
`tag`, `chip`, `badge`).
Adds regression test: page with generic label/tag/chip/badge elements
containing "В продаже" must NOT activate Level-1; only the real
`status-badge` block ("Продана") should be returned → status=sold.
Replace the system-curl SOCKS5 subprocess transport with the shared
BrowserFetcher (camoufox). camoufox's real-browser fingerprint is not
tarpitted by yandex and does not burn the mobile-proxy IP; raw system-curl
got the proxy IP flagged.
One BrowserFetcher is opened per scraper session in __aenter__ and reused
across all page fetches. _http_get now fetches via the browser, extracts the
gate-API JSON from camoufox's HTML <pre> wrapper, and returns it as the body
of a _CurlResponse (status 200 on success, 0 on fetch/extraction failure) so
all existing callers and the tarpit retry/rotate logic keep working unchanged.
URL building, _parse_gate_json, pagination, combos and field mapping are
untouched. Removed the dead curl subprocess constants/method.
sat_factor = 1 + ((sold_pct-50)/100)*0.30 was computed in 09_macro_and_trend.py,
written to district_economics.sat_factor, and fetched in server.py and 10_score_v2.py
— but never multiplied into any score. The live market sub-score uses a separate
sat_score = min(100, sold_pct*100/70) directly, so sat_factor was dead code that
would double-count absorption if ever wired in.
- 09_macro_and_trend.py: remove sat_factor computation, ALTER TABLE column, UPDATE
binding, and debug print column
- 10_score_v2.py: remove sat_factor from SELECT and unpacking
- server.py: remove sat_factor variable assignment and from macro_factors response
- static/index.html: remove sat_factor documentation row
- data/sql/162_drop_district_economics_sat_factor.sql: DROP COLUMN IF EXISTS
- Add window_months > 0 guard to vel_by_room dict comprehension (mirrors _monthly_rate)
- Correct outer comment in recommendation.py: honest-zero for known-zero buckets,
fallback only when velocity_by_room=None or bucket absent from _FORECAST_TO_METRIC_BUCKETS
- Add comment in as_dict() noting velocity_by_room is intentionally not serialized
(internal pipeline attr consumed directly by recommendation.py)
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=«Можно».
`_build_confidence` was calling `compute_report_confidence` without
`deal_count_months`, so the «за N мес» suffix never appeared in
production. Add `_deal_count_months(market_metrics)` extractor
(reads `window_months`, same key as `_history_months`) and pass it.
Also fix pre-existing UP038 violations (isinstance tuple → X | Y).
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.