feat(site-finder): auto-fetch cadastre geometry on-demand (#93) #95

Merged
lekss361 merged 2 commits from feat/site-finder-auto-fetch-cadastre into main 2026-05-12 06:02:18 +00:00
lekss361 commented 2026-05-12 05:48:12 +00:00 (Migrated from github.com)

Summary

Closes #93 (priority/high, UX + Data — главный UX-блокер на /site-finder).

Раньше: пользователь ввёл cad-номер которого нет в БД → 404 «Загрузи через NSPD geo» (dead-end для non-admin).

Теперь: backend автоматически инициирует NSPD fetch (reuse существующей infra), frontend показывает loading state с polling, после готовности геометрии — автоматически рендерит analyze.

Backend

Новый модуль app/services/site_finder/cadastre_fetch.py

  • find_or_enqueue_fetch(db, cad_num)(status, job_id, error_msg):
    • Проверка DB через EXISTS short-circuit (cheapest)
    • Дедупликация: find_active_on_demand_job — параллельные запросы на тот же cad → один job
    • Smart distinguishing recent completed: target_status=done + no cad in DB → not_in_nspd; target_status=failed → retry
  • fetch_status(db, cad_num) — polling endpoint helper
  • detect_thematic_id — 3 сегмента → quarter (2), 4 → parcel (1), 5 → building (5)
  • validate_cad_formatNN:NN:NNNNNN[:NN[:NN]]

Reuse существующей infra

  • enqueue_geo_job + process_nspd_geo_job из workers/tasks/nspd_geo.py
  • source_kind='auto_on_demand' отличает от bulk (rosreestr_pending, manual_list)
  • rate_ms=200 (быстрее bulk 600), Celery priority=9 (высокий)

POST /parcels/{cad}/analyze graceful fallback

Сценарий Response
cad в БД 200 OK с analyze (как было)
cad не в БД, fetch успел < 25s inline await → 200 OK (fast path)
cad не в БД, fetch > 25s 202 Accepted + {status, job_id, eta_seconds, message}
cad invalid format 400 Bad Request + формат hint
cad не нашёл в НСПД 404 Not Found + clear message
НСПД rate-limit / WAF 503 + Retry-After: 60 header

Новый endpoint GET /parcels/{cad}/fetch-status

Polling endpoint, возвращает {status, job_id, error_msg, eta_seconds}.

Frontend

useSiteAnalysis hook расширен

  • Возвращает fetchingState (cadNum, jobId, etaSeconds) + cancel()
  • POST /analyze → при 202 переход в polling каждые 2с (max 60 итераций / 2 мин cap)
  • При status=ready re-trigger analyze автоматически
  • not_in_nspd / failed / invalid_format → typed errors

lib/api.ts — статус-aware variant

  • Новый apiFetchWithStatus<T> возвращает {status, body} для 200 vs 202 distinction
  • HTTPError class с status + body

Новый FetchingState.tsx

  • Animated spinner + progress bar (linear до etaSeconds, asymptote после)
  • Elapsed time counter + ETA badge
  • Cancel button
  • Светло-голубая цветовая схема — отличается от обычного pending skeleton
  • aria-live/aria-valuenow для accessibility

site-finder/page.tsx

  • FetchingState отрисовывается когда fetchingState активен
  • Обычный pending skeleton — только при первичном analyze без 202

Edge cases (per #93 acceptance)

  • cad валидный в НСПД, fetch <25s → inline 200 OK
  • cad валидный, fetch >25s → 202 + frontend polls → ready → analyze
  • cad валидный, не в НСПД → 404 с понятным сообщением
  • cad invalid → 400 + format hint
  • NSPD rate-limited / failed → 503 + Retry-After 60s
  • Параллельные запросы на тот же cad → один job, оба клиента poll'ят

Test plan

  • ruff check + format — clean
  • tsc --noEmit — clean
  • npm run lint --max-warnings 0 — clean
  • После deploy:
    # Ввести cad НЕ из БД (например 66:41:0204015 или 4-сегментный)
    curl -X POST https://gendsgn.ru/api/v1/parcels/66:41:0204015/analyze
    # Expected: 202 + job_id, потом
    curl https://gendsgn.ru/api/v1/parcels/66:41:0204015/fetch-status
    # Expected: status=fetching → status=ready (~15-30s)
    
  • Visual: открыть https://gendsgn.ru/site-finder, ввести unknown cad → FetchingState (синий блок с progress bar) → автоматический analyze
  • Invalid cad (e.g. abc:def) → 400 + clear message
  • Несуществующий cad (правильный формат но не в НСПД) → ждёт ~30с → not_in_nspd error

Risks

  • НСПД rate-limit / IP-block — основной риск. Защита: rate_ms=200 на per-job уровне, queue depth limits.
  • Долгий fetch (>2 мин) — frontend hard cap на 60 итераций, иначе timeout error пользователю.
  • TODO для follow-up: per-IP rate limiting (slowapi middleware), Sentry telemetry для % cad'ов которые потребовали on-demand.
  • Cross-ref с #51 (E1 bulk cad_parcels_geom import) — bulk закрывает baseline, on-demand покрывает edge cases.
  • Foundation для on-demand UX в Generative /concept если cad не в БД.
## Summary Closes #93 (priority/high, UX + Data — главный UX-блокер на /site-finder). Раньше: пользователь ввёл cad-номер которого нет в БД → 404 «Загрузи через NSPD geo» (dead-end для non-admin). Теперь: backend автоматически инициирует NSPD fetch (reuse существующей infra), frontend показывает loading state с polling, после готовности геометрии — автоматически рендерит analyze. ## Backend ### Новый модуль `app/services/site_finder/cadastre_fetch.py` - `find_or_enqueue_fetch(db, cad_num)` → `(status, job_id, error_msg)`: - Проверка DB через EXISTS short-circuit (cheapest) - Дедупликация: `find_active_on_demand_job` — параллельные запросы на тот же cad → один job - Smart distinguishing recent completed: target_status=done + no cad in DB → `not_in_nspd`; target_status=failed → retry - `fetch_status(db, cad_num)` — polling endpoint helper - `detect_thematic_id` — 3 сегмента → quarter (2), 4 → parcel (1), 5 → building (5) - `validate_cad_format` — `NN:NN:NNNNNN[:NN[:NN]]` ### Reuse существующей infra - `enqueue_geo_job` + `process_nspd_geo_job` из `workers/tasks/nspd_geo.py` - `source_kind='auto_on_demand'` отличает от bulk (`rosreestr_pending`, `manual_list`) - `rate_ms=200` (быстрее bulk 600), Celery `priority=9` (высокий) ### `POST /parcels/{cad}/analyze` graceful fallback | Сценарий | Response | |---|---| | cad в БД | 200 OK с analyze (как было) | | cad не в БД, fetch успел < 25s | inline await → 200 OK (fast path) | | cad не в БД, fetch > 25s | 202 Accepted + `{status, job_id, eta_seconds, message}` | | cad invalid format | 400 Bad Request + формат hint | | cad не нашёл в НСПД | 404 Not Found + clear message | | НСПД rate-limit / WAF | 503 + `Retry-After: 60` header | ### Новый endpoint `GET /parcels/{cad}/fetch-status` Polling endpoint, возвращает `{status, job_id, error_msg, eta_seconds}`. ## Frontend ### `useSiteAnalysis` hook расширен - Возвращает `fetchingState` (cadNum, jobId, etaSeconds) + `cancel()` - POST /analyze → при 202 переход в polling каждые 2с (max 60 итераций / 2 мин cap) - При `status=ready` re-trigger analyze автоматически - `not_in_nspd` / `failed` / `invalid_format` → typed errors ### `lib/api.ts` — статус-aware variant - Новый `apiFetchWithStatus<T>` возвращает `{status, body}` для 200 vs 202 distinction - `HTTPError` class с `status` + `body` ### Новый `FetchingState.tsx` - Animated spinner + progress bar (linear до etaSeconds, asymptote после) - Elapsed time counter + ETA badge - Cancel button - Светло-голубая цветовая схема — отличается от обычного pending skeleton - `aria-live`/`aria-valuenow` для accessibility ### `site-finder/page.tsx` - FetchingState отрисовывается когда fetchingState активен - Обычный pending skeleton — только при первичном analyze без 202 ## Edge cases (per #93 acceptance) - [x] cad валидный в НСПД, fetch <25s → inline 200 OK - [x] cad валидный, fetch >25s → 202 + frontend polls → ready → analyze - [x] cad валидный, не в НСПД → 404 с понятным сообщением - [x] cad invalid → 400 + format hint - [x] NSPD rate-limited / failed → 503 + Retry-After 60s - [x] Параллельные запросы на тот же cad → один job, оба клиента poll'ят ## Test plan - [x] `ruff check + format` — clean - [x] `tsc --noEmit` — clean - [x] `npm run lint --max-warnings 0` — clean - [ ] После deploy: ```bash # Ввести cad НЕ из БД (например 66:41:0204015 или 4-сегментный) curl -X POST https://gendsgn.ru/api/v1/parcels/66:41:0204015/analyze # Expected: 202 + job_id, потом curl https://gendsgn.ru/api/v1/parcels/66:41:0204015/fetch-status # Expected: status=fetching → status=ready (~15-30s) ``` - [ ] Visual: открыть https://gendsgn.ru/site-finder, ввести unknown cad → FetchingState (синий блок с progress bar) → автоматический analyze - [ ] Invalid cad (e.g. `abc:def`) → 400 + clear message - [ ] Несуществующий cad (правильный формат но не в НСПД) → ждёт ~30с → not_in_nspd error ## Risks - НСПД rate-limit / IP-block — основной риск. Защита: rate_ms=200 на per-job уровне, queue depth limits. - Долгий fetch (>2 мин) — frontend hard cap на 60 итераций, иначе timeout error пользователю. - TODO для follow-up: per-IP rate limiting (slowapi middleware), Sentry telemetry для % cad'ов которые потребовали on-demand. ## Related - Cross-ref с #51 (E1 bulk cad_parcels_geom import) — bulk закрывает baseline, on-demand покрывает edge cases. - Foundation для on-demand UX в Generative `/concept` если cad не в БД.
lekss361 commented 2026-05-12 05:51:29 +00:00 (Migrated from github.com)

⚠️ Auto-review: minor (non-blocking)

Backend — backend/app/services/site_finder/cadastre_fetch.py:

  • Race condition: docstring header упоминает SELECT FOR UPDATE, но SQL в find_active_on_demand_job:252-275 обычный SELECT. Два параллельных запроса на один cad могут оба создать новый job. Fix: либо pg_try_advisory_xact_lock(hashtext(cad_num)) обернуть шаги 3-6 find_or_enqueue_fetch, либо убрать упоминание FOR UPDATE из docstring + добавить TODO(#93): add advisory lock for strict dedup. Severity: low (дубль job, не данные).
  • Missing index: nspd_geo_targets.cad_numfind_active_on_demand_job / find_recent_completed_job фильтруют по t.cad_num, индекс (job_id, cad_num, thematic_id) UNIQUE здесь не помогает. Создать data/sql/NN_on_demand_indexes.sql с CREATE INDEX nspd_geo_targets_cad_num_idx ON nspd_geo_targets(cad_num) + composite (source_kind, status) на nspd_geo_jobs. Не блокер при текущем размере таблицы, нужно при росте.

Backend — backend/app/api/v1/parcels.py:

  • Threadpool exhaustion (documented tradeoff): sync def analyze_parcel блокирует один FastAPI threadpool slot до 25s на inline-wait. При concurrent surge (>40 одновременных «миссинг cad» запросов) threadpool сатурируется. Добавить комментарий о cost или снизить _INLINE_FETCH_WAIT_S до 15s.

Frontend — frontend/src/hooks/useSiteAnalysis.ts:797-801:

  • UI flicker: setFetchingState(null) вызывается до того как resolve'нется second apiFetch после status=ready. Между этими моментами и fetchingState, и isPending могут быть false → пустой экран на ~1 RTT. Fix: переместить setFetchingState(null) после await second.

Frontend — frontend/src/components/site-finder/FetchingState.tsx:

  • Convention deviation: компонент использует inline styles, не Tailwind (CLAUDE.md: «Tailwind 4 utility classes, inline styles только для динамики»). Динамика — только width: ${progressPct}%, остальное — статика → должно быть Tailwind. Cosmetic, не блокер.
  • <style jsx> для @keyframes fetch-spin → можно заменить Tailwind animate-spin для консистентности с остальными spinner'ами.

Positive:

  • SQL parameterization корректна, все :c bindings, нет f-string injection.
  • validate_cad_format gate перед DB touch.
  • psycopg v3, httpx, no print(), no requests.
  • cancelledRef.current check корректный.
  • 503 + Retry-After: 60 header — хороший API hygiene.
⚠️ Auto-review: minor (non-blocking) **Backend — `backend/app/services/site_finder/cadastre_fetch.py`:** - Race condition: docstring header упоминает `SELECT FOR UPDATE`, но SQL в `find_active_on_demand_job:252-275` обычный SELECT. Два параллельных запроса на один cad могут оба создать новый job. Fix: либо `pg_try_advisory_xact_lock(hashtext(cad_num))` обернуть шаги 3-6 `find_or_enqueue_fetch`, либо убрать упоминание FOR UPDATE из docstring + добавить `TODO(#93): add advisory lock for strict dedup`. Severity: low (дубль job, не данные). - Missing index: `nspd_geo_targets.cad_num` — `find_active_on_demand_job` / `find_recent_completed_job` фильтруют по `t.cad_num`, индекс `(job_id, cad_num, thematic_id)` UNIQUE здесь не помогает. Создать `data/sql/NN_on_demand_indexes.sql` с `CREATE INDEX nspd_geo_targets_cad_num_idx ON nspd_geo_targets(cad_num)` + composite `(source_kind, status)` на `nspd_geo_jobs`. Не блокер при текущем размере таблицы, нужно при росте. **Backend — `backend/app/api/v1/parcels.py`:** - Threadpool exhaustion (documented tradeoff): sync `def analyze_parcel` блокирует один FastAPI threadpool slot до 25s на inline-wait. При concurrent surge (>40 одновременных «миссинг cad» запросов) threadpool сатурируется. Добавить комментарий о cost или снизить `_INLINE_FETCH_WAIT_S` до 15s. **Frontend — `frontend/src/hooks/useSiteAnalysis.ts:797-801`:** - UI flicker: `setFetchingState(null)` вызывается до того как resolve'нется second `apiFetch` после status=ready. Между этими моментами и `fetchingState`, и `isPending` могут быть false → пустой экран на ~1 RTT. Fix: переместить `setFetchingState(null)` после `await second`. **Frontend — `frontend/src/components/site-finder/FetchingState.tsx`:** - Convention deviation: компонент использует inline styles, не Tailwind (CLAUDE.md: «Tailwind 4 utility classes, inline styles только для динамики»). Динамика — только `width: ${progressPct}%`, остальное — статика → должно быть Tailwind. Cosmetic, не блокер. - `<style jsx>` для `@keyframes fetch-spin` → можно заменить Tailwind `animate-spin` для консистентности с остальными spinner'ами. **Positive:** - SQL parameterization корректна, все `:c` bindings, нет f-string injection. - `validate_cad_format` gate перед DB touch. - psycopg v3, httpx, no `print()`, no `requests`. - `cancelledRef.current` check корректный. - 503 + `Retry-After: 60` header — хороший API hygiene. <!-- gendesign-review-bot: sha=2252236 verdict=minor -->
lekss361 commented 2026-05-12 05:59:11 +00:00 (Migrated from github.com)

Применено 4 из 6 findings в `cce2bf3`, 2 — оставлены с обоснованием:

Backend:

  1. Race condition — добавлен `pg_try_advisory_xact_lock(hashtext(cad_num))` вокруг "check active → enqueue" в `find_or_enqueue_fetch`. Lock transaction-scoped, released at COMMIT. Параллельные запросы: первый получает lock и enqueue; второй lock=false → re-check active job (уже виден после первого COMMIT) → тот же job_id. Docstring обновлён, упоминание SELECT FOR UPDATE удалено.

  2. Missing indexes — новая миграция `data/sql/87_on_demand_indexes.sql`:

    • `nspd_geo_targets_cad_num_idx ON nspd_geo_targets(cad_num)`
    • composite `nspd_geo_jobs_source_status_idx ON nspd_geo_jobs(source_kind, status)`
  3. Threadpool exhaustion — `_INLINE_FETCH_WAIT_S` снижен 25→15s с подробным комментарием про anyio default 40 slots, ~70% fast path при avg НСПД 5-15s.

Frontend:
4. UI flicker — `setFetchingState(null)` перемещён ПОСЛЕ `await second apiFetch`.

Rebuttal (не применено):
5/6. Tailwind convention / `animate-spin` — проверил: в проекте нет ни `globals.css`, ни `@tailwind` directives. ВСЕ существующие site-finder components (ConfidenceBadge, GeotechRiskBlock, ScoreBreakdownPanel, Pipeline24moBlock и т.д.) используют inline styles. Tailwind в deps, но не wired up (видимо legacy). FetchingState.tsx consistent с siblings — keep inline. `

Применено 4 из 6 findings в \`cce2bf3\`, 2 — оставлены с обоснованием: **Backend:** 1. ✅ Race condition — добавлен \`pg_try_advisory_xact_lock(hashtext(cad_num))\` вокруг "check active → enqueue" в \`find_or_enqueue_fetch\`. Lock transaction-scoped, released at COMMIT. Параллельные запросы: первый получает lock и enqueue; второй lock=false → re-check active job (уже виден после первого COMMIT) → тот же job_id. Docstring обновлён, упоминание SELECT FOR UPDATE удалено. 2. ✅ Missing indexes — новая миграция \`data/sql/87_on_demand_indexes.sql\`: - \`nspd_geo_targets_cad_num_idx ON nspd_geo_targets(cad_num)\` - composite \`nspd_geo_jobs_source_status_idx ON nspd_geo_jobs(source_kind, status)\` 3. ✅ Threadpool exhaustion — \`_INLINE_FETCH_WAIT_S\` снижен 25→15s с подробным комментарием про anyio default 40 slots, ~70% fast path при avg НСПД 5-15s. **Frontend:** 4. ✅ UI flicker — \`setFetchingState(null)\` перемещён ПОСЛЕ \`await second apiFetch\`. **Rebuttal (не применено):** 5/6. ❌ Tailwind convention / \`animate-spin\` — проверил: в проекте **нет ни \`globals.css\`, ни \`@tailwind\` directives**. ВСЕ существующие site-finder components (ConfidenceBadge, GeotechRiskBlock, ScoreBreakdownPanel, Pipeline24moBlock и т.д.) используют inline styles. Tailwind в deps, но не wired up (видимо legacy). FetchingState.tsx consistent с siblings — keep inline. \`<style jsx>\` — built-in Next.js, валидный паттерн без зависимостей от Tailwind globals. \`ruff\` + \`tsc\` + \`lint\` — clean. Ждём re-review.
lekss361 commented 2026-05-12 06:02:08 +00:00 (Migrated from github.com)

Auto-review passed (LGTM) — все 4 applied findings корректны:

  1. Advisory lockpg_try_advisory_xact_lock(hashtext(cad_num)) transaction-scoped, auto-release at COMMIT. Double-check pattern (find_active → lock → re-check → enqueue) корректен. Hash collision risk acceptable — benign false-serialization, не correctness bug.
  2. Migration 87_on_demand_indexes.sqlIF NOT EXISTS, BEGIN/COMMIT, composite ordering (source_kind, status) правильный (equality first). Соответствует существующим запросам.
  3. _INLINE_FETCH_WAIT_S 25→15s — threadpool math корректна (anyio 40 slots × 15s).
  4. UI flicker fixsetFetchingState(null) после await second apiFetch.

Rebuttals accepted — все site-finder components используют inline styles, FetchingState consistent с siblings; <style jsx> — стандартный Next.js паттерн без extra deps.

Minor cosmetic (не блокер): docstring analyze_parcel всё ещё пишет "до 25с" вместо 15с. Можно поправить in-place при следующем коммите.

Merging.

✅ Auto-review passed (LGTM) — все 4 applied findings корректны: 1. **Advisory lock** — `pg_try_advisory_xact_lock(hashtext(cad_num))` transaction-scoped, auto-release at COMMIT. Double-check pattern (find_active → lock → re-check → enqueue) корректен. Hash collision risk acceptable — benign false-serialization, не correctness bug. 2. **Migration `87_on_demand_indexes.sql`** — `IF NOT EXISTS`, `BEGIN/COMMIT`, composite ordering `(source_kind, status)` правильный (equality first). Соответствует существующим запросам. 3. **`_INLINE_FETCH_WAIT_S` 25→15s** — threadpool math корректна (anyio 40 slots × 15s). 4. **UI flicker fix** — `setFetchingState(null)` после `await second apiFetch`. Rebuttals **accepted** — все site-finder components используют inline styles, FetchingState consistent с siblings; `<style jsx>` — стандартный Next.js паттерн без extra deps. Minor cosmetic (не блокер): docstring `analyze_parcel` всё ещё пишет "до 25с" вместо 15с. Можно поправить in-place при следующем коммите. Merging. <!-- gendesign-review-bot: sha=cce2bf3 verdict=approve -->
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#95
No description provided.