feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1) #206
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#206
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/201-inline-weights"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Critical UX fix для #114 — user-drag слайдеры в
WeightProfilePanelтеперь применяются immediately к scoring, без обязательного profile save. Issue #201 Phase 1.Backend
POST /api/v1/parcels/{cad_num}/analyzeтеперь принимает optionalbody: AnalyzeRequest = { weights: dict[str, float] | None }ALLOWED_CATEGORIES, values ∈[MIN_WEIGHT, MAX_WEIGHT]→ 422 при violation{**_POI_WEIGHTS, **inline_weights}— только переданные categories override, остальные keep defaultsFrontend
useSiteAnalysis.ts—weights?: Record<string, number> | nullparam в analyze mutation, body JSON conditionalpage.tsx—handleAnalyzeвсегда передаётcurrentWeightsеслиactiveProfileId=null(раньше draft игнорировался);handleWeightsChangere-trigger analyze immediately если parcel loadedЧто НЕ в этом PR (sequenced sub-PRs)
lib/api/weightProfiles.ts, не wired)Test plan
pytest tests/api/v1/test_analyze_inline_weights.py -v(5 tests)ruff check + format, no:x::type, no f-string SQLtsc --noEmitclean,npm run lintclean,npm run buildgreenCarryover bugs (НЕ в скоупе)
competitors.py:143WHERE f.status='sold'— отдельный follow-up issueCloses part of #201. Phase 2/3 — отдельные PR'ы после merge этого.
Backend (~330 LOC): - POST /api/v1/parcels/{cad_num}/best-layouts/pdf via WeasyPrint - services/exporters/layout_tz_pdf.py — HTML template с XSS-escape (html.escape) для cad_num/parcel_address/rationale_text/time_window - 5 unit tests (skip on Windows если WeasyPrint deps missing) Frontend (~800 LOC): - BestLayoutsBlock.tsx — DataQualityCard + TopLayoutsTable + RecommendationCard + PDF download button (Blob → revokeObjectURL) - useBestLayouts.ts — TanStack useMutation hook - types/best-layouts.ts — manual TS types - MarketTab.tsx +4 LOC integration после competitors Pre-push fixes per review: - XSS escape user-data в PDF HTML - sold_pct_of_supply — убрать double ×100 (backend уже 0..100) - alert() → pdfError state - isNaN → Number.isNaN - 7 inline-styles → Tailwind utilities Phase 2.1 complete. Phase 2.2 (layout_type/balcony_count via B2B Объектив #52) — follow-up issue.Deep Code Review — verdict
Summary
038e39e· Base:256909d· Mergeable: true, no conflicts#113 PR D(PDF endpoint + BestLayoutsBlock UI, +1184 LOC) и#201 Phase 1(inline weights, +419 LOC). Both verified.🔴 Critical
(none)
🟠 High
(none)
🟡 Medium (желательно fix, можно follow-up)
backend/app/api/v1/parcels.py:1257-1259— NaN bypass в inline weights validation.Pydantic v2 with default
allow_inf_nan=Trueпринимает{"weights": {"school": NaN}}(Pythonjson.loadsтоже парситNaNliteral). Текущая проверкаv < _MIN_WEIGHT or v > _MAX_WEIGHTвозвращаетFalseдля NaN (любое сравнение с NaN → False), поэтому NaN проходит. Дальшеscore += w * decay→ score становится NaN → response.score: NaN → frontendJSON.parseпринимает (Node) или ломается (strict JSON). Не эксплуатируется через UI (слайдеры дают finite floats), но тривиально черезcurl.Fix (минимально):
Или на уровне Pydantic:
weights: dict[str, Annotated[float, Field(allow_inf_nan=False)]]. Тот же fix желательно применить вweight_profiles.py:_validate_weights_dict(та же дыра в saved-profile path — pre-existing, не введено этим PR, но логически связано).🟢 Low / nits (отдельным PR)
backend/app/api/v1/parcels.py:302иbackend/app/services/site_finder/weight_profiles.py:48— duplicated_POI_WEIGHTS/_SYSTEM_POI_WEIGHTS. Сейчас идентичны (11 категорий), помечены "keep in sync" комментарием. Drift-risk при добавлении 12-й категории. Single source of truth: re-export_SYSTEM_POI_WEIGHTSизweight_profilesвparcels.py. Не блокирующее — drift пока не случился.backend/tests/api/v1/test_analyze_inline_weights.py:445-478— мок 16db.executecalls по индексу — крайне fragile. Любая перестановка SQL-запросов вanalyze_parcelсломает тесты. Уже отмечено в pre-push review. Long-term refactor: вместо positional mock — patch per-helper-функции (_compute_score,_load_competitors) и оставить только integration test с реальной БД. Follow-up.frontend/src/components/site-finder/BestLayoutsBlock.tsx(#113 PR D) — 7 sub-components в одном 761-LOC файле с inlinestyle={...}(40+ блоков). Pre-push review уже флагал. Cleanup до Tailwind utilities — follow-up.frontend/src/types/best-layouts.ts— manual TS types, не сгенерены черезnpm run codegen. Комментарий в файле признаёт это. При следующемcodegenrun эти типы должны быть удалены и заменены OpenAPI-сгенеренными. Не блок, но не забыть.Cross-file impact analysis
useSiteAnalysisAPI contract (AnalyzeOptions.weights?added) — единственный consumerfrontend/src/app/site-finder/page.tsx:95. Updated. Безопасно.POST /analyzebody added — backward compat сохранён:AnalyzeRequest | None = None. Старые clients (без body) →body is None→ fallback на старую_resolve_weightsветку. Testtest_analyze_no_body_uses_system_defaultsпокрывает.weights_profile.inline_weights— additive, Optional. FrontendParcelAnalysistypes обновлять опционально (если поле читается). Не ломает._POI_WEIGHTSvsALLOWED_CATEGORIES(drift check): 11 keys в обоих, идентичны. ОК.handleWeightsChangetrigger: Я опасался, что каждый slider drag → re-analyze (full pipeline = ~5-15 db.execute + httpx Weather + Velocity Celery). На деле —WeightProfilePanel.tsx:124 handleApply()вызываетonWeightsChangeТОЛЬКО по кнопке "Применить" (или при load profile). Внутренний 300ms debounce вhandleSliderChange— для visual update draft state, не для callback. Performance concern из brief'а — mitigated by UX design. Phase 2 debounce будет нужен только если изменят семантику apply-on-drag./best-layouts/pdfendpoint — pdf renderer применяетhtml.escape()на user-controllable:cad_num,parcel_address,time_window,room_bucket,area_bin,rationale_text.dq.confidence(используется как CSS class) защищён PydanticLiteral["high","medium","low"]. XSS surface clean.Vault cross-check
fixes/Fix_InlineWeights_BodyIgnored_201.mdуже создан для этого fix — соответствует CLAUDE.md rule #6.code/modules/Module_Weight_Profiles_Frontend.md/Module_Weight_Profiles_Service.md— есть. Желательно обновить после merge: добавить inline-weights priority order вModule_Weight_Profiles_Service.md.decisions/, нарушающих этот PR, не найдено.Conventions check
CAST(:x AS type)соблюдён (verified в schema файле).requestsimports.print(): нет.any: новые TS types все строго типизированы (Record<string, number>, generic Confidence).useBestLayoutsиспользуетuseMutationкорректно.Field(default=None, description=...)— canonical pattern..claude/rules/frontend.md("Tailwind 4 утилитарные классы; inline styles только для динамических значений"). Большая часть — статические. Не блок, но nit.Security findings
/analyze— read-only, без auth (existing pattern)./best-layouts/pdfтоже без auth — consistent с другими/best-layoutsendpoints, безопасно (read-only, no PII).:x::typeconcat._html.escape()применён в PDF renderer для всех user-controllable fields.tz-layout-{cad_safe}-{today}.pdf—cad_num.replace(":", "-"), не пропускает/или... Но если бы cad_num содержал/, ушло бы в Content-Disposition. Сейчас формат kadastr66:41:0204016:10— только digits + colons → safe.Performance findings
handleWeightsChange— task brief opasался: "каждый weight change → re-trigger". Реальность: trigger только поhandleApply("Применить" button). Each apply → fullanalyze_parcelpipeline (~15 SQL queries + Weather httpx 5s timeout × 2 + Celery enqueue check). На 100ms RTT БД + 200ms Weather → ~3-7s per re-analyze. Acceptable для on-demand UX, но Phase 2 debounce + cancel-in-flight нужен ЕСЛИ позже изменят на apply-on-drag./best-layouts/pdfsynchronously вызывает WeasyPrint (CPU-bound, ~1-3s). Должно быть в FastAPI thread pool. Используетсяasync def+Response(content=...)безrun_in_threadpool— потенциальный event loop block. Pre-existing pattern, follow-up.Positive observations
detail422 (помогает диагностике: указывают конкретные категории + диапазон).{**_POI_WEIGHTS, **inline_weights}— корректно: если фронт послал{kindergarten: 2.5}, остальные 10 категорий keep defaults. Testtest_analyze_inline_weights_appliedпроверяет.response.weights_profile.inline_weightsecho'нет обратно user input для debugging.fixes/Fix_InlineWeights_BodyIgnored_201.md.434341eуже устранил замечания review-bot (404 handling + XSS escape + locale dates + clamp).competitors.py:143 WHERE f.status='sold'— упомянут в PR body как НЕ в скоупе, отдельный issue. Подтверждено что не трогает (verified в diff). Не блокирует.Recommended next steps
math.isfinite()guard в inline weights validation (+ доп. unit-test с{"school": "NaN"}payload через raw httpx)._POI_WEIGHTS(re-export изweight_profiles).run_in_threadpoolобёртка для WeasyPrint.code/modules/Module_Weight_Profiles_Service.md(priority order inline→profile→user→system).Complexity / blast radius
038e39e— обратимо без data loss; #113 PR D commits 5878596+434341e — также чисто обратимы)Auto-merge decision
❌ BLOCKED for auto-merge. Scope =
backend/app/api/v1/parcels.py+backend/app/services/exporters/**+backend/app/schemas/parcel.py— все в.claude/rules/git-pr.mdblocked-list (backend/app/api/v1/**,backend/app/services/**). Even при ✅ verdict — нужен human merge per scope policy.Verdict сам по себе 🟡 MEDIUM (NaN issue). Recommend: либо human fix NaN guard + re-push, либо human merge как есть с отдельным follow-up issue (impact низкий — exploitable только через ручной curl).
Ping @lekss361 для merge decision.
Deep Code Review — re-verdict (after fixup
cada96e)Summary
038e39e(🟡 MEDIUM — 1 NaN bypass, 4 🟢 nits)cada96e(fixup +35/-19, 3 files touched)🟡 → ✅ NaN bypass resolved
Confirmed fix in 3 layers:
backend/app/api/v1/parcels.py:1257(inline validation):Plain
NaN < MINevaluatesFalse→ bypass теперь невозможен. ✅backend/app/services/site_finder/weight_profiles.py:118— defense-in-depth fix для_validate_weights_dict(pre-existing dup bug на CRUD path для сохранённых профилей). Bonus. ✅backend/tests/api/v1/test_analyze_inline_weights.py::test_inline_weights_rejects_nan— regression test: отправляет raw bytesb'{"weights": {"school": NaN}}'(т.к. httpx не encodefloat('nan')), expect 422. ✅🟢 → ✅
_POI_WEIGHTSduplication resolved_POI_WEIGHTSтеперь не локальная константа вparcels.py, а импорт изweight_profiles._SYSTEM_POI_WEIGHTS(SSOT). Docstring обновлён ("single source of truth; imported by api/v1/parcels.py"). Drift risk устранён. ✅🟢 Остающиеся nits (informational, не блокирующие)
test_analyze_inline_weights.py— 16-call positional mock в_make_db_for_analyzeостался brittle (см. комментарии 0..15). Если вanalyze_parcelпоменяется порядок execute() — тесты молча сломаются. Recommendation: дальше через side_effect dispatch on SQL pattern, но это рефакторинг не для этого PR.BestLayoutsBlock.tsx761 LOC inline-style heavy — Tailwind migration отдельным PR.frontend/src/types/best-layouts.ts— manual TS types, расхождение с Pydantic возможно. Runnpm run codegenесли/когда codegen wired для этого endpoint._inline_weightsхранится в response.weights_profile.inline_weights какNoneили объект — frontend должен handle оба case'а (видно вuseSiteAnalysis.tsкорректно).Cross-file impact analysis
_POI_WEIGHTSимпорт вparcels.py— проверил: используется вscoreaccumulation (line ~1267), вweights_appliedresponse field (line ~1965). Все consumers получают тот же dict object — OK.math.isfinite—import mathуже был вweight_profiles.py(added строка 19), не сломал ничего ещё импортирующего модуль.test_analyze_inline_weights.py— изолированный, используетdependency_overrides+patch()для тяжёлых вызовов — не зависит от env / реальной DB.Pre-merge checks
mergeable=trueopencada96ematches scanAuto-merge policy
❌ BLOCKED for auto-merge per
.claude/agents/deep-code-reviewer.md:PR touches
backend/app/api/v1/**,backend/app/services/**,backend/app/schemas/**→ human approval required даже при ✅ APPROVE.Recommended action
Human merge (squash + delete branch). Все 🟡 findings resolved, no new issues introduced fixup'ом.
Complexity / blast radius
weightsfield optional)