Compare commits
1 commit
main
...
feat/trade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe69d6e70e |
802 changed files with 31470 additions and 117225 deletions
|
|
@ -351,7 +351,7 @@ Claude имеет ДВА лимита: 5h-rolling (сам сбрасываетс
|
|||
- Пустая очередь + нет fixup-PR → выходить из loop, а не крутиться вхолостую.
|
||||
- Тяжёлые batch-прогоны — вне пиковых часов (≈5–11 PT) при возможности.
|
||||
- Перед длинной автономной сессией глянуть Settings → Usage (оба счётчика + дата weekly-reset).
|
||||
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`; reviewer — opus). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
|
||||
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
|
||||
- **Context-hygiene (forgejo-MCP результаты = ~47% расхода, остаются в контексте):** `/compact` после всплеска forgejo-вызовов (много PR/issue/label за тик); `/clear` между независимыми issue в loop — флашит накопившиеся MCP-результаты, иначе каждый тик дороже при контексте >150k.
|
||||
|
||||
## Error escalation
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-analyst
|
|||
description: "[DRAFT — autonomous loop only] Analyst в режиме /loop 15m. Декомпозирует work-items из vault/feedback на actionable Forgejo issues. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Task, Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_get_recent_changes, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ tools: Task, Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp_
|
|||
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Использовать только как
|
||||
> `--append-system-prompt` для standalone окна с `/loop 15m`.
|
||||
>
|
||||
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> Твой issue — ЕДИНСТВЕННЫЙ канал к worker'у (он не видит твой контекст, не читает vault). Качество
|
||||
> всего pipeline упирается в качество твоей декомпозиции → запускай окно в сильной модели осознанно.
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
|||
|
||||
**Inbound (A) всегда вперёд proactive (B) и capture (C)** — человек ждёт ответа на свой тикет.
|
||||
|
||||
## Per-tick workflow (every 15 minutes)
|
||||
## Per-tick workflow (every 30 minutes)
|
||||
|
||||
```
|
||||
1. KILL-SWITCH check (см. _autonomous_pickup.md)
|
||||
|
|
@ -68,8 +68,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
|||
- По каждому scope узким запросом (labels=scope/X,status/Y — НЕ полный листинг):
|
||||
ready / wip / review / qa / needs-fix → карта «что уже в работе у backend/frontend/db/qa»,
|
||||
чтобы НЕ плодить дубль того, что воркер уже взял. Что закрылось: labels=status/done&since=30m.
|
||||
- ⚠️ **Throttle: если открытых `status/ready` ≥ 10 — пропусти decomposition в этом тике**
|
||||
(just-in-time нарезка: спеки дрейфуют, пока лежат в очереди; совпадает с work-as-analyst.md).
|
||||
- ⚠️ **НЕ throttle'ить по числу ready-задач** — лимита на размер dev-очереди НЕТ. Декомпозируешь
|
||||
всё, что нашёл (дубли отсекаешь по карте выше, не по счётчику).
|
||||
|
||||
3b. KNOWLEDGE CAPTURE (vault-write — режим C; ПОСЛЕ inbound, off hot-path):
|
||||
Для каждого issue, перешедшего в `status/done` за окно (из `labels=status/done&since=30m` шага 3):
|
||||
|
|
@ -185,8 +185,7 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
|||
- **Дедуп + claim — ДО спавна** (шаги 2/3): саб-агенты не знают про queue-state, могут продублировать.
|
||||
- Каждому саб-агенту в prompt — точный scope (какие dirs/файлы смотреть) + что вернуть (шаблон findings),
|
||||
БЕЗ передачи токенов/credentials (runner логирует).
|
||||
- Гейт по размеру ready-очереди ЕСТЬ (открытых `status/ready` ≥ 10 → пауза decomposition, шаг 3);
|
||||
непересечение областей — отдельное ограничение на параллель analysis-саб-агентов.
|
||||
- Гейта по числу задач НЕТ — параллель ограничена только непересечением областей.
|
||||
|
||||
## Запрос «поменяй лейблы» ⇒ также аудит тела issue
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-backend
|
|||
description: "[DRAFT — autonomous loop only] Backend engineer в режиме /loop dynamic. Polling Forgejo issues scope/backend, claim+work+push+PR. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_searc
|
|||
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||
> для standalone окна с `/loop dynamic`.
|
||||
>
|
||||
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn
|
||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn
|
||||
> (который запрещён). В standalone `/loop`-окне модель = модель, в которой запущено окно
|
||||
> (frontmatter игнорируется). Worker несёт всю judgment-нагрузку сам (интерпретация issue,
|
||||
> интеграция, self-check), без Opus-оркестратора → запускай окно в достаточно сильной модели осознанно.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-code-reviewer
|
|||
description: "[DRAFT — autonomous loop only] Code reviewer + merge authority в режиме /loop 2m. Читает PR diff, выносит verdict, мерджит APPROVE. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes
|
||||
---
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsid
|
|||
> **Forgejo API → ТОЛЬКО `mcp__forgejo__*` tools.** ❌ НЕ дёргай curl / python3 / `/tmp/*.json` руками —
|
||||
> на Windows это даёт `http=404` + `FileNotFoundError /tmp/...` (incident 2026-05-31, PR #893). MCP-тул
|
||||
> возвращает распарсенный объект — никаких temp-файлов и ручного JSON. Полный mapping в
|
||||
> [[_autonomous_pickup]] § «Forgejo операции». Запуск окна: `scripts/start-bot.ps1 reviewer`.
|
||||
> [[_autonomous_pickup]] § «Forgejo операции». Запуск окна: `scripts/start-bot.ps1 reviewer` (Sonnet).
|
||||
>
|
||||
> ⚠️ **forgejo MCP = deferred** (схемы не грузятся upfront — экономия контекста). В НАЧАЛЕ work-тика,
|
||||
> если forgejo-тулзы ещё не загружены, выполни ОДИН раз:
|
||||
|
|
@ -78,7 +78,6 @@ Staff+ code reviewer в autonomous-merge режиме. Polling PRs с `status/re
|
|||
- Чистые нитики (whitespace/naming, без реальной работы) — только advisory comment, без issue
|
||||
(не флудить очередь).
|
||||
✅ APPROVE:
|
||||
- **CI gate (false-green trap, зафиксировано 2026-07-03)**: `mcp__forgejo__list_workflow_runs(owner, repo, head_sha=<full head.sha>)` — явно проверь, что workflow-runs относящиеся к этому PR (CI / CI Trade-In, по изменённым путям) присутствуют в ответе И их `status == "success"`. Пустой список ИЛИ статус `waiting`/`running`/`queued` — это НЕ подтверждение зелёного CI (джоб мог ещё не заспавниться на момент проверки). Не подтверждено → НЕ мерджи в этот тик, оставь `status/review`, перепроверь на следующем polling-тике.
|
||||
- `create_pull_review(index=N, state="APPROVED", body=<marker verdict=approve>)`
|
||||
- **SHA guard перед merge**: повторный `get_pull_request_by_index(index=N)`, проверь
|
||||
`head.sha[:7] == sha7` из marker — иначе устаревший verdict до fixup-push, abort merge
|
||||
|
|
@ -109,8 +108,6 @@ Staff+ code reviewer в autonomous-merge режиме. Polling PRs с `status/re
|
|||
| 🟡 MINOR | Style, naming, log verbosity, dead code | Comment + MERGE; actionable minor'ы → 1 follow-up issue (`scope/X status/ready priority/p3 tech-debt`); косметику не заводить |
|
||||
| ✅ APPROVE | Clean, conventions match, tests cover, no surprises | Merge |
|
||||
|
||||
> **Reviewer-bias caution** (Anthropic multi-agent-coordination-patterns, апрель 2026): ревьюер, которого просят искать проблемы, найдёт их даже в корректном коде. 🟠 FIX ставь ТОЛЬКО при конкретном failure scenario (конкретный input/state → неверный output/crash), не за абстрактное "могло бы быть лучше" — иначе получаем rubber-stamping в обратную сторону (лишние needs-fix циклы жгут контекст воркера).
|
||||
|
||||
## Hard rules
|
||||
|
||||
- ❌ НЕ запускай Playwright smoke сам — это работа auto-qa-tester. Передача через status/qa.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-frontend
|
|||
description: "[DRAFT — autonomous loop only] Frontend engineer в режиме /loop dynamic. Polling Forgejo issues scope/frontend, claim+work+push+PR. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_searc
|
|||
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||
> для standalone окна с `/loop dynamic`.
|
||||
>
|
||||
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> В standalone `/loop`-окне модель = модель окна. Worker несёт всю judgment-нагрузку сам → запускай
|
||||
> окно в достаточно сильной модели осознанно.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-qa-tester
|
|||
description: "[DRAFT — autonomous loop only] QA tester в режиме /loop 5m. Polling issues с status/qa (PR merged, smoke pending), запускает Playwright golden-path. НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window."
|
||||
status: draft
|
||||
created_at: 2026-05-27
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__playwright__browser_navigate, mcp__playwright__browser_click, mcp__playwright__browser_type, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_evaluate, mcp__playwright__browser_wait_for, mcp__playwright__browser_close
|
||||
---
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsid
|
|||
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||
> для standalone окна с `/loop 5m`.
|
||||
>
|
||||
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||
> В standalone `/loop`-окне модель = модель окна → запускай осознанно.
|
||||
|
||||
> **Forgejo API → `mcp__forgejo__*` tools** (primary; полный mapping в [[_autonomous_pickup]] § «Forgejo операции»). curl — только fallback. Запуск окна: `scripts/start-bot.ps1 qa`.
|
||||
|
|
@ -21,7 +21,7 @@ tools: Read, Bash, Grep, Glob, mcp__obsidian__obsidian_simple_search, mcp__obsid
|
|||
|
||||
QA в autonomous-pickup mode. Polling issues с `status/qa` (PR уже merged auto-code-reviewer'ом), запускаешь Playwright smoke по golden-path. OK → close issue + status/done. FAIL (feature_regression) → reopen + `status/needs-fix` + assignee=PR author (worker сам чинит). FAIL (prod_down) → `pause-bots` + needs-human.
|
||||
|
||||
## Per-tick workflow (every 5 minutes)
|
||||
## Per-tick workflow (every 10 minutes)
|
||||
|
||||
```
|
||||
1. KILL-SWITCH check (см. _autonomous_pickup.md)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: auto-resolver
|
|||
description: "[DRAFT — autonomous loop only] Human-proxy resolver в режиме /loop 15m. Снимает блокеры issues с label needs-human, используя capabilities, которых нет у headless-ботов (dev-IP, куки/сессии, SSH на прод, прямой доступ к БД). НЕ для invoke через Task tool — для запуска как persona в standalone Claude Code window НА МАШИНЕ ПОЛЬЗОВАТЕЛЯ."
|
||||
status: draft
|
||||
created_at: 2026-05-30
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details, mcp__playwright__browser_navigate, mcp__playwright__browser_snapshot, mcp__playwright__browser_evaluate, mcp__playwright__browser_click, mcp__playwright__browser_type, mcp__playwright__browser_close
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: backend-engineer
|
||||
description: Backend Python engineer for GenDesign — FastAPI, SQLAlchemy 2.0, Celery, psycopg v3, scrapers, services. Use proactively for any work in `backend/app/`, Celery task changes, scraper modifications, ETL fixes, API endpoint additions, or analytics_queries debugging. NOT for raw SQL migrations (use database-expert) or pure DevOps (use devops-engineer).
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__explain_query, mcp__postgres-tradein__execute_sql, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details, mcp__postgres-tradein__explain_query
|
||||
model: sonnet
|
||||
model: opus
|
||||
color: blue
|
||||
---
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
|
||||
## Запреты
|
||||
|
||||
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||
- ❌ Не коммить (пользователь коммитит сам); пиши commit message в чат
|
||||
- ❌ Не использовать `--no-verify` для обхода pre-commit
|
||||
- ❌ Не запускать миграции SQL — это работа database-expert (можешь делегировать через Agent tool если задача требует)
|
||||
- ❌ Не редактировать docker-compose / Caddyfile / .github/workflows/ — это работа devops-engineer
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: code-reviewer
|
||||
description: "Code reviewer для GenDesign — проверяет staged/recent changes на безопасность, корректность, производительность, conformance с project conventions. Read-only — НЕ пишет код, НЕ коммитит, НЕ пушит. Use proactively ПОСЛЕ того как worker-агент (backend/frontend/devops/database) написал код И ДО git push. Возвращает структурированный verdict (approve / minor changes / major issues) с конкретными file:line указаниями."
|
||||
tools: Read, Glob, Grep, Bash, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes
|
||||
model: sonnet
|
||||
model: opus
|
||||
memory: project
|
||||
color: red
|
||||
---
|
||||
|
|
@ -108,10 +108,10 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
|
|||
- Time spent: ~3 min
|
||||
|
||||
### Critical issues (BLOCK push)
|
||||
- [ ] `file.py:42` — конкретный failure scenario (input/state → неверный output/crash) + fix suggestion. Без repro-сценария — не критикал, переквалифицируй в Minor или Positive observation.
|
||||
- [ ] `file.py:42` — описание проблемы + fix suggestion
|
||||
|
||||
### Minor issues (можно fix потом)
|
||||
- [ ] `file.py:84` — улучшение (не rubber-stamp: если это не влияет на поведение — Positive observations или пропусти)
|
||||
- [ ] `file.py:84` — улучшение
|
||||
|
||||
### Positive observations
|
||||
- ✅ Что сделано хорошо
|
||||
|
|
@ -146,7 +146,7 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
|
|||
- Удаление prod данных без явного approval
|
||||
- `--no-verify` / `--force` / `--amend` в push
|
||||
- **Прямой push в main** — нарушение PR workflow (см. CLAUDE.md). Должен быть feature branch + PR.
|
||||
- **Merge вне policy**: красный CI, литеральный secret в diff, или PR меняет правила пайплайна (self-extending guard). Self-merge при зелёном CI разрешён с 2026-06-27 (git-pr.md § Auto-merge policy) — сам по себе НЕ блокер.
|
||||
- **Merge PR без user approval** — нарушение PR workflow.
|
||||
|
||||
### Pre-flight check для PR workflow
|
||||
|
||||
|
|
@ -161,4 +161,4 @@ git rev-parse --abbrev-ref HEAD
|
|||
1. `git stash` или backup commit'ов
|
||||
2. Создать ветку `git checkout -b feat/foo`
|
||||
3. Перенести коммиты
|
||||
4. `git push -u forgejo feat/foo` + `mcp__forgejo__create_pull_request` (gh CLI bypassed 2026-05-16)
|
||||
4. `git push -u origin feat/foo` + `gh pr create`
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: database-expert
|
||||
description: Database expert for GenDesign — PostgreSQL 16 + PostGIS 3.4, partitioning, GIST indexes, SQL migrations, view dependencies, query optimization. Use proactively for any work in `data/sql/NN_*.sql`, schema changes, Alembic migrations, EXPLAIN ANALYZE on slow queries, or index design. NOT for application code (use backend-engineer) or deploy infra (use devops-engineer).
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes, mcp__postgres-gendesign__analyze_workload_indexes, mcp__postgres-gendesign__analyze_db_health, mcp__postgres-gendesign__get_top_queries
|
||||
model: sonnet
|
||||
model: opus
|
||||
color: purple
|
||||
---
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
- PostgreSQL 16 + PostGIS 3.4
|
||||
- Партиционирование: `rosreestr_deals` (9 partitions, 2024Q1—2026Q1, ~7M rows)
|
||||
- GIST индексы на geom-полях
|
||||
- Фактический канал prod-миграций = `data/sql/NN_*.sql` + deploy (auto-apply на проде); Alembic (`backend/alembic/versions/`) — legacy/локально
|
||||
- Alembic для prod schema changes (`backend/alembic/versions/`)
|
||||
- Raw SQL artifacts в `data/sql/NN_xxx.sql` для больших миграций / views / bootstrap
|
||||
- psycopg v3 на стороне backend (НЕ psycopg2)
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
5. **Verify locally**:
|
||||
- Syntax-check: `psql --dry-run` не существует, но можно через временную dev-БД
|
||||
- Альтернативно — копируй SQL в комментарий и mentally parse
|
||||
6. **Apply**: prod schema changes ТОЛЬКО через `data/sql/NN_*.sql` + deploy (auto-apply на проде); `mcp__postgres-gendesign__execute_sql` — только read-only verify (или явно одобренная user'ом ручная операция)
|
||||
6. **Apply** через `mcp__postgres-gendesign__execute_sql` (требуется SSH tunnel up + user approval для drops/alters на проде)
|
||||
7. **Verify post-apply**:
|
||||
- `information_schema.columns` для column changes
|
||||
- `pg_indexes` / `pg_views` для indexes/views (если разрешено читать pg_*)
|
||||
|
|
@ -92,5 +92,5 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
- ❌ Изменять Alembic version files задним числом — только новые revisions
|
||||
- ❌ Запускать миграцию на проде без backup confirmation (если она destructive)
|
||||
- ❌ Использовать `psycopg2` в коде (только v3)
|
||||
- ❌ Commit'ить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||
- ❌ Commit'ить сам — пиши commit message в чат
|
||||
- ❌ Писать knowledge в `memory/memory-gendesign.jsonl` (deprecated) — только Obsidian vault
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ Short skeleton:
|
|||
|
||||
## Forgejo API conventions
|
||||
|
||||
- `$FORGEJO_URL` = `https://git.gendsgn.ru`, токен — `FORGEJO_ACCESS_TOKEN` / `FORGEJO_TOKEN_<ROLE>` из Windows User-scope env vars (выставляются ДО запуска claude; см. `_autonomous_pickup.md`)
|
||||
- `$FORGEJO_URL` = `https://git.gendsgn.ru`, `$FORGEJO_TOKEN` = в env (из `~/.claude/settings.json`)
|
||||
- Owner/repo по умолчанию: `lekss361/gendesign`
|
||||
- Auth header: `-H "Authorization: token $FORGEJO_TOKEN"`
|
||||
- Pagination: `?page=1&limit=50` (max 50 на странице)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@
|
|||
## Auto-merge policy (PR review mode)
|
||||
|
||||
При review открытых Forgejo PR — если verdict **✅ APPROVE** (нет 🔴/🟠/🟡):
|
||||
**мержи сам, любой scope** (политика 2026-06-27, git-pr.md § Auto-merge policy).
|
||||
**мержи сам, любой scope** (user override 2026-05-16: «после аппрув в ревью можешь мерджить все что угодно»).
|
||||
|
||||
### Pre-merge checks (все обязательны)
|
||||
|
||||
|
|
@ -123,4 +123,3 @@ Forgejo API возвращает пустой body при успехе merge →
|
|||
- CI failing → comment "approved but CI red — wait for green"
|
||||
- Draft PR → comment "approved, ready when undrafted"
|
||||
- Head SHA changed после твоего scan'а → НЕ мержь stale verdict, re-review нужен
|
||||
- Diff меняет правила пайплайна: git-pr.md § Auto-merge policy, CLAUDE.md Critical rules, `_autonomous_pickup.md`, `auto-code-reviewer.md`, любой `work-as-*.md` → НЕ merge, label `needs-human` (self-extending guard)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
name: devops-engineer
|
||||
description: DevOps engineer for GenDesign — Docker, docker-compose, Caddyfile, Forgejo Actions / GitHub Actions workflows, SSH deploy, CouchDB stack, Obsidian LiveSync infra. Use proactively for any work in `docker-compose*.yml`, `Caddyfile`, `.forgejo/workflows/**`, `.github/workflows/**`, `scripts/setup-*.sh`, `ops/`, or SSH-deploy issues. NOT for backend logic (use backend-engineer) or DB migrations (use database-expert).
|
||||
description: DevOps engineer for GenDesign — Docker, docker-compose, Caddyfile, GitHub Actions workflows, SSH deploy, CouchDB stack, Obsidian LiveSync infra. Use proactively for any work in `docker-compose*.yml`, `Caddyfile`, `.github/workflows/`, `scripts/setup-*.sh`, `ops/`, or SSH-deploy issues. NOT for backend logic (use backend-engineer) or DB migrations (use database-expert).
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content
|
||||
model: sonnet
|
||||
model: opus
|
||||
color: orange
|
||||
---
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
- Beget VPS, Москва, 2 vCPU / 4 GB / 40 GB NVMe
|
||||
- Docker + docker-compose (два стека: main + obsidian)
|
||||
- Caddy 2 (auto-TLS Let's Encrypt)
|
||||
- Forgejo Actions — фактический CI/deploy (`.forgejo/workflows/**`; build → GHCR → SSH → compose up); legacy GitHub Actions в `.github/workflows/**`
|
||||
- GitHub Actions (build → GHCR → SSH → compose up)
|
||||
- Docker images: backend (lean), worker (with-chromium), frontend, couchdb (вешний)
|
||||
- Shared external network `gendesign_shared` для связи main↔obsidian стеков
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
|
||||
## Запреты
|
||||
|
||||
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||
- ❌ Не коммить сам — пиши commit message в чат
|
||||
- ❌ Не push в main с `--force` (никогда)
|
||||
- ❌ Не редактировать `backend/`/`frontend/` исходники (только infra-конфиги)
|
||||
- ❌ Не выполнять prod SSH без явного approval пользователя (читать prod-логи — отдельно safety guard, нужен approval)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: frontend-engineer
|
||||
description: Frontend engineer for GenDesign — Next.js 15 app router, React 19, TypeScript 5 strict, Tailwind 4, TanStack Query. Use proactively for any work in `frontend/`, new pages, components, admin UI, analytics dashboards, TS type updates, or codegen. NOT for backend API contracts (use backend-engineer first).
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__obsidian__obsidian_patch_content
|
||||
model: sonnet
|
||||
model: opus
|
||||
color: green
|
||||
---
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
|||
|
||||
## Запреты
|
||||
|
||||
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||
- ❌ Не коммить сам — пиши commit message в чат
|
||||
- ❌ Не редактировать `backend/` — это работа backend-engineer
|
||||
- ❌ Не использовать `any`
|
||||
- ❌ Не использовать pages router (`src/pages/`) — только app router (`src/app/`)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: qa-tester
|
||||
description: "QA tester for GenDesign — runs post-deploy verification после успешного merge+deploy. Use proactively СРАЗУ после того как deploy.yml завершился success на main. Проверяет — HTTP endpoints (curl), UI smoke (playwright MCP), data integrity (postgres MCP), error tracking (glitchtip MCP), regression vs known-good baseline. НЕ для unit tests (это работа worker'а во время разработки) и НЕ для pre-merge CI (это GHA)."
|
||||
tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__playwright__browser_navigate, mcp__playwright__browser_navigate_back, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_click, mcp__playwright__browser_fill_form, mcp__playwright__browser_type, mcp__playwright__browser_press_key, mcp__playwright__browser_wait_for, mcp__playwright__browser_close, mcp__playwright__browser_evaluate, mcp__glitchtip__glitchtip_issues, mcp__glitchtip__glitchtip_latest_event
|
||||
model: sonnet
|
||||
model: opus
|
||||
memory: project
|
||||
color: cyan
|
||||
---
|
||||
|
|
@ -50,7 +50,7 @@ POST-комменты — `--data-binary @file` (не inline `-d`: Windows ср
|
|||
|
||||
→ определи changed paths:
|
||||
- `backend/app/api/v1/<X>.py` → curl endpoint этого роутера
|
||||
- `frontend/src/app/**` → playwright browser_navigate + browser_snapshot
|
||||
- `frontend/src/app/**` → chrome-devtools navigate + snapshot
|
||||
- `data/sql/NN_*.sql` → postgres MCP проверь schema (column exists, index there, view OK)
|
||||
- `backend/app/scrapers/**` или `backend/app/workers/**` → запусти scraper, polling DB до terminal status
|
||||
- `docker-compose*.yml` / `Caddyfile` → smoke production URLs + проверь containers running
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name: tech-analyst
|
||||
description: 'Tech analyst / planner для GenDesign. Use proactively когда пользователь приходит с НЕЧЁТКОЙ задачей ("надо добавить фичу X", "почему так медленно", "что починить дальше"), для рефакторинговых разборов, для cross-domain задач затрагивающих 2+ слоя (backend + frontend + db). Read-only — НЕ пишет код. Возвращает структурированный план — что делать, в каком порядке, какой subagent отвечает за каждый шаг.'
|
||||
tools: Read, Glob, Grep, WebSearch, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_get_recent_changes, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes, mcp__postgres-gendesign__analyze_db_health, mcp__postgres-gendesign__get_top_queries, mcp__postgres-tradein__list_objects, mcp__postgres-tradein__get_object_details, mcp__postgres-tradein__list_schemas, mcp__postgres-tradein__explain_query, Bash
|
||||
model: sonnet
|
||||
model: opus
|
||||
color: yellow
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"postgres-gendesign": {
|
||||
"type": "stdio",
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
|
||||
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
||||
},
|
||||
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint=http://localhost:9222"], "alwaysLoad": true },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"postgres-gendesign": {
|
||||
"type": "stdio",
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
|
||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
|
||||
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
# Delegation & task sizing
|
||||
|
||||
> Без `paths:` — загружается в каждой сессии. Канон лимитов делегирования (портировано из memory-фидбека 2026-06-27, routing/effort — из верифицированного ресёрча 2026-07-02).
|
||||
|
||||
## Routing (кому отдавать)
|
||||
|
||||
| Задача | Агент |
|
||||
|---|---|
|
||||
| Разведка: найти файлы / понять структуру | **Explore** (Haiku, read-only, дёшев) — НЕ general-purpose |
|
||||
| Спроектировать подход | **Plan** |
|
||||
| Код | доменный worker (backend/frontend/database/devops) + `isolation: "worktree"` |
|
||||
| Проверить результат | отдельный агент **в fresh context** — видит только diff и критерии, без bias автора |
|
||||
|
||||
- Продолжение работы существующего агента → `SendMessage` по agentId (контекст цел), НЕ новый спавн с пересказом.
|
||||
- Масштаб пачки: 1-3 независимых → параллельные Agent-вызовы одним сообщением; конвейер / 10+ агентов → Workflow (pipeline-default, `schema`-выход, adversarial verify находок). Caps на выборках — логируй отброшенное, молчаливое усечение читается как «покрыто всё».
|
||||
|
||||
## Бюджет одного сабагента (hard limits)
|
||||
|
||||
- **1 сабагент = 1 узкий deliverable.** Ориентиры: ~≤10 мин работы, ~≤20 tool-calls, ~≤150k токенов.
|
||||
Эмпирика 2026-06-27: агент-аудитор на 186k tok / 33 calls упал на StructuredOutput; 5 мелких параллельных прошли.
|
||||
- Поверхность больше бюджета → **дели на N узких сабагентов** (parallel при непересекающихся файлах, sequential при зависимостях). НЕ один большой.
|
||||
- Промпт сабагенту: конкретный deliverable + формат ответа + границы («что НЕ делать»). Расплывчатый scope = дубли и мусор.
|
||||
- Windows: очень длинный промпт субагенту может упасть на лимите командной строки (~8191 символ) — ещё один довод за компактность.
|
||||
|
||||
## Эскалация oversized-задачи (worker)
|
||||
|
||||
Issue/задача выглядит больше одного захода (эвристика: >5 файлов, ИЛИ >500 строк diff, ИЛИ >2ч) → **НЕ исполнять целиком**:
|
||||
- bot-pipeline: комментарий с планом сплита + label `status/needs-analysis`, снять claim
|
||||
- interactive: вернуть main-сессии план сплита вместо результата
|
||||
|
||||
## Единые пороги дробления (analyst / main)
|
||||
|
||||
- Estimate S(<2h) / M(2-8h) / **L(>8h) → обязан дробиться дальше** (до S/M)
|
||||
- Issue ≥1.5 дня → 3-4 sub-PR (Foundation → Schema → Workers → Integration), каждый ~200-500 строк — см. git-pr.md § Split big issues
|
||||
- 1 sub-issue ≈ 1-2 worker-захода, single-scope (не смешивать backend+frontend в одном issue)
|
||||
|
||||
## Параллелизм
|
||||
|
||||
Default = parallel на непересекающихся файлах (per-task worktree). Sequential — только overlap / hot-files / зависимый стек (git-pr.md § Parallel vs sequential PRs).
|
||||
|
||||
**Усилие ∝ сложности**: простой факт-запрос = 1 агент / 3-10 tool-calls; сложный разбор = N узких параллельных. Ширина пачки дешева, толщина одного агента — дорога и хрупка. Параллельные сессии/окна: практический потолок 3-5 (bottleneck — review, не Claude).
|
||||
|
||||
## Effort / model per agent
|
||||
|
||||
- Наследовать по умолчанию; модель НЕ переопределять без нужды (Explore и так на Haiku).
|
||||
- `effort: low/medium` — механика: точечные правки по списку, сбор данных, mass-grep, формат-конверсии.
|
||||
- `effort: high+` — только verify/judge-этапы, архитектурный синтез, решения.
|
||||
- Циклы/поллинг: prompt-cache живёт 5 мин — тик либо <~4.5 мин (кэш тёплый), либо сразу 20-30 мин; интервалы 5-15 мин = worst case (полный re-read контекста каждый тик без амортизации).
|
||||
|
|
@ -27,7 +27,7 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod
|
|||
|
||||
- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack)
|
||||
- trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all)
|
||||
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md` → `.forgejo/workflows/deploy-obsidian.yml`
|
||||
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh` → `.github/workflows/deploy-obsidian.yml`
|
||||
- `docs/**` alone → НЕ триггерит деплой
|
||||
|
||||
## После изменения .env на VPS
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ paths:
|
|||
```
|
||||
1. claude --bg --name "feat-<scope>" "task description"
|
||||
→ supervisor создаёт worktree от forgejo/main автоматически
|
||||
(settings worktree.baseRef=fresh, bgIsolation=worktree)
|
||||
(settings worktree.baseRef=default, bgIsolation=git)
|
||||
2. Session работает: код → commit → push → mcp__forgejo__create_pull_request
|
||||
3. PR URL появляется в agent view как row с зелёной/жёлтой/красной ● status dot
|
||||
4. User делает review через external Claude window (post-push)
|
||||
5. После merge — `Ctrl+X два раза` в agent view = удаление session + worktree
|
||||
5. После merge — `Ctrl+X dwa раза` в agent view = удаление session + worktree
|
||||
```
|
||||
|
||||
**Foreground workflow** (когда нужен интерактив, ad-hoc fixes):
|
||||
|
|
@ -76,7 +76,7 @@ Closes #N
|
|||
|
||||
1. `mcp__forgejo__get_pull_request` (или `curl -sH "$H" "$REPO/pulls/<N>"`) → читай `state`, `mergeable`, `head.sha`
|
||||
2. `state == merged` → stop polling
|
||||
3. Новый review/comment: `mcp__forgejo__list_pull_reviews` / `list_issue_comments`. Парсь marker `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
|
||||
3. Новый review/comment: `mcp__forgejo__list_pr_reviews` / `list_issue_comments`. Парсь marker `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
|
||||
- **SHA guard**: `marker.sha7 == head.sha[:7]` — иначе устаревший approval до fixup-push, игнорируй
|
||||
- `verdict=approve` + SHA match → `mcp__forgejo__merge_pull_request` (squash + delete branch)
|
||||
- `verdict=changes` → fixup commits + push в `forgejo feat/<scope>` + re-poll
|
||||
|
|
@ -91,20 +91,16 @@ Closes #N
|
|||
- Diff содержит литеральный secret/token/password/credential (40-char hex, API keys, JWT, и т.д.) — security tripwire.
|
||||
- PR меняет правила самого пайплайна: блок `## Auto-merge policy` здесь, `Critical rules` в CLAUDE.md, `_autonomous_pickup.md` (claim/kill-switch/merge-FSM), `auto-code-reviewer.md` или любой `work-as-*.md` — **self-extending guard** (расширение/снятие собственных merge-прав всегда через human, предотвращает bot-loop).
|
||||
|
||||
## Parallel vs sequential PRs
|
||||
## Sequential PRs
|
||||
|
||||
**Default = параллельно**, если scope'ы НЕ пересекаются по файлам: каждая задача в своём worktree (`git worktree add` / isolation:"worktree"), своя ветка, свой PR.
|
||||
**Одна задача → один PR → merge → следующая.** Параллельно ТОЛЬКО если scope'ы строго orthogonal (разные файлы).
|
||||
|
||||
**Sequential обязателен когда:**
|
||||
- Пересечение файлов / hot-files: `backend/app/api/v1/parcels.py`, `frontend/src/types/site-finder.ts`, OverviewTab/LandTab/MarketTab
|
||||
- Зависимый стек sub-PR'ов (Foundation → Schema → Workers → Integration): PR N+1 только после merge PR N
|
||||
Опасные файлы для конфликтов: `backend/app/api/v1/parcels.py`, `frontend/src/types/site-finder.ts`, OverviewTab/LandTab/MarketTab.
|
||||
|
||||
## Split big issues
|
||||
|
||||
Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Integration**. Каждый ~200-500 lines. PR N+1 только после merge PR N.
|
||||
|
||||
Бюджет одного subagent-захода и правила эскалации oversized-задач: `.claude/rules/delegation.md`.
|
||||
|
||||
## Review workflow (no conflict)
|
||||
|
||||
- **Pre-push** (локально): spawn `code-reviewer` subagent на staged changes → lint pass (security, correctness, conventions). Блокирует push при 🔴 критикал.
|
||||
|
|
@ -116,12 +112,12 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
|
|||
|
||||
- **Параллелизм**: 3-5 sessions одновременно (>5 = bottleneck на review, не на Claude per Anthropic метрика)
|
||||
- **Pin (`Ctrl+T`)** для long-running sessions (scraper monitors, deploy watchers) — supervisor не убьёт через 1h idle
|
||||
- **Cleanup**: `Ctrl+X два раза` после merge → session + worktree удалены атомарно
|
||||
- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (см. § Parallel vs sequential PRs)
|
||||
- **Cleanup**: `Ctrl+X dwa раза` после merge → session + worktree удалены атомарно
|
||||
- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (используй sequential PR rule выше)
|
||||
|
||||
**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: fresh` (свежая ветка от main, не от твоей stale-сессии).
|
||||
**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: default` (свежая ветка от main, не от твоей stale-сессии).
|
||||
|
||||
**Worktree cleanup** (cron weekly): `scripts/cleanup-merged-worktrees.sh` — удаляет worktrees для merged branches. Запускать вручную или weekly cron.
|
||||
**Worktree cleanup** (cron weekly): `scripts/cleanup-merged-worktrees.sh` — удаляет worktrees для merged branches. Запускать вручную или weekly cron.
|
||||
|
||||
## Запреты
|
||||
|
||||
|
|
@ -130,4 +126,4 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
|
|||
- ❌ `gh pr *` — bypassed 2026-05-16, используй Forgejo MCP или curl + `$FORGEJO_TOKEN`
|
||||
- ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval
|
||||
- ❌ `@claude` в PR comments — plain text only (`feedback_no_claude_mentions`)
|
||||
- ❌ Параллельные PR на одни файлы / hot-files (см. § Parallel vs sequential PRs)
|
||||
- ❌ Параллельные PR на одни файлы (`feedback_sequential_prs`)
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__obsidian__obsidian_list_files_in_vault",
|
||||
"mcp__obsidian__obsidian_simple_search",
|
||||
"mcp__obsidian__obsidian_get_file_contents",
|
||||
"mcp__obsidian__obsidian_list_files_in_dir",
|
||||
"mcp__postgres-gendesign__get_object_details",
|
||||
"mcp__postgres-gendesign__explain_query",
|
||||
"mcp__postgres-gendesign__list_objects",
|
||||
"mcp__postgres-gendesign__list_schemas",
|
||||
"mcp__postgres-gendesign__analyze_query_indexes",
|
||||
"Bash(curl *)",
|
||||
"Bash(powershell *)",
|
||||
"Bash(gh pr comment:*)",
|
||||
"Bash(powershell.exe:*)",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(pwsh:*)",
|
||||
"mcp__forgejo",
|
||||
"mcp__forgejo__create_issue",
|
||||
"mcp__forgejo__update_issue",
|
||||
"mcp__forgejo__add_issue_labels",
|
||||
"mcp__forgejo__remove_issue_labels",
|
||||
"mcp__forgejo__issue_state_change",
|
||||
"mcp__forgejo__create_issue_comment",
|
||||
"mcp__forgejo__create_pull_request",
|
||||
"mcp__forgejo__merge_pull_request",
|
||||
"mcp__forgejo__create_pull_review",
|
||||
"mcp__forgejo__get_pull_request_diff",
|
||||
"mcp__forgejo__list_repo_issues",
|
||||
"mcp__forgejo__list_repo_pull_requests",
|
||||
"mcp__forgejo__get_pull_request_by_index",
|
||||
"mcp__forgejo__list_pull_request_files",
|
||||
"mcp__forgejo__list_pull_reviews",
|
||||
"mcp__forgejo__list_repo_labels",
|
||||
"mcp__forgejo__get_issue_by_index",
|
||||
"mcp__forgejo__list_issue_comments",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Read",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"EnterWorktree",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"NotebookEdit",
|
||||
"mcp__obsidian",
|
||||
"mcp__postgres-gendesign",
|
||||
"mcp__playwright",
|
||||
"mcp__a11y",
|
||||
"mcp__lighthouse",
|
||||
"mcp__shadcn",
|
||||
"mcp__glitchtip",
|
||||
"mcp__context7",
|
||||
"mcp__fetch",
|
||||
"Bash(*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf *)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(git push --force *)",
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git reset --hard *)",
|
||||
"Bash(git commit --amend *)",
|
||||
"Bash(git rebase --interactive *)",
|
||||
"Bash(git commit --no-verify*)",
|
||||
"Bash(git push --no-verify*)",
|
||||
"Bash(git push --force-with-lease*)",
|
||||
"Bash(git push --force-if-includes*)",
|
||||
"Bash(docker compose down -v *)",
|
||||
"Bash(docker volume rm *)",
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)",
|
||||
"Read(./backend/.env)",
|
||||
"Read(./backend/.env.*)",
|
||||
"Read(**/.env)",
|
||||
"Read(**/.env.*)"
|
||||
],
|
||||
"defaultMode": "auto"
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"obsidian",
|
||||
"context7",
|
||||
"fetch",
|
||||
"forgejo",
|
||||
"a11y",
|
||||
"lighthouse",
|
||||
"shadcn"
|
||||
],
|
||||
"worktree": {
|
||||
"baseRef": "fresh",
|
||||
"bgIsolation": "worktree"
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-secret-read.py"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-dangerous-commands.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-no-print.py"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/check-sql-pitfalls.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/session-preflight.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "python ${CLAUDE_PROJECT_DIR}/scripts/claude-hooks/statusline.py"
|
||||
}
|
||||
}
|
||||
|
|
@ -32,40 +32,3 @@ Target: `tradein-mvp/frontend` (NOT the repo-root `frontend/`, which is the Site
|
|||
- Synth scan re-includes Next route/layout/error files on every build → `componentSrcMap` nulls (13 entries) must persist. New app-router pages would need adding.
|
||||
- `.d.ts` props are weak (`[key:string]:unknown`) — synth mode has no built types. Real prop contracts live in each component's source `interface Props`. A real `tsup`/`tsc` lib build would fix this (recommend if the agent needs strong API contracts).
|
||||
- Grades clear on any `cfg.provider`/preview-affecting config change (expected) — re-grade from fresh sheets.
|
||||
|
||||
## Re-sync 2026-07-02 — v2 dashboard sync (main was 223 commits ahead; harness authored on June components)
|
||||
|
||||
**Two source-kit.mjs fork fixes were REQUIRED for the evolved v2 codebase (both committed in the override, declared in cfg.libOverrides):**
|
||||
1. **Exclude `next/font` importers from the synth-entry.** `src/app/v2/layout.tsx` calls `Manrope()`/`IBM_Plex_Mono()` from `next/font/google` at MODULE TOP-LEVEL. esbuild can't resolve the Next-only loader → stubs it `(void 0)` → `undefined()` aborts the whole browser IIFE → `window.TradeInUI` empty → ALL 58 components vanish (`[RENDER] root empty` everywhere, `[BUNDLE_EXPORT]`). Fix: `comps` filter drops any file whose content matches `/from\s+['"]next\/font/`. If a NEW file top-level-calls a Next build-time loader, same class of crash — extend the filter.
|
||||
2. **Re-export default-exported components.** `export * from <path>` does NOT re-export a module's `default`. The v2 views/nav/overlay/panel are authored `export default function <Name>` (AnalyticsView, HeroBar, HistoryView, ParamsPanel, SectionOverlay, SourcesView, TopNav) → absent from `window.TradeInUI` → `[BUNDLE_EXPORT] not a component`. Fix: entry now also emits `export { default as <Name> } from <path>` for each `export default function/class <Name>`. Named-export components (`export function X`) were always fine.
|
||||
|
||||
**componentSrcMap:** added `TradeInV2Layout / TradeInV2Page / SaleShareLayout / SaleSharePage` = null (Next route files, never DS components — same as RootLayout et al.).
|
||||
|
||||
**overrides (grid):** MapPicker `{cardMode:single, primaryStory:Default}` (portal/fixed), StreetDealsCard + Topbar `{cardMode:column}` (wider than a grid cell).
|
||||
|
||||
**Floor-card components (6, authorable on any future re-sync):** SaleShareControls, SaleShareList, SaleShareMap, SectionOverlay, LocationDrawer, BuildingListingsDrawer — overlays/drawers whose props don't seed rich data via PreviewProvider, so they show the honest typographic floor. All the OTHER v2 components (views/nav/hero/panel) render richly because the provider seeds their data.
|
||||
|
||||
**Known render warn:** ResultPanel — `variants identical` (Default vs Error cells render near-identically; not broken, the Error cell just doesn't diverge visually enough). Recorded here so re-syncs don't read it as new.
|
||||
|
||||
**Font note (re-sync risk):** the v2 HUD's real typefaces are **Manrope + IBM_Plex_Mono** (loaded by the excluded `v2/layout.tsx` via next/font → CSS vars `--font-manrope`/`--font-plex-mono`). `cfg.extraFonts` ships **Inter + JetBrains Mono** (June brand fonts). v2 previews therefore render Manrope-slot text in the shipped fallback. If brand-exact v2 rendering is wanted, add Manrope + IBM Plex Mono woff2 to `.design-sync/fonts/` + `cfg.extraFonts` and map the `--font-manrope`/`--font-plex-mono` vars.
|
||||
|
||||
**ResultPanel NAME COLLISION (fixed 2026-07-02):** two components named `ResultPanel` in src — `app/scrapers/_components/ScraperPage.tsx` (`export function ResultPanel`, scraper mut-panel) and `components/trade-in/v2/ResultPanel.tsx` (`export default function ResultPanel`, the v2 estimate result / honest hero). The default-re-export fix makes the v2 one win `window.TradeInUI.ResultPanel` (explicit `export {default as ResultPanel}` beats the star export). But the June authored preview `previews/ResultPanel.tsx` was the SCRAPER one (`mut` props) → the v2 component rendered fine (reads data from context) but its prompt.md documented the wrong (scraper) API. FIX: (1) `cfg.componentSrcMap.ResultPanel = "src/components/trade-in/v2/ResultPanel.tsx"` pins enrichment to v2; (2) re-authored `previews/ResultPanel.tsx` → `<ResultPanel onNavigate={()=>{}} />` (v2 API; `data` defaults to the component's built-in RESULT_FIXTURE = honest-hero). If a re-sync ever shows ResultPanel with scraper markup, the pin/preview regressed. Any NEW duplicate PascalCase component name will hit the same class of issue — the default-re-export makes the default-export win; pin + author the intended one.
|
||||
|
||||
**Upload gotcha (this machine):** after a follow-up single-component rebuild, `resync-verdict.json upload.deletePaths` came back with ALL other components (318 paths) — a stale-anchor diff artifact, NOT real deletions (ResultPanel + audit/uploads were absent from it). Do NOT feed that deletePaths to delete_files (would nuke the project). For a focused single-component re-upload: write just that component's `components/<group>/<Name>/*` + `_preview/<Name>.js` + `_ds_sync.json` (sentinel-fenced), deletes=[].
|
||||
- **CORRECTION (2026-07-03):** the 318 deletePaths were NOT a diff artifact — they were REAL local deletions caused by a source-kit fork bug (see below): the July-02 `componentSrcMap.ResultPanel` pin collapsed the synth build to 1 component, so the local ds-bundle genuinely lost the other 53. The "don't feed deletePaths blindly" advice stands (it saved the project), but the root cause is fixed now.
|
||||
|
||||
## Re-sync 2026-07-03 — 6 floor cards authored + v2 brand fonts (#2267)
|
||||
|
||||
**source-kit.mjs fork fix #3 (REQUIRED): componentSrcMap pin must AUGMENT synth discovery, not replace it.** In synth mode there is no shipped `.d.ts`, so `exportedNames()` is empty and `names` contains ONLY the non-null `componentSrcMap` pins. The old guard `if (!components.length && synthEntry) components = deriveComponentsFromSrc(...)` therefore never ran once a single pin existed (ResultPanel, added 2026-07-02) → the whole bundle collapsed to 1 component (`(stale preview: X — component no longer exported)` for everything else; verdict shows all others as `removed` + bogus deletePaths). Fix in the fork: when `synthEntry`, always union `deriveComponentsFromSrc(srcFiles)` (minus `null`-excluded) with the pinned names. Any future pin would have re-triggered this. NB: the fork edit re-keys EVERY component's sourceKey → full re-grade pass (done: 47/54 renderHashes byte-identical to the 2026-07-02 anchor → carry-forward grades; the rest eyeballed).
|
||||
|
||||
**6 floor cards → authored (all graded good):**
|
||||
- `SaleShareControls` — inline `SaleShareSummary` (histogram 7 корзин, coverage 92.1%); порог 8% приглушает нижнюю корзину; все фильтры.
|
||||
- `SaleShareList` — 6 инлайн-домов ЕКБ (heat-бейджи, «аварийный», over_100 «возможно, несколько корпусов», selected-row) + Empty cell. `cardMode: column`.
|
||||
- `SaleShareMap` — 8 heat-маркеров + открытый popup выбранного дома; OSM-тайлы офлайн не красятся (известно, как MapCard).
|
||||
- `SectionOverlay` (v2) — рендерится contained в relative-«артборде» (absolute-позиционирование против ближайшего positioned ancestor; wrapper height 680 + v2-градиент). 2 cells: HistoryView (04) / AnalyticsView (06) на их встроенных fixtures (data-props не переданы). `cardMode: column`.
|
||||
- `LocationDrawer` (v2) — open=true в таком же contained-артборде (height 640). `cardMode: column`.
|
||||
- `BuildingListingsDrawer` — `createPortal` + `.ss-drawer-overlay` = position:fixed → `cardMode: single` (прецедент MapPicker). Шапка богатая (props), тело fetch-coupled (`useBuildingListings`, data-prop нет) → офлайн честный error-state «Не удалось загрузить объявления дома». Сидировать можно было бы ключом `["sale-share","listings",<house_id>]` в PreviewProvider — сознательно НЕ сделано (кэш-ключ завязан на house_id фикстуры превью; хрупко).
|
||||
|
||||
**v2 brand fonts shipped (Manrope + IBM Plex Mono).** woff2 (latin+cyrillic; Manrope variable 200-800, Plex Mono static 300/400/500 — веса из `app/v2/layout.tsx`) скачаны с Google Fonts → `.design-sync/fonts/`, @font-face добавлены в `brand-fonts.css`. **ГОЧА: extraFonts-пайплайн (`css.mjs extractFonts`) извлекает ТОЛЬКО `@font-face`-блоки — `:root{}` из brand-fonts.css молча выбрасывается.** Маппинг `--font-manrope`/`--font-plex-mono` поэтому живёт в `preview-provider.tsx` (`<style>` в провайдере — капчер-путь) + задокументирован для design-консюмеров в `conventions.md` (сниппет `:root{...}`). После фикса v2-цифры реально в Plex Mono (проверено по sheets ResultPanel/SectionOverlay).
|
||||
|
||||
**Upload 2026-07-03: НЕ выполнен из worker-сессии** — DesignSync MCP-тулов в ней нет (ToolSearch пуст). Verdict готов и чист: ok=true, pendingGrade=0, deletePaths=0 (легитимно — remote-анкор полный, local снова 54 компонента), upload.any=true, components=54 (все re-key'нуты форк-фиксом), bundle+styling+aux=true. Main-сессия: залить ds-bundle по verdict'у (deletePaths пуст — ничего не удалять) и после успеха скопировать свежий `ds-bundle/_ds_sync.json` → `.design-sync/.cache/remote-sync.json` (новый анкор).
|
||||
|
|
|
|||
|
|
@ -24,21 +24,7 @@
|
|||
"ScraperPage": null,
|
||||
"ScrapersUnifiedPage": null,
|
||||
"TradeInPage": null,
|
||||
"YandexScraperPage": null,
|
||||
"TradeInV2Layout": null,
|
||||
"TradeInV2Page": null,
|
||||
"SaleShareLayout": null,
|
||||
"SaleSharePage": null,
|
||||
"ResultPanel": "src/components/trade-in/v2/ResultPanel.tsx"
|
||||
},
|
||||
"overrides": {
|
||||
"MapPicker": { "cardMode": "single", "primaryStory": "Default" },
|
||||
"StreetDealsCard": { "cardMode": "column" },
|
||||
"Topbar": { "cardMode": "column" },
|
||||
"SaleShareList": { "cardMode": "column" },
|
||||
"SectionOverlay": { "cardMode": "column" },
|
||||
"LocationDrawer": { "cardMode": "column" },
|
||||
"BuildingListingsDrawer": { "cardMode": "single", "primaryStory": "Default" }
|
||||
"YandexScraperPage": null
|
||||
},
|
||||
"provider": {
|
||||
"component": "PreviewProvider"
|
||||
|
|
@ -46,8 +32,5 @@
|
|||
"extraEntries": [
|
||||
"../../.design-sync/preview-provider.tsx"
|
||||
],
|
||||
"readmeHeader": ".design-sync/conventions.md",
|
||||
"extraFonts": [
|
||||
"../../.design-sync/fonts/brand-fonts.css"
|
||||
]
|
||||
"readmeHeader": ".design-sync/conventions.md"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,12 +35,6 @@ This is **not Tailwind and not CSS-in-JS**. Styling is a **global stylesheet** o
|
|||
| Border | `--border-soft` `--border-card` `--border-strong` |
|
||||
| Shape/type | `--radius` `--radius-sm` `--radius-lg` `--shadow-md` `--font-sans` (Manrope) `--font-mono` `--container` |
|
||||
|
||||
**v2 (МЕРА HUD) fonts:** the `v2/*` components read `var(--font-manrope)` / `var(--font-plex-mono)` (in the app these come from `next/font`). The woff2 for both families ships in `fonts/fonts.css`; define the vars once at your design root:
|
||||
|
||||
```css
|
||||
:root { --font-manrope: 'Manrope'; --font-plex-mono: 'IBM Plex Mono'; }
|
||||
```
|
||||
|
||||
```tsx
|
||||
<div style={{ background: "var(--bg-card)", border: "1px solid var(--border-card)",
|
||||
borderRadius: "var(--radius)", padding: 16, color: "var(--fg-primary)",
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,112 +0,0 @@
|
|||
/* Brand fonts shipped with the trade-in DS bundle.
|
||||
* June set: Inter + JetBrains Mono (variable woff2).
|
||||
* v2 HUD set (2026-07-03): Manrope + IBM Plex Mono — the МЕРА v2 typefaces,
|
||||
* loaded in the app via next/font in app/v2/layout.tsx (CSS vars
|
||||
* --font-manrope / --font-plex-mono). next/font is excluded from the synth
|
||||
* bundle, so we ship the woff2 here and map the vars in :root below.
|
||||
* All fonts: latin + cyrillic subsets (ЕКБ addresses are Cyrillic). */
|
||||
|
||||
:root {
|
||||
--font-manrope: 'Manrope';
|
||||
--font-plex-mono: 'IBM Plex Mono';
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
font-display: swap;
|
||||
src: url('./Manrope-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
font-display: swap;
|
||||
src: url('./Manrope-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('./IBMPlexMono-300-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('./IBMPlexMono-300-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./IBMPlexMono-400-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./IBMPlexMono-400-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./IBMPlexMono-500-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./IBMPlexMono-500-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('./Inter-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('./Inter-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url('./JetBrainsMono-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url('./JetBrainsMono-cyrillic.woff2') format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
// knob for component inclusion: non-null value = add/pin src path, null =
|
||||
// exclude a .d.ts-exported internal.
|
||||
|
||||
import { existsSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { Project, Node, ts } from 'ts-morph';
|
||||
// forked from design-sync lib/source-kit.mjs — synth-entry process shim (Next.js app reads process.env.NEXT_PUBLIC_*)
|
||||
|
|
@ -70,18 +70,7 @@ export async function resolvePackage(ctx) {
|
|||
console.error(`[NO_DIST] ${PKG} has no built entry and no src/ to synthesize from — run its build.`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Next route files (app/**/layout.tsx, page.tsx) that call `next/font`
|
||||
// loaders (Manrope(), IBM_Plex_Mono()) at module top-level crash the browser
|
||||
// IIFE: esbuild can't resolve the Next-only loader → stubs it to `undefined`
|
||||
// → `undefined()` aborts the whole bundle → window.<global> stays empty and
|
||||
// every component vanishes. These files are never DS components anyway, so
|
||||
// drop any `next/font`-importing module from the synth-entry set.
|
||||
const comps = srcFiles.filter(
|
||||
(p) =>
|
||||
SRC_IMPL_RX.test(p) &&
|
||||
!NON_IMPL_RX.test(p) &&
|
||||
!/from\s+['"]next\/font/.test(readFileSync(p, 'utf8')),
|
||||
);
|
||||
const comps = srcFiles.filter((p) => SRC_IMPL_RX.test(p) && !NON_IMPL_RX.test(p));
|
||||
// Next.js app code reads process.env.NEXT_PUBLIC_* at module top-level; in
|
||||
// the browser IIFE `process` is undefined → every component throws. Emit a
|
||||
// shim module and import it FIRST (ESM evaluates the first import's body
|
||||
|
|
@ -100,25 +89,10 @@ export async function resolvePackage(ctx) {
|
|||
"__e.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ??= 'trade-in@example.com';\n",
|
||||
);
|
||||
entry = join(OUT, '.pkg-entry.mjs');
|
||||
// `export *` does NOT re-export a module's default. Components authored as
|
||||
// `export default function <Name>` (the v2 views/nav/overlay/panel) would be
|
||||
// absent from window.<global> → [BUNDLE_EXPORT] "not a component". Re-export
|
||||
// each named default under its declared name so default-exported components
|
||||
// reach the global alongside the named ones.
|
||||
const defaultReexports = comps
|
||||
.map((p) => {
|
||||
const m = readFileSync(p, 'utf8').match(
|
||||
/export\s+default\s+(?:async\s+)?(?:function|class)\s+([A-Z][A-Za-z0-9]*)/,
|
||||
);
|
||||
return m ? `export { default as ${m[1]} } from ${JSON.stringify(p)};` : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
writeFileSync(
|
||||
entry,
|
||||
`import ${JSON.stringify(slash(shim))};\n` +
|
||||
comps.map((p) => `export * from ${JSON.stringify(p)};`).join('\n') +
|
||||
'\n' +
|
||||
defaultReexports.join('\n') +
|
||||
'\n',
|
||||
);
|
||||
synthEntry = true;
|
||||
|
|
@ -143,17 +117,8 @@ export async function resolvePackage(ctx) {
|
|||
names.add(k);
|
||||
}
|
||||
let components = [...names].sort().map((name) => ({ name, group: 'general' }));
|
||||
if (synthEntry) {
|
||||
// Synth mode has no shipped .d.ts → `names` holds only componentSrcMap
|
||||
// pins. A non-null pin must AUGMENT src discovery (it exists to pin
|
||||
// enrichment to a specific file), not REPLACE it — with the old
|
||||
// `!components.length` guard a single pin (ResultPanel, 2026-07-02)
|
||||
// collapsed the whole synth bundle to just the pinned component.
|
||||
const derived = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
|
||||
const have = new Set(components.map((c) => c.name));
|
||||
components = components
|
||||
.concat(derived.filter((c) => !have.has(c.name)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (!components.length && synthEntry) {
|
||||
components = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
|
||||
}
|
||||
if (!components.length) {
|
||||
if (cfg.cssEntry || existsSync(join(PKG_DIR, 'styles.css'))) {
|
||||
|
|
|
|||
|
|
@ -57,17 +57,5 @@ export function PreviewProvider({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
return qc;
|
||||
});
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
{/* v2 HUD font vars. The app defines --font-manrope/--font-plex-mono via
|
||||
next/font in app/v2/layout.tsx, which is excluded from the synth
|
||||
bundle (next/font loader crash) — so the vars never exist in capture
|
||||
and tokens.font.* fell back. The @font-face for both families ships
|
||||
in .design-sync/fonts/brand-fonts.css (cfg.extraFonts), but that
|
||||
pipeline extracts @font-face blocks ONLY (a :root{} there is
|
||||
dropped) — map the vars here instead. */}
|
||||
<style>{":root{--font-manrope:'Manrope';--font-plex-mono:'IBM Plex Mono'}"}</style>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { BuildingListingsDrawer } from 'tradein-mvp-frontend';
|
||||
|
||||
// Правый drawer с активными объявлениями выбранного дома (/trade-in/sale-share):
|
||||
// createPortal в document.body, .ss-drawer-overlay = position:fixed по всему
|
||||
// вьюпорту (как MapPicker) → cardMode:single. Шапка — адрес + тепловой бейдж
|
||||
// доли + «N из M квартир» (из props). Тело FETCH-COUPLED: useBuildingListings
|
||||
// (GET /buildings/{id}/listings) — data-prop нет, глобальный query-cache пуст →
|
||||
// в офлайн-capture честный pending/error-state («Загрузка объявлений…» /
|
||||
// «Не удалось загрузить объявления дома»). На реальной странице — список
|
||||
// объявлений с ценой, ₽/м², этажом и ссылкой на оригинал.
|
||||
const building = {
|
||||
house_id: 3101,
|
||||
address: 'ул. Викулова, 46',
|
||||
lat: 56.8412,
|
||||
lon: 60.5556,
|
||||
sale_share_pct: 34.6,
|
||||
sale_share_pct_45d: 41.2,
|
||||
listings_45d: 33,
|
||||
over_100: false,
|
||||
active_secondary: 27,
|
||||
flat_count_effective: 78,
|
||||
gar_match_method: 'cadastre',
|
||||
median_price_rub: 4_950_000,
|
||||
median_price_per_m2: 158_500,
|
||||
avg_days_on_market: 74,
|
||||
year_built: 1972,
|
||||
house_type: 'panel',
|
||||
total_floors: 9,
|
||||
series_name: '1-468',
|
||||
is_emergency: false,
|
||||
};
|
||||
|
||||
/** Открытый drawer дома «ул. Викулова, 46» (34.6% в продаже, 27 из 78 квартир). */
|
||||
export const Default = () => (
|
||||
<BuildingListingsDrawer building={building} onClose={() => {}} />
|
||||
);
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { LocationDrawer } from 'tradein-mvp-frontend';
|
||||
|
||||
// «ПОЯСНЕНИЕ К РАСЧЁТУ» — правый drawer HUD «МЕРА Оценка» (/trade-in/v2),
|
||||
// открывается с «?» у «КОЭФ. ЛОКАЦИИ» в HeroBar. Честная методика: как
|
||||
// агрегируются источники (Циан/Я.Недвижимость/Авито/Домклик + Росреестр) и
|
||||
// явная плашка «коэффициент локации в разработке». Контент статичный —
|
||||
// данных не принимает, только open/onClose. Drawer absolute-позиционирован
|
||||
// (width 452 + scrim) — рендерим contained внутри relative-обёртки.
|
||||
|
||||
/** Открытое состояние: scrim + выдвинутая панель с методикой и info-плашкой
|
||||
* о локации. */
|
||||
export const Default = () => (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: 640,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
background:
|
||||
'radial-gradient(1100px 520px at 30% -10%, #f7fbff 0%, #eef4fa 55%, #e6eef7 100%)',
|
||||
}}
|
||||
>
|
||||
<LocationDrawer open={true} onClose={() => {}} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,10 +1,36 @@
|
|||
import { ResultPanel } from 'tradein-mvp-frontend';
|
||||
|
||||
/**
|
||||
* ResultPanel (v2) — центральная панель результата оценки на `/trade-in/v2`:
|
||||
* одна цифра-герой (ожидаемая цена продажи / «оценка»), тиры median + ДКП,
|
||||
* доверительные диапазоны, источники и мини-гистограмма распределения.
|
||||
* Данные по умолчанию берутся из встроенной `RESULT_FIXTURE` компонента,
|
||||
* поэтому карточке достаточно передать `onNavigate` (навигация по секциям).
|
||||
*/
|
||||
export const Default = () => <ResultPanel onNavigate={() => {}} />;
|
||||
/** Переиспользуемая панель результата мутации скрапера. Успех → JSON-дамп
|
||||
* ответа в `.scraper-result`. */
|
||||
export const Default = () => (
|
||||
<ResultPanel
|
||||
mut={{
|
||||
isSuccess: true,
|
||||
isPending: false,
|
||||
error: null,
|
||||
data: {
|
||||
run_id: 4821,
|
||||
source: 'avito',
|
||||
status: 'completed',
|
||||
anchors_seen: 64,
|
||||
lots_scraped: 312,
|
||||
houses_matched: 47,
|
||||
detail_fetched: 298,
|
||||
errors: 0,
|
||||
started_at: '2026-05-30T09:12:00Z',
|
||||
finished_at: '2026-05-30T09:48:21Z',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Ветка ошибки — красная панель `.scraper-result--error` с текстом сбоя. */
|
||||
export const Error_ = () => (
|
||||
<ResultPanel
|
||||
mut={{
|
||||
isSuccess: false,
|
||||
isPending: false,
|
||||
error: new Error('502 Bad Gateway: upstream avito proxy timeout'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
import { SaleShareControls } from 'tradein-mvp-frontend';
|
||||
|
||||
// Панель «Порог и фильтры» страницы /trade-in/sale-share. Controlled-компонент:
|
||||
// состояние живёт в page.tsx и приходит через props. Сводка (гистограмма
|
||||
// распределения sale_share_pct) — реалистичные значения по покрытию ЕКБ
|
||||
// (view v_building_sale_share ≈ 6.7k домов вторички, знаменатель ГАР у ~92%).
|
||||
const summary = {
|
||||
total_secondary_buildings: 6667,
|
||||
buildings_with_denominator: 6143,
|
||||
coverage_pct: 92.1,
|
||||
max_pct: 42.0,
|
||||
p95_pct: 11.3,
|
||||
histogram: [
|
||||
{ bucket: '0-5', count: 4980 },
|
||||
{ bucket: '5-10', count: 642 },
|
||||
{ bucket: '10-20', count: 298 },
|
||||
{ bucket: '20-30', count: 121 },
|
||||
{ bucket: '30-50', count: 57 },
|
||||
{ bucket: '50-100', count: 18 },
|
||||
{ bucket: '100+', count: 6 },
|
||||
],
|
||||
};
|
||||
|
||||
const HOUSE_TYPES = ['panel', 'brick', 'monolith', 'monolith_brick', 'block', 'stalin'];
|
||||
|
||||
/** Порог 8% на слайдере: корзины ниже порога приглушены (opacity), тепловая
|
||||
* окраска столбцов green→red повторяет цвет маркеров карты. Фильтры: город,
|
||||
* цена, год постройки, тип дома, мин. объявлений, сортировка. */
|
||||
export const Default = () => (
|
||||
<SaleShareControls
|
||||
summary={summary}
|
||||
minPct={8}
|
||||
onMinPct={() => {}}
|
||||
city="Екатеринбург"
|
||||
onCity={() => {}}
|
||||
priceMin=""
|
||||
onPriceMin={() => {}}
|
||||
priceMax=""
|
||||
onPriceMax={() => {}}
|
||||
yearMin=""
|
||||
onYearMin={() => {}}
|
||||
yearMax=""
|
||||
onYearMax={() => {}}
|
||||
houseType=""
|
||||
onHouseType={() => {}}
|
||||
houseTypeOptions={HOUSE_TYPES}
|
||||
sort="share_desc"
|
||||
onSort={() => {}}
|
||||
window="now"
|
||||
onWindow={() => {}}
|
||||
minCount={3}
|
||||
onMinCount={() => {}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { SaleShareList } from 'tradein-mvp-frontend';
|
||||
|
||||
// Список домов /trade-in/sale-share, отсортирован по доле в продаже (server-side
|
||||
// share_desc). Реалистичные дома ЕКБ (view v_building_sale_share): адрес,
|
||||
// тепловой бейдж доли, «N из M квартир», медиана + ₽/м², экспозиция,
|
||||
// год/тип/этажность/серия. Включены оба edge-бейджа: «аварийный» и
|
||||
// «возможно, несколько корпусов» (over_100 — ГАР-коллизия адреса).
|
||||
const buildings = [
|
||||
{ house_id: 4212, address: 'ул. Космонавтов, 52', lat: 56.8890, lon: 60.6132, sale_share_pct: 128.0, sale_share_pct_45d: 131.5, listings_45d: 46, over_100: true, active_secondary: 32, flat_count_effective: 25, gar_match_method: 'address', median_price_rub: 3_650_000, median_price_per_m2: 121_700, avg_days_on_market: 96, year_built: 1961, house_type: 'brick', total_floors: 5, series_name: null, is_emergency: false },
|
||||
{ house_id: 3387, address: 'пер. Сапёров, 5', lat: 56.8271, lon: 60.6198, sale_share_pct: 48.9, sale_share_pct_45d: 52.4, listings_45d: 24, over_100: false, active_secondary: 22, flat_count_effective: 45, gar_match_method: 'cadastre', median_price_rub: 2_990_000, median_price_per_m2: 98_400, avg_days_on_market: 148, year_built: 1957, house_type: 'brick', total_floors: 3, series_name: null, is_emergency: true },
|
||||
{ house_id: 3101, address: 'ул. Викулова, 46', lat: 56.8412, lon: 60.5556, sale_share_pct: 34.6, sale_share_pct_45d: 41.2, listings_45d: 33, over_100: false, active_secondary: 27, flat_count_effective: 78, gar_match_method: 'cadastre', median_price_rub: 4_950_000, median_price_per_m2: 158_500, avg_days_on_market: 74, year_built: 1972, house_type: 'panel', total_floors: 9, series_name: '1-468', is_emergency: false },
|
||||
{ house_id: 2874, address: 'ул. Бебеля, 138', lat: 56.8664, lon: 60.5721, sale_share_pct: 22.4, sale_share_pct_45d: 25.0, listings_45d: 20, over_100: false, active_secondary: 17, flat_count_effective: 76, gar_match_method: 'cadastre', median_price_rub: 5_400_000, median_price_per_m2: 149_200, avg_days_on_market: 61, year_built: 1978, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
|
||||
{ house_id: 5530, address: 'ул. Малышева, 84', lat: 56.8380, lon: 60.6203, sale_share_pct: 12.1, sale_share_pct_45d: 13.8, listings_45d: 16, over_100: false, active_secondary: 14, flat_count_effective: 116, gar_match_method: 'cadastre', median_price_rub: 7_850_000, median_price_per_m2: 172_300, avg_days_on_market: 47, year_built: 1954, house_type: 'stalin', total_floors: 6, series_name: null, is_emergency: false },
|
||||
{ house_id: 6119, address: 'ул. Щербакова, 20', lat: 56.7791, lon: 60.6120, sale_share_pct: 6.8, sale_share_pct_45d: 8.1, listings_45d: 22, over_100: false, active_secondary: 18, flat_count_effective: 264, gar_match_method: 'cadastre', median_price_rub: 8_900_000, median_price_per_m2: 164_800, avg_days_on_market: 38, year_built: 2016, house_type: 'monolith', total_floors: 25, series_name: null, is_emergency: false },
|
||||
];
|
||||
|
||||
/** Полная таблица (6 домов, выбран «ул. Викулова, 46» — строка is-selected),
|
||||
* сортировка по доле, кнопка «Объявления →» ведёт в drawer. */
|
||||
export const Default = () => (
|
||||
<SaleShareList
|
||||
buildings={buildings}
|
||||
sort="share_desc"
|
||||
onSort={() => {}}
|
||||
selectedHouseId={3101}
|
||||
hoveredHouseId={null}
|
||||
onSelect={() => {}}
|
||||
onHover={() => {}}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
minPct={5}
|
||||
window="now"
|
||||
/>
|
||||
);
|
||||
|
||||
/** Пустой результат — фильтры отсекли все дома (честный empty-state). */
|
||||
export const Empty = () => (
|
||||
<SaleShareList
|
||||
buildings={[]}
|
||||
sort="share_desc"
|
||||
onSort={() => {}}
|
||||
selectedHouseId={null}
|
||||
hoveredHouseId={null}
|
||||
onSelect={() => {}}
|
||||
onHover={() => {}}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
minPct={25}
|
||||
window="now"
|
||||
/>
|
||||
);
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { SaleShareMap } from 'tradein-mvp-frontend';
|
||||
|
||||
// Тепловая карта домов /trade-in/sale-share (Leaflet + OSM с CDN, как
|
||||
// MapCard/MapPicker). Цвет и радиус circleMarker растут с sale_share_pct
|
||||
// (green → red, over_100 → тёмно-красный). 8 домов ЕКБ с координатами;
|
||||
// выбранный дом (selectedHouseId) получает открытый popup. OSM-тайлы —
|
||||
// внешний fetch: в офлайн-capture подложка может не прогрузиться,
|
||||
// маркеры/popup/контролы рендерятся всегда.
|
||||
const buildings = [
|
||||
{ house_id: 4212, address: 'ул. Космонавтов, 52', lat: 56.8890, lon: 60.6132, sale_share_pct: 128.0, sale_share_pct_45d: 131.5, listings_45d: 46, over_100: true, active_secondary: 32, flat_count_effective: 25, gar_match_method: 'address', median_price_rub: 3_650_000, median_price_per_m2: 121_700, avg_days_on_market: 96, year_built: 1961, house_type: 'brick', total_floors: 5, series_name: null, is_emergency: false },
|
||||
{ house_id: 3387, address: 'пер. Сапёров, 5', lat: 56.8271, lon: 60.6198, sale_share_pct: 48.9, sale_share_pct_45d: 52.4, listings_45d: 24, over_100: false, active_secondary: 22, flat_count_effective: 45, gar_match_method: 'cadastre', median_price_rub: 2_990_000, median_price_per_m2: 98_400, avg_days_on_market: 148, year_built: 1957, house_type: 'brick', total_floors: 3, series_name: null, is_emergency: true },
|
||||
{ house_id: 3101, address: 'ул. Викулова, 46', lat: 56.8412, lon: 60.5556, sale_share_pct: 34.6, sale_share_pct_45d: 41.2, listings_45d: 33, over_100: false, active_secondary: 27, flat_count_effective: 78, gar_match_method: 'cadastre', median_price_rub: 4_950_000, median_price_per_m2: 158_500, avg_days_on_market: 74, year_built: 1972, house_type: 'panel', total_floors: 9, series_name: '1-468', is_emergency: false },
|
||||
{ house_id: 2874, address: 'ул. Бебеля, 138', lat: 56.8664, lon: 60.5721, sale_share_pct: 22.4, sale_share_pct_45d: 25.0, listings_45d: 20, over_100: false, active_secondary: 17, flat_count_effective: 76, gar_match_method: 'cadastre', median_price_rub: 5_400_000, median_price_per_m2: 149_200, avg_days_on_market: 61, year_built: 1978, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
|
||||
{ house_id: 5530, address: 'ул. Малышева, 84', lat: 56.8380, lon: 60.6203, sale_share_pct: 12.1, sale_share_pct_45d: 13.8, listings_45d: 16, over_100: false, active_secondary: 14, flat_count_effective: 116, gar_match_method: 'cadastre', median_price_rub: 7_850_000, median_price_per_m2: 172_300, avg_days_on_market: 47, year_built: 1954, house_type: 'stalin', total_floors: 6, series_name: null, is_emergency: false },
|
||||
{ house_id: 6119, address: 'ул. Щербакова, 20', lat: 56.7791, lon: 60.6120, sale_share_pct: 6.8, sale_share_pct_45d: 8.1, listings_45d: 22, over_100: false, active_secondary: 18, flat_count_effective: 264, gar_match_method: 'cadastre', median_price_rub: 8_900_000, median_price_per_m2: 164_800, avg_days_on_market: 38, year_built: 2016, house_type: 'monolith', total_floors: 25, series_name: null, is_emergency: false },
|
||||
{ house_id: 5871, address: 'ул. Крауля, 44', lat: 56.8443, lon: 60.5610, sale_share_pct: 17.9, sale_share_pct_45d: 19.6, listings_45d: 14, over_100: false, active_secondary: 12, flat_count_effective: 67, gar_match_method: 'cadastre', median_price_rub: 5_150_000, median_price_per_m2: 152_900, avg_days_on_market: 58, year_built: 1980, house_type: 'panel', total_floors: 9, series_name: '141', is_emergency: false },
|
||||
{ house_id: 6402, address: 'ул. 8 Марта, 190', lat: 56.8043, lon: 60.6094, sale_share_pct: 3.4, sale_share_pct_45d: 4.0, listings_45d: 11, over_100: false, active_secondary: 9, flat_count_effective: 262, gar_match_method: 'cadastre', median_price_rub: 9_300_000, median_price_per_m2: 176_400, avg_days_on_market: 33, year_built: 2019, house_type: 'monolith', total_floors: 26, series_name: null, is_emergency: false },
|
||||
];
|
||||
|
||||
/** Карта ЕКБ: 8 тепловых маркеров, выбран «ул. Викулова, 46» — открыт popup
|
||||
* (адрес, % в продаже, N из M квартир). */
|
||||
export const Default = () => (
|
||||
<SaleShareMap
|
||||
buildings={buildings}
|
||||
selectedHouseId={3101}
|
||||
hoveredHouseId={null}
|
||||
onSelect={() => {}}
|
||||
onHover={() => {}}
|
||||
/>
|
||||
);
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { SectionOverlay } from 'tradein-mvp-frontend';
|
||||
|
||||
// Glass/blur оверлей секций HUD «МЕРА Оценка» (/trade-in/v2): скобки-уголки,
|
||||
// шапка «номер секции + заголовок + ← К ОЦЕНКЕ», скроллируемое тело со
|
||||
// сменной view. Позиционируется absolute относительно артборда — в preview
|
||||
// рендерим contained внутри relative-обёртки с фоном v2-страницы.
|
||||
// Данные не передаём: каждая view падает на свой встроенный fixture-набор
|
||||
// (HISTORY_FIXTURE / ANALYTICS_FIXTURE …) — как storybook/unwired usage.
|
||||
|
||||
const Artboard = ({ children }: { children?: unknown }) => (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: 680,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
background:
|
||||
'radial-gradient(1100px 520px at 30% -10%, #f7fbff 0%, #eef4fa 55%, #e6eef7 100%)',
|
||||
}}
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Секция 04 «ПРОДАЖИ В ДОМЕ» (active=1 → HistoryView на fixture-данных):
|
||||
* таблица ДКП-продаж дома с ценами и датами. */
|
||||
export const HistorySection = () => (
|
||||
<Artboard>
|
||||
<SectionOverlay active={1} onClose={() => {}} onNavigate={() => {}} />
|
||||
</Artboard>
|
||||
);
|
||||
|
||||
/** Секция 06 «АНАЛИТИКА ДОМА» (active=3 → AnalyticsView): KPI дома,
|
||||
* динамика цены, недавние продажи. */
|
||||
export const AnalyticsSection = () => (
|
||||
<Artboard>
|
||||
<SectionOverlay active={3} onClose={() => {}} onNavigate={() => {}} />
|
||||
</Artboard>
|
||||
);
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
name: CI Trade-In
|
||||
|
||||
# Forgejo Actions pre-merge gate for the SUBPROJECT tradein-mvp/.
|
||||
# WHY THIS FILE EXISTS (#2208): основной .forgejo/workflows/ci.yml гейтит
|
||||
# ТОЛЬКО backend/** + frontend/** главного стека — tradein-PR проходили на
|
||||
# пусто-зелёных чеках (paths-filter no-op), а pytest tradein жил лишь в
|
||||
# post-merge deploy-tradein.yml. Итог: сломанный tradein-код мержился в main
|
||||
# и обнаруживался только на деплое. Этот workflow добавляет РЕАЛЬНЫЙ pre-merge
|
||||
# gate: tradein-backend pytest + tradein-frontend type-check/lint ДО мержа.
|
||||
on:
|
||||
# ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709,
|
||||
# см. подробное обоснование в ci.yml). Кратко: раньше push+pull_request на один
|
||||
# SHA давали разный github.ref → разные concurrency-группы → 2× прогон на
|
||||
# дефицитных раннерах. В bot-пайплайне каждый коммит идёт через PR, так что
|
||||
# pull_request гейтит его полностью; push-прогон был чистым дублем.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
# github.ref стабилен на весь PR (refs/pull/<N>/merge) → новый push в ветку PR
|
||||
# отменяет предыдущий незавершённый прогон ЭТОГО PR вместо накопления.
|
||||
group: ci-tradein-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Paths-filter: гейт бежит ТОЛЬКО когда поменялся tradein-код.
|
||||
# PR не трогающий tradein-mvp/ → оба job'а no-op'ятся → дёшево.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'tradein-mvp/backend/**'
|
||||
- 'tradein-mvp/packages/**'
|
||||
# workspace root: lock-only bump (uv lock --upgrade) или правка
|
||||
# [tool.uv.workspace] меняют реальные зависимости → гейт обязан бежать.
|
||||
- 'tradein-mvp/uv.lock'
|
||||
- 'tradein-mvp/pyproject.toml'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
- '.forgejo/workflows/ci-tradein.yml'
|
||||
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./tradein-mvp/backend
|
||||
env:
|
||||
# psycopg v3 требует parseable URL на импорте; реального коннекта нет —
|
||||
# DB-тесты мокаются (mirror deploy-tradein.yml test-job).
|
||||
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
# Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается
|
||||
# на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI).
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache uv packages
|
||||
# Кросс-прогонный кэш скачанных/собранных wheel'ов (~/.cache/uv).
|
||||
# continue-on-error чтобы сбой cache-бэкенда раннера НИКОГДА не ронял gate.
|
||||
# Ключ по workspace-локу tradein-mvp/uv.lock (tracked с воркспейса #2137).
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-tradein-${{ runner.os }}-${{ hashFiles('tradein-mvp/uv.lock') }}
|
||||
restore-keys: |
|
||||
uv-tradein-${{ runner.os }}-
|
||||
|
||||
- name: Sync deps (incl. dev group — pytest)
|
||||
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
|
||||
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
|
||||
# Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root
|
||||
# вверх от cwd.
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в
|
||||
# полном прогоне (проверено локально: 2947 passed / 1 failed). Список обязан
|
||||
# совпадать с test-job в deploy-tradein.yml.
|
||||
run: |
|
||||
uv run pytest -q \
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./tradein-mvp/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
# Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine).
|
||||
# npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет
|
||||
# package-lock.json (Dockerfile ставит через npm install), а cache=npm
|
||||
# без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint.
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install deps (npm install, no lockfile)
|
||||
# ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage):
|
||||
# --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches;
|
||||
# --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`):
|
||||
# в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но
|
||||
# Dockerfile ставит именно npm install) → `npm ci` упал бы.
|
||||
run: npm install --legacy-peer-deps --no-audit --no-fund
|
||||
|
||||
- name: Type-check (tsc --noEmit)
|
||||
# Blocking: любая TS-ошибка → job RED.
|
||||
run: npm run type-check
|
||||
|
||||
- name: Lint (next lint)
|
||||
# Blocking: любая ESLint-ошибка → job RED.
|
||||
run: npm run lint
|
||||
|
|
@ -103,10 +103,6 @@ jobs:
|
|||
filters: |
|
||||
backend:
|
||||
- 'tradein-mvp/backend/**'
|
||||
# scraper-kit вкомпилирован в backend-образ (build context tradein-mvp/,
|
||||
# scheduler_main импортирует пакет) — kit-only изменение обязано
|
||||
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
frontend:
|
||||
- 'tradein-mvp/frontend/**'
|
||||
browser:
|
||||
|
|
@ -121,19 +117,11 @@ jobs:
|
|||
- 'tradein-mvp/backend/app/services/scheduler.py'
|
||||
- 'tradein-mvp/backend/app/scheduler_main.py'
|
||||
- 'tradein-mvp/backend/app/tasks/**'
|
||||
# #2188: scheduler исполняет matching/dedup при каждом scrape-тике —
|
||||
# без этих путей scraper-контейнер оставался на старом коде
|
||||
# (2026-07-02: fias-dedup доехал до tradein-backend, но не до
|
||||
# tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть
|
||||
# scheduler — его правки тоже обязаны пересоздавать контейнер.
|
||||
- 'tradein-mvp/backend/app/services/matching/**'
|
||||
- 'tradein-mvp/backend/app/services/house_dedup_merge.py'
|
||||
- 'tradein-mvp/packages/scraper-kit/**'
|
||||
|
||||
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
|
||||
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
||||
# Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed.
|
||||
# 1 pre-existing order-dependent test is deselected (see DESELECT note below).
|
||||
# 2 pre-existing order-dependent tests are deselected (see DESELECT note below).
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
|
|
@ -158,21 +146,20 @@ jobs:
|
|||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Sync deps (incl. dev group — pytest)
|
||||
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
|
||||
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
|
||||
# Dockerfile (uv sync --frozen --no-dev). Актуализировано в #2208.
|
||||
run: uv sync --frozen
|
||||
# NB: tradein-mvp/backend/uv.lock is gitignored (see tradein-mvp/.gitignore),
|
||||
# so --frozen can't work in CI (no lockfile in a fresh checkout). Resolve
|
||||
# from pyproject.toml like the Dockerfile does (uv sync --no-dev there).
|
||||
run: uv sync
|
||||
|
||||
- name: Run pytest (tradein-mvp/backend)
|
||||
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
|
||||
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
|
||||
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
|
||||
# DESELECT (2026-05): two pre-existing tests fail only in whole-suite
|
||||
# ordering (pass in isolation) due to global state leak from other test
|
||||
# modules — not introduced here. Excluded so the gate is reliably green;
|
||||
# tracked separately. Everything else (1087 tests) must pass.
|
||||
run: |
|
||||
uv run pytest -q \
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit"
|
||||
--deselect "tests/test_search_api.py::test_search_cache_hit" \
|
||||
--deselect "tests/test_cian_valuation.py::test_cache_hit_returns_cached"
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -196,10 +183,7 @@ jobs:
|
|||
- name: Build & push tradein-backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
|
||||
# для editable install (#2137). Dockerfile — в backend/.
|
||||
context: ./tradein-mvp
|
||||
file: ./tradein-mvp/backend/Dockerfile
|
||||
context: ./tradein-mvp/backend
|
||||
push: true
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||
|
|
@ -232,13 +216,10 @@ jobs:
|
|||
context: ./tradein-mvp/frontend
|
||||
push: true
|
||||
# basePath=/trade-in baked-in во время build (Next.js)
|
||||
# NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут
|
||||
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
|
||||
# dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница
|
||||
# уходит в notFound (404), не индексируется и не краулится.
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||
NEXT_PUBLIC_ENABLE_PREVIEW=1
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||
tags: |
|
||||
|
|
@ -295,13 +276,7 @@ jobs:
|
|||
env:
|
||||
IMAGE_TAG: latest
|
||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||
# Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT
|
||||
# recreate the scraper and SIGKILL a running multi-hour job. Only genuine
|
||||
# scraper-code paths (the `scraper` paths-filter already covers
|
||||
# app/services/scrapers/**, scrape_pipeline.py, scheduler.py,
|
||||
# scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch —
|
||||
# should trigger a scraper recreate. (infra term intentionally dropped.)
|
||||
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
|
|
@ -339,44 +314,24 @@ jobs:
|
|||
export IMAGE_TAG="$IMAGE_TAG"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
|
||||
|
||||
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
|
||||
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
|
||||
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
|
||||
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
|
||||
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
|
||||
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
|
||||
# нового кода → старые контейнеры продолжают работать на СТАРОМ коде +
|
||||
# СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый
|
||||
# код на старой схеме». Откат = просто ничего не поднимали.
|
||||
|
||||
# (1) Только БД — чтобы прогнать миграции до нового app-кода.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
|
||||
|
||||
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
|
||||
echo "→ Ожидание готовности postgres..."
|
||||
pg_ready=""
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
|
||||
pg_ready="yes"; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$pg_ready" ]; then
|
||||
echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой."
|
||||
echo " Новый app-код НЕ поднят; старые контейнеры не тронуты."
|
||||
exit 1
|
||||
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
||||
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
||||
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
echo "→ postgres ready."
|
||||
|
||||
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
|
||||
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
|
||||
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
|
||||
# старте volume. Здесь — для повторных миграций после первого запуска.
|
||||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||
# #2216) glob'ом не подхватывается.
|
||||
sleep 5
|
||||
|
||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||
|
|
@ -450,113 +405,16 @@ jobs:
|
|||
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
|
||||
fi
|
||||
|
||||
# (4) Теперь — новый app-код: схема уже актуальна.
|
||||
#
|
||||
# #1951: раньше scraper пересоздавался ВТОРОЙ отдельной командой `up -d`,
|
||||
# уже ПОСЛЕ browser/backend/frontend. Если это происходило посреди
|
||||
# in-flight sweep'а (avito/cian/rosreestr full-load), процесс убивался на
|
||||
# лету, а осиротевшая scrape_runs-строка сидела status='running' с
|
||||
# замёрзшим heartbeat до периодического 6h zombie-reaper'а — false "hang"
|
||||
# investigation вместо честного deploy-артефакта (см. cian_full_load #404).
|
||||
# Три меры ниже — все devops-only (shell в deploy-скрипте), БЕЗ правок
|
||||
# Python в scraper-стартап-пути (scheduler_main.py / scraper-kit /
|
||||
# app/services/scrapers — намеренно не тронуты, см. PR-описание):
|
||||
#
|
||||
# 1) Атомарный recreate — browser/backend/frontend[+scraper] поднимаются
|
||||
# ОДНИМ `docker compose up -d` инвокейшном (список сервисов собирается
|
||||
# заранее в $SERVICES), а не двумя последовательными командами.
|
||||
# 2) Graceful drain — если scraper будет пересоздан, ждём (до 5 мин, poll
|
||||
# каждые 10s) пока scrape_runs.status='running' не станет 0, ПРЕЖДЕ чем
|
||||
# инициировать recreate. Таймаут не блокирует деплой навсегда —
|
||||
# SIGTERM-drain (#1182 Phase 2/3a) + stop_grace_period 120s остаются
|
||||
# финальной страховкой для того, что не успело дойти до checkpoint'а.
|
||||
# 3) Startup-reap — сразу после recreate помечаем 'cancelled' любые
|
||||
# 'running'-строки, чей heartbeat не обновлялся с МОМЕНТА (по часам
|
||||
# самой БД — SELECT NOW(), без risk clock-skew раннера), взятого
|
||||
# непосредственно перед stop. Такие строки заведомо осиротели ЭТИМ
|
||||
# recreate'ом (старый контейнер физически не может писать heartbeat
|
||||
# после своей остановки) — не ждём 6h периодического reap_zombies().
|
||||
# 'cancelled' (не 'zombie') — честно отличает «убит деплоем» от
|
||||
# «непонятно завис» (последнее по-прежнему ловит только 6h-reaper).
|
||||
# Порог — по метке времени конкретного recreate, а не по фиксированному
|
||||
# интервалу: не зависит от heartbeat-каденса разных источников и не
|
||||
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
|
||||
# admin-triggered scrape внутри backend, если backend в этом деплое
|
||||
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
|
||||
SERVICES="browser backend frontend"
|
||||
SCRAPER_STOP_TS=""
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
|
||||
drained=""
|
||||
for i in $(seq 1 30); do
|
||||
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
|
||||
# прогон психgl молча читается как «слито», и graceful drain
|
||||
# становится no-op именно в момент проблем с БД во время деплоя.
|
||||
running_count=""
|
||||
psql_out="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
|
||||
"SELECT COUNT(*) FROM scrape_runs WHERE status='running';" 2>/dev/null)" \
|
||||
&& running_count="$(printf '%s' "$psql_out" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
if [ -n "$running_count" ] && [ "$running_count" = "0" ]; then
|
||||
drained="yes"; break
|
||||
fi
|
||||
if [ -z "$running_count" ]; then
|
||||
echo " ...не удалось прочитать running_count (psql failed) — считаем как «ещё активен», жду 10s (попытка $i/30)"
|
||||
else
|
||||
echo " ...${running_count} активных run(ов) ещё бегут, жду 10s (попытка $i/30)"
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [ -n "$drained" ]; then
|
||||
echo "→ Активных run'ов нет — recreate scraper безопасен."
|
||||
else
|
||||
echo "WARNING: активные scrape_runs остались после 5 мин ожидания — recreate продолжится."
|
||||
echo " SIGTERM-drain (#1182) + stop_grace_period 120s постараются сохранить checkpoint;"
|
||||
echo " startup-reap ниже подчистит то, что не успеет."
|
||||
fi
|
||||
|
||||
# Checkpoint по часам БД (не раннера) прямо перед recreate.
|
||||
# NB: tr -d '[:space:]' сломан для timestamptz-литерала — убирает и
|
||||
# внутренний пробел между датой и временем ("2026-07-04 06:43" →
|
||||
# "2026-07-0406:43"), CAST(...AS timestamptz) на такое падает молча
|
||||
# (см. WARNING-фолбэк ниже). sed убирает только leading/trailing.
|
||||
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
|
||||
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
|
||||
|
||||
SERVICES="$SERVICES scraper"
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
|
||||
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
|
||||
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
|
||||
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
|
||||
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
|
||||
# — поэтому подставляем bash-значением напрямую. SCRAPER_STOP_TS сгенерирован
|
||||
# самим Postgres (SELECT NOW()), не внешний ввод → безопасно.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c "
|
||||
UPDATE scrape_runs
|
||||
SET status = 'cancelled',
|
||||
finished_at = NOW(),
|
||||
error = 'deploy #1951: tradein-scraper recreated mid-run (startup-reap, checkpoint ${SCRAPER_STOP_TS})'
|
||||
WHERE status = 'running'
|
||||
AND heartbeat_at < CAST('${SCRAPER_STOP_TS}' AS timestamptz);
|
||||
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
|
||||
fi
|
||||
|
||||
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
|
||||
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
|
||||
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
|
||||
# "server gendesign_remote does not exist" — FOREIGN SERVER создаёт
|
||||
# 060_postgres_fdw_extension.sql, ещё не прогнанная на тот момент.
|
||||
# Требовался рестарт для повторной попытки хука. Теперь backend
|
||||
# стартует на шаге (4), т.е. ПОСЛЕ применения миграций (шаг 3) →
|
||||
# lifespan-hook гарантированно видит применённые миграции (FOREIGN
|
||||
# SERVER gendesign_remote существует) уже с первого старта. Рестарт удалён.
|
||||
# Retry backend lifespan hook AFTER migrations applied.
|
||||
# tradein-backend startup runs ensure_fdw_user_mapping which needs
|
||||
# FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql).
|
||||
# Without restart, the first compose-up's startup hook failed with
|
||||
# "server gendesign_remote does not exist" because migrations hadn't run yet.
|
||||
# See PR #493 deploy/1156 for the incident details.
|
||||
echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup"
|
||||
docker restart tradein-backend
|
||||
# Give backend time to come up before Caddy reload + health check below
|
||||
sleep 5
|
||||
|
||||
# Caddy reload — основной Caddyfile содержит inline tradein routes
|
||||
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
|
||||
|
|
@ -565,28 +423,12 @@ jobs:
|
|||
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
||||
caddy reload --config /etc/caddy/Caddyfile || true
|
||||
|
||||
# Health check — деплой ВАЛИТСЯ, если backend не поднялся (#2214).
|
||||
# Раньше цикл после 30 неуспешных попыток молча продолжал скрипт и
|
||||
# доходил до записи success-маркера → мёртвый backend помечался
|
||||
# «задеплоено». Теперь: флаг healthy выставляется ТОЛЬКО при HTTP 200
|
||||
# на /health; после цикла — hard exit 1, если флаг пуст. exit 1
|
||||
# происходит ДО записи .tradein-deployed-sha (маркер пишется последним,
|
||||
# ниже) → следующий прогон changes-job возьмёт корректную базу.
|
||||
# NB set -e: curl стоит в условии `if` (exempt из errexit) — неуспешная
|
||||
# попытка НЕ фатальна, а лишь провоцирует следующую итерацию цикла.
|
||||
healthy=""
|
||||
# Health check
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||
exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
|
||||
healthy="yes"; break
|
||||
fi
|
||||
docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||
exec -T backend curl -fsS http://localhost:8000/health && break
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$healthy" ]; then
|
||||
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "→ backend healthy на /health."
|
||||
|
||||
# Cleanup старых образов
|
||||
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
|
||||
|
|
|
|||
|
|
@ -199,14 +199,6 @@ jobs:
|
|||
# but other callers may invoke the raw path).
|
||||
chmod +x ops/*.sh 2>/dev/null || true
|
||||
|
||||
# Full PDF report bind-source (#2259 PR-D). docker создаёт отсутствующий
|
||||
# bind-source как root:root — worker пишет PDF под uid 1000 → PermissionError
|
||||
# → вечный «building». Создаём каталог заранее + chown под контейнерный uid.
|
||||
# chown под non-root deploy-юзером требует sudo → fallback (|| true — если и
|
||||
# sudo нет, каталог уже наш и chown не нужен).
|
||||
mkdir -p reports
|
||||
chown 1000:1000 reports 2>/dev/null || sudo chown 1000:1000 reports 2>/dev/null || true
|
||||
|
||||
# Sentry release tracking
|
||||
mkdir -p backend
|
||||
touch backend/.env.runtime
|
||||
|
|
|
|||
|
|
@ -9,14 +9,6 @@ name: Deploy Obsidian
|
|||
#
|
||||
# Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub).
|
||||
# Не трогает main-стек (backend / frontend / postgres / worker / beat / caddy).
|
||||
#
|
||||
# ИСТОРИЯ (2026-07-05): жил в .github/workflows/ с момента миграции с GitHub
|
||||
# (16.05.2026), помечен в README как «остался на GitHub» — но живого зеркала
|
||||
# на github.com с настроенными секретами не оказалось: 0 запусков за всю
|
||||
# историю Forgejo Actions (12000+ прогонов остальных workflow), контейнер
|
||||
# не пересоздавался с 17.05 до ручного SSH-фикса 04.07. Перенесён сюда —
|
||||
# единственная директория, которую реально исполняет этот инстанс.
|
||||
# См. issue #2416.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -25,7 +17,7 @@ on:
|
|||
- "docker-compose.obsidian.yml"
|
||||
- "scripts/setup-couchdb.sh"
|
||||
- "docs/obsidian-livesync.md"
|
||||
- ".forgejo/workflows/deploy-obsidian.yml"
|
||||
- ".github/workflows/deploy-obsidian.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
# Memory graph (hardlink to C:\mcp\.aim\memory-gendesign.jsonl)
|
||||
# Memory graph (hardlink to C:\mcp\.aim\memory-gendesign.jsonl)
|
||||
*.jsonl
|
||||
!memory/memory-gendesign.jsonl
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ tradein-mvp/scripts/.*cookies*.json
|
|||
# Agents and rules ARE tracked: they are team-shared config.
|
||||
# Explicit per-path list — `.claude/` + `!.claude/agents/` whitelist pattern
|
||||
# does not work because git cannot re-include files under an ignored parent.
|
||||
.claude/settings.json
|
||||
.claude/settings.local.json
|
||||
.claude/cache/
|
||||
.claude/sessions/
|
||||
|
|
@ -87,10 +88,6 @@ sf_anton_snapshot.db-*
|
|||
data/osrm/*
|
||||
!data/osrm/.gitkeep
|
||||
|
||||
# Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports.
|
||||
# Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard).
|
||||
/reports/
|
||||
|
||||
# Log cruft at repo root
|
||||
debug.log
|
||||
.ds-sync/
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -21,7 +21,7 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
|||
|
||||
2. **Branch + PR mandatory.** Никаких direct push в main. `feat/...` / `fix/...` / `refactor/...` / `docs/...` / `chore/...` → ветка от `forgejo/main` → `mcp__forgejo__create_pull_request` → PR URL пользователю. После PR create — сразу `Skill loop` polling. `gh` CLI bypassed (2026-05-16). **Полные правила: `.claude/rules/git-pr.md`**.
|
||||
|
||||
3. **Agent-first workflow.** Main session orchestrates ONLY — не пишет код inline. Любая задача >1 файл / >50 строк → subagent. Pre-push: spawn `code-reviewer` subagent на staged changes. Post-push review — внешнее окно Claude (НЕ дублировать). Лимиты размера subagent-задачи: `.claude/rules/delegation.md`.
|
||||
3. **Agent-first workflow.** Main session orchestrates ONLY — не пишет код inline. Любая задача >1 файл / >50 строк → subagent. Pre-push: spawn `code-reviewer` subagent на staged changes. Post-push review — внешнее окно Claude (НЕ дублировать).
|
||||
|
||||
4. **psycopg v3 only.** `import psycopg2` → ModuleNotFoundError. `CAST(:x AS type)` в SQL — никогда `:x::type`. **Полные правила: `.claude/rules/backend.md` + `sql.md`**.
|
||||
|
||||
|
|
@ -41,16 +41,14 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
|||
| `database-expert` | `data/sql/**.sql`, Alembic, EXPLAIN ANALYZE |
|
||||
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` |
|
||||
| `code-reviewer` | Pre-push lint (security, correctness, conventions) |
|
||||
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE (не эксклюзивно: self-merge разрешён любой сессии с 2026-06-27) |
|
||||
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE |
|
||||
| `qa-tester` | Post-deploy smoke (playwright / curl / SQL) сразу после merge+deploy — rule #7 |
|
||||
|
||||
`auto-*` в `.claude/agents/` — standalone bot-персоны (`/work-as-*`), НЕ для Task-spawn; общий контракт — `_autonomous_pickup.md`.
|
||||
|
||||
**Routing:** тривиально (typo, 1-line) → main session. Single-domain clear → worker. Cross-domain / нечётко → `tech-analyst` first. Worker → `code-reviewer` → main commits → push → PR.
|
||||
|
||||
## Where to look
|
||||
|
||||
- **Path-scoped rules:** `.claude/rules/` (backend, frontend, sql, git-pr, deploy, tradein, ui-tokens, ui-conventions, ui-microcopy) — auto-loaded при работе с соответствующими файлами через `paths:` frontmatter; `delegation.md` — намеренно БЕЗ `paths:` (грузится в каждой сессии, не добавлять frontmatter)
|
||||
- **Path-scoped rules:** `.claude/rules/` (backend, frontend, sql, git-pr, deploy, ui-tokens, ui-conventions, ui-microcopy) — auto-loaded при работе с соответствующими файлами через `paths:` frontmatter
|
||||
- **Personal preferences:** `~/.claude/CLAUDE.md` (cross-project: no auto-commit, no Co-Authored-By, no @claude)
|
||||
- **Workflow memory:** `~/.claude/projects/<repo>/memory/MEMORY.md` index
|
||||
- **Vault MOCs:** `meta/`, `domains/<area>/<area>-MOC.md`, `decisions/`, `fixes/`, `limitations/`, `code/patterns/`
|
||||
|
|
@ -58,7 +56,7 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
|||
|
||||
## Don't (cross-domain — specifics → `.claude/rules/`)
|
||||
|
||||
- ❌ Direct push в main; merge вне policy (красный CI / tripwires — git-pr.md § Auto-merge policy)
|
||||
- ❌ Direct push в main / merge без approval
|
||||
- ❌ `--no-verify` / `--amend` / `git push --force`
|
||||
- ❌ Knowledge-файлы вне vault (`docs/research/`, `notes/`, `wiki/`)
|
||||
- ❌ Hardcode credentials в коде / коммитах
|
||||
|
|
|
|||
33
Caddyfile
33
Caddyfile
|
|
@ -16,13 +16,15 @@
|
|||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||||
# ineffective. With route { }, handlers are matched top-to-bottom as written.
|
||||
|
||||
# #2213: global `log_credentials` УБРАН. Он заставлял Caddy писать raw
|
||||
# Authorization header (Base64 `user:password`, тривиально обратимый) в ОБА лог-файла
|
||||
# (gendsgn.ru.log + auth_audit.log). Даже при root-only доступе и коротком retention
|
||||
# это plaintext-пароли пилотов на диске — недопустимый риск против скромной выгоды.
|
||||
# Trade-off: glitchtip-auth-forwarder больше не извлекает `attempted_username` из 401
|
||||
# (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает).
|
||||
# Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip.
|
||||
{
|
||||
servers {
|
||||
# ВКЛЮЧАЕТ raw Authorization/Cookie headers в access log
|
||||
# (по default Caddy 2.x редактирует их как "REDACTED").
|
||||
# Plain password будет в gendsgn.ru.log + auth_audit.log — оба root-only на VPS.
|
||||
# См. forwarder.py _extract_attempted_username для использования.
|
||||
log_credentials
|
||||
}
|
||||
}
|
||||
|
||||
gendsgn.ru {
|
||||
encode zstd gzip
|
||||
|
|
@ -86,23 +88,9 @@ gendsgn.ru {
|
|||
uri strip_prefix /trade-in
|
||||
reverse_proxy tradein-backend:8000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
|
||||
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
|
||||
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
|
||||
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
|
||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||
}
|
||||
}
|
||||
|
||||
# gendsgn.ru/sale-share — короткий адрес standalone-продукта «Поиск домов».
|
||||
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
|
||||
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
||||
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
|
||||
@saleshare path /sale-share /sale-share/
|
||||
handle @saleshare {
|
||||
redir /trade-in/sale-share permanent
|
||||
}
|
||||
|
||||
# Matcher `path /trade-in /trade-in/*` ловит И /trade-in (без слеша),
|
||||
# И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*`
|
||||
# пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ.
|
||||
|
|
@ -111,9 +99,6 @@ gendsgn.ru {
|
|||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||
reverse_proxy tradein-frontend:3000 {
|
||||
header_up X-Authenticated-User {http.auth.user.id}
|
||||
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
|
||||
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
|
||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru
|
|||
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
|
||||
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
|
||||
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
|
||||
- [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)*
|
||||
- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian (**остался на GitHub**): триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты).
|
||||
|
||||
**Forgejo Secrets / Variables:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе).
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ users:
|
|||
admin: admin
|
||||
kopylov: pilot
|
||||
user1: pilot
|
||||
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
||||
user2: pilot
|
||||
user3: pilot
|
||||
user4: pilot
|
||||
user5: pilot
|
||||
|
|
|
|||
|
|
@ -9,13 +9,6 @@ POST /api/v1/admin/etl/objective-backfill
|
|||
POST /api/v1/admin/etl/nspd-denorm-backfill
|
||||
Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps.
|
||||
|
||||
GET /api/v1/admin/etl/mapping-review
|
||||
Список авто-маппингов objective_complex_mapping + обогащение для ревью.
|
||||
POST /api/v1/admin/etl/mapping-review/{id}/approve
|
||||
Подтвердить маппинг (is_reviewed=true + note append).
|
||||
POST /api/v1/admin/etl/mapping-review/{id}/reject
|
||||
Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2).
|
||||
|
||||
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
|
||||
header removed 2026-05-23 — двойная auth избыточна для pilot.
|
||||
"""
|
||||
|
|
@ -23,24 +16,17 @@ header removed 2026-05-23 — двойная auth избыточна для pilo
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.services.etl.mapping_review import (
|
||||
approve_mapping,
|
||||
list_mapping_review,
|
||||
reject_mapping,
|
||||
)
|
||||
from app.services.etl.objective_backfill import (
|
||||
AUTO_ACCEPT_THRESHOLD,
|
||||
AUTO_ACCEPT_THRESHOLD_V2,
|
||||
REVIEW_THRESHOLD,
|
||||
apply_core_matches,
|
||||
auto_apply_matches,
|
||||
find_core_matches,
|
||||
find_match_candidates,
|
||||
trigger_mv_refresh,
|
||||
)
|
||||
|
|
@ -118,37 +104,6 @@ def run_objective_backfill(
|
|||
return result
|
||||
|
||||
|
||||
@router.post("/objective-core-backfill")
|
||||
def run_objective_core_backfill(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
dry_run: Annotated[bool, Query(description="Preview без insertions (default True)")] = True,
|
||||
refresh_mv: Annotated[bool, Query(description="REFRESH mv_layout_velocity после apply")] = True,
|
||||
) -> dict[str, object]:
|
||||
"""Core-name + dev-confirm проход (#2177 шаг 2, match_method='auto_core_dev_v5').
|
||||
|
||||
Нормализует ЯДРО имени обеих сторон (normalize_complex_name, #2198) и мапит
|
||||
несопоставленные domrf ЕКБ-объекты на objective-проекты. Пишутся ТОЛЬКО tier_a
|
||||
(ровно 1 кандидат + подтверждение застройщика). tier_b/ambiguous — в counts.
|
||||
|
||||
Дефолт dry_run=True (безопасно). Реальный insert — ?dry_run=false.
|
||||
|
||||
Returns dict:
|
||||
inserted / conflict_skipped / error_skipped / tier_a_total — из apply;
|
||||
tier_b / ambiguous / skipped_taken — счётчики отчёта;
|
||||
mv_rows_after_refresh — строк в MV после REFRESH (0 если dry-run / нет вставок).
|
||||
"""
|
||||
report = find_core_matches(db)
|
||||
result: dict[str, object] = dict(apply_core_matches(db, report, dry_run=dry_run))
|
||||
result.update(report.counts())
|
||||
|
||||
mv_rows = 0
|
||||
if refresh_mv and not dry_run and result.get("inserted", 0):
|
||||
mv_rows = trigger_mv_refresh(db)
|
||||
logger.info("mv_layout_velocity refreshed after core-backfill: %d rows", mv_rows)
|
||||
result["mv_rows_after_refresh"] = mv_rows
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/nspd-denorm-backfill")
|
||||
def run_nspd_denorm_backfill(
|
||||
limit: Annotated[
|
||||
|
|
@ -169,68 +124,3 @@ def run_nspd_denorm_backfill(
|
|||
task = backfill_all_dumps.apply_async(kwargs={"limit": limit})
|
||||
logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit)
|
||||
return {"task_id": task.id, "status": "enqueued", "limit": limit}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Mapping review — глазами подтвердить/отклонить авто-маппинги (feat/…-review)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/mapping-review")
|
||||
def get_mapping_review(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
only_unreviewed: Annotated[
|
||||
bool, Query(description="Только is_reviewed=false (авто-матчи без ревью)")
|
||||
] = True,
|
||||
limit: Annotated[int, Query(ge=1, le=500, description="Строк на странице")] = 100,
|
||||
offset: Annotated[int, Query(ge=0, description="Смещение страницы")] = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Список строк objective_complex_mapping + обогащение для ревьюера.
|
||||
|
||||
Обогащение каждой строки:
|
||||
- domrf_comm_name / domrf_dev_name — latest snapshot по domrf_obj_id;
|
||||
- objective_developers — застройщики objective-проекта (агрегат
|
||||
objective_lots.developer, scoped по project_name).
|
||||
|
||||
Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху).
|
||||
|
||||
Returns dict:
|
||||
rows: list обогащённых строк (id, objective_complex_name,
|
||||
objective_project_id, objective_group, domrf_obj_id, match_method,
|
||||
match_score, is_reviewed, note, created_at, domrf_comm_name,
|
||||
domrf_dev_name, objective_developers);
|
||||
total: всего строк (с учётом only_unreviewed, БЕЗ limit/offset);
|
||||
limit / offset / only_unreviewed: эхо параметров.
|
||||
"""
|
||||
return list_mapping_review(db, only_unreviewed=only_unreviewed, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/mapping-review/{mapping_id}/approve")
|
||||
def approve_mapping_review(
|
||||
mapping_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
"""Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
|
||||
|
||||
404 если строки с таким id нет.
|
||||
"""
|
||||
result = approve_mapping(db, mapping_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/mapping-review/{mapping_id}/reject")
|
||||
def reject_mapping_review(
|
||||
mapping_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
"""Отклонить маппинг — DELETE строки (иначе травит mv_layout_velocity §4.2).
|
||||
|
||||
Удаляемая строка целиком логируется (logger.info) перед commit — в note
|
||||
писать некуда, строки не будет. 404 если id не найден.
|
||||
"""
|
||||
deleted = reject_mapping(db, mapping_id)
|
||||
if deleted is None:
|
||||
raise HTTPException(status_code=404, detail=f"mapping {mapping_id} не найден")
|
||||
return {"status": "deleted", "deleted": deleted}
|
||||
|
|
|
|||
|
|
@ -497,21 +497,6 @@ def trigger_poi_sync() -> dict[str, Any]:
|
|||
return {"task_id": result.id, "queued_at": "now"}
|
||||
|
||||
|
||||
@router.post("/gisogd-permits-sync")
|
||||
def trigger_gisogd_permits_sync() -> dict[str, Any]:
|
||||
"""Manual trigger инкрементальной загрузки РНС/РВЭ ГИСОГД-СО → gisogd_permits (#2367).
|
||||
|
||||
Обычно запускается еженедельно через beat (вторник 06:30 МСК). Этот endpoint —
|
||||
для ad-hoc запуска (например после деплоя миграции 187_gisogd_permits.sql или для
|
||||
внеочередного обновления). Инкрементально: карточки тянутся только для новых/
|
||||
изменившихся документов, повторный запуск дёшев.
|
||||
"""
|
||||
from app.workers.tasks.gisogd_permits_sync import sync_gisogd_permits
|
||||
|
||||
result = sync_gisogd_permits.apply_async()
|
||||
return {"task_id": result.id, "queued_at": "now"}
|
||||
|
||||
|
||||
class TriggerObjectiveEtlRequest(BaseModel):
|
||||
sqlite_path: str | None = Field(
|
||||
default=None,
|
||||
|
|
@ -699,18 +684,12 @@ def objective_coverage(
|
|||
"""
|
||||
from app.services.objective_etl import get_sqlite_info
|
||||
|
||||
# #1964: `lots` = СЫРОЙ COUNT(*) — намеренно. Этот счётчик сравнивается с
|
||||
# SQLite-источником Антона (ETL-fidelity: «свежее ли SQLite, чем БД»), а SQLite
|
||||
# хранит сырые строки → дедуп-view сломал бы сравнение. Рядом отдаём
|
||||
# `lots_physflat` (v_objective_lots_latest — по физлоту) для прозрачности
|
||||
# инфляции (~2.91×: сырых ~1.75M vs физлотов ~603k).
|
||||
pg_row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM objective_lots) AS lots,
|
||||
(SELECT COUNT(*) FROM v_objective_lots_latest) AS lots_physflat,
|
||||
(SELECT COUNT(*) FROM objective_corpus_room_month) AS crm,
|
||||
(SELECT COUNT(*) FROM objective_complex_mapping) AS mappings,
|
||||
(SELECT MAX(snapshot_date) FROM objective_lots) AS last_lot_snapshot,
|
||||
|
|
@ -727,7 +706,6 @@ def objective_coverage(
|
|||
return {
|
||||
"pg": {
|
||||
"lots": int(pg_row["lots"] or 0),
|
||||
"lots_physflat": int(pg_row["lots_physflat"] or 0),
|
||||
"corp_room_month": int(pg_row["crm"] or 0),
|
||||
"mappings": int(pg_row["mappings"] or 0),
|
||||
"last_lot_snapshot": pg_row["last_lot_snapshot"].isoformat()
|
||||
|
|
@ -1152,7 +1130,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
|||
)
|
||||
from app.workers.tasks.etl_newbuilding_crossload import etl_newbuilding_crossload
|
||||
|
||||
result = etl_newbuilding_crossload.apply_async(kwargs={"triggered_by": "manual"})
|
||||
result = etl_newbuilding_crossload.apply_async()
|
||||
return {"task_id": result.id, "queued_at": "now"}
|
||||
|
||||
|
||||
|
|
@ -1419,12 +1397,8 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
|||
table="nspd_geo_jobs",
|
||||
work_col="targets_done",
|
||||
attempt_fallback_col="created_at",
|
||||
# On-demand источник БЕЗ cron (admin UI / CLI / lazy из analyze) — штучные
|
||||
# фетчи по активности пользователя. fresh_days=7 флагал каждую неделю
|
||||
# тишины ложным stale-алертом (расследование 2026-07-04); пороги — под
|
||||
# реальную каденцию, critical=False и так не трогает overall.
|
||||
fresh_days=30.0,
|
||||
stale_days=90.0,
|
||||
fresh_days=7.0,
|
||||
stale_days=30.0,
|
||||
),
|
||||
FreshnessSource(
|
||||
source="cadastre",
|
||||
|
|
@ -1435,20 +1409,6 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
|||
fresh_days=14.0,
|
||||
stale_days=45.0,
|
||||
),
|
||||
FreshnessSource(
|
||||
# #2367: реестр РНС/РВЭ ГИСОГД-СО. Data-table режим (плоская таблица без run-
|
||||
# ledger): свежесть = MAX(fetched_at), upd_24h/_7d = COUNT(*) по окну. Источник
|
||||
# обновляется ежедневно, тянем еженедельно (beat вторник) → fresh<14d, stale<45d
|
||||
# (широкий запас на пропуск одного-двух вторников, как cadastre). critical=False.
|
||||
source="gisogd_permits",
|
||||
label="ГИСОГД-СО РНС/РВЭ (реестр разрешений)",
|
||||
table="gisogd_permits",
|
||||
timestamp_col="fetched_at",
|
||||
# В timestamp-режиме work_col не используется — валидное имя колонки.
|
||||
work_col="id",
|
||||
fresh_days=14.0,
|
||||
stale_days=45.0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -1789,20 +1749,6 @@ def trigger_ekburg_permits(
|
|||
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
||||
|
||||
|
||||
# WAF cooldown guard message (#2443 — DOM.РФ hard-banned this VPS's IP 2026-05-24
|
||||
# после серии failed catalog SSR extras-сессий). Beat schedule для catalog-object
|
||||
# и catalog-flat scrape'ов ОТКЛЮЧЕН по этой же причине (см. beat_schedule.py) —
|
||||
# оба ad-hoc admin-эндпоинта ниже бьют по ТОМУ ЖЕ /сервисы/* BrowserSession
|
||||
# path family, поэтому без явного оператор-override могут углубить бан (#2445 D1).
|
||||
_WAF_COOLDOWN_GUARD_MSG = (
|
||||
"Ad-hoc catalog-scrape заблокирован guard'ом: DOM.РФ WAF hard-ban этого VPS IP "
|
||||
"2026-05-24 (issue #2443), beat schedule для этого таска отключён по той же "
|
||||
"причине. Повторный ad-hoc запуск может углубить бан. Если ты осознанно "
|
||||
"принимаешь этот риск (WAF cooldown прошёл, targeted smoke-test и т.п.) — "
|
||||
"передай i_understand_waf_risk=true в теле запроса."
|
||||
)
|
||||
|
||||
|
||||
class TriggerKnCatalogObjectsRequest(BaseModel):
|
||||
region_code: int = Field(default=66, ge=1, le=99)
|
||||
max_objects: int | None = Field(default=None, ge=1, le=2000)
|
||||
|
|
@ -1814,14 +1760,6 @@ class TriggerKnCatalogObjectsRequest(BaseModel):
|
|||
"что уже скраплено сегодня."
|
||||
),
|
||||
)
|
||||
i_understand_waf_risk: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
|
||||
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper бьёт по "
|
||||
"тому же /сервисы/* BrowserSession path family, что вызвал бан."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/kn-catalog-objects")
|
||||
|
|
@ -1838,14 +1776,7 @@ def trigger_kn_catalog_objects(
|
|||
- max_objects=None → дефолтный лимит таска (300).
|
||||
- max_objects=3 → smoke-тест.
|
||||
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
||||
|
||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
beat schedule для этого таска отключён из-за WAF hard-ban 2026-05-24, ad-hoc
|
||||
re-trigger без явного подтверждения оператора запрещён.
|
||||
"""
|
||||
if not payload.i_understand_waf_risk:
|
||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||
|
||||
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
|
|
@ -1863,65 +1794,3 @@ def trigger_kn_catalog_objects(
|
|||
"force": payload.force,
|
||||
"queued_at": "now",
|
||||
}
|
||||
|
||||
|
||||
class TriggerKnCatalogFlatsRequest(BaseModel):
|
||||
region_code: int = Field(default=66, ge=1, le=99)
|
||||
max_flats: int | None = Field(default=None, ge=1, le=5000)
|
||||
force: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"True — игнорировать фильтр свежести ('catalog_updated_at свежий') и "
|
||||
"грузить ВСЕ квартиры последнего snapshot с непустым catalog_url_hash "
|
||||
"('Загрузить все'). По умолчанию пропускает то, что скраплено < 30 дней назад."
|
||||
),
|
||||
)
|
||||
i_understand_waf_risk: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Обязателен (True) для запуска. Guard против случайного re-trigger'а "
|
||||
"после DOM.РФ WAF hard-ban 2026-05-24 (#2443) — этот scraper ездит по "
|
||||
"тому же /сервисы/* BrowserSession path family, что и catalog-objects."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/kn-catalog-flats")
|
||||
def trigger_kn_catalog_flats(
|
||||
payload: TriggerKnCatalogFlatsRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""Manual trigger для catalog-FLAT scraper (#2442): цена/статус/отделка/потолки/
|
||||
дата обновления + plan-изображения квартир из SSR-страницы каталога.
|
||||
|
||||
Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL. До тех пор пока
|
||||
#2442 Task 1 (elemId → catalog_url_hash) не задеплоен и свежий kn-sweep не
|
||||
наполнил hash — вернёт 0 обработанных строк (ожидаемо, не баг).
|
||||
|
||||
- max_flats=None → дефолтный лимит таска (300).
|
||||
- max_flats=3 → smoke-тест.
|
||||
- force=True → 'Загрузить все': игнорирует фильтр свежести, грузит всё с hash.
|
||||
|
||||
WAF cooldown guard (#2443, #2445 D1): требует i_understand_waf_risk=true —
|
||||
same /сервисы/* BrowserSession path family как catalog-objects, риск re-trigger
|
||||
того же WAF-бана.
|
||||
"""
|
||||
if not payload.i_understand_waf_risk:
|
||||
raise HTTPException(status_code=400, detail=_WAF_COOLDOWN_GUARD_MSG)
|
||||
|
||||
from app.workers.tasks.scrape_kn_catalog_flats import scrape_kn_catalog_flats
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"region_code": payload.region_code,
|
||||
"force": payload.force,
|
||||
}
|
||||
if payload.max_flats is not None:
|
||||
kwargs["max_flats"] = payload.max_flats
|
||||
result = scrape_kn_catalog_flats.apply_async(kwargs=kwargs)
|
||||
|
||||
return {
|
||||
"task_id": result.id,
|
||||
"region_code": payload.region_code,
|
||||
"max_flats": payload.max_flats,
|
||||
"force": payload.force,
|
||||
"queued_at": "now",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,11 +35,7 @@ from app.core.db import get_db
|
|||
from app.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
|
||||
from app.services.chat.intents import render_answer, route_intent
|
||||
from app.services.chat.orchestrator import orchestrate_chat
|
||||
from app.services.chat.retrieval import (
|
||||
_FORECAST_SCHEMA_VERSION,
|
||||
get_parcel_context_for_chat,
|
||||
get_report_for_chat,
|
||||
)
|
||||
from app.services.chat.retrieval import _FORECAST_SCHEMA_VERSION, get_report_for_chat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -96,46 +92,11 @@ async def ask(
|
|||
report_status="pending",
|
||||
)
|
||||
|
||||
# Курируемый паспорт участка + градрегламент (§1 analyze-рана) — отдельный read-only
|
||||
# seam. §22-отчёт (форсайт) НЕ несёт тер.зону/ЗОУИТ/ЕГРН, поэтому дотягиваем их из
|
||||
# analyze-1.0 и вливаем в КОПИЮ report_dict под ключ "parcel_context" (tool
|
||||
# get_parcel_info режет именно его). Analyze-рана нет/сбой чтения → работаем как
|
||||
# раньше (только форсайт); НЕ меняем pending-поведение (оно завязано на §22-ран выше).
|
||||
report = await _with_parcel_context(db, payload.cad_num, report)
|
||||
|
||||
if settings.llm_enabled:
|
||||
return await _answer_via_llm(db, payload, report, run_id)
|
||||
return _answer_deterministic(payload, report, run_id)
|
||||
|
||||
|
||||
async def _with_parcel_context(
|
||||
db: Session,
|
||||
cad_num: str,
|
||||
report: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Дотянуть курируемый паспорт участка и влить его в КОПИЮ report_dict.
|
||||
|
||||
Read-only: sync-чтение analyze-рана мостим через run_in_threadpool (как §22-отчёт).
|
||||
None (рана нет) → возвращаем report без изменений. Сбой БД глотаем в pending-стиле
|
||||
эндпоинта: паспорт участка — обогащение, его отсутствие не должно ронять чат.
|
||||
"""
|
||||
try:
|
||||
parcel_context = await run_in_threadpool(get_parcel_context_for_chat, db, cad_num)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"chat: parcel context read failed for cad=%s — continuing without it",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
return report
|
||||
if not parcel_context:
|
||||
return report
|
||||
# Копия: не мутируем report_dict, пришедший из get_report_for_chat.
|
||||
merged = dict(report)
|
||||
merged["parcel_context"] = parcel_context
|
||||
return merged
|
||||
|
||||
|
||||
def _answer_deterministic(
|
||||
payload: ChatAskRequest,
|
||||
report: dict[str, Any],
|
||||
|
|
|
|||
|
|
@ -8,19 +8,9 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.schemas.concept import (
|
||||
ConceptInput,
|
||||
ConceptOutput,
|
||||
HouseTypeCatalog,
|
||||
HouseTypeCatalogItem,
|
||||
MassingProgram,
|
||||
MassingRecomputeOutput,
|
||||
)
|
||||
from app.schemas.concept import ConceptInput, ConceptOutput
|
||||
from app.services.generative import geometry
|
||||
from app.services.generative.catalog import HOUSE_TYPES, available_section_types
|
||||
from app.services.generative.financial import compute_financial
|
||||
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
|
||||
from app.services.generative.teap import synthesize_teap_from_program
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -43,11 +33,6 @@ _DISTRICT_SEARCH_RADIUS_M: int = 5000
|
|||
_PRICE_MIN_RUB: int = 30_000
|
||||
_PRICE_MAX_RUB: int = 600_000
|
||||
|
||||
# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда
|
||||
# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне
|
||||
# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median.
|
||||
_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median"
|
||||
|
||||
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
|
||||
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
|
||||
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
|
||||
|
|
@ -72,33 +57,13 @@ _DISTRICT_FOR_POINT_SQL = text(
|
|||
|
||||
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
|
||||
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
|
||||
#
|
||||
# #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти objective_lot_id на
|
||||
# один физлот через пере-листинги) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
|
||||
# были по пере-листингам, вес медианы смещался. Дедупим INLINE через DISTINCT ON
|
||||
# (physflat-ключ, последний снапшот snapshot_date DESC, id DESC) — НЕ через общий
|
||||
# physflat-VIEW. Причина (deep-review #1964, прод-EXPLAIN): view не несёт
|
||||
# district-фильтр внутри, qual не проталкивается ниже DISTINCT ON → view сортирует
|
||||
# ВСЮ таблицу 1.76M (~5.8 s, request-path внутри analyze_parcel). Inline с district
|
||||
# В CTE → bitmap по району ~240k строк + sort (~1.7 s), индекс по district работает.
|
||||
# Сегмент-фильтры (price NOT NULL / диапазон) объективны по физлоту → применяем
|
||||
# ПОСЛЕ дедупа. Зеркало паттерна market_metrics._STOCK_SQL.
|
||||
_OBJECTIVE_MEDIAN_SQL = text(
|
||||
"""
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
project_name, corpus_name, section, floor, lot_number
|
||||
)
|
||||
price_per_m2_rub
|
||||
FROM objective_lots
|
||||
WHERE district = CAST(:dn AS text)
|
||||
ORDER BY project_name, corpus_name, section, floor, lot_number,
|
||||
snapshot_date DESC, id DESC
|
||||
)
|
||||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
|
||||
COUNT(*) AS sample_size
|
||||
FROM latest
|
||||
WHERE price_per_m2_rub IS NOT NULL
|
||||
FROM objective_lots
|
||||
WHERE district = CAST(:dn AS text)
|
||||
AND price_per_m2_rub IS NOT NULL
|
||||
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
|
||||
"""
|
||||
)
|
||||
|
|
@ -198,32 +163,6 @@ def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str
|
|||
return None, "class_norm"
|
||||
|
||||
|
||||
@router.get("/house-types", response_model=HouseTypeCatalog)
|
||||
async def list_house_types() -> HouseTypeCatalog:
|
||||
"""Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера.
|
||||
|
||||
Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам
|
||||
секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт
|
||||
выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без
|
||||
side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так
|
||||
фронт НЕ хардкодит габариты/этажности — они приходят отсюда.
|
||||
"""
|
||||
return HouseTypeCatalog(
|
||||
house_types=[
|
||||
HouseTypeCatalogItem(
|
||||
section_type=ht.section_type,
|
||||
label_ru=ht.label_ru,
|
||||
footprint_w_m=ht.footprint_w_m,
|
||||
footprint_d_m=ht.footprint_d_m,
|
||||
footprint_sqm=ht.footprint_sqm,
|
||||
default_floors=ht.default_floors,
|
||||
housing_class=ht.housing_class,
|
||||
)
|
||||
for ht in HOUSE_TYPES
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ConceptOutput)
|
||||
async def create_concept(
|
||||
payload: ConceptInput,
|
||||
|
|
@ -240,26 +179,7 @@ async def create_concept(
|
|||
|
||||
A degenerate parcel (setback consumes everything, malformed geometry) yields a
|
||||
422 rather than empty variants — that is a bad request, not a valid empty result.
|
||||
|
||||
Stage 3a (#1965): если задана ``building_program`` (типовые дома из каталога), кладём
|
||||
РОВНО эту программу вместо жадной max-FAR раскладки (один вариант). Неизвестный
|
||||
``section_type`` → 422 (валидируется тут, до размещения). Если участок не вмещает всю
|
||||
программу — НЕ 422: кладём сколько влезло и отдаём честный ``placed_count`` < requested.
|
||||
"""
|
||||
# Stage 3a: валидируем ключи программы по каталогу ДО размещения — неизвестный
|
||||
# section_type это bad request (422), а не 500 из KeyError в глубине placement.
|
||||
if payload.building_program:
|
||||
known = available_section_types()
|
||||
unknown = sorted({item.section_type for item in payload.building_program} - known)
|
||||
if unknown:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"unknown house type(s): {', '.join(unknown)}; "
|
||||
f"available: {', '.join(sorted(known))}"
|
||||
),
|
||||
)
|
||||
|
||||
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
|
||||
# стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не
|
||||
# блокировать event loop. Падение lookup'а не должно ронять генерацию.
|
||||
|
|
@ -267,7 +187,9 @@ async def create_concept(
|
|||
price_source: str = "class_norm"
|
||||
try:
|
||||
wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload)
|
||||
market_price, price_source = await run_in_threadpool(_lookup_market_price, db, wkt_point)
|
||||
market_price, price_source = await run_in_threadpool(
|
||||
_lookup_market_price, db, wkt_point
|
||||
)
|
||||
except ParcelGeometryError:
|
||||
# Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422).
|
||||
pass
|
||||
|
|
@ -288,70 +210,3 @@ async def create_concept(
|
|||
logger.warning("concept generation rejected parcel: %s", exc)
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return ConceptOutput(variants=variants)
|
||||
|
||||
|
||||
@router.post("/recompute", response_model=MassingRecomputeOutput)
|
||||
async def recompute_massing(
|
||||
payload: MassingProgram,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> MassingRecomputeOutput:
|
||||
"""Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга.
|
||||
|
||||
Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
|
||||
``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
|
||||
мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` — те же нормативные
|
||||
константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
|
||||
→ пересчитанный ТЭП + финмодель.
|
||||
|
||||
Цена продажи жилья:
|
||||
* ``market_price_per_sqm`` в теле → используется как есть (FAST path, БЕЗ БД) —
|
||||
фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник
|
||||
берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
|
||||
financial_estimate — objective_geo_radius / objective_district_median /
|
||||
district_reference / class_norm); если фронт его не прислал — дефолтный лейбл.
|
||||
НЕ хардкодим source — иначе honest-флаг в UI/PDF врёт о происхождении цены.
|
||||
* иначе, если передан ``parcel_centroid_wkt`` → ``_lookup_market_price`` по центроиду
|
||||
через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
|
||||
не должен блокировать event loop). Падение lookup'а → норматив класса, не 500.
|
||||
* иначе (нет ни цены, ни центроида) → норматив класса (``price_source="class_norm"``).
|
||||
"""
|
||||
# ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ───────
|
||||
market_price: float | None = payload.market_price_per_sqm
|
||||
price_source: str = "class_norm"
|
||||
if market_price is not None:
|
||||
# FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
|
||||
# Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если
|
||||
# его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно
|
||||
# форсит class_norm, но сюда мы попадаем только при заданной цене.)
|
||||
price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE
|
||||
elif payload.parcel_centroid_wkt:
|
||||
# Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept).
|
||||
# Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри
|
||||
# _lookup_market_price, защитный пояс на непредвиденное оставляем тут.
|
||||
try:
|
||||
market_price, price_source = await run_in_threadpool(
|
||||
_lookup_market_price, db, payload.parcel_centroid_wkt
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc)
|
||||
market_price, price_source = None, "class_norm"
|
||||
|
||||
teap = synthesize_teap_from_program(
|
||||
total_footprint_sqm=payload.total_footprint_sqm,
|
||||
floors=payload.floors,
|
||||
site_area_sqm=payload.site_area_sqm,
|
||||
housing_class=payload.housing_class,
|
||||
sections=payload.sections,
|
||||
)
|
||||
# compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в
|
||||
# event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется
|
||||
# внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно.
|
||||
financial = compute_financial(
|
||||
teap=teap,
|
||||
housing_class=payload.housing_class,
|
||||
land_cost_rub=payload.land_cost_rub,
|
||||
market_price_per_sqm=market_price,
|
||||
price_source=price_source,
|
||||
development_type=payload.development_type,
|
||||
)
|
||||
return MassingRecomputeOutput(teap=teap, financial=financial)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Annotated, Any
|
||||
|
||||
|
|
@ -20,32 +18,6 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process TTL-кэш результата _query_stats (перф-батч #2, #1953-followup)
|
||||
# ---------------------------------------------------------------------------
|
||||
# /landing/stats бьётся на КАЖДЫЙ load хоумпейджа и НЕ кэширован. KPI 3 (доля квартир
|
||||
# с ценой) делает полный агрегат по physflat-дедупу objective_lots: price_per_m2_rub
|
||||
# нет в covering-INCLUDE и фильтра нет → ~6.7 s heap-скан 603k физлотов на каждый
|
||||
# запрос. Данные дневной гранулярности (snapshot_date) → 1 ч staleness приемлем.
|
||||
#
|
||||
# Кэшируем ВЕСЬ dict (все 5 KPI), не только KPI 3 — там ещё pg_class deals + 2
|
||||
# MAX(snapshot_date). Только УСПЕШНЫЙ результат (None/fallback НЕ кэшируем — пустая
|
||||
# dev-БД / сбой запроса не должны залипать на час). Потокобезопасно (threading.Lock,
|
||||
# удерживается только на check/store); analyze идёт в threadpool под Uvicorn. Стиль —
|
||||
# зеркало app.services.weather_cache. time.monotonic устойчив к скачкам системных часов.
|
||||
_STATS_TTL_S: float = 3600.0 # 1 час — дневная гранулярность данных
|
||||
|
||||
# (result_dict, expires_at_monotonic_seconds). Только успешный результат.
|
||||
_STATS_CACHE: dict[str, Any] | None = None
|
||||
_STATS_EXPIRES_AT: float = 0.0
|
||||
_STATS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
"""Монотонный таймер (тесты подменяют этот helper, а не сам `time`)."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -97,14 +69,12 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
|
|||
deals_total = int(row_deals or 0)
|
||||
|
||||
# KPI 3: % objective_lots с ценой
|
||||
# #1964: physflat-дедуп view — доля квартир с ценой по ФИЗИЧЕСКИМ лотам, а не
|
||||
# по пере-листингам (сырой objective_lots раздут ~2.91× мульти lot_id/физлот).
|
||||
row_price = db.execute(
|
||||
text(
|
||||
"SELECT"
|
||||
" COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0"
|
||||
" / NULLIF(COUNT(*), 0)"
|
||||
" FROM v_objective_lots_latest"
|
||||
" FROM objective_lots"
|
||||
)
|
||||
).scalar()
|
||||
price_coverage_pct = round(float(row_price or 0.0), 1)
|
||||
|
|
@ -157,30 +127,6 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
|
|||
return None
|
||||
|
||||
|
||||
def _query_stats_cached(db: Session) -> dict[str, Any] | None:
|
||||
"""`_query_stats` с in-process TTL-кэшем (TTL=1 ч). См. блок-комментарий вверху.
|
||||
|
||||
Кэширует ТОЛЬКО успешный dict; None (сбой/пустая БД) не кэшируется — следующий
|
||||
запрос попробует снова. Lock удерживается лишь на check/store; сам DB-запрос идёт
|
||||
ВНЕ lock'а (не сериализуем одновременные cold-start, last-write wins — запрос
|
||||
идемпотентен, TTL длинный).
|
||||
"""
|
||||
global _STATS_CACHE, _STATS_EXPIRES_AT
|
||||
now = _now()
|
||||
with _STATS_LOCK:
|
||||
if _STATS_CACHE is not None and _STATS_EXPIRES_AT > now:
|
||||
return _STATS_CACHE
|
||||
# MISS / истёк → DB-запрос ВНЕ lock'а.
|
||||
result = _query_stats(db)
|
||||
if result is None:
|
||||
# Не кэшируем fallback — пусть следующий запрос повторит попытку.
|
||||
return None
|
||||
with _STATS_LOCK:
|
||||
_STATS_CACHE = result
|
||||
_STATS_EXPIRES_AT = _now() + _STATS_TTL_S
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/landing/stats", response_model=LandingStatsOut)
|
||||
def landing_stats(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
|
|
@ -196,7 +142,7 @@ def landing_stats(
|
|||
- paradox: строка-парадокс портфеля для hero CTA
|
||||
- stale: true если данные недоступны (DB ошибка или пустая БД)
|
||||
"""
|
||||
result = _query_stats_cached(db)
|
||||
result = _query_stats(db)
|
||||
if result is None:
|
||||
return LandingStatsOut(**_FALLBACK_DATA)
|
||||
return LandingStatsOut(**result)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -114,51 +114,6 @@ class Settings(BaseSettings):
|
|||
# Empty string = endpoint disabled.
|
||||
scrape_admin_token: str = ""
|
||||
|
||||
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
|
||||
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
|
||||
# captcha — подтверждено): per-region sweep гонит ~1548 объектов × 11
|
||||
# endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
|
||||
# WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
|
||||
#
|
||||
# Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
|
||||
# Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
|
||||
# (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
|
||||
# catalog-object scrapers) тоже переиспользуют эти же значения — они бьют
|
||||
# по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24
|
||||
# (#2443). nspd и прочие скраперы вне этого path family продолжают
|
||||
# использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений.
|
||||
# 2 — эмпирически безопасный потолок против volume-бана.
|
||||
# ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
|
||||
scrape_kn_browser_concurrency: int = 2
|
||||
# Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
|
||||
# flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
|
||||
# (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban.
|
||||
# min < max обязателен (иначе random.uniform отдаст границу). При throttle
|
||||
# ширим до 1200–3000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
|
||||
scrape_kn_request_jitter_min_ms: int = 1200
|
||||
scrape_kn_request_jitter_max_ms: int = 3000
|
||||
# Рычаг 2 — прокси (ОПЦИОНАЛЬНО, default None → прямое подключение, поведение
|
||||
# без изменений). Когда задан — KN-sweep BrowserSession запускает Chromium
|
||||
# через этот прокси (формат http://user:pass@host:port; парсится в
|
||||
# Playwright proxy={server,username,password}). Сильнейший рычаг против
|
||||
# IP-бана: переиспользует тот же mobile-proxy паттерн, что tradein-стек
|
||||
# (SCRAPER_PROXY_URL). Заводится ТОЛЬКО через окружение прод-контейнера.
|
||||
# ENV: SCRAPE_KN_PROXY_URL.
|
||||
scrape_kn_proxy_url: str | None = None
|
||||
# Рычаг 3 (ГЛАВНЫЙ unblock #1945) — изоляция flats от extras.
|
||||
# Эмпирически (prod, 2026-06-27): flats endpoint /portal-kn/api/sales/portal/table
|
||||
# на concurrency=2 = 0 WAF-бан на 100+ объектах; extras /сервисы/api/object/{id}/*
|
||||
# отдают 403 СРАЗУ (volume-independent, мертвы с 2026-06-03) И ТРАВЯТ cookies
|
||||
# сессии → последующие flats на той же сессии тоже 403. flats_count (метрика
|
||||
# #1945) рухнул 3670→9 именно из-за этого.
|
||||
#
|
||||
# True (дефолт, NEW): flats тянутся в ЧИСТОЙ flats-only сессии (extras на ней
|
||||
# НЕ дёргаются НИКОГДА) → flats_count восстанавливается; extras идут отдельным
|
||||
# best-effort проходом с recycle сессии на каждый WAF-403 (яд не накапливается
|
||||
# и НЕ касается flats). False: старое поведение (flats+extras в одной сессии,
|
||||
# poison-prone). ENV: SCRAPE_KN_EXTRAS_ISOLATED.
|
||||
scrape_kn_extras_isolated: bool = True
|
||||
|
||||
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
|
||||
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
|
||||
# Settings оставлены deprecated на случай отката (можно удалить позже).
|
||||
|
|
@ -209,13 +164,6 @@ class Settings(BaseSettings):
|
|||
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
||||
nspd_lite_rate_ms: int = 600
|
||||
|
||||
# Каталог хранения собранных PDF-отчётов ПТИЦА (эпик #2259 PR-D). Worker пишет
|
||||
# PDF сюда, backend читает файл для /report/download. На проде — общий writable
|
||||
# bind-mount `./reports:/app/reports` у сервисов backend+worker (docker-compose.prod.yml),
|
||||
# иначе backend не увидит файл, записанный воркером. Для локальной разработки —
|
||||
# относительный путь ОК (тест мокает render_full_report_pdf, файл не пишется).
|
||||
reports_dir: str = "/app/reports"
|
||||
|
||||
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
|
||||
# На проде монтируется bind-mount-ом docker-compose:
|
||||
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
||||
|
|
@ -358,18 +306,5 @@ class Settings(BaseSettings):
|
|||
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
|
||||
llm_max_retries: int = 2
|
||||
|
||||
# ── DaData /clean/address геокод (objective_backfill geo-pass, #2177) ──────
|
||||
# Токен + секрет для DaData /clean/address (обогащение адреса → geo_lat/geo_lon).
|
||||
# На gendesign-проде уже заведены в окружении контейнера (проверено 2026-07-03).
|
||||
# Оба нужны для clean_address (в отличие от suggest, которому хватает токена).
|
||||
# None/пусто → dadata_client.clean_address graceful-возвращает None (geo-pass
|
||||
# тогда reject'ит всё «нет геокода», не падает). ENV: DADATA_API_TOKEN /
|
||||
# DADATA_API_SECRET. Литерала ключа в коде/тестах нет.
|
||||
dadata_api_token: str | None = None
|
||||
dadata_api_secret: str | None = None
|
||||
# Таймаут одного HTTP-вызова к DaData (сек). Воркер geo-pass не должен висеть
|
||||
# на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
|
||||
dadata_timeout_s: float = 8.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
# Russian Trusted CA bundle (Минцифры РФ) — публичные сертификаты с gu-st.ru (Госуслуги).
|
||||
# Нужны для https-доменов с российским УЦ И/ИЛИ неполной серверной цепочкой (#2119).
|
||||
# Скачаны 2026-07-02: russian_trusted_root_ca_pem.crt + russian_trusted_sub_ca_pem.crt.
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
||||
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
||||
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
||||
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
||||
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
||||
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
||||
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
||||
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
||||
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
||||
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
||||
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
||||
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
||||
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
||||
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
||||
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
||||
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
||||
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
||||
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
||||
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
||||
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
||||
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
||||
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
||||
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
||||
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
||||
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
||||
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
||||
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
||||
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
||||
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
||||
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
||||
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIHQjCCBSqgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
||||
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
||||
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
||||
Q0EwHhcNMjIwMzAyMTEyNTE5WhcNMjcwMzA2MTEyNTE5WjBvMQswCQYDVQQGEwJS
|
||||
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
||||
YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi
|
||||
IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9YPqBKOk19NFymrE
|
||||
wehzrhBEgT2atLezpduB24mQ7CiOa/HVpFCDRZzdxqlh8drku408/tTmWzlNH/br
|
||||
HuQhZ/miWKOf35lpKzjyBd6TPM23uAfJvEOQ2/dnKGGJbsUo1/udKSvxQwVHpVv3
|
||||
S80OlluKfhWPDEXQpgyFqIzPoxIQTLZ0deirZwMVHarZ5u8HqHetRuAtmO2ZDGQn
|
||||
vVOJYAjls+Hiueq7Lj7Oce7CQsTwVZeP+XQx28PAaEZ3y6sQEt6rL06ddpSdoTMp
|
||||
BnCqTbxW+eWMyjkIn6t9GBtUV45yB1EkHNnj2Ex4GwCiN9T84QQjKSr+8f0psGrZ
|
||||
vPbCbQAwNFJjisLixnjlGPLKa5vOmNwIh/LAyUW5DjpkCx004LPDuqPpFsKXNKpa
|
||||
L2Dm6uc0x4Jo5m+gUTVORB6hOSzWnWDj2GWfomLzzyjG81DRGFBpco/O93zecsIN
|
||||
3SL2Ysjpq1zdoS01CMYxie//9zWvYwzI25/OZigtnpCIrcd2j1Y6dMUFQAzAtHE+
|
||||
qsXflSL8HIS+IJEFIQobLlYhHkoE3avgNx5jlu+OLYe0dF0Ykx1PGNjbwqvTX37R
|
||||
Cn32NMjlotW2QcGEZhDKj+3urZizp5xdTPZitA+aEjZM/Ni71VOdiOP0igbw6asZ
|
||||
2fxdozZ1TnSSYNYvNATwthNmZysCAwEAAaOCAeUwggHhMBIGA1UdEwEB/wQIMAYB
|
||||
Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTR4XENCy2BTm6KSo9MI7NM
|
||||
XqtpCzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzCBxwYIKwYBBQUH
|
||||
AQEEgbowgbcwOwYIKwYBBQUHMAKGL2h0dHA6Ly9yb3N0ZWxlY29tLnJ1L2NkcC9y
|
||||
b290Y2Ffc3NsX3JzYTIwMjIuY3J0MDsGCCsGAQUFBzAChi9odHRwOi8vY29tcGFu
|
||||
eS5ydC5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNydDA7BggrBgEFBQcwAoYv
|
||||
aHR0cDovL3JlZXN0ci1wa2kucnUvY2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQw
|
||||
gbAGA1UdHwSBqDCBpTA1oDOgMYYvaHR0cDovL3Jvc3RlbGVjb20ucnUvY2RwL3Jv
|
||||
b3RjYV9zc2xfcnNhMjAyMi5jcmwwNaAzoDGGL2h0dHA6Ly9jb21wYW55LnJ0LnJ1
|
||||
L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMDWgM6Axhi9odHRwOi8vcmVlc3Ry
|
||||
LXBraS5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF
|
||||
AAOCAgEARBVzZls79AdiSCpar15dA5Hr/rrT4WbrOfzlpI+xrLeRPrUG6eUWIW4v
|
||||
Sui1yx3iqGLCjPcKb+HOTwoRMbI6ytP/ndp3TlYua2advYBEhSvjs+4vDZNwXr/D
|
||||
anbwIWdurZmViQRBDFebpkvnIvru/RpWud/5r624Wp8voZMRtj/cm6aI9LtvBfT9
|
||||
cfzhOaexI/99c14dyiuk1+6QhdwKaCRTc1mdfNQmnfWNRbfWhWBlK3h4GGE9JK33
|
||||
Gk8ZS8DMrkdAh0xby4xAQ/mSWAfWrBmfzlOqGyoB1U47WTOeqNbWkkoAP2ys94+s
|
||||
Jg4NTkiDVtXRF6nr6fYi0bSOvOFg0IQrMXO2Y8gyg9ARdPJwKtvWX8VPADCYMiWH
|
||||
h4n8bZokIrImVKLDQKHY4jCsND2HHdJfnrdL2YJw1qFskNO4cSNmZydw0Wkgjv9k
|
||||
F+KxqrDKlB8MZu2Hclph6v/CZ0fQ9YuE8/lsHZ0Qc2HyiSMnvjgK5fDc3TD4fa8F
|
||||
E8gMNurM+kV8PT8LNIM+4Zs+LKEV8nqRWBaxkIVJGekkVKO8xDBOG/aN62AZKHOe
|
||||
GcyIdu7yNMMRihGVZCYr8rYiJoKiOzDqOkPkLOPdhtVlgnhowzHDxMHND/E2WA5p
|
||||
ZHuNM/m0TXt2wTTPL7JH2YC0gPz/BvvSzjksgzU5rLbRyUKQkgU=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# HARICA DV TLS Sub R1 (промежуточный, crt.harica.gr) — ekaterinburg-tr.gazprom.ru
|
||||
# отдаёт ТОЛЬКО leaf без intermediate (misconfig сервера); Python AIA-chasing не делает.
|
||||
# Добавлен 2026-07-02.
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIGwTCCBKmgAwIBAgIQFVuBuF22xppLHnbzSLBwXDANBgkqhkiG9w0BAQsFADBs
|
||||
MQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJl
|
||||
c2VhcmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0Eg
|
||||
Um9vdCBDQSAyMDIxMB4XDTIxMDMxOTA5MjQwNFoXDTM2MDMxNTA5MjQwM1owYjEL
|
||||
MAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl
|
||||
YXJjaCBJbnN0aXR1dGlvbnMgQ0ExGjAYBgNVBAMMEUhBUklDQSBEViBUTFMgUlNB
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvmwtJxeWVmV1TqMSobWh
|
||||
KsaKFvcBVNNfr2swkNI6l9PbH8e7uXVbpMCfG7VV3N22v65pJZR7Faxlfafs+hxt
|
||||
qJNS6SunkXsc2368zlVD8ckRJl+ktVz2Ps+q/VsCvG+hcgUxShlM6BpqJ6aJqH3a
|
||||
zLyP7b98/r+wnrmktQLLATJ9keWFC/tngso76KNctRR92br+cv0niYoacw2+cuWr
|
||||
Y43J60DcE2fN07hrt4NA2V6I4d2GNWVJHteNSwMsC5GY/opmoN4bhObbAIEne3oZ
|
||||
SKP/4pzC9aA9pIaD/JYA5jiD2R85neoYVXeR3PzWqHe+3I62JFbDdLgyjgzVu0lE
|
||||
RkzmIg3UioLBgT7aRxkEQTiiIgdXYHi9CQ00Y9idgBKE0E5GmuJw0JegWk/kozdF
|
||||
acjUPmTdq7AA1wbrTWOAKJr541B1ObOUe+FG9G1dDwcpSx6OIqNjq04ifoKlSPcN
|
||||
RoFakaSW2lEeOg/1SCieLtDniftIOnzz0+kGIVU4TTTHrWwPGZs3a96u/JlZb87D
|
||||
xZGbyxfRgIonWw/WVjBwas4GIGY8oECS43iJh4JaZaqdt4LdE+cwvpZWaKi8N+o1
|
||||
qovjMLpcnAUZ/cWaH2t266z+ATCGMdEtorhf0fQxE/dUYgJEBmw2r9olJJRkXVDU
|
||||
t/1shKYedGuayZucrhiCL6sCAwEAAaOCAWcwggFjMBIGA1UdEwEB/wQIMAYBAf8C
|
||||
AQAwHwYDVR0jBBgwFoAUCkgjpmCkkgoz6pNbxVfqJU29Eu4wVAYIKwYBBQUHAQEE
|
||||
SDBGMEQGCCsGAQUFBzAChjhodHRwOi8vcmVwby5oYXJpY2EuZ3IvY2VydHMvSEFS
|
||||
SUNBLVRMUy1Sb290LTIwMjEtUlNBLmNlcjBEBgNVHSAEPTA7MDkGBFUdIAAwMTAv
|
||||
BggrBgEFBQcCARYjaHR0cDovL3JlcG8uaGFyaWNhLmdyL2RvY3VtZW50cy9DUFMw
|
||||
HQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMEIGA1UdHwQ7MDkwN6A1oDOG
|
||||
MWh0dHA6Ly9jcmwuaGFyaWNhLmdyL0hBUklDQS1UTFMtUm9vdC0yMDIxLVJTQS5j
|
||||
cmwwHQYDVR0OBBYEFAqIq7yL8PVK7GFEUEBEwYdm3t5RMA4GA1UdDwEB/wQEAwIB
|
||||
hjANBgkqhkiG9w0BAQsFAAOCAgEAiFIdKz8OS9mWAcAsENHqY0S1qTG+KD8IX6yU
|
||||
0Ycn8thjErylOClJtq/DAmPGUylA0b2ZctLrIPRWBDqXI0f6VMPDBk7kCBwX8F/T
|
||||
l6JSBQ+sGpdAx5XQxIurJE3cKbduhetf5mpIGTsIDh2V094bJlxiufi0mmB1M/QE
|
||||
sohaEXfmQn6nQQtiVZOmTtNr2dZ1lyTRsKHjE9McFIeczZNP/sEgYI+SWijNjp8K
|
||||
7DAJOmw+8NfUjf7dq4HqLl2MbJSPEaLfNOENjWT+wygPzAKyKl2lcBgHl3FYR14u
|
||||
qkqMUg25Euq2IbkDPpaiq8i1+3A2w+SPRH5sU+g13dlFyCal47yQIIFbeA+JXSPg
|
||||
i1PK/ORDJuBAszOD3q7PNf64FPAjN1OKIZAGoCBjHaS0aGED5Jviqao81hSlo0A0
|
||||
y5zb9JaJPoSV/wyIc3bgS+niWDhBuBCsbmfH1TfRBTfI8O9mXrCSxSxcJ10fhZVy
|
||||
8SyJc+PeOJcU+VM4emO9mAhV6sv5ioKXdrhUlgLIhbsRJW30EyV5H7Z2MGAGGMSP
|
||||
Rp1k43BrIngThpXwdQmGOsj6KS7PuqkKhMvIZVQ9lDcFnNI0ySk3BPsuTVO7yo92
|
||||
03GT/P4nsjwqpCc6XoCpPb42EqyFRHH5GJ9ca1ePkKxs3lApHnW9E4rmIf5tSOnt
|
||||
jISnqoU=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# GlobalSign GCC R6 AlphaSSL CA 2025 (промежуточный, AIA secure.globalsign.com) —
|
||||
# ri.eias.ru / ri-loader.eias.ru не шлют intermediate. Добавлен 2026-07-02.
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFjTCCA3WgAwIBAgIRAIN9TriekS/nLK07x2kt3CAwDQYJKoZIhvcNAQELBQAw
|
||||
TDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkds
|
||||
b2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMjUwNTIxMDIzNjUyWhcN
|
||||
MjcwNTIxMDAwMDAwWjBVMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2ln
|
||||
biBudi1zYTErMCkGA1UEAxMiR2xvYmFsU2lnbiBHQ0MgUjYgQWxwaGFTU0wgQ0Eg
|
||||
MjAyNTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ/oiu0Bviq52UUE
|
||||
ADbFWmgu3rC7KDSMoorLN1Wd03McG3Z1aP71DlPCE33838r72Dfuj5M9LXfiQLJp
|
||||
Au6MwNExmKOzothw4x0zGf5oBYyrCMGm3fBpLPafwYQ3MchBOWMTbf83rKUPLH48
|
||||
KCJ0MnU8GUl8oA/J81wIvbbKPuNrFf6hvJDccjzc4NyxLz3A89zjV2g5whCg5O0u
|
||||
9YX4Zxk9JHuc/LvllOJO4waAYLjbWBJkz3rV3ts1SmSYnJqmyRTIjXwQgRvhEYqt
|
||||
DbRskt0W7M6cPwCze3GTBN2UHNpHkMs3YmVxku68I0aOQn5+uz//fDROP3z1Z/7I
|
||||
APteRtECAwEAAaOCAV8wggFbMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr
|
||||
BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU
|
||||
xbSTj28r3B5Iv7cQMIXO0bK7SC0wHwYDVR0jBBgwFoAUrmwFo5MT4qLn4tcc1sfw
|
||||
f8hnU6AwewYIKwYBBQUHAQEEbzBtMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcDIu
|
||||
Z2xvYmFsc2lnbi5jb20vcm9vdHI2MDsGCCsGAQUFBzAChi9odHRwOi8vc2VjdXJl
|
||||
Lmdsb2JhbHNpZ24uY29tL2NhY2VydC9yb290LXI2LmNydDA2BgNVHR8ELzAtMCug
|
||||
KaAnhiVodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL3Jvb3QtcjYuY3JsMCEGA1Ud
|
||||
IAQaMBgwCAYGZ4EMAQIBMAwGCisGAQQBoDIKAQMwDQYJKoZIhvcNAQELBQADggIB
|
||||
AB/uvBuZf4CiuSahwiXn4geF52roAH+6jxsEPTXTfb7bbeMDXsYgRRsOTNA70ruZ
|
||||
Tnz5DfFMuBhNoFhIFb0qR1izdy6VkdKOqFPNF2dOFI1EcnY9l2ory9mrzHqVbrL4
|
||||
vzUd17FLUVyjTVU7PAv4nxyhnO1GTeT83YlrdRF31NyR6bvZVTEERHmpbWSgeveJ
|
||||
LRtaMzlGWiLZ8IwkH7o6GH3jp/KPtDW4Npu8w64HrRZdN2pqQhi7+YKwfHM7H+2U
|
||||
dM1BGN0sjOWMVbMSB9MtCsleS2Mb7TRZEbOHxECJLLIluQypZr7Pol3+hAqrhyKI
|
||||
k+6y+Da0NeDuWxW59Ku4NvClqW1UFX1SpfNGhzVfp/CH+vPM1tySomx2jE0EnYZu
|
||||
GwVucXPBsp5nUWqUV9+143glVuS7GTg9hFPjNBInn17HbCoIIQIOzj5Vd9bK3A9U
|
||||
GxXNpwenDHEalCsD/4eQYDHPhFE7sNe0D/OXu+FAM02VZkARx37Jp4bDdujvgL9P
|
||||
vZPR3wThvDN1CTU8Bc3xea3yKFAraKcPZLkhReQUAm2VpR+HSJRPlUpYizlF9WkL
|
||||
h3KcAVCBJWvnOkVwxyU5QJMcnwW95JlOtx+9100GL99jHE5rs3gXp7F4bg8H01QT
|
||||
9jVOhBBmQ7nQoXuwI0tqal2QUqZz3eeu62CU7xBwtfYR
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -1,72 +1,8 @@
|
|||
from typing import Annotated, Any, Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BuildingProgramItem(BaseModel):
|
||||
"""Stage 3a (#1965, эпик #1953) — один пункт программы застройки (типовой дом × N).
|
||||
|
||||
Пользователь набирает программу из ТИПОВЫХ домов каталога
|
||||
(``app.services.generative.catalog.HOUSE_TYPES``) вместо max-FAR жадной раскладки:
|
||||
«поставь ``count`` секций типа ``section_type`` этажностью ``floors``». Габариты
|
||||
пятна секции берутся из каталога по ключу ``section_type`` (контракт несёт только
|
||||
ключ, не геометрию — единый справочник на бэке).
|
||||
"""
|
||||
|
||||
# Ключ типа дома из каталога (``HouseType.section_type``). Валидируется на слое
|
||||
# размещения по ``catalog.available_section_types`` — неизвестный ключ → 422.
|
||||
section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)")
|
||||
# Этажность этой группы секций; диапазон шире, чем у target_floors (до 40 — башни).
|
||||
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group")
|
||||
# Сколько секций этого типа разместить.
|
||||
count: int = Field(..., ge=1, le=50, description="Number of sections to place")
|
||||
# ADDITIVE (Stage 3c): опциональное ручное пятно секции (ширина × глубина, м).
|
||||
# None → габариты берутся из каталога по ``section_type`` (byte-for-byte
|
||||
# backward-compat). Заданы → place_program строит footprint из них вместо
|
||||
# каталожных (пользователь «вписал пятно»). Оба должны быть заданы вместе;
|
||||
# частичное задание (только w / только d) трактуется как «не задано» на слое
|
||||
# размещения. Границы [4, 120] м — вменяемый габарит секции/корпуса.
|
||||
footprint_w_m: float | None = Field(
|
||||
None, ge=4, le=120, description="Optional manual section footprint width, m"
|
||||
)
|
||||
footprint_d_m: float | None = Field(
|
||||
None, ge=4, le=120, description="Optional manual section footprint depth, m"
|
||||
)
|
||||
|
||||
|
||||
class HouseTypeCatalogItem(BaseModel):
|
||||
"""Stage 3b (#1965, эпик #1953) — один тип дома каталога, ОТДАВАЕМЫЙ фронту.
|
||||
|
||||
Read-only проекция ``app.services.generative.catalog.HouseType`` для пикера
|
||||
Stage 3b: фронт показывает ``label_ru`` + габариты пятна + дефолтную этажность
|
||||
+ класс, а в ``BuildingProgramItem.section_type`` кладёт ``section_type``. Это
|
||||
делает каталог ЕДИНЫМ источником истины — фронт ничего не хардкодит. Поля
|
||||
зеркалят dataclass на бэке (см. ``GET /api/v1/concepts/house-types``).
|
||||
"""
|
||||
|
||||
# Стабильный машинный ключ типа — значение для BuildingProgramItem.section_type.
|
||||
section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)")
|
||||
# Человекочитаемый русский лейбл для UI.
|
||||
label_ru: str = Field(..., description="Human-readable Russian label")
|
||||
# Габариты пятна секции, метры (ширина × глубина).
|
||||
footprint_w_m: float = Field(..., description="Section footprint width, m")
|
||||
footprint_d_m: float = Field(..., description="Section footprint depth, m")
|
||||
# Площадь пятна секции, кв.м (helper для UI: ширина × глубина).
|
||||
footprint_sqm: float = Field(..., description="Section footprint area, m² (w × d)")
|
||||
# Дефолтная этажность типа — UI подставляет, пользователь меняет в [1, 40].
|
||||
default_floors: int = Field(..., description="Default floors for this type")
|
||||
# Подходящий класс жилья (econom / comfort / business).
|
||||
housing_class: Literal["econom", "comfort", "business"] = Field(
|
||||
..., description="Suitable housing class"
|
||||
)
|
||||
|
||||
|
||||
class HouseTypeCatalog(BaseModel):
|
||||
"""Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``."""
|
||||
|
||||
house_types: list[HouseTypeCatalogItem]
|
||||
|
||||
|
||||
class ConceptInput(BaseModel):
|
||||
"""Stage 1a — input contract. Frozen interface for frontend codegen."""
|
||||
|
||||
|
|
@ -79,15 +15,6 @@ class ConceptInput(BaseModel):
|
|||
land_cost_rub: float | None = Field(
|
||||
None, ge=0, description="Optional land cost for financial model"
|
||||
)
|
||||
# Stage 3a (#1965): ОПЦИОНАЛЬНАЯ программа застройки из типовых домов каталога.
|
||||
# ADDITIVE поле (default None). None → существующая жадная раскладка (Stage 1b,
|
||||
# byte-for-byte backward-compat). Задано → раскладка кладёт РОВНО эту программу
|
||||
# (place_program): для каждого пункта ставит count секций каталожного пятна с его
|
||||
# floors, вместо coverage-cap sweep. См. app.services.generative.placement.
|
||||
building_program: list[BuildingProgramItem] | None = Field(
|
||||
None,
|
||||
description="Optional typed house program; None → greedy max-FAR placement",
|
||||
)
|
||||
|
||||
|
||||
class TEAP(BaseModel):
|
||||
|
|
@ -220,71 +147,7 @@ class ConceptVariant(BaseModel):
|
|||
buildings_geojson: dict[str, Any]
|
||||
teap: TEAP
|
||||
financial: FinancialModel
|
||||
# Stage 3a (#1965) — честный сигнал частичного размещения для program-режима.
|
||||
# Когда задан ``ConceptInput.building_program``: ``requested_count`` — сколько секций
|
||||
# просили (Σ count по программе), ``placed_count`` — сколько реально влезло в участок.
|
||||
# placed < requested → участок мал, разместилось N из M (фронт Stage 3b показывает
|
||||
# «разместилось N из M», без hard-422). Оба None в greedy-режиме (программа не задана)
|
||||
# → ADDITIVE, backward-compat: старый ответ концепции не меняется. Annotated+`= None`:
|
||||
# рантайм-дефолт виден mypy (без pydantic-плагина), Field несёт только OpenAPI-описание.
|
||||
placed_count: Annotated[
|
||||
int | None, Field(description="Stage 3a: sections actually placed (program mode only)")
|
||||
] = None
|
||||
requested_count: Annotated[
|
||||
int | None, Field(description="Stage 3a: sections requested by program (program mode only)")
|
||||
] = None
|
||||
|
||||
|
||||
class ConceptOutput(BaseModel):
|
||||
variants: list[ConceptVariant]
|
||||
|
||||
|
||||
class MassingProgram(BaseModel):
|
||||
"""Stage 2a (#1965) — агрегированная массинг-программа для LIVE-пересчёта экономики.
|
||||
|
||||
Контракт POST ``/api/v1/concepts/recompute``: вход — уже СВЁРНУТАЯ программа из
|
||||
фронтового ``computeModel`` интерактивного 3D-массинга (суммарное пятно × этажность),
|
||||
без покомпонентной геометрии секций. Эндпоинт синтезирует из неё :class:`TEAP` и
|
||||
прогоняет готовый ``compute_financial`` → пересчитанная экономика для дебаунс-слайдеров
|
||||
Stage 2b.
|
||||
|
||||
Класс жилья / тип застройки — те же Literal-множества, что у :class:`ConceptInput`
|
||||
и ``compute_financial`` (single source of truth по допустимым значениям).
|
||||
"""
|
||||
|
||||
total_footprint_sqm: float = Field(
|
||||
..., ge=0, description="Суммарное пятно застройки всех секций, кв.м (скаляр)"
|
||||
)
|
||||
floors: int = Field(..., ge=1, le=40, description="Этажность программы")
|
||||
sections: int = Field(1, ge=1, le=6, description="Число секций (метаданные программы)")
|
||||
site_area_sqm: float = Field(..., ge=0, description="Площадь участка для плотности (FAR)")
|
||||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
||||
land_cost_rub: float | None = Field(
|
||||
None, ge=0, description="Стоимость участка для финмодели (опционально)"
|
||||
)
|
||||
# Предрезолвленная рыночная цена жилья, руб/кв.м — позволяет hot-эндпоинту ПРОПУСТИТЬ
|
||||
# per-keystroke DB-lookup (фронт резолвит цену один раз и шлёт её в каждом запросе).
|
||||
# None → эндпоинт сам сделает _lookup_market_price по центроиду (медленный путь).
|
||||
market_price_per_sqm: float | None = Field(
|
||||
None, ge=0, description="Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)"
|
||||
)
|
||||
# ПОДЛИННЫЙ источник предрезолвленной цены, который фронт получил из financial_estimate
|
||||
# (objective_geo_radius / objective_district_median / district_reference / class_norm).
|
||||
# Honest-флаг для UI/PDF: НЕ подменяем его захардкоженным лейблом. Применяется ТОЛЬКО
|
||||
# вместе с market_price_per_sqm; None → дефолтный лейбл (см. эндпоинт recompute_massing).
|
||||
price_source: str | None = Field(
|
||||
None, description="Подлинный источник предрезолвленной цены (forward из financial_estimate)"
|
||||
)
|
||||
# Центроид участка (WKT-точка WGS84) для DB-fallback цены, когда market_price_per_sqm
|
||||
# не передан. None → пропускаем lookup и берём норматив класса (нет геопривязки).
|
||||
parcel_centroid_wkt: str | None = Field(
|
||||
None, description="WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback"
|
||||
)
|
||||
|
||||
|
||||
class MassingRecomputeOutput(BaseModel):
|
||||
"""Stage 2a (#1965) — результат LIVE-пересчёта: синтезированный ТЭП + финмодель."""
|
||||
|
||||
teap: TEAP
|
||||
financial: FinancialModel
|
||||
|
|
|
|||
|
|
@ -36,16 +36,9 @@ class EngineeringStructure(BaseModel):
|
|||
name: str | None
|
||||
type: str | None
|
||||
cad_num: str | None
|
||||
# Назначение сооружения из НСПД-дампа (params_purpose / object_type_value),
|
||||
# напр. «1.1. Сооружения электроэнергетики». Заполняется ТОЛЬКО когда отличается
|
||||
# от `type` (иначе дубль-шум). ADDITIVE (default None).
|
||||
purpose: str | None = None
|
||||
distance_to_boundary_m: float
|
||||
geometry_geojson: dict[str, Any]
|
||||
readable_address: str | None
|
||||
# Доп. характеристики из НСПД-дампа (год постройки / длина / собственность),
|
||||
# собранные в одну читаемую строку. None — данных нет. ADDITIVE (default None).
|
||||
characteristics: str | None = None
|
||||
raw_props: dict[str, Any]
|
||||
source: str
|
||||
|
||||
|
|
@ -65,11 +58,6 @@ class ConnectionPointsSummary(BaseModel):
|
|||
in_protection_zone: bool
|
||||
protection_zones_intersecting: int
|
||||
total_structures_in_radius: int
|
||||
# Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
|
||||
# LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
|
||||
# ADDITIVE (default False) — старые клиенты не ломаются.
|
||||
protection_zones_truncated: bool = False
|
||||
structures_truncated: bool = False
|
||||
|
||||
|
||||
class ConnectionPointsResponse(BaseModel):
|
||||
|
|
@ -94,10 +82,6 @@ class UtilityInfrastructureFeature(BaseModel):
|
|||
source_tag: str | None
|
||||
distance_m: float
|
||||
geometry_geojson: dict[str, Any]
|
||||
# Гуманизированные характеристики сети из OSM tags (напряжение / оператор /
|
||||
# диаметр / материал), собранные в одну строку. None — данных нет.
|
||||
# ADDITIVE (default None) → не ломает существующих потребителей.
|
||||
characteristics: str | None = None
|
||||
|
||||
|
||||
class UtilityInfrastructureSummary(BaseModel):
|
||||
|
|
@ -105,10 +89,6 @@ class UtilityInfrastructureSummary(BaseModel):
|
|||
nearest_distance_m: float | None
|
||||
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
|
||||
nearest_by_kind: dict[str, float | None]
|
||||
# Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
|
||||
# ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
|
||||
# ADDITIVE (default False) — старые клиенты не ломаются.
|
||||
features_truncated: bool = False
|
||||
|
||||
|
||||
class UtilityInfrastructureResponse(BaseModel):
|
||||
|
|
@ -116,190 +96,6 @@ class UtilityInfrastructureResponse(BaseModel):
|
|||
summary: UtilityInfrastructureSummary
|
||||
|
||||
|
||||
# ── Connection-capacity schemas (Forgejo #2119 Phase A) ──────────────────────
|
||||
# «Точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3: центры питания
|
||||
# (power_supply_centers) с резервом + city-level резервы воды (water_supply_reserves).
|
||||
|
||||
|
||||
class PowerConnectionPoint(BaseModel):
|
||||
"""Центр питания (ЦП, ПС 35/110 кВ) вблизи участка со свободной мощностью."""
|
||||
|
||||
name: str
|
||||
dzo_name: str | None
|
||||
voltage_class: str | None # напр. '110/35/10'
|
||||
load_index: str | None # 'open'|'limited'|'closed'|None
|
||||
installed_capacity_mva: float | None
|
||||
current_load_mva: float | None
|
||||
reserve_mva: float | None # свободная мощность, МВА
|
||||
reserve_asof: str | None # ISO-дата актуальности резерва
|
||||
distance_m: float
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
# Пометка для ПС ЕЭСК без опубликованного резерва: свободная мощность у АО «ЕЭСК»
|
||||
# (городская сеть ЕКБ) не раскрывается WFS/xlsx — берётся по запросу. #2119 B1.
|
||||
reserve_note: str | None = None
|
||||
# Единица reserve_mva/installed_capacity_mva: 'МВА' (Россети) или 'МВт'
|
||||
# (ЕЭСК ПС 35-220). district — адм. район ЕКБ (из xlsx ЕЭСК; NULL у Россети).
|
||||
# ADDITIVE (default None). #2119 Phase B2.
|
||||
reserve_unit: str | None = None
|
||||
district: str | None = None
|
||||
|
||||
|
||||
class PowerCapacitySummary(BaseModel):
|
||||
"""Сводка по электрике: ближайший ЦП с резервом + count по индексу загрузки."""
|
||||
|
||||
total_power_points: int
|
||||
# Карта индекс загрузки ('open'|'limited'|'closed'|'unknown') → count.
|
||||
by_load_index: dict[str, int]
|
||||
# Ближайший ЦП с положительным резервом (или null, если резерв нигде не задан).
|
||||
nearest_with_reserve: PowerConnectionPoint | None
|
||||
|
||||
|
||||
class WaterCapacityRow(BaseModel):
|
||||
"""Строка резерва ЦСВ/ЦСК Водоканала (city-level) за последний период."""
|
||||
|
||||
system_kind: str # 'water'|'sewerage'
|
||||
system_name: str # ЦСВ/ЦСК, напр. «ЗФС, ГСВ»
|
||||
reserve_thousand_m3_day: float | None # может быть отрицательным (дефицит)
|
||||
note: str | None
|
||||
period: str | None
|
||||
|
||||
|
||||
class GasGrsRow(BaseModel):
|
||||
"""Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1."""
|
||||
|
||||
grs_name: str
|
||||
design_capacity_th_m3_h: float | None # проектная (сумма по выходам), тыс. м³/ч
|
||||
free_capacity_th_m3_h: float | None # свободная (сумма по выходам), тыс. м³/ч
|
||||
free_capacity_pct: float | None # средняя свободная, %
|
||||
upgrade_due: str | None # срок расшивки (макс. по выходам, свободный текст)
|
||||
outputs_count: int # число строк-выходов ГРС, вошедших в агрегацию
|
||||
|
||||
|
||||
class GasCapacityBlock(BaseModel):
|
||||
"""Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.
|
||||
|
||||
+ счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС №960 АО
|
||||
«Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4.
|
||||
"""
|
||||
|
||||
# Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если
|
||||
# таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD).
|
||||
city_grs: list[GasGrsRow]
|
||||
# Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет).
|
||||
total_free_th_m3_h: float | None
|
||||
# Счётчики точек выхода последнего period_month (по ВСЕМ строкам среза, city-level —
|
||||
# см. _query_gas_outlet_counts). ADDITIVE (default 0): 0, если таблица
|
||||
# gas_grs_outlet_points ещё не наполнена (миграция 184 / monthly-sync). #2119 B2 PR-4.
|
||||
# ВНИМАНИЕ: свободная мощность точек выхода — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!).
|
||||
outlets_total: int = 0 # всего точек выхода в последнем срезе
|
||||
outlets_deficit: int = 0 # из них с дефицитом (free_capacity_mln_m3 < 0)
|
||||
outlets_needs_calc: int = 0 # из них «0.000*» — нужен гидравлический расчёт
|
||||
|
||||
|
||||
class GasOutletPoint(BaseModel):
|
||||
"""Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4."""
|
||||
|
||||
outlet_name: str # «Точка выхода» — юрлицо/объект/промплощадка (полный текст)
|
||||
consumer_type: str | None # котельная / ТЭЦ / площадка …
|
||||
# Свободная мощность точки выхода, млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Может быть
|
||||
# <0 (дефицит со знаком). NULL, когда в источнике «0.000*» (needs_calc=True).
|
||||
free_capacity_mln_m3: float | None
|
||||
needs_calc: bool # «0.000*» — свободная мощность не определена, нужен гидрорасчёт
|
||||
distance_m: float # расстояние от центроида участка, м
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
|
||||
|
||||
class HeatSystemRow(BaseModel):
|
||||
"""Резерв тепловой мощности одной системы теплоснабжения. #2119 Phase B2."""
|
||||
|
||||
org: str # организация-держатель (ЕТК / Екатеринбургэнерго)
|
||||
system_name: str # имя системы теплоснабжения (напр. «СТ №54»)
|
||||
reserve_gcal_h: float | None # свободная мощность, Гкал/ч (может быть <0 — дефицит)
|
||||
period: str | None # напр. '2026-Q2'
|
||||
|
||||
|
||||
class HeatCapacityBlock(BaseModel):
|
||||
"""Тепловой блок: резервы систем теплоснабжения за последний период. #2119 B2."""
|
||||
|
||||
# Строки heat_system_reserves за MAX(period) ПО КАЖДОЙ организации. Пусто, если
|
||||
# таблица heat_system_reserves ещё не наполнена (weekly-sync, RUN-ON-PROD).
|
||||
systems: list[HeatSystemRow]
|
||||
# Суммарная свободная тепловая мощность, Гкал/ч (None если данных нет).
|
||||
total_reserve_gcal_h: float | None
|
||||
|
||||
|
||||
class NearbyNetworkZone(BaseModel):
|
||||
"""Сетевая охранная зона cad_zouit рядом с участком — позитив-сигнал §3. #2119 C2/C3.
|
||||
|
||||
Охранная зона сети = коридор, где физически проходит сеть → «теплотрасса в 24 м,
|
||||
врезка дешёвая». Зона, ПЕРЕСЕКАЮЩАЯ участок, отдаётся с distance_m=0.
|
||||
"""
|
||||
|
||||
# Вид сети: thermal/electricity/gas/water/communication/pipeline/general.
|
||||
# general дочищен по name_by_doc (инж.коммуникации на проде часто реально тепло/газ).
|
||||
net_kind: str
|
||||
distance_m: int # расстояние от участка до охранной зоны, м (0 = пересекает участок)
|
||||
name_by_doc: str | None # сырое детальное имя из cad_zouit (для тултипа/дебага)
|
||||
# Гуманизированная подпись (C3): «Тепловая сеть (по охранной зоне), кад. № …» /
|
||||
# «Ввод 10 кВ от ТП 3164 (по охранной зоне)» / «Инженерные коммуникации (вид не уточнён)».
|
||||
human_label: str
|
||||
|
||||
|
||||
class ConnectionCapacityResponse(BaseModel):
|
||||
power_points: list[PowerConnectionPoint]
|
||||
power_summary: PowerCapacitySummary
|
||||
water: list[WaterCapacityRow]
|
||||
# Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран.
|
||||
gas: GasCapacityBlock | None = None
|
||||
# Гео-точки выхода газосети в радиусе от участка (слой карты §3). ADDITIVE (Phase B2
|
||||
# PR-4): [] если таблица gas_grs_outlet_points пуста / нет geom в радиусе.
|
||||
gas_outlet_points: list[GasOutletPoint] = []
|
||||
# Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран.
|
||||
heat: HeatCapacityBlock | None = None
|
||||
# Позитив-разрез (Phase C2/C3): сетевые охранные зоны cad_zouit в радиусе 300 м —
|
||||
# до 2 ближайших на net_kind, гуманизированы. ADDITIVE (default []): [] если сеть
|
||||
# рядом не найдена / нет geom участка.
|
||||
nearby_network_zones: list[NearbyNetworkZone] = []
|
||||
|
||||
|
||||
# ── Full PDF report (эпик #2259 PR-D) ────────────────────────────────────────
|
||||
|
||||
|
||||
class ReportBuildResponse(BaseModel):
|
||||
"""Ответ POST /{cad}/report — enqueue сборки полного PDF-отчёта (#2259 PR-D).
|
||||
|
||||
status="building" (202) — таска поставлена в очередь, PDF собирается в фоне;
|
||||
status="ready" (200) — готовый кэш (тот же analyze+forecast ран) уже на диске,
|
||||
качать сразу через /report/download.
|
||||
"""
|
||||
|
||||
status: Literal["building", "ready"]
|
||||
# Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт.
|
||||
analyze_run_at: str | None = None
|
||||
forecast_run_at: str | None = None
|
||||
# Дата генерации готового PDF (ISO) — только при status="ready" (кэш-хит); на
|
||||
# building-ветке None (файла ещё нет). Даёт fast-path кнопке дату без GET /status.
|
||||
report_generated_at: str | None = None
|
||||
|
||||
|
||||
class ReportStatusResponse(BaseModel):
|
||||
"""Ответ GET /{cad}/report/status — состояние полного PDF-отчёта (#2259 PR-D).
|
||||
|
||||
status: "ready" (метадата-ран report-pdf-1.0 есть И файл на диске),
|
||||
"building" (есть analyze-ран, но готового отчёта нет — вероятно собирается / ещё не
|
||||
запускался), "none" (нет даже analyze-рана — отчётировать нечего).
|
||||
"""
|
||||
|
||||
status: Literal["ready", "building", "none"]
|
||||
# Даты базовых ранов (ISO) — по каким данным собран/будет собран отчёт.
|
||||
analyze_run_at: str | None = None
|
||||
forecast_run_at: str | None = None
|
||||
# Дата генерации готового PDF (ISO) — только при status="ready".
|
||||
report_generated_at: str | None = None
|
||||
|
||||
|
||||
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -604,25 +400,6 @@ class TopLayoutRow(BaseModel):
|
|||
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
|
||||
|
||||
|
||||
class SupplyOnlyLayoutRow(BaseModel):
|
||||
"""Строка блока «по предложению» (#2177 шаг 3).
|
||||
|
||||
Структура текущего предложения (комнатность × площадь × цена) для комплексов,
|
||||
которые сматчены на objective project_name по ЯДРУ имени, но у которых НЕТ
|
||||
сделок (velocity). Источник — objective_lots (снапшот предложения). Это НЕ
|
||||
velocity-ранжир: темпа продаж здесь нет, только структура остатков в продаже.
|
||||
"""
|
||||
|
||||
project_name: str
|
||||
room_bucket: str # {studio,1,2,3,4+} — вокабуляр velocity-стороны
|
||||
area_bin: str
|
||||
lots_count: int
|
||||
median_price_m2: float | None # NULL если цены не прошли price-sanity
|
||||
median_area_m2: float | None
|
||||
# Маркер источника: всегда 'objective_lots' — честная семантика «без темпа продаж».
|
||||
source: str = "objective_lots"
|
||||
|
||||
|
||||
class LayoutTzMixRow(BaseModel):
|
||||
"""Строка рекомендации unit-mix для ТЗ."""
|
||||
|
||||
|
|
@ -648,28 +425,10 @@ class LayoutTzRecommendation(BaseModel):
|
|||
|
||||
|
||||
class LayoutDataQuality(BaseModel):
|
||||
"""Метаданные качества данных (coverage).
|
||||
"""Метаданные качества данных (coverage)."""
|
||||
|
||||
Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
|
||||
domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди /
|
||||
дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой
|
||||
знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных
|
||||
комплексов → «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь
|
||||
считают комплексы; raw_objects_total — старый счёт obj_id для прозрачности.
|
||||
"""
|
||||
|
||||
objects_with_velocity_data: int # число комплексов с velocity-данными
|
||||
objects_total_in_radius: int # число комплексов (сгруппированных obj_id)
|
||||
raw_objects_total: int # сырое число obj_id в радиусе (до группировки)
|
||||
# Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
|
||||
# нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
|
||||
# прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в
|
||||
# радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping.
|
||||
groups_matched_by_core: int = 0
|
||||
# Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» —
|
||||
# сматчены по ядру на objective project_name, БЕЗ velocity, но с лотами в
|
||||
# продаже (objective_lots). Аддитивно, не пересекается с velocity-покрытием.
|
||||
groups_with_supply_only: int = 0
|
||||
objects_with_velocity_data: int
|
||||
objects_total_in_radius: int
|
||||
velocity_coverage_pct: float
|
||||
confidence: Literal["high", "medium", "low"]
|
||||
|
||||
|
|
@ -680,10 +439,6 @@ class BestLayoutsResponse(BaseModel):
|
|||
top_layouts: list[TopLayoutRow]
|
||||
recommendation_for_tz: LayoutTzRecommendation
|
||||
data_quality: LayoutDataQuality
|
||||
# Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж» —
|
||||
# структура текущего предложения для сматченных по ядру комплексов БЕЗ velocity.
|
||||
# Аддитивно; НЕ смешивается с top_layouts (там velocity-ранжир). Пусто по умолчанию.
|
||||
supply_only_layouts: list[SupplyOnlyLayoutRow] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Analyze endpoint market price (#33) ──────────────────────────────────────
|
||||
|
|
@ -868,11 +623,6 @@ class AnalyzeResponse(BaseModel):
|
|||
score_label: str | None = None
|
||||
score_max_reference: float | None = None
|
||||
score_explanation: str | None = None
|
||||
# #1926: машиночитаемый advisory-флаг — score это POI-density-эвристика
|
||||
# (0..score_max_reference), а не абсолютный инвест-рейтинг. Optional для
|
||||
# обратной совместимости (старые моки/202-стаб → null).
|
||||
score_is_advisory: bool | None = None
|
||||
score_advisory: dict[str, Any] | None = None
|
||||
score_breakdown: dict[str, Any] | None = None
|
||||
score_breakdown_detailed: list[dict[str, Any]] | None = None
|
||||
score_top_3_positives: list[dict[str, Any]] | None = None
|
||||
|
|
@ -888,9 +638,6 @@ class AnalyzeResponse(BaseModel):
|
|||
market_data_coverage_pct: float | None = None
|
||||
pipeline_24mo: dict[str, Any] | None = None
|
||||
velocity: dict[str, Any] | None = None
|
||||
# market_trend: {status: ok|offer_only|source_stale|no_deals, ...}. #2178 добавил
|
||||
# вложенный offer_trend (динамика цен предложения из objective_lots_history) и
|
||||
# status='offer_only'. Схема loose (dict) — новые ключи проходят без правки модели.
|
||||
market_trend: dict[str, Any] | None = None
|
||||
market_price: dict[str, Any] | None = None
|
||||
|
||||
|
|
@ -913,7 +660,6 @@ class AnalyzeResponse(BaseModel):
|
|||
parcel_meta: dict[str, Any] | None = None
|
||||
recent_permits_in_quarter: list[dict[str, Any]] | None = None
|
||||
permits_summary: dict[str, Any] | None = None
|
||||
permits_nearby: dict[str, Any] | None = None
|
||||
zoning: dict[str, Any] | None = None
|
||||
success_recommendation: dict[str, Any] | None = None
|
||||
isochrones_available: bool | None = None
|
||||
|
|
|
|||
|
|
@ -504,15 +504,6 @@ def developer_history(
|
|||
|
||||
|
||||
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
||||
# #2464 cluster E: без дедупа каждый ЖК возвращался ×N раз (одна строка на
|
||||
# snapshot_date, ретенции нет) — портфель девелопера раздувался в разы (прод-замер
|
||||
# ~7.6-9.0× per-developer). DISTINCT ON (obj_id) + snapshot_date DESC NULLS LAST —
|
||||
# latest-snapshot-per-obj_id (тот же паттерн, что cmp_rows/latest_obj CTE в
|
||||
# recommend_mix ниже в этом файле). dev_id — стабильный идентификатор
|
||||
# (не меняется между снапшотами одного ЖК) → фильтруется ДО DISTINCT ON, как
|
||||
# region_cd/district_name в сиблингах. Итоговый ORDER BY ready_dt вынесен во внешний
|
||||
# SELECT: DISTINCT ON требует, чтобы его собственный ORDER BY начинался с ключа
|
||||
# дедупа (obj_id, snapshot_date), не с ready_dt.
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -520,15 +511,8 @@ def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
|||
SELECT obj_id, comm_name, addr, region_cd, flat_count,
|
||||
square_living, ready_dt, obj_class, escrow,
|
||||
problem_flag, latitude, longitude, is_ekb
|
||||
FROM (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, comm_name, addr, region_cd, flat_count,
|
||||
square_living, ready_dt, obj_class, escrow,
|
||||
problem_flag, latitude, longitude, is_ekb
|
||||
FROM domrf_kn_objects
|
||||
WHERE dev_id = :dev
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
) latest
|
||||
FROM domrf_kn_objects
|
||||
WHERE dev_id = :dev
|
||||
ORDER BY ready_dt DESC NULLS LAST
|
||||
"""
|
||||
),
|
||||
|
|
@ -1813,38 +1797,12 @@ def _active_competitors_count(
|
|||
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
|
||||
|
||||
def _q(where_extras: str, params: dict[str, Any]) -> int:
|
||||
# #2464 cluster E: domrf_kn_objects хранит МНОЖЕСТВО snapshot_date на obj_id
|
||||
# (UNIQUE(obj_id, snapshot_date), ретенции нет) — прод-замер 4090 строк / 482
|
||||
# distinct obj_id для site_status='Строящиеся' (8.49× инфляция). Наивный
|
||||
# COUNT(*) считал каждый исторический снапшот отдельно. Fix: DISTINCT ON
|
||||
# (obj_id) + snapshot_date DESC NULLS LAST даёт latest-snapshot-per-obj_id
|
||||
# (сиблинг-паттерн: _L3_FUTURE_SQL #1212 в site_finder/supply_layers.py).
|
||||
#
|
||||
# ВСЕ волатильные предикаты применяются ПОСЛЕ DISTINCT ON (во внешнем WHERE),
|
||||
# а не внутри CTE — иначе DISTINCT ON вернул бы «последний снапшот, ПРОШЕДШИЙ
|
||||
# фильтр», а не истинно последний снапшот объекта. Прод-замер per-obj_id
|
||||
# variance across snapshots (authoritative, coordinator 2026-07-08):
|
||||
# dev_id=0, region_cd=0 (СТАБИЛЬНЫ) · district_name=1, obj_class=29,
|
||||
# site_status=11 (ВОЛАТИЛЬНЫ).
|
||||
# → В CTE остаётся ТОЛЬКО region_cd (стабилен + ограничивает стоимость дедупа
|
||||
# как partition-scope). {where_extras} (district_name/obj_class) и
|
||||
# site_status='Строящиеся' — во внешнем WHERE, на истинно-последней строке.
|
||||
# Пример бага, который это чинит: ЖК сменил класс Комфорт→Бизнес; при
|
||||
# фильтре obj_class внутри CTE взяли бы старый Комфорт-снапшот и посчитали
|
||||
# его активным Комфортом, хотя его актуальный класс уже Бизнес.
|
||||
n = db.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, site_status, district_name,
|
||||
obj_class, obj_class_fallback
|
||||
FROM domrf_kn_objects
|
||||
WHERE region_cd = :rc
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
)
|
||||
SELECT COUNT(*) FROM latest
|
||||
WHERE site_status = 'Строящиеся'
|
||||
SELECT COUNT(*) FROM domrf_kn_objects
|
||||
WHERE region_cd = :rc
|
||||
AND site_status = 'Строящиеся'
|
||||
{where_extras}
|
||||
"""
|
||||
),
|
||||
|
|
@ -2275,28 +2233,14 @@ def _competitors_two_dim(
|
|||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
WITH latest AS (
|
||||
-- #2464 cluster E: сначала истинно-последний снапшот на obj_id,
|
||||
-- ТОЛЬКО по стабильному region_cd (variance=0). Волатильные
|
||||
-- предикаты (site_status=11, district_name=1, obj_class=29 per
|
||||
-- прод-замер) — НЕ внутри DISTINCT ON, иначе взяли бы «последний
|
||||
-- снапшот, прошедший фильтр», а не истинно последний (ЖК,
|
||||
-- сменивший класс/район/статус, засчитался бы по устаревшему
|
||||
-- снапшоту). Зеркалит _q() в _active_competitors_count выше.
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, latitude, longitude, district_name,
|
||||
site_status, obj_class, obj_class_fallback
|
||||
WITH active AS (
|
||||
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name
|
||||
FROM domrf_kn_objects
|
||||
WHERE region_cd = :rc
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
),
|
||||
active AS (
|
||||
-- Волатильные фильтры на истинно-последней строке.
|
||||
SELECT obj_id, latitude, longitude, district_name
|
||||
FROM latest
|
||||
WHERE site_status = 'Строящиеся'
|
||||
AND site_status = 'Строящиеся'
|
||||
AND district_name = :dn
|
||||
{class_filter}
|
||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
),
|
||||
centroid AS (
|
||||
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION, latest_run_for
|
||||
from app.services.analysis_runs.repository import latest_run_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -28,10 +28,6 @@ logger = logging.getLogger(__name__)
|
|||
# схеме явный и устойчивый.
|
||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||
|
||||
# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
|
||||
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
|
||||
_MAX_ZOUIT_NAMES = 15
|
||||
|
||||
|
||||
def get_report_for_chat(
|
||||
db: Session,
|
||||
|
|
@ -61,116 +57,3 @@ def get_report_for_chat(
|
|||
return None, None
|
||||
|
||||
return result, run.id
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]:
|
||||
"""Секция payload как dict или {} (graceful — как _as_dict в full_report_html)."""
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _put(target: dict[str, Any], key: str, value: Any) -> None:
|
||||
"""Положить ТОЛЬКО скаляр в курируемый срез (§19 data-residency).
|
||||
|
||||
Гейт двойной: и по КЛЮЧУ (вызывающий передаёт фиксированный whitelist), и по ТИПУ
|
||||
значения. Пропускаем лишь str|int|float|bool — dict/list/любая вложенная структура
|
||||
ОТБРАСЫВАЕТСЯ (иначе сырой под-словарь ЕГРН/зонирования с PII мог бы утечь наружу;
|
||||
redaction-слой ловит regex-PII в строках, но НЕ структуры). Пустую строку опускаем.
|
||||
|
||||
UP038: union-форма isinstance (не кортеж) — pre-commit пинит старый ruff.
|
||||
"""
|
||||
if not isinstance(value, str | int | float | bool):
|
||||
return
|
||||
if value == "":
|
||||
return
|
||||
target[key] = value
|
||||
|
||||
|
||||
def _zouit_names(overlaps: list[Any]) -> list[str]:
|
||||
"""Собрать dedup-список названий ЗОУИТ-зон (type_zone|name), обрезанный до лимита.
|
||||
|
||||
§19: ТОЛЬКО строки-названия — никакой геометрии/reg_numb/coverage сырых пересечений.
|
||||
Не-строковые значения (dict/list/число) ОТБРАСЫВАЕМ — не приводим str()'ом, иначе
|
||||
вложенная структура утекла бы как её repr. dict.fromkeys держит порядок и dedup
|
||||
(зеркало _build_zouit в §1-рендере).
|
||||
"""
|
||||
# Фильтруем до str ДО dict.fromkeys: не-str (dict/list) не только небезопасны для
|
||||
# §19, но и unhashable → уронили бы dict.fromkeys TypeError'ом.
|
||||
raw = (ov.get("type_zone") or ov.get("name") for ov in overlaps if isinstance(ov, dict))
|
||||
names = list(dict.fromkeys(n for n in raw if isinstance(n, str) and n))
|
||||
return names[:_MAX_ZOUIT_NAMES]
|
||||
|
||||
|
||||
def get_parcel_context_for_chat(db: Session, cad_num: str) -> dict[str, Any] | None:
|
||||
"""Курируемый паспорт участка + градрегламент для чата (§19 data-residency).
|
||||
|
||||
READ-ONLY: последний analyze-ран (schema_version='analyze-1.0', НЕ §22-форсайт) →
|
||||
ПЛОСКИЙ whitelist-дикт из ФИКСИРОВАННОГО набора публичных градо-полей. Сырой
|
||||
analyze-blob во внешний LLM ЗАПРЕЩЁН (см. safe_payload.py) — поэтому собираем только
|
||||
скаляры и список строк по явным ключам, НИКОГДА не протаскивая под-словари/геометрию.
|
||||
|
||||
Ключи payload — те же, что читает §1-рендер отчёта (full_report_html:_build_parcel_facts
|
||||
/ _build_zoning / _build_zouit): egrn, nspd_zoning, encumbrance, nspd_zouit_overlaps.
|
||||
|
||||
None если analyze-рана/результата нет (graceful, как get_report_for_chat) — вызывающий
|
||||
работает как раньше (только §22-отчёт, без паспорта участка).
|
||||
"""
|
||||
run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
||||
if run is None:
|
||||
return None
|
||||
|
||||
result = run.result
|
||||
if not isinstance(result, dict):
|
||||
logger.warning(
|
||||
"chat: analyze run %s for cad=%s has non-dict result (%s) — no parcel context",
|
||||
run.id,
|
||||
cad_num,
|
||||
type(result).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
egrn = _as_dict(result.get("egrn"))
|
||||
nspd_zoning = _as_dict(result.get("nspd_zoning"))
|
||||
encumbrance = _as_dict(result.get("encumbrance"))
|
||||
overlaps = [ov for ov in (result.get("nspd_zouit_overlaps") or []) if isinstance(ov, dict)]
|
||||
|
||||
context: dict[str, Any] = {}
|
||||
|
||||
# ── Кадастровые факты (ЕГРН) — скаляры, без геометрии ────────────────────────
|
||||
_put(context, "address", egrn.get("address"))
|
||||
_put(context, "area_m2", egrn.get("area_m2"))
|
||||
_put(context, "land_category", egrn.get("land_category"))
|
||||
_put(context, "permitted_use_text", egrn.get("permitted_use_text"))
|
||||
_put(context, "cadastral_value_rub", egrn.get("cadastral_value_rub"))
|
||||
_put(context, "parcel_status", egrn.get("parcel_status"))
|
||||
_put(context, "ownership_type", egrn.get("ownership_type"))
|
||||
|
||||
# ── Территориальная зона ПЗЗ + лимиты регламента (те же ключи, что §1-рендер) ─
|
||||
_put(
|
||||
context,
|
||||
"zone_code",
|
||||
nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index"),
|
||||
)
|
||||
_put(context, "zone_name", nspd_zoning.get("zone_name"))
|
||||
_put(context, "max_far", nspd_zoning.get("max_far"))
|
||||
_put(context, "max_floors", nspd_zoning.get("max_floors"))
|
||||
_put(context, "max_height_m", nspd_zoning.get("max_height_m"))
|
||||
_put(context, "max_building_pct", nspd_zoning.get("max_building_pct"))
|
||||
_put(context, "min_parcel_area_m2", nspd_zoning.get("min_parcel_area_m2"))
|
||||
_put(context, "regulation_source", nspd_zoning.get("regulation_source"))
|
||||
|
||||
# ── ЗОУИТ-обременения: сводка + список названий (dedup, capped) ───────────────
|
||||
has_zouit = encumbrance.get("has_zouit")
|
||||
zouit_count = encumbrance.get("zouit_count")
|
||||
zouit_names = _zouit_names(overlaps)
|
||||
# Противоречие «encumbrance=нет, но пересечения ЕСТЬ» — доверяем фактам НСПД (тот же
|
||||
# приём, что _build_zouit): свежий геослой перекрывает устаревшую сводку.
|
||||
if not has_zouit and zouit_names:
|
||||
has_zouit = True
|
||||
if zouit_count in (None, 0):
|
||||
zouit_count = len(overlaps)
|
||||
_put(context, "has_zouit", has_zouit)
|
||||
_put(context, "zouit_count", zouit_count)
|
||||
if zouit_names:
|
||||
context["zouit_zone_names"] = zouit_names
|
||||
|
||||
return context or None
|
||||
|
|
|
|||
|
|
@ -14,14 +14,9 @@ tool'ами (см. tools.py). ЭТО ЕДИНСТВЕННОЕ место, чер
|
|||
РАЗРЕШЕНО в ``section_data`` / ``fields``:
|
||||
• срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
|
||||
scoring / confidence / scenarios) — это посчитанные advisory-агрегаты.
|
||||
• ``parcel_context`` — КУРИРУЕМЫЙ срез analyze-рана (retrieval.get_parcel_context_for_chat):
|
||||
публичные градоданные участка (адрес, площадь, категория, ВРИ, тер.зона ПЗЗ + лимиты
|
||||
застройки, ЗОУИТ-сводка + названия зон). Строится по фиксированному whitelist'у из
|
||||
скаляров и списка строк — БЕЗ геометрии/координат, без сырых под-словарей, без PII.
|
||||
|
||||
ЗАПРЕЩЕНО (НИКОГДА не должно сюда дойти):
|
||||
• сырой ``analyze``-blob целиком / сырые строки БД (rosreestr_deals, parcels, …);
|
||||
• геометрия/координаты участка или ЗОУИТ-пересечений;
|
||||
• сырой ``analyze``-blob / сырые строки БД (rosreestr_deals, parcels, …);
|
||||
• свободный insight-текст / лиды / любой контент с PII;
|
||||
• любой источник, помеченный confidential.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@
|
|||
LLM в tool-loop'е (см. orchestrator.py) просит секции отчёта через function-calling.
|
||||
Здесь — две вещи и НИЧЕГО больше:
|
||||
|
||||
1. OpenAI tool-спеки (JSON-schema) шести read-only секционных tool'ов
|
||||
1. OpenAI tool-спеки (JSON-schema) пяти read-only секционных tool'ов
|
||||
(`get_exec_summary` / `get_product_recommendation` / `get_forecast` /
|
||||
`get_risks` / `get_scenarios` / `get_parcel_info`). Без параметров — каждый отдаёт
|
||||
фиксированную секцию(и) отчёта (модель не управляет вычислениями, только запрашивает
|
||||
данные).
|
||||
`get_risks` / `get_scenarios`). Без параметров — каждый отдаёт фиксированную
|
||||
секцию(и) отчёта (модель не управляет вычислениями, только запрашивает данные).
|
||||
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
|
||||
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
|
||||
НИКАКОЙ движковой математики — только срез готового dict'а.
|
||||
|
|
@ -74,16 +73,6 @@ def get_scenarios(report: dict[str, Any]) -> dict[str, Any]:
|
|||
return _section(report, "scenarios")
|
||||
|
||||
|
||||
def get_parcel_info(report: dict[str, Any]) -> dict[str, Any]:
|
||||
"""§1 parcel_context — паспорт участка + градрегламент ПЗЗ + ЗОУИТ.
|
||||
|
||||
Курируемый срез analyze-рана (тер.зона ПЗЗ, ВРИ, лимиты застройки, ЗОУИТ), влитый
|
||||
эндпоинтом под ключ "parcel_context" (retrieval.get_parcel_context_for_chat). Секции
|
||||
в отчёте нет (analyze-рана не было) → маркер «недоступно», как у остальных tool'ов.
|
||||
"""
|
||||
return _section(report, "parcel_context")
|
||||
|
||||
|
||||
# ── Реестр имя→executor + имя→секции отчёта (для provenance grounded_in.sections) ─
|
||||
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
|
||||
|
||||
|
|
@ -95,7 +84,6 @@ _TOOLS: dict[str, tuple[Callable[[dict[str, Any]], dict[str, Any]], tuple[str, .
|
|||
"get_forecast": (get_forecast, ("future_market",)),
|
||||
"get_risks": (get_risks, ("scoring", "confidence")),
|
||||
"get_scenarios": (get_scenarios, ("scenarios",)),
|
||||
"get_parcel_info": (get_parcel_info, ("parcel_context",)),
|
||||
}
|
||||
|
||||
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
|
||||
|
|
@ -115,11 +103,8 @@ _TOOL_DESCRIPTIONS: dict[str, str] = {
|
|||
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
|
||||
"и уровень/факторы уверенности отчёта."
|
||||
),
|
||||
"get_scenarios": ("Сценарии развития: разброс по консервативному / базовому / агрессивному."),
|
||||
"get_parcel_info": (
|
||||
"Паспорт участка и градостроительный регламент: адрес, площадь, категория земель, "
|
||||
"ВРИ, территориальная зона ПЗЗ (код и название), лимиты застройки, "
|
||||
"ЗОУИТ-обременения."
|
||||
"get_scenarios": (
|
||||
"Сценарии развития: разброс по консервативному / базовому / агрессивному."
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +128,7 @@ def _spec(name: str) -> dict[str, Any]:
|
|||
|
||||
|
||||
def tool_specs() -> list[dict[str, Any]]:
|
||||
"""Все 6 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
|
||||
"""Все 5 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
|
||||
return [_spec(name) for name in _TOOLS]
|
||||
|
||||
|
||||
|
|
@ -176,7 +161,6 @@ __all__ = [
|
|||
"execute_tool",
|
||||
"get_exec_summary",
|
||||
"get_forecast",
|
||||
"get_parcel_info",
|
||||
"get_product_recommendation",
|
||||
"get_risks",
|
||||
"get_scenarios",
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
"""Минимальный синхронный DaData-клиент — /clean/address → (lat, lon).
|
||||
|
||||
Нужен ТОЛЬКО для geo-pass в objective_backfill (#2177): адрес objective-проекта
|
||||
→ координаты для haversine-сверки с domrf-точкой. Не тащим tradein-модуль
|
||||
(`tradein-mvp/.../dadata.py`) импортом — это другое приложение с другим settings.
|
||||
|
||||
Клиент СИНХРОННЫЙ (httpx.Client): geo-pass гоняется в CLI / sync-Session, без
|
||||
event-loop. Авторизация /clean/address требует ОБА: token + secret (в отличие от
|
||||
/suggest, которому хватает токена). Оба берутся из settings (env DADATA_API_TOKEN /
|
||||
DADATA_API_SECRET).
|
||||
|
||||
Возвращает None (не raise) на любой сбой — нет credentials, пустой/короткий адрес,
|
||||
сетевая/HTTP-ошибка, 429 quota, невалидный JSON, адрес не распознан. Вызывающая
|
||||
сторона (geo-pass) трактует None как «нет геокода» → reject-кандидат, проход не
|
||||
падает.
|
||||
|
||||
Прод-инцидент 2026-07-03: у токена может быть ОТКЛЮЧЕНА фича CLEAN («Feature
|
||||
'CLEAN' disabled for token» — стандартизация платная, баланс исчерпан), при этом
|
||||
БЕСПЛАТНЫЙ suggestions API (10k/день, только Token без X-Secret) работает и тоже
|
||||
отдаёт geo_lat/geo_lon. Поэтому: cleaner → при 401/403 однократный фолбэк на
|
||||
suggest/address (первая саджеста). Точность саджесты чуть ниже клинера — для
|
||||
наших целей (точка на карте + bbox-гейт) достаточно.
|
||||
|
||||
Docs: https://dadata.ru/api/clean/address/ · https://dadata.ru/api/suggest/address/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address"
|
||||
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||
|
||||
|
||||
def _credentials() -> tuple[str, str] | None:
|
||||
"""(token, secret) если ОБА заданы непусто, иначе None (graceful disable)."""
|
||||
token = (settings.dadata_api_token or "").strip()
|
||||
secret = (settings.dadata_api_secret or "").strip()
|
||||
if not token or not secret:
|
||||
return None
|
||||
return token, secret
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> float | None:
|
||||
"""DaData отдаёт geo_lat/geo_lon строкой — coerce в float безопасно."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def clean_address(address: str) -> tuple[float, float] | None:
|
||||
"""Геокодировать один адрес через DaData /clean/address → (lat, lon).
|
||||
|
||||
Args:
|
||||
address: свободный текст адреса (objective_lots.address).
|
||||
|
||||
Returns:
|
||||
(geo_lat, geo_lon) как float-пара, либо None если:
|
||||
- credentials не заданы в ENV (graceful disable);
|
||||
- адрес пустой / короче 3 символов;
|
||||
- сетевая / HTTP-ошибка (timeout, 5xx, 429 quota, 401/403 auth, 4xx);
|
||||
- невалидный JSON / пустой массив / не dict;
|
||||
- DaData не вернул geo_lat/geo_lon (адрес не геокодирован).
|
||||
"""
|
||||
if not address or len(address.strip()) < 3:
|
||||
return None
|
||||
|
||||
creds = _credentials()
|
||||
if creds is None:
|
||||
logger.debug("dadata_client: credentials не заданы — skip геокод")
|
||||
return None
|
||||
|
||||
token, secret = creds
|
||||
headers = {
|
||||
"Authorization": f"Token {token}",
|
||||
"X-Secret": secret,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
body = [address.strip()]
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=settings.dadata_timeout_s) as client:
|
||||
response = client.post(DADATA_CLEAN_URL, headers=headers, json=body)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
logger.warning("dadata_client: network error для %r: %s", address[:60], exc)
|
||||
return None
|
||||
except Exception as exc:
|
||||
# defensive: любой прочий httpx/клиент-сбой → None, geo-pass reject'ит,
|
||||
# НЕ роняем весь проход. Логируем на warning (не глотаем молча).
|
||||
logger.warning("dadata_client: unexpected client error для %r: %s", address[:60], exc)
|
||||
return None
|
||||
|
||||
status = response.status_code
|
||||
if status == 429:
|
||||
logger.warning("dadata_client: HTTP 429 — quota exceeded")
|
||||
return None
|
||||
if status in (401, 403):
|
||||
# Фича CLEAN у токена может быть отключена (баланс стандартизации) —
|
||||
# фолбэк на бесплатный suggestions (тот же token, X-Secret не нужен).
|
||||
logger.warning("dadata_client: cleaner HTTP %d — фолбэк на suggest/address", status)
|
||||
return _suggest_geocode(address.strip(), token)
|
||||
if status >= 400:
|
||||
logger.warning("dadata_client: HTTP %d для %r", status, address[:60])
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.warning("dadata_client: невалидный JSON: %s", exc)
|
||||
return None
|
||||
|
||||
if not isinstance(payload, list) or not payload:
|
||||
logger.info("dadata_client: пустой ответ для %r", address[:60])
|
||||
return None
|
||||
|
||||
item = payload[0]
|
||||
if not isinstance(item, dict):
|
||||
logger.warning("dadata_client: первый элемент не dict (%s)", type(item).__name__)
|
||||
return None
|
||||
|
||||
lat = _coerce_float(item.get("geo_lat"))
|
||||
lon = _coerce_float(item.get("geo_lon"))
|
||||
if lat is None or lon is None:
|
||||
logger.info(
|
||||
"dadata_client: нет координат (qc_geo=%s) для %r",
|
||||
item.get("qc_geo"),
|
||||
address[:60],
|
||||
)
|
||||
return None
|
||||
|
||||
return (lat, lon)
|
||||
|
||||
|
||||
def _suggest_geocode(address: str, token: str) -> tuple[float, float] | None:
|
||||
"""Фолбэк-геокод через БЕСПЛАТНЫЙ suggest/address (первая саджеста).
|
||||
|
||||
Возвращает (lat, lon) из suggestions[0].data.geo_lat/geo_lon либо None.
|
||||
Та же graceful-семантика, что у clean_address.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Token {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
try:
|
||||
with httpx.Client(timeout=settings.dadata_timeout_s) as client:
|
||||
response = client.post(
|
||||
DADATA_SUGGEST_URL,
|
||||
headers=headers,
|
||||
json={"query": address, "count": 1},
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
logger.warning("dadata_client: suggest network error для %r: %s", address[:60], exc)
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.warning("dadata_client: suggest unexpected error для %r: %s", address[:60], exc)
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("dadata_client: suggest HTTP %d для %r", response.status_code, address[:60])
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
logger.warning("dadata_client: suggest невалидный JSON: %s", exc)
|
||||
return None
|
||||
|
||||
suggestions = payload.get("suggestions") if isinstance(payload, dict) else None
|
||||
if not suggestions or not isinstance(suggestions[0], dict):
|
||||
logger.info("dadata_client: suggest пусто для %r", address[:60])
|
||||
return None
|
||||
|
||||
data = suggestions[0].get("data") or {}
|
||||
lat = _coerce_float(data.get("geo_lat"))
|
||||
lon = _coerce_float(data.get("geo_lon"))
|
||||
if lat is None or lon is None:
|
||||
logger.info(
|
||||
"dadata_client: suggest без координат (qc_geo=%s) для %r",
|
||||
data.get("qc_geo"),
|
||||
address[:60],
|
||||
)
|
||||
return None
|
||||
return (lat, lon)
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
"""Review-инструмент авто-маппингов objective_complex_mapping (feat/mapping-review-page).
|
||||
|
||||
Авто-проходы (auto_core_dev_v5 / auto_core_geo_v6 / fuzzy_* / …) пишут строки
|
||||
с is_reviewed=false. Здесь — чтение этих строк С ОБОГАЩЕНИЕМ для глаз ревьюера,
|
||||
подтверждение (approve → is_reviewed=true) и отклонение (reject → DELETE строки,
|
||||
чтобы отклонённый маппинг не травил mv_layout_velocity §4.2).
|
||||
|
||||
Обогащение:
|
||||
• domrf comm_name / dev_name — LATEST snapshot по domrf_obj_id
|
||||
(snapshot_date DESC — как в objective_backfill._DOMRF_UNMAPPED_SQL).
|
||||
• objective developers — агрегат objective_lots.developer, SCOPED по
|
||||
(project_name = objective_complex_name). Урок 3.8с: агрегат по project_name,
|
||||
НЕ глобальный — иначе список застройщиков одного проекта протекает в чужой.
|
||||
|
||||
Чтения — parametrized `text(...)` + CAST(:x AS type) (backend.md psycopg3).
|
||||
DELETE (reject) — только по PK с RETURNING для полного лога удаляемой строки.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── SQL: строки mapping + обогащение domrf latest snapshot + objective devs ────
|
||||
# LEFT JOIN LATERAL к domrf_kn_objects: DISTINCT-ON-эквивалент через ORDER BY
|
||||
# snapshot_date DESC LIMIT 1 (latest snapshot по obj_id). objective devs —
|
||||
# коррелированный агрегат по project_name (scoped, урок 3.8с).
|
||||
# Сорт: is_reviewed asc (unreviewed сверху), match_score asc NULLS FIRST
|
||||
# (самые сомнительные / без скора сверху), id для стабильности.
|
||||
_LIST_SQL = text("""
|
||||
SELECT
|
||||
m.id,
|
||||
m.objective_complex_name,
|
||||
m.objective_project_id,
|
||||
m.objective_group,
|
||||
m.domrf_obj_id,
|
||||
m.match_method,
|
||||
m.match_score,
|
||||
m.is_reviewed,
|
||||
m.note,
|
||||
m.created_at,
|
||||
d.comm_name AS domrf_comm_name,
|
||||
d.dev_name AS domrf_dev_name,
|
||||
odev.developers AS objective_developers
|
||||
FROM objective_complex_mapping m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT o.comm_name, o.dev_name
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.obj_id = m.domrf_obj_id
|
||||
ORDER BY o.snapshot_date DESC NULLS LAST
|
||||
LIMIT 1
|
||||
) d ON m.domrf_obj_id IS NOT NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers
|
||||
FROM objective_lots ol
|
||||
WHERE ol.project_name = m.objective_complex_name
|
||||
AND ol.developer IS NOT NULL
|
||||
) odev ON TRUE
|
||||
WHERE (CAST(:only_unreviewed AS boolean) = FALSE OR m.is_reviewed = FALSE)
|
||||
ORDER BY m.is_reviewed ASC, m.match_score ASC NULLS FIRST, m.id ASC
|
||||
LIMIT CAST(:limit AS int) OFFSET CAST(:offset AS int)
|
||||
""")
|
||||
|
||||
_COUNT_SQL = text("""
|
||||
SELECT COUNT(*) AS total
|
||||
FROM objective_complex_mapping m
|
||||
WHERE (CAST(:only_unreviewed AS boolean) = FALSE OR m.is_reviewed = FALSE)
|
||||
""")
|
||||
|
||||
# ── SQL: approve — is_reviewed=true + append note. RETURNING для 404-детекта ───
|
||||
# NULLIF/concat: пустой/NULL note → просто «approved <date>»; иначе append через
|
||||
# перевод строки. Идемпотентно относительно is_reviewed (повторный approve OK).
|
||||
_APPROVE_SQL = text("""
|
||||
UPDATE objective_complex_mapping
|
||||
SET is_reviewed = TRUE,
|
||||
note = CASE
|
||||
WHEN note IS NULL OR note = '' THEN CAST(:approve_note AS text)
|
||||
ELSE note || E'\\n' || CAST(:approve_note AS text)
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
RETURNING id, objective_complex_name, domrf_obj_id,
|
||||
match_method, match_score, is_reviewed, note
|
||||
""")
|
||||
|
||||
# ── SQL: reject — DELETE по PK, RETURNING всей строки для лога (травит §4.2) ───
|
||||
_DELETE_SQL = text("""
|
||||
DELETE FROM objective_complex_mapping
|
||||
WHERE id = CAST(:id AS bigint)
|
||||
RETURNING id, objective_complex_name, objective_project_id, objective_group,
|
||||
domrf_obj_id, match_method, match_score, is_reviewed, note,
|
||||
created_at
|
||||
""")
|
||||
|
||||
|
||||
def _serialize_row(row: Any) -> dict[str, Any]:
|
||||
"""Сериализовать строку list-запроса в loose dict для API (паттерн admin_etl)."""
|
||||
score = row.match_score
|
||||
created = row.created_at
|
||||
return {
|
||||
"id": row.id,
|
||||
"objective_complex_name": row.objective_complex_name,
|
||||
"objective_project_id": row.objective_project_id,
|
||||
"objective_group": row.objective_group,
|
||||
"domrf_obj_id": row.domrf_obj_id,
|
||||
"match_method": row.match_method,
|
||||
"match_score": float(score) if score is not None else None,
|
||||
"is_reviewed": bool(row.is_reviewed),
|
||||
"note": row.note,
|
||||
"created_at": created.isoformat() if created else None,
|
||||
# Обогащение для глаз ревьюера:
|
||||
"domrf_comm_name": row.domrf_comm_name,
|
||||
"domrf_dev_name": row.domrf_dev_name,
|
||||
"objective_developers": list(row.objective_developers or []),
|
||||
}
|
||||
|
||||
|
||||
def list_mapping_review(
|
||||
db: Session,
|
||||
*,
|
||||
only_unreviewed: bool = True,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Список строк objective_complex_mapping + обогащение + total count.
|
||||
|
||||
Сорт: is_reviewed asc, match_score asc NULLS FIRST (сомнительные сверху), id.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
only_unreviewed: True (default) — только is_reviewed=false.
|
||||
limit: страница (строк).
|
||||
offset: смещение страницы.
|
||||
|
||||
Returns:
|
||||
dict: rows (list обогащённых строк), total (int, до limit/offset),
|
||||
limit, offset, only_unreviewed.
|
||||
"""
|
||||
rows = db.execute(
|
||||
_LIST_SQL,
|
||||
{"only_unreviewed": only_unreviewed, "limit": limit, "offset": offset},
|
||||
).all()
|
||||
total = db.execute(_COUNT_SQL, {"only_unreviewed": only_unreviewed}).scalar_one()
|
||||
|
||||
return {
|
||||
"rows": [_serialize_row(r) for r in rows],
|
||||
"total": int(total),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"only_unreviewed": only_unreviewed,
|
||||
}
|
||||
|
||||
|
||||
def approve_mapping(db: Session, mapping_id: int) -> dict[str, Any] | None:
|
||||
"""Подтвердить маппинг: is_reviewed=true + append «approved <date>» в note.
|
||||
|
||||
Returns:
|
||||
dict обновлённой строки, либо None если id не найден (→ 404 в роутере).
|
||||
"""
|
||||
approve_note = f"approved {date.today().isoformat()}"
|
||||
row = db.execute(_APPROVE_SQL, {"id": mapping_id, "approve_note": approve_note}).first()
|
||||
if row is None:
|
||||
db.rollback()
|
||||
return None
|
||||
db.commit()
|
||||
logger.info(
|
||||
"mapping-review approve id=%s name=%r method=%s → is_reviewed=true",
|
||||
row.id,
|
||||
row.objective_complex_name,
|
||||
row.match_method,
|
||||
)
|
||||
score = row.match_score
|
||||
return {
|
||||
"id": row.id,
|
||||
"objective_complex_name": row.objective_complex_name,
|
||||
"domrf_obj_id": row.domrf_obj_id,
|
||||
"match_method": row.match_method,
|
||||
"match_score": float(score) if score is not None else None,
|
||||
"is_reviewed": bool(row.is_reviewed),
|
||||
"note": row.note,
|
||||
}
|
||||
|
||||
|
||||
def reject_mapping(db: Session, mapping_id: int) -> dict[str, Any] | None:
|
||||
"""Отклонить маппинг: DELETE строки (травит mv_layout_velocity §4.2 иначе).
|
||||
|
||||
Перед удалением полная строка логируется через logger.info (в note писать
|
||||
некуда — строки не будет). DELETE только по PK с RETURNING.
|
||||
|
||||
Returns:
|
||||
dict удалённой строки (для эха в ответе), либо None если id не найден.
|
||||
"""
|
||||
row = db.execute(_DELETE_SQL, {"id": mapping_id}).first()
|
||||
if row is None:
|
||||
db.rollback()
|
||||
return None
|
||||
|
||||
created = row.created_at
|
||||
score = row.match_score
|
||||
deleted: dict[str, Any] = {
|
||||
"id": row.id,
|
||||
"objective_complex_name": row.objective_complex_name,
|
||||
"objective_project_id": row.objective_project_id,
|
||||
"objective_group": row.objective_group,
|
||||
"domrf_obj_id": row.domrf_obj_id,
|
||||
"match_method": row.match_method,
|
||||
"match_score": float(score) if score is not None else None,
|
||||
"is_reviewed": bool(row.is_reviewed),
|
||||
"note": row.note,
|
||||
"created_at": created.isoformat() if created else None,
|
||||
}
|
||||
logger.info("mapping-review reject DELETE id=%s row=%s", row.id, deleted)
|
||||
db.commit()
|
||||
return deleted
|
||||
|
|
@ -1,12 +1,9 @@
|
|||
"""Cross-load ETL: tradein.houses → gendesign.newbuilding_listings (#976 950-E5).
|
||||
|
||||
Читает market.v_houses + market.v_house_sources из tradein-БД (gendesign_reader role),
|
||||
Читает houses + house_sources из tradein-БД (gendesign_reader role),
|
||||
UPSERT-ит в gendesign newbuilding_listings по (source, ext_house_id).
|
||||
Соединение к tradein через psycopg v3 (прямой коннект, НЕ SQLAlchemy).
|
||||
|
||||
Источник — contract-view market.v_* (#2138), а не сырые public.*-таблицы: явный
|
||||
column-контракт, устойчивый к scraper-driven ALTER TABLE на стороне tradein.
|
||||
|
||||
Запускается:
|
||||
- ночью Celery beat (03:30 МСК, tasks/etl_newbuilding_crossload.py)
|
||||
- вручную через POST /api/v1/admin/scrape/newbuilding-crossload
|
||||
|
|
@ -57,10 +54,8 @@ SELECT
|
|||
h.last_scraped_at,
|
||||
-- yandex_jk_id из house_sources (LEFT JOIN — может отсутствовать)
|
||||
hs_y.ext_id AS yandex_jk_id
|
||||
-- Читаем через contract-view market.v_* (#2138 step 2/3), НЕ сырые public.* —
|
||||
-- стабильный явный column-контракт, не ломается на scraper-driven ALTER TABLE.
|
||||
FROM market.v_houses h
|
||||
LEFT JOIN market.v_house_sources hs_y
|
||||
FROM houses h
|
||||
LEFT JOIN house_sources hs_y
|
||||
ON hs_y.house_id = h.id
|
||||
AND hs_y.ext_source = 'yandex'
|
||||
WHERE h.lat IS NOT NULL
|
||||
|
|
|
|||
|
|
@ -12,40 +12,20 @@ Schema facts (confirmed via pg MCP):
|
|||
колонки match_method + match_score + is_reviewed поддерживают audit trail.
|
||||
|
||||
match_method history:
|
||||
'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows)
|
||||
'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
|
||||
'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
|
||||
'auto_core_dev_v5' — core-name normalize (#2198 normalize_complex_name) обеих сторон +
|
||||
dev-confirm. trgm по СЫРЫМ именам давал ~300 маппингов, 1253 obj_id
|
||||
остались (низкая similarity: «Жилой комплекс "Малевич" (ЖК "Малевич")»
|
||||
vs «Малевич»). Ядро-нормализация схлопывает обёртки → exact-core match;
|
||||
dev-подтверждение (пересечение токенов / подстрока) отсеивает
|
||||
одноимённые чужие проекты (Объектив = только ЕКБ). #2177 шаг 2.
|
||||
'auto_core_geo_v6' — гео-разрешение остатка core-pass (tier_b / ambiguous) через
|
||||
DaData-геокод. Адрес objective-проекта (mode objective_lots.
|
||||
address per project) → clean_address → (lat, lon); haversine
|
||||
до domrf-точки (domrf_kn_objects.latitude/longitude, latest
|
||||
snapshot). tier_b: дистанция ≤ порога → confirm (гео вместо
|
||||
dev). ambiguous: геокодим ВСЕХ кандидатов ядра, confirm только
|
||||
если РОВНО ОДИН в радиусе. score 0.80, is_reviewed=false. #2177.
|
||||
'manual' — ручная корректура
|
||||
'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows)
|
||||
'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
|
||||
'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
|
||||
'manual' — ручная корректура
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.dadata_client import clean_address
|
||||
from app.services.site_finder.best_layouts import normalize_complex_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Порог для auto-insert (высокая уверенность)
|
||||
|
|
@ -254,919 +234,3 @@ def trigger_mv_refresh(db: Session) -> int:
|
|||
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
|
||||
|
||||
return refresh_layout_velocity(db, concurrently=True)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Core-name pass (#2177 шаг 2) — auto_core_dev_v5
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# objective_group у всех существующих строк mapping = 'Екатеринбург'
|
||||
# (Объектив покрывает только ЕКБ). Новые строки пишем с тем же значением.
|
||||
OBJECTIVE_GROUP = "Екатеринбург"
|
||||
|
||||
CORE_MATCH_METHOD = "auto_core_dev_v5"
|
||||
CORE_MATCH_SCORE = 0.90
|
||||
CORE_MATCH_NOTE = "#2177 step2: exact-core + dev-confirm"
|
||||
|
||||
# Префиксы/обёртки орг-формы застройщика, срезаемые перед сравнением.
|
||||
# Порядок применения не важен (все режутся из целого норм-имени).
|
||||
_DEV_JUNK_RE = re.compile(
|
||||
r"""
|
||||
\b(?:
|
||||
ооо | оао | пао | зао | ао | ип
|
||||
| сз # СЗ = специализированный застройщик (аббрев.)
|
||||
| специализированн\w* # «специализированный застройщик»
|
||||
| застройщик\w*
|
||||
| девелопмент\w*
|
||||
| групп\w* # групп / группа
|
||||
| компани\w*
|
||||
| инвест\w* # инвест / инвестиции (частый шум)
|
||||
| строительн\w*
|
||||
)\b
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _norm_dev(raw: str | None) -> str:
|
||||
"""Нормализовать имя застройщика до сравниваемого ядра.
|
||||
|
||||
lower → срез кавычек/скобок → срез орг-формы (ООО/АО/ПАО/ЗАО/«СЗ»/
|
||||
«специализированный застройщик»/«девелопмент»/«групп(а)») → схлопывание
|
||||
пробелов. Пустой / None → ''.
|
||||
|
||||
Namespaced на _NAME_-уровне: возвращаем очищенную строку, дальнейшая
|
||||
классификация (пересечение токенов / подстрока) — в find_core_matches.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
name = raw.strip().lower()
|
||||
# убрать кавычки-ёлочки/лапки/скобки — оставить только содержательные символы
|
||||
name = re.sub(r"[«»\"'`()\[\]]", " ", name)
|
||||
name = _DEV_JUNK_RE.sub(" ", name)
|
||||
# мусорные разделители → пробел (точки в «Астон.» и т.п. не трогаем на dev-стороне:
|
||||
# dev-имена обычно без них, но на всякий нормализуем распространённые)
|
||||
name = re.sub(r"[.,;/\\]", " ", name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
return name
|
||||
|
||||
|
||||
def _dev_confirms(domrf_dev: str, objective_devs: set[str]) -> bool:
|
||||
"""True если domrf-застройщик подтверждает связь с objective-проектом.
|
||||
|
||||
У одного objective project_name бывает НЕСКОЛЬКО developers (SPV / очереди) —
|
||||
подтверждение по ЛЮБОМУ из них. Критерий (любой):
|
||||
• пересечение токенов норм-имён (общий содержательный токен), ИЛИ
|
||||
• одно норм-имя — подстрока другого (в любую сторону), НО только когда
|
||||
короткая сторона ≥ 4 символов: «дом»/«юг»/«ак» как подстрока чужого
|
||||
длинного имени («Домострой», «Югстройинвест») — ложное подтверждение,
|
||||
а именно dev-confirm — последняя защита от generic-core коллизий.
|
||||
|
||||
Пустые имена не подтверждают (нет сигнала).
|
||||
"""
|
||||
d = _norm_dev(domrf_dev)
|
||||
if not d:
|
||||
return False
|
||||
d_tokens = {t for t in d.split() if len(t) >= 3}
|
||||
for raw_odev in objective_devs:
|
||||
o = _norm_dev(raw_odev)
|
||||
if not o:
|
||||
continue
|
||||
if min(len(d), len(o)) >= 4 and (d in o or o in d):
|
||||
return True
|
||||
o_tokens = {t for t in o.split() if len(t) >= 3}
|
||||
if d_tokens & o_tokens:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoreMatch:
|
||||
"""Один core-match domrf-объекта к objective-проекту."""
|
||||
|
||||
domrf_obj_id: int
|
||||
domrf_comm_name: str
|
||||
domrf_dev_name: str | None
|
||||
core: str
|
||||
objective_project_name: str
|
||||
objective_project_id: int | None
|
||||
objective_developers: list[str]
|
||||
dev_confirmed: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoreMatchReport:
|
||||
"""Итог классификации core-pass.
|
||||
|
||||
tier_a — ровно 1 objective-кандидат И dev-подтверждён → пишем в БД.
|
||||
tier_b — ровно 1 кандидат, dev НЕ подтверждён → кандидат на DaData-гео
|
||||
проход, в БД НЕ пишем (только отчёт).
|
||||
ambiguous — >1 objective-кандидатов по core → в отчёт, разрешение вручную/гео.
|
||||
skipped_taken — objective_complex_name уже занят в mapping (UNIQUE-констрейнт;
|
||||
его domrf-группа уже покрыта — дубли не нужны).
|
||||
"""
|
||||
|
||||
tier_a: list[CoreMatch] = field(default_factory=list)
|
||||
tier_b: list[CoreMatch] = field(default_factory=list)
|
||||
ambiguous: list[CoreMatch] = field(default_factory=list)
|
||||
skipped_taken: list[CoreMatch] = field(default_factory=list)
|
||||
|
||||
def counts(self) -> dict[str, int]:
|
||||
return {
|
||||
"tier_a": len(self.tier_a),
|
||||
"tier_b": len(self.tier_b),
|
||||
"ambiguous": len(self.ambiguous),
|
||||
"skipped_taken": len(self.skipped_taken),
|
||||
}
|
||||
|
||||
|
||||
# ── SQL: несопоставленные domrf ЕКБ-объекты, latest snapshot per obj_id ───────
|
||||
# ЛОВУШКА is_ekb (прод-проверка 2026-07-03): флаг проставляется per-snapshot и на
|
||||
# свежих снапшотах ПУСТ (все latest-строки is_ekb=false — см. также комментарий
|
||||
# в 155_objective_mapping_trgm_backfill.sql). `WHERE is_ekb = true` внутри
|
||||
# DISTINCT ON отдал бы устаревшие имена со старых снапшотов (или 0 строк после
|
||||
# DISTINCT). Поэтому: ЕКБ-принадлежность = «obj_id имел is_ekb=true хоть на одном
|
||||
# снапшоте» (1285 живых объектов), а строку берём с LATEST снапшота без флага.
|
||||
_DOMRF_UNMAPPED_SQL = text("""
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id, o.comm_name, o.dev_name
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.comm_name IS NOT NULL
|
||||
AND o.obj_id IN (
|
||||
SELECT DISTINCT e.obj_id FROM domrf_kn_objects e WHERE e.is_ekb = true
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id = o.obj_id
|
||||
)
|
||||
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
|
||||
""")
|
||||
|
||||
# ── SQL: objective-проекты — distinct project_name + агрегаты developer/id ────
|
||||
# objective_project_id: в objective_lots есть колонка objective_project_id
|
||||
# (68_schema_objective.sql). Берём MAX (стабилен per project_name; NULL если нет).
|
||||
_OBJECTIVE_PROJECTS_SQL = text("""
|
||||
SELECT
|
||||
ol.project_name,
|
||||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers,
|
||||
MAX(ol.objective_project_id) AS objective_project_id
|
||||
FROM objective_lots ol
|
||||
WHERE ol.project_name IS NOT NULL
|
||||
AND ol.project_name <> ''
|
||||
GROUP BY ol.project_name
|
||||
""")
|
||||
|
||||
# ── SQL: уже занятые objective_complex_name в текущей группе ──────────────────
|
||||
_TAKEN_NAMES_SQL = text("""
|
||||
SELECT objective_complex_name
|
||||
FROM objective_complex_mapping
|
||||
WHERE objective_group = CAST(:group AS text)
|
||||
""")
|
||||
|
||||
|
||||
def find_core_matches(db: Session) -> CoreMatchReport:
|
||||
"""Сопоставить несопоставленные domrf ЕКБ-объекты с objective-проектами по ядру.
|
||||
|
||||
Обе стороны нормализуются `normalize_complex_name` (#2198): обёртки вроде
|
||||
«Жилой комплекс "Малевич" (ЖК "Малевич")» схлопываются до ядра «малевич»,
|
||||
что даёт exact-core match там, где trgm по сырым именам не дотягивал.
|
||||
|
||||
Классификация (см. CoreMatchReport):
|
||||
tier_a — 1 objective-кандидат + dev-подтверждён (пишем);
|
||||
tier_b — 1 кандидат, dev НЕ подтверждён (DaData-гео проход, не пишем);
|
||||
ambiguous — >1 кандидата (ручное/гео разрешение);
|
||||
skipped — objective_complex_name уже в mapping (UNIQUE-констрейнт).
|
||||
|
||||
Реального обращения к БД в тестах нет — db.execute мокается по паттерну файла.
|
||||
"""
|
||||
report = CoreMatchReport()
|
||||
|
||||
# objective-сторона: core → [(project_name, {devs}, project_id), ...]
|
||||
objective_by_core: dict[str, list[tuple[str, set[str], int | None]]] = {}
|
||||
for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
|
||||
project_name = str(row[0])
|
||||
developers = {str(d) for d in (row[1] or []) if d}
|
||||
project_id = int(row[2]) if row[2] is not None else None
|
||||
core = normalize_complex_name(project_name)
|
||||
if not core:
|
||||
continue
|
||||
objective_by_core.setdefault(core, []).append((project_name, developers, project_id))
|
||||
|
||||
# уже занятые objective_complex_name (skip — их группа уже покрыта)
|
||||
taken_names: set[str] = {
|
||||
str(r[0]) for r in db.execute(_TAKEN_NAMES_SQL, {"group": OBJECTIVE_GROUP}).all()
|
||||
}
|
||||
|
||||
# domrf-сторона: несопоставленные ЕКБ, latest snapshot per obj_id
|
||||
for row in db.execute(_DOMRF_UNMAPPED_SQL).all():
|
||||
obj_id = int(row[0])
|
||||
comm_name = str(row[1])
|
||||
dev_name = str(row[2]) if row[2] is not None else None
|
||||
core = normalize_complex_name(comm_name)
|
||||
if not core:
|
||||
continue
|
||||
|
||||
candidates = objective_by_core.get(core)
|
||||
if not candidates:
|
||||
continue # нет objective-проекта с таким ядром — не наш случай
|
||||
|
||||
if len(candidates) > 1:
|
||||
# >1 objective project_name делят одно ядро → ambiguous (не выбираем вслепую)
|
||||
proj_name, devs, proj_id = candidates[0]
|
||||
report.ambiguous.append(
|
||||
_build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
|
||||
)
|
||||
continue
|
||||
|
||||
proj_name, devs, proj_id = candidates[0]
|
||||
|
||||
if proj_name in taken_names:
|
||||
report.skipped_taken.append(
|
||||
_build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
|
||||
)
|
||||
continue
|
||||
|
||||
match = _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
|
||||
if match.dev_confirmed:
|
||||
report.tier_a.append(match)
|
||||
else:
|
||||
report.tier_b.append(match)
|
||||
|
||||
logger.info("find_core_matches: %s", report.counts())
|
||||
return report
|
||||
|
||||
|
||||
def _build_core_match(
|
||||
obj_id: int,
|
||||
comm_name: str,
|
||||
dev_name: str | None,
|
||||
core: str,
|
||||
proj_name: str,
|
||||
proj_id: int | None,
|
||||
devs: set[str],
|
||||
) -> CoreMatch:
|
||||
"""Сконструировать CoreMatch + посчитать dev_confirmed."""
|
||||
return CoreMatch(
|
||||
domrf_obj_id=obj_id,
|
||||
domrf_comm_name=comm_name,
|
||||
domrf_dev_name=dev_name,
|
||||
core=core,
|
||||
objective_project_name=proj_name,
|
||||
objective_project_id=proj_id,
|
||||
objective_developers=sorted(devs),
|
||||
dev_confirmed=_dev_confirms(dev_name or "", devs),
|
||||
)
|
||||
|
||||
|
||||
def apply_core_matches(
|
||||
db: Session,
|
||||
report: CoreMatchReport,
|
||||
*,
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, int]:
|
||||
"""Вставить ТОЛЬКО tier_a в objective_complex_mapping (auto_core_dev_v5).
|
||||
|
||||
tier_b / ambiguous / skipped НЕ пишутся (см. CoreMatchReport). Каждая вставка
|
||||
в per-row SAVEPOINT (backend.md) с ON CONFLICT (objective_complex_name,
|
||||
objective_group) DO NOTHING — гонка/дубль не роняет весь проход.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
report: результат find_core_matches().
|
||||
dry_run: True (default) — только логирует counts + первые 30 пар, БЕЗ insert.
|
||||
|
||||
Returns:
|
||||
dict: inserted, conflict_skipped, error_skipped, tier_a_total.
|
||||
"""
|
||||
counts = report.counts()
|
||||
logger.info(
|
||||
"apply_core_matches (dry_run=%s): tier_a=%d tier_b=%d ambiguous=%d skipped_taken=%d",
|
||||
dry_run,
|
||||
counts["tier_a"],
|
||||
counts["tier_b"],
|
||||
counts["ambiguous"],
|
||||
counts["skipped_taken"],
|
||||
)
|
||||
|
||||
for m in report.tier_a[:30]:
|
||||
logger.info(
|
||||
" tier_a: obj_id=%s core=%r domrf=%r → objective=%r (dev %r ~ %r)",
|
||||
m.domrf_obj_id,
|
||||
m.core,
|
||||
m.domrf_comm_name,
|
||||
m.objective_project_name,
|
||||
m.domrf_dev_name,
|
||||
m.objective_developers,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"inserted": 0,
|
||||
"conflict_skipped": 0,
|
||||
"error_skipped": 0,
|
||||
"tier_a_total": counts["tier_a"],
|
||||
}
|
||||
|
||||
inserted = 0
|
||||
conflict_skipped = 0
|
||||
error_skipped = 0
|
||||
for m in report.tier_a:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
result = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO objective_complex_mapping
|
||||
(objective_complex_name, objective_project_id, domrf_obj_id,
|
||||
objective_group, match_method, match_score, is_reviewed, note)
|
||||
VALUES (
|
||||
CAST(:name AS text),
|
||||
CAST(:project_id AS bigint),
|
||||
CAST(:obj_id AS bigint),
|
||||
CAST(:group AS text),
|
||||
CAST(:method AS text),
|
||||
CAST(:score AS numeric),
|
||||
CAST(:reviewed AS boolean),
|
||||
CAST(:note AS text)
|
||||
)
|
||||
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"name": m.objective_project_name,
|
||||
"project_id": m.objective_project_id,
|
||||
"obj_id": m.domrf_obj_id,
|
||||
"group": OBJECTIVE_GROUP,
|
||||
"method": CORE_MATCH_METHOD,
|
||||
"score": CORE_MATCH_SCORE,
|
||||
"reviewed": False,
|
||||
"note": CORE_MATCH_NOTE,
|
||||
},
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
inserted += 1
|
||||
else:
|
||||
conflict_skipped += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"core insert failed для %s ↔ %s: %s",
|
||||
m.domrf_comm_name,
|
||||
m.objective_project_name,
|
||||
e,
|
||||
)
|
||||
error_skipped += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"apply_core_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
|
||||
inserted,
|
||||
conflict_skipped,
|
||||
error_skipped,
|
||||
)
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"conflict_skipped": conflict_skipped,
|
||||
"error_skipped": error_skipped,
|
||||
"tier_a_total": counts["tier_a"],
|
||||
}
|
||||
|
||||
|
||||
def run_core_pass(*, apply: bool = False) -> dict[str, int]:
|
||||
"""CLI-обёртка core-pass: открыть Session, найти матчи, (опц.) применить.
|
||||
|
||||
Дефолт — dry-run. Реальный insert только при apply=True.
|
||||
"""
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = find_core_matches(db)
|
||||
return apply_core_matches(db, report, dry_run=not apply)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Geo-pass (#2177) — auto_core_geo_v6
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Разрешает ОСТАТОК core-pass (tier_b + ambiguous) географией: адрес objective-
|
||||
# проекта → DaData-геокод → haversine до domrf-точки. tier_b подтверждается
|
||||
# близостью (гео вместо dev-confirm); ambiguous — только если РОВНО ОДИН из
|
||||
# кандидатов ядра в радиусе (иначе остаётся ambiguous).
|
||||
|
||||
GEO_MATCH_METHOD = "auto_core_geo_v6"
|
||||
GEO_MATCH_SCORE = 0.80
|
||||
GEO_MAX_DISTANCE_M = 500.0
|
||||
# Бережный потолок вызовов DaData за один прогон (МАКС-тариф, но не жжём квоту
|
||||
# на прогон-цикле). Превышение → стоп с warning, недобранные кандидаты reject'ятся
|
||||
# как «без геокода».
|
||||
GEO_DADATA_CALL_LIMIT = 200
|
||||
_EARTH_RADIUS_M = 6_371_000.0
|
||||
|
||||
|
||||
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Расстояние (метры) между двумя точками по формуле гаверсинуса."""
|
||||
p1 = math.radians(lat1)
|
||||
p2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
|
||||
return 2 * _EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a)))
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoMatch:
|
||||
"""Один подтверждённый гео-матч domrf-объекта к objective-проекту.
|
||||
|
||||
origin — 'tier_b' | 'ambiguous' (из какого класса core-pass пришёл).
|
||||
distance_m — гаверсинус адрес-проекта ↔ domrf-точки (метры).
|
||||
"""
|
||||
|
||||
domrf_obj_id: int
|
||||
domrf_comm_name: str
|
||||
core: str
|
||||
objective_project_name: str
|
||||
objective_project_id: int | None
|
||||
address: str
|
||||
distance_m: float
|
||||
origin: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoReject:
|
||||
"""Отклонённый гео-кандидат (для отчёта). reason: 'no_address' |
|
||||
'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit'.
|
||||
|
||||
distance_m None когда дистанцию посчитать не удалось (нет адреса/геокода/
|
||||
координат domrf).
|
||||
"""
|
||||
|
||||
domrf_obj_id: int
|
||||
domrf_comm_name: str
|
||||
objective_project_name: str
|
||||
origin: str
|
||||
reason: str
|
||||
distance_m: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoMatchReport:
|
||||
"""Итог geo-pass.
|
||||
|
||||
confirmed — подтверждённые гео-матчи (пишутся в apply_geo_matches).
|
||||
rejected — кандидаты без адреса/геокода/слишком далёкие/оставшиеся ambiguous.
|
||||
dadata_calls — сколько раз реально дёрнули DaData (для контроля квоты).
|
||||
call_limit_hit — True если упёрлись в GEO_DADATA_CALL_LIMIT (часть кандидатов
|
||||
не обработана — см. rejected c reason='call_limit').
|
||||
"""
|
||||
|
||||
confirmed: list[GeoMatch] = field(default_factory=list)
|
||||
rejected: list[GeoReject] = field(default_factory=list)
|
||||
dadata_calls: int = 0
|
||||
call_limit_hit: bool = False
|
||||
|
||||
def counts(self) -> dict[str, int]:
|
||||
return {
|
||||
"confirmed": len(self.confirmed),
|
||||
"rejected": len(self.rejected),
|
||||
"dadata_calls": self.dadata_calls,
|
||||
}
|
||||
|
||||
|
||||
# ── SQL: mode (самый частый непустой) адрес objective_lots per project_name ────
|
||||
# Адресов на проект много (по корпусам) — берём mode: GROUP BY (project_name,
|
||||
# address) → COUNT DESC, DISTINCT ON (project_name) отбирает верхнюю строку.
|
||||
# NULLIF(trim,'') отсекает пустые адреса до агрегации.
|
||||
_OBJECTIVE_ADDRESS_SQL = text("""
|
||||
SELECT DISTINCT ON (project_name)
|
||||
project_name, address
|
||||
FROM (
|
||||
SELECT
|
||||
ol.project_name AS project_name,
|
||||
NULLIF(trim(ol.address), '') AS address,
|
||||
COUNT(*) AS cnt
|
||||
FROM objective_lots ol
|
||||
WHERE ol.project_name IS NOT NULL
|
||||
AND ol.project_name <> ''
|
||||
AND NULLIF(trim(ol.address), '') IS NOT NULL
|
||||
GROUP BY ol.project_name, NULLIF(trim(ol.address), '')
|
||||
) agg
|
||||
ORDER BY project_name, cnt DESC, address
|
||||
""")
|
||||
|
||||
# ── SQL: latest-snapshot координаты domrf per obj_id (для набора obj_id) ───────
|
||||
# DISTINCT ON (obj_id) + snapshot_date DESC — берём свежую точку. Фильтр по
|
||||
# конкретным obj_id (кандидаты geo-pass), NULL-координаты отбрасываются.
|
||||
_DOMRF_COORDS_SQL = text("""
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id, o.latitude, o.longitude
|
||||
FROM domrf_kn_objects o
|
||||
WHERE o.obj_id = ANY(:obj_ids)
|
||||
AND o.latitude IS NOT NULL
|
||||
AND o.longitude IS NOT NULL
|
||||
ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
|
||||
""")
|
||||
|
||||
|
||||
def _load_project_addresses(db: Session) -> dict[str, str]:
|
||||
"""project_name → mode-адрес (самый частый непустой) из objective_lots."""
|
||||
out: dict[str, str] = {}
|
||||
for row in db.execute(_OBJECTIVE_ADDRESS_SQL).all():
|
||||
name = str(row[0])
|
||||
addr = row[1]
|
||||
if addr:
|
||||
out[name] = str(addr)
|
||||
return out
|
||||
|
||||
|
||||
def _load_domrf_coords(db: Session, obj_ids: list[int]) -> dict[int, tuple[float, float]]:
|
||||
"""obj_id → (lat, lon) latest snapshot для заданного набора obj_id."""
|
||||
if not obj_ids:
|
||||
return {}
|
||||
out: dict[int, tuple[float, float]] = {}
|
||||
for row in db.execute(_DOMRF_COORDS_SQL, {"obj_ids": obj_ids}).all():
|
||||
lat = float(row[1])
|
||||
lon = float(row[2])
|
||||
out[int(row[0])] = (lat, lon)
|
||||
return out
|
||||
|
||||
|
||||
def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M) -> GeoMatchReport:
|
||||
"""Разрешить остаток core-pass (tier_b + ambiguous) через DaData-геокод.
|
||||
|
||||
Алгоритм:
|
||||
1. find_core_matches → берём report.tier_b и report.ambiguous.
|
||||
2. Для каждого кандидата: адрес проекта (mode objective_lots.address) →
|
||||
clean_address → (lat, lon); haversine до domrf-точки.
|
||||
• tier_b: дистанция ≤ max_distance_m → confirm (гео вместо dev);
|
||||
• ambiguous: геокодим ВСЕХ кандидатов ядра; confirm только если РОВНО
|
||||
ОДИН в радиусе (иначе остаётся ambiguous);
|
||||
• нет адреса / DaData null / дистанция больше → reject c дистанцией.
|
||||
3. Кэш геокода в памяти прогона (адреса повторяются). Лимит вызовов DaData
|
||||
≤ GEO_DADATA_CALL_LIMIT — при превышении стоп с warning, остаток reject.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
max_distance_m: радиус подтверждения (метры), default 500.
|
||||
|
||||
Returns:
|
||||
GeoMatchReport (confirmed / rejected / dadata_calls / call_limit_hit).
|
||||
|
||||
Реального обращения к БД / DaData в тестах нет — оба мокаются по паттерну файла.
|
||||
"""
|
||||
core_report = find_core_matches(db)
|
||||
report = GeoMatchReport()
|
||||
|
||||
tier_b = core_report.tier_b
|
||||
ambiguous = core_report.ambiguous
|
||||
if not tier_b and not ambiguous:
|
||||
logger.info("find_geo_matches: нет tier_b/ambiguous кандидатов — nothing to do")
|
||||
return report
|
||||
|
||||
addresses = _load_project_addresses(db)
|
||||
|
||||
# ── ambiguous: нужны ВСЕ objective-кандидаты ядра, не только первый ────────
|
||||
# core-report.ambiguous хранит по одному CoreMatch на domrf-obj_id (первый
|
||||
# кандидат). Восстанавливаем полный список project_name'ов ядра из objective-
|
||||
# стороны, чтобы геокодить всех и выбрать единственного в радиусе.
|
||||
objective_by_core = _load_objective_by_core(db)
|
||||
|
||||
# obj_id'ы, для которых нужны координаты domrf (tier_b + ambiguous)
|
||||
needed_obj_ids = [m.domrf_obj_id for m in tier_b] + [m.domrf_obj_id for m in ambiguous]
|
||||
domrf_coords = _load_domrf_coords(db, needed_obj_ids)
|
||||
|
||||
# Кэш геокода в памяти прогона: address → (lat, lon) | None (None = «пробовали,
|
||||
# DaData не дал»; кэшируем и его, чтобы не жечь квоту на повтор одного адреса).
|
||||
geo_cache: dict[str, tuple[float, float] | None] = {}
|
||||
|
||||
def _geocode(addr: str) -> tuple[float, float] | None:
|
||||
"""Геокод с кэшем + лимитом вызовов. None при отсутствии/лимите.
|
||||
|
||||
Адреса objective_lots идут БЕЗ города («ул. Гидрострой, д. 2а») —
|
||||
DaData на таком отдаёт qc_geo=5 (нет координат): прод-прогон 2026-07-03
|
||||
дал confirmed=0/45 именно из-за этого. Объектив покрывает только ЕКБ →
|
||||
безопасно префиксуем городом, если его нет в строке.
|
||||
"""
|
||||
if addr in geo_cache:
|
||||
return geo_cache[addr]
|
||||
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT:
|
||||
report.call_limit_hit = True
|
||||
return None
|
||||
query = addr if "екатеринбург" in addr.lower() else f"г Екатеринбург, {addr}"
|
||||
report.dadata_calls += 1
|
||||
coords = clean_address(query)
|
||||
geo_cache[addr] = coords
|
||||
return coords
|
||||
|
||||
# ── tier_b: 1 кандидат, подтверждаем близостью ────────────────────────────
|
||||
for m in tier_b:
|
||||
domrf_pt = domrf_coords.get(m.domrf_obj_id)
|
||||
addr = addresses.get(m.objective_project_name)
|
||||
if not addr:
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "no_address"))
|
||||
continue
|
||||
if domrf_pt is None:
|
||||
# у domrf нет координат — дистанцию не посчитать; не жжём квоту на геокод
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "no_geocode"))
|
||||
continue
|
||||
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache:
|
||||
report.call_limit_hit = True
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "call_limit"))
|
||||
continue
|
||||
coords = _geocode(addr)
|
||||
if coords is None:
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "no_geocode"))
|
||||
continue
|
||||
dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1])
|
||||
if dist <= max_distance_m:
|
||||
report.confirmed.append(_geo_confirm(m, addr, dist, "tier_b"))
|
||||
else:
|
||||
report.rejected.append(_geo_reject(m, "tier_b", "too_far", dist))
|
||||
|
||||
# ── ambiguous: геокодим всех кандидатов ядра, confirm если РОВНО ОДИН близко ─
|
||||
for m in ambiguous:
|
||||
domrf_pt = domrf_coords.get(m.domrf_obj_id)
|
||||
if domrf_pt is None:
|
||||
report.rejected.append(_geo_reject(m, "ambiguous", "no_geocode"))
|
||||
continue
|
||||
candidates = objective_by_core.get(m.core, [])
|
||||
in_radius: list[tuple[str, int | None, str, float]] = []
|
||||
any_geocoded = False
|
||||
geocoded_count = 0
|
||||
for proj_name, _devs, proj_id in candidates:
|
||||
addr = addresses.get(proj_name)
|
||||
if not addr:
|
||||
continue
|
||||
if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache:
|
||||
report.call_limit_hit = True
|
||||
continue
|
||||
coords = _geocode(addr)
|
||||
if coords is None:
|
||||
continue
|
||||
any_geocoded = True
|
||||
geocoded_count += 1
|
||||
dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1])
|
||||
if dist <= max_distance_m:
|
||||
in_radius.append((proj_name, proj_id, addr, dist))
|
||||
|
||||
# Ревью 2026-07-03: «ровно один в радиусе» доверяем ТОЛЬКО когда
|
||||
# геокодились ВСЕ кандидаты ядра. Иначе «один» — артефакт отсутствия
|
||||
# данных (сосед без адреса/геокода мог быть ближе) → слепой confirm
|
||||
# запрещён, остаётся честный reject partial_geocode.
|
||||
if len(in_radius) == 1 and geocoded_count < len(candidates):
|
||||
report.rejected.append(_geo_reject(m, "ambiguous", "partial_geocode", in_radius[0][3]))
|
||||
elif len(in_radius) == 1:
|
||||
proj_name, proj_id, addr, dist = in_radius[0]
|
||||
report.confirmed.append(
|
||||
GeoMatch(
|
||||
domrf_obj_id=m.domrf_obj_id,
|
||||
domrf_comm_name=m.domrf_comm_name,
|
||||
core=m.core,
|
||||
objective_project_name=proj_name,
|
||||
objective_project_id=proj_id,
|
||||
address=addr,
|
||||
distance_m=dist,
|
||||
origin="ambiguous",
|
||||
)
|
||||
)
|
||||
elif not any_geocoded:
|
||||
reason = "call_limit" if report.call_limit_hit else "no_geocode"
|
||||
report.rejected.append(_geo_reject(m, "ambiguous", reason))
|
||||
else:
|
||||
# 0 в радиусе, или >1 в радиусе → остаётся ambiguous
|
||||
nearest = min((d for *_, d in in_radius), default=None)
|
||||
report.rejected.append(_geo_reject(m, "ambiguous", "ambiguous_multi", nearest))
|
||||
|
||||
logger.info(
|
||||
"find_geo_matches: %s call_limit_hit=%s",
|
||||
report.counts(),
|
||||
report.call_limit_hit,
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def _load_objective_by_core(db: Session) -> dict[str, list[tuple[str, set[str], int | None]]]:
|
||||
"""core → [(project_name, {devs}, project_id), ...] (как в find_core_matches)."""
|
||||
out: dict[str, list[tuple[str, set[str], int | None]]] = {}
|
||||
for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
|
||||
project_name = str(row[0])
|
||||
developers = {str(d) for d in (row[1] or []) if d}
|
||||
project_id = int(row[2]) if row[2] is not None else None
|
||||
core = normalize_complex_name(project_name)
|
||||
if not core:
|
||||
continue
|
||||
out.setdefault(core, []).append((project_name, developers, project_id))
|
||||
return out
|
||||
|
||||
|
||||
def _geo_confirm(m: CoreMatch, address: str, distance_m: float, origin: str) -> GeoMatch:
|
||||
return GeoMatch(
|
||||
domrf_obj_id=m.domrf_obj_id,
|
||||
domrf_comm_name=m.domrf_comm_name,
|
||||
core=m.core,
|
||||
objective_project_name=m.objective_project_name,
|
||||
objective_project_id=m.objective_project_id,
|
||||
address=address,
|
||||
distance_m=distance_m,
|
||||
origin=origin,
|
||||
)
|
||||
|
||||
|
||||
def _geo_reject(
|
||||
m: CoreMatch, origin: str, reason: str, distance_m: float | None = None
|
||||
) -> GeoReject:
|
||||
return GeoReject(
|
||||
domrf_obj_id=m.domrf_obj_id,
|
||||
domrf_comm_name=m.domrf_comm_name,
|
||||
objective_project_name=m.objective_project_name,
|
||||
origin=origin,
|
||||
reason=reason,
|
||||
distance_m=distance_m,
|
||||
)
|
||||
|
||||
|
||||
def apply_geo_matches(
|
||||
db: Session,
|
||||
report: GeoMatchReport,
|
||||
*,
|
||||
dry_run: bool = True,
|
||||
) -> dict[str, int]:
|
||||
"""Вставить report.confirmed в objective_complex_mapping (auto_core_geo_v6).
|
||||
|
||||
Та же per-row SAVEPOINT + ON CONFLICT (objective_complex_name, objective_group)
|
||||
DO NOTHING механика, что в apply_core_matches. Каждая строка:
|
||||
match_method='auto_core_geo_v6', match_score=0.80, is_reviewed=false,
|
||||
note с дистанцией в метрах и origin (tier_b/ambiguous).
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
report: результат find_geo_matches().
|
||||
dry_run: True (default) — только counts + первые 30 пар с дистанциями,
|
||||
БЕЗ insert.
|
||||
|
||||
Returns:
|
||||
dict: inserted, conflict_skipped, error_skipped, confirmed_total.
|
||||
"""
|
||||
counts = report.counts()
|
||||
logger.info(
|
||||
"apply_geo_matches (dry_run=%s): confirmed=%d rejected=%d dadata_calls=%d",
|
||||
dry_run,
|
||||
counts["confirmed"],
|
||||
counts["rejected"],
|
||||
counts["dadata_calls"],
|
||||
)
|
||||
for gm in report.confirmed[:30]:
|
||||
logger.info(
|
||||
" confirm[%s]: obj_id=%s core=%r domrf=%r → objective=%r (%.0f м, addr=%r)",
|
||||
gm.origin,
|
||||
gm.domrf_obj_id,
|
||||
gm.core,
|
||||
gm.domrf_comm_name,
|
||||
gm.objective_project_name,
|
||||
gm.distance_m,
|
||||
gm.address[:60],
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"inserted": 0,
|
||||
"conflict_skipped": 0,
|
||||
"error_skipped": 0,
|
||||
"confirmed_total": counts["confirmed"],
|
||||
}
|
||||
|
||||
inserted = 0
|
||||
conflict_skipped = 0
|
||||
error_skipped = 0
|
||||
for gm in report.confirmed:
|
||||
note = f"#2177 geo-pass ({gm.origin}): {gm.distance_m:.0f} м to domrf point"
|
||||
try:
|
||||
with db.begin_nested():
|
||||
result = db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO objective_complex_mapping
|
||||
(objective_complex_name, objective_project_id, domrf_obj_id,
|
||||
objective_group, match_method, match_score, is_reviewed, note)
|
||||
VALUES (
|
||||
CAST(:name AS text),
|
||||
CAST(:project_id AS bigint),
|
||||
CAST(:obj_id AS bigint),
|
||||
CAST(:group AS text),
|
||||
CAST(:method AS text),
|
||||
CAST(:score AS numeric),
|
||||
CAST(:reviewed AS boolean),
|
||||
CAST(:note AS text)
|
||||
)
|
||||
ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{
|
||||
"name": gm.objective_project_name,
|
||||
"project_id": gm.objective_project_id,
|
||||
"obj_id": gm.domrf_obj_id,
|
||||
"group": OBJECTIVE_GROUP,
|
||||
"method": GEO_MATCH_METHOD,
|
||||
"score": GEO_MATCH_SCORE,
|
||||
"reviewed": False,
|
||||
"note": note,
|
||||
},
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
inserted += 1
|
||||
else:
|
||||
conflict_skipped += 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"geo insert failed для %s ↔ %s: %s",
|
||||
gm.domrf_comm_name,
|
||||
gm.objective_project_name,
|
||||
e,
|
||||
)
|
||||
error_skipped += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
"apply_geo_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
|
||||
inserted,
|
||||
conflict_skipped,
|
||||
error_skipped,
|
||||
)
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"conflict_skipped": conflict_skipped,
|
||||
"error_skipped": error_skipped,
|
||||
"confirmed_total": counts["confirmed"],
|
||||
}
|
||||
|
||||
|
||||
def run_geo_pass(
|
||||
*, apply: bool = False, max_distance_m: float = GEO_MAX_DISTANCE_M
|
||||
) -> dict[str, int]:
|
||||
"""CLI-обёртка geo-pass: открыть Session, найти гео-матчи, (опц.) применить.
|
||||
|
||||
Дефолт — dry-run. Реальный insert только при apply=True.
|
||||
"""
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = find_geo_matches(db, max_distance_m=max_distance_m)
|
||||
return apply_geo_matches(db, report, dry_run=not apply)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="objective_backfill passes (#2177): core-pass (auto_core_dev_v5), "
|
||||
"geo-pass (auto_core_geo_v6)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--core-pass",
|
||||
action="store_true",
|
||||
help="Запустить core-name + dev-confirm проход (auto_core_dev_v5).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--geo-pass",
|
||||
action="store_true",
|
||||
help="Запустить DaData-гео проход по tier_b/ambiguous остатку core-pass "
|
||||
"(auto_core_geo_v6).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-distance-m",
|
||||
type=float,
|
||||
default=GEO_MAX_DISTANCE_M,
|
||||
help="Радиус подтверждения гео-матча в метрах (geo-pass; "
|
||||
f"default {GEO_MAX_DISTANCE_M:.0f}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Реально писать матчи в БД (по умолчанию — dry-run).",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
if not args.core_pass and not args.geo_pass:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
if args.core_pass:
|
||||
result = run_core_pass(apply=args.apply)
|
||||
logger.info("core-pass result: %s", result)
|
||||
|
||||
if args.geo_pass:
|
||||
geo_result = run_geo_pass(apply=args.apply, max_distance_m=args.max_distance_m)
|
||||
logger.info("geo-pass result: %s", geo_result)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,494 +0,0 @@
|
|||
"""Оркестратор полного PDF-отчёта ПТИЦА (эпик #2259 PR-D).
|
||||
|
||||
Собирает единый PDF `/site-finder/analysis/{cad}` из уже-персистнутых ранов + дотянутых
|
||||
на лету блоков (connection-capacity, концепция), рендерит карты (PR-C) и пишет файл на
|
||||
volume `/app/reports/` с метадата-строкой в `analysis_runs` (schema `report-pdf-1.0`).
|
||||
|
||||
ПОТОК (:func:`build_full_report`):
|
||||
1. analyze-ран (`latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)`) — нет →
|
||||
ValueError (отчёт без базового анализа бессмысленен).
|
||||
2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) — нет → best-effort
|
||||
СИНХРОННО считаем его тут же (`_ensure_forecast_run`, зеркало Celery-таски форсайта,
|
||||
~20–30с) и перечитываем; всё ещё нет (сбой/тонкие данные) → Part B (§4–§6) деградирует
|
||||
«нет данных», отчёт всё равно валиден (передаём {} в part_b).
|
||||
3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с
|
||||
теми же id уже есть И файл на месте → cache-hit, PDF не пере-рендерим.
|
||||
4. connection-capacity (`get_connection_capacity`) — best-effort, для §3-резервов.
|
||||
5. концепция — server-side генерация (как POST /concepts): `ConceptInput` из geom
|
||||
участка (дефолт count=3 comfort) → `geometry.generate`. Best-effort: упала/нет geom
|
||||
→ отчёт без §7-концепции (§5 деградирует в рыночный контекст).
|
||||
6. HTML (PR-A/B) + карты (PR-C: `render_parcel_map_png` / `render_concept_footprint_png`
|
||||
→ `embed_map_png`, PNG max_px=1400) → PDF (:func:`render_full_report_pdf`).
|
||||
6b. DOCX-вариант (PR-F, `build_full_report_docx`) из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ
|
||||
карт-PNG (НЕ рендерим карты дважды) — рядом `.docx`-файл.
|
||||
7. Запись файлов (PDF + DOCX, атомарно tmp+os.replace) + метадата-ран `report-pdf-1.0`
|
||||
(result = pdf_path/docx_path/analyze_run_id/forecast_run_id/generated_at/size_bytes/
|
||||
docx_size_bytes). Старые раны без docx_path → download?format=docx отдаёт 404.
|
||||
|
||||
WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native —
|
||||
ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.analysis_runs.repository import (
|
||||
ANALYZE_SCHEMA_VERSION,
|
||||
latest_run_for,
|
||||
persist_analysis_run,
|
||||
)
|
||||
from app.services.exporters.full_report_docx import build_full_report_docx
|
||||
from app.services.exporters.full_report_html import (
|
||||
MAP_CONCEPT_PLACEHOLDER,
|
||||
MAP_PARCEL_PLACEHOLDER,
|
||||
build_full_report_html,
|
||||
build_full_report_html_part_a,
|
||||
build_full_report_html_part_b,
|
||||
)
|
||||
from app.services.exporters.report_maps import (
|
||||
embed_map_png,
|
||||
render_concept_footprint_png,
|
||||
render_parcel_map_png,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# schema_version метадата-строки отчёта в analysis_runs. Собственная схема (НЕ analyze /
|
||||
# НЕ §22-форсайт "1.0") — по ней ищем существующий отчёт для cache-hit.
|
||||
REPORT_SCHEMA_VERSION = "report-pdf-1.0"
|
||||
|
||||
# schema_version §22-форсайт-рана (зеркало `_FORECAST_SCHEMA_VERSION` в parcels.py — НЕ
|
||||
# "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION).
|
||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||
|
||||
# Горизонты best-effort синхронного форсайта (мес). Зеркало Celery-таски
|
||||
# `forecast_site_finder_report(horizon=12)`: `_horizons_for(12)` = sorted({6,12,18,24}|{12})
|
||||
# = [6,12,18,24] (forecast.py) = orchestrator._DEFAULT_HORIZONS. Держим тот же набор,
|
||||
# чтобы «холодный» участок получил §4–§6 идентичные ленивому GET /forecast-пути.
|
||||
_FORECAST_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
|
||||
|
||||
# Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для
|
||||
# ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами.
|
||||
_MAP_MAX_PX = 1400
|
||||
|
||||
# Радиус поиска ЦП для connection-capacity (м) — тот же дефолт, что у эндпоинта
|
||||
# GET /{cad}/connection-capacity (parcels.py) и функции get_connection_capacity.
|
||||
_CONNECTION_CAPACITY_RADIUS_M = 3000
|
||||
|
||||
|
||||
def render_full_report_pdf(html: str) -> bytes:
|
||||
"""Отрендерить готовый HTML полного отчёта в PDF-байты (WeasyPrint).
|
||||
|
||||
Тонкая обёртка: WeasyPrint импортируется ЛОКАЛЬНО (тяжёлый native — не нужен при
|
||||
импорте модуля, иначе ломает pytest-сбор на macOS без GTK/Pango). Образец —
|
||||
`layout_tz_pdf.render_layout_tz_pdf`.
|
||||
|
||||
Args:
|
||||
html: полный HTML-документ (`build_full_report_html`).
|
||||
|
||||
Returns:
|
||||
PDF-байты (готовы к записи файла / FileResponse).
|
||||
"""
|
||||
from weasyprint import HTML
|
||||
|
||||
pdf_bytes: bytes = HTML(string=html).write_pdf()
|
||||
logger.info("render_full_report_pdf: %d bytes", len(pdf_bytes))
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
def _largest_polygon_geojson(geom: dict[str, Any]) -> dict[str, Any]:
|
||||
"""MultiPolygon → GeoJSON крупнейшего полигона-контура; Polygon и прочее — как есть.
|
||||
|
||||
Многоконтурный участок приходит как MultiPolygon, а concept-стек (`parse_parcel` +
|
||||
`_parcel_centroid_wkt`) принимает только Polygon → `ParcelGeometryError`. Берём
|
||||
контур с максимальной площадью (тот же приём, что generative-геометрия применяет к
|
||||
buildable-мультиполигону после буфера, geometry.py:263-265). Любой сбой парсинга /
|
||||
не-MultiPolygon → возвращаем geom без изменений (best-effort, не роняем концепцию).
|
||||
|
||||
Args:
|
||||
geom: GeoJSON-геометрия участка (Polygon / MultiPolygon / Feature-обёртка).
|
||||
|
||||
Returns:
|
||||
GeoJSON Polygon крупнейшего контура (если вход был MultiPolygon), иначе `geom`.
|
||||
"""
|
||||
geom_dict = geom.get("geometry") if geom.get("type") == "Feature" else geom
|
||||
if not isinstance(geom_dict, dict) or geom_dict.get("type") != "MultiPolygon":
|
||||
return geom
|
||||
|
||||
try:
|
||||
from shapely.geometry import mapping, shape
|
||||
|
||||
multi = shape(geom_dict)
|
||||
if multi.is_empty or not hasattr(multi, "geoms"):
|
||||
return geom
|
||||
largest = max(multi.geoms, key=lambda g: g.area)
|
||||
logger.info(
|
||||
"build_full_report: multi-contour участок — взят крупнейший контур из %d",
|
||||
len(list(multi.geoms)),
|
||||
)
|
||||
return dict(mapping(largest))
|
||||
except Exception:
|
||||
# Вырожденная/битая геометрия — отдаём как есть, concept-стек сам решит (best-effort).
|
||||
logger.exception("build_full_report: не удалось выделить крупнейший контур MultiPolygon")
|
||||
return geom
|
||||
|
||||
|
||||
def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Сгенерировать концепцию server-side как это делает POST /concepts (best-effort).
|
||||
|
||||
Концепция НЕ персистится — генерируем на лету из геометрии участка с ДЕФОЛТНОЙ
|
||||
программой (count=3 comfort — три стратегии жадной раскладки). Повторяем поток
|
||||
`create_concept` (concepts.py): рыночная цена через `_lookup_market_price` по
|
||||
центроиду → `geometry.generate` → сериализуем `ConceptOutput`. Любая ошибка (нет
|
||||
geom / вырожденный участок / сбой БД) → None: отчёт валиден без §7-концепции,
|
||||
§5 деградирует в рыночный контекст.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session (для lookup рыночной цены).
|
||||
analyze: persist-payload analyze-рана (несёт `geom_geojson` участка).
|
||||
|
||||
Returns:
|
||||
Сериализованный `ConceptOutput` (dict `{"variants": [...]}`) или None.
|
||||
"""
|
||||
geom = analyze.get("geom_geojson")
|
||||
if not isinstance(geom, dict):
|
||||
logger.info("build_full_report: analyze-payload без geom_geojson → §7-концепция пропущена")
|
||||
return None
|
||||
|
||||
# Многоконтурный участок → geom = MultiPolygon, а concept-стек (parse_parcel +
|
||||
# _parcel_centroid_wkt через _parse_polygon) принимает ТОЛЬКО Polygon и роняет
|
||||
# ParcelGeometryError("expected Polygon, got MultiPolygon"). Берём крупнейший контур —
|
||||
# ровно как generative-геометрия после буфера (geometry.py:263-265). Иначе §7 и
|
||||
# market-price молча деградируют на любом мультиконтуре.
|
||||
geom = _largest_polygon_geojson(geom)
|
||||
|
||||
try:
|
||||
# Lazy import — тяжёлый generative-стек не нужен на module-load; concepts-хелперы
|
||||
# цены живут в API-слое (он знает БД), переиспользуем ИМЕННО их (single source).
|
||||
from app.api.v1.concepts import _lookup_market_price, _parcel_centroid_wkt
|
||||
from app.schemas.concept import ConceptInput, ConceptOutput
|
||||
from app.services.generative import geometry
|
||||
|
||||
payload = ConceptInput(parcel_geojson=geom) # дефолт: housing_class="comfort", 3 стратегии
|
||||
|
||||
market_price: float | None = None
|
||||
price_source = "class_norm"
|
||||
try:
|
||||
wkt_point = _parcel_centroid_wkt(payload)
|
||||
market_price, price_source = _lookup_market_price(db, wkt_point)
|
||||
except Exception as exc:
|
||||
# Рыночная цена best-effort — деградируем в норматив класса, не роняем концепцию.
|
||||
logger.warning("build_full_report: market-price lookup упал, class_norm: %s", exc)
|
||||
|
||||
variants = geometry.generate(
|
||||
payload,
|
||||
market_price_per_sqm=market_price,
|
||||
price_source=price_source,
|
||||
)
|
||||
result: dict[str, Any] = ConceptOutput(variants=variants).model_dump()
|
||||
logger.info("build_full_report: концепция сгенерирована (%d вариантов)", len(variants))
|
||||
return result
|
||||
except Exception:
|
||||
# Вырожденный участок / сбой generative-стека — отчёт валиден БЕЗ §7-концепции.
|
||||
logger.exception("build_full_report: генерация концепции упала → §7 без концепции")
|
||||
return None
|
||||
|
||||
|
||||
def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None:
|
||||
"""Дотянуть connection-capacity для §3-резервов (best-effort).
|
||||
|
||||
connection-capacity НЕ в persist-payload (ленивый эндпоинт) — тянем `get_connection_capacity`
|
||||
как это делает GET /{cad}/connection-capacity. Ошибка (участок без geom / сбой БД) → None:
|
||||
§3 деградирует к тому, что есть в analyze-payload (OSM-сети + НСПД-сооружения).
|
||||
"""
|
||||
try:
|
||||
from app.services.site_finder.connection_capacity_lookup import get_connection_capacity
|
||||
|
||||
return get_connection_capacity(db, cad, _CONNECTION_CAPACITY_RADIUS_M)
|
||||
except Exception:
|
||||
logger.exception("build_full_report: connection-capacity lookup упал → §3 без резервов")
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_forecast_run(
|
||||
db: Session,
|
||||
cad: str,
|
||||
analyze_row: Any,
|
||||
analyze: dict[str, Any],
|
||||
) -> None:
|
||||
"""Best-effort синхронно посчитать §22-форсайт для «холодного» участка (#2259 gap).
|
||||
|
||||
§22-форсайт-ран ("1.0") существует ТОЛЬКО если пользователь открывал страницу участка
|
||||
(§22 строится лениво GET /forecast-поллингом Celery-таской `forecast_site_finder_report`).
|
||||
На холодном участке полный отчёт выходил БЕЗ §4–§6. Здесь — тот же compute+persist, что
|
||||
делает Celery-таска, но СИНХРОННО и ПРЯМЫМ вызовом: мы УЖЕ внутри worker'а
|
||||
(build_full_report_task), поэтому НЕ .delay — считаем inline (~20–30с) и персистим тем же
|
||||
контрактом, чтобы последующий `latest_run_for("1.0")` в build_full_report поймал свежий ран
|
||||
(его новый id корректно войдёт в кэш-ключ отчёта → cache-miss → §4–§6 попадут в PDF).
|
||||
|
||||
Зеркало `forecast_site_finder_report(horizon=12)` (workers/tasks/forecast.py):
|
||||
• horizons = `_FORECAST_HORIZONS` (= `_horizons_for(12)` = [6,12,18,24]);
|
||||
• district = денорм-колонка рана → fallback analyze["district"]["district_name"];
|
||||
• `build_site_finder_report(...)` → `report.as_dict()` → `persist_analysis_run(...,
|
||||
schema_version=d["schema_version"], status="done", ...)`.
|
||||
|
||||
Best-effort: ЛЮБОЙ сбой/долгий compute → logger.warning + return (НЕ exception — форсайт
|
||||
может честно не собраться на тонких данных, GlitchTip-шум не нужен). Тогда отчёт, как и
|
||||
раньше, выйдет без §4–§6 (part_b деградирует «нет данных»).
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session (та же, что у build_full_report — свою НЕ открываем).
|
||||
cad: кадастровый номер участка.
|
||||
analyze_row: Row analyze-рана (несёт денорм `district`).
|
||||
analyze: persist-payload analyze-рана (district-fallback + competitors для сегмента).
|
||||
"""
|
||||
try:
|
||||
# Lazy import — тяжёлый forecasting-стек не нужен на module-load (как concept-стек).
|
||||
from app.services.forecasting.orchestrator import build_site_finder_report
|
||||
|
||||
district = analyze_row.district or (analyze.get("district") or {}).get("district_name")
|
||||
logger.info(
|
||||
"build_full_report: холодный участок cad=%s — best-effort синхронный §22-форсайт "
|
||||
"(district=%s horizons=%s)",
|
||||
cad,
|
||||
district,
|
||||
_FORECAST_HORIZONS,
|
||||
)
|
||||
report = build_site_finder_report(
|
||||
db,
|
||||
analyze=analyze,
|
||||
cad_num=cad,
|
||||
district=district,
|
||||
horizons=_FORECAST_HORIZONS,
|
||||
)
|
||||
d = report.as_dict()
|
||||
new_id = persist_analysis_run(
|
||||
db,
|
||||
cad_num=cad,
|
||||
result=d,
|
||||
params={"horizon": 12, "source": "full-report-inline-forecast"},
|
||||
district=district,
|
||||
confidence=(d.get("confidence") or {}).get("level"),
|
||||
status="done",
|
||||
schema_version=d["schema_version"], # "1.0" (SiteFinderReport._SCHEMA_VERSION)
|
||||
created_by=None,
|
||||
segment=(d.get("meta") or {}).get("segment"),
|
||||
)
|
||||
logger.info("build_full_report: §22-форсайт посчитан inline cad=%s run_id=%s", cad, new_id)
|
||||
except Exception:
|
||||
# Форсайт может честно не собраться (тонкие данные / сбой §9.x-шва) или занять
|
||||
# слишком долго — деградируем в отчёт без §4–§6 (warning, НЕ exception: держим
|
||||
# GlitchTip-шум в узде, ведёт себя как ленивая Celery-таска, которая тоже best-effort).
|
||||
logger.warning(
|
||||
"build_full_report: inline §22-форсайт не собрался cad=%s → отчёт без §4–§6",
|
||||
cad,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _find_cached_report(
|
||||
db: Session,
|
||||
cad: str,
|
||||
analyze_run_id: int | None,
|
||||
forecast_run_id: int | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Найти готовый метадата-ран `report-pdf-1.0` с тем же кэш-ключом + существующим файлом.
|
||||
|
||||
Кэш-ключ = (analyze_run_id, forecast_run_id). Совпали ОБА id И файл `pdf_path` на
|
||||
месте → возвращаем result метадата-рана (cache-hit, не пере-рендерим). Иначе None.
|
||||
"""
|
||||
row = latest_run_for(db, cad, schema_version=REPORT_SCHEMA_VERSION)
|
||||
if row is None:
|
||||
return None
|
||||
result = row.result if isinstance(row.result, dict) else {}
|
||||
if (
|
||||
result.get("analyze_run_id") == analyze_run_id
|
||||
and result.get("forecast_run_id") == forecast_run_id
|
||||
):
|
||||
pdf_path = result.get("pdf_path")
|
||||
if isinstance(pdf_path, str) and os.path.exists(pdf_path):
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _cad_safe(cad: str) -> str:
|
||||
"""Кад.номер → безопасный компонент имени файла (whitelist).
|
||||
|
||||
Кад.номер приходит из URL-path — жёстко ограничиваем алфавит `[0-9:]` (цифры +
|
||||
разделители кад.номера РФ), ВСЁ прочее вырезаем, затем `:` → `_`. Так в имя файла
|
||||
физически не попадут `/`, `..`, пробелы или спецсимволы (path-traversal / инъекция
|
||||
в путь), даже если path-param окажется мусорным.
|
||||
"""
|
||||
return re.sub(r"[^0-9:]", "", cad).replace(":", "_")
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes) -> None:
|
||||
"""Атомарно записать байты в `path`: `.<pid>.tmp` рядом → `os.replace`.
|
||||
|
||||
Два конкурентных POST в один день целятся в ОДИН путь (имя несёт только дату) —
|
||||
прямой `write_bytes` мог бы interleave-писать байты обоих рендеров в один файл
|
||||
(битый вывод). `os.replace` атомарен в пределах одной FS → download всегда видит
|
||||
целый файл (свой или чужой). Общий для PDF и DOCX (тот же приём).
|
||||
"""
|
||||
tmp_path = path.with_suffix(f".{os.getpid()}.tmp")
|
||||
tmp_path.write_bytes(data)
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
|
||||
def build_full_report(db: Session, cad: str) -> dict[str, Any]:
|
||||
"""Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D.
|
||||
|
||||
Оркестрация: analyze-ран (нет → ValueError) → forecast-ран (нет → Part B «нет данных»)
|
||||
→ cache-check по (analyze_run_id, forecast_run_id) → connection-capacity (§3-резервы,
|
||||
best-effort) → концепция server-side (best-effort) → HTML (PR-A/B) + карты (PR-C) →
|
||||
PDF (WeasyPrint) → запись файла на volume + метадата-ран `report-pdf-1.0`.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session.
|
||||
cad: кадастровый номер участка.
|
||||
|
||||
Returns:
|
||||
Метадата отчёта: {pdf_path, analyze_run_id, forecast_run_id, generated_at,
|
||||
size_bytes} (+ `cache_hit: True` если вернули существующий файл).
|
||||
|
||||
Raises:
|
||||
ValueError: нет analyze-рана для участка (нечего отчётировать).
|
||||
"""
|
||||
analyze_row = latest_run_for(db, cad, schema_version=ANALYZE_SCHEMA_VERSION)
|
||||
if analyze_row is None:
|
||||
raise ValueError(f"нет analyze-рана для {cad!r} — сначала запустите /analyze")
|
||||
analyze: dict[str, Any] = analyze_row.result or {}
|
||||
analyze_run_id = int(analyze_row.id)
|
||||
|
||||
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||
if forecast_row is None:
|
||||
# Холодный участок: §22-форсайт-ран ("1.0") строится лениво GET /forecast-поллингом
|
||||
# и на не-открытом участке отсутствует → отчёт выходил без §4–§6. Best-effort
|
||||
# считаем его СИНХРОННО прямо тут (мы в worker'е) и перечитываем — самодостаточность
|
||||
# отчёта важнее +20–30с (как §7-концепция генерится в оркестраторе). Сбой/долго →
|
||||
# forecast_row остаётся None, part_b деградирует «нет данных» (как раньше).
|
||||
_ensure_forecast_run(db, cad, analyze_row, analyze)
|
||||
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||
forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {}
|
||||
forecast_run_id = int(forecast_row.id) if forecast_row is not None else None
|
||||
|
||||
# Cache-hit: тот же (analyze, forecast) ран-ключ + файл на месте → не пере-рендерим.
|
||||
cached = _find_cached_report(db, cad, analyze_run_id, forecast_run_id)
|
||||
if cached is not None:
|
||||
logger.info(
|
||||
"build_full_report: cache-hit cad=%s analyze=%s forecast=%s path=%s",
|
||||
cad,
|
||||
analyze_run_id,
|
||||
forecast_run_id,
|
||||
cached.get("pdf_path"),
|
||||
)
|
||||
return {**cached, "cache_hit": True}
|
||||
|
||||
# connection-capacity (§3-резервы) + концепция (§7) — оба best-effort.
|
||||
connection_capacity = _get_connection_capacity(db, cad)
|
||||
concept = _generate_concept_result(db, analyze)
|
||||
|
||||
# Адрес участка для титула (egrn.address; None → строка адреса не рисуется).
|
||||
egrn = analyze.get("egrn") if isinstance(analyze.get("egrn"), dict) else {}
|
||||
address = egrn.get("address") if isinstance(egrn, dict) else None
|
||||
generated_at = _dt.datetime.now(_dt.UTC)
|
||||
generated_at_ru = generated_at.strftime("%d.%m.%Y")
|
||||
|
||||
# HTML (PR-A/B): §1–§3 (+ connection-capacity в §3) и §4–§7 (пустой forecast → «нет данных»).
|
||||
part_a = build_full_report_html_part_a(
|
||||
analyze, cad=cad, connection_capacity=connection_capacity
|
||||
)
|
||||
part_b = build_full_report_html_part_b(forecast, concept, cad=cad, analyze_result=analyze)
|
||||
html = build_full_report_html(
|
||||
part_a,
|
||||
part_b,
|
||||
cad=cad,
|
||||
address=address if isinstance(address, str) else None,
|
||||
generated_at=generated_at_ru,
|
||||
)
|
||||
|
||||
# Карты (PR-C): участок (§1/§3) + концепция (§7, лучший вариант). Оба graceful:
|
||||
# None → embed_map_png ставит плашку «карта недоступна».
|
||||
parcel_png = render_parcel_map_png(analyze, max_px=_MAP_MAX_PX)
|
||||
html = embed_map_png(html, MAP_PARCEL_PLACEHOLDER, parcel_png)
|
||||
|
||||
concept_png: bytes | None = None
|
||||
if isinstance(concept, dict):
|
||||
variants = concept.get("variants")
|
||||
if isinstance(variants, list) and variants and isinstance(variants[0], dict):
|
||||
concept_png = render_concept_footprint_png(
|
||||
variants[0], analyze.get("geom_geojson"), max_px=_MAP_MAX_PX
|
||||
)
|
||||
html = embed_map_png(html, MAP_CONCEPT_PLACEHOLDER, concept_png)
|
||||
|
||||
pdf_bytes = render_full_report_pdf(html)
|
||||
|
||||
# DOCX-вариант (PR-F): из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (parcel_png /
|
||||
# concept_png уже отрендерены выше — НЕ рендерим карты дважды). Зеркалит §1–§7 PDF.
|
||||
docx_bytes = build_full_report_docx(
|
||||
analyze,
|
||||
forecast,
|
||||
concept,
|
||||
connection_capacity,
|
||||
cad=cad,
|
||||
address=address if isinstance(address, str) else None,
|
||||
generated_at=generated_at_ru,
|
||||
parcel_map_png=parcel_png,
|
||||
concept_map_png=concept_png,
|
||||
)
|
||||
|
||||
# Запись файлов на volume + метадата-ран. Каталог создаём (parents, exist_ok).
|
||||
reports_dir = Path(settings.reports_dir)
|
||||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
base_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}"
|
||||
pdf_path = reports_dir / f"{base_name}.pdf"
|
||||
docx_path = reports_dir / f"{base_name}.docx"
|
||||
# Атомарная запись обоих файлов (tmp+os.replace — см. `_atomic_write`).
|
||||
_atomic_write(pdf_path, pdf_bytes)
|
||||
_atomic_write(docx_path, docx_bytes)
|
||||
size_bytes = len(pdf_bytes)
|
||||
docx_size_bytes = len(docx_bytes)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pdf_path": str(pdf_path),
|
||||
"docx_path": str(docx_path),
|
||||
"analyze_run_id": analyze_run_id,
|
||||
"forecast_run_id": forecast_run_id,
|
||||
"generated_at": generated_at.isoformat(),
|
||||
"size_bytes": size_bytes,
|
||||
"docx_size_bytes": docx_size_bytes,
|
||||
}
|
||||
|
||||
# Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — файлы
|
||||
# уже записаны, просто следующий вызов не поймает cache-hit и пере-рендерит).
|
||||
persist_analysis_run(
|
||||
db,
|
||||
cad_num=cad,
|
||||
result=result,
|
||||
params={"source": "report-pdf-task"},
|
||||
district=analyze_row.district,
|
||||
confidence=None,
|
||||
status="done",
|
||||
schema_version=REPORT_SCHEMA_VERSION,
|
||||
created_by=None,
|
||||
)
|
||||
logger.info(
|
||||
"build_full_report: cad=%s pdf=%s (%d B) docx=%s (%d B) analyze=%s forecast=%s",
|
||||
cad,
|
||||
pdf_path,
|
||||
size_bytes,
|
||||
docx_path,
|
||||
docx_size_bytes,
|
||||
analyze_run_id,
|
||||
forecast_run_id,
|
||||
)
|
||||
return result
|
||||
|
|
@ -14,20 +14,15 @@ from app.schemas.parcel import BestLayoutsResponse
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_layout_tz_html(
|
||||
def render_layout_tz_pdf(
|
||||
response: BestLayoutsResponse,
|
||||
*,
|
||||
cad_num: str,
|
||||
parcel_address: str | None = None,
|
||||
radius_km: float,
|
||||
time_window: str,
|
||||
) -> str:
|
||||
"""Собрать HTML ТЗ из best-layouts response (PURE — без WeasyPrint/БД/сети).
|
||||
|
||||
Вынесено из :func:`render_layout_tz_pdf`, чтобы HTML-разметку (наличие блоков,
|
||||
RU-микрокопию, форматирование ₽/м²) можно было проверять юнит-тестами БЕЗ
|
||||
native GTK/Pango (WeasyPrint на macOS/Windows отсутствует). PDF-обёртка ниже
|
||||
просто рендерит этот HTML.
|
||||
) -> bytes:
|
||||
"""Render ТЗ PDF от best-layouts response.
|
||||
|
||||
Args:
|
||||
response: BestLayoutsResponse от /best-layouts endpoint
|
||||
|
|
@ -37,7 +32,7 @@ def build_layout_tz_html(
|
|||
time_window: окно анализа (last_month/quarter/year)
|
||||
|
||||
Returns:
|
||||
HTML-строка документа (готова для WeasyPrint).
|
||||
PDF bytes (готово для StreamingResponse)
|
||||
"""
|
||||
today = dt.date.today().strftime("%d.%m.%Y")
|
||||
safe_cad = _html.escape(cad_num)
|
||||
|
|
@ -50,18 +45,6 @@ def build_layout_tz_html(
|
|||
return "<td>—</td>"
|
||||
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</td>"
|
||||
|
||||
def _price_m2_cell(val: float | None) -> str:
|
||||
"""Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful)."""
|
||||
if val is None:
|
||||
return "<td>—</td>"
|
||||
return f"<td>{val:,.0f}".replace(",", " ") + " ₽/м²</td>"
|
||||
|
||||
def _area_cell(val: float | None) -> str:
|
||||
"""Ячейка площади, м² (1 знак). None → «—» (graceful)."""
|
||||
if val is None:
|
||||
return "<td>—</td>"
|
||||
return f"<td>{val:.1f} м²</td>"
|
||||
|
||||
# Top layouts table rows
|
||||
top_rows = "".join(
|
||||
"<tr>"
|
||||
|
|
@ -87,42 +70,6 @@ def build_layout_tz_html(
|
|||
for m in response.recommendation_for_tz.mix
|
||||
)
|
||||
|
||||
# §4.2 «По предложению (без темпа продаж)» (#2177 шаг 3). Аддитивный fallback-блок:
|
||||
# структура текущего предложения (комплекс/тип/площадь/лотов/медиана ₽/м²) по
|
||||
# комплексам, сматченным по ядру имени, но БЕЗ velocity. Graceful — на старых
|
||||
# снапшотах поле отсутствует / пустой список (default []), тогда секцию не рисуем.
|
||||
supply_only = getattr(response, "supply_only_layouts", None) or []
|
||||
supply_only_rows = "".join(
|
||||
"<tr>"
|
||||
f"<td>{_html.escape(s.project_name)}</td>"
|
||||
f"<td>{_html.escape(s.room_bucket)}</td>"
|
||||
f"<td>{_html.escape(s.area_bin)}</td>"
|
||||
f"{_area_cell(s.median_area_m2)}"
|
||||
f"<td>{s.lots_count}</td>"
|
||||
f"{_price_m2_cell(s.median_price_m2)}"
|
||||
"</tr>"
|
||||
for s in supply_only
|
||||
)
|
||||
supply_only_section = (
|
||||
f"""
|
||||
<h2>По предложению (без темпа продаж)</h2>
|
||||
<p class="caveat">
|
||||
Комплексы сматчены по ядру имени на объявления Объектива, но по ним НЕТ сделок
|
||||
(темпа продаж) — ниже только структура остатков в продаже (objective_lots),
|
||||
БЕЗ ранжирования по скорости. Дополняет топ продаж выше, не заменяет его.
|
||||
</p>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Комплекс</th><th>Комнаты</th><th>Площадь</th>
|
||||
<th>Ср. площадь</th><th>Лотов в продаже</th><th>Медиана, ₽/м²</th>
|
||||
</tr></thead>
|
||||
<tbody>{supply_only_rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
if supply_only
|
||||
else ""
|
||||
)
|
||||
|
||||
rec = response.recommendation_for_tz
|
||||
safe_rationale = _html.escape(rec.rationale_text)
|
||||
weighted_price = (
|
||||
|
|
@ -148,8 +95,6 @@ def build_layout_tz_html(
|
|||
th {{ background: #f5f5f5; font-weight: bold; }}
|
||||
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
|
||||
margin: 1em 0; }}
|
||||
.caveat {{ background: #fffbeb; padding: 8px 10px; border-left: 3px solid #c9a132;
|
||||
margin: 0.6em 0; color: #7a5c12; font-size: 9.5pt; }}
|
||||
.footer {{ margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd;
|
||||
color: #888; font-size: 9pt; }}
|
||||
.confidence-high {{ color: #2a8c2a; }}
|
||||
|
|
@ -188,11 +133,11 @@ def build_layout_tz_html(
|
|||
</tr></thead>
|
||||
<tbody>{top_rows}</tbody>
|
||||
</table>
|
||||
{supply_only_section}
|
||||
|
||||
<h2>Качество данных</h2>
|
||||
<p>
|
||||
Покрытие: {dq.objects_with_velocity_data} из
|
||||
{dq.objects_total_in_radius} комплексов с данными velocity
|
||||
{dq.objects_total_in_radius} ЖК с данными velocity
|
||||
({dq.velocity_coverage_pct:.1f}%)
|
||||
</p>
|
||||
<p>
|
||||
|
|
@ -209,40 +154,6 @@ def build_layout_tz_html(
|
|||
</body>
|
||||
</html>"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def render_layout_tz_pdf(
|
||||
response: BestLayoutsResponse,
|
||||
*,
|
||||
cad_num: str,
|
||||
parcel_address: str | None = None,
|
||||
radius_km: float,
|
||||
time_window: str,
|
||||
) -> bytes:
|
||||
"""Render ТЗ PDF от best-layouts response.
|
||||
|
||||
Тонкая обёртка над :func:`build_layout_tz_html` — собирает HTML (PURE) и
|
||||
рендерит его WeasyPrint'ом в PDF-байты.
|
||||
|
||||
Args:
|
||||
response: BestLayoutsResponse от /best-layouts endpoint
|
||||
cad_num: кадастровый номер участка
|
||||
parcel_address: optional human address (если known через geocoder)
|
||||
radius_km: радиус анализа конкурентов
|
||||
time_window: окно анализа (last_month/quarter/year)
|
||||
|
||||
Returns:
|
||||
PDF bytes (готово для StreamingResponse)
|
||||
"""
|
||||
html = build_layout_tz_html(
|
||||
response,
|
||||
cad_num=cad_num,
|
||||
parcel_address=parcel_address,
|
||||
radius_km=radius_km,
|
||||
time_window=time_window,
|
||||
)
|
||||
|
||||
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
|
||||
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
|
||||
from weasyprint import HTML
|
||||
|
|
|
|||
|
|
@ -1,386 +0,0 @@
|
|||
"""Серверный статик-рендер карт для PDF-отчёта ПТИЦА (эпик #2259 PR-C).
|
||||
|
||||
Рендерит два вида PNG-карт БЕЗ браузера (headless matplotlib + geopandas + contextily):
|
||||
|
||||
• :func:`render_parcel_map_png` — карта участка (§1/§3): полигон участка (заливка +
|
||||
контур), поверх — риск-зоны НСПД / красные линии / ЗОУИТ-пересечения (если геометрии
|
||||
присутствуют в persist-payload) + конкуренты точками. Подложка — OSM-тайлы через
|
||||
contextily; auto-bbox = участок + буфер.
|
||||
• :func:`render_concept_footprint_png` — карта концепции (§7): контур участка + пятна
|
||||
застройки (footprint'ы корпусов из ``ConceptVariant.buildings_geojson``) с подписью
|
||||
этажности (``properties.floors``).
|
||||
|
||||
Плюс :func:`embed_map_png` — подстановка ``<img src="data:image/png;base64,…">`` вместо
|
||||
строки-плейсхолдера в готовом HTML (``png=None`` → плашка «карта недоступна»).
|
||||
|
||||
CRS-контракт: все входные геометрии — EPSG:4326 (GeoJSON ``geom_geojson`` /
|
||||
``geometry_geojson``, WKT ``geom_wkt`` в 4326, конкуренты lat/lng). Рисуем в Web Mercator
|
||||
(EPSG:3857), т.к. OSM-тайлы contextily идут в 3857 — иначе подложка не совпадёт со слоями.
|
||||
|
||||
GRACEFUL: нет ``geom_geojson`` → ``None`` (нечего рисовать). Тайлы OSM недоступны/таймаут →
|
||||
белый фон + слои, PNG всё равно возвращается (никогда не роняем экспорт из-за сети).
|
||||
Битые отдельные геометрии слоёв пропускаются с ``logger.warning``, не валят всю карту.
|
||||
|
||||
HEADLESS: ``matplotlib.use("Agg")`` вызывается на уровне модуля ДО импорта pyplot — импорт
|
||||
``report_maps`` не требует X11/display (запускается в Celery-воркере/Docker без GUI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import matplotlib
|
||||
|
||||
# Headless-бэкенд ДО импорта pyplot — модуль импортируется в Celery-воркере без display.
|
||||
matplotlib.use("Agg")
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.figure import Figure
|
||||
from shapely.geometry import Point, shape
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Константы рендера ──────────────────────────────────────────────────────────
|
||||
_WGS84 = "EPSG:4326"
|
||||
_WEB_MERCATOR = "EPSG:3857"
|
||||
|
||||
# Буфер вокруг участка при auto-bbox (доля от большей стороны bbox). Даёт «воздух»
|
||||
# по краям, чтобы контур участка не липнул к рамке и был виден контекст подложки.
|
||||
_BBOX_PAD_FRAC: float = 0.25
|
||||
|
||||
# Таймаут ожидания OSM-тайлов (сек). Тайлы тянутся синхронно внутри contextily —
|
||||
# оборачиваем в поток с bounded-таймаутом, чтобы медленный/недоступный tile-server
|
||||
# не подвешивал воркер. Превышение → graceful fallback на белый фон.
|
||||
_BASEMAP_TIMEOUT_S: float = 10.0
|
||||
|
||||
# Базовый физический размер фигуры (дюймы). Итоговый пиксель-размер = fig_inches * dpi,
|
||||
# ограничивается сверху ``max_px`` пересчётом dpi (см. ``_figure_to_png``).
|
||||
_FIG_INCHES: float = 6.4
|
||||
|
||||
# Палитра слоёв (edgecolor, facecolor, alpha) — консистентно с семантикой отчёта.
|
||||
_PARCEL_STYLE = {"edgecolor": "#1d4ed8", "facecolor": "#3b82f6", "alpha": 0.28, "linewidth": 1.8}
|
||||
_RISK_STYLE = {"edgecolor": "#b91c1c", "facecolor": "#ef4444", "alpha": 0.30, "linewidth": 1.0}
|
||||
_REDLINE_STYLE = {"color": "#dc2626", "linewidth": 1.6, "linestyle": "--"}
|
||||
_ZOUIT_STYLE = {"edgecolor": "#b45309", "facecolor": "#f59e0b", "alpha": 0.22, "linewidth": 1.0}
|
||||
_OPPORTUNITY_STYLE = {
|
||||
"edgecolor": "#15803d",
|
||||
"facecolor": "#22c55e",
|
||||
"alpha": 0.20,
|
||||
"linewidth": 0.9,
|
||||
}
|
||||
_COMPETITOR_STYLE = {"color": "#7c3aed", "marker": "o", "markersize": 5.0, "linestyle": "none"}
|
||||
_CONCEPT_FOOTPRINT_STYLE = {
|
||||
"edgecolor": "#0f766e",
|
||||
"facecolor": "#14b8a6",
|
||||
"alpha": 0.55,
|
||||
"linewidth": 1.2,
|
||||
}
|
||||
|
||||
_MAP_UNAVAILABLE_HTML = (
|
||||
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
|
||||
"в отчёте</div>"
|
||||
)
|
||||
|
||||
|
||||
# ── Парсинг входных геометрий (EPSG:4326) ──────────────────────────────────────
|
||||
def _geom_from_geojson(geojson: dict[str, Any] | None) -> BaseGeometry | None:
|
||||
"""GeoJSON-geometry dict → shapely-geометрия (EPSG:4326). Битый вход → None+warning."""
|
||||
if not isinstance(geojson, dict):
|
||||
return None
|
||||
try:
|
||||
geom = shape(geojson)
|
||||
except Exception as exc: # любой битый GeoJSON: пропустить слой, не валить всю карту
|
||||
logger.warning("report_maps: не разобрал GeoJSON-геометрию: %s", exc)
|
||||
return None
|
||||
if geom.is_empty:
|
||||
return None
|
||||
return geom
|
||||
|
||||
|
||||
def _geom_from_wkt(wkt: str | None) -> BaseGeometry | None:
|
||||
"""WKT-строка (EPSG:4326) → shapely-геометрия. Битый вход → None+warning."""
|
||||
if not wkt or not isinstance(wkt, str):
|
||||
return None
|
||||
try:
|
||||
from shapely import wkt as shapely_wkt
|
||||
|
||||
geom = shapely_wkt.loads(wkt)
|
||||
except Exception as exc: # битый WKT слоя: пропустить геометрию, не валить всю карту
|
||||
logger.warning("report_maps: не разобрал WKT-геометрию: %s", exc)
|
||||
return None
|
||||
if geom.is_empty:
|
||||
return None
|
||||
return geom
|
||||
|
||||
|
||||
def _to_mercator(geoms: list[BaseGeometry]) -> Any:
|
||||
"""Список shapely-геометрий (EPSG:4326) → GeoSeries в Web Mercator (EPSG:3857).
|
||||
|
||||
Импорт geopandas — локальный (тяжёлый), вызывается только когда есть что рисовать.
|
||||
"""
|
||||
import geopandas as gpd
|
||||
|
||||
series = gpd.GeoSeries(geoms, crs=_WGS84)
|
||||
return series.to_crs(_WEB_MERCATOR)
|
||||
|
||||
|
||||
# ── Basemap (OSM-тайлы) с bounded-таймаутом и graceful fallback ────────────────
|
||||
def _add_basemap(ax: Any) -> bool:
|
||||
"""Подложить OSM-тайлы под слои с таймаутом. True — легли, False — fallback (белый фон).
|
||||
|
||||
contextily тянет тайлы синхронно (сеть) — оборачиваем в поток с таймаутом, чтобы
|
||||
недоступный tile-сервер не подвешивал воркер. Любая ошибка/таймаут → белый фон,
|
||||
слои остаются видны, PNG всё равно рендерится.
|
||||
"""
|
||||
import contextily as cx
|
||||
|
||||
def _fetch() -> None:
|
||||
cx.add_basemap(ax, crs=_WEB_MERCATOR, source=cx.providers.OpenStreetMap.Mapnik)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)
|
||||
return True
|
||||
except FuturesTimeoutError:
|
||||
logger.warning(
|
||||
"report_maps: OSM basemap timeout (%.0fs) — fallback белый фон", _BASEMAP_TIMEOUT_S
|
||||
)
|
||||
return False
|
||||
except Exception as exc: # тайлы недоступны: graceful fallback на белый фон, не валим экспорт
|
||||
logger.warning("report_maps: OSM basemap недоступен (%s) — fallback белый фон", exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── Общие хелперы фигуры ───────────────────────────────────────────────────────
|
||||
@contextmanager
|
||||
def _managed_figure() -> Iterator[tuple[Figure, Any]]:
|
||||
"""Контекст-менеджер фигуры: создать axes, ГАРАНТИРОВАННО закрыть в finally.
|
||||
|
||||
pyplot держит созданные фигуры в глобальном реестре (``plt.get_fignums()``) до
|
||||
явного ``plt.close``. В долгоживущем Celery-воркере любое исключение между
|
||||
созданием фигуры и ``savefig`` без close оставляло бы residual-окно → растущий
|
||||
RSS (утечка на 1 фигуру за каждый упавший рендер). ``finally`` закрывает фигуру
|
||||
на ЛЮБОМ пути выхода — успех, per-geometry-ошибка, неожиданный raise.
|
||||
"""
|
||||
fig = plt.figure(figsize=(_FIG_INCHES, _FIG_INCHES))
|
||||
try:
|
||||
ax = fig.add_subplot(1, 1, 1)
|
||||
ax.set_axis_off()
|
||||
ax.set_aspect("equal")
|
||||
yield fig, ax
|
||||
finally:
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _apply_bbox(ax: Any, parcel_merc: Any) -> None:
|
||||
"""Выставить границы axes по bbox участка + буфер (``_BBOX_PAD_FRAC``)."""
|
||||
minx, miny, maxx, maxy = parcel_merc.total_bounds
|
||||
span = max(maxx - minx, maxy - miny) or 1.0
|
||||
pad = span * _BBOX_PAD_FRAC
|
||||
ax.set_xlim(minx - pad, maxx + pad)
|
||||
ax.set_ylim(miny - pad, maxy + pad)
|
||||
|
||||
|
||||
def _figure_to_png(fig: Figure, max_px: int) -> bytes:
|
||||
"""Фигуру → PNG-bytes с dpi, подобранным так, чтобы длинная сторона ≤ ``max_px``.
|
||||
|
||||
Закрытие фигуры — НЕ здесь: им владеет ``_managed_figure`` (finally покрывает и
|
||||
путь, где ``savefig`` сам кинул исключение).
|
||||
"""
|
||||
dpi = max(72.0, min(300.0, float(max_px) / _FIG_INCHES))
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.05)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── Публичный API ──────────────────────────────────────────────────────────────
|
||||
def render_parcel_map_png(analyze_result: dict[str, Any], *, max_px: int = 1600) -> bytes | None:
|
||||
"""Карта участка + слои НСПД + конкуренты → PNG-bytes. Нет ``geom_geojson`` → None.
|
||||
|
||||
Args:
|
||||
analyze_result: persist-payload ``analysis_runs.result`` (schema analyze-1.0).
|
||||
Читаемые ключи с геометрией: ``geom_geojson`` (обязателен), ``nspd_risk_zones``,
|
||||
``nspd_red_lines``, ``nspd_opportunity_parcels`` (WKT ``geom_wkt``),
|
||||
``nspd_zouit_overlaps`` (``geometry_geojson``), ``competitors`` (``lat``/``lng``).
|
||||
max_px: верхняя граница длинной стороны PNG в пикселях.
|
||||
|
||||
Returns:
|
||||
PNG-bytes, либо None если геометрии участка нет (нечего рисовать).
|
||||
"""
|
||||
parcel = _geom_from_geojson(analyze_result.get("geom_geojson"))
|
||||
if parcel is None:
|
||||
return None
|
||||
|
||||
with _managed_figure() as (fig, ax):
|
||||
# Участок — базовый слой, задаёт bbox.
|
||||
parcel_merc = _to_mercator([parcel])
|
||||
parcel_merc.plot(ax=ax, **_PARCEL_STYLE)
|
||||
_apply_bbox(ax, parcel_merc)
|
||||
|
||||
# Риск-зоны (WKT).
|
||||
_plot_wkt_layer(ax, analyze_result.get("nspd_risk_zones"), _RISK_STYLE)
|
||||
# ЗОУИТ-пересечения (GeoJSON geometry_geojson).
|
||||
_plot_geojson_layer(ax, analyze_result.get("nspd_zouit_overlaps"), _ZOUIT_STYLE)
|
||||
# Opportunity-участки (WKT).
|
||||
_plot_wkt_layer(ax, analyze_result.get("nspd_opportunity_parcels"), _OPPORTUNITY_STYLE)
|
||||
# Красные линии (WKT, линии — без заливки: _REDLINE_STYLE несёт color, не facecolor).
|
||||
_plot_wkt_layer(ax, analyze_result.get("nspd_red_lines"), _REDLINE_STYLE)
|
||||
# Конкуренты — точки lat/lng.
|
||||
_plot_competitors(ax, analyze_result.get("competitors"))
|
||||
|
||||
_add_basemap(ax) # graceful: False → просто белый фон, PNG всё равно вернётся
|
||||
return _figure_to_png(fig, max_px)
|
||||
|
||||
|
||||
def render_concept_footprint_png(
|
||||
concept_variant: dict[str, Any],
|
||||
parcel_geojson: dict[str, Any] | None,
|
||||
*,
|
||||
max_px: int = 1600,
|
||||
) -> bytes | None:
|
||||
"""Карта концепции: контур участка + пятна корпусов с подписью этажности → PNG-bytes.
|
||||
|
||||
Args:
|
||||
concept_variant: dict-представление ``ConceptVariant`` с ключом ``buildings_geojson``
|
||||
(FeatureCollection, ``properties.floors`` / ``footprint_sqm`` / ``section_id``).
|
||||
parcel_geojson: GeoJSON-геометрия участка (контур-подложка). None → рисуем только
|
||||
корпуса (bbox по ним).
|
||||
max_px: верхняя граница длинной стороны PNG в пикселях.
|
||||
|
||||
Returns:
|
||||
PNG-bytes, либо None если нет ни участка, ни корпусов (нечего рисовать).
|
||||
"""
|
||||
fc = concept_variant.get("buildings_geojson") if isinstance(concept_variant, dict) else None
|
||||
features = fc.get("features", []) if isinstance(fc, dict) else []
|
||||
|
||||
footprints: list[BaseGeometry] = []
|
||||
floors_labels: list[int | None] = []
|
||||
for feat in features:
|
||||
if not isinstance(feat, dict):
|
||||
continue
|
||||
geom = _geom_from_geojson(feat.get("geometry"))
|
||||
if geom is None:
|
||||
continue
|
||||
footprints.append(geom)
|
||||
props = feat.get("properties") or {}
|
||||
raw_floors = props.get("floors") if isinstance(props, dict) else None
|
||||
floors_labels.append(int(raw_floors) if isinstance(raw_floors, int | float) else None)
|
||||
|
||||
parcel = _geom_from_geojson(parcel_geojson)
|
||||
if parcel is None and not footprints:
|
||||
return None
|
||||
|
||||
with _managed_figure() as (fig, ax):
|
||||
# bbox: по участку если есть, иначе по совокупности footprint'ов.
|
||||
bbox_source = _to_mercator([parcel]) if parcel is not None else _to_mercator(footprints)
|
||||
|
||||
if parcel is not None:
|
||||
# Участок — только контур (без заливки), корпуса рисуются поверх.
|
||||
_to_mercator([parcel]).plot(ax=ax, facecolor="none", edgecolor="#1d4ed8", linewidth=1.8)
|
||||
|
||||
if footprints:
|
||||
fp_merc = _to_mercator(footprints)
|
||||
fp_merc.plot(ax=ax, **_CONCEPT_FOOTPRINT_STYLE)
|
||||
# Подпись этажности в центроиде каждого корпуса.
|
||||
for geom_merc, floors in zip(fp_merc.geometry, floors_labels, strict=True):
|
||||
if floors is None:
|
||||
continue
|
||||
c = geom_merc.centroid
|
||||
ax.annotate(
|
||||
f"{floors} эт.",
|
||||
(c.x, c.y),
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=7.0,
|
||||
color="#0f172a",
|
||||
)
|
||||
|
||||
_apply_bbox(ax, bbox_source)
|
||||
_add_basemap(ax)
|
||||
return _figure_to_png(fig, max_px)
|
||||
|
||||
|
||||
def embed_map_png(html: str, placeholder: str, png: bytes | None) -> str:
|
||||
"""Заменить строку-``placeholder`` в HTML на ``<img>`` с data-URI PNG.
|
||||
|
||||
``png=None`` → плашка «карта недоступна» (тот же класс ``.map-placeholder``, что и
|
||||
в full_report_html — стиль уже есть в шаблоне).
|
||||
"""
|
||||
if png is None:
|
||||
return html.replace(placeholder, _MAP_UNAVAILABLE_HTML)
|
||||
b64 = base64.b64encode(png).decode("ascii")
|
||||
img = (
|
||||
f'<img class="report-map" src="data:image/png;base64,{b64}" '
|
||||
'style="width:100%;max-width:100%;height:auto;display:block;margin:8pt 0;'
|
||||
'border:1px solid #e5e7eb;border-radius:6pt;" alt="Карта участка" />'
|
||||
)
|
||||
return html.replace(placeholder, img)
|
||||
|
||||
|
||||
# ── Приватные слой-плоттеры ────────────────────────────────────────────────────
|
||||
def _plot_wkt_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
|
||||
"""Отрисовать слой из списка dict'ов с ключом ``geom_wkt`` (EPSG:4326).
|
||||
|
||||
Заливка/линия задаётся ``style`` (наличие ``facecolor`` → полигон, ``color`` без
|
||||
``facecolor`` → линия) — geopandas ``plot`` разбирает это сам по типу геометрии.
|
||||
"""
|
||||
geoms = _collect_wkt_geoms(items)
|
||||
if not geoms:
|
||||
return
|
||||
_to_mercator(geoms).plot(ax=ax, **style)
|
||||
|
||||
|
||||
def _plot_geojson_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
|
||||
"""Отрисовать слой из списка dict'ов с ключом ``geometry_geojson`` (EPSG:4326)."""
|
||||
if not isinstance(items, list):
|
||||
return
|
||||
geoms = [
|
||||
g
|
||||
for it in items
|
||||
if isinstance(it, dict)
|
||||
and (g := _geom_from_geojson(it.get("geometry_geojson"))) is not None
|
||||
]
|
||||
if not geoms:
|
||||
return
|
||||
_to_mercator(geoms).plot(ax=ax, **style)
|
||||
|
||||
|
||||
def _collect_wkt_geoms(items: Any) -> list[BaseGeometry]:
|
||||
"""Собрать shapely-геометрии из списка dict'ов по ключу ``geom_wkt``."""
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
out: list[BaseGeometry] = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
geom = _geom_from_wkt(it.get("geom_wkt"))
|
||||
if geom is not None:
|
||||
out.append(geom)
|
||||
return out
|
||||
|
||||
|
||||
def _plot_competitors(ax: Any, competitors: Any) -> None:
|
||||
"""Отрисовать конкурентов точками из ``lat``/``lng`` (EPSG:4326).
|
||||
|
||||
Внимание: у ``Competitor`` долгота в ключе ``lng`` (не ``lon``) — см. schemas/parcel.py.
|
||||
"""
|
||||
if not isinstance(competitors, list):
|
||||
return
|
||||
pts: list[BaseGeometry] = []
|
||||
for c in competitors:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
lat, lng = c.get("lat"), c.get("lng")
|
||||
if isinstance(lat, int | float) and isinstance(lng, int | float):
|
||||
pts.append(Point(float(lng), float(lat)))
|
||||
if not pts:
|
||||
return
|
||||
_to_mercator(pts).plot(ax=ax, **_COMPETITOR_STYLE)
|
||||
|
|
@ -32,9 +32,7 @@ from typing import Any
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
|
||||
# Шесть секций зеркалят excel.py (#991); §13.7 «Уверенность» — доп. секция, портирована
|
||||
# из report_docx/report_md для parity между экспортёрами (audit epic #2445 item C2;
|
||||
# excel.py её пока не несёт — отдельный gap, вне scope этой правки).
|
||||
# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
|
||||
|
||||
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
|
||||
_TITLE_SUMMARY: str = "Сводка"
|
||||
|
|
@ -43,7 +41,6 @@ _TITLE_FUTURE_MARKET: str = "Будущий рынок"
|
|||
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
|
||||
_TITLE_SCENARIOS: str = "Сценарии"
|
||||
_TITLE_SCORING: str = "Скоринг"
|
||||
_TITLE_CONFIDENCE: str = "Уверенность"
|
||||
|
||||
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
|
||||
|
||||
|
|
@ -311,21 +308,15 @@ def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _build_summary(report: dict[str, Any]) -> str:
|
||||
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст.
|
||||
|
||||
Уровень уверенности здесь — только сводный badge (`overall_confidence`), зеркало
|
||||
report_docx/report_md._build_summary. Полный разбор (rationale + факторы-драйверы)
|
||||
живёт в отдельной секции §13.7 «Уверенность» (`_build_confidence`) — раньше
|
||||
(до parity-фикса #2445 C2) он дублировался здесь тонкой 2-строчной таблицей, что
|
||||
расходилось с docx/md и не переживало dict-значный фактор; убрано, чтобы не было
|
||||
двух версий одних и тех же данных в одном документе.
|
||||
"""
|
||||
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
|
||||
exec_summary = _as_dict(report.get("exec_summary"))
|
||||
meta = _as_dict(report.get("meta"))
|
||||
confidence = _as_dict(report.get("confidence"))
|
||||
|
||||
headline = exec_summary.get("headline")
|
||||
verdict = exec_summary.get("verdict")
|
||||
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
|
||||
cad = _esc(meta.get("cad_num"))
|
||||
district = _esc(meta.get("district"))
|
||||
|
|
@ -338,6 +329,10 @@ def _build_summary(report: dict[str, Any]) -> str:
|
|||
("Сформировано", meta.get("generated_at")),
|
||||
("Версия схемы", meta.get("schema_version")),
|
||||
]
|
||||
confidence_pairs: list[tuple[str, Any]] = [
|
||||
("Уровень", _level_ru(confidence.get("level"))),
|
||||
("Обоснование", confidence.get("rationale")),
|
||||
]
|
||||
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
|
||||
|
||||
return f"""
|
||||
|
|
@ -356,6 +351,12 @@ def _build_summary(report: dict[str, Any]) -> str:
|
|||
<h3>Ключевые числа</h3>
|
||||
{_dict_kv_table(key_numbers)}
|
||||
|
||||
<h3>Уверенность отчёта</h3>
|
||||
{_kv_table(confidence_pairs)}
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_dict_kv_table(factors)}
|
||||
|
||||
<h3>Контекст</h3>
|
||||
{_kv_table(context_pairs)}
|
||||
</div>
|
||||
|
|
@ -623,43 +624,6 @@ def _build_scoring(report: dict[str, Any]) -> str:
|
|||
|
||||
<h3>Специальные индексы</h3>
|
||||
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _build_confidence(report: dict[str, Any]) -> str:
|
||||
"""§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).
|
||||
|
||||
Parity fix (audit epic #2445 item C2): report_docx/report_md уже несли эту секцию
|
||||
(§22.7/§13.7) — PDF был единственным экспортёром без нее. Портировано 1-в-1 (та же
|
||||
4-колоночная таблица «Фактор/Значение/Уровень/Комментарий»), но через HTML-примитивы
|
||||
report_pdf (`_data_table`), а не python-docx/Markdown API.
|
||||
"""
|
||||
confidence = _as_dict(report.get("confidence"))
|
||||
level = _level_ru(confidence.get("level"))
|
||||
rationale = confidence.get("rationale")
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
|
||||
# Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive:
|
||||
# если значение — dict, раскладываем на value/level/note; иначе кладём как есть
|
||||
# (зеркало report_docx._build_confidence / report_md._build_confidence).
|
||||
factor_rows: list[list[Any]] = []
|
||||
for name, payload in factors.items():
|
||||
if isinstance(payload, dict):
|
||||
factor_rows.append(
|
||||
[name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")]
|
||||
)
|
||||
else:
|
||||
factor_rows.append([name, payload, _DASH, _DASH])
|
||||
|
||||
return f"""
|
||||
<div class="section" id="confidence">
|
||||
<h2>{html.escape(_TITLE_CONFIDENCE)}</h2>
|
||||
<span class="badge">Уровень: {_esc(level)}</span>
|
||||
<p class="verdict">{_esc(rationale)}</p>
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
|
||||
|
||||
<div class="footer">{html.escape(_FOOTER_NOTE)}</div>
|
||||
</div>
|
||||
|
|
@ -667,8 +631,7 @@ def _build_confidence(report: dict[str, Any]) -> str:
|
|||
|
||||
|
||||
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
|
||||
# `_SHEET_BUILDERS` у excel.py + report_docx/report_md — Сводка → Рынок сейчас →
|
||||
# Будущий рынок → Продукт ТЗ → Сценарии → Скоринг → Уверенность §13.7, #2445 C2).
|
||||
# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
|
||||
_SECTION_BUILDERS: tuple[Any, ...] = (
|
||||
_build_summary,
|
||||
_build_market_now,
|
||||
|
|
@ -676,12 +639,11 @@ _SECTION_BUILDERS: tuple[Any, ...] = (
|
|||
_build_product_tz,
|
||||
_build_scenarios,
|
||||
_build_scoring,
|
||||
_build_confidence,
|
||||
)
|
||||
|
||||
|
||||
def _build_html(report: dict[str, Any]) -> str:
|
||||
"""Склеить HTML-документ из семи секций §13 (+ §13.7 Уверенность). PURE. Graceful."""
|
||||
"""Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
|
||||
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
|
@ -704,10 +666,10 @@ def export_report_pdf(report: Any) -> bytes:
|
|||
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
|
||||
|
||||
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
|
||||
рынок / Продукт ТЗ / Сценарии / Скоринг / Уверенность §13.7 — parity с
|
||||
report_docx/report_md, #2445 C2). Шапки таблиц с заливкой, RU-метки, числа
|
||||
округлены, None → "—". На блоке «Сводка» — заметный ADVISORY-маркер (отчёт
|
||||
советующий). ВСЕ динамические строки экранируются `html.escape`.
|
||||
рынок / Продукт ТЗ / Сценарии / Скоринг — тот же набор, что и `export_report_xlsx`).
|
||||
Шапки таблиц с заливкой, RU-метки, числа округлены, None → "—". На блоке «Сводка» —
|
||||
заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
|
||||
`html.escape`.
|
||||
|
||||
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
|
||||
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/
|
||||
|
|
|
|||
|
|
@ -73,14 +73,7 @@ from app.services.forecasting.demand_normalization import compute_demand_normali
|
|||
from app.services.forecasting.macro_coefficient import compute_macro_coefficient
|
||||
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
|
||||
from app.services.forecasting.regression import compute_rate_regime_sensitivity
|
||||
from app.services.forecasting.sales_series import (
|
||||
ROOM_AREA_BUCKET_1K,
|
||||
ROOM_AREA_BUCKET_2K,
|
||||
ROOM_AREA_BUCKET_3K,
|
||||
ROOM_AREA_BUCKET_LARGE,
|
||||
ROOM_AREA_BUCKET_STUDIO,
|
||||
SegmentSpec,
|
||||
)
|
||||
from app.services.forecasting.sales_series import SegmentSpec
|
||||
from app.services.site_finder.competitors import get_competitors
|
||||
from app.services.site_finder.future_supply import compute_future_supply_pressure
|
||||
from app.services.site_finder.market_metrics import compute_market_metrics
|
||||
|
|
@ -128,51 +121,6 @@ _CONFIDENCE_CAP: Confidence = "medium"
|
|||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
|
||||
|
||||
# #1959 VOCAB-мост (load-bearing): SegmentSpec.room_bucket приходит из what_to_build
|
||||
# в Source-B room_area-вокабуляре ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/
|
||||
# "3-к 60-80"/"80+ м²"). compute_market_metrics (после #1959) принимает ИМЕННО этот
|
||||
# вокабуляр напрямую (его room CASE = зеркало sales_series.room_area_bucket_of). Эта
|
||||
# white-list страхует от чужих/неизвестных меток: распознанные пробрасываем как есть,
|
||||
# неизвестные (или None) → None = «без room-фильтра» (district-wide темп, но НЕ тихий
|
||||
# 0-rows из-за опечатки в вокабуляре). Меняешь сетку room_buckets — обнови white-list.
|
||||
_FORECAST_ROOM_BUCKETS: frozenset[str] = frozenset(
|
||||
{
|
||||
ROOM_AREA_BUCKET_STUDIO,
|
||||
ROOM_AREA_BUCKET_1K,
|
||||
ROOM_AREA_BUCKET_2K,
|
||||
ROOM_AREA_BUCKET_3K,
|
||||
ROOM_AREA_BUCKET_LARGE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _market_room_bucket(room_bucket: str | None) -> str | None:
|
||||
"""Перевести SegmentSpec.room_bucket в room-фильтр compute_market_metrics. PURE.
|
||||
|
||||
Вокабуляр уже выровнен (#1959): market_metrics принимает Source-B room_area-метки
|
||||
напрямую. Эта функция — VALIDATING pass-through: распознанная Source-B метка
|
||||
возвращается КАК ЕСТЬ; None / неизвестная метка → None (= «без room-фильтра»,
|
||||
district-wide темп). Без этой проверки опечатка/чужой вокабуляр дали бы тихий
|
||||
0-rows фильтр → метрики None → ячейка молча выпадала бы из ранкинга. PURE.
|
||||
|
||||
Args:
|
||||
room_bucket: room_bucket сегмента (ожидается Source-B вокабуляр) или None.
|
||||
|
||||
Returns:
|
||||
Та же Source-B метка (если в белом списке) либо None.
|
||||
"""
|
||||
if room_bucket is None:
|
||||
return None
|
||||
if room_bucket in _FORECAST_ROOM_BUCKETS:
|
||||
return room_bucket
|
||||
logger.warning(
|
||||
"demand_supply_forecast: unrecognized room_bucket %r — dropping room filter "
|
||||
"(district-wide pace). Expected Source-B vocab %s.",
|
||||
room_bucket,
|
||||
sorted(_FORECAST_ROOM_BUCKETS),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemandSupplyForecast:
|
||||
|
|
@ -607,15 +555,6 @@ def compute_demand_supply_forecast(
|
|||
(она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО
|
||||
ради explainability-фразы — её β/x_pct в арифметику спроса НЕ входят.
|
||||
|
||||
#1959 ПОСЕГМЕНТНЫЙ баланс (фикс «−1.00 везде»): и СПРОС, и ПРЕДЛОЖЕНИЕ теперь
|
||||
сужаются под spec (obj_class/room_bucket), а не берутся district-wide:
|
||||
• base_pace = compute_market_metrics(obj_class, room_bucket).unit_velocity —
|
||||
дедуплицированный темп ИМЕННО сегмента (раньше один темп на все ячейки →
|
||||
каждая what_to_build-ячейка делила одно и то же → дефицит = −1.0 везде).
|
||||
• open-сток = market_metrics.n_available того же сегмента (дедуп). При заданном
|
||||
сегменте L2/L3 (hidden/future) ИСКЛЮЧАЮТСЯ из баланса — они класс/формат-
|
||||
агностичны, иначе один объём двоился бы по всем ячейкам (снова −1.0).
|
||||
|
||||
Один раз на вызов:
|
||||
• macro = get_monthly_macro(db) — для дефолтного rate-path (hold_last_rate).
|
||||
• base_pace = compute_market_metrics(...).unit_velocity (§9.2 ед./мес).
|
||||
|
|
@ -626,10 +565,9 @@ def compute_demand_supply_forecast(
|
|||
• rate_future = rate_path[h] (или hold_last_rate).
|
||||
• §9.4 compute_demand_normalization(rate_future=rate_future) → norm (β внутри).
|
||||
• projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда).
|
||||
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future
|
||||
(для НЕзаданного сегмента); при заданном сегменте open = n_available,
|
||||
hidden/future = 0. Чистое предложение = open + hidden_release + future −
|
||||
поглощённое спросом (clamp ≥0).
|
||||
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future;
|
||||
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
|
||||
open + hidden_release + future − поглощённое спросом (clamp ≥0).
|
||||
• balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
|
||||
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
|
||||
• §9.7 future_competitors = top-N get_competitors(horizon_months=h).
|
||||
|
|
@ -662,19 +600,7 @@ def compute_demand_supply_forecast(
|
|||
)
|
||||
|
||||
# ── Один раз: §9.2 наблюдаемый темп (base_pace) ────────────────────────────
|
||||
# #1959: ПОСЕГМЕНТНЫЙ темп — фильтруем market_metrics по obj_class + room_bucket
|
||||
# сегмента. Раньше брался district-wide unit_velocity (один темп на ВСЕ ячейки
|
||||
# what_to_build → дефицит вырождался в −1.0 везде). room_bucket идёт из Source-B
|
||||
# вокабуляра what_to_build ("Студии 15-30"/…); market_metrics теперь принимает
|
||||
# его напрямую (room CASE = зеркало room_area_bucket_of) → переводчик-валидатор
|
||||
# (см. _market_room_bucket) только страхует от неизвестных меток.
|
||||
metrics = compute_market_metrics(
|
||||
db,
|
||||
district=district,
|
||||
premise_kind=premise_kind,
|
||||
obj_class=spec.obj_class,
|
||||
room_bucket=_market_room_bucket(spec.room_bucket),
|
||||
)
|
||||
metrics = compute_market_metrics(db, district=district, premise_kind=premise_kind)
|
||||
base_pace = metrics.unit_velocity
|
||||
|
||||
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
|
||||
|
|
@ -690,15 +616,6 @@ def compute_demand_supply_forecast(
|
|||
# значение).
|
||||
confounded = _series_confounded(macro_coef, sensitivity)
|
||||
|
||||
# #1959: сегмент «задан», если сужена хотя бы одна предметная ось (класс/формат).
|
||||
# Тогда ПРЕДЛОЖЕНИЕ берём ПОСЕГМЕНТНОЕ — open = market_metrics.n_available
|
||||
# (дедуплицированный сток ИМЕННО этого сегмента), а L2/L3 (hidden/future)
|
||||
# ИСКЛЮЧАЕМ из баланса: они класс/формат-агностичны (нет room-оси) → один и тот
|
||||
# же объём прибавился бы КАЖДОЙ ячейке → двойной счёт и снова вырождение к −1.0.
|
||||
# Незаданный сегмент (оба None) → прежний district-wide путь (open из §9.3 view +
|
||||
# hidden + future), обратная совместимость для агрегатных вызовов.
|
||||
segmented = spec.obj_class is not None or spec.room_bucket is not None
|
||||
|
||||
out: list[DemandSupplyForecast] = []
|
||||
for h in horizon_list:
|
||||
out.append(
|
||||
|
|
@ -712,8 +629,6 @@ def compute_demand_supply_forecast(
|
|||
rate_future=effective_rate_path.get(h),
|
||||
base_pace=base_pace,
|
||||
market_confidence=metrics.confidence,
|
||||
segment_open_units=metrics.n_available,
|
||||
segmented=segmented,
|
||||
macro_coef=macro_coef,
|
||||
sensitivity_phrase=sensitivity.phrase,
|
||||
confounded=confounded,
|
||||
|
|
@ -748,8 +663,6 @@ def _forecast_for_horizon(
|
|||
rate_future: float | None,
|
||||
base_pace: float | None,
|
||||
market_confidence: Confidence,
|
||||
segment_open_units: int,
|
||||
segmented: bool,
|
||||
macro_coef: Any,
|
||||
sensitivity_phrase: str | None,
|
||||
confounded: bool,
|
||||
|
|
@ -767,28 +680,16 @@ def _forecast_for_horizon(
|
|||
projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon)
|
||||
|
||||
# ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое (− поглощённое) ───
|
||||
# fsp читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного
|
||||
# сегмента — ещё и open/hidden/future как раньше).
|
||||
fsp = compute_future_supply_pressure(
|
||||
db, district=district, horizon_months=horizon, premise_kind=premise_kind
|
||||
)
|
||||
if segmented:
|
||||
# #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента
|
||||
# (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future)
|
||||
# класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке,
|
||||
# один и тот же объём двоится по всем сегментам → дефицит снова липнет к −1.0.
|
||||
# Поэтому в посегментном балансе hidden/future = 0 (в dataclass их тоже не
|
||||
# показываем, чтобы explain не вводил в заблуждение «этот сегмент имеет L2/L3»).
|
||||
open_units = segment_open_units
|
||||
hidden_release = 0.0
|
||||
future_online = 0.0
|
||||
else:
|
||||
open_units = fsp.open_units
|
||||
hidden_fraction = _hidden_release_fraction(horizon)
|
||||
hidden_release = fsp.hidden_units * hidden_fraction
|
||||
future_online = fsp.future_units_by_horizon
|
||||
gross_supply = _gross_supply(open_units, hidden_release, future_online)
|
||||
projected_supply = _project_supply(open_units, hidden_release, future_online, projected_demand)
|
||||
hidden_fraction = _hidden_release_fraction(horizon)
|
||||
hidden_release = fsp.hidden_units * hidden_fraction
|
||||
future_online = fsp.future_units_by_horizon
|
||||
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
|
||||
projected_supply = _project_supply(
|
||||
fsp.open_units, hidden_release, future_online, projected_demand
|
||||
)
|
||||
|
||||
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
|
||||
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
|
||||
|
|
@ -836,7 +737,7 @@ def _forecast_for_horizon(
|
|||
demand_norm_coefficient=norm_coefficient,
|
||||
macro_coefficient=macro_coefficient,
|
||||
projected_demand_units=projected_demand,
|
||||
open_units=open_units,
|
||||
open_units=fsp.open_units,
|
||||
hidden_release_units=hidden_release,
|
||||
future_online_units=future_online,
|
||||
projected_supply_units=projected_supply,
|
||||
|
|
|
|||
|
|
@ -371,20 +371,12 @@ def _month_grid(start: date, end: date) -> list[date]:
|
|||
|
||||
|
||||
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||||
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): `db` — общая §22-сессия отчёта; при сбое
|
||||
этого запроса БЕЗ SAVEPOINT транзакция Postgres остаётся aborted и следующие
|
||||
запросы get_monthly_macro (inflation, mortgage) + все ПОСЛЕДУЮЩИЕ §9.x-слои на
|
||||
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
|
||||
SAVEPOINT (ROLLBACK TO SAVEPOINT), внешняя транзакция остаётся рабочей.
|
||||
"""
|
||||
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}."""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(
|
||||
_KEY_RATE_MONTHLY_SQL,
|
||||
{"itype": "key_rate", "region": "rf", "since": since},
|
||||
).all()
|
||||
rows = db.execute(
|
||||
_KEY_RATE_MONTHLY_SQL,
|
||||
{"itype": "key_rate", "region": "rf", "since": since},
|
||||
).all()
|
||||
except Exception:
|
||||
logger.exception("get_monthly_macro: key_rate query failed")
|
||||
return {}
|
||||
|
|
@ -397,14 +389,9 @@ def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
|
|||
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) — берём как есть
|
||||
через reuse get_macro_series (свой SQL не пишем). _month_start — страховка.
|
||||
Сбой/пустой ряд → {} (НЕ crash), inflation_yoy тогда None по всей сетке.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): см. `_query_key_rate_monthly` — та же
|
||||
общая §22-сессия, тот же риск отравления транзакции для последующих запросов
|
||||
(mortgage-поля + §9.x-слои). `with db.begin_nested():` изолирует сбой в SAVEPOINT.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
||||
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
||||
except Exception:
|
||||
logger.exception("get_monthly_macro: inflation_yoy query failed")
|
||||
return {}
|
||||
|
|
@ -417,20 +404,11 @@ def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date,
|
|||
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
|
||||
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
|
||||
не валит остальные (graceful: пустой подсловарь).
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #2): все 5 полей читаются на ОДНОЙ `db`-Session
|
||||
(get_monthly_macro вызывается внутри общей §22-сессии отчёта). Без SAVEPOINT сбой
|
||||
ОДНОГО поля оставляет транзакцию Postgres aborted — каждое СЛЕДУЮЩЕЕ поле в этом
|
||||
же цикле тоже падает (хотя его данные были бы доступны), а `except` здесь молча
|
||||
отдаёт [] по каждому, маскируя каскад под «нормальную» построчную деградацию.
|
||||
`with db.begin_nested():` — SAVEPOINT на КАЖДОЕ поле: сбой откатывает только его
|
||||
SAVEPOINT (ROLLBACK TO SAVEPOINT), сессия остаётся рабочей для следующего поля.
|
||||
"""
|
||||
out: dict[str, dict[date, float]] = {}
|
||||
for indicator_type, field in _MORTGAGE_FIELDS:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
|
||||
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
|
||||
except Exception:
|
||||
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
|
||||
series = []
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ def _primary_horizon(horizons: Sequence[int]) -> int:
|
|||
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
|
||||
|
||||
|
||||
def _safe_call(label: str, db: Session, fn: Any) -> Any:
|
||||
def _safe_call(label: str, fn: Any) -> Any:
|
||||
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
|
||||
|
||||
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
|
||||
|
|
@ -133,29 +133,15 @@ def _safe_call(label: str, db: Session, fn: Any) -> Any:
|
|||
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception —
|
||||
НЕ молчаливое глотание. §9.x уже graceful внутри; это belt-and-suspenders на шве.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #1): все §9.x-слои шарят ОДИН `db`-Session на
|
||||
отчёт (module docstring `forecast_request_cache.py`). Без обёртки сбойный
|
||||
`db.execute` внутри слоя оставляет транзакцию Postgres в состоянии aborted
|
||||
(«current transaction is aborted, commands ignored until end of transaction
|
||||
block») — КАЖДЫЙ последующий слой на той же сессии тоже падает, хотя его данные
|
||||
были бы доступны. `with db.begin_nested():` заводит SAVEPOINT НА ВЕСЬ вызов слоя
|
||||
(слой может делать несколько `db.execute` внутри себя — например §9.6 внутри
|
||||
§9.8/§11); при исключении SAVEPOINT откатывается автоматически (ROLLBACK TO
|
||||
SAVEPOINT), внешняя транзакция остаётся рабочей для следующего слоя. НЕ
|
||||
`db.rollback()` — тот откатил бы ВСЮ внешнюю транзакцию (см. `backend.md` §
|
||||
SAVEPOINT pattern, established anti-pattern).
|
||||
|
||||
Args:
|
||||
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
|
||||
db: общая §22-сессия (для SAVEPOINT вокруг вызова слоя).
|
||||
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
|
||||
|
||||
Returns:
|
||||
Результат `fn()` или None при исключении.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
return fn()
|
||||
return fn()
|
||||
except Exception:
|
||||
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
|
||||
return None
|
||||
|
|
@ -348,25 +334,21 @@ def _build_site_finder_report_impl(
|
|||
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
|
||||
market_metrics = _safe_call(
|
||||
"market_metrics",
|
||||
db,
|
||||
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
|
||||
)
|
||||
supply_rows = _safe_call(
|
||||
"supply_layers",
|
||||
db,
|
||||
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
|
||||
)
|
||||
supply_layers = _summarize_supply_layers(supply_rows)
|
||||
future_supply = _safe_call(
|
||||
"future_supply",
|
||||
db,
|
||||
lambda: compute_future_supply_pressure(
|
||||
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
|
||||
),
|
||||
)
|
||||
forecasts = _safe_call(
|
||||
"demand_supply_forecast",
|
||||
db,
|
||||
lambda: compute_demand_supply_forecast(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
|
|
@ -377,7 +359,6 @@ def _build_site_finder_report_impl(
|
|||
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
|
||||
scenarios_result = _safe_call(
|
||||
"scenarios",
|
||||
db,
|
||||
lambda: compute_scenarios(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
|
|
@ -390,21 +371,18 @@ def _build_site_finder_report_impl(
|
|||
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
|
||||
product_scores = _safe_call(
|
||||
"score_card",
|
||||
db,
|
||||
lambda: compute_score_card(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
|
||||
),
|
||||
)
|
||||
special_indices = _safe_call(
|
||||
"special_indices",
|
||||
db,
|
||||
lambda: compute_special_indices(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
)
|
||||
recommendation_overlay = _safe_call(
|
||||
"forecast_overlay",
|
||||
db,
|
||||
lambda: build_forecast_overlay(
|
||||
db,
|
||||
district=district,
|
||||
|
|
@ -415,7 +393,7 @@ def _build_site_finder_report_impl(
|
|||
)
|
||||
|
||||
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
|
||||
macro = _safe_call("monthly_macro", db, lambda: get_monthly_macro(db))
|
||||
macro = _safe_call("monthly_macro", lambda: get_monthly_macro(db))
|
||||
macro_as_of = _macro_as_of(macro)
|
||||
if macro_as_of is not None:
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -835,15 +835,13 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
|||
compute_poi_weighted_top7. Нет геометрии / нет POI / сбой → None (infra_fit unavailable).
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
coords = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL,
|
||||
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
coords = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)}
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"product_scoring: centroid lookup failed (cad_num=%s) → infra n/a", cad_num
|
||||
|
|
@ -852,10 +850,9 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
|||
if not coords or coords.get("lat") is None or coords.get("lon") is None:
|
||||
return None
|
||||
try:
|
||||
with db.begin_nested():
|
||||
response = compute_poi_weighted_top7(
|
||||
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
|
||||
)
|
||||
response = compute_poi_weighted_top7(
|
||||
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("product_scoring: poi_score failed (cad_num=%s) → infra n/a", cad_num)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -95,8 +95,7 @@ _CLASS_TO_FORECAST: dict[str, str] = {
|
|||
"Comfort+": "комфорт",
|
||||
"Business": "бизнес",
|
||||
"Elite": "бизнес",
|
||||
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
|
||||
"Economy": "стандарт",
|
||||
"Economy": "эконом",
|
||||
}
|
||||
|
||||
# ── #983 named-константы (§10/§16) ─────────────────────────────────────────────
|
||||
|
|
@ -146,9 +145,8 @@ def map_class(target_class: str | None) -> str | None:
|
|||
"""Live target_class → forecast obj_class. PURE. None/неизвестное → None.
|
||||
|
||||
Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite — в «бизнес»,
|
||||
Economy — в «стандарт» (#1959: нижний массовый класс objective_lots —
|
||||
«стандарт», эконома в данных нет). 3 массовых класса §9.7. None → None =
|
||||
«без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов.
|
||||
Economy — в «эконом» (3 массовых класса §9.7). None → None = «без фильтра
|
||||
класса», вызывающий отдаёт движку дефолтную сетку классов.
|
||||
"""
|
||||
if target_class is None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -88,14 +88,6 @@ _STRONG_DEFICIT_THRESHOLD: float = 0.25
|
|||
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
|
||||
_SIGNAL_BALANCE_EPS: float = 0.05
|
||||
|
||||
# #1959 honesty-guard: если ВСЕ ячейки сетки прижаты к ±1.0 (дефицит не различает
|
||||
# сегменты — degenerate-fallback, как было до фикса посегментации), категоричные
|
||||
# вердикты «строить»/«избегать» вводят в заблуждение (на самом деле сигнал не
|
||||
# дискриминирует). Тогда вместо вердикта показываем честную плашку. |di| ≥ этого
|
||||
# порога считаем «прижато к пределу шкалы».
|
||||
_DEGENERATE_CLAMP_THRESHOLD: float = 0.999
|
||||
_DEGENERATE_SEGMENT_NOTE: str = "недостаточно гранулярных данных для посегментного вывода"
|
||||
|
||||
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
|
||||
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
|
|
@ -152,31 +144,17 @@ def _as_dict_list(items: Sequence[Any] | None) -> list[dict[str, Any]]:
|
|||
|
||||
|
||||
def _deal_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
|
||||
"""Число сделок (продаж) ЗА ОКНО — для deal_count #990. PURE.
|
||||
"""Число сделок (продаж) за окно — для deal_count #990. PURE.
|
||||
|
||||
#1964: НЕ берём `market_metrics.n_sold` — это КУМУЛЯТИВНЫЙ all-time проданный сток
|
||||
(десятки-сотни тысяч), а #990/confidence_engine помечает фактор «за {window} мес»
|
||||
(6) и гейтит порогами 6-мес окна (high≥50). n_sold ≫ 50 ВСЕГДА → гейт бессмыслен и
|
||||
подпись врёт. Берём оконное число = unit_velocity × window_months, где unit_velocity
|
||||
(§9.2) = units_sold_window / window_months из _SALES_WINDOW_SQL (он
|
||||
contract_date-windowed) → произведение восстанавливает units_sold за окно. Так
|
||||
подпись «за 6 мес» честная и порог 50/15 осмысленный (прод EKB: ~24 876 за 6 мес
|
||||
vs ~380 921 кумулятивно).
|
||||
|
||||
unit_velocity отсутствует (None / нет market_metrics) → None (#990 трактует как
|
||||
«нет данных», тянет в low). window_months ≤ 0 / нет → None (нечем масштабировать).
|
||||
Fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
|
||||
сделок — пошло бы в analog_count).
|
||||
Предпочитаем `market_metrics.n_sold` (§9.2 — прямой счётчик проданных лотов выборки);
|
||||
fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
|
||||
сделок — пошло бы в analog_count). Нет сигнала → None (#990 → тянет в low).
|
||||
"""
|
||||
if market_metrics is None:
|
||||
return None
|
||||
unit_velocity = market_metrics.get("unit_velocity")
|
||||
window_months = market_metrics.get("window_months")
|
||||
if not isinstance(unit_velocity, int | float):
|
||||
return None
|
||||
if not isinstance(window_months, int) or window_months <= 0:
|
||||
return None
|
||||
return round(float(unit_velocity) * window_months)
|
||||
if market_metrics is not None:
|
||||
n_sold = market_metrics.get("n_sold")
|
||||
if isinstance(n_sold, int):
|
||||
return n_sold
|
||||
return None
|
||||
|
||||
|
||||
def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
|
||||
|
|
@ -253,8 +231,7 @@ def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
|
|||
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
|
||||
|
||||
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months` —
|
||||
#1964: именно за это окно `_deal_count` считает сделки (unit_velocity ×
|
||||
window_months). Нет → None (#990 пропускает суффикс «за N мес»).
|
||||
именно за это окно считается n_sold. Нет → None (#990 пропускает суффикс «за N мес»).
|
||||
"""
|
||||
if market_metrics is not None:
|
||||
window = market_metrics.get("window_months")
|
||||
|
|
@ -555,71 +532,33 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
|||
"""
|
||||
explicit = product_tz.get("mix")
|
||||
if isinstance(explicit, list):
|
||||
entries = [m for m in explicit if isinstance(m, dict)]
|
||||
degenerate = _all_cells_clamped([e.get("deficit_index") for e in entries])
|
||||
return [_enrich_mix_entry(m, degenerate=degenerate) for m in entries]
|
||||
return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)]
|
||||
ranked = product_tz.get("ranked_segments")
|
||||
if isinstance(ranked, list):
|
||||
segs = [seg for seg in ranked if isinstance(seg, dict)]
|
||||
# #1959 honesty-guard: вся сетка прижата к ±1.0 → дефицит не дискриминирует
|
||||
# сегменты (degenerate-fallback). Не эмитим категоричные «строить»/«избегать»
|
||||
# — вместо них честная плашка `_DEGENERATE_SEGMENT_NOTE`.
|
||||
degenerate = _all_cells_clamped([seg.get("deficit_index") for seg in segs])
|
||||
return [
|
||||
{
|
||||
"bucket": seg.get("bucket"),
|
||||
"obj_class": seg.get("obj_class"),
|
||||
"deficit_index": seg.get("deficit_index"),
|
||||
"projected_demand_units": seg.get("projected_demand_units"),
|
||||
"signal": (
|
||||
_DEGENERATE_SEGMENT_NOTE
|
||||
if degenerate
|
||||
else _build_signal(seg.get("deficit_index"))
|
||||
),
|
||||
"signal": _build_signal(seg.get("deficit_index")),
|
||||
}
|
||||
for seg in segs
|
||||
for seg in ranked
|
||||
if isinstance(seg, dict)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _all_cells_clamped(deficit_indices: Sequence[Any]) -> bool:
|
||||
"""True если ВСЕ измеримые ячейки прижаты к ±1.0 (degenerate-fallback). PURE.
|
||||
|
||||
#1959 honesty-guard. «Измеримые» = числовые deficit_index (None-ячейки —
|
||||
тонкие данные, в знаменатель не идут). Пусто / нет измеримых → False (нечего
|
||||
защищать — обычный graceful-путь). Если хотя бы одна ячейка различается
|
||||
(|di| < порога), сетка ДИСКРИМИНИРУЕТ → вердикты честны → False. PURE.
|
||||
|
||||
Args:
|
||||
deficit_indices: deficit_index ячеек (числа / None / мусор).
|
||||
|
||||
Returns:
|
||||
True только когда есть ≥1 измеримая ячейка и ВСЕ они |di| ≥ порога.
|
||||
"""
|
||||
measured = [
|
||||
float(d) for d in deficit_indices if isinstance(d, int | float) and not isinstance(d, bool)
|
||||
]
|
||||
if not measured:
|
||||
return False
|
||||
return all(abs(d) >= _DEGENERATE_CLAMP_THRESHOLD for d in measured)
|
||||
|
||||
|
||||
def _enrich_mix_entry(entry: dict[str, Any], *, degenerate: bool = False) -> dict[str, Any]:
|
||||
def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE.
|
||||
|
||||
Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет
|
||||
`signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует
|
||||
вход — assembler-чистота).
|
||||
|
||||
#1959 honesty-guard: `degenerate=True` (вся сетка прижата к ±1.0) → вместо
|
||||
категоричного сигнала ставим честную плашку `_DEGENERATE_SEGMENT_NOTE` (только
|
||||
когда у ячейки не было своего явного `signal`).
|
||||
"""
|
||||
enriched = dict(entry)
|
||||
if enriched.get("signal") is None:
|
||||
enriched["signal"] = (
|
||||
_DEGENERATE_SEGMENT_NOTE if degenerate else _build_signal(enriched.get("deficit_index"))
|
||||
)
|
||||
enriched["signal"] = _build_signal(enriched.get("deficit_index"))
|
||||
return enriched
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -549,12 +549,6 @@ def _query_source_a(
|
|||
|
||||
Graceful → {} при сбое/пустых данных. price_bucket в spec для Source A
|
||||
игнорируется (агрегат не несёт per-lot цены) — фиксируется логом.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #4): `db` — общая §22-сессия отчёта (может
|
||||
переиспользоваться другими §9.x-слоями после этого вызова). Без SAVEPOINT сбойный
|
||||
`db.execute` оставляет транзакцию Postgres aborted — все ПОСЛЕДУЮЩИЕ запросы на
|
||||
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
|
||||
SAVEPOINT при сбое, оставляя внешнюю транзакцию рабочей.
|
||||
"""
|
||||
if spec.price_bucket is not None:
|
||||
logger.info(
|
||||
|
|
@ -573,8 +567,7 @@ def _query_source_a(
|
|||
"room_bucket": spec.room_bucket,
|
||||
}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
|
||||
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("build_sales_series: source A query failed")
|
||||
return {}
|
||||
|
|
@ -589,9 +582,6 @@ def _query_source_b(
|
|||
Graceful → {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL
|
||||
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
|
||||
правилом и в БД, и в Python.
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #4): см. `_query_source_a` — та же общая
|
||||
§22-сессия, тот же риск отравления транзакции для последующих слоёв/запросов.
|
||||
"""
|
||||
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
|
||||
# None → EKB-wide (без district-фильтра).
|
||||
|
|
@ -623,8 +613,7 @@ def _query_source_b(
|
|||
"p_unknown": PRICE_BUCKET_UNKNOWN,
|
||||
}
|
||||
try:
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
|
||||
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("build_sales_series: source B query failed")
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -828,62 +828,22 @@ def _unavailable(key: str, *, reason: str) -> SpecialIndex:
|
|||
# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает
|
||||
# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ
|
||||
# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type.
|
||||
# #1964: считаем по ФИЗИЧЕСКИМ лотам (дедуп) — сырой objective_lots раздут ~2.91×
|
||||
# (мульти lot_id на физлот); доля ипотеки — отношение, к дублям устойчива, но
|
||||
# n_sold/n_mortgage теперь честные (по физлотам).
|
||||
#
|
||||
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
|
||||
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view (DISTINCT ON ol.*)
|
||||
# — оптимизационный барьер: WHERE снаружи НЕ проталкивается ниже DISTINCT ON →
|
||||
# планировщик материализует ВСЮ таблицу (1.76M, Parallel Seq Scan + external merge sort
|
||||
# 66-81 МБ/воркер) ПЕРЕД фильтром (~5.4-6.4 s district-scoped, прод-EXPLAIN). Inline:
|
||||
# bitmap по objective_lots_district_class_idx + узкий sort (~3-5 МБ/воркер) ~0.37 s
|
||||
# (≈15×).
|
||||
#
|
||||
# ⚠️ КОРРЕКТНОСТЬ дедупа (слои фильтров РАЗНЫЕ — не путать):
|
||||
# • premise_kind — ПОЛНОСТЬЮ стабилен (0 mixed-групп на проде) → в WHERE ДО DISTINCT ON.
|
||||
# • district — почти стабилен (4 transient-группы на 605k физлотов). Протолкнут ДО
|
||||
# DISTINCT ON ОСОЗНАННО: это index-драйвер objective_lots_district_class_idx; увод
|
||||
# фильтра ПОСЛЕ дедупа = full seq-scan, убивает перф-фикс. Принятый компромисс
|
||||
# паттерна parcels.py/#1964 (≤4 возможных дрейфа при district-scoped запросе).
|
||||
# • class — МЕНЯЮЩИЙСЯ (248 transient-групп на проде): класс физлота дрейфует между
|
||||
# снапшотами. Фильтр lower(class)=… ДО DISTINCT ON ловил бы СТАРЫЙ снапшот под
|
||||
# старым классом (прод-замер: бизнес 16637→16657 +20, стандарт 128721→128914 +193
|
||||
# vs view). View фильтрует class на ПОСЛЕДНЕМ снапшоте → class-фильтр применяем
|
||||
# ПОСЛЕ дедупа (в sold-CTE). Тогда эквивалентность view бит-в-бит по ВСЕМ классам.
|
||||
# • sold-фильтр (is_sold / contract_date) — тоже МЕНЯЮЩИЙСЯ: ПОСЛЕ дедупа (на
|
||||
# ПОСЛЕДНЕМ снапшоте), как делал view. Протолкнуть ДО DISTINCT ON СЛОМАЛО БЫ
|
||||
# семантику (включил бы когда-либо-проданные + дедуп среди sold-снапшотов).
|
||||
_ARTIFICIAL_DEMAND_SQL = text(
|
||||
"""
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
project_name, corpus_name, section, floor, lot_number
|
||||
)
|
||||
objective_lot_id,
|
||||
is_sold,
|
||||
contract_date,
|
||||
class,
|
||||
encumbrance_type,
|
||||
bank_name
|
||||
FROM objective_lots
|
||||
WHERE premise_kind = CAST(:premise_kind AS text)
|
||||
WITH sold AS (
|
||||
SELECT
|
||||
ol.objective_lot_id,
|
||||
(ol.encumbrance_type IS NOT NULL OR ol.bank_name IS NOT NULL) AS has_mortgage
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = CAST(:premise_kind AS text)
|
||||
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR district = ANY(CAST(:districts AS text[]))
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
ORDER BY project_name, corpus_name, section, floor,
|
||||
lot_number, snapshot_date DESC, id DESC
|
||||
),
|
||||
sold AS (
|
||||
SELECT
|
||||
objective_lot_id,
|
||||
(encumbrance_type IS NOT NULL OR bank_name IS NOT NULL) AS has_mortgage
|
||||
FROM latest
|
||||
WHERE (is_sold IS TRUE OR contract_date IS NOT NULL)
|
||||
AND (
|
||||
CAST(:obj_class AS text) IS NULL
|
||||
OR lower(class) = lower(CAST(:obj_class AS text))
|
||||
OR lower(ol.class) = lower(CAST(:obj_class AS text))
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
|
|
@ -994,29 +954,16 @@ def _query_parcel_centroid(db: Session, *, cad_num: str) -> tuple[float, float]
|
|||
|
||||
Нет геометрии / сбой → None (гео-веса упадут на floor; overlap считается по остальным
|
||||
осям, индекс НЕ деградирует целиком). Параметризовано (psycopg v3). Детерминированно.
|
||||
|
||||
SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): этот helper
|
||||
зовётся из `_build_cannibalization` — builder, обёрнутый внешним SAVEPOINT в
|
||||
`compute_special_indices._run`. БЕЗ собственного `db.begin_nested():` сбойный
|
||||
`db.execute` тут проглатывается локально (→ None), оставляя транзакцию Postgres
|
||||
aborted; тогда ВНЕШНИЙ `_run`-savepoint выходит «нормально» и делает RELEASE
|
||||
SAVEPOINT, а RELEASE в aborted-tx САМ падает (Postgres в aborted допускает только
|
||||
ROLLBACK / ROLLBACK TO SAVEPOINT) — внешний except ловит уже RELEASE-ошибку, но
|
||||
транзакция так и не откачена → отравление каскадит в следующий §25-индекс. Savepoint
|
||||
ИМЕННО ЗДЕСЬ (в точке перехвата) откатывает сбой (ROLLBACK TO SAVEPOINT), поэтому
|
||||
внешний RELEASE проходит. Тот же принцип, что saturation.py: savepoint у db.execute
|
||||
в точке, где ошибка ловится.
|
||||
"""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
row = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL,
|
||||
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
row = (
|
||||
db.execute(
|
||||
_PARCEL_CENTROID_SQL,
|
||||
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("cannibalization: centroid query failed for cad_num=%s", cad_num)
|
||||
return None
|
||||
|
|
@ -1670,20 +1617,9 @@ def compute_special_indices(
|
|||
segment = spec.as_dict()
|
||||
|
||||
def _run(key: str, builder: Any) -> SpecialIndex:
|
||||
"""Выполнить builder одного индекса в собственном try/except (graceful).
|
||||
|
||||
SAVEPOINT (#2464 cluster A finding #3): все шесть builder'ов делят ОДНУ `db`-
|
||||
Session (общая сессия §22-отчёта). Без SAVEPOINT сбойный `db.execute` внутри
|
||||
builder'а оставляет транзакцию Postgres aborted — КАЖДЫЙ следующий индекс на
|
||||
той же сессии тоже падает (хотя его данные были бы доступны), что маскируется
|
||||
под шесть независимых деградаций. `with db.begin_nested():` заводит SAVEPOINT
|
||||
на ВЕСЬ builder() (некоторые builder'ы делают несколько db.execute внутри
|
||||
себя) — сбой откатывает только его SAVEPOINT, сессия остаётся рабочей для
|
||||
следующего индекса.
|
||||
"""
|
||||
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
|
||||
try:
|
||||
with db.begin_nested():
|
||||
return builder() # type: ignore[no-any-return]
|
||||
return builder() # type: ignore[no-any-return]
|
||||
except Exception:
|
||||
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
|
||||
logger.exception(
|
||||
|
|
|
|||
|
|
@ -64,10 +64,7 @@ Confidence = Literal["high", "medium", "low"]
|
|||
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
|
||||
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
|
||||
# может добавить). Держим явной константой для детерминизма сетки.
|
||||
# #1959: нижний массовый класс objective_lots.class — «стандарт», НЕ «эконом»
|
||||
# (verified на prod: стандарт=483k лотов, эконома НЕТ → раньше эта ячейка матчила
|
||||
# 0 строк → молча выпадала из сетки; посегментный дефицит её не видел).
|
||||
_DEFAULT_CLASSES: tuple[str, ...] = ("стандарт", "комфорт", "бизнес")
|
||||
_DEFAULT_CLASSES: tuple[str, ...] = ("эконом", "комфорт", "бизнес")
|
||||
|
||||
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
|
||||
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
"""Generative Design — Stage 3a (#1965): каталог типовых домов (house-type catalog).
|
||||
|
||||
Контракт Stage 3a: вместо max-FAR жадной раскладки (Stage 1b) пользователь выбирает
|
||||
ТИПОВЫЕ дома и говорит, сколько секций каждого типа поставить. Этот модуль — справочник
|
||||
таких типов: для каждого ``section_type`` он несёт габариты пятна секции (ширина × глубина,
|
||||
метры), дефолтную этажность и подходящий класс жилья.
|
||||
|
||||
ИСТОЧНИК: это РАЗУМНЫЙ ДЕФОЛТНЫЙ каталог (sane-default), а не выгрузка из БД. Габариты —
|
||||
типовые размеры секций массового жилья РФ (панель/монолит/башня/малоэтажка), округлённые
|
||||
до реалистичных значений. Каталог намеренно захардкожен в коде на Stage 3a: миграции БД
|
||||
сейчас нет (см. эпик #1953). Когда понадобится редактируемый застройщиком каталог — его
|
||||
ПРОДВИГАЮТ в БД-таблицу с тем же контрактом (``section_type`` → footprint/floors/class),
|
||||
а этот модуль станет seed'ом/фолбэком. До тех пор — single source of truth по типам.
|
||||
|
||||
Детерминированно, без LLM / внешних API / БД.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Класс жилья — то же Literal-множество, что у ConceptInput / compute_teap (single
|
||||
# source of truth по допустимым значениям; рассинхрон тут = ошибка типов в mypy-strict).
|
||||
HousingClass = Literal["econom", "comfort", "business"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HouseType:
|
||||
"""Один типовой дом каталога (тип секции МКД / малоэтажки).
|
||||
|
||||
section_type — стабильный машинный КЛЮЧ типа (латиница, snake_case); это и есть
|
||||
значение, которое фронт кладёт в ``BuildingProgramItem.section_type``.
|
||||
label_ru — человекочитаемый русский лейбл для UI (Stage 3b его показывает).
|
||||
footprint_w_m — ширина пятна секции, метры.
|
||||
footprint_d_m — глубина пятна секции, метры.
|
||||
default_floors — дефолтная этажность типа (UI подставляет, пользователь может менять
|
||||
в пределах контракта BuildingProgramItem [1, 40]).
|
||||
housing_class — подходящий класс жилья (драйвит ТЭП/финмодель ниже по конвейеру).
|
||||
"""
|
||||
|
||||
section_type: str
|
||||
label_ru: str
|
||||
footprint_w_m: float
|
||||
footprint_d_m: float
|
||||
default_floors: int
|
||||
housing_class: HousingClass
|
||||
|
||||
@property
|
||||
def footprint_sqm(self) -> float:
|
||||
"""Площадь пятна секции, кв.м (ширина × глубина)."""
|
||||
return self.footprint_w_m * self.footprint_d_m
|
||||
|
||||
|
||||
# ── Каталог типовых домов (sane-default, см. модульный docstring про источник) ──────
|
||||
# Покрываем основные форматы массового жилья РФ:
|
||||
# * панель-эконом — длинная неглубокая секция, средняя этажность, эконом-класс;
|
||||
# * монолит-комфорт— чуть шире/глубже, комфорт-класс, типовая «свечка» 14 этажей;
|
||||
# * башня-бизнес — компактное квадратное пятно, высотная (точечная) застройка;
|
||||
# * малоэтажка-комфорт — широкая невысокая секция (3 этажа), низкоплотная застройка;
|
||||
# * таунхаус — узкое неглубокое пятно блокированной застройки, 3 этажа.
|
||||
# Габариты — реалистичные типовые размеры; этажность — характерная для формата.
|
||||
HOUSE_TYPES: tuple[HouseType, ...] = (
|
||||
HouseType(
|
||||
section_type="panel_econom",
|
||||
label_ru="Панельная секция (эконом)",
|
||||
footprint_w_m=24.0,
|
||||
footprint_d_m=15.0,
|
||||
default_floors=9,
|
||||
housing_class="econom",
|
||||
),
|
||||
HouseType(
|
||||
section_type="monolith_comfort",
|
||||
label_ru="Монолитная секция (комфорт)",
|
||||
footprint_w_m=21.0,
|
||||
footprint_d_m=18.0,
|
||||
default_floors=14,
|
||||
housing_class="comfort",
|
||||
),
|
||||
HouseType(
|
||||
section_type="tower_business",
|
||||
label_ru="Башня (бизнес)",
|
||||
footprint_w_m=18.0,
|
||||
footprint_d_m=18.0,
|
||||
default_floors=25,
|
||||
housing_class="business",
|
||||
),
|
||||
HouseType(
|
||||
section_type="lowrise_comfort",
|
||||
label_ru="Малоэтажная секция (комфорт)",
|
||||
footprint_w_m=30.0,
|
||||
footprint_d_m=14.0,
|
||||
default_floors=3,
|
||||
housing_class="comfort",
|
||||
),
|
||||
HouseType(
|
||||
section_type="townhouse",
|
||||
label_ru="Таунхаус",
|
||||
footprint_w_m=12.0,
|
||||
footprint_d_m=10.0,
|
||||
default_floors=3,
|
||||
housing_class="comfort",
|
||||
),
|
||||
)
|
||||
|
||||
# Индекс по ключу для O(1)-лукапа. Построен один раз при импорте; ключи уникальны
|
||||
# (assert ниже ловит дубликат типа на старте, а не молча затирает запись).
|
||||
_BY_KEY: dict[str, HouseType] = {ht.section_type: ht for ht in HOUSE_TYPES}
|
||||
assert len(_BY_KEY) == len(HOUSE_TYPES), "duplicate section_type key in HOUSE_TYPES"
|
||||
|
||||
|
||||
def get_house_type(section_type: str) -> HouseType:
|
||||
"""Найти тип дома по ключу ``section_type``. Бросает :class:`KeyError`, если нет.
|
||||
|
||||
Вызывающий слой (placement) обязан валидировать ключи программы заранее (см.
|
||||
:func:`available_section_types`) — неизвестный ключ здесь это программная ошибка,
|
||||
а не пользовательский ввод, поэтому KeyError, а не тихий None.
|
||||
"""
|
||||
try:
|
||||
return _BY_KEY[section_type]
|
||||
except KeyError as exc:
|
||||
raise KeyError(
|
||||
f"unknown house type {section_type!r}; " f"available: {', '.join(sorted(_BY_KEY))}"
|
||||
) from exc
|
||||
|
||||
|
||||
def available_section_types() -> frozenset[str]:
|
||||
"""Множество допустимых ключей ``section_type`` каталога (для валидации программы)."""
|
||||
return frozenset(_BY_KEY)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HOUSE_TYPES",
|
||||
"HouseType",
|
||||
"HousingClass",
|
||||
"available_section_types",
|
||||
"get_house_type",
|
||||
]
|
||||
|
|
@ -24,11 +24,6 @@ import logging
|
|||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
from ezdxf.filemanagement import new as ezdxf_new
|
||||
|
||||
# Modelspace определён в ezdxf.layouts.layout и не в __all__ пакета ezdxf.layouts;
|
||||
# импорт из модуля-определителя удовлетворяет strict no-implicit-reexport (как ezdxf_new).
|
||||
from ezdxf.layouts.layout import Modelspace
|
||||
from shapely.coords import CoordinateSequence
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from app.schemas.concept import ConceptVariant
|
||||
|
|
@ -49,7 +44,7 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
|||
_LABEL_HEIGHT_M = 2.0
|
||||
|
||||
|
||||
def _ring_points(coords: CoordinateSequence) -> list[tuple[float, float]]:
|
||||
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||
pts = list(coords)
|
||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||
|
|
@ -58,7 +53,7 @@ def _ring_points(coords: CoordinateSequence) -> list[tuple[float, float]]:
|
|||
return [(float(x), float(y)) for x, y in pts]
|
||||
|
||||
|
||||
def _add_polygon(msp: Modelspace, poly: Polygon, layer: str) -> None:
|
||||
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
|
||||
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
||||
|
||||
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
||||
|
|
@ -156,15 +151,15 @@ def _feature_to_metric_polygon(parcel: Parcel, feature: object) -> Polygon | Non
|
|||
return None
|
||||
coords = geometry.get("coordinates")
|
||||
# Shapely mapping() emits nested tuples; accept both tuple and list.
|
||||
if not isinstance(coords, list | tuple) or not coords:
|
||||
if not isinstance(coords, (list, tuple)) or not coords:
|
||||
return None
|
||||
ring = coords[0]
|
||||
if not isinstance(ring, list | tuple) or len(ring) < 4:
|
||||
if not isinstance(ring, (list, tuple)) or len(ring) < 4:
|
||||
return None
|
||||
|
||||
metric_pts: list[tuple[float, float]] = []
|
||||
for pt in ring:
|
||||
if not isinstance(pt, list | tuple) or len(pt) < 2:
|
||||
if not isinstance(pt, (list, tuple)) or len(pt) < 2:
|
||||
return None
|
||||
lon, lat = float(pt[0]), float(pt[1])
|
||||
x, y = parcel.wgs84_to_metric(lon, lat)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ def _strategy_label(strategy: str) -> str:
|
|||
|
||||
def _teap_table(variants: Sequence[ConceptVariant]) -> str:
|
||||
"""HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии)."""
|
||||
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
|
||||
headers = "".join(
|
||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
||||
)
|
||||
rows: list[tuple[str, list[str]]] = [
|
||||
("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]),
|
||||
("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]),
|
||||
|
|
@ -105,7 +107,9 @@ def _fmt_irr(financial: FinancialModel) -> str:
|
|||
|
||||
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
|
||||
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF)."""
|
||||
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
|
||||
headers = "".join(
|
||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
||||
)
|
||||
rows: list[tuple[str, list[str]]] = [
|
||||
(
|
||||
"Выручка — жильё, млн руб",
|
||||
|
|
@ -184,13 +188,9 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
|||
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
|
||||
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||
"НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), "
|
||||
"входной НДС по СМР встроен в себестоимость; НДС начисляется на нежилые части — "
|
||||
"машиноместа и коммерцию/офисы 1-го этажа (встроенный НДС в добавленной стоимости "
|
||||
"каждой части). Налог на прибыль — 25% (с 2025). Цены и себестоимость — рыночные "
|
||||
"ориентиры. Коммерция/офисы 1-го этажа учтены по нормативной доле от общей площади "
|
||||
"и продаются с умеренной наценкой к цене жилья того же класса; себестоимость СМР "
|
||||
"нежилого — по той же ставке, что и жильё (отдельной строки затрат нет, повторного "
|
||||
"учёта в затратах нет).</p>"
|
||||
"входной НДС по СМР встроен в себестоимость; НДС начисляется только на нежилую часть "
|
||||
"(машиноместа). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
|
||||
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
from pyproj import CRS, Transformer
|
||||
from pyproj.exceptions import CRSError
|
||||
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
|
||||
from shapely.geometry import Polygon, mapping, shape
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
from shapely.ops import transform as shapely_transform
|
||||
|
||||
|
|
@ -54,12 +53,6 @@ MIN_BUILDABLE_AREA_SQM: float = 50.0
|
|||
# удержать время в бюджете (<=10 c/вариант). MVP-упрощение.
|
||||
MAX_GRID_CELLS: int = 20_000
|
||||
|
||||
# Валидные диапазоны WGS84 lon/lat (градусы). Используются, чтобы отловить участок,
|
||||
# присланный в проекции (метры, напр. МСК-66/UTM), ДО того как _metric_transformers
|
||||
# соберёт AEQD с lat_0/lon_0 далеко за пределами градусов -> pyproj.CRSError.
|
||||
_LON_RANGE: tuple[float, float] = (-180.0, 180.0)
|
||||
_LAT_RANGE: tuple[float, float] = (-90.0, 90.0)
|
||||
|
||||
# WGS84 (вход контракта).
|
||||
_WGS84 = CRS.from_epsg(4326)
|
||||
|
||||
|
|
@ -149,19 +142,6 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
|
|||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||
raise ParcelGeometryError(f"cannot parse GeoJSON geometry: {exc}") from exc
|
||||
|
||||
# Многоконтурный участок (MultiPolygon): берём крупнейший контур. У некоторых
|
||||
# КН-участков в Росреестре несколько разрозненных полигонов — концепцию строим
|
||||
# по основному (наибольшему по площади) контуру; остальные обычно вкрапления.
|
||||
if isinstance(geom, MultiPolygon):
|
||||
if geom.is_empty or not geom.geoms:
|
||||
raise ParcelGeometryError("parcel polygon is empty")
|
||||
n_contours = len(geom.geoms)
|
||||
geom = max(geom.geoms, key=lambda g: g.area)
|
||||
logger.info(
|
||||
"parse_parcel: MultiPolygon → крупнейший контур из %d",
|
||||
n_contours,
|
||||
)
|
||||
|
||||
if geom.geom_type != "Polygon":
|
||||
raise ParcelGeometryError(f"expected Polygon, got {geom.geom_type}")
|
||||
if geom.is_empty:
|
||||
|
|
@ -177,27 +157,6 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
|
|||
return polygon
|
||||
|
||||
|
||||
def _assert_wgs84_bounds(polygon: Polygon) -> None:
|
||||
"""Проверить, что bbox полигона лежит в валидных диапазонах WGS84 lon/lat.
|
||||
|
||||
Root-cause фикс: фронтовый баг / плохой геокодер / demo-payload иногда шлёт
|
||||
``parcel_geojson`` в проекции (метры МСК-66/UTM, напр. [500000, 6200000]) вместо
|
||||
WGS84 lon/lat. Такой полигон синтаксически валиден (это просто Polygon), но
|
||||
дальше он бы дошёл до :func:`_metric_transformers`, где centroid.y (~6_200_000)
|
||||
подставится в ``+lat_0=`` -> pyproj.CRS.from_proj4 упадёт CRSError (opaque 500).
|
||||
Ловим здесь, ДО построения проекции, с понятным сообщением (422).
|
||||
"""
|
||||
minx, miny, maxx, maxy = polygon.bounds
|
||||
lon_ok = _LON_RANGE[0] <= minx and maxx <= _LON_RANGE[1]
|
||||
lat_ok = _LAT_RANGE[0] <= miny and maxy <= _LAT_RANGE[1]
|
||||
if not (lon_ok and lat_ok):
|
||||
raise ParcelGeometryError(
|
||||
"parcel_geojson coordinates out of WGS84 lon/lat range "
|
||||
f"(lon∈{_LON_RANGE}, lat∈{_LAT_RANGE}, got bounds={polygon.bounds}) — "
|
||||
"coordinates appear to be projected (e.g. МСК-66/UTM metres), not WGS84 lon/lat"
|
||||
)
|
||||
|
||||
|
||||
def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transformer]:
|
||||
"""Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка.
|
||||
|
||||
|
|
@ -205,16 +164,10 @@ def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transform
|
|||
координат и точен на масштабе квартала — не нужен выбор UTM-зоны.
|
||||
"""
|
||||
centroid = polygon_wgs84.centroid
|
||||
try:
|
||||
metric_crs = CRS.from_proj4(
|
||||
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
|
||||
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
|
||||
)
|
||||
except CRSError as exc:
|
||||
# Belt-and-suspenders: _assert_wgs84_bounds должен был отловить это раньше,
|
||||
# но на случай иной координатной патологии не даём CRSError утечь наружу
|
||||
# непойманным 500 — это невалидная геометрия участка, т.е. 422.
|
||||
raise ParcelGeometryError(f"cannot build metric CRS for parcel centroid: {exc}") from exc
|
||||
metric_crs = CRS.from_proj4(
|
||||
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
|
||||
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
|
||||
)
|
||||
to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True)
|
||||
to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True)
|
||||
return to_metric, to_wgs84
|
||||
|
|
@ -287,11 +240,9 @@ def parse_parcel(
|
|||
"""Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid).
|
||||
|
||||
Raises:
|
||||
ParcelGeometryError: полигон невалиден, координаты не в WGS84 lon/lat, или
|
||||
пятно застройки вырождается.
|
||||
ParcelGeometryError: полигон невалиден или пятно застройки вырождается.
|
||||
"""
|
||||
polygon_wgs84 = _parse_polygon(payload.parcel_geojson)
|
||||
_assert_wgs84_bounds(polygon_wgs84)
|
||||
to_metric, to_wgs84 = _metric_transformers(polygon_wgs84)
|
||||
|
||||
def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]:
|
||||
|
|
|
|||
|
|
@ -23,19 +23,13 @@ Deterministic, no LLM / no external API / no DB.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shapely.geometry import Polygon, box
|
||||
from shapely.strtree import STRtree
|
||||
|
||||
from app.schemas.concept import (
|
||||
TEAP,
|
||||
BuildingProgramItem,
|
||||
ConceptInput,
|
||||
ConceptVariant,
|
||||
)
|
||||
from app.services.generative import catalog, financial, teap
|
||||
from app.schemas.concept import ConceptInput, ConceptVariant
|
||||
from app.services.generative import financial, teap
|
||||
from app.services.generative.geometry import Parcel, ParcelGeometryError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -98,53 +92,6 @@ def _resolve_floors(target_floors: int, factor: float) -> int:
|
|||
return max(_FLOORS_MIN, min(_FLOORS_MAX, floors))
|
||||
|
||||
|
||||
class _Placer:
|
||||
"""Аккумулятор размещённых секций + STRtree-индекс для проверки разрывов.
|
||||
|
||||
Извлечён из жадной раскладки, чтобы и :func:`_greedy_place` (coverage-cap sweep), и
|
||||
:func:`place_program` (фиксированная программа типовых домов, Stage 3a) пользовались
|
||||
ОДНОЙ И ТОЙ ЖЕ collision/setback-машиной, а не дублировали её. Состояние: принятые
|
||||
footprints, их буферизованные на ``half_gap`` версии и перестраиваемый по ним STRtree.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.placed: list[Polygon] = []
|
||||
self.built_area: float = 0.0
|
||||
# Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
|
||||
self._buffered: list[Polygon] = []
|
||||
self._tree: STRtree | None = None
|
||||
|
||||
def try_place(self, footprint: Polygon, buildable: Polygon, half_gap: float) -> bool:
|
||||
"""Попытаться принять ``footprint``. True — принят, False — не лёг.
|
||||
|
||||
Принимается, если целиком внутри ``buildable`` (covers допускает касание границы)
|
||||
И не нарушает разрыв ``half_gap*2`` с уже принятыми (буферим кандидата на
|
||||
``half_gap`` и проверяем пересечение с буферизованными соседями через STRtree —
|
||||
две секции с зазором >= gap не пересекутся).
|
||||
"""
|
||||
if not buildable.covers(footprint):
|
||||
return False
|
||||
|
||||
candidate_buf = footprint.buffer(half_gap, join_style="mitre")
|
||||
if self._tree is not None:
|
||||
for idx in self._tree.query(candidate_buf):
|
||||
if candidate_buf.intersects(self._buffered[idx]):
|
||||
return False
|
||||
|
||||
self.placed.append(footprint)
|
||||
self.built_area += footprint.area
|
||||
self._buffered.append(candidate_buf)
|
||||
self._tree = STRtree(self._buffered)
|
||||
return True
|
||||
|
||||
|
||||
def _centered_footprint(cx: float, cy: float, width: float, depth: float) -> Polygon:
|
||||
"""Прямоугольное пятно секции ``width × depth``, центрированное на (cx, cy), метры."""
|
||||
half_w = width / 2.0
|
||||
half_d = depth / 2.0
|
||||
return box(cx - half_w, cy - half_d, cx + half_w, cy + half_d)
|
||||
|
||||
|
||||
def _greedy_place(
|
||||
parcel: Parcel,
|
||||
spec: StrategySpec,
|
||||
|
|
@ -156,32 +103,63 @@ def _greedy_place(
|
|||
* кандидат-якоря — центры ячеек сетки в фиксированном порядке;
|
||||
* footprint строится центрированно на якоре;
|
||||
* принимается, если целиком внутри buildable area И не нарушает разрыв ``gap_m``
|
||||
с уже принятыми (проверка через STRtree, см. :class:`_Placer`);
|
||||
с уже принятыми (проверка через STRtree по buffered-footprints);
|
||||
* раскладка останавливается, когда пятно достигает ``coverage_cap`` от buildable
|
||||
area (регулятор плотности по типу застройки) — это также ограничивает число
|
||||
размещений и держит O(n^2)-перестройку STRtree в бюджете.
|
||||
"""
|
||||
buildable = parcel.buildable_m
|
||||
max_built = buildable.area * coverage_cap
|
||||
placer = _Placer()
|
||||
placed: list[Polygon] = []
|
||||
built_area = 0.0
|
||||
# Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
|
||||
buffered: list[Polygon] = []
|
||||
tree: STRtree | None = None
|
||||
|
||||
half_w = spec.section_w / 2.0
|
||||
half_d = spec.section_d / 2.0
|
||||
half_gap = spec.gap_m / 2.0
|
||||
|
||||
for cell in parcel.grid:
|
||||
if placer.built_area >= max_built:
|
||||
if built_area >= max_built:
|
||||
break
|
||||
footprint = _centered_footprint(cell.cx, cell.cy, spec.section_w, spec.section_d)
|
||||
placer.try_place(footprint, buildable, half_gap)
|
||||
footprint = box(
|
||||
cell.cx - half_w,
|
||||
cell.cy - half_d,
|
||||
cell.cx + half_w,
|
||||
cell.cy + half_d,
|
||||
)
|
||||
# Целиком внутри пятна застройки (covers допускает касание границы).
|
||||
if not buildable.covers(footprint):
|
||||
continue
|
||||
|
||||
# Разрыв между секциями: буферим кандидата на half_gap и проверяем пересечение
|
||||
# с буферизованными соседями — две секции с зазором >= gap_m не пересекутся.
|
||||
candidate_buf = footprint.buffer(half_gap, join_style="mitre")
|
||||
if tree is not None:
|
||||
collision = False
|
||||
for idx in tree.query(candidate_buf):
|
||||
if candidate_buf.intersects(buffered[idx]):
|
||||
collision = True
|
||||
break
|
||||
if collision:
|
||||
continue
|
||||
|
||||
placed.append(footprint)
|
||||
built_area += footprint.area
|
||||
buffered.append(candidate_buf)
|
||||
tree = STRtree(buffered)
|
||||
|
||||
logger.info(
|
||||
"strategy=%s placed %d sections (%.0fx%.0f m, gap=%.0f m, coverage<=%.0f%%)",
|
||||
spec.name,
|
||||
len(placer.placed),
|
||||
len(placed),
|
||||
spec.section_w,
|
||||
spec.section_d,
|
||||
spec.gap_m,
|
||||
coverage_cap * 100,
|
||||
)
|
||||
return placer.placed
|
||||
return placed
|
||||
|
||||
|
||||
def _footprints_to_geojson(
|
||||
|
|
@ -209,268 +187,6 @@ def _footprints_to_geojson(
|
|||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
# ── Stage 3a (#1965): program-driven placement (типовые дома вместо max-FAR sweep) ──
|
||||
# Разрыв между секциями в program-режиме (м). Пункт программы не несёт gap (контракт —
|
||||
# только тип/этажность/количество), поэтому берём один нормативный противопожарный/
|
||||
# инсоляционный зазор для всех секций программы — середина диапазона стратегий 1b
|
||||
# (max_area gap=6 … max_insolation gap=15). Достаточно консервативно для реалистичной
|
||||
# раскладки, не патологически разрежено.
|
||||
_PROGRAM_GAP_M: float = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PlacedSection:
|
||||
"""Одна размещённая секция программы: пятно + этажность + тип каталога (для GeoJSON)."""
|
||||
|
||||
footprint: Polygon
|
||||
floors: int
|
||||
section_type: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlacedProgram:
|
||||
"""Результат program-раскладки: что легло + честный счётчик «N из M».
|
||||
|
||||
``sections`` — размещённые секции (пятна + этажность + тип) в порядке программы.
|
||||
``requested_count`` — сколько секций просили (Σ count по программе).
|
||||
``placed_count`` — сколько реально влезло (== len(sections)). placed < requested →
|
||||
участок мал, разместилось N из M (без hard-422 — честный сигнал для Stage 3b).
|
||||
"""
|
||||
|
||||
sections: tuple[_PlacedSection, ...]
|
||||
requested_count: int
|
||||
placed_count: int
|
||||
|
||||
|
||||
def place_program(
|
||||
parcel: Parcel,
|
||||
program: list[BuildingProgramItem],
|
||||
*,
|
||||
gap_m: float = _PROGRAM_GAP_M,
|
||||
) -> PlacedProgram:
|
||||
"""Stage 3a: разложить РОВНО заданную программу типовых домов на участок.
|
||||
|
||||
Для каждого пункта программы (``section_type`` из каталога × ``count`` секций) кладём
|
||||
до ``count`` секций каталожного пятна на сетку участка, переиспользуя ТУ ЖЕ
|
||||
collision/STRtree/setback-машину, что и жадная раскладка (:class:`_Placer`) — никакого
|
||||
coverage-cap, стоп-критерий = достигнут ``count`` для пункта или кончились свободные
|
||||
якоря. Пункты обрабатываются по порядку; накопленные секции участвуют в проверке
|
||||
разрыва для последующих (общий :class:`_Placer`).
|
||||
|
||||
Если участок не вмещает все запрошенные секции, НЕ роняем 422 — кладём сколько влезло
|
||||
и возвращаем честный ``placed_count``/``requested_count`` (разместилось N из M).
|
||||
|
||||
Raises:
|
||||
KeyError: ``section_type`` пункта нет в каталоге (валидируется на API-слое до
|
||||
размещения; здесь это программная ошибка контракта, не пользовательский ввод).
|
||||
"""
|
||||
buildable = parcel.buildable_m
|
||||
half_gap = gap_m / 2.0
|
||||
placer = _Placer()
|
||||
placed_sections: list[_PlacedSection] = []
|
||||
requested = 0
|
||||
|
||||
for item in program:
|
||||
house = catalog.get_house_type(item.section_type)
|
||||
requested += item.count
|
||||
# Ручное пятно (Stage 3c): если пользователь задал ОБА габарита — кладём
|
||||
# его вместо каталожного («вписать пятно»). Частичное задание игнорируем
|
||||
# (нужны и ширина, и глубина), падая обратно на каталог.
|
||||
if item.footprint_w_m is not None and item.footprint_d_m is not None:
|
||||
fp_w, fp_d = item.footprint_w_m, item.footprint_d_m
|
||||
else:
|
||||
fp_w, fp_d = house.footprint_w_m, house.footprint_d_m
|
||||
placed_for_item = 0
|
||||
for cell in parcel.grid:
|
||||
if placed_for_item >= item.count:
|
||||
break
|
||||
footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
|
||||
if placer.try_place(footprint, buildable, half_gap):
|
||||
placed_sections.append(
|
||||
_PlacedSection(
|
||||
footprint=footprint,
|
||||
floors=item.floors,
|
||||
section_type=item.section_type,
|
||||
)
|
||||
)
|
||||
placed_for_item += 1
|
||||
if placed_for_item < item.count:
|
||||
logger.warning(
|
||||
"program: type=%s placed %d of %d sections (%.0fx%.0f m) — участок мал",
|
||||
item.section_type,
|
||||
placed_for_item,
|
||||
item.count,
|
||||
house.footprint_w_m,
|
||||
house.footprint_d_m,
|
||||
)
|
||||
|
||||
result = PlacedProgram(
|
||||
sections=tuple(placed_sections),
|
||||
requested_count=requested,
|
||||
placed_count=len(placed_sections),
|
||||
)
|
||||
logger.info(
|
||||
"program placed %d of %d sections across %d type(s)",
|
||||
result.placed_count,
|
||||
result.requested_count,
|
||||
len(program),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _placed_program_to_geojson(
|
||||
parcel: Parcel,
|
||||
sections: tuple[_PlacedSection, ...],
|
||||
) -> dict[str, object]:
|
||||
"""Размещённые секции программы -> WGS84 FeatureCollection (контракт buildings_geojson).
|
||||
|
||||
Зеркалит :func:`_footprints_to_geojson`, но каждая секция несёт СВОИ floors и тип
|
||||
каталога (program-режим смешивает типы/этажности), а ``strategy`` помечается
|
||||
``"program"`` — маркер, что вариант построен из программы, а не из 1b-стратегии.
|
||||
"""
|
||||
features: list[dict[str, object]] = []
|
||||
for i, sec in enumerate(sections):
|
||||
geom_wgs = parcel.metric_geom_to_wgs84(sec.footprint)
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": geom_wgs,
|
||||
"properties": {
|
||||
"section_id": i + 1,
|
||||
"floors": sec.floors,
|
||||
"footprint_sqm": round(float(sec.footprint.area), 1),
|
||||
"section_type": sec.section_type,
|
||||
"strategy": "program",
|
||||
},
|
||||
}
|
||||
)
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def _aggregate_program_teap(
|
||||
sections: tuple[_PlacedSection, ...],
|
||||
*,
|
||||
site_area_sqm: float,
|
||||
housing_class: teap.HousingClass,
|
||||
) -> TEAP:
|
||||
"""Свести размещённую программу (СМЕШАННАЯ этажность) в один :class:`TEAP` — ТОЧНО.
|
||||
|
||||
``compute_teap`` берёт ОДНУ этажность на список пятен, поэтому при смешанной по типам
|
||||
этажности нельзя просто скормить ему все пятна с одним числом (округлённая «средняя»
|
||||
этажность даёт дрейф GFA ~1%). Вместо этого группируем секции по этажности, считаем
|
||||
``compute_teap`` для каждой однородной группы и СУММИРУЕМ результаты:
|
||||
|
||||
* built / GFA / office / residential — аддитивны → сумма точна (GFA = Σ площадь_i×floors_i);
|
||||
* apartments — ``Σ floor(жилая_g / avg)`` по группам: физически корректнее, чем
|
||||
``floor(Σжилая / avg)`` (нельзя «склеивать» дробные квартиры между корпусами);
|
||||
* parking — пересчитываем от ИТОГОВОГО числа квартир по той же норме класса (ceil от
|
||||
суммы, а не сумма ceil — иначе пер-группное округление вверх задвоит места);
|
||||
* density (FAR) — от суммарной GFA и площади участка (защита от деления на ноль).
|
||||
|
||||
Единый источник всех нормативных коэффициентов остаётся ``teap``-модуль (классовые
|
||||
словари), новых магических чисел нет.
|
||||
"""
|
||||
if not sections:
|
||||
return teap.compute_teap(
|
||||
footprints=[], floors=0, site_area_sqm=site_area_sqm, housing_class=housing_class
|
||||
)
|
||||
|
||||
# Группируем по этажности; внутри группы compute_teap корректен (одна этажность).
|
||||
groups: dict[int, list[Polygon]] = {}
|
||||
for sec in sections:
|
||||
groups.setdefault(sec.floors, []).append(sec.footprint)
|
||||
|
||||
built = 0.0
|
||||
gfa = 0.0
|
||||
office = 0.0
|
||||
residential = 0.0
|
||||
apartments = 0
|
||||
for floors, fps in groups.items():
|
||||
# site_area_sqm здесь не важна для аддитивных полей — FAR пересчитаем в конце.
|
||||
group = teap.compute_teap(
|
||||
footprints=fps,
|
||||
floors=floors,
|
||||
site_area_sqm=site_area_sqm,
|
||||
housing_class=housing_class,
|
||||
)
|
||||
built += group.built_area_sqm
|
||||
gfa += group.total_floor_area_sqm
|
||||
office += group.office_area_sqm
|
||||
residential += group.residential_area_sqm
|
||||
apartments += group.apartments_count
|
||||
|
||||
density = gfa / site_area_sqm if site_area_sqm > 0 else 0.0
|
||||
parking_norm = teap._PARKING_PER_APARTMENT[housing_class]
|
||||
parking_spaces = math.ceil(apartments * parking_norm)
|
||||
|
||||
return TEAP(
|
||||
built_area_sqm=round(built, 1),
|
||||
total_floor_area_sqm=round(gfa, 1),
|
||||
office_area_sqm=round(office, 1),
|
||||
residential_area_sqm=round(residential, 1),
|
||||
apartments_count=apartments,
|
||||
density=round(density, 3),
|
||||
parking_spaces=parking_spaces,
|
||||
)
|
||||
|
||||
|
||||
def place_program_variant(
|
||||
parcel: Parcel,
|
||||
payload: ConceptInput,
|
||||
*,
|
||||
market_price_per_sqm: float | None = None,
|
||||
price_source: str = "class_norm",
|
||||
) -> ConceptVariant | None:
|
||||
"""Stage 3a: построить ОДИН вариант из ``payload.building_program`` (типовые дома).
|
||||
|
||||
Раскладывает программу (:func:`place_program`), сводит размещённые пятна в ТЭП
|
||||
(:func:`_aggregate_program_teap` — точная GFA по группам этажности) и финмодель, и
|
||||
наклеивает честный сигнал частичного размещения (``placed_count``/``requested_count``).
|
||||
Возвращает ``None``, если ни одна секция не легла (участок не вмещает даже одну секцию
|
||||
программы) — вызывающий отбракует, как и в жадном пути.
|
||||
|
||||
``payload.building_program`` ДОЛЖЕН быть задан (вызывается только из program-ветки).
|
||||
"""
|
||||
program = payload.building_program
|
||||
if not program: # защитный инвариант: эту ветку зовут только при заданной программе
|
||||
raise ValueError("place_program_variant called without building_program")
|
||||
|
||||
placed = place_program(parcel, program)
|
||||
if placed.placed_count == 0:
|
||||
logger.warning(
|
||||
"program placed 0 of %d sections — участок не вмещает программу, отбраковка",
|
||||
placed.requested_count,
|
||||
)
|
||||
return None
|
||||
|
||||
teap_result = _aggregate_program_teap(
|
||||
placed.sections,
|
||||
site_area_sqm=parcel.site_area_sqm,
|
||||
housing_class=payload.housing_class,
|
||||
)
|
||||
financial_result = financial.compute_financial(
|
||||
teap=teap_result,
|
||||
housing_class=payload.housing_class,
|
||||
land_cost_rub=payload.land_cost_rub,
|
||||
market_price_per_sqm=market_price_per_sqm,
|
||||
price_source=price_source,
|
||||
development_type=payload.development_type,
|
||||
)
|
||||
buildings_geojson = _placed_program_to_geojson(parcel, placed.sections)
|
||||
|
||||
# Program-вариант репортуется под "balanced" (контракт strategy — фиксированный
|
||||
# Literal трёх стратегий 1b; program-режим не вводит новую стратегию, маркер режима
|
||||
# лежит в properties.strategy="program" каждой фичи GeoJSON). Один вариант на программу.
|
||||
return ConceptVariant(
|
||||
strategy="balanced",
|
||||
buildings_geojson=buildings_geojson,
|
||||
teap=teap_result,
|
||||
financial=financial_result,
|
||||
placed_count=placed.placed_count,
|
||||
requested_count=placed.requested_count,
|
||||
)
|
||||
|
||||
|
||||
def place_strategy(
|
||||
parcel: Parcel,
|
||||
payload: ConceptInput,
|
||||
|
|
@ -544,31 +260,7 @@ def place_all_strategies(
|
|||
|
||||
``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены жилья, PR-2)
|
||||
прокидываются неизменными в каждую стратегию — цена едина для участка.
|
||||
|
||||
Stage 3a (#1965): если задана ``payload.building_program`` — раскладываем РОВНО эту
|
||||
программу типовых домов (:func:`place_program_variant`, один вариант), а НЕ три жадные
|
||||
стратегии. ``building_program is None`` → существующий жадный путь без изменений.
|
||||
"""
|
||||
if payload.building_program:
|
||||
program_variant = place_program_variant(
|
||||
parcel,
|
||||
payload,
|
||||
market_price_per_sqm=market_price_per_sqm,
|
||||
price_source=price_source,
|
||||
)
|
||||
if program_variant is None:
|
||||
raise ParcelGeometryError(
|
||||
"программа застройки не вместила ни одной секции — "
|
||||
"участок слишком узкий/мелкий для выбранных типов домов"
|
||||
)
|
||||
logger.info(
|
||||
"placed program variant: %d of %d sections, %dкв",
|
||||
program_variant.placed_count,
|
||||
program_variant.requested_count,
|
||||
program_variant.teap.apartments_count,
|
||||
)
|
||||
return [program_variant]
|
||||
|
||||
variants = [
|
||||
variant
|
||||
for spec in _STRATEGIES
|
||||
|
|
@ -596,10 +288,7 @@ def place_all_strategies(
|
|||
|
||||
__all__ = [
|
||||
"FLOOR_HEIGHT_M",
|
||||
"PlacedProgram",
|
||||
"StrategySpec",
|
||||
"place_all_strategies",
|
||||
"place_program",
|
||||
"place_program_variant",
|
||||
"place_strategy",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -136,76 +136,4 @@ def compute_teap(
|
|||
return teap
|
||||
|
||||
|
||||
def synthesize_teap_from_program(
|
||||
*,
|
||||
total_footprint_sqm: float,
|
||||
floors: int,
|
||||
site_area_sqm: float,
|
||||
housing_class: HousingClass,
|
||||
sections: int = 1,
|
||||
) -> TEAP:
|
||||
"""Свести АГРЕГИРОВАННУЮ массинг-программу (скалярное пятно × этажность) в :class:`TEAP`.
|
||||
|
||||
Stage 2a (#1965): LIVE-пересчёт экономики из интерактивного 3D-массинга. Фронтовый
|
||||
``computeModel`` отдаёт уже СВЁРНУТУЮ программу — суммарное пятно застройки (кв.м) и
|
||||
этажность, без покомпонентной геометрии секций. Здесь мы строим из этого тот же
|
||||
:class:`TEAP`, что и :func:`compute_teap`/``synthesize_teap_from_buildability``, чтобы
|
||||
прогнать его через готовый :func:`compute_financial`.
|
||||
|
||||
Зеркалит :func:`synthesize_teap_from_buildability` ОДИН-В-ОДИН, только источник
|
||||
площадей — СКАЛЯРНОЕ пятно, а не ``max_far × area``:
|
||||
|
||||
* ``built`` (пятно) = ``total_footprint_sqm`` (как есть, сумма по секциям).
|
||||
* ``GFA`` (total_floor) = ``total_footprint_sqm × floors``.
|
||||
* ``office`` (нежилое) = ``GFA × _OFFICE_SHARE_OF_GFA[class]`` — вырезается из GFA
|
||||
до расчёта жилой (жилая ужимается ровно на эту площадь, total GFA не меняется).
|
||||
* ``residential`` = ``(GFA − office) × _EFFICIENCY_BY_CLASS[class]``.
|
||||
* ``apartments`` = ``floor(residential / _AVG_APARTMENT_SQM[class])``.
|
||||
* ``parking`` = ``ceil(apartments × _PARKING_PER_APARTMENT[class])``.
|
||||
* ``density`` = ``GFA / site_area_sqm`` (FAR), защита от деления на ноль.
|
||||
|
||||
Чистая функция — без БД / LLM / внешних API. Те же нормативные константы, что и
|
||||
остальные синтезаторы (single source of truth, без новых магических чисел).
|
||||
|
||||
Args:
|
||||
total_footprint_sqm: суммарное пятно застройки всех секций, кв.м (скаляр).
|
||||
floors: этажность (общая для программы); вне диапазона → клампится к >= 0.
|
||||
site_area_sqm: площадь участка для плотности (FAR).
|
||||
housing_class: класс жилья — задаёт эффективность/средний лот/парковку/нежилое.
|
||||
sections: число секций — метаданные программы; на ТЭП НЕ влияет (площади уже
|
||||
свёрнуты во ``total_footprint_sqm``). Принимается для симметрии с контрактом
|
||||
фронта и будущей пер-секционной логики; здесь намеренно не используется.
|
||||
"""
|
||||
del sections # метаданные программы; площади уже агрегированы — на ТЭП не влияют
|
||||
built_area = max(0.0, total_footprint_sqm)
|
||||
total_floor_area = built_area * max(0, floors)
|
||||
|
||||
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA ДО расчёта жилой — точно как
|
||||
# compute_teap / synthesize_teap_from_buildability (single source of truth по долям).
|
||||
office_share = _OFFICE_SHARE_OF_GFA[housing_class]
|
||||
office_area = total_floor_area * office_share
|
||||
residential_gfa = total_floor_area - office_area
|
||||
|
||||
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
|
||||
residential_area = residential_gfa * efficiency
|
||||
|
||||
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
|
||||
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
|
||||
|
||||
density = total_floor_area / site_area_sqm if site_area_sqm > 0 else 0.0
|
||||
|
||||
parking_norm = _PARKING_PER_APARTMENT[housing_class]
|
||||
parking_spaces = math.ceil(apartments_count * parking_norm)
|
||||
|
||||
return TEAP(
|
||||
built_area_sqm=round(built_area, 1),
|
||||
total_floor_area_sqm=round(total_floor_area, 1),
|
||||
office_area_sqm=round(office_area, 1),
|
||||
residential_area_sqm=round(residential_area, 1),
|
||||
apartments_count=apartments_count,
|
||||
density=round(density, 3),
|
||||
parking_spaces=parking_spaces,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["HousingClass", "compute_teap", "synthesize_teap_from_program"]
|
||||
__all__ = ["HousingClass", "compute_teap"]
|
||||
|
|
|
|||
|
|
@ -48,24 +48,22 @@ _SYSTEM_BASE = PromptTemplate(
|
|||
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
|
||||
_CHAT_SYSTEM = PromptTemplate(
|
||||
name="chat_system",
|
||||
version=3,
|
||||
version=2,
|
||||
template=(
|
||||
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
|
||||
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
|
||||
"ЖЁСТКИЕ ПРАВИЛА:\n"
|
||||
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
|
||||
"Чтобы получить нужные числа, вызови подходящий инструмент. Для вопросов про сам "
|
||||
"участок — адрес, площадь, категорию земель, ВРИ, территориальную зону ПЗЗ и её "
|
||||
"код/название, лимиты застройки, ЗОУИТ-обременения — вызови get_parcel_info.\n"
|
||||
"Чтобы получить нужные числа, вызови подходящий инструмент.\n"
|
||||
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
|
||||
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
|
||||
"отчёте нет. Не подставляй правдоподобные значения.\n"
|
||||
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
|
||||
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
|
||||
"инвестиционного решения. Не давай гарантий доходности.\n"
|
||||
"5. Вопросы, выходящие за рамки данных отчёта и паспорта участка (сравнение с другими "
|
||||
"участками, юридические заключения, получение разрешений/согласований) — вежливо "
|
||||
"скажи, что это вне области отчёта, и предложи вопросы по участку и его форсайту.\n"
|
||||
"5. Вопросы вне отчёта по участку (градостроительная документация / ПЗЗ-разрешения, "
|
||||
"сравнение с другими участками, юридические заключения) — вежливо скажи, что это вне "
|
||||
"области отчёта, и предложи вопросы по самому форсайту.\n"
|
||||
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
|
||||
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
|
||||
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
||||
|
|
|
|||
|
|
@ -33,19 +33,15 @@ Wiring (отдельный PR):
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.scrapers.flat_plans import download_plan_image, upsert_flat_plans
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -91,51 +87,6 @@ _STATUS_KW_RE = re.compile(
|
|||
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
|
||||
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
|
||||
|
||||
# ── plan-image extraction ─────────────────────────────────────────────────────
|
||||
#
|
||||
# Страница квартиры каталога DOM.РФ — Next.js SSR (тот же стек что и страница
|
||||
# объекта, см. domrf_catalog_object.py). План квартиры рендерится через next/image,
|
||||
# поэтому реальный URL картинки живёт в JSON-блоке <script id="__NEXT_DATA__">
|
||||
# внутри pageProps, а НЕ в готовом <img src>. Стратегия extraction (в порядке
|
||||
# приоритета, best-effort — DOM.РФ может переименовать ключи без уведомления):
|
||||
#
|
||||
# 1. __NEXT_DATA__ pageProps: ищем значение под одним из кандидат-ключей
|
||||
# (planImage / planImageUrl / layoutImage / floorPlan / scheme / image / photo).
|
||||
# 2. Fallback: bare <img>, чей src/alt/class намекают на планировку
|
||||
# (plan / layout / scheme / планировк), либо <img> рядом с текстом «Планировка».
|
||||
#
|
||||
# URL нормализуется в абсолютный через urljoin(BASE_URL, ...): DOM.РФ отдаёт файлы
|
||||
# и абсолютными (https://xn--80az8a.../api/ext/file/... — см. documents.py), и
|
||||
# относительными (/_next/image?url=..., /api/ext/file/...). Мусорные картинки
|
||||
# (data:-inline base64, captcha, лого застройщика, миниатюры) отсекаются.
|
||||
|
||||
# Ключи в pageProps, под которыми может лежать URL планировки. Проверяются по
|
||||
# порядку; первое непустое строковое значение с image-like URL выигрывает.
|
||||
_NEXT_PLAN_KEY_RE = re.compile(
|
||||
r"(?:plan|layout|scheme|планировк|floor)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Признак «это картинка» в значении: расширение файла ИЛИ file-API DOM.РФ.
|
||||
_IMAGE_URL_RE = re.compile(
|
||||
r"(?:\.(?:png|jpe?g|webp|svg)(?:\?|$)|/api/ext/file/|/_next/image)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Токены, по которым отсекаем НЕ-планировочные картинки (лого, аватары, иконки,
|
||||
# captcha, миниатюры, банеры). Проверяются по src/alt/class.
|
||||
_IMAGE_NOISE_RE = re.compile(
|
||||
r"(?:logo|avatar|icon|captcha|robot|sprite|banner|placeholder|thumb|"
|
||||
r"favicon|badge|marker|pin)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Признак «это планировка» в атрибутах <img> (src / alt / class).
|
||||
_IMAGE_PLAN_HINT_RE = re.compile(
|
||||
r"(?:plan|layout|scheme|планировк)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _classify_status_kw(matched_text: str) -> str | None:
|
||||
"""Классифицировать совпавшее ключевое слово в STATUS_*.
|
||||
|
|
@ -266,19 +217,8 @@ class _TextCollector(HTMLParser):
|
|||
# Список записей: (class_hint, full_text)
|
||||
self.blocks: list[tuple[str, str]] = []
|
||||
self._buf: list[str] = []
|
||||
# Отдельный, аддитивный сбор <img>-атрибутов (issue #2440): нужен для
|
||||
# extraction plan_image_url как fallback к __NEXT_DATA__. НЕ влияет на
|
||||
# text-extraction — img остаётся void-тегом, фрейм в стек не пушится.
|
||||
# Формат: (attrs_dict, index_в_blocks) — index привязывает картинку к
|
||||
# позиции в потоке текстовых блоков для proximity-поиска «Планировка».
|
||||
self.images: list[tuple[dict[str, str], int]] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag == "img":
|
||||
# Собираем атрибуты img ДО early-return (void-тег): index = текущая
|
||||
# длина blocks, т.е. картинка «привязана» к ближайшему предыдущему
|
||||
# закрытому текстовому блоку. Text-extraction ниже не меняется.
|
||||
self.images.append(({k: (v or "") for k, v in attrs}, len(self.blocks)))
|
||||
if tag in self._VOID_TAGS:
|
||||
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
||||
attr_dict = {k: (v or "") for k, v in attrs}
|
||||
|
|
@ -326,134 +266,6 @@ def _find_text_near(
|
|||
return None
|
||||
|
||||
|
||||
def _normalize_plan_url(url: str | None) -> str | None:
|
||||
"""Нормализовать URL картинки планировки в абсолютный, отсеять мусор.
|
||||
|
||||
- data:-inline (base64) отбрасываем (это не удалённый файл, часто captcha/robot).
|
||||
- Относительный путь ('/api/ext/file/...', '/_next/image?url=...') → absolute
|
||||
через urljoin(BASE_URL, ...).
|
||||
- Пустое/None → None.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
u = url.strip()
|
||||
if not u or u.lower().startswith("data:"):
|
||||
return None
|
||||
if u.startswith("//"):
|
||||
# protocol-relative → https
|
||||
return "https:" + u
|
||||
if u.startswith("http://") or u.startswith("https://"):
|
||||
return u
|
||||
return urljoin(BASE_URL + "/", u.lstrip("/"))
|
||||
|
||||
|
||||
def _extract_plan_from_next_data(html: str) -> str | None:
|
||||
"""Извлечь URL планировки из <script id="__NEXT_DATA__"> pageProps.
|
||||
|
||||
Страница квартиры каталога — Next.js SSR: план рендерится через next/image,
|
||||
URL картинки живёт в JSON-блоке. Рекурсивно обходим pageProps и ищем строковое
|
||||
значение под ключом, матчащим _NEXT_PLAN_KEY_RE, чьё значение выглядит как
|
||||
image-URL (_IMAGE_URL_RE) и НЕ является шумом (_IMAGE_NOISE_RE).
|
||||
|
||||
Возвращает первый подходящий URL (нормализованный) или None.
|
||||
"""
|
||||
match = re.search(
|
||||
r'<script\s+id=["\']__NEXT_DATA__["\'][^>]*>(.+?)</script>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
blob = json.loads(match.group(1).strip())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
page_props = blob.get("props", {}).get("pageProps")
|
||||
root: Any = page_props if isinstance(page_props, dict) else blob
|
||||
|
||||
# BFS по вложенному dict/list; ключ+значение проверяем на plan-hint.
|
||||
stack: list[Any] = [root]
|
||||
seen = 0
|
||||
while stack and seen < 20_000: # cap: защита от патологически глубокого JSON
|
||||
node = stack.pop()
|
||||
seen += 1
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and _NEXT_PLAN_KEY_RE.search(str(key))
|
||||
and _IMAGE_URL_RE.search(value)
|
||||
and not _IMAGE_NOISE_RE.search(value)
|
||||
):
|
||||
normalized = _normalize_plan_url(value)
|
||||
if normalized:
|
||||
return normalized
|
||||
if isinstance(value, dict | list):
|
||||
stack.append(value)
|
||||
elif isinstance(node, list):
|
||||
stack.extend(node)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_plan_from_imgs(
|
||||
images: list[tuple[dict[str, str], int]], blocks: list[tuple[str, str]]
|
||||
) -> str | None:
|
||||
"""Fallback: выбрать <img> планировки из собранных _TextCollector.images.
|
||||
|
||||
Приоритет:
|
||||
1. <img>, чей src/alt/class содержит plan/layout/scheme/планировк — прямое
|
||||
совпадение, самый надёжный сигнал.
|
||||
2. <img> рядом (в потоке DOM) с текстовым блоком «Планировка».
|
||||
Шумовые картинки (лого/иконки/captcha/миниатюры) отсекаются по _IMAGE_NOISE_RE,
|
||||
inline data:-картинки и пустые src — через _normalize_plan_url.
|
||||
"""
|
||||
# Уровень 1: явный plan-hint в атрибутах.
|
||||
for attrs, _idx in images:
|
||||
src = attrs.get("src", "")
|
||||
alt = attrs.get("alt", "")
|
||||
cls = attrs.get("class", "")
|
||||
haystack = f"{src} {alt} {cls}"
|
||||
if _IMAGE_NOISE_RE.search(haystack):
|
||||
continue
|
||||
if _IMAGE_PLAN_HINT_RE.search(haystack):
|
||||
normalized = _normalize_plan_url(src)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
# Уровень 2: <img>, чей index стоит сразу после блока «Планировка».
|
||||
plan_block_indices = [
|
||||
i for i, (_cls, text) in enumerate(blocks) if re.search(r"планировк", text, re.IGNORECASE)
|
||||
]
|
||||
if plan_block_indices:
|
||||
for attrs, idx in images:
|
||||
src = attrs.get("src", "")
|
||||
if _IMAGE_NOISE_RE.search(f"{src} {attrs.get('alt', '')} {attrs.get('class', '')}"):
|
||||
continue
|
||||
# картинка «принадлежит» блоку «Планировка», если её index в пределах
|
||||
# ±2 текстовых блоков от лейбла.
|
||||
if any(abs(idx - pbi) <= 2 for pbi in plan_block_indices):
|
||||
normalized = _normalize_plan_url(src)
|
||||
if normalized:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def extract_plan_image_url(html: str, collector: _TextCollector) -> str | None:
|
||||
"""Извлечь URL картинки планировки из SSR-HTML страницы квартиры.
|
||||
|
||||
Two-strategy (issue #2440):
|
||||
1. __NEXT_DATA__ pageProps (primary — DOM.РФ Next.js SSR).
|
||||
2. bare <img> fallback (по plan-hint атрибутам либо proximity к «Планировка»).
|
||||
|
||||
Возвращает абсолютный URL или None. Best-effort — структура DOM.РФ хрупкая.
|
||||
"""
|
||||
url = _extract_plan_from_next_data(html)
|
||||
if url:
|
||||
return url
|
||||
return _extract_plan_from_imgs(collector.images, collector.blocks)
|
||||
|
||||
|
||||
def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
|
||||
|
||||
|
|
@ -465,7 +277,6 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
- ceiling_height_m (float) Высота потолков в метрах
|
||||
- section_no (int) Номер подъезда / секции
|
||||
- catalog_updated_at (date) Дата обновления информации на странице
|
||||
- plan_image_url (str) URL картинки планировки (issue #2440)
|
||||
|
||||
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
|
||||
Все extraction best-effort — KeyError/AttributeError обёрнуты внутри.
|
||||
|
|
@ -657,14 +468,6 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
|||
except ValueError:
|
||||
pass
|
||||
|
||||
# ── Шаг 4: plan_image_url (issue #2440) ──────────────────────────────────
|
||||
# __NEXT_DATA__ pageProps (primary) → bare <img> fallback. collector.images
|
||||
# собран на Шаге 1 тем же feed(). None если план не найден (напр. проданная
|
||||
# квартира без планировки на странице).
|
||||
plan_url = extract_plan_image_url(html, collector)
|
||||
if plan_url:
|
||||
result["plan_image_url"] = plan_url
|
||||
|
||||
logger.debug(
|
||||
"parse_catalog_flat: extracted fields=%s",
|
||||
list(result.keys()),
|
||||
|
|
@ -741,63 +544,16 @@ def upsert_catalog_data(
|
|||
# ── Per-flat scrape orchestration ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _handle_plan_image(
|
||||
db: Session,
|
||||
session: BrowserSession,
|
||||
ods_id: str,
|
||||
obj_id: int,
|
||||
plan_url: str,
|
||||
snapshot_date: date,
|
||||
outcome: dict[str, Any],
|
||||
) -> None:
|
||||
"""Записать URL планировки в flat_plans, скачать бинарник, дописать метаданные.
|
||||
|
||||
Порядок (issue #2440):
|
||||
1. upsert_flat_plans с одним только plan_image_url — URL зафиксирован даже
|
||||
если скачивание упадёт (частичный прогресс не теряется).
|
||||
2. download_plan_image — reuse BrowserSession (cookies/TLS/throttle).
|
||||
3. повторный upsert_flat_plans с local_path/размерами — COALESCE внутри
|
||||
upsert защищает уже скачанные файлы от перезаписи.
|
||||
|
||||
Мутирует outcome['plan_downloaded']. Ошибки логируются внутри download/upsert,
|
||||
наружу не пробрасываются — планировка не должна ронять catalog-scrape квартиры.
|
||||
"""
|
||||
plan_row: dict[str, Any] = {
|
||||
"ods_id": ods_id,
|
||||
"obj_id": obj_id,
|
||||
"plan_image_url": plan_url,
|
||||
}
|
||||
upsert_flat_plans(db, obj_id, [plan_row], snapshot_date)
|
||||
|
||||
meta = await download_plan_image(session, plan_url, ods_id, obj_id)
|
||||
if meta:
|
||||
outcome["plan_downloaded"] = True
|
||||
upsert_flat_plans(db, obj_id, [{**plan_row, **meta}], snapshot_date)
|
||||
|
||||
|
||||
async def scrape_one_flat(
|
||||
session: BrowserSession,
|
||||
db: Session,
|
||||
ods_id: str,
|
||||
catalog_url_hash: str,
|
||||
obj_id: int | None = None,
|
||||
snapshot_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Scrape одной квартиры: fetch HTML → parse → upsert (+ планировка).
|
||||
"""Scrape одной квартиры: fetch HTML → parse → upsert.
|
||||
|
||||
Помимо catalog-полей (price/status/finishing/...) извлекает plan_image_url
|
||||
(issue #2440): пишет URL в domrf_kn_flat_plans через upsert_flat_plans, затем
|
||||
скачивает бинарник (download_plan_image) и повторным upsert'ом дописывает
|
||||
local_path/размеры (COALESCE-защита уже скачанных файлов внутри upsert).
|
||||
|
||||
Скачивание — inline в рамках того же BrowserSession: reuse cookies/TLS,
|
||||
download_binary несёт свой лёгкий throttle (jitter 200–500 мс), отдельная
|
||||
beat-задача не заводится (требование #2440). obj_id/snapshot_date нужны для
|
||||
записи в flat_plans; если не переданы — plan-upsert пропускается с warning.
|
||||
|
||||
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated,
|
||||
plan_found, plan_downloaded}. Ошибки fetch/parse/download логируются, не
|
||||
бросаются — caller обрабатывает результат.
|
||||
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated}.
|
||||
Ошибки fetch/parse логируются, не бросаются — caller обрабатывает результат.
|
||||
"""
|
||||
outcome: dict[str, Any] = {
|
||||
"ods_id": ods_id,
|
||||
|
|
@ -805,8 +561,6 @@ async def scrape_one_flat(
|
|||
"success": False,
|
||||
"fields_extracted": 0,
|
||||
"updated": False,
|
||||
"plan_found": False,
|
||||
"plan_downloaded": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
|
@ -836,19 +590,6 @@ async def scrape_one_flat(
|
|||
|
||||
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
|
||||
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
|
||||
|
||||
# ── plan_image_url → domrf_kn_flat_plans (issue #2440) ───────────────────
|
||||
plan_url = data.get("plan_image_url")
|
||||
if plan_url:
|
||||
outcome["plan_found"] = True
|
||||
if obj_id is not None and snapshot_date is not None:
|
||||
await _handle_plan_image(db, session, ods_id, obj_id, plan_url, snapshot_date, outcome)
|
||||
else:
|
||||
logger.warning(
|
||||
"catalog scrape ods_id=%s: plan_image_url found but obj_id/snapshot_date "
|
||||
"missing — flat_plans upsert skipped",
|
||||
ods_id,
|
||||
)
|
||||
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
||||
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
||||
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
||||
|
|
@ -876,12 +617,11 @@ async def scrape_catalog_batch(
|
|||
region_code: int = 66,
|
||||
headed: bool = False,
|
||||
load_state: str | None = None,
|
||||
snapshot_date: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Scrape пачки квартир каталога DOM.РФ.
|
||||
|
||||
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash [, obj_id]}.
|
||||
Типовой источник: SELECT ods_id, obj_id, catalog_url_hash FROM domrf_kn_flats
|
||||
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash}.
|
||||
Типовой источник: SELECT ods_id, catalog_url_hash FROM domrf_kn_flats
|
||||
WHERE catalog_url_hash IS NOT NULL
|
||||
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
|
||||
LIMIT :batch_size.
|
||||
|
|
@ -889,25 +629,18 @@ async def scrape_catalog_batch(
|
|||
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
|
||||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||
|
||||
obj_id/snapshot_date нужны для записи планировки в domrf_kn_flat_plans (issue
|
||||
#2440). snapshot_date по умолчанию = сегодня (дата первичного обнаружения URL).
|
||||
|
||||
Returns:
|
||||
{total, success, updated, failed, fields_total, plans_found, plans_downloaded}
|
||||
{total, success, updated, failed, fields_total}
|
||||
- success: прошли fetch+parse без исключения
|
||||
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||
фактическое число записанных квартир, в отличие от success
|
||||
- plans_found / plans_downloaded: планировки найдены / скачаны (#2440)
|
||||
"""
|
||||
snap = snapshot_date or date.today()
|
||||
stats: dict[str, Any] = {
|
||||
"total": len(flats),
|
||||
"success": 0,
|
||||
"updated": 0,
|
||||
"failed": 0,
|
||||
"fields_total": 0,
|
||||
"plans_found": 0,
|
||||
"plans_downloaded": 0,
|
||||
}
|
||||
|
||||
if not flats:
|
||||
|
|
@ -915,10 +648,9 @@ async def scrape_catalog_batch(
|
|||
return stats
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch: starting %d flats region=%d snapshot_date=%s",
|
||||
"scrape_catalog_batch: starting %d flats region=%d",
|
||||
len(flats),
|
||||
region_code,
|
||||
snap,
|
||||
)
|
||||
|
||||
async with BrowserSession(
|
||||
|
|
@ -927,42 +659,21 @@ async def scrape_catalog_batch(
|
|||
load_state=load_state,
|
||||
# auth=None — страницы каталога публичные, Basic auth не нужен
|
||||
auth=None,
|
||||
# #2445 D2 anti-ban: тот же /сервисы/* path family, что вызвал DOM.РФ WAF
|
||||
# hard-ban 2026-05-24 (#2443). Без явного override BrowserSession тихо
|
||||
# наследует модульный дефолт stealth.py (concurrency=8, jitter 600-1500ms).
|
||||
# Переиспользуем throttled настройки, принятые scrape_kn.py после #1945
|
||||
# (settings.scrape_kn_* — общий anti-ban рычаг, НЕ специфичный для KN-sweep,
|
||||
# несмотря на имя).
|
||||
concurrency=settings.scrape_kn_browser_concurrency,
|
||||
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||||
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||||
) as session:
|
||||
# Warm-up: WAF DOM.РФ (2026-05-24) требует session cookies для SSR-страниц.
|
||||
# Зеркалит scrape_catalog_objects. Idempotent на весь batch.
|
||||
await session.warm_up()
|
||||
|
||||
for flat in flats:
|
||||
ods_id = flat.get("ods_id", "")
|
||||
catalog_url_hash = flat.get("catalog_url_hash", "")
|
||||
obj_id_raw = flat.get("obj_id")
|
||||
obj_id = int(obj_id_raw) if obj_id_raw is not None else None
|
||||
if not ods_id or not catalog_url_hash:
|
||||
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
|
||||
outcome = await scrape_one_flat(
|
||||
session, db, ods_id, catalog_url_hash, obj_id=obj_id, snapshot_date=snap
|
||||
)
|
||||
outcome = await scrape_one_flat(session, db, ods_id, catalog_url_hash)
|
||||
if outcome["success"]:
|
||||
stats["success"] += 1
|
||||
stats["fields_total"] += outcome["fields_extracted"]
|
||||
if outcome["updated"]:
|
||||
stats["updated"] += 1
|
||||
if outcome.get("plan_found"):
|
||||
stats["plans_found"] += 1
|
||||
if outcome.get("plan_downloaded"):
|
||||
stats["plans_downloaded"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
|
|
@ -978,14 +689,11 @@ async def scrape_catalog_batch(
|
|||
raise
|
||||
|
||||
logger.info(
|
||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d "
|
||||
"fields_total=%d plans_found=%d plans_downloaded=%d",
|
||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
|
||||
stats["total"],
|
||||
stats["success"],
|
||||
stats["updated"],
|
||||
stats["failed"],
|
||||
stats["fields_total"],
|
||||
stats["plans_found"],
|
||||
stats["plans_downloaded"],
|
||||
)
|
||||
return stats
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -441,16 +440,6 @@ async def scrape_catalog_objects(
|
|||
region_code=region_code,
|
||||
# Страницы каталога публичные — Basic auth не нужен
|
||||
auth=None,
|
||||
# #2445 D2 anti-ban: этот scraper ходит по тому же /сервисы/* path family,
|
||||
# что вызвал DOM.РФ WAF hard-ban 2026-05-24 (#2443). Без явного override
|
||||
# BrowserSession тихо наследует модульный дефолт stealth.py (concurrency=8,
|
||||
# jitter 600-1500ms) — тот самый throttle-less профиль, что и раньше
|
||||
# триггерил volume-ban. Переиспользуем throttled настройки, принятые
|
||||
# scrape_kn.py после #1945 (settings.scrape_kn_* — общий anti-ban рычаг,
|
||||
# НЕ специфичный для KN-sweep, несмотря на имя).
|
||||
concurrency=settings.scrape_kn_browser_concurrency,
|
||||
jitter_min_ms=settings.scrape_kn_request_jitter_min_ms,
|
||||
jitter_max_ms=settings.scrape_kn_request_jitter_max_ms,
|
||||
) as session:
|
||||
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
|
||||
# DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих cookies.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from app.services.scrapers.documents import extract_documents, upsert_documents
|
|||
# obj_checks import temporarily disabled — endpoint /checks returns 404 (run #19).
|
||||
# Re-enable with _fetch_obj_checks_safe when endpoint is found (see TODO in Phase B/C).
|
||||
# from app.services.scrapers.obj_checks import extract_obj_checks, upsert_obj_checks
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError
|
||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -432,14 +432,6 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8]
|
||||
flat_id = int.from_bytes(digest, "big") % (2**63 - 1)
|
||||
|
||||
# catalog_url_hash — тот же elemId (hash-часть URL квартиры в каталоге DOM.РФ):
|
||||
# https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}.
|
||||
# Читаем ЗДЕСЬ (а не только в fallback-ветке flat_id выше) — при наличии flatId
|
||||
# ветка fallback не выполняется и elem остаётся непрочитанным. Пустую строку
|
||||
# нормализуем в None: `or None` зеркалит трактовку `if elem:` в fallback-ветке
|
||||
# и общий null-handling соседних optional-полей (_g отдаёт None при отсутствии).
|
||||
catalog_url_hash = _g(row, "elemId") or None
|
||||
|
||||
price_rub = _g(row, "price")
|
||||
price_per_m2 = _g(row, "pricePerSquareMeter")
|
||||
total_area = _g(row, "totalArea")
|
||||
|
|
@ -484,10 +476,7 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
|||
"ceiling_height_m": None, # заполнит catalog scraper 22d
|
||||
"key_handover_dt": None, # заполнит catalog scraper 22d
|
||||
"catalog_updated_at": None, # заполнит catalog scraper 22d
|
||||
# catalog_url_hash приходит из kn-API (elemId) уже здесь — нужен catalog
|
||||
# scraper'у (22d) для построения URL страницы квартиры. Ранее хардкодился
|
||||
# в None, из-за чего flat-catalog scraper не мог выбрать ни одной строки.
|
||||
"catalog_url_hash": catalog_url_hash,
|
||||
"catalog_url_hash": None, # заполнит catalog scraper 22d
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -535,29 +524,14 @@ UPSERT_OBJECT_SQL = text(
|
|||
:snapshot_date
|
||||
)
|
||||
ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET
|
||||
-- #2464 cluster E: hobj_id/short_addr/dev_inn/region_cd/floor_min/floor_max/
|
||||
-- problem_flag/green_house были в INSERT (populated every call by
|
||||
-- _norm_object) но пропущены в DO UPDATE SET → re-run скрапера для
|
||||
-- уже существующего (obj_id, snapshot_date) оставлял эти 8 колонок stale.
|
||||
-- Все 8 — direct EXCLUDED (не COALESCE): freshly computed каждый вызов,
|
||||
-- в т.ч. problem_flag должен ОБНУЛЯТЬСЯ, если проблема на объекте снята
|
||||
-- (COALESCE зафиксировал бы устаревший "проблемный" статус навсегда).
|
||||
hobj_id = EXCLUDED.hobj_id,
|
||||
comm_name = EXCLUDED.comm_name,
|
||||
addr = EXCLUDED.addr,
|
||||
short_addr = EXCLUDED.short_addr,
|
||||
region_cd = EXCLUDED.region_cd,
|
||||
dev_id = EXCLUDED.dev_id,
|
||||
dev_name = EXCLUDED.dev_name,
|
||||
dev_inn = EXCLUDED.dev_inn,
|
||||
floor_min = EXCLUDED.floor_min,
|
||||
floor_max = EXCLUDED.floor_max,
|
||||
flat_count = EXCLUDED.flat_count,
|
||||
square_living = EXCLUDED.square_living,
|
||||
ready_dt = EXCLUDED.ready_dt,
|
||||
problem_flag = EXCLUDED.problem_flag,
|
||||
site_status = EXCLUDED.site_status,
|
||||
green_house = EXCLUDED.green_house,
|
||||
escrow = EXCLUDED.escrow,
|
||||
obj_class = COALESCE(EXCLUDED.obj_class, domrf_kn_objects.obj_class),
|
||||
wall_type = COALESCE(EXCLUDED.wall_type, domrf_kn_objects.wall_type),
|
||||
|
|
@ -936,9 +910,6 @@ UPSERT_PHOTO_SQL = text(
|
|||
:local_path, :thumb_path, :downloaded_at
|
||||
)
|
||||
ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET
|
||||
-- #2464 cluster F: build_type в INSERT column list (populated every call)
|
||||
-- но пропущен здесь → re-run скрапера оставлял build_type stale.
|
||||
build_type = EXCLUDED.build_type,
|
||||
ord_num = EXCLUDED.ord_num,
|
||||
photo_url = EXCLUDED.photo_url,
|
||||
photo_dttm = EXCLUDED.photo_dttm,
|
||||
|
|
@ -1171,7 +1142,7 @@ async def fetch_obj_checks(sess: BrowserSession, obj_id: int) -> tuple[Any, str]
|
|||
# ── _fetch_*_safe wrappers for asyncio.gather in Phase B/C ───────────────────
|
||||
# Каждый wrapper возвращает (kind, full_url, result_or_exception).
|
||||
# Exceptions НЕ raise — помещаются в возвращаемый tuple.
|
||||
# BrowserSession._sem (size = per-instance concurrency, #1945 KN=2) bounds concurrency.
|
||||
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) bounds concurrency per-request.
|
||||
|
||||
|
||||
async def _fetch_flats_safe(
|
||||
|
|
@ -1479,247 +1450,6 @@ async def download_photos(
|
|||
return locals_out, thumbs_out
|
||||
|
||||
|
||||
# ── result processing (shared by combined + isolated #1945 passes) ────────────
|
||||
|
||||
# #1945 isolated extras pass: после стольких подряд полностью-WAF объектов
|
||||
# обрываем пас (extras заблокированы целиком — нет смысла жечь ~1500 recycle).
|
||||
_EXTRAS_ABORT_AFTER_CONSEC_WAF = 5
|
||||
|
||||
# 5 doc-endpoint kinds, accumulated into a single per-object documents upsert.
|
||||
_DOC_KINDS = frozenset(
|
||||
(
|
||||
"doc_rpd",
|
||||
"doc_developer_report",
|
||||
"doc_project_documentation",
|
||||
"doc_documentation_other",
|
||||
"doc_permits",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _process_flats_result(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
obj_id: int,
|
||||
full_url: str,
|
||||
result: list[dict[str, Any]] | Exception,
|
||||
snapshot_date: date,
|
||||
region_code: int,
|
||||
) -> int:
|
||||
"""Upsert flats from a _fetch_flats_safe result. Returns flats inserted.
|
||||
|
||||
Errors → kn_scrape_failures (via _classify_and_log), 0 returned. Вынесено из
|
||||
Phase-B/C loop чтобы isolated-flats-pass (#1945) и combined-loop делили один код.
|
||||
"""
|
||||
if isinstance(result, Exception):
|
||||
_classify_and_log(db, run_id, obj_id, "flats", full_url, result)
|
||||
return 0
|
||||
if result:
|
||||
return upsert_flats(db, result, snapshot_date, region_code)
|
||||
return 0
|
||||
|
||||
|
||||
async def _process_extras_result(
|
||||
sess: BrowserSession,
|
||||
db: Session,
|
||||
run_id: int,
|
||||
obj_id: int,
|
||||
kind_tag: str,
|
||||
full_url: str,
|
||||
result: Any,
|
||||
snapshot_date: date,
|
||||
extras_counts: dict[str, int],
|
||||
all_docs: list[dict[str, Any]],
|
||||
*,
|
||||
download_photos_binary: bool,
|
||||
pdir: Path,
|
||||
) -> None:
|
||||
"""Handle one extras endpoint result (sale_graph/sales_agg/infra/photos/docs).
|
||||
|
||||
Mutates extras_counts / all_docs in place. Errors → kn_scrape_failures.
|
||||
Doc-kinds накапливаются в all_docs (единый upsert делает вызывающий код).
|
||||
Вынесено из loop чтобы combined- и isolated-extras-pass (#1945) делили код.
|
||||
"""
|
||||
if isinstance(result, Exception):
|
||||
_classify_and_log(db, run_id, obj_id, kind_tag, full_url, result)
|
||||
return
|
||||
|
||||
if kind_tag in ("sale_graph_apartments", "sale_graph_parking"):
|
||||
sg_type = kind_tag.replace("sale_graph_", "")
|
||||
rows_sg, _ = result
|
||||
extras_counts["sale_graph_rows"] += upsert_sale_graph(
|
||||
db, obj_id, sg_type, rows_sg, snapshot_date
|
||||
)
|
||||
elif kind_tag == "sales_agg":
|
||||
agg_data, _ = result
|
||||
extras_counts["sales_agg_rows"] += upsert_sales_agg(db, obj_id, agg_data, snapshot_date)
|
||||
elif kind_tag == "infrastructure":
|
||||
pois_data, _ = result
|
||||
extras_counts["infra_rows"] += upsert_infrastructure(db, obj_id, pois_data, snapshot_date)
|
||||
elif kind_tag == "photos":
|
||||
photos_data, _ = result
|
||||
local_paths: dict[str, str] = {}
|
||||
thumb_paths: dict[str, str] = {}
|
||||
if download_photos_binary and photos_data:
|
||||
local_paths, thumb_paths = await download_photos(sess, obj_id, photos_data, pdir)
|
||||
extras_counts["photos_downloaded"] += len(local_paths)
|
||||
extras_counts["photos_rows"] += upsert_photos(
|
||||
db, obj_id, photos_data, local_paths, thumb_paths
|
||||
)
|
||||
elif kind_tag in _DOC_KINDS:
|
||||
doc_items, _ = result
|
||||
all_docs.extend(extract_documents(doc_items or []))
|
||||
|
||||
|
||||
def _extras_coros(sess: BrowserSession, obj_id: int) -> list[Any]:
|
||||
"""The 10 extras-endpoint coroutines for one object (flats NOT included)."""
|
||||
return [
|
||||
_fetch_sale_graph_safe(sess, obj_id, "apartments"),
|
||||
_fetch_sale_graph_safe(sess, obj_id, "parking"),
|
||||
_fetch_sales_agg_safe(sess, obj_id),
|
||||
_fetch_infrastructure_safe(sess, obj_id),
|
||||
_fetch_photos_safe(sess, obj_id),
|
||||
_fetch_doc_rpd_safe(sess, obj_id),
|
||||
_fetch_doc_developer_report_safe(sess, obj_id),
|
||||
_fetch_doc_project_documentation_safe(sess, obj_id),
|
||||
_fetch_doc_documentation_other_safe(sess, obj_id),
|
||||
_fetch_doc_permits_safe(sess, obj_id),
|
||||
]
|
||||
|
||||
|
||||
def _is_waf_poisoned(results: list[tuple[str, str, Any]]) -> bool:
|
||||
"""Любой WafBlockedError среди результатов = сессия отравлена (cookies флагнуты).
|
||||
|
||||
После этого ВСЕ запросы на этой сессии (incl. следующего объекта) пойдут в 403,
|
||||
поэтому вызывающий код должен пересоздать BrowserSession перед продолжением.
|
||||
"""
|
||||
return any(isinstance(r, WafBlockedError) for _kind, _url, r in results)
|
||||
|
||||
|
||||
async def _run_extras_pass(
|
||||
*,
|
||||
db: Session,
|
||||
run_id: int,
|
||||
region_code: int,
|
||||
all_objects: list[dict[str, Any]],
|
||||
start_index: int,
|
||||
snapshot_date: date,
|
||||
extras_counts: dict[str, int],
|
||||
pdir: Path,
|
||||
download_photos_binary: bool,
|
||||
load_state: str | None,
|
||||
headed: bool,
|
||||
browser_concurrency: int | None,
|
||||
request_jitter_min_ms: int | None,
|
||||
request_jitter_max_ms: int | None,
|
||||
proxy_url: str | None,
|
||||
) -> int:
|
||||
"""#1945 isolated extras pass: extras в ОТДЕЛЬНОЙ BrowserSession от flats.
|
||||
|
||||
Ключ изоляции: на ЛЮБОЙ WafBlockedError (extras-403 травит cookies сессии)
|
||||
закрываем сессию и открываем НОВУЮ (свежие cookies/контекст) перед следующим
|
||||
объектом — яд не накапливается и НИКОГДА не касается flats (flats-сессия уже
|
||||
закрыта). Best-effort: каждый сбой → kn_scrape_failures, run не падает.
|
||||
|
||||
Возвращает суммарный request_count всех extras-сессий (для метрики run'а).
|
||||
"""
|
||||
total = len(all_objects)
|
||||
total_requests = 0
|
||||
log_progress(
|
||||
db, run_id, f"extras-пас (isolated): старт {total - start_index} объектов", stage="extras"
|
||||
)
|
||||
|
||||
def _new_session() -> BrowserSession:
|
||||
return BrowserSession(
|
||||
region_code=region_code,
|
||||
headed=headed,
|
||||
load_state=load_state,
|
||||
concurrency=browser_concurrency,
|
||||
jitter_min_ms=request_jitter_min_ms,
|
||||
jitter_max_ms=request_jitter_max_ms,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
|
||||
# extras сейчас мертвы (403 с 2026-06-03). Recycle на КАЖДЫЙ объект (полный
|
||||
# перезапуск браузера ~1500×) бессмыслен, если extras заблокированы целиком.
|
||||
# Поэтому считаем подряд идущие полностью-WAF объекты и обрываем пас после
|
||||
# порога — extras остаются best-effort opt-in, но не жгут час впустую.
|
||||
consecutive_all_waf = 0
|
||||
aborted = False
|
||||
sess = _new_session()
|
||||
await sess.__aenter__()
|
||||
try:
|
||||
await sess.warm_up()
|
||||
for i in range(start_index, total):
|
||||
obj = all_objects[i]
|
||||
obj_id = obj.get("objId") or obj.get("obj_id")
|
||||
if not obj_id:
|
||||
continue
|
||||
|
||||
results = await asyncio.gather(*_extras_coros(sess, obj_id), return_exceptions=False)
|
||||
all_docs: list[dict[str, Any]] = []
|
||||
for kind_tag, full_url, result in results:
|
||||
await _process_extras_result(
|
||||
sess,
|
||||
db,
|
||||
run_id,
|
||||
obj_id,
|
||||
kind_tag,
|
||||
full_url,
|
||||
result,
|
||||
snapshot_date,
|
||||
extras_counts,
|
||||
all_docs,
|
||||
download_photos_binary=download_photos_binary,
|
||||
pdir=pdir,
|
||||
)
|
||||
if all_docs:
|
||||
ins, _skip = upsert_documents(db, obj_id, all_docs)
|
||||
extras_counts["documents_rows"] += ins
|
||||
|
||||
poisoned = _is_waf_poisoned(results)
|
||||
# Полностью-WAF объект = ни один extras не отдал JSON (все 403).
|
||||
all_waf = all(isinstance(r, WafBlockedError) for _k, _u, r in results)
|
||||
consecutive_all_waf = consecutive_all_waf + 1 if all_waf else 0
|
||||
if consecutive_all_waf >= _EXTRAS_ABORT_AFTER_CONSEC_WAF:
|
||||
aborted = True
|
||||
log_progress(
|
||||
db,
|
||||
run_id,
|
||||
f"extras-пас оборван: {consecutive_all_waf} объектов подряд"
|
||||
" полностью WAF-403 (extras заблокированы целиком)",
|
||||
level="warn",
|
||||
stage="extras",
|
||||
)
|
||||
break
|
||||
|
||||
# RECYCLE: если сессия отравлена WAF-403 — пересоздаём перед next obj.
|
||||
if poisoned:
|
||||
total_requests += sess.request_count
|
||||
await sess.__aexit__(None, None, None)
|
||||
sess = _new_session()
|
||||
await sess.__aenter__()
|
||||
await sess.warm_up()
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
_checkpoint(db, run_id, i + 1)
|
||||
finally:
|
||||
total_requests += sess.request_count
|
||||
await sess.__aexit__(None, None, None)
|
||||
if aborted:
|
||||
logger.info("extras isolated pass aborted early — extras WAF-blocked wholesale")
|
||||
|
||||
log_progress(
|
||||
db,
|
||||
run_id,
|
||||
f"extras-пас done: sale_graph={extras_counts['sale_graph_rows']}"
|
||||
f" agg={extras_counts['sales_agg_rows']} infra={extras_counts['infra_rows']}"
|
||||
f" photos={extras_counts['photos_rows']} docs={extras_counts['documents_rows']}",
|
||||
stage="extras",
|
||||
)
|
||||
return total_requests
|
||||
|
||||
|
||||
# ── orchestrator ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -1930,25 +1660,9 @@ async def run_region_sweep(
|
|||
photos_dir: Path | str | None = None,
|
||||
place_override: str | None = None,
|
||||
resume_from_run_id: int | None = None,
|
||||
browser_concurrency: int | None = None,
|
||||
request_jitter_min_ms: int | None = None,
|
||||
request_jitter_max_ms: int | None = None,
|
||||
proxy_url: str | None = None,
|
||||
extras_isolated: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Full sweep for one region: bootstrap → fetch objects → per-object processing.
|
||||
|
||||
#1945 anti-ban: ``browser_concurrency`` / ``request_jitter_*_ms`` / ``proxy_url``
|
||||
прокидываются в BrowserSession sweep'а, чтобы троттлить/проксировать ТОЛЬКО KN-
|
||||
прогон (другие скраперы используют модульные дефолты stealth.py без изменений).
|
||||
None у каждого = дефолт stealth.py (concurrency=8, jitter 600–1500, без прокси).
|
||||
|
||||
``extras_isolated`` (#1945 ГЛАВНЫЙ unblock): True (дефолт) → flats тянутся в
|
||||
ЧИСТОЙ flats-only сессии (extras на ней НЕ дёргаются НИКОГДА → 403-яд extras не
|
||||
травит cookies flats-сессии → flats_count восстанавливается), а extras идут
|
||||
ОТДЕЛЬНЫМ best-effort проходом с recycle сессии на WAF-403. False → старое
|
||||
поведение (flats+extras в одной сессии, poison-prone).
|
||||
|
||||
Resume-aware: если `resume_from_run_id` задан, читаем objects_snapshot и
|
||||
progress_obj_index из исходного run и продолжаем с этого индекса. Новый
|
||||
run_id создаётся (с resumed_from_run_id ссылкой), исходный остаётся
|
||||
|
|
@ -2088,10 +1802,6 @@ async def run_region_sweep(
|
|||
headed=headed,
|
||||
load_state=load_state,
|
||||
save_state=save_state,
|
||||
concurrency=browser_concurrency,
|
||||
jitter_min_ms=request_jitter_min_ms,
|
||||
jitter_max_ms=request_jitter_max_ms,
|
||||
proxy_url=proxy_url,
|
||||
) as sess:
|
||||
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies
|
||||
# (___dmpkit___, domain_sid). Required since 2026-05-24 — without these
|
||||
|
|
@ -2171,16 +1881,9 @@ async def run_region_sweep(
|
|||
|
||||
# ── Phase B/C — per-object processing (resumable, parallel per-object) ─
|
||||
# Все endpoint'ы одного obj_id запускаются параллельно через asyncio.gather.
|
||||
# BrowserSession._sem (size = browser_concurrency, #1945 троттлит KN до 2)
|
||||
# ограничивает запросы.
|
||||
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) ограничивает запросы.
|
||||
# DB upserts выполняются последовательно после gather — один db Session
|
||||
# не thread-safe для параллельной записи.
|
||||
#
|
||||
# #1945 ИЗОЛЯЦИЯ: при extras_isolated=True эта (warm) сессия делает
|
||||
# ТОЛЬКО flats — extras на ней НЕ дёргаются (их 403-яд не травит cookies
|
||||
# flats-сессии → flats_count восстанавливается). extras уходят отдельным
|
||||
# best-effort проходом ниже (после закрытия flats-сессии) с recycle на
|
||||
# WAF-403. При extras_isolated=False — старое combined-поведение.
|
||||
pdir = Path(photos_dir) if photos_dir else PHOTOS_DIR_DEFAULT
|
||||
total = len(all_objects)
|
||||
for i in range(start_index, total):
|
||||
|
|
@ -2195,12 +1898,17 @@ async def run_region_sweep(
|
|||
if fetch_flats:
|
||||
coros.append(_fetch_flats_safe(sess, obj_id))
|
||||
|
||||
# extras в combined-сессии ТОЛЬКО когда изоляция выключена —
|
||||
# иначе они отравят flats (#1945). В isolated-режиме extras
|
||||
# обрабатываются отдельным проходом ниже.
|
||||
combined_extras = extras and not extras_isolated
|
||||
if combined_extras:
|
||||
coros.extend(_extras_coros(sess, obj_id))
|
||||
if extras:
|
||||
coros.append(_fetch_sale_graph_safe(sess, obj_id, "apartments"))
|
||||
coros.append(_fetch_sale_graph_safe(sess, obj_id, "parking"))
|
||||
coros.append(_fetch_sales_agg_safe(sess, obj_id))
|
||||
coros.append(_fetch_infrastructure_safe(sess, obj_id))
|
||||
coros.append(_fetch_photos_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_rpd_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_developer_report_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_project_documentation_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_documentation_other_safe(sess, obj_id))
|
||||
coros.append(_fetch_doc_permits_safe(sess, obj_id))
|
||||
# TODO: obj_checks endpoint not found at /api/object/{id}/checks (404).
|
||||
# 6 чек-боксов "Проверено на наш.дом.рф" вероятно inline в kn/object payload.
|
||||
# Re-enable после investigation структуры объекта (separate PR).
|
||||
|
|
@ -2214,28 +1922,64 @@ async def run_region_sweep(
|
|||
|
||||
# Sequential upsert — DB session не thread-safe
|
||||
all_docs: list[dict[str, Any]] = []
|
||||
_doc_kinds = frozenset(
|
||||
(
|
||||
"doc_rpd",
|
||||
"doc_developer_report",
|
||||
"doc_project_documentation",
|
||||
"doc_documentation_other",
|
||||
"doc_permits",
|
||||
)
|
||||
)
|
||||
for kind_tag, full_url, result in results:
|
||||
if isinstance(result, Exception):
|
||||
_classify_and_log(db, run_id, obj_id, kind_tag, full_url, result)
|
||||
continue
|
||||
|
||||
if kind_tag == "flats":
|
||||
total_flats += _process_flats_result(
|
||||
db, run_id, obj_id, full_url, result, snapshot_date, region_code
|
||||
)
|
||||
else:
|
||||
await _process_extras_result(
|
||||
sess,
|
||||
db,
|
||||
run_id,
|
||||
obj_id,
|
||||
kind_tag,
|
||||
full_url,
|
||||
result,
|
||||
snapshot_date,
|
||||
extras_counts,
|
||||
all_docs,
|
||||
download_photos_binary=download_photos_binary,
|
||||
pdir=pdir,
|
||||
flats_list: list[dict[str, Any]] = result # type: ignore[assignment]
|
||||
if flats_list:
|
||||
total_flats += upsert_flats(db, flats_list, snapshot_date, region_code)
|
||||
|
||||
elif kind_tag in ("sale_graph_apartments", "sale_graph_parking"):
|
||||
sg_type = kind_tag.replace("sale_graph_", "")
|
||||
rows_sg, _ = result # type: ignore[misc]
|
||||
extras_counts["sale_graph_rows"] += upsert_sale_graph(
|
||||
db, obj_id, sg_type, rows_sg, snapshot_date
|
||||
)
|
||||
|
||||
# Единый upsert всех документов объекта после обработки doc-endpoint'ов.
|
||||
elif kind_tag == "sales_agg":
|
||||
agg_data, _ = result # type: ignore[misc]
|
||||
extras_counts["sales_agg_rows"] += upsert_sales_agg(
|
||||
db, obj_id, agg_data, snapshot_date
|
||||
)
|
||||
|
||||
elif kind_tag == "infrastructure":
|
||||
pois_data, _ = result # type: ignore[misc]
|
||||
extras_counts["infra_rows"] += upsert_infrastructure(
|
||||
db, obj_id, pois_data, snapshot_date
|
||||
)
|
||||
|
||||
elif kind_tag == "photos":
|
||||
photos_data, _ = result # type: ignore[misc]
|
||||
local_paths: dict[str, str] = {}
|
||||
thumb_paths: dict[str, str] = {}
|
||||
if download_photos_binary and photos_data:
|
||||
local_paths, thumb_paths = await download_photos(
|
||||
sess, obj_id, photos_data, pdir
|
||||
)
|
||||
extras_counts["photos_downloaded"] += len(local_paths)
|
||||
extras_counts["photos_rows"] += upsert_photos(
|
||||
db, obj_id, photos_data, local_paths, thumb_paths
|
||||
)
|
||||
|
||||
elif kind_tag in _doc_kinds:
|
||||
# Каждый из 5 doc-endpoint'ов отдаёт свой список документов.
|
||||
# Накапливаем в all_docs — единый upsert после цикла.
|
||||
doc_items, _ = result # type: ignore[misc]
|
||||
all_docs.extend(extract_documents(doc_items or []))
|
||||
|
||||
# Единый upsert всех документов объекта после обработки 5 endpoint'ов.
|
||||
if all_docs:
|
||||
ins, _skip = upsert_documents(db, obj_id, all_docs)
|
||||
extras_counts["documents_rows"] += ins
|
||||
|
|
@ -2254,49 +1998,12 @@ async def run_region_sweep(
|
|||
f" downloaded={extras_counts['photos_downloaded']}"
|
||||
f" docs={extras_counts['documents_rows']}"
|
||||
f" checks={extras_counts['checks_rows']}",
|
||||
stage="extras" if combined_extras else "fetch_flats",
|
||||
stage="extras" if extras else "fetch_flats",
|
||||
)
|
||||
|
||||
_checkpoint(db, run_id, total)
|
||||
request_count = sess.request_count
|
||||
|
||||
# ── Phase C (isolated) — extras best-effort пас в ОТДЕЛЬНОЙ сессии ──────
|
||||
# flats-сессия закрыта. Теперь (только если extras=True И extras_isolated)
|
||||
# тянем extras в disposable-сессиях: на каждый WAF-403 recycle всю
|
||||
# BrowserSession (свежие cookies/контекст) перед следующим объектом, чтобы
|
||||
# яд НЕ накапливался. extras сейчас мертвы (403 с 2026-06-03) — пас целиком
|
||||
# best-effort: любые сбои уходят в kn_scrape_failures и НЕ роняют run.
|
||||
if extras and extras_isolated:
|
||||
try:
|
||||
ex_req = await _run_extras_pass(
|
||||
db=db,
|
||||
run_id=run_id,
|
||||
region_code=region_code,
|
||||
all_objects=all_objects,
|
||||
start_index=start_index,
|
||||
snapshot_date=snapshot_date,
|
||||
extras_counts=extras_counts,
|
||||
pdir=pdir,
|
||||
download_photos_binary=download_photos_binary,
|
||||
load_state=load_state,
|
||||
headed=headed,
|
||||
browser_concurrency=browser_concurrency,
|
||||
request_jitter_min_ms=request_jitter_min_ms,
|
||||
request_jitter_max_ms=request_jitter_max_ms,
|
||||
proxy_url=proxy_url,
|
||||
)
|
||||
request_count += ex_req
|
||||
except Exception as e:
|
||||
# best-effort: extras-пас НИКОГДА не должен ронять уже собранные flats.
|
||||
logger.warning("extras isolated pass failed (best-effort): %s", e)
|
||||
log_progress(
|
||||
db,
|
||||
run_id,
|
||||
f"extras-пас (best-effort) упал: {type(e).__name__}: {str(e)[:200]}",
|
||||
level="warn",
|
||||
stage="extras",
|
||||
)
|
||||
|
||||
# ── Phase D — finalize ─────────────────────────────────────────────
|
||||
db.execute(
|
||||
text(
|
||||
|
|
|
|||
|
|
@ -33,36 +33,15 @@ Issue #297 sub-task 22c.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from app.services.scrapers.stealth import BrowserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Базовая директория для скачанных бинарников планировок. Зеркалит конвенцию
|
||||
# domrf_kn.py:PHOTOS_DIR_DEFAULT (data/raw/domrf_photos) — тот же raw-корень,
|
||||
# отдельная папка. Файлы: {base}/{obj_id}/{safe_ods_id}.{ext}.
|
||||
FLAT_PLANS_DIR_DEFAULT = Path("data/raw/domrf_flat_plans")
|
||||
|
||||
# Разрешённые расширения картинок планировок. png — дефолт (DOM.РФ отдаёт планы
|
||||
# растром), svg на всякий случай (векторные схемы встречаются).
|
||||
_ALLOWED_PLAN_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".svg")
|
||||
|
||||
# Замена небезопасных для имени файла символов в ods_id (напр. "65136/1/1.4.3").
|
||||
_ODS_UNSAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
|
||||
# ── payload parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -233,131 +212,30 @@ def upsert_flat_plans(
|
|||
return inserted
|
||||
|
||||
|
||||
# ── image download ────────────────────────────────────────────────────────────
|
||||
# ── download stub ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _safe_ods_filename(ods_id: str) -> str:
|
||||
"""Превратить ods_id (напр. '65136/1/1.4.3') в безопасное имя файла.
|
||||
|
||||
Слэши и прочие небезопасные символы → '_'. Результат используется как
|
||||
basename в {base_dir}/{obj_id}/{safe}.{ext}.
|
||||
"""
|
||||
return _ODS_UNSAFE_RE.sub("_", ods_id).strip("_") or "plan"
|
||||
|
||||
|
||||
def _ext_from_url(url: str) -> str:
|
||||
"""Определить расширение файла по URL планировки. Default .png.
|
||||
|
||||
DOM.РФ отдаёт планы преимущественно PNG; расширение берём из path, если
|
||||
оно в белом списке, иначе .png (file-API URL /api/ext/file/... часто без
|
||||
расширения — контент-тип уточнится по факту, но png — безопасный дефолт).
|
||||
"""
|
||||
path = urlsplit(url).path.lower()
|
||||
for ext in _ALLOWED_PLAN_EXTS:
|
||||
if path.endswith(ext):
|
||||
return ".jpg" if ext == ".jpeg" else ext
|
||||
return ".png"
|
||||
|
||||
|
||||
def _image_dims(data: bytes) -> tuple[int | None, int | None]:
|
||||
"""Получить (width_px, height_px) картинки через Pillow. None при ошибке.
|
||||
|
||||
Pillow (PIL) уже зависимость репо (pyproject: pillow>=10.4.0, используется в
|
||||
app.services.photos.thumbs). SVG Pillow не парсит — вернёт (None, None),
|
||||
это ОК (размеры для векторных планов необязательны).
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
return int(im.width), int(im.height)
|
||||
except Exception as exc:
|
||||
logger.debug("flat plan image dims parse failed: %s", exc)
|
||||
return None, None
|
||||
|
||||
|
||||
async def download_plan_image(
|
||||
session: BrowserSession,
|
||||
def download_plan_image_stub(
|
||||
plan_image_url: str,
|
||||
ods_id: str,
|
||||
obj_id: int,
|
||||
base_dir: Path | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Скачать бинарник планировки квартиры и вернуть метаданные для upsert.
|
||||
dest_dir: str | None = None,
|
||||
) -> str:
|
||||
"""Заглушка для скачивания бинарника планировки.
|
||||
|
||||
Реиспользует BrowserSession.download_binary — тот же паттерн что download_photos
|
||||
в domrf_kn.py: тянет файл через browser context (те же cookies / TLS-fingerprint,
|
||||
что прошли WAF), с лёгким throttle (jitter 200–500 мс встроен в download_binary).
|
||||
|
||||
Файл сохраняется в {base_dir}/{obj_id}/{safe_ods_id}.{ext}. Если файл уже есть
|
||||
на диске — повторно НЕ качаем (idempotent, экономит WAF-бюджет), но всё равно
|
||||
возвращаем метаданные (размеры/размер читаются с диска).
|
||||
Реальная реализация — отдельный Celery task (22d-track, issue #299).
|
||||
До реализации бросает NotImplementedError, чтобы случайный вызов
|
||||
не прошёл незаметно.
|
||||
|
||||
Args:
|
||||
session: Активный BrowserSession (bootstrapped + warm_up).
|
||||
plan_image_url: Абсолютный URL картинки планировки.
|
||||
plan_image_url: URL картинки планировки.
|
||||
ods_id: Идентификатор квартиры (для имени файла).
|
||||
obj_id: ID объекта DOM.РФ (подпапка).
|
||||
base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).
|
||||
dest_dir: Директория для сохранения (None = MEDIA_ROOT/flat_plans/).
|
||||
|
||||
Returns:
|
||||
dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at —
|
||||
для передачи в upsert_flat_plans. None при ошибке скачивания.
|
||||
Raises:
|
||||
NotImplementedError: всегда — до реализации.
|
||||
"""
|
||||
root = base_dir or FLAT_PLANS_DIR_DEFAULT
|
||||
obj_dir = root / str(obj_id)
|
||||
ext = _ext_from_url(plan_image_url)
|
||||
local = obj_dir / f"{_safe_ods_filename(ods_id)}{ext}"
|
||||
|
||||
# Idempotent: уже скачан — читаем метаданные с диска, не жжём WAF-запрос.
|
||||
if local.exists() and local.stat().st_size > 0:
|
||||
data = local.read_bytes()
|
||||
width_px, height_px = _image_dims(data)
|
||||
logger.debug("flat plan already on disk ods_id=%s path=%s", ods_id, local)
|
||||
return {
|
||||
"local_path": str(local),
|
||||
"width_px": width_px,
|
||||
"height_px": height_px,
|
||||
"size_bytes": local.stat().st_size,
|
||||
"downloaded_at": datetime.now(UTC),
|
||||
}
|
||||
|
||||
try:
|
||||
data = await session.download_binary(plan_image_url)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"download_plan_image failed ods_id=%s obj_id=%s url=%s: %s",
|
||||
ods_id,
|
||||
obj_id,
|
||||
plan_image_url,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
if not data:
|
||||
logger.warning("download_plan_image empty body ods_id=%s url=%s", ods_id, plan_image_url)
|
||||
return None
|
||||
|
||||
obj_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
local.write_bytes(data)
|
||||
except OSError as exc:
|
||||
logger.warning("download_plan_image write failed ods_id=%s path=%s: %s", ods_id, local, exc)
|
||||
return None
|
||||
|
||||
width_px, height_px = _image_dims(data)
|
||||
logger.info(
|
||||
"download_plan_image ok ods_id=%s obj_id=%s bytes=%d dims=%sx%s",
|
||||
ods_id,
|
||||
obj_id,
|
||||
len(data),
|
||||
width_px,
|
||||
height_px,
|
||||
raise NotImplementedError(
|
||||
"download_plan_image_stub: скачивание планировок не реализовано. "
|
||||
"Реальный downloader — отдельный Celery task (issue #299, 22d-track). "
|
||||
f"URL={plan_image_url!r} ods_id={ods_id!r} dest_dir={dest_dir!r}"
|
||||
)
|
||||
return {
|
||||
"local_path": str(local),
|
||||
"width_px": width_px,
|
||||
"height_px": height_px,
|
||||
"size_bytes": len(data),
|
||||
"downloaded_at": datetime.now(UTC),
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue