Compare commits
508 commits
fix/object
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d56103219a | |||
|
|
7fd40f6ea5 | ||
| fcfc777baa | |||
|
|
b579fa4ced | ||
| 698576e77b | |||
| 67b50bf713 | |||
| c61ed5a96e | |||
| e61c2debe5 | |||
| c904fbf94e | |||
| 9fd6396fbc | |||
| 8b7af92ee3 | |||
| 2c5b30f8c9 | |||
| d530602fbd | |||
| 32037af6de | |||
| 5647c2b40e | |||
| 749c96cd0c | |||
| 987dce11bc | |||
| b5477362f0 | |||
| 60c21ae2a5 | |||
| 3bd0873230 | |||
| 5e73b35206 | |||
| 69ca746673 | |||
| 2cd24ecfbc | |||
| 92371e7d9e | |||
| 7fa52bb3ad | |||
| 348be445e3 | |||
|
|
8fdcd8df62 | ||
| c98a879a79 | |||
|
|
2ef3e42da1 | ||
|
|
720cee2c8e | ||
| 73f932d60a | |||
| e7c388dde7 | |||
|
|
07275c3c97 | ||
|
|
d92ff5c3d3 | ||
|
|
d31b1e01ee | ||
| 51473fc841 | |||
| c3d0f86f37 | |||
|
|
219cdfdbcc | ||
|
|
bf4ce1ba36 | ||
| 519b482cdd | |||
| e4bb2fc2e3 | |||
|
|
85321d1d1a | ||
| b1ac551451 | |||
| 032fd95a7e | |||
|
|
0583fb0efd | ||
| a3dfa2a99f | |||
|
|
20bdc7dec8 | ||
|
|
6487813ec5 | ||
|
|
1c9f0e1161 | ||
| 0f4ed2f6e9 | |||
|
|
27d1b09c72 | ||
|
|
c1a01fdbf9 | ||
| fb123bd97e | |||
|
|
f9ae6f0c53 | ||
| 3293df8e14 | |||
| 4c56c5189a | |||
|
|
25a865f27b | ||
|
|
69f8b50934 | ||
|
|
7801ac6cc1 | ||
| 5d7c3e1c30 | |||
| 6936558f6f | |||
|
|
f631af4de3 | ||
|
|
4a579e74e0 | ||
|
|
b93d26990b | ||
| 90a6e4bfe9 | |||
|
|
cf91ac6b24 | ||
| 74017b39c4 | |||
|
|
e250869c9d | ||
| b0b2c94ece | |||
|
|
e2502a0134 | ||
| 51952ebd70 | |||
|
|
c0cbdc2f99 | ||
| fcc37c46ab | |||
| 95f0b0aa64 | |||
|
|
752cb1044b | ||
| 338ccbb78f | |||
| a88e755403 | |||
| 4f205bd7da | |||
| 8dd35c01d8 | |||
| ed504cbbd9 | |||
| cc231946c2 | |||
| d22e0e00ae | |||
| 346767ba90 | |||
|
|
f4c26d474a | ||
| 3fe1216317 | |||
|
|
3c4a33ca32 | ||
| f987e819b2 | |||
| 9a6b56016b | |||
| d3f3370bed | |||
| 01360e9c3d | |||
| 22f3c44dc2 | |||
| 82fdabccba | |||
| 39dd63333e | |||
| 1fa99812af | |||
| 53b160ff0a | |||
| 464519c69b | |||
| 7315d04396 | |||
| d1fa450f48 | |||
| be4b2ee1fa | |||
| f3988add0a | |||
| c0c666cccc | |||
| de5da6ec35 | |||
| 1bbd4c99e2 | |||
| 82e0b8b0a3 | |||
| b40995780b | |||
| c8c96a87ee | |||
| 9b6aba42dc | |||
| 5b3799a23a | |||
| eea9c99e73 | |||
| 9c46689da1 | |||
| 994eba0a16 | |||
| 7c1d3693e2 | |||
| f93ef5374d | |||
| b2d42a631a | |||
| ced616d150 | |||
| acc1e05357 | |||
| 102bdbda47 | |||
| 752c3656a3 | |||
| cfed042712 | |||
| 5cbf3485a0 | |||
| b102c334d8 | |||
|
|
b238d3ef50 | ||
| 3a2b4ba825 | |||
| 27868a4d2e | |||
| 9c6afcc876 | |||
| 921aba04b8 | |||
| 0658399f01 | |||
|
|
27bbd579c3 | ||
| a0c609caf4 | |||
| 8030f8ecef | |||
| 00a4dca5c0 | |||
| f0a9763e48 | |||
| b28aefec16 | |||
| c8343c8f3d | |||
| 2a3a93b138 | |||
|
|
5015109f31 | ||
|
|
6ace4ea4ce | ||
|
|
b6b7af6b42 | ||
|
|
f0c7ac4972 | ||
|
|
36c451e155 | ||
| eafa305ac8 | |||
|
|
575c233e51 | ||
| 84716e61de | |||
|
|
79a77200d7 | ||
| 2e345acb82 | |||
| ab250a132b | |||
| 828d691c0d | |||
|
|
c30581ae8d | ||
|
|
85059aeb1b | ||
| b3a59b7cf1 | |||
| 5d51eae705 | |||
|
|
d0b011d388 | ||
|
|
42b1ca6d7f | ||
| 27ba08b821 | |||
|
|
8ca529ece0 | ||
| 155d5ec3a9 | |||
| 480d4c2128 | |||
| a7e1391814 | |||
| 98df57827e | |||
| dd222a4eb3 | |||
|
|
a9b87aa311 | ||
| a5097c3a97 | |||
| 2538fc02e2 | |||
| 293184ebc6 | |||
| 4f0cae5c18 | |||
| 9850bbde14 | |||
| 2580158d8c | |||
| 9b92b595db | |||
|
|
7800f6014a | ||
| 758a4b06d4 | |||
| 53947015e9 | |||
|
|
b445c34863 | ||
|
|
96dd27c4ae | ||
| 53e7834f19 | |||
|
|
debe55b8a0 | ||
| ae3278f92f | |||
| 282c92747e | |||
| ae814706a2 | |||
| 57d156973e | |||
| 38f703c8ec | |||
| d31fabd102 | |||
|
|
51e2e545b7 | ||
|
|
9b4cdf8ec6 | ||
| 91c8365d91 | |||
| 6468bd6b37 | |||
| 1e5fc13635 | |||
| 758d7c6bf8 | |||
|
|
ccf716119c | ||
|
|
b89e0c3daf | ||
| c4201f7d2a | |||
| c778e4c85d | |||
|
|
ecec9ff6d7 | ||
|
|
fea054d24f | ||
| 7ebeddc2bc | |||
| 36fe33750f | |||
|
|
4a6820b8f7 | ||
| 5fa505b809 | |||
| 9289c3d69d | |||
| f872f5636b | |||
| aa2b33f264 | |||
| 17cf7344b3 | |||
|
|
53287da886 | ||
|
|
7546868535 | ||
|
|
7049717782 | ||
| 1d9e25ce07 | |||
| e7f97f1355 | |||
| e3d26f0aae | |||
| 49304a78bc | |||
| f41ca530c5 | |||
| 30e033d653 | |||
| 9feadc8458 | |||
|
|
116cd10046 | ||
|
|
eb564cfc01 | ||
| 793234cff1 | |||
| 308eeac214 | |||
| 01f2c5290d | |||
| 7fd80adfa7 | |||
| 86af5b78bf | |||
| 7a113273f4 | |||
| 24b773efe3 | |||
| 279e7de778 | |||
|
|
88b93435fc | ||
|
|
ea24289f47 | ||
|
|
db3afa3176 | ||
| a7677b0fba | |||
| 27e21f37d3 | |||
|
|
8c8289cdad | ||
| bc9549782b | |||
| 5db6a2bec3 | |||
|
|
d95c9e6e55 | ||
|
|
57963e3a06 | ||
|
|
90731da537 | ||
| a8b504c3de | |||
| 4fedfc5970 | |||
| 0e2b4998c8 | |||
| c129515ab8 | |||
| 3945060f0c | |||
| 64d3a925ef | |||
| 71b125cc13 | |||
|
|
a7253639cd | ||
| 698c1be4f3 | |||
|
|
6f170504ba | ||
| b902edd44c | |||
|
|
dafd12d9fa | ||
|
|
404d88ee34 | ||
|
|
c672e0322d | ||
| e9e3cc7dcf | |||
| d15ff3d99d | |||
| b22edc6d6f | |||
|
|
25186a78ac | ||
| 666962185c | |||
| d6d4a1c94a | |||
| 2f127c4316 | |||
| 034d9f9bca | |||
| 0b3ec7f91b | |||
| e7881cf45c | |||
| c0c18f4ecc | |||
| 85435fbf1b | |||
| 8a42fba836 | |||
| 7c12812f81 | |||
| 99ee09e230 | |||
| dfc1ee8be0 | |||
| 039954580e | |||
|
|
6f81ee73d7 | ||
| 703f6f60c0 | |||
| 4a9ddb2a9a | |||
|
|
a3cab9023f | ||
|
|
9cdc7cef21 | ||
| fc5c4b1e40 | |||
|
|
9835876ced | ||
|
|
375d59c48d | ||
|
|
9a9f13d513 | ||
|
|
a08e263c60 | ||
|
|
ca0a51c876 | ||
| 68429967ab | |||
|
|
dd050af16d | ||
|
|
8a10d943d7 | ||
| f891f8f00f | |||
| bbe596747d | |||
|
|
dd8b5769e5 | ||
| c44bb49809 | |||
|
|
33e3503f97 | ||
| fc6542416d | |||
| e1c5f2184c | |||
|
|
c2096249d5 | ||
| 21608eced1 | |||
| 5f6db8dbb7 | |||
|
|
291717fa2f | ||
|
|
efa0bafa18 | ||
|
|
c65a73ca9a | ||
| 910bfed5f1 | |||
| e440209d91 | |||
|
|
edbdd28286 | ||
| 14d76fb136 | |||
| 29a3cf9483 | |||
|
|
bbd47ae536 | ||
| 6f5bdffbe4 | |||
| 15757685c9 | |||
| 55b13e2b13 | |||
| cc9516ab3d | |||
| c438c9a9e8 | |||
| af70d50f0d | |||
| 7c73bbfbeb | |||
| 830d9f429a | |||
| 031bafde86 | |||
| 5888b0b9d3 | |||
| 4b6d1cbc06 | |||
| 1bba3db7eb | |||
| 9cfefc0ca4 | |||
| fc55c2c0b7 | |||
| 4c23c222f6 | |||
| e9c8d05e1d | |||
| 8ea52b9b7a | |||
|
|
9de867631b | ||
| 9f28092356 | |||
| 5fb5675871 | |||
| 0793571c4a | |||
| 19ecd941f3 | |||
| ae483468c9 | |||
| ac19c4f7b3 | |||
| 4ce0f51bb8 | |||
| 47f45fc3d2 | |||
| 72d26b35fa | |||
| f5b0076e6f | |||
| e6000cb029 | |||
| b028377584 | |||
| a7ad1764c1 | |||
| ca473a0012 | |||
| d8ea4f2fd7 | |||
|
|
8fd2be97e4 | ||
| 0937da4260 | |||
| ee47e331f3 | |||
| a9d06c9c44 | |||
|
|
e84b6e553a | ||
| bcdec5ebd4 | |||
| c0566705a1 | |||
|
|
03ece36abd | ||
| 139bf16a8a | |||
|
|
0eb033d77d | ||
| a3db6e4158 | |||
|
|
35a0e29122 | ||
| 3b9dfdb44e | |||
| b2d794587f | |||
| 1c520cadb7 | |||
|
|
333a36f049 | ||
|
|
3505a4e3e5 | ||
| d87ebcc30e | |||
| cdc1e03b32 | |||
|
|
c4b1992de5 | ||
| b5f8f7b437 | |||
|
|
67a852caf1 | ||
| 509cecfa6a | |||
| 94114adca1 | |||
|
|
3909db745b | ||
| abdb413a99 | |||
| 15224da3b5 | |||
|
|
3ad1550363 | ||
| 8a177df0e2 | |||
| f7063d49b5 | |||
| e501dedf78 | |||
| d7f4873a53 | |||
| 62fa1f732a | |||
| b8ed46d2dc | |||
| 90e4a741f7 | |||
| f937f5a168 | |||
| 86a22dfc8f | |||
| d63122d3e4 | |||
| 3be8062062 | |||
| 19c0be5d5a | |||
| b7abaeb569 | |||
| df5e9003df | |||
| 97acf5273c | |||
| 5c17d21f6c | |||
| 9d71d94586 | |||
| e4a2d58c0c | |||
| ecc3ab5aab | |||
| 6391587572 | |||
| b7d35cfee0 | |||
| abef3e6e02 | |||
| 1c184959a8 | |||
| 03b3cb9fe4 | |||
| 0a6281b6e2 | |||
| f02067c7af | |||
| 227b939778 | |||
| 6965fd0484 | |||
| 79e4528eeb | |||
| b29b69343b | |||
| 18b22c9df8 | |||
| 8d7feabf9f | |||
| ddf6cb7ce3 | |||
| 32d1818fd4 | |||
| e2bb5061f9 | |||
| 5f661bd0b7 | |||
| 3bf2c1a48a | |||
| 24b107ddb5 | |||
| 102ef8392b | |||
| da0b8f0374 | |||
| 5839680998 | |||
| 2bb3ceb9de | |||
| e1a75d98d4 | |||
| 4758ea5e2b | |||
| 4a7ce91db1 | |||
| babb2a3473 | |||
| a2ec1fbc90 | |||
| e52dc47067 | |||
| 663f04b777 | |||
| 02b5f0b5d1 | |||
| 6d6e0ce0ca | |||
| b024864518 | |||
| daf4ebefc2 | |||
| 53cfd3c3b8 | |||
| 4ffda3299c | |||
| fe85cbb7ae | |||
| 806428abc9 | |||
| 02f49e795c | |||
| de4704f6cd | |||
| 975c400536 | |||
| e359fb4b95 | |||
| 5f84008cb3 | |||
| 005ce2f4ad | |||
| f448b550b5 | |||
| d9915c7834 | |||
| 1337fadb49 | |||
| 86f0797499 | |||
| a35d42f1c7 | |||
| caffd9b28d | |||
| 267e5dfc5b | |||
| 11a7018e78 | |||
| 0ea850c957 | |||
| 4863e67f27 | |||
| c362b8592a | |||
| 69845ddcee | |||
| cbe56cab0e | |||
| 078dadc440 | |||
| 169201bb8c | |||
| bb54de6000 | |||
| 89fb666d88 | |||
| f5a7f9b437 | |||
| 31e7816967 | |||
| 7cf6267b48 | |||
| a96af4c19c | |||
| ebc3813648 | |||
| 1b515f4e94 | |||
| b105e0a500 | |||
| ec4c7e9e6a | |||
| 384c4e7ac3 | |||
| bd3562b6bd | |||
| f617c323bd | |||
| 0f82b58265 | |||
| 848a44c629 | |||
| 565d5c906b | |||
| 32a58c91c5 | |||
| ec03a43283 | |||
| 77c01b6d3b | |||
| f60f2b24e6 | |||
| 7066117cb8 | |||
| 0641183ecd | |||
| efa2dc75ea | |||
| b45a819cb6 | |||
| 3320805723 | |||
| eab3d928d6 | |||
| 1722309372 | |||
| 2a38561175 | |||
| c8e7cb1c79 | |||
| 03ca46fe1d | |||
| 566b2f9617 | |||
| e76030588c | |||
| 041589f2fc | |||
| e148e61d49 | |||
| 6f76b565e5 | |||
| 848e6d3bdd | |||
| 4d063d6827 | |||
| 77b4f7e64c | |||
| b8fd7771b8 | |||
| 30e158ad2a | |||
| 8aaa42f101 | |||
| 57ae2dfe00 | |||
| 579536b4ad | |||
| 83ae6f8a47 | |||
| bcbbb53495 | |||
| e23dabe4f5 | |||
| 123717994b | |||
| f8112b420d | |||
| c97dd57e1c | |||
| fb25180929 | |||
| 5a2b6d91cd | |||
| f3e10a04ad | |||
| 94d01ed066 | |||
| 9e2d9ddbea | |||
| a100f03aa7 | |||
| 81ae21ada9 | |||
| 3421b21632 | |||
| 0c0efb887a | |||
| 267585e312 | |||
| 510cf1cb80 | |||
| ad5c1913cb | |||
| e2ff8958ca | |||
| 890daa4a31 | |||
| d651dac99a | |||
| a50af40ad6 | |||
| 7b8ffd4200 | |||
| fcb85deab8 | |||
| fba41c0cce | |||
| ca4637e5c8 | |||
| 2184453d8e | |||
| 43b392b809 | |||
| 59ee8b672f | |||
| e2bf285980 |
785 changed files with 113981 additions and 31189 deletions
|
|
@ -351,7 +351,7 @@ Claude имеет ДВА лимита: 5h-rolling (сам сбрасываетс
|
||||||
- Пустая очередь + нет fixup-PR → выходить из loop, а не крутиться вхолостую.
|
- Пустая очередь + нет fixup-PR → выходить из loop, а не крутиться вхолостую.
|
||||||
- Тяжёлые batch-прогоны — вне пиковых часов (≈5–11 PT) при возможности.
|
- Тяжёлые batch-прогоны — вне пиковых часов (≈5–11 PT) при возможности.
|
||||||
- Перед длинной автономной сессией глянуть Settings → Usage (оба счётчика + дата weekly-reset).
|
- Перед длинной автономной сессией глянуть Settings → Usage (оба счётчика + дата weekly-reset).
|
||||||
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
|
- **Окна — на Sonnet, не Opus** (`start-bot.ps1` уже запускает с `--model sonnet`; reviewer — opus). Opus — только main-оркестратору. Sonnet-пул отдельный, недельный All-models (Opus) пул так не горит.
|
||||||
- **Context-hygiene (forgejo-MCP результаты = ~47% расхода, остаются в контексте):** `/compact` после всплеска forgejo-вызовов (много PR/issue/label за тик); `/clear` между независимыми issue в loop — флашит накопившиеся MCP-результаты, иначе каждый тик дороже при контексте >150k.
|
- **Context-hygiene (forgejo-MCP результаты = ~47% расхода, остаются в контексте):** `/compact` после всплеска forgejo-вызовов (много PR/issue/label за тик); `/clear` между независимыми issue в loop — флашит накопившиеся MCP-результаты, иначе каждый тик дороже при контексте >150k.
|
||||||
|
|
||||||
## Error escalation
|
## 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."
|
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
|
status: draft
|
||||||
created_at: 2026-05-27
|
created_at: 2026-05-27
|
||||||
model: opus
|
model: sonnet
|
||||||
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
|
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. Использовать только как
|
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Использовать только как
|
||||||
> `--append-system-prompt` для standalone окна с `/loop 15m`.
|
> `--append-system-prompt` для standalone окна с `/loop 15m`.
|
||||||
>
|
>
|
||||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||||
> Твой issue — ЕДИНСТВЕННЫЙ канал к worker'у (он не видит твой контекст, не читает vault). Качество
|
> Твой issue — ЕДИНСТВЕННЫЙ канал к worker'у (он не видит твой контекст, не читает vault). Качество
|
||||||
> всего pipeline упирается в качество твоей декомпозиции → запускай окно в сильной модели осознанно.
|
> всего pipeline упирается в качество твоей декомпозиции → запускай окно в сильной модели осознанно.
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
||||||
|
|
||||||
**Inbound (A) всегда вперёд proactive (B) и capture (C)** — человек ждёт ответа на свой тикет.
|
**Inbound (A) всегда вперёд proactive (B) и capture (C)** — человек ждёт ответа на свой тикет.
|
||||||
|
|
||||||
## Per-tick workflow (every 30 minutes)
|
## Per-tick workflow (every 15 minutes)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. KILL-SWITCH check (см. _autonomous_pickup.md)
|
1. KILL-SWITCH check (см. _autonomous_pickup.md)
|
||||||
|
|
@ -68,8 +68,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
||||||
- По каждому scope узким запросом (labels=scope/X,status/Y — НЕ полный листинг):
|
- По каждому scope узким запросом (labels=scope/X,status/Y — НЕ полный листинг):
|
||||||
ready / wip / review / qa / needs-fix → карта «что уже в работе у backend/frontend/db/qa»,
|
ready / wip / review / qa / needs-fix → карта «что уже в работе у backend/frontend/db/qa»,
|
||||||
чтобы НЕ плодить дубль того, что воркер уже взял. Что закрылось: labels=status/done&since=30m.
|
чтобы НЕ плодить дубль того, что воркер уже взял. Что закрылось: labels=status/done&since=30m.
|
||||||
- ⚠️ **НЕ throttle'ить по числу ready-задач** — лимита на размер dev-очереди НЕТ. Декомпозируешь
|
- ⚠️ **Throttle: если открытых `status/ready` ≥ 10 — пропусти decomposition в этом тике**
|
||||||
всё, что нашёл (дубли отсекаешь по карте выше, не по счётчику).
|
(just-in-time нарезка: спеки дрейфуют, пока лежат в очереди; совпадает с work-as-analyst.md).
|
||||||
|
|
||||||
3b. KNOWLEDGE CAPTURE (vault-write — режим C; ПОСЛЕ inbound, off hot-path):
|
3b. KNOWLEDGE CAPTURE (vault-write — режим C; ПОСЛЕ inbound, off hot-path):
|
||||||
Для каждого issue, перешедшего в `status/done` за окно (из `labels=status/done&since=30m` шага 3):
|
Для каждого issue, перешедшего в `status/done` за окно (из `labels=status/done&since=30m` шага 3):
|
||||||
|
|
@ -185,7 +185,8 @@ actionable-спек (+ `scope/*` + `status/ready`), либо расщепляе
|
||||||
- **Дедуп + claim — ДО спавна** (шаги 2/3): саб-агенты не знают про queue-state, могут продублировать.
|
- **Дедуп + claim — ДО спавна** (шаги 2/3): саб-агенты не знают про queue-state, могут продублировать.
|
||||||
- Каждому саб-агенту в prompt — точный scope (какие dirs/файлы смотреть) + что вернуть (шаблон findings),
|
- Каждому саб-агенту в prompt — точный scope (какие dirs/файлы смотреть) + что вернуть (шаблон findings),
|
||||||
БЕЗ передачи токенов/credentials (runner логирует).
|
БЕЗ передачи токенов/credentials (runner логирует).
|
||||||
- Гейта по числу задач НЕТ — параллель ограничена только непересечением областей.
|
- Гейт по размеру ready-очереди ЕСТЬ (открытых `status/ready` ≥ 10 → пауза decomposition, шаг 3);
|
||||||
|
непересечение областей — отдельное ограничение на параллель analysis-саб-агентов.
|
||||||
|
|
||||||
## Запрос «поменяй лейблы» ⇒ также аудит тела issue
|
## Запрос «поменяй лейблы» ⇒ также аудит тела 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."
|
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
|
status: draft
|
||||||
created_at: 2026-05-27
|
created_at: 2026-05-27
|
||||||
model: opus
|
model: sonnet
|
||||||
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
|
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`
|
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||||
> для standalone окна с `/loop dynamic`.
|
> для standalone окна с `/loop dynamic`.
|
||||||
>
|
>
|
||||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn
|
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn
|
||||||
> (который запрещён). В standalone `/loop`-окне модель = модель, в которой запущено окно
|
> (который запрещён). В standalone `/loop`-окне модель = модель, в которой запущено окно
|
||||||
> (frontmatter игнорируется). Worker несёт всю judgment-нагрузку сам (интерпретация issue,
|
> (frontmatter игнорируется). Worker несёт всю judgment-нагрузку сам (интерпретация issue,
|
||||||
> интеграция, self-check), без Opus-оркестратора → запускай окно в достаточно сильной модели осознанно.
|
> интеграция, 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."
|
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
|
status: draft
|
||||||
created_at: 2026-05-27
|
created_at: 2026-05-27
|
||||||
model: opus
|
model: sonnet
|
||||||
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
|
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` руками —
|
> **Forgejo API → ТОЛЬКО `mcp__forgejo__*` tools.** ❌ НЕ дёргай curl / python3 / `/tmp/*.json` руками —
|
||||||
> на Windows это даёт `http=404` + `FileNotFoundError /tmp/...` (incident 2026-05-31, PR #893). MCP-тул
|
> на Windows это даёт `http=404` + `FileNotFoundError /tmp/...` (incident 2026-05-31, PR #893). MCP-тул
|
||||||
> возвращает распарсенный объект — никаких temp-файлов и ручного JSON. Полный mapping в
|
> возвращает распарсенный объект — никаких temp-файлов и ручного JSON. Полный mapping в
|
||||||
> [[_autonomous_pickup]] § «Forgejo операции». Запуск окна: `scripts/start-bot.ps1 reviewer` (Sonnet).
|
> [[_autonomous_pickup]] § «Forgejo операции». Запуск окна: `scripts/start-bot.ps1 reviewer`.
|
||||||
>
|
>
|
||||||
> ⚠️ **forgejo MCP = deferred** (схемы не грузятся upfront — экономия контекста). В НАЧАЛЕ work-тика,
|
> ⚠️ **forgejo MCP = deferred** (схемы не грузятся upfront — экономия контекста). В НАЧАЛЕ work-тика,
|
||||||
> если forgejo-тулзы ещё не загружены, выполни ОДИН раз:
|
> если forgejo-тулзы ещё не загружены, выполни ОДИН раз:
|
||||||
|
|
@ -78,6 +78,7 @@ Staff+ code reviewer в autonomous-merge режиме. Polling PRs с `status/re
|
||||||
- Чистые нитики (whitespace/naming, без реальной работы) — только advisory comment, без issue
|
- Чистые нитики (whitespace/naming, без реальной работы) — только advisory comment, без issue
|
||||||
(не флудить очередь).
|
(не флудить очередь).
|
||||||
✅ APPROVE:
|
✅ 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>)`
|
- `create_pull_review(index=N, state="APPROVED", body=<marker verdict=approve>)`
|
||||||
- **SHA guard перед merge**: повторный `get_pull_request_by_index(index=N)`, проверь
|
- **SHA guard перед merge**: повторный `get_pull_request_by_index(index=N)`, проверь
|
||||||
`head.sha[:7] == sha7` из marker — иначе устаревший verdict до fixup-push, abort merge
|
`head.sha[:7] == sha7` из marker — иначе устаревший verdict до fixup-push, abort merge
|
||||||
|
|
@ -108,6 +109,8 @@ 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`); косметику не заводить |
|
| 🟡 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 |
|
| ✅ 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
|
## Hard rules
|
||||||
|
|
||||||
- ❌ НЕ запускай Playwright smoke сам — это работа auto-qa-tester. Передача через status/qa.
|
- ❌ НЕ запускай 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."
|
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
|
status: draft
|
||||||
created_at: 2026-05-27
|
created_at: 2026-05-27
|
||||||
model: opus
|
model: sonnet
|
||||||
tools: Read, Write, Edit, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents
|
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`
|
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||||
> для standalone окна с `/loop dynamic`.
|
> для standalone окна с `/loop dynamic`.
|
||||||
>
|
>
|
||||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||||
> В standalone `/loop`-окне модель = модель окна. Worker несёт всю judgment-нагрузку сам → запускай
|
> В 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."
|
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
|
status: draft
|
||||||
created_at: 2026-05-27
|
created_at: 2026-05-27
|
||||||
model: opus
|
model: sonnet
|
||||||
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
|
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`
|
> **DRAFT.** Эта persona НЕ для Task-tool spawn. Только как `--append-system-prompt`
|
||||||
> для standalone окна с `/loop 5m`.
|
> для standalone окна с `/loop 5m`.
|
||||||
>
|
>
|
||||||
> **Модель = модель окна.** Frontmatter `model: sonnet` действует ТОЛЬКО при Task-spawn (запрещён).
|
> **Модель = модель окна.** Frontmatter `model` действует ТОЛЬКО при Task-spawn (запрещён).
|
||||||
> В standalone `/loop`-окне модель = модель окна → запускай осознанно.
|
> В standalone `/loop`-окне модель = модель окна → запускай осознанно.
|
||||||
|
|
||||||
> **Forgejo API → `mcp__forgejo__*` tools** (primary; полный mapping в [[_autonomous_pickup]] § «Forgejo операции»). curl — только fallback. Запуск окна: `scripts/start-bot.ps1 qa`.
|
> **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.
|
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 10 minutes)
|
## Per-tick workflow (every 5 minutes)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. KILL-SWITCH check (см. _autonomous_pickup.md)
|
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 НА МАШИНЕ ПОЛЬЗОВАТЕЛЯ."
|
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
|
status: draft
|
||||||
created_at: 2026-05-30
|
created_at: 2026-05-30
|
||||||
model: opus
|
model: sonnet
|
||||||
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
|
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
|
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).
|
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
|
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: opus
|
model: sonnet
|
||||||
color: blue
|
color: blue
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
||||||
|
|
||||||
## Запреты
|
## Запреты
|
||||||
|
|
||||||
- ❌ Не коммить (пользователь коммитит сам); пиши commit message в чат
|
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||||
- ❌ Не использовать `--no-verify` для обхода pre-commit
|
- ❌ Не использовать `--no-verify` для обхода pre-commit
|
||||||
- ❌ Не запускать миграции SQL — это работа database-expert (можешь делегировать через Agent tool если задача требует)
|
- ❌ Не запускать миграции SQL — это работа database-expert (можешь делегировать через Agent tool если задача требует)
|
||||||
- ❌ Не редактировать docker-compose / Caddyfile / .github/workflows/ — это работа devops-engineer
|
- ❌ Не редактировать docker-compose / Caddyfile / .github/workflows/ — это работа devops-engineer
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: code-reviewer
|
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 указаниями."
|
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
|
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: opus
|
model: sonnet
|
||||||
memory: project
|
memory: project
|
||||||
color: red
|
color: red
|
||||||
---
|
---
|
||||||
|
|
@ -108,10 +108,10 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
|
||||||
- Time spent: ~3 min
|
- Time spent: ~3 min
|
||||||
|
|
||||||
### Critical issues (BLOCK push)
|
### Critical issues (BLOCK push)
|
||||||
- [ ] `file.py:42` — описание проблемы + fix suggestion
|
- [ ] `file.py:42` — конкретный failure scenario (input/state → неверный output/crash) + fix suggestion. Без repro-сценария — не критикал, переквалифицируй в Minor или Positive observation.
|
||||||
|
|
||||||
### Minor issues (можно fix потом)
|
### Minor issues (можно fix потом)
|
||||||
- [ ] `file.py:84` — улучшение
|
- [ ] `file.py:84` — улучшение (не rubber-stamp: если это не влияет на поведение — Positive observations или пропусти)
|
||||||
|
|
||||||
### Positive observations
|
### Positive observations
|
||||||
- ✅ Что сделано хорошо
|
- ✅ Что сделано хорошо
|
||||||
|
|
@ -146,7 +146,7 @@ mcp__obsidian__obsidian_simple_search "<keyword>"
|
||||||
- Удаление prod данных без явного approval
|
- Удаление prod данных без явного approval
|
||||||
- `--no-verify` / `--force` / `--amend` в push
|
- `--no-verify` / `--force` / `--amend` в push
|
||||||
- **Прямой push в main** — нарушение PR workflow (см. CLAUDE.md). Должен быть feature branch + PR.
|
- **Прямой push в main** — нарушение PR workflow (см. CLAUDE.md). Должен быть feature branch + PR.
|
||||||
- **Merge PR без user approval** — нарушение PR workflow.
|
- **Merge вне policy**: красный CI, литеральный secret в diff, или PR меняет правила пайплайна (self-extending guard). Self-merge при зелёном CI разрешён с 2026-06-27 (git-pr.md § Auto-merge policy) — сам по себе НЕ блокер.
|
||||||
|
|
||||||
### Pre-flight check для PR workflow
|
### Pre-flight check для PR workflow
|
||||||
|
|
||||||
|
|
@ -161,4 +161,4 @@ git rev-parse --abbrev-ref HEAD
|
||||||
1. `git stash` или backup commit'ов
|
1. `git stash` или backup commit'ов
|
||||||
2. Создать ветку `git checkout -b feat/foo`
|
2. Создать ветку `git checkout -b feat/foo`
|
||||||
3. Перенести коммиты
|
3. Перенести коммиты
|
||||||
4. `git push -u origin feat/foo` + `gh pr create`
|
4. `git push -u forgejo feat/foo` + `mcp__forgejo__create_pull_request` (gh CLI bypassed 2026-05-16)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: database-expert
|
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).
|
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
|
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: opus
|
model: sonnet
|
||||||
color: purple
|
color: purple
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
||||||
- PostgreSQL 16 + PostGIS 3.4
|
- PostgreSQL 16 + PostGIS 3.4
|
||||||
- Партиционирование: `rosreestr_deals` (9 partitions, 2024Q1—2026Q1, ~7M rows)
|
- Партиционирование: `rosreestr_deals` (9 partitions, 2024Q1—2026Q1, ~7M rows)
|
||||||
- GIST индексы на geom-полях
|
- GIST индексы на geom-полях
|
||||||
- Alembic для prod schema changes (`backend/alembic/versions/`)
|
- Фактический канал prod-миграций = `data/sql/NN_*.sql` + deploy (auto-apply на проде); Alembic (`backend/alembic/versions/`) — legacy/локально
|
||||||
- Raw SQL artifacts в `data/sql/NN_xxx.sql` для больших миграций / views / bootstrap
|
- Raw SQL artifacts в `data/sql/NN_xxx.sql` для больших миграций / views / bootstrap
|
||||||
- psycopg v3 на стороне backend (НЕ psycopg2)
|
- psycopg v3 на стороне backend (НЕ psycopg2)
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
||||||
5. **Verify locally**:
|
5. **Verify locally**:
|
||||||
- Syntax-check: `psql --dry-run` не существует, но можно через временную dev-БД
|
- Syntax-check: `psql --dry-run` не существует, но можно через временную dev-БД
|
||||||
- Альтернативно — копируй SQL в комментарий и mentally parse
|
- Альтернативно — копируй SQL в комментарий и mentally parse
|
||||||
6. **Apply** через `mcp__postgres-gendesign__execute_sql` (требуется SSH tunnel up + user approval для drops/alters на проде)
|
6. **Apply**: prod schema changes ТОЛЬКО через `data/sql/NN_*.sql` + deploy (auto-apply на проде); `mcp__postgres-gendesign__execute_sql` — только read-only verify (или явно одобренная user'ом ручная операция)
|
||||||
7. **Verify post-apply**:
|
7. **Verify post-apply**:
|
||||||
- `information_schema.columns` для column changes
|
- `information_schema.columns` для column changes
|
||||||
- `pg_indexes` / `pg_views` для indexes/views (если разрешено читать pg_*)
|
- `pg_indexes` / `pg_views` для indexes/views (если разрешено читать pg_*)
|
||||||
|
|
@ -92,5 +92,5 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
||||||
- ❌ Изменять Alembic version files задним числом — только новые revisions
|
- ❌ Изменять Alembic version files задним числом — только новые revisions
|
||||||
- ❌ Запускать миграцию на проде без backup confirmation (если она destructive)
|
- ❌ Запускать миграцию на проде без backup confirmation (если она destructive)
|
||||||
- ❌ Использовать `psycopg2` в коде (только v3)
|
- ❌ Использовать `psycopg2` в коде (только v3)
|
||||||
- ❌ Commit'ить сам — пиши commit message в чат
|
- ❌ Commit'ить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||||
- ❌ Писать knowledge в `memory/memory-gendesign.jsonl` (deprecated) — только Obsidian vault
|
- ❌ Писать knowledge в `memory/memory-gendesign.jsonl` (deprecated) — только Obsidian vault
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ Short skeleton:
|
||||||
|
|
||||||
## Forgejo API conventions
|
## Forgejo API conventions
|
||||||
|
|
||||||
- `$FORGEJO_URL` = `https://git.gendsgn.ru`, `$FORGEJO_TOKEN` = в env (из `~/.claude/settings.json`)
|
- `$FORGEJO_URL` = `https://git.gendsgn.ru`, токен — `FORGEJO_ACCESS_TOKEN` / `FORGEJO_TOKEN_<ROLE>` из Windows User-scope env vars (выставляются ДО запуска claude; см. `_autonomous_pickup.md`)
|
||||||
- Owner/repo по умолчанию: `lekss361/gendesign`
|
- Owner/repo по умолчанию: `lekss361/gendesign`
|
||||||
- Auth header: `-H "Authorization: token $FORGEJO_TOKEN"`
|
- Auth header: `-H "Authorization: token $FORGEJO_TOKEN"`
|
||||||
- Pagination: `?page=1&limit=50` (max 50 на странице)
|
- Pagination: `?page=1&limit=50` (max 50 на странице)
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
## Auto-merge policy (PR review mode)
|
## Auto-merge policy (PR review mode)
|
||||||
|
|
||||||
При review открытых Forgejo PR — если verdict **✅ APPROVE** (нет 🔴/🟠/🟡):
|
При review открытых Forgejo PR — если verdict **✅ APPROVE** (нет 🔴/🟠/🟡):
|
||||||
**мержи сам, любой scope** (user override 2026-05-16: «после аппрув в ревью можешь мерджить все что угодно»).
|
**мержи сам, любой scope** (политика 2026-06-27, git-pr.md § Auto-merge policy).
|
||||||
|
|
||||||
### Pre-merge checks (все обязательны)
|
### Pre-merge checks (все обязательны)
|
||||||
|
|
||||||
|
|
@ -123,3 +123,4 @@ Forgejo API возвращает пустой body при успехе merge →
|
||||||
- CI failing → comment "approved but CI red — wait for green"
|
- CI failing → comment "approved but CI red — wait for green"
|
||||||
- Draft PR → comment "approved, ready when undrafted"
|
- Draft PR → comment "approved, ready when undrafted"
|
||||||
- Head SHA changed после твоего scan'а → НЕ мержь stale verdict, re-review нужен
|
- 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
|
name: devops-engineer
|
||||||
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).
|
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).
|
||||||
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
|
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: opus
|
model: sonnet
|
||||||
color: orange
|
color: orange
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
||||||
- Beget VPS, Москва, 2 vCPU / 4 GB / 40 GB NVMe
|
- Beget VPS, Москва, 2 vCPU / 4 GB / 40 GB NVMe
|
||||||
- Docker + docker-compose (два стека: main + obsidian)
|
- Docker + docker-compose (два стека: main + obsidian)
|
||||||
- Caddy 2 (auto-TLS Let's Encrypt)
|
- Caddy 2 (auto-TLS Let's Encrypt)
|
||||||
- GitHub Actions (build → GHCR → SSH → compose up)
|
- Forgejo Actions — фактический CI/deploy (`.forgejo/workflows/**`; build → GHCR → SSH → compose up); legacy GitHub Actions в `.github/workflows/**`
|
||||||
- Docker images: backend (lean), worker (with-chromium), frontend, couchdb (вешний)
|
- Docker images: backend (lean), worker (with-chromium), frontend, couchdb (вешний)
|
||||||
- Shared external network `gendesign_shared` для связи main↔obsidian стеков
|
- Shared external network `gendesign_shared` для связи main↔obsidian стеков
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
||||||
|
|
||||||
## Запреты
|
## Запреты
|
||||||
|
|
||||||
- ❌ Не коммить сам — пиши commit message в чат
|
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||||
- ❌ Не push в main с `--force` (никогда)
|
- ❌ Не push в main с `--force` (никогда)
|
||||||
- ❌ Не редактировать `backend/`/`frontend/` исходники (только infra-конфиги)
|
- ❌ Не редактировать `backend/`/`frontend/` исходники (только infra-конфиги)
|
||||||
- ❌ Не выполнять prod SSH без явного approval пользователя (читать prod-логи — отдельно safety guard, нужен approval)
|
- ❌ Не выполнять prod SSH без явного approval пользователя (читать prod-логи — отдельно safety guard, нужен approval)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: frontend-engineer
|
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).
|
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
|
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: opus
|
model: sonnet
|
||||||
color: green
|
color: green
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ Pre-loaded context: смотри vault через `mcp__obsidian__*` перед
|
||||||
|
|
||||||
## Запреты
|
## Запреты
|
||||||
|
|
||||||
- ❌ Не коммить сам — пиши commit message в чат
|
- ❌ Не коммить сам — оставь staged; коммитит main-сессия (agent-first pipeline)
|
||||||
- ❌ Не редактировать `backend/` — это работа backend-engineer
|
- ❌ Не редактировать `backend/` — это работа backend-engineer
|
||||||
- ❌ Не использовать `any`
|
- ❌ Не использовать `any`
|
||||||
- ❌ Не использовать pages router (`src/pages/`) — только app router (`src/app/`)
|
- ❌ Не использовать pages router (`src/pages/`) — только app router (`src/app/`)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: qa-tester
|
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)."
|
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
|
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: opus
|
model: sonnet
|
||||||
memory: project
|
memory: project
|
||||||
color: cyan
|
color: cyan
|
||||||
---
|
---
|
||||||
|
|
@ -50,7 +50,7 @@ POST-комменты — `--data-binary @file` (не inline `-d`: Windows ср
|
||||||
|
|
||||||
→ определи changed paths:
|
→ определи changed paths:
|
||||||
- `backend/app/api/v1/<X>.py` → curl endpoint этого роутера
|
- `backend/app/api/v1/<X>.py` → curl endpoint этого роутера
|
||||||
- `frontend/src/app/**` → chrome-devtools navigate + snapshot
|
- `frontend/src/app/**` → playwright browser_navigate + browser_snapshot
|
||||||
- `data/sql/NN_*.sql` → postgres MCP проверь schema (column exists, index there, view OK)
|
- `data/sql/NN_*.sql` → postgres MCP проверь schema (column exists, index there, view OK)
|
||||||
- `backend/app/scrapers/**` или `backend/app/workers/**` → запусти scraper, polling DB до terminal status
|
- `backend/app/scrapers/**` или `backend/app/workers/**` → запусти scraper, polling DB до terminal status
|
||||||
- `docker-compose*.yml` / `Caddyfile` → smoke production URLs + проверь containers running
|
- `docker-compose*.yml` / `Caddyfile` → smoke production URLs + проверь containers running
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
name: tech-analyst
|
name: tech-analyst
|
||||||
description: 'Tech analyst / planner для GenDesign. Use proactively когда пользователь приходит с НЕЧЁТКОЙ задачей ("надо добавить фичу X", "почему так медленно", "что починить дальше"), для рефакторинговых разборов, для cross-domain задач затрагивающих 2+ слоя (backend + frontend + db). Read-only — НЕ пишет код. Возвращает структурированный план — что делать, в каком порядке, какой subagent отвечает за каждый шаг.'
|
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
|
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: opus
|
model: sonnet
|
||||||
color: yellow
|
color: yellow
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"postgres-gendesign": {
|
"postgres-gendesign": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
|
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
|
||||||
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
||||||
},
|
},
|
||||||
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint=http://localhost:9222"], "alwaysLoad": true },
|
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint=http://localhost:9222"], "alwaysLoad": true },
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"postgres-gendesign": {
|
"postgres-gendesign": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=unrestricted"],
|
"args": ["run", "-i", "--rm", "-e", "DATABASE_URI", "crystaldba/postgres-mcp", "--access-mode=restricted"],
|
||||||
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
"env": { "DATABASE_URI": "${GENDESIGN_DB_URI}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
.claude/rules/delegation.md
Normal file
48
.claude/rules/delegation.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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)
|
- `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)
|
- 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` → `.github/workflows/deploy-obsidian.yml`
|
- `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md` → `.forgejo/workflows/deploy-obsidian.yml`
|
||||||
- `docs/**` alone → НЕ триггерит деплой
|
- `docs/**` alone → НЕ триггерит деплой
|
||||||
|
|
||||||
## После изменения .env на VPS
|
## После изменения .env на VPS
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,11 @@ paths:
|
||||||
```
|
```
|
||||||
1. claude --bg --name "feat-<scope>" "task description"
|
1. claude --bg --name "feat-<scope>" "task description"
|
||||||
→ supervisor создаёт worktree от forgejo/main автоматически
|
→ supervisor создаёт worktree от forgejo/main автоматически
|
||||||
(settings worktree.baseRef=default, bgIsolation=git)
|
(settings worktree.baseRef=fresh, bgIsolation=worktree)
|
||||||
2. Session работает: код → commit → push → mcp__forgejo__create_pull_request
|
2. Session работает: код → commit → push → mcp__forgejo__create_pull_request
|
||||||
3. PR URL появляется в agent view как row с зелёной/жёлтой/красной ● status dot
|
3. PR URL появляется в agent view как row с зелёной/жёлтой/красной ● status dot
|
||||||
4. User делает review через external Claude window (post-push)
|
4. User делает review через external Claude window (post-push)
|
||||||
5. После merge — `Ctrl+X dwa раза` в agent view = удаление session + worktree
|
5. После merge — `Ctrl+X два раза` в agent view = удаление session + worktree
|
||||||
```
|
```
|
||||||
|
|
||||||
**Foreground workflow** (когда нужен интерактив, ad-hoc fixes):
|
**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`
|
1. `mcp__forgejo__get_pull_request` (или `curl -sH "$H" "$REPO/pulls/<N>"`) → читай `state`, `mergeable`, `head.sha`
|
||||||
2. `state == merged` → stop polling
|
2. `state == merged` → stop polling
|
||||||
3. Новый review/comment: `mcp__forgejo__list_pr_reviews` / `list_issue_comments`. Парсь marker `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
|
3. Новый review/comment: `mcp__forgejo__list_pull_reviews` / `list_issue_comments`. Парсь marker `<!-- gendesign-review-bot: sha=<sha7> verdict=<approve|changes> -->`
|
||||||
- **SHA guard**: `marker.sha7 == head.sha[:7]` — иначе устаревший approval до fixup-push, игнорируй
|
- **SHA guard**: `marker.sha7 == head.sha[:7]` — иначе устаревший approval до fixup-push, игнорируй
|
||||||
- `verdict=approve` + SHA match → `mcp__forgejo__merge_pull_request` (squash + delete branch)
|
- `verdict=approve` + SHA match → `mcp__forgejo__merge_pull_request` (squash + delete branch)
|
||||||
- `verdict=changes` → fixup commits + push в `forgejo feat/<scope>` + re-poll
|
- `verdict=changes` → fixup commits + push в `forgejo feat/<scope>` + re-poll
|
||||||
|
|
@ -91,16 +91,20 @@ Closes #N
|
||||||
- Diff содержит литеральный secret/token/password/credential (40-char hex, API keys, JWT, и т.д.) — security tripwire.
|
- 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).
|
- 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).
|
||||||
|
|
||||||
## Sequential PRs
|
## Parallel vs sequential PRs
|
||||||
|
|
||||||
**Одна задача → один PR → merge → следующая.** Параллельно ТОЛЬКО если scope'ы строго orthogonal (разные файлы).
|
**Default = параллельно**, если scope'ы НЕ пересекаются по файлам: каждая задача в своём worktree (`git worktree add` / isolation:"worktree"), своя ветка, свой PR.
|
||||||
|
|
||||||
Опасные файлы для конфликтов: `backend/app/api/v1/parcels.py`, `frontend/src/types/site-finder.ts`, OverviewTab/LandTab/MarketTab.
|
**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
|
||||||
|
|
||||||
## Split big issues
|
## Split big issues
|
||||||
|
|
||||||
Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Integration**. Каждый ~200-500 lines. PR N+1 только после merge PR N.
|
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)
|
## Review workflow (no conflict)
|
||||||
|
|
||||||
- **Pre-push** (локально): spawn `code-reviewer` subagent на staged changes → lint pass (security, correctness, conventions). Блокирует push при 🔴 критикал.
|
- **Pre-push** (локально): spawn `code-reviewer` subagent на staged changes → lint pass (security, correctness, conventions). Блокирует push при 🔴 критикал.
|
||||||
|
|
@ -112,12 +116,12 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
|
||||||
|
|
||||||
- **Параллелизм**: 3-5 sessions одновременно (>5 = bottleneck на review, не на Claude per Anthropic метрика)
|
- **Параллелизм**: 3-5 sessions одновременно (>5 = bottleneck на review, не на Claude per Anthropic метрика)
|
||||||
- **Pin (`Ctrl+T`)** для long-running sessions (scraper monitors, deploy watchers) — supervisor не убьёт через 1h idle
|
- **Pin (`Ctrl+T`)** для long-running sessions (scraper monitors, deploy watchers) — supervisor не убьёт через 1h idle
|
||||||
- **Cleanup**: `Ctrl+X dwa раза` после merge → session + worktree удалены атомарно
|
- **Cleanup**: `Ctrl+X два раза` после merge → session + worktree удалены атомарно
|
||||||
- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (используй sequential PR rule выше)
|
- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (см. § Parallel vs sequential PRs)
|
||||||
|
|
||||||
**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: default` (свежая ветка от main, не от твоей stale-сессии).
|
**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: fresh` (свежая ветка от 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.
|
||||||
|
|
||||||
## Запреты
|
## Запреты
|
||||||
|
|
||||||
|
|
@ -126,4 +130,4 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte
|
||||||
- ❌ `gh pr *` — bypassed 2026-05-16, используй Forgejo MCP или curl + `$FORGEJO_TOKEN`
|
- ❌ `gh pr *` — bypassed 2026-05-16, используй Forgejo MCP или curl + `$FORGEJO_TOKEN`
|
||||||
- ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval
|
- ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval
|
||||||
- ❌ `@claude` в PR comments — plain text only (`feedback_no_claude_mentions`)
|
- ❌ `@claude` в PR comments — plain text only (`feedback_no_claude_mentions`)
|
||||||
- ❌ Параллельные PR на одни файлы (`feedback_sequential_prs`)
|
- ❌ Параллельные PR на одни файлы / hot-files (см. § Parallel vs sequential PRs)
|
||||||
|
|
|
||||||
144
.claude/settings.json
Normal file
144
.claude/settings.json
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
{
|
||||||
|
"$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,3 +32,40 @@ 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.
|
- 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).
|
- `.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.
|
- 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,7 +24,21 @@
|
||||||
"ScraperPage": null,
|
"ScraperPage": null,
|
||||||
"ScrapersUnifiedPage": null,
|
"ScrapersUnifiedPage": null,
|
||||||
"TradeInPage": null,
|
"TradeInPage": null,
|
||||||
"YandexScraperPage": 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" }
|
||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"component": "PreviewProvider"
|
"component": "PreviewProvider"
|
||||||
|
|
@ -32,5 +46,8 @@
|
||||||
"extraEntries": [
|
"extraEntries": [
|
||||||
"../../.design-sync/preview-provider.tsx"
|
"../../.design-sync/preview-provider.tsx"
|
||||||
],
|
],
|
||||||
"readmeHeader": ".design-sync/conventions.md"
|
"readmeHeader": ".design-sync/conventions.md",
|
||||||
|
"extraFonts": [
|
||||||
|
"../../.design-sync/fonts/brand-fonts.css"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@ This is **not Tailwind and not CSS-in-JS**. Styling is a **global stylesheet** o
|
||||||
| Border | `--border-soft` `--border-card` `--border-strong` |
|
| Border | `--border-soft` `--border-card` `--border-strong` |
|
||||||
| Shape/type | `--radius` `--radius-sm` `--radius-lg` `--shadow-md` `--font-sans` (Manrope) `--font-mono` `--container` |
|
| 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
|
```tsx
|
||||||
<div style={{ background: "var(--bg-card)", border: "1px solid var(--border-card)",
|
<div style={{ background: "var(--bg-card)", border: "1px solid var(--border-card)",
|
||||||
borderRadius: "var(--radius)", padding: 16, color: "var(--fg-primary)",
|
borderRadius: "var(--radius)", padding: 16, color: "var(--fg-primary)",
|
||||||
|
|
|
||||||
BIN
.design-sync/fonts/IBMPlexMono-300-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-300-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-300-latin.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-300-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-400-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-400-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-400-latin.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-400-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-500-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-500-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/IBMPlexMono-500-latin.woff2
Normal file
BIN
.design-sync/fonts/IBMPlexMono-500-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/Inter-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/Inter-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/Inter-latin.woff2
Normal file
BIN
.design-sync/fonts/Inter-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/JetBrainsMono-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/JetBrainsMono-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/JetBrainsMono-latin.woff2
Normal file
BIN
.design-sync/fonts/JetBrainsMono-latin.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/Manrope-cyrillic.woff2
Normal file
BIN
.design-sync/fonts/Manrope-cyrillic.woff2
Normal file
Binary file not shown.
BIN
.design-sync/fonts/Manrope-latin.woff2
Normal file
BIN
.design-sync/fonts/Manrope-latin.woff2
Normal file
Binary file not shown.
112
.design-sync/fonts/brand-fonts.css
Normal file
112
.design-sync/fonts/brand-fonts.css
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/* 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 =
|
// knob for component inclusion: non-null value = add/pin src path, null =
|
||||||
// exclude a .d.ts-exported internal.
|
// exclude a .d.ts-exported internal.
|
||||||
|
|
||||||
import { existsSync, writeFileSync } from 'node:fs';
|
import { existsSync, writeFileSync, readFileSync } from 'node:fs';
|
||||||
import { dirname, join, relative, resolve } from 'node:path';
|
import { dirname, join, relative, resolve } from 'node:path';
|
||||||
import { Project, Node, ts } from 'ts-morph';
|
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_*)
|
// forked from design-sync lib/source-kit.mjs — synth-entry process shim (Next.js app reads process.env.NEXT_PUBLIC_*)
|
||||||
|
|
@ -70,7 +70,18 @@ export async function resolvePackage(ctx) {
|
||||||
console.error(`[NO_DIST] ${PKG} has no built entry and no src/ to synthesize from — run its build.`);
|
console.error(`[NO_DIST] ${PKG} has no built entry and no src/ to synthesize from — run its build.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const comps = srcFiles.filter((p) => SRC_IMPL_RX.test(p) && !NON_IMPL_RX.test(p));
|
// 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')),
|
||||||
|
);
|
||||||
// Next.js app code reads process.env.NEXT_PUBLIC_* at module top-level; in
|
// 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
|
// the browser IIFE `process` is undefined → every component throws. Emit a
|
||||||
// shim module and import it FIRST (ESM evaluates the first import's body
|
// shim module and import it FIRST (ESM evaluates the first import's body
|
||||||
|
|
@ -89,10 +100,25 @@ export async function resolvePackage(ctx) {
|
||||||
"__e.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ??= 'trade-in@example.com';\n",
|
"__e.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ??= 'trade-in@example.com';\n",
|
||||||
);
|
);
|
||||||
entry = join(OUT, '.pkg-entry.mjs');
|
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(
|
writeFileSync(
|
||||||
entry,
|
entry,
|
||||||
`import ${JSON.stringify(slash(shim))};\n` +
|
`import ${JSON.stringify(slash(shim))};\n` +
|
||||||
comps.map((p) => `export * from ${JSON.stringify(p)};`).join('\n') +
|
comps.map((p) => `export * from ${JSON.stringify(p)};`).join('\n') +
|
||||||
|
'\n' +
|
||||||
|
defaultReexports.join('\n') +
|
||||||
'\n',
|
'\n',
|
||||||
);
|
);
|
||||||
synthEntry = true;
|
synthEntry = true;
|
||||||
|
|
@ -117,8 +143,17 @@ export async function resolvePackage(ctx) {
|
||||||
names.add(k);
|
names.add(k);
|
||||||
}
|
}
|
||||||
let components = [...names].sort().map((name) => ({ name, group: 'general' }));
|
let components = [...names].sort().map((name) => ({ name, group: 'general' }));
|
||||||
if (!components.length && synthEntry) {
|
if (synthEntry) {
|
||||||
components = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
|
// 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) {
|
if (!components.length) {
|
||||||
if (cfg.cssEntry || existsSync(join(PKG_DIR, 'styles.css'))) {
|
if (cfg.cssEntry || existsSync(join(PKG_DIR, 'styles.css'))) {
|
||||||
|
|
|
||||||
|
|
@ -57,5 +57,17 @@ export function PreviewProvider({ children }: { children: React.ReactNode }) {
|
||||||
);
|
);
|
||||||
return qc;
|
return qc;
|
||||||
});
|
});
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
.design-sync/previews/BuildingListingsDrawer.tsx
Normal file
36
.design-sync/previews/BuildingListingsDrawer.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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={() => {}} />
|
||||||
|
);
|
||||||
25
.design-sync/previews/LocationDrawer.tsx
Normal file
25
.design-sync/previews/LocationDrawer.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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,36 +1,10 @@
|
||||||
import { ResultPanel } from 'tradein-mvp-frontend';
|
import { ResultPanel } from 'tradein-mvp-frontend';
|
||||||
|
|
||||||
/** Переиспользуемая панель результата мутации скрапера. Успех → JSON-дамп
|
/**
|
||||||
* ответа в `.scraper-result`. */
|
* ResultPanel (v2) — центральная панель результата оценки на `/trade-in/v2`:
|
||||||
export const Default = () => (
|
* одна цифра-герой (ожидаемая цена продажи / «оценка»), тиры median + ДКП,
|
||||||
<ResultPanel
|
* доверительные диапазоны, источники и мини-гистограмма распределения.
|
||||||
mut={{
|
* Данные по умолчанию берутся из встроенной `RESULT_FIXTURE` компонента,
|
||||||
isSuccess: true,
|
* поэтому карточке достаточно передать `onNavigate` (навигация по секциям).
|
||||||
isPending: false,
|
*/
|
||||||
error: null,
|
export const Default = () => <ResultPanel onNavigate={() => {}} />;
|
||||||
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'),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
|
||||||
54
.design-sync/previews/SaleShareControls.tsx
Normal file
54
.design-sync/previews/SaleShareControls.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
50
.design-sync/previews/SaleShareList.tsx
Normal file
50
.design-sync/previews/SaleShareList.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
30
.design-sync/previews/SaleShareMap.tsx
Normal file
30
.design-sync/previews/SaleShareMap.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
39
.design-sync/previews/SectionOverlay.tsx
Normal file
39
.design-sync/previews/SectionOverlay.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
135
.forgejo/workflows/ci-tradein.yml
Normal file
135
.forgejo/workflows/ci-tradein.yml
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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
|
||||||
|
|
@ -9,6 +9,14 @@ name: Deploy Obsidian
|
||||||
#
|
#
|
||||||
# Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub).
|
# Не пересобирает никаких Docker-образов (CouchDB официальный с DockerHub).
|
||||||
# Не трогает main-стек (backend / frontend / postgres / worker / beat / caddy).
|
# Не трогает 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:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -17,7 +25,7 @@ on:
|
||||||
- "docker-compose.obsidian.yml"
|
- "docker-compose.obsidian.yml"
|
||||||
- "scripts/setup-couchdb.sh"
|
- "scripts/setup-couchdb.sh"
|
||||||
- "docs/obsidian-livesync.md"
|
- "docs/obsidian-livesync.md"
|
||||||
- ".github/workflows/deploy-obsidian.yml"
|
- ".forgejo/workflows/deploy-obsidian.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|
@ -103,6 +103,10 @@ jobs:
|
||||||
filters: |
|
filters: |
|
||||||
backend:
|
backend:
|
||||||
- 'tradein-mvp/backend/**'
|
- 'tradein-mvp/backend/**'
|
||||||
|
# scraper-kit вкомпилирован в backend-образ (build context tradein-mvp/,
|
||||||
|
# scheduler_main импортирует пакет) — kit-only изменение обязано
|
||||||
|
# пересобрать образ, иначе деплой рестартует контейнеры на старом.
|
||||||
|
- 'tradein-mvp/packages/scraper-kit/**'
|
||||||
frontend:
|
frontend:
|
||||||
- 'tradein-mvp/frontend/**'
|
- 'tradein-mvp/frontend/**'
|
||||||
browser:
|
browser:
|
||||||
|
|
@ -117,11 +121,19 @@ jobs:
|
||||||
- 'tradein-mvp/backend/app/services/scheduler.py'
|
- 'tradein-mvp/backend/app/services/scheduler.py'
|
||||||
- 'tradein-mvp/backend/app/scheduler_main.py'
|
- 'tradein-mvp/backend/app/scheduler_main.py'
|
||||||
- 'tradein-mvp/backend/app/tasks/**'
|
- '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).
|
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
|
||||||
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
||||||
# Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed.
|
# Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed.
|
||||||
# 2 pre-existing order-dependent tests are deselected (see DESELECT note below).
|
# 1 pre-existing order-dependent test is deselected (see DESELECT note below).
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: changes
|
needs: changes
|
||||||
|
|
@ -146,20 +158,21 @@ jobs:
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Sync deps (incl. dev group — pytest)
|
- name: Sync deps (incl. dev group — pytest)
|
||||||
# NB: tradein-mvp/backend/uv.lock is gitignored (see tradein-mvp/.gitignore),
|
# Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored
|
||||||
# so --frozen can't work in CI (no lockfile in a fresh checkout). Resolve
|
# только старый backend/uv.lock) → --frozen детерминирован и зеркалит
|
||||||
# from pyproject.toml like the Dockerfile does (uv sync --no-dev there).
|
# Dockerfile (uv sync --frozen --no-dev). Актуализировано в #2208.
|
||||||
run: uv sync
|
run: uv sync --frozen
|
||||||
|
|
||||||
- name: Run pytest (tradein-mvp/backend)
|
- name: Run pytest (tradein-mvp/backend)
|
||||||
# DESELECT (2026-05): two pre-existing tests fail only in whole-suite
|
# DESELECT (актуализировано 2026-07-02, #2208): test_search_cache_hit падает
|
||||||
# ordering (pass in isolation) due to global state leak from other test
|
# ТОЛЬКО в whole-suite ordering (401 vs 200; в изоляции проходит) — global-state
|
||||||
# modules — not introduced here. Excluded so the gate is reliably green;
|
# leak из другого test-модуля, pre-existing. Второй исторический deselect
|
||||||
# tracked separately. Everything else (1087 tests) must pass.
|
# (test_cian_valuation::test_cache_hit_returns_cached) убран — проходит в полном
|
||||||
|
# прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан
|
||||||
|
# совпадать с backend-tests в ci-tradein.yml (pre-merge гейт).
|
||||||
run: |
|
run: |
|
||||||
uv run pytest -q \
|
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:
|
build-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -183,7 +196,10 @@ jobs:
|
||||||
- name: Build & push tradein-backend
|
- name: Build & push tradein-backend
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./tradein-mvp/backend
|
# Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit
|
||||||
|
# для editable install (#2137). Dockerfile — в backend/.
|
||||||
|
context: ./tradein-mvp
|
||||||
|
file: ./tradein-mvp/backend/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
|
||||||
|
|
@ -216,10 +232,13 @@ jobs:
|
||||||
context: ./tradein-mvp/frontend
|
context: ./tradein-mvp/frontend
|
||||||
push: true
|
push: true
|
||||||
# basePath=/trade-in baked-in во время build (Next.js)
|
# 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: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_PATH=/trade-in
|
NEXT_PUBLIC_BASE_PATH=/trade-in
|
||||||
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
NEXT_PUBLIC_API_BASE_URL=/trade-in
|
||||||
NEXT_PUBLIC_ENABLE_PREVIEW=1
|
|
||||||
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max
|
||||||
tags: |
|
tags: |
|
||||||
|
|
@ -276,7 +295,13 @@ jobs:
|
||||||
env:
|
env:
|
||||||
IMAGE_TAG: latest
|
IMAGE_TAG: latest
|
||||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||||
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
|
# 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' }}
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
GITHUB_SHA: ${{ github.sha }}
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
|
@ -314,24 +339,44 @@ jobs:
|
||||||
export IMAGE_TAG="$IMAGE_TAG"
|
export IMAGE_TAG="$IMAGE_TAG"
|
||||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
|
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
|
||||||
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
|
||||||
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
|
||||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
|
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
|
||||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
|
||||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
|
||||||
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
|
|
||||||
|
|
||||||
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
|
# (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
|
||||||
|
fi
|
||||||
|
echo "→ postgres ready."
|
||||||
|
|
||||||
|
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
|
||||||
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
|
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
|
||||||
# старте volume. Здесь — для повторных миграций после первого запуска.
|
# старте volume. Здесь — для повторных миграций после первого запуска.
|
||||||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||||
sleep 5
|
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||||
|
# #2216) glob'ом не подхватывается.
|
||||||
|
|
||||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||||
|
|
@ -405,16 +450,117 @@ jobs:
|
||||||
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
|
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Retry backend lifespan hook AFTER migrations applied.
|
# (4) Теперь — новый app-код: схема уже актуальна.
|
||||||
# tradein-backend startup runs ensure_fdw_user_mapping which needs
|
#
|
||||||
# FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql).
|
# #1951: раньше scraper пересоздавался ВТОРОЙ отдельной командой `up -d`,
|
||||||
# Without restart, the first compose-up's startup hook failed with
|
# уже ПОСЛЕ browser/backend/frontend. Если это происходило посреди
|
||||||
# "server gendesign_remote does not exist" because migrations hadn't run yet.
|
# in-flight sweep'а (avito/cian/rosreestr full-load), процесс убивался на
|
||||||
# See PR #493 deploy/1156 for the incident details.
|
# лету, а осиротевшая scrape_runs-строка сидела status='running' с
|
||||||
echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup"
|
# замёрзшим heartbeat до периодического 6h zombie-reaper'а — false "hang"
|
||||||
docker restart tradein-backend
|
# investigation вместо честного deploy-артефакта (см. cian_full_load #404).
|
||||||
# Give backend time to come up before Caddy reload + health check below
|
# Три меры ниже — все devops-only (shell в deploy-скрипте), БЕЗ правок
|
||||||
sleep 5
|
# 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'а).
|
||||||
|
# tgbot: тот же backend-образ (rebuild уже покрыт filters.backend —
|
||||||
|
# tradein-mvp/backend/** включает app/tgbot_main.py), никакого
|
||||||
|
# in-flight state вроде scrape_runs → пересоздаётся безусловно вместе
|
||||||
|
# с browser/backend/frontend, отдельного graceful-drain не требует.
|
||||||
|
SERVICES="browser backend frontend tgbot"
|
||||||
|
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 существует) уже с первого старта. Рестарт удалён.
|
||||||
|
|
||||||
# Caddy reload — основной Caddyfile содержит inline tradein routes
|
# Caddy reload — основной Caddyfile содержит inline tradein routes
|
||||||
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
|
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
|
||||||
|
|
@ -423,12 +569,28 @@ jobs:
|
||||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \
|
||||||
caddy reload --config /etc/caddy/Caddyfile || true
|
caddy reload --config /etc/caddy/Caddyfile || true
|
||||||
|
|
||||||
# Health check
|
# 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=""
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \
|
||||||
exec -T backend curl -fsS http://localhost:8000/health && break
|
exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then
|
||||||
|
healthy="yes"; break
|
||||||
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
if [ -z "$healthy" ]; then
|
||||||
|
echo "ERROR: backend не ответил на /health за 30s — деплой FAILED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "→ backend healthy на /health."
|
||||||
|
|
||||||
# Cleanup старых образов
|
# Cleanup старых образов
|
||||||
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
|
for repo in ghcr.io/lekss361/gendesign-tradein-backend \
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,14 @@ jobs:
|
||||||
# but other callers may invoke the raw path).
|
# but other callers may invoke the raw path).
|
||||||
chmod +x ops/*.sh 2>/dev/null || true
|
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
|
# Sentry release tracking
|
||||||
mkdir -p backend
|
mkdir -p backend
|
||||||
touch backend/.env.runtime
|
touch backend/.env.runtime
|
||||||
|
|
|
||||||
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
|
*.jsonl
|
||||||
!memory/memory-gendesign.jsonl
|
!memory/memory-gendesign.jsonl
|
||||||
|
|
||||||
|
|
@ -44,7 +44,6 @@ tradein-mvp/scripts/.*cookies*.json
|
||||||
# Agents and rules ARE tracked: they are team-shared config.
|
# Agents and rules ARE tracked: they are team-shared config.
|
||||||
# Explicit per-path list — `.claude/` + `!.claude/agents/` whitelist pattern
|
# Explicit per-path list — `.claude/` + `!.claude/agents/` whitelist pattern
|
||||||
# does not work because git cannot re-include files under an ignored parent.
|
# does not work because git cannot re-include files under an ignored parent.
|
||||||
.claude/settings.json
|
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/cache/
|
.claude/cache/
|
||||||
.claude/sessions/
|
.claude/sessions/
|
||||||
|
|
@ -88,6 +87,10 @@ sf_anton_snapshot.db-*
|
||||||
data/osrm/*
|
data/osrm/*
|
||||||
!data/osrm/.gitkeep
|
!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
|
# Log cruft at repo root
|
||||||
debug.log
|
debug.log
|
||||||
.ds-sync/
|
.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`**.
|
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 (НЕ дублировать).
|
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`.
|
||||||
|
|
||||||
4. **psycopg v3 only.** `import psycopg2` → ModuleNotFoundError. `CAST(:x AS type)` в SQL — никогда `:x::type`. **Полные правила: `.claude/rules/backend.md` + `sql.md`**.
|
4. **psycopg v3 only.** `import psycopg2` → ModuleNotFoundError. `CAST(:x AS type)` в SQL — никогда `:x::type`. **Полные правила: `.claude/rules/backend.md` + `sql.md`**.
|
||||||
|
|
||||||
|
|
@ -41,14 +41,16 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
||||||
| `database-expert` | `data/sql/**.sql`, Alembic, EXPLAIN ANALYZE |
|
| `database-expert` | `data/sql/**.sql`, Alembic, EXPLAIN ANALYZE |
|
||||||
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` |
|
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` |
|
||||||
| `code-reviewer` | Pre-push lint (security, correctness, conventions) |
|
| `code-reviewer` | Pre-push lint (security, correctness, conventions) |
|
||||||
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE |
|
| `deep-code-reviewer` | Тщательный review критичных PR (миграции / auth / scrapers) + merge authority при ✅ APPROVE (не эксклюзивно: self-merge разрешён любой сессии с 2026-06-27) |
|
||||||
| `qa-tester` | Post-deploy smoke (playwright / curl / SQL) сразу после merge+deploy — rule #7 |
|
| `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.
|
**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
|
## Where to look
|
||||||
|
|
||||||
- **Path-scoped rules:** `.claude/rules/` (backend, frontend, sql, git-pr, deploy, ui-tokens, ui-conventions, ui-microcopy) — auto-loaded при работе с соответствующими файлами через `paths:` frontmatter
|
- **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)
|
||||||
- **Personal preferences:** `~/.claude/CLAUDE.md` (cross-project: no auto-commit, no Co-Authored-By, no @claude)
|
- **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
|
- **Workflow memory:** `~/.claude/projects/<repo>/memory/MEMORY.md` index
|
||||||
- **Vault MOCs:** `meta/`, `domains/<area>/<area>-MOC.md`, `decisions/`, `fixes/`, `limitations/`, `code/patterns/`
|
- **Vault MOCs:** `meta/`, `domains/<area>/<area>-MOC.md`, `decisions/`, `fixes/`, `limitations/`, `code/patterns/`
|
||||||
|
|
@ -56,7 +58,7 @@ Live: `https://gendsgn.ru/` — Свердловская обл. (ЕКБ, ПЗЗ
|
||||||
|
|
||||||
## Don't (cross-domain — specifics → `.claude/rules/`)
|
## Don't (cross-domain — specifics → `.claude/rules/`)
|
||||||
|
|
||||||
- ❌ Direct push в main / merge без approval
|
- ❌ Direct push в main; merge вне policy (красный CI / tripwires — git-pr.md § Auto-merge policy)
|
||||||
- ❌ `--no-verify` / `--amend` / `git push --force`
|
- ❌ `--no-verify` / `--amend` / `git push --force`
|
||||||
- ❌ Knowledge-файлы вне vault (`docs/research/`, `notes/`, `wiki/`)
|
- ❌ Knowledge-файлы вне vault (`docs/research/`, `notes/`, `wiki/`)
|
||||||
- ❌ Hardcode credentials в коде / коммитах
|
- ❌ Hardcode credentials в коде / коммитах
|
||||||
|
|
|
||||||
33
Caddyfile
33
Caddyfile
|
|
@ -16,15 +16,13 @@
|
||||||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||||||
# ineffective. With route { }, handlers are matched top-to-bottom as written.
|
# ineffective. With route { }, handlers are matched top-to-bottom as written.
|
||||||
|
|
||||||
{
|
# #2213: global `log_credentials` УБРАН. Он заставлял Caddy писать raw
|
||||||
servers {
|
# Authorization header (Base64 `user:password`, тривиально обратимый) в ОБА лог-файла
|
||||||
# ВКЛЮЧАЕТ raw Authorization/Cookie headers в access log
|
# (gendsgn.ru.log + auth_audit.log). Даже при root-only доступе и коротком retention
|
||||||
# (по default Caddy 2.x редактирует их как "REDACTED").
|
# это plaintext-пароли пилотов на диске — недопустимый риск против скромной выгоды.
|
||||||
# Plain password будет в gendsgn.ru.log + auth_audit.log — оба root-only на VPS.
|
# Trade-off: glitchtip-auth-forwarder больше не извлекает `attempted_username` из 401
|
||||||
# См. forwarder.py _extract_attempted_username для использования.
|
# (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает).
|
||||||
log_credentials
|
# Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip.
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gendsgn.ru {
|
gendsgn.ru {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
@ -88,9 +86,23 @@ gendsgn.ru {
|
||||||
uri strip_prefix /trade-in
|
uri strip_prefix /trade-in
|
||||||
reverse_proxy tradein-backend:8000 {
|
reverse_proxy tradein-backend:8000 {
|
||||||
header_up X-Authenticated-User {http.auth.user.id}
|
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 (без слеша),
|
# Matcher `path /trade-in /trade-in/*` ловит И /trade-in (без слеша),
|
||||||
# И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*`
|
# И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*`
|
||||||
# пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ.
|
# пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ.
|
||||||
|
|
@ -99,6 +111,9 @@ gendsgn.ru {
|
||||||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
header_up X-Authenticated-User {http.auth.user.id}
|
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/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.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-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
|
||||||
- [`.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/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; контейнер держался вручную.)*
|
||||||
|
|
||||||
**Forgejo Secrets / Variables:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе).
|
**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
|
admin: admin
|
||||||
kopylov: pilot
|
kopylov: pilot
|
||||||
user1: pilot
|
user1: pilot
|
||||||
user2: pilot
|
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
||||||
user3: pilot
|
user3: pilot
|
||||||
user4: pilot
|
user4: pilot
|
||||||
user5: pilot
|
user5: pilot
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ POST /api/v1/admin/etl/objective-backfill
|
||||||
POST /api/v1/admin/etl/nspd-denorm-backfill
|
POST /api/v1/admin/etl/nspd-denorm-backfill
|
||||||
Запустить backfill nspd_parcels/nspd_buildings из всех nspd_quarter_dumps.
|
Запустить 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
|
Auth: gendsgn.ru-wide Caddy basic_auth gate (PR #426). App-level X-Admin-Token
|
||||||
header removed 2026-05-23 — двойная auth избыточна для pilot.
|
header removed 2026-05-23 — двойная auth избыточна для pilot.
|
||||||
"""
|
"""
|
||||||
|
|
@ -16,17 +23,24 @@ header removed 2026-05-23 — двойная auth избыточна для pilo
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db
|
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 (
|
from app.services.etl.objective_backfill import (
|
||||||
AUTO_ACCEPT_THRESHOLD,
|
AUTO_ACCEPT_THRESHOLD,
|
||||||
AUTO_ACCEPT_THRESHOLD_V2,
|
AUTO_ACCEPT_THRESHOLD_V2,
|
||||||
REVIEW_THRESHOLD,
|
REVIEW_THRESHOLD,
|
||||||
|
apply_core_matches,
|
||||||
auto_apply_matches,
|
auto_apply_matches,
|
||||||
|
find_core_matches,
|
||||||
find_match_candidates,
|
find_match_candidates,
|
||||||
trigger_mv_refresh,
|
trigger_mv_refresh,
|
||||||
)
|
)
|
||||||
|
|
@ -104,6 +118,37 @@ def run_objective_backfill(
|
||||||
return result
|
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")
|
@router.post("/nspd-denorm-backfill")
|
||||||
def run_nspd_denorm_backfill(
|
def run_nspd_denorm_backfill(
|
||||||
limit: Annotated[
|
limit: Annotated[
|
||||||
|
|
@ -124,3 +169,68 @@ def run_nspd_denorm_backfill(
|
||||||
task = backfill_all_dumps.apply_async(kwargs={"limit": limit})
|
task = backfill_all_dumps.apply_async(kwargs={"limit": limit})
|
||||||
logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit)
|
logger.info("nspd-denorm-backfill enqueued: task_id=%s limit=%s", task.id, limit)
|
||||||
return {"task_id": task.id, "status": "enqueued", "limit": 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,6 +497,21 @@ def trigger_poi_sync() -> dict[str, Any]:
|
||||||
return {"task_id": result.id, "queued_at": "now"}
|
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):
|
class TriggerObjectiveEtlRequest(BaseModel):
|
||||||
sqlite_path: str | None = Field(
|
sqlite_path: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -1137,7 +1152,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
||||||
)
|
)
|
||||||
from app.workers.tasks.etl_newbuilding_crossload import etl_newbuilding_crossload
|
from app.workers.tasks.etl_newbuilding_crossload import etl_newbuilding_crossload
|
||||||
|
|
||||||
result = etl_newbuilding_crossload.apply_async()
|
result = etl_newbuilding_crossload.apply_async(kwargs={"triggered_by": "manual"})
|
||||||
return {"task_id": result.id, "queued_at": "now"}
|
return {"task_id": result.id, "queued_at": "now"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1404,8 +1419,12 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
||||||
table="nspd_geo_jobs",
|
table="nspd_geo_jobs",
|
||||||
work_col="targets_done",
|
work_col="targets_done",
|
||||||
attempt_fallback_col="created_at",
|
attempt_fallback_col="created_at",
|
||||||
fresh_days=7.0,
|
# On-demand источник БЕЗ cron (admin UI / CLI / lazy из analyze) — штучные
|
||||||
stale_days=30.0,
|
# фетчи по активности пользователя. fresh_days=7 флагал каждую неделю
|
||||||
|
# тишины ложным stale-алертом (расследование 2026-07-04); пороги — под
|
||||||
|
# реальную каденцию, critical=False и так не трогает overall.
|
||||||
|
fresh_days=30.0,
|
||||||
|
stale_days=90.0,
|
||||||
),
|
),
|
||||||
FreshnessSource(
|
FreshnessSource(
|
||||||
source="cadastre",
|
source="cadastre",
|
||||||
|
|
@ -1416,6 +1435,20 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
|
||||||
fresh_days=14.0,
|
fresh_days=14.0,
|
||||||
stale_days=45.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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1756,6 +1789,20 @@ def trigger_ekburg_permits(
|
||||||
return {"task_id": result.id, "scope": scope, "queued_at": "now"}
|
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):
|
class TriggerKnCatalogObjectsRequest(BaseModel):
|
||||||
region_code: int = Field(default=66, ge=1, le=99)
|
region_code: int = Field(default=66, ge=1, le=99)
|
||||||
max_objects: int | None = Field(default=None, ge=1, le=2000)
|
max_objects: int | None = Field(default=None, ge=1, le=2000)
|
||||||
|
|
@ -1767,6 +1814,14 @@ 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")
|
@router.post("/kn-catalog-objects")
|
||||||
|
|
@ -1783,7 +1838,14 @@ def trigger_kn_catalog_objects(
|
||||||
- max_objects=None → дефолтный лимит таска (300).
|
- max_objects=None → дефолтный лимит таска (300).
|
||||||
- max_objects=3 → smoke-тест.
|
- max_objects=3 → smoke-тест.
|
||||||
- force=True → "Загрузить все": игнорирует skip-today, грузит всё подряд.
|
- 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
|
from app.workers.tasks.scrape_kn_catalog_objects import scrape_kn_catalog_objects
|
||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
|
|
@ -1801,3 +1863,65 @@ def trigger_kn_catalog_objects(
|
||||||
"force": payload.force,
|
"force": payload.force,
|
||||||
"queued_at": "now",
|
"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,7 +35,11 @@ from app.core.db import get_db
|
||||||
from app.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
|
from app.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
|
||||||
from app.services.chat.intents import render_answer, route_intent
|
from app.services.chat.intents import render_answer, route_intent
|
||||||
from app.services.chat.orchestrator import orchestrate_chat
|
from app.services.chat.orchestrator import orchestrate_chat
|
||||||
from app.services.chat.retrieval import _FORECAST_SCHEMA_VERSION, get_report_for_chat
|
from app.services.chat.retrieval import (
|
||||||
|
_FORECAST_SCHEMA_VERSION,
|
||||||
|
get_parcel_context_for_chat,
|
||||||
|
get_report_for_chat,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -92,11 +96,46 @@ async def ask(
|
||||||
report_status="pending",
|
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:
|
if settings.llm_enabled:
|
||||||
return await _answer_via_llm(db, payload, report, run_id)
|
return await _answer_via_llm(db, payload, report, run_id)
|
||||||
return _answer_deterministic(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(
|
def _answer_deterministic(
|
||||||
payload: ChatAskRequest,
|
payload: ChatAskRequest,
|
||||||
report: dict[str, Any],
|
report: dict[str, Any],
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
|
@ -18,6 +20,32 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
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
|
# Response schema
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -129,6 +157,30 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
|
||||||
return 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)
|
@router.get("/landing/stats", response_model=LandingStatsOut)
|
||||||
def landing_stats(
|
def landing_stats(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
|
@ -144,7 +196,7 @@ def landing_stats(
|
||||||
- paradox: строка-парадокс портфеля для hero CTA
|
- paradox: строка-парадокс портфеля для hero CTA
|
||||||
- stale: true если данные недоступны (DB ошибка или пустая БД)
|
- stale: true если данные недоступны (DB ошибка или пустая БД)
|
||||||
"""
|
"""
|
||||||
result = _query_stats(db)
|
result = _query_stats_cached(db)
|
||||||
if result is None:
|
if result is None:
|
||||||
return LandingStatsOut(**_FALLBACK_DATA)
|
return LandingStatsOut(**_FALLBACK_DATA)
|
||||||
return LandingStatsOut(**result)
|
return LandingStatsOut(**result)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -114,6 +114,51 @@ class Settings(BaseSettings):
|
||||||
# Empty string = endpoint disabled.
|
# Empty string = endpoint disabled.
|
||||||
scrape_admin_token: str = ""
|
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
|
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
|
||||||
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
|
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
|
||||||
# Settings оставлены deprecated на случай отката (можно удалить позже).
|
# Settings оставлены deprecated на случай отката (можно удалить позже).
|
||||||
|
|
@ -164,6 +209,13 @@ class Settings(BaseSettings):
|
||||||
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
# Default rate-limit для nspd_lite fetcher (мс между запросами).
|
||||||
nspd_lite_rate_ms: int = 600
|
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_* таблицы).
|
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
|
||||||
# На проде монтируется bind-mount-ом docker-compose:
|
# На проде монтируется bind-mount-ом docker-compose:
|
||||||
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
||||||
|
|
@ -306,5 +358,18 @@ class Settings(BaseSettings):
|
||||||
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
|
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
|
||||||
llm_max_retries: int = 2
|
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()
|
settings = Settings()
|
||||||
|
|
|
||||||
155
backend/app/resources/certs/russian_trusted_ca_bundle.pem
Normal file
155
backend/app/resources/certs/russian_trusted_ca_bundle.pem
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
# 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-----
|
||||||
|
|
@ -20,6 +20,18 @@ class BuildingProgramItem(BaseModel):
|
||||||
floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group")
|
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")
|
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):
|
class HouseTypeCatalogItem(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,16 @@ class EngineeringStructure(BaseModel):
|
||||||
name: str | None
|
name: str | None
|
||||||
type: str | None
|
type: str | None
|
||||||
cad_num: 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
|
distance_to_boundary_m: float
|
||||||
geometry_geojson: dict[str, Any]
|
geometry_geojson: dict[str, Any]
|
||||||
readable_address: str | None
|
readable_address: str | None
|
||||||
|
# Доп. характеристики из НСПД-дампа (год постройки / длина / собственность),
|
||||||
|
# собранные в одну читаемую строку. None — данных нет. ADDITIVE (default None).
|
||||||
|
characteristics: str | None = None
|
||||||
raw_props: dict[str, Any]
|
raw_props: dict[str, Any]
|
||||||
source: str
|
source: str
|
||||||
|
|
||||||
|
|
@ -58,6 +65,11 @@ class ConnectionPointsSummary(BaseModel):
|
||||||
in_protection_zone: bool
|
in_protection_zone: bool
|
||||||
protection_zones_intersecting: int
|
protection_zones_intersecting: int
|
||||||
total_structures_in_radius: 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):
|
class ConnectionPointsResponse(BaseModel):
|
||||||
|
|
@ -82,6 +94,10 @@ class UtilityInfrastructureFeature(BaseModel):
|
||||||
source_tag: str | None
|
source_tag: str | None
|
||||||
distance_m: float
|
distance_m: float
|
||||||
geometry_geojson: dict[str, Any]
|
geometry_geojson: dict[str, Any]
|
||||||
|
# Гуманизированные характеристики сети из OSM tags (напряжение / оператор /
|
||||||
|
# диаметр / материал), собранные в одну строку. None — данных нет.
|
||||||
|
# ADDITIVE (default None) → не ломает существующих потребителей.
|
||||||
|
characteristics: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UtilityInfrastructureSummary(BaseModel):
|
class UtilityInfrastructureSummary(BaseModel):
|
||||||
|
|
@ -89,6 +105,10 @@ class UtilityInfrastructureSummary(BaseModel):
|
||||||
nearest_distance_m: float | None
|
nearest_distance_m: float | None
|
||||||
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
|
# Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
|
||||||
nearest_by_kind: dict[str, float | None]
|
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):
|
class UtilityInfrastructureResponse(BaseModel):
|
||||||
|
|
@ -96,6 +116,190 @@ class UtilityInfrastructureResponse(BaseModel):
|
||||||
summary: UtilityInfrastructureSummary
|
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) ───────────────────────────────
|
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -400,6 +604,25 @@ class TopLayoutRow(BaseModel):
|
||||||
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)
|
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):
|
class LayoutTzMixRow(BaseModel):
|
||||||
"""Строка рекомендации unit-mix для ТЗ."""
|
"""Строка рекомендации unit-mix для ТЗ."""
|
||||||
|
|
||||||
|
|
@ -425,10 +648,28 @@ class LayoutTzRecommendation(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class LayoutDataQuality(BaseModel):
|
class LayoutDataQuality(BaseModel):
|
||||||
"""Метаданные качества данных (coverage)."""
|
"""Метаданные качества данных (coverage).
|
||||||
|
|
||||||
objects_with_velocity_data: int
|
Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
|
||||||
objects_total_in_radius: int
|
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
|
||||||
velocity_coverage_pct: float
|
velocity_coverage_pct: float
|
||||||
confidence: Literal["high", "medium", "low"]
|
confidence: Literal["high", "medium", "low"]
|
||||||
|
|
||||||
|
|
@ -439,6 +680,10 @@ class BestLayoutsResponse(BaseModel):
|
||||||
top_layouts: list[TopLayoutRow]
|
top_layouts: list[TopLayoutRow]
|
||||||
recommendation_for_tz: LayoutTzRecommendation
|
recommendation_for_tz: LayoutTzRecommendation
|
||||||
data_quality: LayoutDataQuality
|
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) ──────────────────────────────────────
|
# ── Analyze endpoint market price (#33) ──────────────────────────────────────
|
||||||
|
|
@ -623,6 +868,11 @@ class AnalyzeResponse(BaseModel):
|
||||||
score_label: str | None = None
|
score_label: str | None = None
|
||||||
score_max_reference: float | None = None
|
score_max_reference: float | None = None
|
||||||
score_explanation: str | 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: dict[str, Any] | None = None
|
||||||
score_breakdown_detailed: list[dict[str, Any]] | None = None
|
score_breakdown_detailed: list[dict[str, Any]] | None = None
|
||||||
score_top_3_positives: list[dict[str, Any]] | None = None
|
score_top_3_positives: list[dict[str, Any]] | None = None
|
||||||
|
|
@ -638,6 +888,9 @@ class AnalyzeResponse(BaseModel):
|
||||||
market_data_coverage_pct: float | None = None
|
market_data_coverage_pct: float | None = None
|
||||||
pipeline_24mo: dict[str, Any] | None = None
|
pipeline_24mo: dict[str, Any] | None = None
|
||||||
velocity: 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_trend: dict[str, Any] | None = None
|
||||||
market_price: dict[str, Any] | None = None
|
market_price: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
@ -660,6 +913,7 @@ class AnalyzeResponse(BaseModel):
|
||||||
parcel_meta: dict[str, Any] | None = None
|
parcel_meta: dict[str, Any] | None = None
|
||||||
recent_permits_in_quarter: list[dict[str, Any]] | None = None
|
recent_permits_in_quarter: list[dict[str, Any]] | None = None
|
||||||
permits_summary: 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
|
zoning: dict[str, Any] | None = None
|
||||||
success_recommendation: dict[str, Any] | None = None
|
success_recommendation: dict[str, Any] | None = None
|
||||||
isochrones_available: bool | None = None
|
isochrones_available: bool | None = None
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,15 @@ def developer_history(
|
||||||
|
|
||||||
|
|
||||||
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
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 = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -511,8 +520,15 @@ def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
||||||
SELECT obj_id, comm_name, addr, region_cd, flat_count,
|
SELECT obj_id, comm_name, addr, region_cd, flat_count,
|
||||||
square_living, ready_dt, obj_class, escrow,
|
square_living, ready_dt, obj_class, escrow,
|
||||||
problem_flag, latitude, longitude, is_ekb
|
problem_flag, latitude, longitude, is_ekb
|
||||||
FROM domrf_kn_objects
|
FROM (
|
||||||
WHERE dev_id = :dev
|
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
|
||||||
ORDER BY ready_dt DESC NULLS LAST
|
ORDER BY ready_dt DESC NULLS LAST
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -1797,12 +1813,38 @@ def _active_competitors_count(
|
||||||
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
|
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
|
||||||
|
|
||||||
def _q(where_extras: str, params: dict[str, Any]) -> int:
|
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(
|
n = db.execute(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
SELECT COUNT(*) FROM domrf_kn_objects
|
WITH latest AS (
|
||||||
WHERE region_cd = :rc
|
SELECT DISTINCT ON (obj_id)
|
||||||
AND site_status = 'Строящиеся'
|
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 = 'Строящиеся'
|
||||||
{where_extras}
|
{where_extras}
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -2233,14 +2275,28 @@ def _competitors_two_dim(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
f"""
|
f"""
|
||||||
WITH active AS (
|
WITH latest AS (
|
||||||
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name
|
-- #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
|
||||||
FROM domrf_kn_objects
|
FROM domrf_kn_objects
|
||||||
WHERE region_cd = :rc
|
WHERE region_cd = :rc
|
||||||
AND site_status = 'Строящиеся'
|
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||||
|
),
|
||||||
|
active AS (
|
||||||
|
-- Волатильные фильтры на истинно-последней строке.
|
||||||
|
SELECT obj_id, latitude, longitude, district_name
|
||||||
|
FROM latest
|
||||||
|
WHERE site_status = 'Строящиеся'
|
||||||
AND district_name = :dn
|
AND district_name = :dn
|
||||||
{class_filter}
|
{class_filter}
|
||||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
|
||||||
),
|
),
|
||||||
centroid AS (
|
centroid AS (
|
||||||
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt
|
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.analysis_runs.repository import latest_run_for
|
from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION, latest_run_for
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
|
||||||
# схеме явный и устойчивый.
|
# схеме явный и устойчивый.
|
||||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||||
|
|
||||||
|
# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
|
||||||
|
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
|
||||||
|
_MAX_ZOUIT_NAMES = 15
|
||||||
|
|
||||||
|
|
||||||
def get_report_for_chat(
|
def get_report_for_chat(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|
@ -57,3 +61,116 @@ def get_report_for_chat(
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
return result, run.id
|
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,9 +14,14 @@ tool'ами (см. tools.py). ЭТО ЕДИНСТВЕННОЕ место, чер
|
||||||
РАЗРЕШЕНО в ``section_data`` / ``fields``:
|
РАЗРЕШЕНО в ``section_data`` / ``fields``:
|
||||||
• срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
|
• срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
|
||||||
scoring / confidence / scenarios) — это посчитанные advisory-агрегаты.
|
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;
|
• свободный insight-текст / лиды / любой контент с PII;
|
||||||
• любой источник, помеченный confidential.
|
• любой источник, помеченный confidential.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
LLM в tool-loop'е (см. orchestrator.py) просит секции отчёта через function-calling.
|
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_exec_summary` / `get_product_recommendation` / `get_forecast` /
|
||||||
`get_risks` / `get_scenarios`). Без параметров — каждый отдаёт фиксированную
|
`get_risks` / `get_scenarios` / `get_parcel_info`). Без параметров — каждый отдаёт
|
||||||
секцию(и) отчёта (модель не управляет вычислениями, только запрашивает данные).
|
фиксированную секцию(и) отчёта (модель не управляет вычислениями, только запрашивает
|
||||||
|
данные).
|
||||||
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
|
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
|
||||||
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
|
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
|
||||||
НИКАКОЙ движковой математики — только срез готового dict'а.
|
НИКАКОЙ движковой математики — только срез готового dict'а.
|
||||||
|
|
@ -73,6 +74,16 @@ def get_scenarios(report: dict[str, Any]) -> dict[str, Any]:
|
||||||
return _section(report, "scenarios")
|
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) ─
|
# ── Реестр имя→executor + имя→секции отчёта (для provenance grounded_in.sections) ─
|
||||||
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
|
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
|
||||||
|
|
||||||
|
|
@ -84,6 +95,7 @@ _TOOLS: dict[str, tuple[Callable[[dict[str, Any]], dict[str, Any]], tuple[str, .
|
||||||
"get_forecast": (get_forecast, ("future_market",)),
|
"get_forecast": (get_forecast, ("future_market",)),
|
||||||
"get_risks": (get_risks, ("scoring", "confidence")),
|
"get_risks": (get_risks, ("scoring", "confidence")),
|
||||||
"get_scenarios": (get_scenarios, ("scenarios",)),
|
"get_scenarios": (get_scenarios, ("scenarios",)),
|
||||||
|
"get_parcel_info": (get_parcel_info, ("parcel_context",)),
|
||||||
}
|
}
|
||||||
|
|
||||||
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
|
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
|
||||||
|
|
@ -103,8 +115,11 @@ _TOOL_DESCRIPTIONS: dict[str, str] = {
|
||||||
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
|
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
|
||||||
"и уровень/факторы уверенности отчёта."
|
"и уровень/факторы уверенности отчёта."
|
||||||
),
|
),
|
||||||
"get_scenarios": (
|
"get_scenarios": ("Сценарии развития: разброс по консервативному / базовому / агрессивному."),
|
||||||
"Сценарии развития: разброс по консервативному / базовому / агрессивному."
|
"get_parcel_info": (
|
||||||
|
"Паспорт участка и градостроительный регламент: адрес, площадь, категория земель, "
|
||||||
|
"ВРИ, территориальная зона ПЗЗ (код и название), лимиты застройки, "
|
||||||
|
"ЗОУИТ-обременения."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +143,7 @@ def _spec(name: str) -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
def tool_specs() -> list[dict[str, Any]]:
|
def tool_specs() -> list[dict[str, Any]]:
|
||||||
"""Все 5 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
|
"""Все 6 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
|
||||||
return [_spec(name) for name in _TOOLS]
|
return [_spec(name) for name in _TOOLS]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -161,6 +176,7 @@ __all__ = [
|
||||||
"execute_tool",
|
"execute_tool",
|
||||||
"get_exec_summary",
|
"get_exec_summary",
|
||||||
"get_forecast",
|
"get_forecast",
|
||||||
|
"get_parcel_info",
|
||||||
"get_product_recommendation",
|
"get_product_recommendation",
|
||||||
"get_risks",
|
"get_risks",
|
||||||
"get_scenarios",
|
"get_scenarios",
|
||||||
|
|
|
||||||
195
backend/app/services/dadata_client.py
Normal file
195
backend/app/services/dadata_client.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""Минимальный синхронный 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)
|
||||||
220
backend/app/services/etl/mapping_review.py
Normal file
220
backend/app/services/etl/mapping_review.py
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"""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,9 +1,12 @@
|
||||||
"""Cross-load ETL: tradein.houses → gendesign.newbuilding_listings (#976 950-E5).
|
"""Cross-load ETL: tradein.houses → gendesign.newbuilding_listings (#976 950-E5).
|
||||||
|
|
||||||
Читает houses + house_sources из tradein-БД (gendesign_reader role),
|
Читает market.v_houses + market.v_house_sources из tradein-БД (gendesign_reader role),
|
||||||
UPSERT-ит в gendesign newbuilding_listings по (source, ext_house_id).
|
UPSERT-ит в gendesign newbuilding_listings по (source, ext_house_id).
|
||||||
Соединение к tradein через psycopg v3 (прямой коннект, НЕ SQLAlchemy).
|
Соединение к 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)
|
- ночью Celery beat (03:30 МСК, tasks/etl_newbuilding_crossload.py)
|
||||||
- вручную через POST /api/v1/admin/scrape/newbuilding-crossload
|
- вручную через POST /api/v1/admin/scrape/newbuilding-crossload
|
||||||
|
|
@ -54,8 +57,10 @@ SELECT
|
||||||
h.last_scraped_at,
|
h.last_scraped_at,
|
||||||
-- yandex_jk_id из house_sources (LEFT JOIN — может отсутствовать)
|
-- yandex_jk_id из house_sources (LEFT JOIN — может отсутствовать)
|
||||||
hs_y.ext_id AS yandex_jk_id
|
hs_y.ext_id AS yandex_jk_id
|
||||||
FROM houses h
|
-- Читаем через contract-view market.v_* (#2138 step 2/3), НЕ сырые public.* —
|
||||||
LEFT JOIN house_sources hs_y
|
-- стабильный явный column-контракт, не ломается на scraper-driven ALTER TABLE.
|
||||||
|
FROM market.v_houses h
|
||||||
|
LEFT JOIN market.v_house_sources hs_y
|
||||||
ON hs_y.house_id = h.id
|
ON hs_y.house_id = h.id
|
||||||
AND hs_y.ext_source = 'yandex'
|
AND hs_y.ext_source = 'yandex'
|
||||||
WHERE h.lat IS NOT NULL
|
WHERE h.lat IS NOT NULL
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,40 @@ Schema facts (confirmed via pg MCP):
|
||||||
колонки match_method + match_score + is_reviewed поддерживают audit trail.
|
колонки match_method + match_score + is_reviewed поддерживают audit trail.
|
||||||
|
|
||||||
match_method history:
|
match_method history:
|
||||||
'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows)
|
'fuzzy' — legacy Anton SQLite import (avg score 0.98, 127 rows)
|
||||||
'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
|
'fuzzy_trgm' — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
|
||||||
'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
|
'fuzzy_v2' — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
|
||||||
'manual' — ручная корректура
|
'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' — ручная корректура
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
import math
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Порог для auto-insert (высокая уверенность)
|
# Порог для auto-insert (высокая уверенность)
|
||||||
|
|
@ -234,3 +254,919 @@ def trigger_mv_refresh(db: Session) -> int:
|
||||||
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
|
from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity
|
||||||
|
|
||||||
return refresh_layout_velocity(db, concurrently=True)
|
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())
|
||||||
|
|
|
||||||
1277
backend/app/services/exporters/full_report_docx.py
Normal file
1277
backend/app/services/exporters/full_report_docx.py
Normal file
File diff suppressed because it is too large
Load diff
1873
backend/app/services/exporters/full_report_html.py
Normal file
1873
backend/app/services/exporters/full_report_html.py
Normal file
File diff suppressed because it is too large
Load diff
494
backend/app/services/exporters/full_report_pdf.py
Normal file
494
backend/app/services/exporters/full_report_pdf.py
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
"""Оркестратор полного 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,15 +14,20 @@ from app.schemas.parcel import BestLayoutsResponse
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def render_layout_tz_pdf(
|
def build_layout_tz_html(
|
||||||
response: BestLayoutsResponse,
|
response: BestLayoutsResponse,
|
||||||
*,
|
*,
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
parcel_address: str | None = None,
|
parcel_address: str | None = None,
|
||||||
radius_km: float,
|
radius_km: float,
|
||||||
time_window: str,
|
time_window: str,
|
||||||
) -> bytes:
|
) -> str:
|
||||||
"""Render ТЗ PDF от best-layouts response.
|
"""Собрать HTML ТЗ из best-layouts response (PURE — без WeasyPrint/БД/сети).
|
||||||
|
|
||||||
|
Вынесено из :func:`render_layout_tz_pdf`, чтобы HTML-разметку (наличие блоков,
|
||||||
|
RU-микрокопию, форматирование ₽/м²) можно было проверять юнит-тестами БЕЗ
|
||||||
|
native GTK/Pango (WeasyPrint на macOS/Windows отсутствует). PDF-обёртка ниже
|
||||||
|
просто рендерит этот HTML.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: BestLayoutsResponse от /best-layouts endpoint
|
response: BestLayoutsResponse от /best-layouts endpoint
|
||||||
|
|
@ -32,7 +37,7 @@ def render_layout_tz_pdf(
|
||||||
time_window: окно анализа (last_month/quarter/year)
|
time_window: окно анализа (last_month/quarter/year)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PDF bytes (готово для StreamingResponse)
|
HTML-строка документа (готова для WeasyPrint).
|
||||||
"""
|
"""
|
||||||
today = dt.date.today().strftime("%d.%m.%Y")
|
today = dt.date.today().strftime("%d.%m.%Y")
|
||||||
safe_cad = _html.escape(cad_num)
|
safe_cad = _html.escape(cad_num)
|
||||||
|
|
@ -45,6 +50,18 @@ def render_layout_tz_pdf(
|
||||||
return "<td>—</td>"
|
return "<td>—</td>"
|
||||||
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</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 layouts table rows
|
||||||
top_rows = "".join(
|
top_rows = "".join(
|
||||||
"<tr>"
|
"<tr>"
|
||||||
|
|
@ -70,6 +87,42 @@ def render_layout_tz_pdf(
|
||||||
for m in response.recommendation_for_tz.mix
|
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
|
rec = response.recommendation_for_tz
|
||||||
safe_rationale = _html.escape(rec.rationale_text)
|
safe_rationale = _html.escape(rec.rationale_text)
|
||||||
weighted_price = (
|
weighted_price = (
|
||||||
|
|
@ -95,6 +148,8 @@ def render_layout_tz_pdf(
|
||||||
th {{ background: #f5f5f5; font-weight: bold; }}
|
th {{ background: #f5f5f5; font-weight: bold; }}
|
||||||
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
|
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
|
||||||
margin: 1em 0; }}
|
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;
|
.footer {{ margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd;
|
||||||
color: #888; font-size: 9pt; }}
|
color: #888; font-size: 9pt; }}
|
||||||
.confidence-high {{ color: #2a8c2a; }}
|
.confidence-high {{ color: #2a8c2a; }}
|
||||||
|
|
@ -133,11 +188,11 @@ def render_layout_tz_pdf(
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>{top_rows}</tbody>
|
<tbody>{top_rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{supply_only_section}
|
||||||
<h2>Качество данных</h2>
|
<h2>Качество данных</h2>
|
||||||
<p>
|
<p>
|
||||||
Покрытие: {dq.objects_with_velocity_data} из
|
Покрытие: {dq.objects_with_velocity_data} из
|
||||||
{dq.objects_total_in_radius} ЖК с данными velocity
|
{dq.objects_total_in_radius} комплексов с данными velocity
|
||||||
({dq.velocity_coverage_pct:.1f}%)
|
({dq.velocity_coverage_pct:.1f}%)
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -154,6 +209,40 @@ def render_layout_tz_pdf(
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</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 импортируем локально — тяжёлый; не нужен при импорте модуля
|
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
|
||||||
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
|
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
|
|
|
||||||
386
backend/app/services/exporters/report_maps.py
Normal file
386
backend/app/services/exporters/report_maps.py
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
"""Серверный статик-рендер карт для 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,7 +32,9 @@ from typing import Any
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
|
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
|
||||||
# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
|
# Шесть секций зеркалят excel.py (#991); §13.7 «Уверенность» — доп. секция, портирована
|
||||||
|
# из report_docx/report_md для parity между экспортёрами (audit epic #2445 item C2;
|
||||||
|
# excel.py её пока не несёт — отдельный gap, вне scope этой правки).
|
||||||
|
|
||||||
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
|
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
|
||||||
_TITLE_SUMMARY: str = "Сводка"
|
_TITLE_SUMMARY: str = "Сводка"
|
||||||
|
|
@ -41,6 +43,7 @@ _TITLE_FUTURE_MARKET: str = "Будущий рынок"
|
||||||
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
|
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
|
||||||
_TITLE_SCENARIOS: str = "Сценарии"
|
_TITLE_SCENARIOS: str = "Сценарии"
|
||||||
_TITLE_SCORING: str = "Скоринг"
|
_TITLE_SCORING: str = "Скоринг"
|
||||||
|
_TITLE_CONFIDENCE: str = "Уверенность"
|
||||||
|
|
||||||
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
|
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
|
||||||
|
|
||||||
|
|
@ -308,15 +311,21 @@ def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
def _build_summary(report: dict[str, Any]) -> str:
|
def _build_summary(report: dict[str, Any]) -> str:
|
||||||
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
|
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст.
|
||||||
|
|
||||||
|
Уровень уверенности здесь — только сводный badge (`overall_confidence`), зеркало
|
||||||
|
report_docx/report_md._build_summary. Полный разбор (rationale + факторы-драйверы)
|
||||||
|
живёт в отдельной секции §13.7 «Уверенность» (`_build_confidence`) — раньше
|
||||||
|
(до parity-фикса #2445 C2) он дублировался здесь тонкой 2-строчной таблицей, что
|
||||||
|
расходилось с docx/md и не переживало dict-значный фактор; убрано, чтобы не было
|
||||||
|
двух версий одних и тех же данных в одном документе.
|
||||||
|
"""
|
||||||
exec_summary = _as_dict(report.get("exec_summary"))
|
exec_summary = _as_dict(report.get("exec_summary"))
|
||||||
meta = _as_dict(report.get("meta"))
|
meta = _as_dict(report.get("meta"))
|
||||||
confidence = _as_dict(report.get("confidence"))
|
|
||||||
|
|
||||||
headline = exec_summary.get("headline")
|
headline = exec_summary.get("headline")
|
||||||
verdict = exec_summary.get("verdict")
|
verdict = exec_summary.get("verdict")
|
||||||
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||||||
factors = _as_dict(confidence.get("factors"))
|
|
||||||
|
|
||||||
cad = _esc(meta.get("cad_num"))
|
cad = _esc(meta.get("cad_num"))
|
||||||
district = _esc(meta.get("district"))
|
district = _esc(meta.get("district"))
|
||||||
|
|
@ -329,10 +338,6 @@ def _build_summary(report: dict[str, Any]) -> str:
|
||||||
("Сформировано", meta.get("generated_at")),
|
("Сформировано", meta.get("generated_at")),
|
||||||
("Версия схемы", meta.get("schema_version")),
|
("Версия схемы", 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")))
|
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
|
|
@ -351,12 +356,6 @@ def _build_summary(report: dict[str, Any]) -> str:
|
||||||
<h3>Ключевые числа</h3>
|
<h3>Ключевые числа</h3>
|
||||||
{_dict_kv_table(key_numbers)}
|
{_dict_kv_table(key_numbers)}
|
||||||
|
|
||||||
<h3>Уверенность отчёта</h3>
|
|
||||||
{_kv_table(confidence_pairs)}
|
|
||||||
|
|
||||||
<h3>Факторы уверенности</h3>
|
|
||||||
{_dict_kv_table(factors)}
|
|
||||||
|
|
||||||
<h3>Контекст</h3>
|
<h3>Контекст</h3>
|
||||||
{_kv_table(context_pairs)}
|
{_kv_table(context_pairs)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -624,6 +623,43 @@ def _build_scoring(report: dict[str, Any]) -> str:
|
||||||
|
|
||||||
<h3>Специальные индексы</h3>
|
<h3>Специальные индексы</h3>
|
||||||
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
|
{_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 class="footer">{html.escape(_FOOTER_NOTE)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -631,7 +667,8 @@ def _build_scoring(report: dict[str, Any]) -> str:
|
||||||
|
|
||||||
|
|
||||||
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
|
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
|
||||||
# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
|
# `_SHEET_BUILDERS` у excel.py + report_docx/report_md — Сводка → Рынок сейчас →
|
||||||
|
# Будущий рынок → Продукт ТЗ → Сценарии → Скоринг → Уверенность §13.7, #2445 C2).
|
||||||
_SECTION_BUILDERS: tuple[Any, ...] = (
|
_SECTION_BUILDERS: tuple[Any, ...] = (
|
||||||
_build_summary,
|
_build_summary,
|
||||||
_build_market_now,
|
_build_market_now,
|
||||||
|
|
@ -639,11 +676,12 @@ _SECTION_BUILDERS: tuple[Any, ...] = (
|
||||||
_build_product_tz,
|
_build_product_tz,
|
||||||
_build_scenarios,
|
_build_scenarios,
|
||||||
_build_scoring,
|
_build_scoring,
|
||||||
|
_build_confidence,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_html(report: dict[str, Any]) -> str:
|
def _build_html(report: dict[str, Any]) -> str:
|
||||||
"""Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
|
"""Склеить HTML-документ из семи секций §13 (+ §13.7 Уверенность). PURE. Graceful."""
|
||||||
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
|
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|
@ -666,10 +704,10 @@ def export_report_pdf(report: Any) -> bytes:
|
||||||
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
|
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
|
||||||
|
|
||||||
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
|
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
|
||||||
рынок / Продукт ТЗ / Сценарии / Скоринг — тот же набор, что и `export_report_xlsx`).
|
рынок / Продукт ТЗ / Сценарии / Скоринг / Уверенность §13.7 — parity с
|
||||||
Шапки таблиц с заливкой, RU-метки, числа округлены, None → "—". На блоке «Сводка» —
|
report_docx/report_md, #2445 C2). Шапки таблиц с заливкой, RU-метки, числа
|
||||||
заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
|
округлены, None → "—". На блоке «Сводка» — заметный ADVISORY-маркер (отчёт
|
||||||
`html.escape`.
|
советующий). ВСЕ динамические строки экранируются `html.escape`.
|
||||||
|
|
||||||
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
|
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
|
||||||
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/
|
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/
|
||||||
|
|
|
||||||
|
|
@ -371,12 +371,20 @@ def _month_grid(start: date, end: date) -> list[date]:
|
||||||
|
|
||||||
|
|
||||||
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
|
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||||||
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}."""
|
"""Ресэмпл дневного 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), внешняя транзакция остаётся рабочей.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
rows = db.execute(
|
with db.begin_nested():
|
||||||
_KEY_RATE_MONTHLY_SQL,
|
rows = db.execute(
|
||||||
{"itype": "key_rate", "region": "rf", "since": since},
|
_KEY_RATE_MONTHLY_SQL,
|
||||||
).all()
|
{"itype": "key_rate", "region": "rf", "since": since},
|
||||||
|
).all()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("get_monthly_macro: key_rate query failed")
|
logger.exception("get_monthly_macro: key_rate query failed")
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -389,9 +397,14 @@ def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||||||
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) — берём как есть
|
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) — берём как есть
|
||||||
через reuse get_macro_series (свой SQL не пишем). _month_start — страховка.
|
через reuse get_macro_series (свой SQL не пишем). _month_start — страховка.
|
||||||
Сбой/пустой ряд → {} (НЕ crash), inflation_yoy тогда None по всей сетке.
|
Сбой/пустой ряд → {} (НЕ crash), inflation_yoy тогда None по всей сетке.
|
||||||
|
|
||||||
|
SAVEPOINT (#2464 cluster A finding #2): см. `_query_key_rate_monthly` — та же
|
||||||
|
общая §22-сессия, тот же риск отравления транзакции для последующих запросов
|
||||||
|
(mortgage-поля + §9.x-слои). `with db.begin_nested():` изолирует сбой в SAVEPOINT.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
with db.begin_nested():
|
||||||
|
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("get_monthly_macro: inflation_yoy query failed")
|
logger.exception("get_monthly_macro: inflation_yoy query failed")
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -404,11 +417,20 @@ def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date,
|
||||||
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
|
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
|
||||||
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
|
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
|
||||||
не валит остальные (graceful: пустой подсловарь).
|
не валит остальные (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]] = {}
|
out: dict[str, dict[date, float]] = {}
|
||||||
for indicator_type, field in _MORTGAGE_FIELDS:
|
for indicator_type, field in _MORTGAGE_FIELDS:
|
||||||
try:
|
try:
|
||||||
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
|
with db.begin_nested():
|
||||||
|
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
|
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
|
||||||
series = []
|
series = []
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ def _primary_horizon(horizons: Sequence[int]) -> int:
|
||||||
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
|
return horizons[0] if horizons else _PREFERRED_PRIMARY_HORIZON
|
||||||
|
|
||||||
|
|
||||||
def _safe_call(label: str, fn: Any) -> Any:
|
def _safe_call(label: str, db: Session, fn: Any) -> Any:
|
||||||
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
|
"""Вызвать §9.x-сервис graceful: сбой → None + logger.exception (не crash отчёта).
|
||||||
|
|
||||||
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
|
Зеркало product_scoring._safe_call: любой §9.x-слой может бросить (тонкие данные / нет
|
||||||
|
|
@ -133,15 +133,29 @@ def _safe_call(label: str, fn: Any) -> Any:
|
||||||
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception —
|
широкий Exception (изоляция одного слоя от отчёта) с ОБЯЗАТЕЛЬНЫМ logger.exception —
|
||||||
НЕ молчаливое глотание. §9.x уже graceful внутри; это belt-and-suspenders на шве.
|
НЕ молчаливое глотание. §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:
|
Args:
|
||||||
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
|
label: имя слоя для лога (диагностика какой §9.x-вызов деградировал).
|
||||||
|
db: общая §22-сессия (для SAVEPOINT вокруг вызова слоя).
|
||||||
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
|
fn: нулевой-аргумент thunk вокруг §9.x-вызова.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Результат `fn()` или None при исключении.
|
Результат `fn()` или None при исключении.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return fn()
|
with db.begin_nested():
|
||||||
|
return fn()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
|
logger.exception("orchestrator: §9.x layer %s failed → section degraded", label)
|
||||||
return None
|
return None
|
||||||
|
|
@ -334,21 +348,25 @@ def _build_site_finder_report_impl(
|
||||||
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
|
# ── 2. §9.x-слои, каждый graceful через _safe_call (ГЕТЕРОГЕННЫЕ сигнатуры) ──
|
||||||
market_metrics = _safe_call(
|
market_metrics = _safe_call(
|
||||||
"market_metrics",
|
"market_metrics",
|
||||||
|
db,
|
||||||
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
|
lambda: compute_market_metrics(db, district=district, premise_kind=_PREMISE_KIND),
|
||||||
)
|
)
|
||||||
supply_rows = _safe_call(
|
supply_rows = _safe_call(
|
||||||
"supply_layers",
|
"supply_layers",
|
||||||
|
db,
|
||||||
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
|
lambda: compute_all_layers(db, district=district, premise_kind=_PREMISE_KIND),
|
||||||
)
|
)
|
||||||
supply_layers = _summarize_supply_layers(supply_rows)
|
supply_layers = _summarize_supply_layers(supply_rows)
|
||||||
future_supply = _safe_call(
|
future_supply = _safe_call(
|
||||||
"future_supply",
|
"future_supply",
|
||||||
|
db,
|
||||||
lambda: compute_future_supply_pressure(
|
lambda: compute_future_supply_pressure(
|
||||||
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
|
db, district=district, horizon_months=primary, premise_kind=_PREMISE_KIND
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
forecasts = _safe_call(
|
forecasts = _safe_call(
|
||||||
"demand_supply_forecast",
|
"demand_supply_forecast",
|
||||||
|
db,
|
||||||
lambda: compute_demand_supply_forecast(
|
lambda: compute_demand_supply_forecast(
|
||||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||||
),
|
),
|
||||||
|
|
@ -359,6 +377,7 @@ def _build_site_finder_report_impl(
|
||||||
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
|
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
|
||||||
scenarios_result = _safe_call(
|
scenarios_result = _safe_call(
|
||||||
"scenarios",
|
"scenarios",
|
||||||
|
db,
|
||||||
lambda: compute_scenarios(
|
lambda: compute_scenarios(
|
||||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||||
),
|
),
|
||||||
|
|
@ -371,18 +390,21 @@ def _build_site_finder_report_impl(
|
||||||
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
|
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
|
||||||
product_scores = _safe_call(
|
product_scores = _safe_call(
|
||||||
"score_card",
|
"score_card",
|
||||||
|
db,
|
||||||
lambda: compute_score_card(
|
lambda: compute_score_card(
|
||||||
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
|
db, spec=spec, district=district, cad_num=cad_num, horizon_months=primary
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
special_indices = _safe_call(
|
special_indices = _safe_call(
|
||||||
"special_indices",
|
"special_indices",
|
||||||
|
db,
|
||||||
lambda: compute_special_indices(
|
lambda: compute_special_indices(
|
||||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
recommendation_overlay = _safe_call(
|
recommendation_overlay = _safe_call(
|
||||||
"forecast_overlay",
|
"forecast_overlay",
|
||||||
|
db,
|
||||||
lambda: build_forecast_overlay(
|
lambda: build_forecast_overlay(
|
||||||
db,
|
db,
|
||||||
district=district,
|
district=district,
|
||||||
|
|
@ -393,7 +415,7 @@ def _build_site_finder_report_impl(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
|
# ── Макро-свежесть (audit MEDIUM): только лог; проводка в отчёт — 3b ─────────
|
||||||
macro = _safe_call("monthly_macro", lambda: get_monthly_macro(db))
|
macro = _safe_call("monthly_macro", db, lambda: get_monthly_macro(db))
|
||||||
macro_as_of = _macro_as_of(macro)
|
macro_as_of = _macro_as_of(macro)
|
||||||
if macro_as_of is not None:
|
if macro_as_of is not None:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -835,13 +835,15 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
||||||
compute_poi_weighted_top7. Нет геометрии / нет POI / сбой → None (infra_fit unavailable).
|
compute_poi_weighted_top7. Нет геометрии / нет POI / сбой → None (infra_fit unavailable).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
coords = (
|
with db.begin_nested():
|
||||||
db.execute(
|
coords = (
|
||||||
_PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)}
|
db.execute(
|
||||||
|
_PARCEL_CENTROID_SQL,
|
||||||
|
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"product_scoring: centroid lookup failed (cad_num=%s) → infra n/a", cad_num
|
"product_scoring: centroid lookup failed (cad_num=%s) → infra n/a", cad_num
|
||||||
|
|
@ -850,9 +852,10 @@ 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:
|
if not coords or coords.get("lat") is None or coords.get("lon") is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
response = compute_poi_weighted_top7(
|
with db.begin_nested():
|
||||||
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:
|
except Exception:
|
||||||
logger.exception("product_scoring: poi_score failed (cad_num=%s) → infra n/a", cad_num)
|
logger.exception("product_scoring: poi_score failed (cad_num=%s) → infra n/a", cad_num)
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,12 @@ def _query_source_a(
|
||||||
|
|
||||||
Graceful → {} при сбое/пустых данных. price_bucket в spec для Source A
|
Graceful → {} при сбое/пустых данных. price_bucket в spec для Source A
|
||||||
игнорируется (агрегат не несёт per-lot цены) — фиксируется логом.
|
игнорируется (агрегат не несёт 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:
|
if spec.price_bucket is not None:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -567,7 +573,8 @@ def _query_source_a(
|
||||||
"room_bucket": spec.room_bucket,
|
"room_bucket": spec.room_bucket,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
|
with db.begin_nested():
|
||||||
|
rows = db.execute(_SOURCE_A_SQL, params).mappings().all()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("build_sales_series: source A query failed")
|
logger.exception("build_sales_series: source A query failed")
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -582,6 +589,9 @@ def _query_source_b(
|
||||||
Graceful → {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL
|
Graceful → {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL
|
||||||
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
|
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
|
||||||
правилом и в БД, и в Python.
|
правилом и в БД, и в Python.
|
||||||
|
|
||||||
|
SAVEPOINT (#2464 cluster A finding #4): см. `_query_source_a` — та же общая
|
||||||
|
§22-сессия, тот же риск отравления транзакции для последующих слоёв/запросов.
|
||||||
"""
|
"""
|
||||||
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
|
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
|
||||||
# None → EKB-wide (без district-фильтра).
|
# None → EKB-wide (без district-фильтра).
|
||||||
|
|
@ -613,7 +623,8 @@ def _query_source_b(
|
||||||
"p_unknown": PRICE_BUCKET_UNKNOWN,
|
"p_unknown": PRICE_BUCKET_UNKNOWN,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
|
with db.begin_nested():
|
||||||
|
rows = db.execute(_SOURCE_B_SQL, params).mappings().all()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("build_sales_series: source B query failed")
|
logger.exception("build_sales_series: source B query failed")
|
||||||
return {}
|
return {}
|
||||||
|
|
|
||||||
|
|
@ -828,25 +828,62 @@ def _unavailable(key: str, *, reason: str) -> SpecialIndex:
|
||||||
# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает
|
# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает
|
||||||
# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ
|
# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ
|
||||||
# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type.
|
# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type.
|
||||||
# #1964: читаем из v_objective_lots_latest (physflat-дедуп view) — сырой
|
# #1964: считаем по ФИЗИЧЕСКИМ лотам (дедуп) — сырой objective_lots раздут ~2.91×
|
||||||
# objective_lots раздут ~2.91× (мульти lot_id на физлот); доля ипотеки —
|
# (мульти lot_id на физлот); доля ипотеки — отношение, к дублям устойчива, но
|
||||||
# отношение, к дублям устойчива, но n_sold/n_mortgage теперь честные (по физлотам).
|
# 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(
|
_ARTIFICIAL_DEMAND_SQL = text(
|
||||||
"""
|
"""
|
||||||
WITH sold AS (
|
WITH latest AS (
|
||||||
SELECT
|
SELECT DISTINCT ON (
|
||||||
ol.objective_lot_id,
|
project_name, corpus_name, section, floor, lot_number
|
||||||
(ol.encumbrance_type IS NOT NULL OR ol.bank_name IS NOT NULL) AS has_mortgage
|
)
|
||||||
FROM v_objective_lots_latest ol
|
objective_lot_id,
|
||||||
WHERE ol.premise_kind = CAST(:premise_kind AS text)
|
is_sold,
|
||||||
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
|
contract_date,
|
||||||
|
class,
|
||||||
|
encumbrance_type,
|
||||||
|
bank_name
|
||||||
|
FROM objective_lots
|
||||||
|
WHERE premise_kind = CAST(:premise_kind AS text)
|
||||||
AND (
|
AND (
|
||||||
CAST(:has_district AS boolean) IS FALSE
|
CAST(:has_district AS boolean) IS FALSE
|
||||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
OR 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 (
|
AND (
|
||||||
CAST(:obj_class AS text) IS NULL
|
CAST(:obj_class AS text) IS NULL
|
||||||
OR lower(ol.class) = lower(CAST(:obj_class AS text))
|
OR lower(class) = lower(CAST(:obj_class AS text))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -957,16 +994,29 @@ def _query_parcel_centroid(db: Session, *, cad_num: str) -> tuple[float, float]
|
||||||
|
|
||||||
Нет геометрии / сбой → None (гео-веса упадут на floor; overlap считается по остальным
|
Нет геометрии / сбой → None (гео-веса упадут на floor; overlap считается по остальным
|
||||||
осям, индекс НЕ деградирует целиком). Параметризовано (psycopg v3). Детерминированно.
|
осям, индекс НЕ деградирует целиком). Параметризовано (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:
|
try:
|
||||||
row = (
|
with db.begin_nested():
|
||||||
db.execute(
|
row = (
|
||||||
_PARCEL_CENTROID_SQL,
|
db.execute(
|
||||||
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
_PARCEL_CENTROID_SQL,
|
||||||
|
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("cannibalization: centroid query failed for cad_num=%s", cad_num)
|
logger.exception("cannibalization: centroid query failed for cad_num=%s", cad_num)
|
||||||
return None
|
return None
|
||||||
|
|
@ -1620,9 +1670,20 @@ def compute_special_indices(
|
||||||
segment = spec.as_dict()
|
segment = spec.as_dict()
|
||||||
|
|
||||||
def _run(key: str, builder: Any) -> SpecialIndex:
|
def _run(key: str, builder: Any) -> SpecialIndex:
|
||||||
"""Выполнить builder одного индекса в собственном try/except (graceful)."""
|
"""Выполнить 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, сессия остаётся рабочей для
|
||||||
|
следующего индекса.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return builder() # type: ignore[no-any-return]
|
with db.begin_nested():
|
||||||
|
return builder() # type: ignore[no-any-return]
|
||||||
except Exception:
|
except Exception:
|
||||||
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
|
# Сбой одного индекса НЕ валит карточку: деградация-None, остальные считаются.
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ import logging
|
||||||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||||||
from ezdxf.enums import TextEntityAlignment
|
from ezdxf.enums import TextEntityAlignment
|
||||||
from ezdxf.filemanagement import new as ezdxf_new
|
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 shapely.geometry import Polygon
|
||||||
|
|
||||||
from app.schemas.concept import ConceptVariant
|
from app.schemas.concept import ConceptVariant
|
||||||
|
|
@ -44,7 +49,7 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
||||||
_LABEL_HEIGHT_M = 2.0
|
_LABEL_HEIGHT_M = 2.0
|
||||||
|
|
||||||
|
|
||||||
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
def _ring_points(coords: CoordinateSequence) -> list[tuple[float, float]]:
|
||||||
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||||
pts = list(coords)
|
pts = list(coords)
|
||||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||||
|
|
@ -53,7 +58,7 @@ def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||||
return [(float(x), float(y)) for x, y in pts]
|
return [(float(x), float(y)) for x, y in pts]
|
||||||
|
|
||||||
|
|
||||||
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
|
def _add_polygon(msp: Modelspace, poly: Polygon, layer: str) -> None:
|
||||||
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
||||||
|
|
||||||
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
||||||
|
|
@ -151,15 +156,15 @@ def _feature_to_metric_polygon(parcel: Parcel, feature: object) -> Polygon | Non
|
||||||
return None
|
return None
|
||||||
coords = geometry.get("coordinates")
|
coords = geometry.get("coordinates")
|
||||||
# Shapely mapping() emits nested tuples; accept both tuple and list.
|
# 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
|
return None
|
||||||
ring = coords[0]
|
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
|
return None
|
||||||
|
|
||||||
metric_pts: list[tuple[float, float]] = []
|
metric_pts: list[tuple[float, float]] = []
|
||||||
for pt in ring:
|
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
|
return None
|
||||||
lon, lat = float(pt[0]), float(pt[1])
|
lon, lat = float(pt[0]), float(pt[1])
|
||||||
x, y = parcel.wgs84_to_metric(lon, lat)
|
x, y = parcel.wgs84_to_metric(lon, lat)
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,7 @@ def _strategy_label(strategy: str) -> str:
|
||||||
|
|
||||||
def _teap_table(variants: Sequence[ConceptVariant]) -> str:
|
def _teap_table(variants: Sequence[ConceptVariant]) -> str:
|
||||||
"""HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии)."""
|
"""HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии)."""
|
||||||
headers = "".join(
|
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
|
||||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
|
||||||
)
|
|
||||||
rows: list[tuple[str, list[str]]] = [
|
rows: list[tuple[str, list[str]]] = [
|
||||||
("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]),
|
("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]),
|
||||||
("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]),
|
("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]),
|
||||||
|
|
@ -107,9 +105,7 @@ def _fmt_irr(financial: FinancialModel) -> str:
|
||||||
|
|
||||||
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
|
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
|
||||||
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF)."""
|
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF)."""
|
||||||
headers = "".join(
|
headers = "".join(f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants)
|
||||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
|
||||||
)
|
|
||||||
rows: list[tuple[str, list[str]]] = [
|
rows: list[tuple[str, list[str]]] = [
|
||||||
(
|
(
|
||||||
"Выручка — жильё, млн руб",
|
"Выручка — жильё, млн руб",
|
||||||
|
|
@ -188,9 +184,13 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
||||||
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
|
"точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток "
|
||||||
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
"вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. "
|
||||||
"НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), "
|
"НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), "
|
||||||
"входной НДС по СМР встроен в себестоимость; НДС начисляется только на нежилую часть "
|
"входной НДС по СМР встроен в себестоимость; НДС начисляется на нежилые части — "
|
||||||
"(машиноместа). Налог на прибыль — 25% (с 2025). Цены и себестоимость "
|
"машиноместа и коммерцию/офисы 1-го этажа (встроенный НДС в добавленной стоимости "
|
||||||
"— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
|
"каждой части). Налог на прибыль — 25% (с 2025). Цены и себестоимость — рыночные "
|
||||||
|
"ориентиры. Коммерция/офисы 1-го этажа учтены по нормативной доле от общей площади "
|
||||||
|
"и продаются с умеренной наценкой к цене жилья того же класса; себестоимость СМР "
|
||||||
|
"нежилого — по той же ставке, что и жильё (отдельной строки затрат нет, повторного "
|
||||||
|
"учёта в затратах нет).</p>"
|
||||||
"</body></html>"
|
"</body></html>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pyproj import CRS, Transformer
|
from pyproj import CRS, Transformer
|
||||||
from shapely.geometry import Polygon, mapping, shape
|
from pyproj.exceptions import CRSError
|
||||||
|
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
|
||||||
from shapely.geometry.base import BaseGeometry
|
from shapely.geometry.base import BaseGeometry
|
||||||
from shapely.ops import transform as shapely_transform
|
from shapely.ops import transform as shapely_transform
|
||||||
|
|
||||||
|
|
@ -53,6 +54,12 @@ MIN_BUILDABLE_AREA_SQM: float = 50.0
|
||||||
# удержать время в бюджете (<=10 c/вариант). MVP-упрощение.
|
# удержать время в бюджете (<=10 c/вариант). MVP-упрощение.
|
||||||
MAX_GRID_CELLS: int = 20_000
|
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 (вход контракта).
|
||||||
_WGS84 = CRS.from_epsg(4326)
|
_WGS84 = CRS.from_epsg(4326)
|
||||||
|
|
||||||
|
|
@ -142,6 +149,19 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
|
||||||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||||
raise ParcelGeometryError(f"cannot parse GeoJSON geometry: {exc}") from 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":
|
if geom.geom_type != "Polygon":
|
||||||
raise ParcelGeometryError(f"expected Polygon, got {geom.geom_type}")
|
raise ParcelGeometryError(f"expected Polygon, got {geom.geom_type}")
|
||||||
if geom.is_empty:
|
if geom.is_empty:
|
||||||
|
|
@ -157,6 +177,27 @@ def _parse_polygon(parcel_geojson: dict[str, Any]) -> Polygon:
|
||||||
return 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]:
|
def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transformer]:
|
||||||
"""Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка.
|
"""Построить пару трансформеров WGS84<->метрический AEQD вокруг центроида участка.
|
||||||
|
|
||||||
|
|
@ -164,10 +205,16 @@ def _metric_transformers(polygon_wgs84: Polygon) -> tuple[Transformer, Transform
|
||||||
координат и точен на масштабе квартала — не нужен выбор UTM-зоны.
|
координат и точен на масштабе квартала — не нужен выбор UTM-зоны.
|
||||||
"""
|
"""
|
||||||
centroid = polygon_wgs84.centroid
|
centroid = polygon_wgs84.centroid
|
||||||
metric_crs = CRS.from_proj4(
|
try:
|
||||||
f"+proj=aeqd +lat_0={centroid.y} +lon_0={centroid.x} "
|
metric_crs = CRS.from_proj4(
|
||||||
"+x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs"
|
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
|
||||||
to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True)
|
to_metric = Transformer.from_crs(_WGS84, metric_crs, always_xy=True)
|
||||||
to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True)
|
to_wgs84 = Transformer.from_crs(metric_crs, _WGS84, always_xy=True)
|
||||||
return to_metric, to_wgs84
|
return to_metric, to_wgs84
|
||||||
|
|
@ -240,9 +287,11 @@ def parse_parcel(
|
||||||
"""Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid).
|
"""Stage 1a: ConceptInput -> :class:`Parcel` (метрика + buildable + grid).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ParcelGeometryError: полигон невалиден или пятно застройки вырождается.
|
ParcelGeometryError: полигон невалиден, координаты не в WGS84 lon/lat, или
|
||||||
|
пятно застройки вырождается.
|
||||||
"""
|
"""
|
||||||
polygon_wgs84 = _parse_polygon(payload.parcel_geojson)
|
polygon_wgs84 = _parse_polygon(payload.parcel_geojson)
|
||||||
|
_assert_wgs84_bounds(polygon_wgs84)
|
||||||
to_metric, to_wgs84 = _metric_transformers(polygon_wgs84)
|
to_metric, to_wgs84 = _metric_transformers(polygon_wgs84)
|
||||||
|
|
||||||
def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]:
|
def _fwd(xs: Any, ys: Any) -> tuple[Any, Any]:
|
||||||
|
|
|
||||||
|
|
@ -273,13 +273,18 @@ def place_program(
|
||||||
for item in program:
|
for item in program:
|
||||||
house = catalog.get_house_type(item.section_type)
|
house = catalog.get_house_type(item.section_type)
|
||||||
requested += item.count
|
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
|
placed_for_item = 0
|
||||||
for cell in parcel.grid:
|
for cell in parcel.grid:
|
||||||
if placed_for_item >= item.count:
|
if placed_for_item >= item.count:
|
||||||
break
|
break
|
||||||
footprint = _centered_footprint(
|
footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
|
||||||
cell.cx, cell.cy, house.footprint_w_m, house.footprint_d_m
|
|
||||||
)
|
|
||||||
if placer.try_place(footprint, buildable, half_gap):
|
if placer.try_place(footprint, buildable, half_gap):
|
||||||
placed_sections.append(
|
placed_sections.append(
|
||||||
_PlacedSection(
|
_PlacedSection(
|
||||||
|
|
|
||||||
|
|
@ -48,22 +48,24 @@ _SYSTEM_BASE = PromptTemplate(
|
||||||
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
|
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
|
||||||
_CHAT_SYSTEM = PromptTemplate(
|
_CHAT_SYSTEM = PromptTemplate(
|
||||||
name="chat_system",
|
name="chat_system",
|
||||||
version=2,
|
version=3,
|
||||||
template=(
|
template=(
|
||||||
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
|
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
|
||||||
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
|
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
|
||||||
"ЖЁСТКИЕ ПРАВИЛА:\n"
|
"ЖЁСТКИЕ ПРАВИЛА:\n"
|
||||||
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
|
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
|
||||||
"Чтобы получить нужные числа, вызови подходящий инструмент.\n"
|
"Чтобы получить нужные числа, вызови подходящий инструмент. Для вопросов про сам "
|
||||||
|
"участок — адрес, площадь, категорию земель, ВРИ, территориальную зону ПЗЗ и её "
|
||||||
|
"код/название, лимиты застройки, ЗОУИТ-обременения — вызови get_parcel_info.\n"
|
||||||
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
|
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
|
||||||
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
|
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
|
||||||
"отчёте нет. Не подставляй правдоподобные значения.\n"
|
"отчёте нет. Не подставляй правдоподобные значения.\n"
|
||||||
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
|
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
|
||||||
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
|
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
|
||||||
"инвестиционного решения. Не давай гарантий доходности.\n"
|
"инвестиционного решения. Не давай гарантий доходности.\n"
|
||||||
"5. Вопросы вне отчёта по участку (градостроительная документация / ПЗЗ-разрешения, "
|
"5. Вопросы, выходящие за рамки данных отчёта и паспорта участка (сравнение с другими "
|
||||||
"сравнение с другими участками, юридические заключения) — вежливо скажи, что это вне "
|
"участками, юридические заключения, получение разрешений/согласований) — вежливо "
|
||||||
"области отчёта, и предложи вопросы по самому форсайту.\n"
|
"скажи, что это вне области отчёта, и предложи вопросы по участку и его форсайту.\n"
|
||||||
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
|
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
|
||||||
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
|
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
|
||||||
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,19 @@ Wiring (отдельный PR):
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
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
|
from app.services.scrapers.stealth import BASE_URL, BrowserSession, jitter_sleep
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -87,6 +91,51 @@ _STATUS_KW_RE = re.compile(
|
||||||
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
|
# не имеющим отношения к статусу продажи (#1609 follow-up, #1686).
|
||||||
_STATUS_BADGE_CLS_RE = re.compile(r"(?<![a-z])status(?![a-z])", re.IGNORECASE)
|
_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:
|
def _classify_status_kw(matched_text: str) -> str | None:
|
||||||
"""Классифицировать совпавшее ключевое слово в STATUS_*.
|
"""Классифицировать совпавшее ключевое слово в STATUS_*.
|
||||||
|
|
@ -217,8 +266,19 @@ class _TextCollector(HTMLParser):
|
||||||
# Список записей: (class_hint, full_text)
|
# Список записей: (class_hint, full_text)
|
||||||
self.blocks: list[tuple[str, str]] = []
|
self.blocks: list[tuple[str, str]] = []
|
||||||
self._buf: list[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:
|
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:
|
if tag in self._VOID_TAGS:
|
||||||
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
|
||||||
attr_dict = {k: (v or "") for k, v in attrs}
|
attr_dict = {k: (v or "") for k, v in attrs}
|
||||||
|
|
@ -266,6 +326,134 @@ def _find_text_near(
|
||||||
return None
|
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]:
|
def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
|
"""Извлечь поля из SSR-HTML страницы квартиры DOM.РФ.
|
||||||
|
|
||||||
|
|
@ -277,6 +465,7 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
- ceiling_height_m (float) Высота потолков в метрах
|
- ceiling_height_m (float) Высота потолков в метрах
|
||||||
- section_no (int) Номер подъезда / секции
|
- section_no (int) Номер подъезда / секции
|
||||||
- catalog_updated_at (date) Дата обновления информации на странице
|
- catalog_updated_at (date) Дата обновления информации на странице
|
||||||
|
- plan_image_url (str) URL картинки планировки (issue #2440)
|
||||||
|
|
||||||
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
|
Парсинг хрупкий по природе (SSR HTML DOM.РФ меняется без уведомлений).
|
||||||
Все extraction best-effort — KeyError/AttributeError обёрнуты внутри.
|
Все extraction best-effort — KeyError/AttributeError обёрнуты внутри.
|
||||||
|
|
@ -468,6 +657,14 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
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(
|
logger.debug(
|
||||||
"parse_catalog_flat: extracted fields=%s",
|
"parse_catalog_flat: extracted fields=%s",
|
||||||
list(result.keys()),
|
list(result.keys()),
|
||||||
|
|
@ -544,16 +741,63 @@ def upsert_catalog_data(
|
||||||
# ── Per-flat scrape orchestration ─────────────────────────────────────────────
|
# ── 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(
|
async def scrape_one_flat(
|
||||||
session: BrowserSession,
|
session: BrowserSession,
|
||||||
db: Session,
|
db: Session,
|
||||||
ods_id: str,
|
ods_id: str,
|
||||||
catalog_url_hash: str,
|
catalog_url_hash: str,
|
||||||
|
obj_id: int | None = None,
|
||||||
|
snapshot_date: date | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Scrape одной квартиры: fetch HTML → parse → upsert.
|
"""Scrape одной квартиры: fetch HTML → parse → upsert (+ планировка).
|
||||||
|
|
||||||
Возвращает dict с результатом: {ods_id, success, fields_extracted, updated}.
|
Помимо catalog-полей (price/status/finishing/...) извлекает plan_image_url
|
||||||
Ошибки fetch/parse логируются, не бросаются — caller обрабатывает результат.
|
(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 обрабатывает результат.
|
||||||
"""
|
"""
|
||||||
outcome: dict[str, Any] = {
|
outcome: dict[str, Any] = {
|
||||||
"ods_id": ods_id,
|
"ods_id": ods_id,
|
||||||
|
|
@ -561,6 +805,8 @@ async def scrape_one_flat(
|
||||||
"success": False,
|
"success": False,
|
||||||
"fields_extracted": 0,
|
"fields_extracted": 0,
|
||||||
"updated": False,
|
"updated": False,
|
||||||
|
"plan_found": False,
|
||||||
|
"plan_downloaded": False,
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -590,6 +836,19 @@ async def scrape_one_flat(
|
||||||
|
|
||||||
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
|
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)
|
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 без исключения; реально
|
# success отражает прохождение пайплайна fetch+parse без исключения; реально
|
||||||
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
# ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
|
||||||
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
# отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
|
||||||
|
|
@ -617,11 +876,12 @@ async def scrape_catalog_batch(
|
||||||
region_code: int = 66,
|
region_code: int = 66,
|
||||||
headed: bool = False,
|
headed: bool = False,
|
||||||
load_state: str | None = None,
|
load_state: str | None = None,
|
||||||
|
snapshot_date: date | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Scrape пачки квартир каталога DOM.РФ.
|
"""Scrape пачки квартир каталога DOM.РФ.
|
||||||
|
|
||||||
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash}.
|
`flats` — список dict'ов с ключами {ods_id, catalog_url_hash [, obj_id]}.
|
||||||
Типовой источник: SELECT ods_id, catalog_url_hash FROM domrf_kn_flats
|
Типовой источник: SELECT ods_id, obj_id, catalog_url_hash FROM domrf_kn_flats
|
||||||
WHERE catalog_url_hash IS NOT NULL
|
WHERE catalog_url_hash IS NOT NULL
|
||||||
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
|
AND (catalog_updated_at IS NULL OR catalog_updated_at < NOW() - INTERVAL '30 days')
|
||||||
LIMIT :batch_size.
|
LIMIT :batch_size.
|
||||||
|
|
@ -629,18 +889,25 @@ async def scrape_catalog_batch(
|
||||||
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
|
Использует один BrowserSession на весь пакет (bootstrapped 1 раз).
|
||||||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||||
|
|
||||||
|
obj_id/snapshot_date нужны для записи планировки в domrf_kn_flat_plans (issue
|
||||||
|
#2440). snapshot_date по умолчанию = сегодня (дата первичного обнаружения URL).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{total, success, updated, failed, fields_total}
|
{total, success, updated, failed, fields_total, plans_found, plans_downloaded}
|
||||||
- success: прошли fetch+parse без исключения
|
- success: прошли fetch+parse без исключения
|
||||||
- updated: реально затронули строку в БД (issue #1610) — отражает
|
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||||
фактическое число записанных квартир, в отличие от success
|
фактическое число записанных квартир, в отличие от success
|
||||||
|
- plans_found / plans_downloaded: планировки найдены / скачаны (#2440)
|
||||||
"""
|
"""
|
||||||
|
snap = snapshot_date or date.today()
|
||||||
stats: dict[str, Any] = {
|
stats: dict[str, Any] = {
|
||||||
"total": len(flats),
|
"total": len(flats),
|
||||||
"success": 0,
|
"success": 0,
|
||||||
"updated": 0,
|
"updated": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"fields_total": 0,
|
"fields_total": 0,
|
||||||
|
"plans_found": 0,
|
||||||
|
"plans_downloaded": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not flats:
|
if not flats:
|
||||||
|
|
@ -648,9 +915,10 @@ async def scrape_catalog_batch(
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"scrape_catalog_batch: starting %d flats region=%d",
|
"scrape_catalog_batch: starting %d flats region=%d snapshot_date=%s",
|
||||||
len(flats),
|
len(flats),
|
||||||
region_code,
|
region_code,
|
||||||
|
snap,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with BrowserSession(
|
async with BrowserSession(
|
||||||
|
|
@ -659,21 +927,42 @@ async def scrape_catalog_batch(
|
||||||
load_state=load_state,
|
load_state=load_state,
|
||||||
# auth=None — страницы каталога публичные, Basic auth не нужен
|
# auth=None — страницы каталога публичные, Basic auth не нужен
|
||||||
auth=None,
|
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:
|
) 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:
|
for flat in flats:
|
||||||
ods_id = flat.get("ods_id", "")
|
ods_id = flat.get("ods_id", "")
|
||||||
catalog_url_hash = flat.get("catalog_url_hash", "")
|
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:
|
if not ods_id or not catalog_url_hash:
|
||||||
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
|
logger.warning("scrape_catalog_batch: skip flat with missing ods_id/hash: %r", flat)
|
||||||
stats["failed"] += 1
|
stats["failed"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
outcome = await scrape_one_flat(session, db, ods_id, catalog_url_hash)
|
outcome = await scrape_one_flat(
|
||||||
|
session, db, ods_id, catalog_url_hash, obj_id=obj_id, snapshot_date=snap
|
||||||
|
)
|
||||||
if outcome["success"]:
|
if outcome["success"]:
|
||||||
stats["success"] += 1
|
stats["success"] += 1
|
||||||
stats["fields_total"] += outcome["fields_extracted"]
|
stats["fields_total"] += outcome["fields_extracted"]
|
||||||
if outcome["updated"]:
|
if outcome["updated"]:
|
||||||
stats["updated"] += 1
|
stats["updated"] += 1
|
||||||
|
if outcome.get("plan_found"):
|
||||||
|
stats["plans_found"] += 1
|
||||||
|
if outcome.get("plan_downloaded"):
|
||||||
|
stats["plans_downloaded"] += 1
|
||||||
else:
|
else:
|
||||||
stats["failed"] += 1
|
stats["failed"] += 1
|
||||||
|
|
||||||
|
|
@ -689,11 +978,14 @@ async def scrape_catalog_batch(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
|
"scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d "
|
||||||
|
"fields_total=%d plans_found=%d plans_downloaded=%d",
|
||||||
stats["total"],
|
stats["total"],
|
||||||
stats["success"],
|
stats["success"],
|
||||||
stats["updated"],
|
stats["updated"],
|
||||||
stats["failed"],
|
stats["failed"],
|
||||||
stats["fields_total"],
|
stats["fields_total"],
|
||||||
|
stats["plans_found"],
|
||||||
|
stats["plans_downloaded"],
|
||||||
)
|
)
|
||||||
return stats
|
return stats
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from typing import Any
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep
|
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError, jitter_sleep
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -440,6 +441,16 @@ async def scrape_catalog_objects(
|
||||||
region_code=region_code,
|
region_code=region_code,
|
||||||
# Страницы каталога публичные — Basic auth не нужен
|
# Страницы каталога публичные — Basic auth не нужен
|
||||||
auth=None,
|
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:
|
) as session:
|
||||||
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
|
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies.
|
||||||
# DOM.РФ WAF (2026-05-24) блокирует SSR-страницы объектов без этих 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).
|
# 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).
|
# 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.obj_checks import extract_obj_checks, upsert_obj_checks
|
||||||
from app.services.scrapers.stealth import BASE_URL, BrowserSession
|
from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -432,6 +432,14 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
||||||
digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8]
|
digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8]
|
||||||
flat_id = int.from_bytes(digest, "big") % (2**63 - 1)
|
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_rub = _g(row, "price")
|
||||||
price_per_m2 = _g(row, "pricePerSquareMeter")
|
price_per_m2 = _g(row, "pricePerSquareMeter")
|
||||||
total_area = _g(row, "totalArea")
|
total_area = _g(row, "totalArea")
|
||||||
|
|
@ -476,7 +484,10 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
|
||||||
"ceiling_height_m": None, # заполнит catalog scraper 22d
|
"ceiling_height_m": None, # заполнит catalog scraper 22d
|
||||||
"key_handover_dt": None, # заполнит catalog scraper 22d
|
"key_handover_dt": None, # заполнит catalog scraper 22d
|
||||||
"catalog_updated_at": None, # заполнит catalog scraper 22d
|
"catalog_updated_at": None, # заполнит catalog scraper 22d
|
||||||
"catalog_url_hash": None, # заполнит catalog scraper 22d
|
# catalog_url_hash приходит из kn-API (elemId) уже здесь — нужен catalog
|
||||||
|
# scraper'у (22d) для построения URL страницы квартиры. Ранее хардкодился
|
||||||
|
# в None, из-за чего flat-catalog scraper не мог выбрать ни одной строки.
|
||||||
|
"catalog_url_hash": catalog_url_hash,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -524,14 +535,29 @@ UPSERT_OBJECT_SQL = text(
|
||||||
:snapshot_date
|
:snapshot_date
|
||||||
)
|
)
|
||||||
ON CONFLICT (obj_id, snapshot_date) DO UPDATE SET
|
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,
|
comm_name = EXCLUDED.comm_name,
|
||||||
addr = EXCLUDED.addr,
|
addr = EXCLUDED.addr,
|
||||||
|
short_addr = EXCLUDED.short_addr,
|
||||||
|
region_cd = EXCLUDED.region_cd,
|
||||||
dev_id = EXCLUDED.dev_id,
|
dev_id = EXCLUDED.dev_id,
|
||||||
dev_name = EXCLUDED.dev_name,
|
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,
|
flat_count = EXCLUDED.flat_count,
|
||||||
square_living = EXCLUDED.square_living,
|
square_living = EXCLUDED.square_living,
|
||||||
ready_dt = EXCLUDED.ready_dt,
|
ready_dt = EXCLUDED.ready_dt,
|
||||||
|
problem_flag = EXCLUDED.problem_flag,
|
||||||
site_status = EXCLUDED.site_status,
|
site_status = EXCLUDED.site_status,
|
||||||
|
green_house = EXCLUDED.green_house,
|
||||||
escrow = EXCLUDED.escrow,
|
escrow = EXCLUDED.escrow,
|
||||||
obj_class = COALESCE(EXCLUDED.obj_class, domrf_kn_objects.obj_class),
|
obj_class = COALESCE(EXCLUDED.obj_class, domrf_kn_objects.obj_class),
|
||||||
wall_type = COALESCE(EXCLUDED.wall_type, domrf_kn_objects.wall_type),
|
wall_type = COALESCE(EXCLUDED.wall_type, domrf_kn_objects.wall_type),
|
||||||
|
|
@ -910,6 +936,9 @@ UPSERT_PHOTO_SQL = text(
|
||||||
:local_path, :thumb_path, :downloaded_at
|
:local_path, :thumb_path, :downloaded_at
|
||||||
)
|
)
|
||||||
ON CONFLICT (obj_id, obj_file_id) DO UPDATE SET
|
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,
|
ord_num = EXCLUDED.ord_num,
|
||||||
photo_url = EXCLUDED.photo_url,
|
photo_url = EXCLUDED.photo_url,
|
||||||
photo_dttm = EXCLUDED.photo_dttm,
|
photo_dttm = EXCLUDED.photo_dttm,
|
||||||
|
|
@ -1142,7 +1171,7 @@ async def fetch_obj_checks(sess: BrowserSession, obj_id: int) -> tuple[Any, str]
|
||||||
# ── _fetch_*_safe wrappers for asyncio.gather in Phase B/C ───────────────────
|
# ── _fetch_*_safe wrappers for asyncio.gather in Phase B/C ───────────────────
|
||||||
# Каждый wrapper возвращает (kind, full_url, result_or_exception).
|
# Каждый wrapper возвращает (kind, full_url, result_or_exception).
|
||||||
# Exceptions НЕ raise — помещаются в возвращаемый tuple.
|
# Exceptions НЕ raise — помещаются в возвращаемый tuple.
|
||||||
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) bounds concurrency per-request.
|
# BrowserSession._sem (size = per-instance concurrency, #1945 KN=2) bounds concurrency.
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_flats_safe(
|
async def _fetch_flats_safe(
|
||||||
|
|
@ -1450,6 +1479,247 @@ async def download_photos(
|
||||||
return locals_out, thumbs_out
|
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 ─────────────────────────────────────────────────────────────
|
# ── orchestrator ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1660,9 +1930,25 @@ async def run_region_sweep(
|
||||||
photos_dir: Path | str | None = None,
|
photos_dir: Path | str | None = None,
|
||||||
place_override: str | None = None,
|
place_override: str | None = None,
|
||||||
resume_from_run_id: int | 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]:
|
) -> dict[str, Any]:
|
||||||
"""Full sweep for one region: bootstrap → fetch objects → per-object processing.
|
"""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 и
|
Resume-aware: если `resume_from_run_id` задан, читаем objects_snapshot и
|
||||||
progress_obj_index из исходного run и продолжаем с этого индекса. Новый
|
progress_obj_index из исходного run и продолжаем с этого индекса. Новый
|
||||||
run_id создаётся (с resumed_from_run_id ссылкой), исходный остаётся
|
run_id создаётся (с resumed_from_run_id ссылкой), исходный остаётся
|
||||||
|
|
@ -1802,6 +2088,10 @@ async def run_region_sweep(
|
||||||
headed=headed,
|
headed=headed,
|
||||||
load_state=load_state,
|
load_state=load_state,
|
||||||
save_state=save_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:
|
) as sess:
|
||||||
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies
|
# Warm-up: visit /сервисы/каталог-новостроек/ to obtain WAF cookies
|
||||||
# (___dmpkit___, domain_sid). Required since 2026-05-24 — without these
|
# (___dmpkit___, domain_sid). Required since 2026-05-24 — without these
|
||||||
|
|
@ -1881,9 +2171,16 @@ async def run_region_sweep(
|
||||||
|
|
||||||
# ── Phase B/C — per-object processing (resumable, parallel per-object) ─
|
# ── Phase B/C — per-object processing (resumable, parallel per-object) ─
|
||||||
# Все endpoint'ы одного obj_id запускаются параллельно через asyncio.gather.
|
# Все endpoint'ы одного obj_id запускаются параллельно через asyncio.gather.
|
||||||
# BrowserSession._sem (Semaphore(_BROWSER_CONCURRENCY)=8) ограничивает запросы.
|
# BrowserSession._sem (size = browser_concurrency, #1945 троттлит KN до 2)
|
||||||
|
# ограничивает запросы.
|
||||||
# DB upserts выполняются последовательно после gather — один db Session
|
# DB upserts выполняются последовательно после gather — один db Session
|
||||||
# не thread-safe для параллельной записи.
|
# не 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
|
pdir = Path(photos_dir) if photos_dir else PHOTOS_DIR_DEFAULT
|
||||||
total = len(all_objects)
|
total = len(all_objects)
|
||||||
for i in range(start_index, total):
|
for i in range(start_index, total):
|
||||||
|
|
@ -1898,17 +2195,12 @@ async def run_region_sweep(
|
||||||
if fetch_flats:
|
if fetch_flats:
|
||||||
coros.append(_fetch_flats_safe(sess, obj_id))
|
coros.append(_fetch_flats_safe(sess, obj_id))
|
||||||
|
|
||||||
if extras:
|
# extras в combined-сессии ТОЛЬКО когда изоляция выключена —
|
||||||
coros.append(_fetch_sale_graph_safe(sess, obj_id, "apartments"))
|
# иначе они отравят flats (#1945). В isolated-режиме extras
|
||||||
coros.append(_fetch_sale_graph_safe(sess, obj_id, "parking"))
|
# обрабатываются отдельным проходом ниже.
|
||||||
coros.append(_fetch_sales_agg_safe(sess, obj_id))
|
combined_extras = extras and not extras_isolated
|
||||||
coros.append(_fetch_infrastructure_safe(sess, obj_id))
|
if combined_extras:
|
||||||
coros.append(_fetch_photos_safe(sess, obj_id))
|
coros.extend(_extras_coros(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).
|
# TODO: obj_checks endpoint not found at /api/object/{id}/checks (404).
|
||||||
# 6 чек-боксов "Проверено на наш.дом.рф" вероятно inline в kn/object payload.
|
# 6 чек-боксов "Проверено на наш.дом.рф" вероятно inline в kn/object payload.
|
||||||
# Re-enable после investigation структуры объекта (separate PR).
|
# Re-enable после investigation структуры объекта (separate PR).
|
||||||
|
|
@ -1922,64 +2214,28 @@ async def run_region_sweep(
|
||||||
|
|
||||||
# Sequential upsert — DB session не thread-safe
|
# Sequential upsert — DB session не thread-safe
|
||||||
all_docs: list[dict[str, Any]] = []
|
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:
|
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":
|
if kind_tag == "flats":
|
||||||
flats_list: list[dict[str, Any]] = result # type: ignore[assignment]
|
total_flats += _process_flats_result(
|
||||||
if flats_list:
|
db, run_id, obj_id, full_url, result, snapshot_date, region_code
|
||||||
total_flats += upsert_flats(db, flats_list, snapshot_date, region_code)
|
)
|
||||||
|
else:
|
||||||
elif kind_tag in ("sale_graph_apartments", "sale_graph_parking"):
|
await _process_extras_result(
|
||||||
sg_type = kind_tag.replace("sale_graph_", "")
|
sess,
|
||||||
rows_sg, _ = result # type: ignore[misc]
|
db,
|
||||||
extras_counts["sale_graph_rows"] += upsert_sale_graph(
|
run_id,
|
||||||
db, obj_id, sg_type, rows_sg, snapshot_date
|
obj_id,
|
||||||
|
kind_tag,
|
||||||
|
full_url,
|
||||||
|
result,
|
||||||
|
snapshot_date,
|
||||||
|
extras_counts,
|
||||||
|
all_docs,
|
||||||
|
download_photos_binary=download_photos_binary,
|
||||||
|
pdir=pdir,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif kind_tag == "sales_agg":
|
# Единый upsert всех документов объекта после обработки doc-endpoint'ов.
|
||||||
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:
|
if all_docs:
|
||||||
ins, _skip = upsert_documents(db, obj_id, all_docs)
|
ins, _skip = upsert_documents(db, obj_id, all_docs)
|
||||||
extras_counts["documents_rows"] += ins
|
extras_counts["documents_rows"] += ins
|
||||||
|
|
@ -1998,12 +2254,49 @@ async def run_region_sweep(
|
||||||
f" downloaded={extras_counts['photos_downloaded']}"
|
f" downloaded={extras_counts['photos_downloaded']}"
|
||||||
f" docs={extras_counts['documents_rows']}"
|
f" docs={extras_counts['documents_rows']}"
|
||||||
f" checks={extras_counts['checks_rows']}",
|
f" checks={extras_counts['checks_rows']}",
|
||||||
stage="extras" if extras else "fetch_flats",
|
stage="extras" if combined_extras else "fetch_flats",
|
||||||
)
|
)
|
||||||
|
|
||||||
_checkpoint(db, run_id, total)
|
_checkpoint(db, run_id, total)
|
||||||
request_count = sess.request_count
|
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 ─────────────────────────────────────────────
|
# ── Phase D — finalize ─────────────────────────────────────────────
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,36 @@ Issue #297 sub-task 22c.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
import re
|
||||||
from typing import Any
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.services.scrapers.stealth import BrowserSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 ───────────────────────────────────────────────────────────
|
# ── payload parsing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -212,30 +233,131 @@ def upsert_flat_plans(
|
||||||
return inserted
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
# ── download stub ─────────────────────────────────────────────────────────────
|
# ── image download ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def download_plan_image_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,
|
||||||
plan_image_url: str,
|
plan_image_url: str,
|
||||||
ods_id: str,
|
ods_id: str,
|
||||||
dest_dir: str | None = None,
|
obj_id: int,
|
||||||
) -> str:
|
base_dir: Path | None = None,
|
||||||
"""Заглушка для скачивания бинарника планировки.
|
) -> dict[str, Any] | None:
|
||||||
|
"""Скачать бинарник планировки квартиры и вернуть метаданные для upsert.
|
||||||
|
|
||||||
Реальная реализация — отдельный Celery task (22d-track, issue #299).
|
Реиспользует BrowserSession.download_binary — тот же паттерн что download_photos
|
||||||
До реализации бросает NotImplementedError, чтобы случайный вызов
|
в 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-бюджет), но всё равно
|
||||||
|
возвращаем метаданные (размеры/размер читаются с диска).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plan_image_url: URL картинки планировки.
|
session: Активный BrowserSession (bootstrapped + warm_up).
|
||||||
|
plan_image_url: Абсолютный URL картинки планировки.
|
||||||
ods_id: Идентификатор квартиры (для имени файла).
|
ods_id: Идентификатор квартиры (для имени файла).
|
||||||
dest_dir: Директория для сохранения (None = MEDIA_ROOT/flat_plans/).
|
obj_id: ID объекта DOM.РФ (подпапка).
|
||||||
|
base_dir: Корень для сохранения (None = FLAT_PLANS_DIR_DEFAULT).
|
||||||
|
|
||||||
Raises:
|
Returns:
|
||||||
NotImplementedError: всегда — до реализации.
|
dict с ключами local_path, width_px, height_px, size_bytes, downloaded_at —
|
||||||
|
для передачи в upsert_flat_plans. None при ошибке скачивания.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(
|
root = base_dir or FLAT_PLANS_DIR_DEFAULT
|
||||||
"download_plan_image_stub: скачивание планировок не реализовано. "
|
obj_dir = root / str(obj_id)
|
||||||
"Реальный downloader — отдельный Celery task (issue #299, 22d-track). "
|
ext = _ext_from_url(plan_image_url)
|
||||||
f"URL={plan_image_url!r} ods_id={ods_id!r} dest_dir={dest_dir!r}"
|
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,
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
"local_path": str(local),
|
||||||
|
"width_px": width_px,
|
||||||
|
"height_px": height_px,
|
||||||
|
"size_bytes": len(data),
|
||||||
|
"downloaded_at": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
|
|
||||||
501
backend/app/services/scrapers/gisogd66.py
Normal file
501
backend/app/services/scrapers/gisogd66.py
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
"""Загрузчик реестра РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО (#2367).
|
||||||
|
|
||||||
|
Источник: ``gisogd66.midural.ru`` — региональная ГИС органов гос. власти Свердловской
|
||||||
|
области. Открытый JSON-API раздела 13 («Сведения о документах в деле о земельном
|
||||||
|
участке»), БЕЗ авторизации (bare curl 200, разведка 2026-07-04).
|
||||||
|
|
||||||
|
Три эндпоинта (все GET JSON, base ``https://gisogd66.midural.ru/api/v1/{schema}``):
|
||||||
|
* ``/gisogddocgroups/razdel13`` — группы документов раздела (DocRS/DocRV/…);
|
||||||
|
* ``/gisogddocgroupdocs/razdel13/{group}`` — весь список группы одним ответом (без
|
||||||
|
пагинации): key, name, docNum, dateDoc, numReg, dateReg;
|
||||||
|
* ``/gisogddocs/razdel13/{key}`` — карточка документа: spatialUnit (кад.№,
|
||||||
|
может быть несколько через запятую / отсутствовать), infosetGeoJson (GeoJSON Polygon
|
||||||
|
WGS84 lon/lat, может отсутствовать — hasGeometry=false), approvedOrganization, docNum,
|
||||||
|
dateDoc, dateReg.
|
||||||
|
|
||||||
|
Две схемы (тянем обе, sverdregion первым): ``agate_sverdregion`` (регион-агрегат,
|
||||||
|
стабильные карточки, покрывает и ЕКБ) и ``agate_ekbgo`` (ЕКБ, свежее на ~неделю, но
|
||||||
|
часть старых ключей битая на портале). Один документ может встретиться в обеих →
|
||||||
|
бизнес-ключ ``(doc_group, doc_num)``; при конфликте предпочитаем запись с более
|
||||||
|
поздним ``date_reg``.
|
||||||
|
|
||||||
|
Инкрементальность: список группы сверяется с БД по (source_schema, source_key, date_reg);
|
||||||
|
карточки (второй запрос — дорогой) тянутся ТОЛЬКО для новых / изменившихся документов.
|
||||||
|
|
||||||
|
Politeness: единый httpx-клиент с limits(max_connections=5), timeout=20с, 2 ретрая на
|
||||||
|
сетевых ошибках. Карточки (5xx-битые записи портала) НЕ ретраятся — deterministically
|
||||||
|
broken, не транзиент. Circuit breaker: 50 карточек подряд с ошибкой → прерываем группу
|
||||||
|
(битый сегмент). UPSERT — per-row SAVEPOINT (битая строка не валит батч). Периодический
|
||||||
|
db.commit() (каждые 200 записей + после каждой schema/group), чтобы партиал пережил
|
||||||
|
soft_time_limit-kill.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Константы источника ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
GISOGD_BASE = "https://gisogd66.midural.ru/api/v1"
|
||||||
|
_SECTION = "razdel13"
|
||||||
|
|
||||||
|
# Схемы источника — обе тянем. Порядок ВАЖЕН: sverdregion первым.
|
||||||
|
# sverdregion (регион-агрегат) отдаёт карточки стабильно и покрывает в т.ч. ЕКБ-доки
|
||||||
|
# через агрегацию, поэтому он — базовый источник. ekbgo вторым как freshness-энхансер
|
||||||
|
# (свежее на ~неделю), НО у него большой сегмент старых ключей (префикс ~100001007xxx)
|
||||||
|
# детерминированно отдаёт 500 на карточках (сломано на стороне портала, 2026-07-04) —
|
||||||
|
# гоняем его после того, как регион уже покрыл основную массу (см. circuit breaker ниже).
|
||||||
|
SCHEMAS: tuple[str, ...] = ("agate_sverdregion", "agate_ekbgo")
|
||||||
|
|
||||||
|
# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV')).
|
||||||
|
GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV"}
|
||||||
|
|
||||||
|
_HTTP_TIMEOUT = 20.0
|
||||||
|
_MAX_CONNECTIONS = 5
|
||||||
|
_MAX_RETRIES = 2 # ретраи (helper делает суммарно до 1 + _MAX_RETRIES попыток)
|
||||||
|
_RETRY_BACKOFF_S = 1.5
|
||||||
|
_CARD_SLEEP_S = 0.05 # вежливая пауза между карточками (второй, «дорогой» запрос)
|
||||||
|
_COMMIT_EVERY = 200 # периодический commit внутри группы (партиал переживает time-limit)
|
||||||
|
# Circuit breaker: столько карточек ПОДРЯД с ошибкой fetch → прерываем группу (портал
|
||||||
|
# отдаёт битый сегмент старых ключей детерминированным 5xx; остаток догоним будущими ранами).
|
||||||
|
_CARD_FAIL_BREAKER = 50
|
||||||
|
|
||||||
|
USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) Site Finder gisogd permits loader"
|
||||||
|
|
||||||
|
# Валидация кадастрового номера: NN:NN:NNNNNN(N):N (6-7 знаков в третьем блоке).
|
||||||
|
_CAD_RE = re.compile(r"^\d{2}:\d{2}:\d{6,7}:\d+$")
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP helpers (module-level — мокаются в тестах) ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client() -> httpx.Client:
|
||||||
|
"""httpx.Client с пулом (max_connections=5), таймаутом и UA. Закрывать вызывающим."""
|
||||||
|
return httpx.Client(
|
||||||
|
base_url=GISOGD_BASE,
|
||||||
|
timeout=_HTTP_TIMEOUT,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
limits=httpx.Limits(max_connections=_MAX_CONNECTIONS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(client: httpx.Client, path: str, *, retries_5xx: int = _MAX_RETRIES) -> Any:
|
||||||
|
"""GET path → JSON с ретраями на 5xx / сетевых ошибках.
|
||||||
|
|
||||||
|
Сетевые Timeout/TransportError ретраятся всегда (до 1 + _MAX_RETRIES попыток) —
|
||||||
|
это транзиент. 5xx ретраятся ``retries_5xx`` раз: списки группы — с дефолтом
|
||||||
|
(транзиентная нагрузка портала), карточки — с ``retries_5xx=0`` (5xx карточки
|
||||||
|
детерминированно битые записи сегмента портала, не транзиент). 4xx → сразу raise.
|
||||||
|
"""
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(_MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
resp = client.get(path)
|
||||||
|
if resp.status_code >= 500:
|
||||||
|
last_exc = httpx.HTTPStatusError(
|
||||||
|
f"{resp.status_code} on {path}", request=resp.request, response=resp
|
||||||
|
)
|
||||||
|
# 5xx: ретраим только пока attempt в пределах retries_5xx (0 → сразу raise).
|
||||||
|
if attempt >= retries_5xx:
|
||||||
|
raise last_exc
|
||||||
|
logger.warning(
|
||||||
|
"gisogd66: %s (попытка %d) на %s", resp.status_code, attempt + 1, path
|
||||||
|
)
|
||||||
|
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||||
|
last_exc = exc
|
||||||
|
logger.warning(
|
||||||
|
"gisogd66: сетевая ошибка (попытка %d) на %s: %s", attempt + 1, path, exc
|
||||||
|
)
|
||||||
|
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
|
||||||
|
assert last_exc is not None
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_group_list(client: httpx.Client, schema: str, group: str) -> list[dict[str, Any]]:
|
||||||
|
"""Весь список документов группы (без пагинации) → list of dict.
|
||||||
|
|
||||||
|
Поля элемента: key, name, docNum, dateDoc, numReg, dateReg. Пустой список при
|
||||||
|
неожиданном (не-list) ответе.
|
||||||
|
"""
|
||||||
|
data = _get_json(client, f"/{schema}/gisogddocgroupdocs/{_SECTION}/{group}")
|
||||||
|
if not isinstance(data, list):
|
||||||
|
logger.warning("gisogd66: %s/%s список не-list (%s)", schema, group, type(data).__name__)
|
||||||
|
return []
|
||||||
|
logger.info("gisogd66: %s/%s список документов=%d", schema, group, len(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_doc_card(client: httpx.Client, schema: str, key: str | int) -> dict[str, Any] | None:
|
||||||
|
"""Карточка документа → dict (или None при не-dict / ошибке карточки).
|
||||||
|
|
||||||
|
Поля: spatialUnit, infosetGeoJson, hasGeometry, approvedOrganization, docNum,
|
||||||
|
dateDoc, dateReg, name.
|
||||||
|
|
||||||
|
5xx карточки НЕ ретраятся (retries_5xx=0): битый сегмент старых ключей портала
|
||||||
|
отдаёт детерминированный 500 — 3 попытки с backoff = ~12с потерянного времени/карточку.
|
||||||
|
"""
|
||||||
|
data = _get_json(client, f"/{schema}/gisogddocs/{_SECTION}/{key}", retries_5xx=0)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning("gisogd66: карточка %s/%s не-dict (%s)", schema, key, type(data).__name__)
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── Парсинг полей ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: Any) -> date | None:
|
||||||
|
"""ISO-datetime / date-строка источника → date. None если пусто / не парсится.
|
||||||
|
|
||||||
|
Источник отдаёт «2026-06-02T00:00:00» (dateDoc) и «2026-06-19T15:55:11» (dateReg).
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
s = str(value).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
# Обрезаем таймзону-суффикс 'Z' если вдруг придёт, парсим ISO.
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00")).date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s[:10], fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
logger.debug("gisogd66: не распарсил дату %r", value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cad_nums(spatial_unit: Any) -> list[str]:
|
||||||
|
"""spatialUnit → список валидных кадастровых номеров (может быть несколько через запятую).
|
||||||
|
|
||||||
|
Split по запятой, strip, валидация по _CAD_RE. Невалидные (мусор) отбрасываются с
|
||||||
|
warning-логом. Отсутствие / пусто → []. Дубликаты схлопываются (сохраняя порядок).
|
||||||
|
"""
|
||||||
|
if not spatial_unit:
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for part in str(spatial_unit).split(","):
|
||||||
|
cad = part.strip()
|
||||||
|
if not cad:
|
||||||
|
continue
|
||||||
|
if not _CAD_RE.match(cad):
|
||||||
|
logger.warning(
|
||||||
|
"gisogd66: невалидный кадастровый номер %r в spatialUnit — отброшен", cad
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if cad not in seen:
|
||||||
|
seen.add(cad)
|
||||||
|
out.append(cad)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def extract_geojson(card: dict[str, Any]) -> str | None:
|
||||||
|
"""infosetGeoJson карточки → GeoJSON-строка (для ST_GeomFromGeoJSON) или None.
|
||||||
|
|
||||||
|
Источник кладёт GeoJSON КАК СТРОКУ в поле infosetGeoJson. Пусто / hasGeometry=false
|
||||||
|
/ невалидный JSON → None (лоадер запишет geom=NULL). Приведение Polygon→MultiPolygon
|
||||||
|
делается в SQL через ST_Multi — здесь возвращаем сырой GeoJSON как есть.
|
||||||
|
"""
|
||||||
|
raw = card.get("infosetGeoJson")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
# На случай если источник когда-нибудь отдаст объект, а не строку.
|
||||||
|
return json.dumps(raw)
|
||||||
|
s = str(raw).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
json.loads(s) # sanity-парс: битую строку не отправляем в PostGIS
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
logger.warning("gisogd66: битый infosetGeoJson (%s) — geom=NULL", exc)
|
||||||
|
return None
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# ── Инкрементальность ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_reg_dates(db: Session, schema: str) -> dict[str, date | None]:
|
||||||
|
"""{source_key: date_reg} для уже загруженных из этой схемы записей.
|
||||||
|
|
||||||
|
Используется для инкрементальной сверки: карточку тянем только если key новый ИЛИ
|
||||||
|
date_reg в списке отличается от сохранённого.
|
||||||
|
"""
|
||||||
|
rows = db.execute(
|
||||||
|
text("SELECT source_key, date_reg FROM gisogd_permits WHERE source_schema = :schema"),
|
||||||
|
{"schema": schema},
|
||||||
|
).all()
|
||||||
|
return {str(r[0]): r[1] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# ── UPSERT ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_permit(db: Session, rec: dict[str, Any]) -> str:
|
||||||
|
"""UPSERT одной записи в gisogd_permits по (doc_group, doc_num). Per-row SAVEPOINT.
|
||||||
|
|
||||||
|
Конфликт-резолв: при коллизии бизнес-ключа обновляем ТОЛЬКО если у новой записи
|
||||||
|
date_reg НЕ старше сохранённой (EXCLUDED.date_reg >= existing) — предпочитаем
|
||||||
|
более позднюю регистрацию (или запись без даты не затирает датированную).
|
||||||
|
|
||||||
|
Returns: 'inserted' | 'updated' | 'skipped_unchanged'.
|
||||||
|
"""
|
||||||
|
result = db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO gisogd_permits
|
||||||
|
(doc_group, doc_num, doc_name, date_doc, date_reg,
|
||||||
|
approved_organization, source_schema, source_key,
|
||||||
|
cad_nums, geom, fetched_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
:doc_group, :doc_num, :doc_name, :date_doc, :date_reg,
|
||||||
|
:approved_org, :source_schema, :source_key,
|
||||||
|
CAST(:cad_nums AS text[]),
|
||||||
|
-- CAST обязателен: без него ни одно вхождение :geojson не даёт Postgres
|
||||||
|
-- тип параметра (ST_GeomFromGeoJSON перегружена text/json/jsonb) →
|
||||||
|
-- psycopg.errors.AmbiguousParameter на КАЖДОЙ строке (прод-прогон #2367).
|
||||||
|
CASE WHEN CAST(:geojson AS text) IS NULL THEN NULL
|
||||||
|
ELSE ST_Multi(ST_SetSRID(
|
||||||
|
ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326)) END,
|
||||||
|
NOW(), NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (doc_group, doc_num) DO UPDATE
|
||||||
|
SET doc_name = EXCLUDED.doc_name,
|
||||||
|
date_doc = EXCLUDED.date_doc,
|
||||||
|
date_reg = EXCLUDED.date_reg,
|
||||||
|
approved_organization = EXCLUDED.approved_organization,
|
||||||
|
source_schema = EXCLUDED.source_schema,
|
||||||
|
source_key = EXCLUDED.source_key,
|
||||||
|
cad_nums = EXCLUDED.cad_nums,
|
||||||
|
geom = EXCLUDED.geom,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE EXCLUDED.date_reg IS NOT DISTINCT FROM gisogd_permits.date_reg
|
||||||
|
OR EXCLUDED.date_reg > gisogd_permits.date_reg
|
||||||
|
OR gisogd_permits.date_reg IS NULL
|
||||||
|
RETURNING (xmax = 0) AS is_insert
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"doc_group": rec["doc_group"],
|
||||||
|
"doc_num": rec["doc_num"],
|
||||||
|
"doc_name": rec["doc_name"],
|
||||||
|
"date_doc": rec["date_doc"],
|
||||||
|
"date_reg": rec["date_reg"],
|
||||||
|
"approved_org": rec["approved_organization"],
|
||||||
|
"source_schema": rec["source_schema"],
|
||||||
|
"source_key": rec["source_key"],
|
||||||
|
"cad_nums": rec["cad_nums"],
|
||||||
|
"geojson": rec["geojson"],
|
||||||
|
},
|
||||||
|
).scalar()
|
||||||
|
if result is None:
|
||||||
|
# WHERE-гейт не пропустил апдейт (у нас более старый date_reg) → без изменений.
|
||||||
|
return "skipped_unchanged"
|
||||||
|
return "inserted" if result else "updated"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Оркестрация ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def load_gisogd_permits(db: Session | None = None) -> dict[str, int]:
|
||||||
|
"""Инкрементально загрузить РНС/РВЭ ГИСОГД-СО (обе схемы, обе группы) в gisogd_permits.
|
||||||
|
|
||||||
|
Для каждой (schema, group): список → сверка с БД по (source_key, date_reg) →
|
||||||
|
карточки только для новых/изменённых → UPSERT по (doc_group, doc_num) с
|
||||||
|
предпочтением позднего date_reg. ``db`` для совместимости сигнатуры; None → своя
|
||||||
|
SessionLocal.
|
||||||
|
|
||||||
|
Returns: счётчики {listed, fetched_details, inserted, updated, skipped_unchanged,
|
||||||
|
no_cad, no_geom, card_failed, group_aborted}.
|
||||||
|
"""
|
||||||
|
owns_session = db is None
|
||||||
|
if db is None:
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
counts = {
|
||||||
|
"listed": 0,
|
||||||
|
"fetched_details": 0,
|
||||||
|
"inserted": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"skipped_unchanged": 0,
|
||||||
|
"no_cad": 0,
|
||||||
|
"no_geom": 0,
|
||||||
|
"card_failed": 0, # каждая упавшая карточка (5xx / сеть / не-dict)
|
||||||
|
"group_aborted": 0, # групп прервано circuit breaker'ом (битый сегмент портала)
|
||||||
|
}
|
||||||
|
client = _make_client()
|
||||||
|
try:
|
||||||
|
for schema in SCHEMAS:
|
||||||
|
existing = _existing_reg_dates(db, schema)
|
||||||
|
for group_key, doc_group in GROUP_CODE.items():
|
||||||
|
try:
|
||||||
|
items = fetch_group_list(client, schema, group_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("gisogd66: список %s/%s упал: %s", schema, group_key, exc)
|
||||||
|
continue
|
||||||
|
counts["listed"] += len(items)
|
||||||
|
_sync_group(db, client, schema, doc_group, items, existing, counts)
|
||||||
|
# Commit после КАЖДОЙ (schema, group), а не один раз в конце: партиал
|
||||||
|
# должен пережить soft_time_limit-kill (иначе теряем весь прогон).
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("gisogd66: outer tx rolled back: %s", exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
if owns_session:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
logger.info("load_gisogd_permits done: %s", counts)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_group(
|
||||||
|
db: Session,
|
||||||
|
client: httpx.Client,
|
||||||
|
schema: str,
|
||||||
|
doc_group: str,
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
existing: dict[str, date | None],
|
||||||
|
counts: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
"""Обработать одну (schema, group): инкрементальная сверка + карточки + UPSERT.
|
||||||
|
|
||||||
|
Периодический commit каждые ``_COMMIT_EVERY`` обработанных записей (партиал переживёт
|
||||||
|
time-limit kill). Circuit breaker: ``_CARD_FAIL_BREAKER`` карточек ПОДРЯД с ошибкой
|
||||||
|
fetch → прерываем группу (битый сегмент портала — остаток догоним будущими ранами).
|
||||||
|
Любая успешная карточка сбрасывает счётчик подряд-фейлов.
|
||||||
|
"""
|
||||||
|
processed = 0 # успешно обработанных (upsert/skip) с последнего commit
|
||||||
|
consecutive_fail = 0 # карточек подряд с ошибкой fetch (для circuit breaker)
|
||||||
|
for item in items:
|
||||||
|
key = item.get("key")
|
||||||
|
if key is None:
|
||||||
|
continue
|
||||||
|
source_key = str(key)
|
||||||
|
list_reg = _parse_date(item.get("dateReg"))
|
||||||
|
|
||||||
|
# Инкрементальность: пропускаем карточку, если key уже есть И date_reg совпал.
|
||||||
|
if source_key in existing and existing[source_key] == list_reg:
|
||||||
|
counts["skipped_unchanged"] += 1
|
||||||
|
consecutive_fail = 0 # skip — не фейл карточки, сбрасываем breaker
|
||||||
|
processed += 1
|
||||||
|
if processed % _COMMIT_EVERY == 0:
|
||||||
|
db.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
card: dict[str, Any] | None
|
||||||
|
try:
|
||||||
|
card = fetch_doc_card(client, schema, source_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("gisogd66: карточка %s/%s упала: %s", schema, source_key, exc)
|
||||||
|
card = None
|
||||||
|
finally:
|
||||||
|
time.sleep(_CARD_SLEEP_S)
|
||||||
|
if card is None:
|
||||||
|
# Неуспех fetch (5xx / сеть / не-dict): считаем и двигаем circuit breaker.
|
||||||
|
counts["card_failed"] += 1
|
||||||
|
consecutive_fail += 1
|
||||||
|
if consecutive_fail >= _CARD_FAIL_BREAKER:
|
||||||
|
logger.error(
|
||||||
|
"gisogd66: %s/%s: %d карточек подряд 5xx — прерываю группу "
|
||||||
|
"(битый сегмент портала)",
|
||||||
|
schema,
|
||||||
|
doc_group,
|
||||||
|
_CARD_FAIL_BREAKER,
|
||||||
|
)
|
||||||
|
counts["group_aborted"] += 1
|
||||||
|
return
|
||||||
|
continue
|
||||||
|
consecutive_fail = 0 # успешная карточка сбрасывает breaker
|
||||||
|
counts["fetched_details"] += 1
|
||||||
|
|
||||||
|
rec = _build_record(item, card, schema, doc_group, source_key, list_reg)
|
||||||
|
if rec is None:
|
||||||
|
continue
|
||||||
|
if not rec["cad_nums"]:
|
||||||
|
counts["no_cad"] += 1
|
||||||
|
if rec["geojson"] is None:
|
||||||
|
counts["no_geom"] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.begin_nested(): # SAVEPOINT — откат только этой строки
|
||||||
|
outcome = _upsert_permit(db, rec)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("gisogd66: upsert %s %r упал: %s", doc_group, rec["doc_num"], exc)
|
||||||
|
continue
|
||||||
|
counts[outcome] += 1
|
||||||
|
processed += 1
|
||||||
|
if processed % _COMMIT_EVERY == 0:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_record(
|
||||||
|
item: dict[str, Any],
|
||||||
|
card: dict[str, Any],
|
||||||
|
schema: str,
|
||||||
|
doc_group: str,
|
||||||
|
source_key: str,
|
||||||
|
list_reg: date | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Список-элемент + карточка → нормализованная запись для UPSERT. None если нет doc_num.
|
||||||
|
|
||||||
|
doc_num берём из карточки, fallback на список; strip обязателен (в списке ~25%
|
||||||
|
номеров с ведущими/хвостовыми пробелами). Даты предпочитаем из карточки, fallback
|
||||||
|
на список. cad_nums из spatialUnit карточки, geojson из infosetGeoJson.
|
||||||
|
"""
|
||||||
|
raw_num = card.get("docNum") or item.get("docNum")
|
||||||
|
doc_num = str(raw_num).strip() if raw_num else ""
|
||||||
|
if not doc_num:
|
||||||
|
logger.warning("gisogd66: карточка %s/%s без docNum — пропуск", schema, source_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"doc_group": doc_group,
|
||||||
|
"doc_num": doc_num,
|
||||||
|
"doc_name": card.get("name") or item.get("name"),
|
||||||
|
"date_doc": _parse_date(card.get("dateDoc")) or _parse_date(item.get("dateDoc")),
|
||||||
|
"date_reg": _parse_date(card.get("dateReg")) or list_reg,
|
||||||
|
"approved_organization": card.get("approvedOrganization"),
|
||||||
|
"source_schema": schema,
|
||||||
|
"source_key": source_key,
|
||||||
|
"cad_nums": parse_cad_nums(card.get("spatialUnit")),
|
||||||
|
"geojson": extract_geojson(card),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GROUP_CODE",
|
||||||
|
"SCHEMAS",
|
||||||
|
"extract_geojson",
|
||||||
|
"fetch_doc_card",
|
||||||
|
"fetch_group_list",
|
||||||
|
"load_gisogd_permits",
|
||||||
|
"parse_cad_nums",
|
||||||
|
]
|
||||||
|
|
@ -19,12 +19,21 @@ searchMap отдаёт в properties **только {id}** — название/
|
||||||
|
|
||||||
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
|
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
|
||||||
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
|
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
|
||||||
|
|
||||||
|
WAF-hygiene (#2445 item D3): sibling-скрапер без backoff словил бан IP по WAF
|
||||||
|
DOM.РФ 24.05.2026 (#2443) — okn-mk.mkrf.ru имеет тот же risk shape (гос. CMS,
|
||||||
|
десятки последовательных запросов без паузы). Идиома пэйсинга/backoff/ретраев
|
||||||
|
портирована из ``gisogd66.py`` (``_RETRY_BACKOFF_S`` + сон между попытками) и
|
||||||
|
``objective.py`` (явная обработка 429/Retry-After): rate-limit между запросами
|
||||||
|
(``_wait_rate_limit``), экспоненциальный backoff на 429/5xx с капом
|
||||||
|
(``_do_request``/``_do_detail_request``), уважение заголовка Retry-After.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -42,6 +51,47 @@ _DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show"
|
||||||
_REQUEST_TIMEOUT = 30.0
|
_REQUEST_TIMEOUT = 30.0
|
||||||
_DETAIL_TIMEOUT = 10.0
|
_DETAIL_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
# ── WAF-hygiene: пэйсинг + backoff (#2445 item D3) ────────────────────────────
|
||||||
|
# Вежливая пауза между последовательными запросами (мс) — тот же порядок величины,
|
||||||
|
# что и у objective.py (_DEFAULT_RATE_MS=500) / nspd_client.py (DEFAULT_RATE_MS=600).
|
||||||
|
_RATE_LIMIT_MS = 400
|
||||||
|
# Ретраи на 429/5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток).
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
# Экспоненциальный backoff на 429/5xx: base * 2**attempt, капается _MAX_BACKOFF_S
|
||||||
|
# (аналогично objective.py: 30 * 2**attempt, cap 300s — здесь эндпоинт легче,
|
||||||
|
# поэтому база/потолок меньше, но идиома та же).
|
||||||
|
_RETRY_BACKOFF_BASE_S = 2.0
|
||||||
|
_MAX_BACKOFF_S = 60.0
|
||||||
|
|
||||||
|
# process-локальная метка времени последнего запроса (обеих эндпоинтов) —
|
||||||
|
# rate-limit общий, чтобы 33 list-запроса и до 1500 detail-запросов не долбили
|
||||||
|
# WAF без пауз даже при чередовании вызовов.
|
||||||
|
_last_request_ts: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_rate_limit() -> None:
|
||||||
|
"""Выдержать паузу ``_RATE_LIMIT_MS`` с последнего запроса к okn-mk.mkrf.ru.
|
||||||
|
|
||||||
|
Общий для searchMap и detail — обе цели один и тот же WAF-хост.
|
||||||
|
"""
|
||||||
|
global _last_request_ts
|
||||||
|
elapsed_ms = (time.time() - _last_request_ts) * 1000.0
|
||||||
|
if elapsed_ms < _RATE_LIMIT_MS:
|
||||||
|
time.sleep((_RATE_LIMIT_MS - elapsed_ms) / 1000.0)
|
||||||
|
_last_request_ts = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _backoff_seconds(attempt: int, retry_after_hdr: str | None) -> float:
|
||||||
|
"""Секунды ожидания перед повтором после 429/5xx.
|
||||||
|
|
||||||
|
Retry-After (если валиден) уважается первым, но капается _MAX_BACKOFF_S —
|
||||||
|
аналогично objective.py. Иначе экспоненциальный backoff от attempt (0-based).
|
||||||
|
"""
|
||||||
|
if retry_after_hdr and retry_after_hdr.isdigit():
|
||||||
|
return min(float(retry_after_hdr), _MAX_BACKOFF_S)
|
||||||
|
return min(_RETRY_BACKOFF_BASE_S * (2**attempt), _MAX_BACKOFF_S)
|
||||||
|
|
||||||
|
|
||||||
# Browser headers -- required: without User-Agent the server returns 403.
|
# Browser headers -- required: without User-Agent the server returns 403.
|
||||||
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
|
# Content-Type включён явно: при content= (raw bytes/str) httpx не выставляет его
|
||||||
# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется.
|
# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется.
|
||||||
|
|
@ -99,7 +149,7 @@ def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
|
||||||
Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0].
|
Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0].
|
||||||
"""
|
"""
|
||||||
form_data = _build_form_data(address, category_type)
|
form_data = _build_form_data(address, category_type)
|
||||||
features = _do_request(form_data, verify=True)
|
features = _do_request(form_data, verify=True, attempt=0)
|
||||||
return features
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -123,19 +173,37 @@ def _do_request(
|
||||||
form_data: list[tuple[str, str]],
|
form_data: list[tuple[str, str]],
|
||||||
*,
|
*,
|
||||||
verify: bool,
|
verify: bool,
|
||||||
|
attempt: int = 0,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False."""
|
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False.
|
||||||
|
|
||||||
|
WAF-hygiene: rate-limit перед каждой попыткой (``_wait_rate_limit``), на 429/5xx —
|
||||||
|
экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз (аналогично objective.py).
|
||||||
|
"""
|
||||||
# urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами
|
# urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами
|
||||||
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
|
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
|
||||||
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
|
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
|
||||||
# «expected bytes, tuple found». content= передаёт тело как raw str.
|
# «expected bytes, tuple found». content= передаёт тело как raw str.
|
||||||
body = urllib.parse.urlencode(form_data)
|
body = urllib.parse.urlencode(form_data)
|
||||||
|
_wait_rate_limit()
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
|
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
_SEARCHMAP_URL,
|
_SEARCHMAP_URL,
|
||||||
content=body,
|
content=body,
|
||||||
)
|
)
|
||||||
|
if resp.status_code == 429 or resp.status_code >= 500:
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
|
||||||
|
logger.warning(
|
||||||
|
"okn_egrkn_client: HTTP %d от ЕГРКН (попытка %d/%d), retry через %.1fs",
|
||||||
|
resp.status_code,
|
||||||
|
attempt + 1,
|
||||||
|
_MAX_RETRIES,
|
||||||
|
wait,
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
|
return _do_request(form_data, verify=verify, attempt=attempt + 1)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
|
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
|
||||||
|
|
@ -145,7 +213,7 @@ def _do_request(
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc
|
"okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc
|
||||||
)
|
)
|
||||||
return _do_request(form_data, verify=False)
|
return _do_request(form_data, verify=False, attempt=attempt)
|
||||||
logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc)
|
logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc)
|
||||||
return []
|
return []
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -275,17 +343,36 @@ def fetch_okn_detail(source_id: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
if not source_id:
|
if not source_id:
|
||||||
return {}
|
return {}
|
||||||
return _do_detail_request(source_id, verify=True)
|
return _do_detail_request(source_id, verify=True, attempt=0)
|
||||||
|
|
||||||
|
|
||||||
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
|
def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dict[str, Any]:
|
||||||
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
|
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.
|
||||||
|
|
||||||
|
WAF-hygiene: rate-limit перед каждой попыткой (общий с searchMap — один WAF-хост),
|
||||||
|
на 429/5xx — экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз.
|
||||||
|
"""
|
||||||
params = {"id": source_id}
|
params = {"id": source_id}
|
||||||
|
_wait_rate_limit()
|
||||||
try:
|
try:
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
|
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
|
||||||
) as client:
|
) as client:
|
||||||
resp = client.get(_DETAIL_URL, params=params)
|
resp = client.get(_DETAIL_URL, params=params)
|
||||||
|
if resp.status_code == 429 or resp.status_code >= 500:
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
wait = _backoff_seconds(attempt, resp.headers.get("Retry-After"))
|
||||||
|
logger.warning(
|
||||||
|
"okn_egrkn_client: detail HTTP %d source_id=%s "
|
||||||
|
"(попытка %d/%d), retry через %.1fs",
|
||||||
|
resp.status_code,
|
||||||
|
source_id,
|
||||||
|
attempt + 1,
|
||||||
|
_MAX_RETRIES,
|
||||||
|
wait,
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
|
return _do_detail_request(source_id, verify=verify, attempt=attempt + 1)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -299,7 +386,7 @@ def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
|
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
|
||||||
)
|
)
|
||||||
return _do_detail_request(source_id, verify=False)
|
return _do_detail_request(source_id, verify=False, attempt=attempt)
|
||||||
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
|
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
|
||||||
return {}
|
return {}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ HTML server-rendered (нет JS-рендеринга), httpx достаточн
|
||||||
|
|
||||||
TLS: сайт использует российский УЦ → verify=False (аналогично ekburg_permits #242).
|
TLS: сайт использует российский УЦ → verify=False (аналогично ekburg_permits #242).
|
||||||
|
|
||||||
|
Politeness (#2445 D4): пауза ``_RATE_S`` перед каждым запросом (search / карточка
|
||||||
|
документа / PDF) — тот же идиом, что nspd_client/objective/gisogd66 (300-500мс). Этот
|
||||||
|
VPS уже словил WAF-бан от DOM.РФ 24.05.2026 (#2443) за непейсованные последовательные
|
||||||
|
запросы; pravo.gov66.ru — сайт той же формы (гос-портал region, строгий WAF). Ретрай с
|
||||||
|
backoff на 429/5xx — идиом gisogd66._get_json.
|
||||||
|
|
||||||
Публичный API:
|
Публичный API:
|
||||||
search_documents(query, law_type, since) → list[dict]
|
search_documents(query, law_type, since) → list[dict]
|
||||||
get_document_pdf_url(doc_id) → str | None
|
get_document_pdf_url(doc_id) → str | None
|
||||||
|
|
@ -15,6 +21,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -28,6 +35,14 @@ _SEARCH_URL = f"{_BASE_URL}/search/"
|
||||||
# Тайм-аут щедрый: сервер иногда медленный.
|
# Тайм-аут щедрый: сервер иногда медленный.
|
||||||
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)
|
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)
|
||||||
|
|
||||||
|
# Politeness pacing (#2445 D4): пауза ПЕРЕД каждым запросом к pravo.gov66.ru — тот же
|
||||||
|
# идиом (300-500мс), что nspd_client/objective/gisogd66. Не непейсованный WAF-риск,
|
||||||
|
# аналогичный DOM.РФ-бану #2443.
|
||||||
|
_RATE_S = 0.4
|
||||||
|
# Ретраи на 429/5xx: экспоненциальный backoff, суммарно до 1 + _MAX_RETRIES попыток.
|
||||||
|
_MAX_RETRIES = 2
|
||||||
|
_RETRY_BACKOFF_S = 2.0
|
||||||
|
|
||||||
# Числовые коды типов документов (select#id_law_type на сайте).
|
# Числовые коды типов документов (select#id_law_type на сайте).
|
||||||
# Текстовое значение «Постановление» — неверный параметр, сервер не матчит → 0 хитов.
|
# Текстовое значение «Постановление» — неверный параметр, сервер не матчит → 0 хитов.
|
||||||
_LAW_TYPE_POSTANOVLENIE = "1" # Постановление Правительства Свердловской области
|
_LAW_TYPE_POSTANOVLENIE = "1" # Постановление Правительства Свердловской области
|
||||||
|
|
@ -41,6 +56,43 @@ _RE_PDF_PATH = re.compile(r"/media/pravo/(?!ecp/)[^\s\"']+\.pdf", re.IGNORECASE)
|
||||||
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)
|
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_with_backoff(
|
||||||
|
client: httpx.Client,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""GET с politeness-паузой перед запросом + backoff-ретраем на 429/5xx.
|
||||||
|
|
||||||
|
Пауза ``_RATE_S`` — ПЕРЕД каждой попыткой (в т.ч. первой): пейсинг
|
||||||
|
последовательных запросов к pravo.gov66.ru, не только ретраев (#2445 D4).
|
||||||
|
429/5xx ретраятся до ``_MAX_RETRIES`` раз с экспоненциальным backoff;
|
||||||
|
прочие статусы (включая 4xx кроме 429) — сразу ``raise_for_status()``.
|
||||||
|
"""
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(_MAX_RETRIES + 1):
|
||||||
|
time.sleep(_RATE_S)
|
||||||
|
resp = client.get(url, params=params)
|
||||||
|
if resp.status_code == 429 or resp.status_code >= 500:
|
||||||
|
last_exc = httpx.HTTPStatusError(
|
||||||
|
f"{resp.status_code} on {url}", request=resp.request, response=resp
|
||||||
|
)
|
||||||
|
if attempt >= _MAX_RETRIES:
|
||||||
|
raise last_exc
|
||||||
|
logger.warning(
|
||||||
|
"pravo_gov66_client: %s (попытка %d) на %s — backoff",
|
||||||
|
resp.status_code,
|
||||||
|
attempt + 1,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
assert last_exc is not None
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
def search_documents(
|
def search_documents(
|
||||||
query: str,
|
query: str,
|
||||||
law_type: str = _LAW_TYPE_POSTANOVLENIE,
|
law_type: str = _LAW_TYPE_POSTANOVLENIE,
|
||||||
|
|
@ -65,8 +117,7 @@ def search_documents(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||||
resp = client.get(_SEARCH_URL, params=params)
|
resp = _get_with_backoff(client, _SEARCH_URL, params=params)
|
||||||
resp.raise_for_status()
|
|
||||||
html = resp.text
|
html = resp.text
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
|
logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
|
||||||
|
|
@ -117,8 +168,7 @@ def get_document_pdf_url(doc_id: str) -> str | None:
|
||||||
doc_url = f"{_BASE_URL}/{doc_id}/"
|
doc_url = f"{_BASE_URL}/{doc_id}/"
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||||
resp = client.get(doc_url)
|
resp = _get_with_backoff(client, doc_url)
|
||||||
resp.raise_for_status()
|
|
||||||
html = resp.text
|
html = resp.text
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -151,6 +201,10 @@ def _extract_pdf_url(html: str) -> str | None:
|
||||||
def fetch_pdf(url: str) -> bytes:
|
def fetch_pdf(url: str) -> bytes:
|
||||||
"""Загружает PDF по URL.
|
"""Загружает PDF по URL.
|
||||||
|
|
||||||
|
Пейсинг + backoff на 429/5xx через ``_get_with_backoff`` (#2445 D4) — caller
|
||||||
|
(reservation_ingest) НЕ должен добавлять свой sleep перед этим вызовом, пауза
|
||||||
|
уже внутри клиента.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: абсолютный URL PDF-файла.
|
url: абсолютный URL PDF-файла.
|
||||||
|
|
||||||
|
|
@ -161,8 +215,7 @@ def fetch_pdf(url: str) -> bytes:
|
||||||
httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
|
httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
|
||||||
"""
|
"""
|
||||||
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
|
||||||
resp = client.get(url)
|
resp = _get_with_backoff(client, url)
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.content
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode, urlsplit
|
||||||
|
|
||||||
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
||||||
|
|
||||||
|
|
@ -33,8 +33,47 @@ USER_AGENT = (
|
||||||
|
|
||||||
# Максимум одновременных in-page fetch() на одну BrowserSession;
|
# Максимум одновременных in-page fetch() на одну BrowserSession;
|
||||||
# поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant.
|
# поднимали 3→6→8 под asyncio.gather fan-out, на наш.дом.рф ServicePipe WAF-tolerant.
|
||||||
|
# Это МОДУЛЬНЫЙ ДЕФОЛТ — используется nspd/прочими скраперами вне /сервисы/* path
|
||||||
|
# family. KN-sweep с 2026-05 ловит volume-ban WAF (#1945) и переопределяет это через
|
||||||
|
# BrowserSession(concurrency=...) на settings.scrape_kn_browser_concurrency=2; после
|
||||||
|
# WAF hard-ban 2026-05-24 (#2443) domrf_catalog.py / domrf_catalog_object.py — тот же
|
||||||
|
# /сервисы/* path family — тоже переопределяют на throttled настройки (#2445 D2).
|
||||||
_BROWSER_CONCURRENCY = 8
|
_BROWSER_CONCURRENCY = 8
|
||||||
|
|
||||||
|
# Дефолтное окно inter-request jitter (мс). KN-sweep ширит его через
|
||||||
|
# BrowserSession(jitter_min_ms=, jitter_max_ms=) против rate-бана (#1945).
|
||||||
|
_DEFAULT_JITTER_MIN_MS = 600
|
||||||
|
_DEFAULT_JITTER_MAX_MS = 1500
|
||||||
|
|
||||||
|
|
||||||
|
def parse_proxy_url(proxy_url: str | None) -> dict[str, str] | None:
|
||||||
|
"""Распарсить ``http://user:pass@host:port`` в Playwright proxy-dict.
|
||||||
|
|
||||||
|
Playwright ждёт ``{"server": "http://host:port", "username": ..., "password": ...}``
|
||||||
|
(creds — отдельные ключи, НЕ в server-URL). Возвращает None для пустого/None
|
||||||
|
входа → вызывающий код запускает Chromium без прокси (поведение по умолчанию).
|
||||||
|
|
||||||
|
Тот же URL-формат, что использует tradein-стек (SCRAPER_PROXY_URL).
|
||||||
|
"""
|
||||||
|
if not proxy_url:
|
||||||
|
return None
|
||||||
|
parts = urlsplit(proxy_url)
|
||||||
|
if not parts.hostname:
|
||||||
|
# НЕ эхо-им сырой proxy_url — он содержит пароль, а это исключение
|
||||||
|
# всплывает в kn_scrape_runs.error / log_progress / Sentry (#1945 sec-review).
|
||||||
|
raise ValueError(f"proxy URL без host (scheme={parts.scheme!r})")
|
||||||
|
scheme = parts.scheme or "http"
|
||||||
|
server = f"{scheme}://{parts.hostname}"
|
||||||
|
if parts.port:
|
||||||
|
server += f":{parts.port}"
|
||||||
|
out: dict[str, str] = {"server": server}
|
||||||
|
if parts.username:
|
||||||
|
out["username"] = parts.username
|
||||||
|
if parts.password:
|
||||||
|
out["password"] = parts.password
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# Маппинг region_code → URL-сегмент города для реалистичного Referer.
|
# Маппинг region_code → URL-сегмент города для реалистичного Referer.
|
||||||
# Не обязан быть исчерпывающим — fallback на /новостройки/строящиеся/.
|
# Не обязан быть исчерпывающим — fallback на /новостройки/строящиеся/.
|
||||||
REGION_LANDING_PATH = {
|
REGION_LANDING_PATH = {
|
||||||
|
|
@ -94,6 +133,10 @@ class BrowserSession:
|
||||||
auth: str | None = "Basic MTpxd2U=",
|
auth: str | None = "Basic MTpxd2U=",
|
||||||
load_state: str | None = None,
|
load_state: str | None = None,
|
||||||
save_state: str | None = None,
|
save_state: str | None = None,
|
||||||
|
concurrency: int | None = None,
|
||||||
|
jitter_min_ms: int | None = None,
|
||||||
|
jitter_max_ms: int | None = None,
|
||||||
|
proxy_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.region_code = region_code
|
self.region_code = region_code
|
||||||
self.headed = headed
|
self.headed = headed
|
||||||
|
|
@ -105,11 +148,20 @@ class BrowserSession:
|
||||||
# so it can be committed and reused on a server.
|
# so it can be committed and reused on a server.
|
||||||
self.load_state = load_state
|
self.load_state = load_state
|
||||||
self.save_state = save_state
|
self.save_state = save_state
|
||||||
|
# Per-instance concurrency (#1945): KN-sweep throttles to 2 against the
|
||||||
|
# volume-ban; catalog/nspd callers pass nothing → keep the module default
|
||||||
|
# _BROWSER_CONCURRENCY=8 (их поведение НЕ меняется).
|
||||||
|
self._concurrency = concurrency if concurrency is not None else _BROWSER_CONCURRENCY
|
||||||
|
# Per-instance inter-request jitter window (#1945). None → module default.
|
||||||
|
self._jitter_min_ms = jitter_min_ms if jitter_min_ms is not None else _DEFAULT_JITTER_MIN_MS
|
||||||
|
self._jitter_max_ms = jitter_max_ms if jitter_max_ms is not None else _DEFAULT_JITTER_MAX_MS
|
||||||
|
# Optional rotating proxy (#1945). None → Chromium launches direct (default).
|
||||||
|
self._proxy = parse_proxy_url(proxy_url)
|
||||||
self._pw: Any = None
|
self._pw: Any = None
|
||||||
self._browser: Browser | None = None
|
self._browser: Browser | None = None
|
||||||
self._context: BrowserContext | None = None
|
self._context: BrowserContext | None = None
|
||||||
self._page: Page | None = None
|
self._page: Page | None = None
|
||||||
self._sem = asyncio.Semaphore(_BROWSER_CONCURRENCY)
|
self._sem = asyncio.Semaphore(self._concurrency)
|
||||||
self._request_count = 0
|
self._request_count = 0
|
||||||
self._warmed_up = False
|
self._warmed_up = False
|
||||||
|
|
||||||
|
|
@ -127,12 +179,19 @@ class BrowserSession:
|
||||||
landing_url = make_referer(self.region_code)
|
landing_url = make_referer(self.region_code)
|
||||||
logger.info("bootstrap: opening %s", landing_url)
|
logger.info("bootstrap: opening %s", landing_url)
|
||||||
self._pw = await async_playwright().start()
|
self._pw = await async_playwright().start()
|
||||||
self._browser = await self._pw.chromium.launch(headless=not self.headed)
|
launch_kwargs: dict[str, Any] = {"headless": not self.headed}
|
||||||
ctx_kwargs: dict[str, Any] = {
|
ctx_kwargs: dict[str, Any] = {
|
||||||
"user_agent": USER_AGENT,
|
"user_agent": USER_AGENT,
|
||||||
"locale": "ru-RU",
|
"locale": "ru-RU",
|
||||||
"viewport": {"width": 1920, "height": 1080},
|
"viewport": {"width": 1920, "height": 1080},
|
||||||
}
|
}
|
||||||
|
if self._proxy is not None:
|
||||||
|
# Прокси задаётся и на launch, и на context (Playwright требует на
|
||||||
|
# browser-level для Chromium; context-level дублирует для надёжности).
|
||||||
|
launch_kwargs["proxy"] = self._proxy
|
||||||
|
ctx_kwargs["proxy"] = self._proxy
|
||||||
|
logger.info("bootstrap: routing Chromium through proxy %s", self._proxy["server"])
|
||||||
|
self._browser = await self._pw.chromium.launch(**launch_kwargs)
|
||||||
if self.load_state:
|
if self.load_state:
|
||||||
ctx_kwargs["storage_state"] = self.load_state
|
ctx_kwargs["storage_state"] = self.load_state
|
||||||
logger.info("bootstrap: loading saved storage_state from %s", self.load_state)
|
logger.info("bootstrap: loading saved storage_state from %s", self.load_state)
|
||||||
|
|
@ -193,7 +252,7 @@ class BrowserSession:
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
for attempt in range(5):
|
for attempt in range(5):
|
||||||
async with self._sem:
|
async with self._sem:
|
||||||
await jitter_sleep()
|
await jitter_sleep(self._jitter_min_ms, self._jitter_max_ms)
|
||||||
try:
|
try:
|
||||||
self._request_count += 1
|
self._request_count += 1
|
||||||
result = await self._page.evaluate(_FETCH_JS, {"url": url, "auth": self.auth})
|
result = await self._page.evaluate(_FETCH_JS, {"url": url, "auth": self.auth})
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -449,16 +449,42 @@ _COMPETITORS_SQL = text("""
|
||||||
# scraper UPSERT per snapshot). Без фильтра snapshot_date AVG усреднял
|
# scraper UPSERT per snapshot). Без фильтра snapshot_date AVG усреднял
|
||||||
# ИСТОРИЮ цен (stale на растущем рынке). Берём ТОЛЬКО latest snapshot — зеркало
|
# ИСТОРИЮ цен (stale на растущем рынке). Берём ТОЛЬКО latest snapshot — зеркало
|
||||||
# паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON.
|
# паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON.
|
||||||
|
#
|
||||||
|
# === PER-OBJECT snapshot fix (#2445 A5, byte-identical bug к #1956 в best_layouts) ===
|
||||||
|
# domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в СВОЙ день,
|
||||||
|
# UNIQUE-снимок (obj_id, snapshot_date), см. migration 174_domrf_kn_flats_obj_
|
||||||
|
# snapshot_idx.sql. Прежний фильтр брал ОДНУ глобальную дату
|
||||||
|
# `f.snapshot_date = MAX(snapshot_date по всей таблице)` — на этой единственной
|
||||||
|
# глобал-max дате присутствует обычно лишь 1 объект (скрейпнутый последним) →
|
||||||
|
# у ВСЕХ остальных конкурентов в радиусе 0 строк flats → avg_price_per_m2=NULL
|
||||||
|
# → _price_similarity(None, ...) молча возвращает нейтраль 0.5, тихо деградируя
|
||||||
|
# relevance-ранжирование конкурентов без единого флага.
|
||||||
|
# Фикс (зеркало best_layouts.flats_latest, #1956): flats_latest берёт для КАЖДОГО
|
||||||
|
# obj_id его СОБСТВЕННЫЙ последний снимок (DISTINCT ON (f.obj_id) ... ORDER BY
|
||||||
|
# f.obj_id, snapshot_date DESC, id DESC), затем self-join к domrf_kn_flats по
|
||||||
|
# (obj_id, snapshot_date) достаёт квартиры именно этого снимка. AVG считается
|
||||||
|
# per-object на СВЕЖЕЙ дате объекта, а не на единственной глобал-max дате.
|
||||||
|
# Слоение с OBJ-3 (#307) не меняется: этот запрос остаётся ПЕРВЫМ источником
|
||||||
|
# (domrf), _OBJECTIVE_PRICE_FALLBACK_SQL — additive fallback ТОЛЬКО для obj_id,
|
||||||
|
# у которых и после фикса нет domrf-цены (см. get_competitors шаг 4a). Ожидаемый
|
||||||
|
# эффект: domrf возвращает реальные цены для БОЛЬШЕ конкурентов → fallback нужен
|
||||||
|
# реже, но композиция (missing_price_ids / price_source) не меняется.
|
||||||
_AVG_PRICE_SQL = text("""
|
_AVG_PRICE_SQL = text("""
|
||||||
|
WITH flats_latest AS (
|
||||||
|
SELECT DISTINCT ON (f.obj_id)
|
||||||
|
f.obj_id, f.snapshot_date
|
||||||
|
FROM domrf_kn_flats f
|
||||||
|
WHERE f.obj_id = ANY(:obj_ids)
|
||||||
|
ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
f.obj_id,
|
f.obj_id,
|
||||||
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
|
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
|
||||||
AS avg_price_per_m2
|
AS avg_price_per_m2
|
||||||
FROM domrf_kn_flats f
|
FROM flats_latest fl
|
||||||
WHERE f.obj_id = ANY(:obj_ids)
|
JOIN domrf_kn_flats f
|
||||||
AND f.snapshot_date = (
|
ON f.obj_id = fl.obj_id
|
||||||
SELECT MAX(snapshot_date) FROM domrf_kn_flats
|
AND f.snapshot_date = fl.snapshot_date
|
||||||
)
|
|
||||||
GROUP BY f.obj_id
|
GROUP BY f.obj_id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -494,12 +520,28 @@ _AVG_PRICE_SQL = text("""
|
||||||
# #1964: objective_lots — current-state UPSERT, но НЕ «ровно одна строка на лот».
|
# #1964: objective_lots — current-state UPSERT, но НЕ «ровно одна строка на лот».
|
||||||
# UNIQUE = objective_lot_id, а Объектив за пере-листинги присваивает ОДНОМУ
|
# UNIQUE = objective_lot_id, а Объектив за пере-листинги присваивает ОДНОМУ
|
||||||
# физлоту (project,corpus,section,floor,lot) НЕСКОЛЬКО objective_lot_id → таблица
|
# физлоту (project,corpus,section,floor,lot) НЕСКОЛЬКО objective_lot_id → таблица
|
||||||
# раздута ~2.91× → flats_sold завышался. Читаем из v_objective_lots_latest
|
# раздута ~2.91× → flats_sold завышался. Поэтому ОБЯЗАТЕЛЕН physflat-дедуп до
|
||||||
# (physflat-дедуп view, по строке на физлот). В view objective_lot_id уникален
|
# последнего снапшота на физлот (DISTINCT ON по physflat-ключу, snapshot_date DESC,
|
||||||
# (603k строк = 603k distinct lot_id), поэтому COUNT(DISTINCT ol.objective_lot_id)
|
# id DESC).
|
||||||
# == COUNT(*) по физлотам — НО оставляем DISTINCT: маппинг 1:N по domrf_obj_id
|
#
|
||||||
# (см. выше) при будущем fan-out задвоил бы строки view, COUNT(*) был бы не
|
# #1964→hunt #1953: РАНЬШЕ дедуп брался из v_objective_lots_latest (JOIN ON
|
||||||
# fan-out-safe. DISTINCT даёт И physflat-дедуп (через view), И fan-out-защиту.
|
# project_name). Регрессия: view материализует ВЕСЬ DISTINCT ON по 1.76M строк ДО
|
||||||
|
# того, как применится фильтр маппинга (cm.domrf_obj_id = ANY) — qual не
|
||||||
|
# проталкивается ниже DISTINCT ON view → Parallel Seq Scan + external sort всей
|
||||||
|
# таблицы (~213 MB temp, ~6896 ms на каждый cold /analyze), хотя нужны лоты лишь
|
||||||
|
# ~15 ЖК. FIX (зеркало #1964 inline-pushdown для analyze-консьюмеров +
|
||||||
|
# market_metrics._STOCK_SQL): сперва фильтруем objective_complex_mapping в CTE
|
||||||
|
# `mapped`, затем DISTINCT ON по СЫРОЙ objective_lots, JOIN ON project_name — так
|
||||||
|
# objective_lots_project_idx (project_name, snapshot_date DESC) обслуживает фильтр
|
||||||
|
# и дедуп материализуется ТОЛЬКО по лотам нужных ЖК. Прод-EXPLAIN: 6896 → ~437 ms
|
||||||
|
# (15.7×), вывод побайтово идентичен (hunt: 7082=7082, 0 mismatch).
|
||||||
|
#
|
||||||
|
# COUNT(DISTINCT objective_lot_id) сохраняем (fan-out-safety из #1964): маппинг 1:N
|
||||||
|
# по domrf_obj_id (UNIQUE = (objective_complex_name, objective_group), см. выше) при
|
||||||
|
# fan-out задвоил бы строки, COUNT(*) был бы не fan-out-safe. DISTINCT ON даёт
|
||||||
|
# physflat-дедуп, COUNT(DISTINCT) — fan-out-защиту по маппингу.
|
||||||
|
#
|
||||||
|
# ⚠ Читаем СЫРУЮ objective_lots inline, НЕ v_objective_lots_latest (см. выше).
|
||||||
#
|
#
|
||||||
# premise_kind='квартира' — как в _STOCK_SQL: без него проданные паркинги/
|
# premise_kind='квартира' — как в _STOCK_SQL: без него проданные паркинги/
|
||||||
# кладовые/нежилое считались бы как проданные квартиры (over-count).
|
# кладовые/нежилое считались бы как проданные квартиры (over-count).
|
||||||
|
|
@ -508,19 +550,35 @@ _AVG_PRICE_SQL = text("""
|
||||||
# (premise_kind="квартира") — оба считают продажи жилых лотов.
|
# (premise_kind="квартира") — оба считают продажи жилых лотов.
|
||||||
_SOLD_PREMISE_KIND = "квартира"
|
_SOLD_PREMISE_KIND = "квартира"
|
||||||
_SOLD_COUNT_SQL = text("""
|
_SOLD_COUNT_SQL = text("""
|
||||||
|
WITH mapped AS (
|
||||||
|
SELECT cm.domrf_obj_id, cm.objective_complex_name
|
||||||
|
FROM objective_complex_mapping cm
|
||||||
|
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||||
|
),
|
||||||
|
latest AS (
|
||||||
|
SELECT DISTINCT ON (
|
||||||
|
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
|
||||||
|
)
|
||||||
|
m.domrf_obj_id,
|
||||||
|
ol.objective_lot_id,
|
||||||
|
(
|
||||||
|
ol.is_sold IS TRUE
|
||||||
|
OR ol.contract_date IS NOT NULL
|
||||||
|
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
||||||
|
) AS sold_now
|
||||||
|
FROM mapped m
|
||||||
|
JOIN objective_lots ol
|
||||||
|
ON ol.project_name = m.objective_complex_name
|
||||||
|
WHERE ol.premise_kind = :premise_kind
|
||||||
|
ORDER BY
|
||||||
|
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||||
|
ol.snapshot_date DESC, ol.id DESC
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
cm.domrf_obj_id AS obj_id,
|
domrf_obj_id AS obj_id,
|
||||||
COUNT(DISTINCT ol.objective_lot_id) FILTER (
|
COUNT(DISTINCT objective_lot_id) FILTER (WHERE sold_now) AS flats_sold
|
||||||
WHERE ol.is_sold IS TRUE
|
FROM latest
|
||||||
OR ol.contract_date IS NOT NULL
|
GROUP BY domrf_obj_id
|
||||||
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
|
||||||
) AS flats_sold
|
|
||||||
FROM objective_complex_mapping cm
|
|
||||||
JOIN v_objective_lots_latest ol
|
|
||||||
ON ol.project_name = cm.objective_complex_name
|
|
||||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
|
||||||
AND ol.premise_kind = :premise_kind
|
|
||||||
GROUP BY cm.domrf_obj_id
|
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
|
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
|
||||||
|
|
@ -741,7 +799,12 @@ def get_competitors(
|
||||||
avg_price_map: dict[int, float] = {}
|
avg_price_map: dict[int, float] = {}
|
||||||
price_source_map: dict[int, str] = {}
|
price_source_map: dict[int, str] = {}
|
||||||
try:
|
try:
|
||||||
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
# SAVEPOINT — db shared с caller (POST .../competitors, Depends(get_db)). Голый
|
||||||
|
# db.rollback() orphan'ит outer SessionTransaction (velocity.py:170) — тем более
|
||||||
|
# здесь ЕЩЁ ДВА db.execute идут следом в этой же функции (sold-count,
|
||||||
|
# objective-fallback) на том же db; без savepoint сбой здесь отравил бы их тоже.
|
||||||
|
with db.begin_nested():
|
||||||
|
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
||||||
for r in price_rows:
|
for r in price_rows:
|
||||||
oid = int(r["obj_id"])
|
oid = int(r["obj_id"])
|
||||||
price = _row_get(r, "avg_price_per_m2")
|
price = _row_get(r, "avg_price_per_m2")
|
||||||
|
|
@ -760,11 +823,16 @@ def get_competitors(
|
||||||
# flats_sold → None → нейтральный stage.
|
# flats_sold → None → нейтральный stage.
|
||||||
sold_count_map: dict[int, int] = {}
|
sold_count_map: dict[int, int] = {}
|
||||||
try:
|
try:
|
||||||
sold_rows = (
|
# SAVEPOINT — db shared, см. avg_price блок выше (иначе отравляет objective
|
||||||
db.execute(_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND})
|
# price fallback ниже на том же db).
|
||||||
.mappings()
|
with db.begin_nested():
|
||||||
.all()
|
sold_rows = (
|
||||||
)
|
db.execute(
|
||||||
|
_SOLD_COUNT_SQL, {"obj_ids": obj_ids, "premise_kind": _SOLD_PREMISE_KIND}
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
for r in sold_rows:
|
for r in sold_rows:
|
||||||
oid = int(r["obj_id"])
|
oid = int(r["obj_id"])
|
||||||
sold = _row_get(r, "flats_sold")
|
sold = _row_get(r, "flats_sold")
|
||||||
|
|
@ -780,17 +848,21 @@ def get_competitors(
|
||||||
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
|
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
|
||||||
if missing_price_ids:
|
if missing_price_ids:
|
||||||
try:
|
try:
|
||||||
obj_price_rows = (
|
# SAVEPOINT — db shared, см. avg_price блок выше. Последний из трёх
|
||||||
db.execute(
|
# db.execute в get_competitors — тоже под savepoint, чтобы дальнейшие
|
||||||
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
# запросы analyze на этом же db (persist_analysis_run и т.п.) не отравились.
|
||||||
{
|
with db.begin_nested():
|
||||||
"obj_ids": missing_price_ids,
|
obj_price_rows = (
|
||||||
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
db.execute(
|
||||||
},
|
_OBJECTIVE_PRICE_FALLBACK_SQL,
|
||||||
|
{
|
||||||
|
"obj_ids": missing_price_ids,
|
||||||
|
"velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
for r in obj_price_rows:
|
for r in obj_price_rows:
|
||||||
oid = int(r["obj_id"])
|
oid = int(r["obj_id"])
|
||||||
price = _row_get(r, "median_price_per_m2")
|
price = _row_get(r, "median_price_per_m2")
|
||||||
|
|
|
||||||
634
backend/app/services/site_finder/connection_capacity_lookup.py
Normal file
634
backend/app/services/site_finder/connection_capacity_lookup.py
Normal file
|
|
@ -0,0 +1,634 @@
|
||||||
|
"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119).
|
||||||
|
|
||||||
|
Собирает по участку:
|
||||||
|
- power_points: центры питания (power_supply_centers) в радиусе от центроида
|
||||||
|
участка (ST_DWithin geography) с классом напряжения / индексом загрузки /
|
||||||
|
резервом свободной мощности. Для ПС ЕЭСК без раскрытого резерва — reserve_note
|
||||||
|
«резерв по запросу в АО «ЕЭСК» (город)».
|
||||||
|
- power_summary: ближайший ЦП с резервом + count по load_index.
|
||||||
|
- water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
|
||||||
|
- gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью
|
||||||
|
(gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1.
|
||||||
|
+ счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС №960 АО
|
||||||
|
«Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен
|
||||||
|
гидрорасчёт». #2119 B2 PR-4.
|
||||||
|
- gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от
|
||||||
|
участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) — для слоя
|
||||||
|
на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2.
|
||||||
|
- heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves)
|
||||||
|
за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2.
|
||||||
|
- nearby_network_zones: ПОЗИТИВ-разрез — сетевые охранные зоны из cad_zouit в радиусе
|
||||||
|
300 м от участка (там физически ПРОХОДИТ сеть → «теплотрасса в 24 м, врезка
|
||||||
|
дешёвая»). До 2 ближайших зон НА КАЖДЫЙ net_kind (thermal/electricity/gas/…), с
|
||||||
|
гуманизированной подписью (кад.номер трубопровода / напряжение ВЛ + ТП). #2119 C2/C3.
|
||||||
|
|
||||||
|
Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал
|
||||||
|
fallback). Радиус по умолчанию 3000 м (cap 10000).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.site_finder.network_zone_humanize import human_label, refine_net_kind
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_RADIUS_M = 3000
|
||||||
|
_MAX_RADIUS_M = 10000
|
||||||
|
|
||||||
|
# Радиус позитив-разреза сетевых охранных зон (cad_zouit): фиксированный 300 м — «сеть
|
||||||
|
# рядом с участком». Не зависит от radius_m ЦП (тот про магистральные ЦП за км).
|
||||||
|
# Degree-эквивалент для GIST-префильтра: 300 м / ~60.8 км/° долготы у ЕКБ (56.8° N) ≈
|
||||||
|
# 0.0050° — берём с запасом 0.006°, точный отсев делает geography ST_DWithin поверх. C2.
|
||||||
|
_NETWORK_ZONE_RADIUS_M = 300
|
||||||
|
_NETWORK_ZONE_RADIUS_DEG = 0.006
|
||||||
|
# До скольких ближайших зон отдаём НА КАЖДЫЙ net_kind (thermal/electricity/gas/…). C2.
|
||||||
|
_NETWORK_ZONE_PER_KIND = 2
|
||||||
|
# Предохранитель: запрос GIST-индексирован (~70 мс на плотном центре ЕКБ, EXPLAIN прод
|
||||||
|
# 2026-07-03), но эндпоинт синхронный — таймаут-guard как у offer_trend (parcels.py).
|
||||||
|
_NETWORK_ZONE_TIMEOUT_MS = 4000
|
||||||
|
|
||||||
|
# Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м —
|
||||||
|
# точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным
|
||||||
|
# ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию,
|
||||||
|
# чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2.
|
||||||
|
_GAS_OUTLET_RADIUS_M = 2000
|
||||||
|
_GAS_OUTLET_LIMIT = 40
|
||||||
|
|
||||||
|
# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная
|
||||||
|
# мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1.
|
||||||
|
_EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)"
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection_capacity(
|
||||||
|
db: Session,
|
||||||
|
cad_num: str,
|
||||||
|
radius_m: int = _DEFAULT_RADIUS_M,
|
||||||
|
) -> dict:
|
||||||
|
"""Точки подключения со свободной мощностью вблизи участка.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy session.
|
||||||
|
cad_num: кадастровый номер участка.
|
||||||
|
radius_m: радиус поиска ЦП, м (caller клампит 1..10000).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"power_points": [...], "power_summary": {...}, "water": [...],
|
||||||
|
"gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None,
|
||||||
|
"nearby_network_zones": [...]}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: участок не найден в БД (нет geom).
|
||||||
|
"""
|
||||||
|
# Lazy import — избегаем циклического импорта на module-load.
|
||||||
|
from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt
|
||||||
|
|
||||||
|
radius_m = max(1, min(int(radius_m), _MAX_RADIUS_M))
|
||||||
|
|
||||||
|
parcel_wkt = _get_parcel_wkt(db, cad_num)
|
||||||
|
if parcel_wkt is None:
|
||||||
|
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
||||||
|
|
||||||
|
power_points = _query_power_points(db, parcel_wkt, radius_m)
|
||||||
|
power_summary = _build_power_summary(power_points)
|
||||||
|
water = _query_water_latest(db)
|
||||||
|
gas = _query_gas_city_grs(db)
|
||||||
|
gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt)
|
||||||
|
heat = _query_heat_latest(db)
|
||||||
|
nearby_network_zones = _query_nearby_network_zones(db, parcel_wkt)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"power_points": power_points,
|
||||||
|
"power_summary": power_summary,
|
||||||
|
"water": water,
|
||||||
|
"gas": gas,
|
||||||
|
"gas_outlet_points": gas_outlet_points,
|
||||||
|
"heat": heat,
|
||||||
|
"nearby_network_zones": nearby_network_zones,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]:
|
||||||
|
"""Центры питания в радиусе от центроида участка (ST_DWithin geography)."""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT sc_name, dzo_name, voltage_class, load_index,
|
||||||
|
installed_capacity_mva, current_load_mva, reserve_mva,
|
||||||
|
reserve_unit, district, reserve_asof,
|
||||||
|
ST_Distance(
|
||||||
|
geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
|
) AS distance_m,
|
||||||
|
ST_Y(geom) AS lat,
|
||||||
|
ST_X(geom) AS lon
|
||||||
|
FROM power_supply_centers
|
||||||
|
WHERE geom IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
CAST(:radius_m AS float)
|
||||||
|
)
|
||||||
|
ORDER BY distance_m ASC
|
||||||
|
"""),
|
||||||
|
{"wkt": parcel_wkt, "radius_m": radius_m},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
points: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
reserve_mva = _num(r["reserve_mva"])
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"name": r["sc_name"],
|
||||||
|
"dzo_name": r["dzo_name"],
|
||||||
|
"voltage_class": r["voltage_class"],
|
||||||
|
"load_index": r["load_index"],
|
||||||
|
"installed_capacity_mva": _num(r["installed_capacity_mva"]),
|
||||||
|
"current_load_mva": _num(r["current_load_mva"]),
|
||||||
|
"reserve_mva": reserve_mva,
|
||||||
|
# Единица резерва: 'МВА' (Россети, default) или 'МВт' (ЕЭСК ПС 35-220).
|
||||||
|
# district — адм. район ЕКБ из xlsx ЕЭСК (NULL у Россети-строк). #2119 B2.
|
||||||
|
"reserve_unit": r["reserve_unit"],
|
||||||
|
"district": r["district"],
|
||||||
|
"reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None,
|
||||||
|
"reserve_note": _reserve_note(r["dzo_name"], reserve_mva),
|
||||||
|
"distance_m": round(float(r["distance_m"]), 1),
|
||||||
|
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
|
||||||
|
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _reserve_note(dzo_name: str | None, reserve_mva: float | None) -> str | None:
|
||||||
|
"""Пометка резерва для ПС ЕЭСК без опубликованного резерва (#2119 B1).
|
||||||
|
|
||||||
|
Для ПС АО «ЕЭСК» (городская сеть ЕКБ) свободная мощность не раскрывается в
|
||||||
|
WFS/xlsx-источниках → reserve_mva IS NULL. Показываем честную пометку «резерв по
|
||||||
|
запросу». Для ПС других ДЗО (или ЕЭСК с известным резервом) → None.
|
||||||
|
"""
|
||||||
|
if reserve_mva is not None:
|
||||||
|
return None
|
||||||
|
if dzo_name and "еэск" in dzo_name.lower():
|
||||||
|
return _EESK_RESERVE_NOTE
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_power_summary(points: list[dict]) -> dict:
|
||||||
|
"""Сводка: ближайший ЦП с положительным резервом + count по load_index."""
|
||||||
|
by_load_index: dict[str, int] = {}
|
||||||
|
for p in points:
|
||||||
|
li = p["load_index"] or "unknown"
|
||||||
|
by_load_index[li] = by_load_index.get(li, 0) + 1
|
||||||
|
|
||||||
|
# points уже отсортированы по distance ASC → первый с reserve_mva>0 = ближайший.
|
||||||
|
nearest_with_reserve = next(
|
||||||
|
(p for p in points if p["reserve_mva"] is not None and p["reserve_mva"] > 0),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_power_points": len(points),
|
||||||
|
"by_load_index": by_load_index,
|
||||||
|
"nearest_with_reserve": nearest_with_reserve,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_water_latest(db: Session) -> list[dict]:
|
||||||
|
"""Все строки water_supply_reserves за ПОСЛЕДНИЙ period ПО КАЖДОМУ system_kind.
|
||||||
|
|
||||||
|
«Последний» — max(period) лексикографически ('2026-Q1' < '2026-Q2' < …),
|
||||||
|
что для формата 'YYYY-Qn' совпадает с хронологией. period IS NULL исключаем.
|
||||||
|
MAX(period) берём per-system_kind (коррелированный подзапрос): Водоканал может
|
||||||
|
опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX
|
||||||
|
молча выкинул бы отстающий вид целиком.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
|
||||||
|
w.note, w.period
|
||||||
|
FROM water_supply_reserves w
|
||||||
|
WHERE w.period IS NOT NULL
|
||||||
|
AND w.period = (
|
||||||
|
SELECT MAX(w2.period) FROM water_supply_reserves w2
|
||||||
|
WHERE w2.period IS NOT NULL
|
||||||
|
AND w2.system_kind = w.system_kind
|
||||||
|
)
|
||||||
|
ORDER BY w.system_kind, w.system_name
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"system_kind": r["system_kind"],
|
||||||
|
"system_name": r["system_name"],
|
||||||
|
"reserve_thousand_m3_day": _num(r["reserve_thousand_m3_day"]),
|
||||||
|
"note": r["note"],
|
||||||
|
"period": r["period"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _query_gas_city_grs(db: Session) -> dict:
|
||||||
|
"""Городские ГРС ЕКБ со свободной мощностью (gas_grs_capacity), агрегация по ГРС.
|
||||||
|
|
||||||
|
Фильтр «городских» ГРС по имени (ILIKE '%свердловск%' / '%екатеринбург%').
|
||||||
|
Агрегация по grs_name (у ГРС несколько строк-выходов): SUM(design), SUM(free),
|
||||||
|
AVG(pct), MAX(upgrade_due), COUNT(*) outputs_count. Пустая/несуществующая таблица
|
||||||
|
→ city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не
|
||||||
|
существовать до применения миграции 181).
|
||||||
|
|
||||||
|
Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из
|
||||||
|
gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний
|
||||||
|
period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc.
|
||||||
|
См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС».
|
||||||
|
|
||||||
|
Returns: {"city_grs": [...], "total_free_th_m3_h": float|None,
|
||||||
|
"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
|
||||||
|
Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода
|
||||||
|
(в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации.
|
||||||
|
"""
|
||||||
|
outlet_counts = _query_gas_outlet_counts(db)
|
||||||
|
try:
|
||||||
|
# SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
|
||||||
|
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||||
|
# begin_nested откатывает только эту savepoint, outer tx остаётся usable.
|
||||||
|
with db.begin_nested():
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT grs_name,
|
||||||
|
SUM(design_capacity_th_m3_h) AS design_sum,
|
||||||
|
SUM(free_capacity_th_m3_h) AS free_sum,
|
||||||
|
AVG(free_capacity_pct) AS pct_avg,
|
||||||
|
MAX(upgrade_due) AS upgrade_due,
|
||||||
|
COUNT(*) AS outputs_count
|
||||||
|
FROM gas_grs_capacity
|
||||||
|
WHERE grs_name ILIKE '%свердловск%'
|
||||||
|
OR grs_name ILIKE '%екатеринбург%'
|
||||||
|
GROUP BY grs_name
|
||||||
|
ORDER BY grs_name
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Таблица может не существовать до применения миграции 181 (schema-first).
|
||||||
|
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
|
||||||
|
# точек выхода (миграция 184, независимая) — оставляем, что удалось собрать.
|
||||||
|
logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e)
|
||||||
|
return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts}
|
||||||
|
|
||||||
|
city_grs: list[dict] = []
|
||||||
|
total_free = 0.0
|
||||||
|
has_free = False
|
||||||
|
for r in rows:
|
||||||
|
free = _num(r["free_sum"])
|
||||||
|
if free is not None:
|
||||||
|
total_free += free
|
||||||
|
has_free = True
|
||||||
|
city_grs.append(
|
||||||
|
{
|
||||||
|
"grs_name": r["grs_name"],
|
||||||
|
"design_capacity_th_m3_h": _num(r["design_sum"]),
|
||||||
|
"free_capacity_th_m3_h": free,
|
||||||
|
"free_capacity_pct": (
|
||||||
|
round(float(r["pct_avg"]), 1) if r["pct_avg"] is not None else None
|
||||||
|
),
|
||||||
|
"upgrade_due": r["upgrade_due"],
|
||||||
|
"outputs_count": int(r["outputs_count"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"city_grs": city_grs,
|
||||||
|
"total_free_th_m3_h": round(total_free, 3) if has_free else None,
|
||||||
|
**outlet_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_gas_outlet_counts(db: Session) -> dict:
|
||||||
|
"""Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month.
|
||||||
|
|
||||||
|
По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в
|
||||||
|
форме 6 АО «Екатеринбурггаз» — это block-marker «Точка входа» (5 блоков ГТЕ вроде
|
||||||
|
«грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой
|
||||||
|
источник). Матч outlet→ГРС по grs_name_norm без FK ненадёжен на per-набор уровне
|
||||||
|
(имена ГРС в двух раскрытиях расходятся) → честнее агрегировать счётчики ПО ГОРОДУ
|
||||||
|
целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3
|
||||||
|
«сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС.
|
||||||
|
|
||||||
|
Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем —
|
||||||
|
в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза.
|
||||||
|
|
||||||
|
Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
|
||||||
|
- outlets_deficit — free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС);
|
||||||
|
- outlets_needs_calc — free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт).
|
||||||
|
Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||||||
|
with db.begin_nested():
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT COUNT(*) AS outlets_total,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE free_capacity_mln_m3 < 0
|
||||||
|
) AS outlets_deficit,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE free_capacity_needs_calc
|
||||||
|
) AS outlets_needs_calc
|
||||||
|
FROM gas_grs_outlet_points
|
||||||
|
WHERE period_month = (
|
||||||
|
SELECT MAX(period_month) FROM gas_grs_outlet_points
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Таблица может не существовать до применения миграции 184 (schema-first).
|
||||||
|
# Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
|
||||||
|
logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e)
|
||||||
|
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
|
||||||
|
return {
|
||||||
|
"outlets_total": int(row["outlets_total"] or 0),
|
||||||
|
"outlets_deficit": int(row["outlets_deficit"] or 0),
|
||||||
|
"outlets_needs_calc": int(row["outlets_needs_calc"] or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
|
||||||
|
"""Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3.
|
||||||
|
|
||||||
|
ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний
|
||||||
|
period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19%
|
||||||
|
строк (геокод DaData, PR-3) → в радиус попадает малое подмножество. Размерность
|
||||||
|
free_capacity_mln_m3 — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть
|
||||||
|
(миграция 184) → [] (graceful, блок аддитивный).
|
||||||
|
|
||||||
|
План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс — geography-каст НЕ использует
|
||||||
|
geometry-GIST; при 1037 строках это норм. Вырастет 10-50× → функциональный GIST
|
||||||
|
по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом.
|
||||||
|
|
||||||
|
Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc,
|
||||||
|
distance_m, lat, lon}] — отсортировано по distance ASC.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||||||
|
with db.begin_nested():
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
|
||||||
|
free_capacity_needs_calc AS needs_calc,
|
||||||
|
ST_Distance(
|
||||||
|
geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
|
) AS distance_m,
|
||||||
|
ST_Y(geom) AS lat,
|
||||||
|
ST_X(geom) AS lon
|
||||||
|
FROM gas_grs_outlet_points
|
||||||
|
WHERE geom IS NOT NULL
|
||||||
|
AND period_month = (
|
||||||
|
SELECT MAX(period_month) FROM gas_grs_outlet_points
|
||||||
|
)
|
||||||
|
AND ST_DWithin(
|
||||||
|
geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
CAST(:radius_m AS float)
|
||||||
|
)
|
||||||
|
ORDER BY distance_m ASC
|
||||||
|
LIMIT CAST(:limit AS int)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"wkt": parcel_wkt,
|
||||||
|
"radius_m": _GAS_OUTLET_RADIUS_M,
|
||||||
|
"limit": _GAS_OUTLET_LIMIT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Таблица может не существовать до применения миграции 184 (schema-first).
|
||||||
|
# Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||||||
|
logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"outlet_name": r["outlet_name"],
|
||||||
|
"consumer_type": r["consumer_type"],
|
||||||
|
# млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком.
|
||||||
|
"free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]),
|
||||||
|
"needs_calc": bool(r["needs_calc"]),
|
||||||
|
"distance_m": round(float(r["distance_m"]), 1),
|
||||||
|
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
|
||||||
|
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _query_heat_latest(db: Session) -> dict:
|
||||||
|
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
|
||||||
|
|
||||||
|
Строки heat_system_reserves за MAX(period) PER org (коррелированный подзапрос —
|
||||||
|
как в _query_water_latest: одна организация может опубликовать свежий квартал
|
||||||
|
раньше другой, глобальный MAX молча выкинул бы отстающую). period IS NULL
|
||||||
|
исключаем; MAX(period) лексикографически совпадает с хронологией для 'YYYY-Qn'.
|
||||||
|
Сортировка org, system_name. Graceful при отсутствии таблицы (как gas-блок).
|
||||||
|
|
||||||
|
Returns: {"systems": [{org, system_name, reserve_gcal_h, period}],
|
||||||
|
"total_reserve_gcal_h": float|None}.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
|
||||||
|
with db.begin_nested():
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
|
||||||
|
FROM heat_system_reserves h
|
||||||
|
WHERE h.period IS NOT NULL
|
||||||
|
AND h.period = (
|
||||||
|
SELECT MAX(h2.period) FROM heat_system_reserves h2
|
||||||
|
WHERE h2.period IS NOT NULL
|
||||||
|
AND h2.org = h.org
|
||||||
|
)
|
||||||
|
ORDER BY h.org, h.system_name
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Таблица может не существовать до применения миграции 182 (schema-first).
|
||||||
|
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||||||
|
logger.warning("heat_system_reserves недоступна (миграция 182 не применена?): %s", e)
|
||||||
|
return {"systems": [], "total_reserve_gcal_h": None}
|
||||||
|
|
||||||
|
systems: list[dict] = []
|
||||||
|
total_reserve = 0.0
|
||||||
|
has_reserve = False
|
||||||
|
for r in rows:
|
||||||
|
reserve = _num(r["reserve_gcal_h"])
|
||||||
|
if reserve is not None:
|
||||||
|
total_reserve += reserve
|
||||||
|
has_reserve = True
|
||||||
|
systems.append(
|
||||||
|
{
|
||||||
|
"org": r["org"],
|
||||||
|
"system_name": r["system_name"],
|
||||||
|
"reserve_gcal_h": reserve,
|
||||||
|
"period": r["period"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"systems": systems,
|
||||||
|
"total_reserve_gcal_h": round(total_reserve, 3) if has_reserve else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_nearby_network_zones(db: Session, parcel_wkt: str) -> list[dict]:
|
||||||
|
"""Сетевые охранные зоны cad_zouit в радиусе 300 м от участка — позитив-развёртка §3.
|
||||||
|
|
||||||
|
Охранная зона сети = коридор, где физически ПРОХОДИТ сеть. «Теплотрасса в 24 м →
|
||||||
|
врезка дешёвая» — решающий сигнал для финдиректора (особенно heat, у которого в §3
|
||||||
|
нет своей гео-привязки). Зоны, ПЕРЕСЕКАЮЩИЕ участок, включаем с distance_m=0.
|
||||||
|
|
||||||
|
План (EXPLAIN прод 2026-07-03, cad_zouit_geom_gist существует): GIST-префильтр
|
||||||
|
``ST_DWithin(geom, parcel, 0.006°)`` + точный ``ST_DWithin(geom::geography,
|
||||||
|
parcel::geography, 300)`` — Index Scan, ~70 мс на плотном центре ЕКБ. cad_zouit.geom
|
||||||
|
= geometry(MultiPolygon,4326); geography-каст приклеен к ``)``, не к bind-имени
|
||||||
|
(psycopg3-safe, как ``ARRAY[...]::int[]`` / power_points-запрос).
|
||||||
|
|
||||||
|
#1957: одна физическая зона хранится 2× (одинаковый reg_numb_border, разный
|
||||||
|
category_name) → DISTINCT ON (reg_numb_border) схлопывает, оставляя ближайшую строку.
|
||||||
|
|
||||||
|
Гуманизация (C3, ``network_zone_humanize``): net_kind дочищается по name_by_doc
|
||||||
|
(general «инженерные коммуникации» на проде часто РЕАЛЬНО теплосеть/газопровод),
|
||||||
|
human_label вытаскивает кад.номер трубопровода / напряжение ВЛ + ТП. Итог — до
|
||||||
|
_NETWORK_ZONE_PER_KIND ближайших зон НА КАЖДЫЙ net_kind (сорт по distance ASC).
|
||||||
|
|
||||||
|
Таймаут-guard в savepoint: запрос лёгкий, но эндпоинт синхронный — при аномально
|
||||||
|
медленном скане (QueryCanceled) деградируем в [], не роняя весь эндпоинт. Блок
|
||||||
|
аддитивный (позитив-сигнал), отсутствие данных читается как «сеть рядом не найдена».
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
# SET не принимает bind-параметры (extended protocol → syntax error
|
||||||
|
# at or near "$1") — только литерал, как у offer_trend (parcels.py).
|
||||||
|
db.execute(text(f"SET LOCAL statement_timeout = '{_NETWORK_ZONE_TIMEOUT_MS:d}'"))
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT DISTINCT ON (z.reg_numb_border)
|
||||||
|
z.type_zone,
|
||||||
|
z.name_by_doc,
|
||||||
|
z.reg_numb_border,
|
||||||
|
round(
|
||||||
|
ST_Distance(
|
||||||
|
z.geom::geography,
|
||||||
|
ST_GeomFromText(:wkt, 4326)::geography
|
||||||
|
)::numeric
|
||||||
|
) AS distance_m
|
||||||
|
FROM cad_zouit z
|
||||||
|
WHERE z.geom IS NOT NULL
|
||||||
|
AND z.type_zone IN (
|
||||||
|
'Охранная зона тепловых сетей',
|
||||||
|
'Охранная зона инженерных коммуникаций',
|
||||||
|
'Охранная зона объектов электроэнергетики'
|
||||||
|
' (объектов электросетевого хозяйства и объектов по'
|
||||||
|
' производству электрической энергии)',
|
||||||
|
'Охранная зона трубопроводов (газопроводов,'
|
||||||
|
' нефтепроводов и нефтепродуктопроводов, аммиакопроводов)',
|
||||||
|
'Охранная зона линий и сооружений связи'
|
||||||
|
)
|
||||||
|
AND ST_DWithin(
|
||||||
|
z.geom,
|
||||||
|
ST_GeomFromText(:wkt, 4326),
|
||||||
|
CAST(:radius_deg AS float)
|
||||||
|
)
|
||||||
|
AND ST_DWithin(
|
||||||
|
z.geom::geography,
|
||||||
|
ST_GeomFromText(:wkt, 4326)::geography,
|
||||||
|
CAST(:radius_m AS float)
|
||||||
|
)
|
||||||
|
ORDER BY z.reg_numb_border, distance_m ASC
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"wkt": parcel_wkt,
|
||||||
|
"radius_deg": _NETWORK_ZONE_RADIUS_DEG,
|
||||||
|
"radius_m": _NETWORK_ZONE_RADIUS_M,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# SET LOCAL переживает RELEASE SAVEPOINT (transaction-scoped) —
|
||||||
|
# сбрасываем явно, как в offer_trend (parcels.py), чтобы таймаут
|
||||||
|
# не утёк на последующие запросы этой транзакции.
|
||||||
|
db.execute(text("SET LOCAL statement_timeout = 0"))
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
# QueryCanceled (таймаут) / отсутствие таблицы / прочие SQL-сбои — блок аддитивный
|
||||||
|
# (позитив-сигнал), деградируем в пустой, не роняя весь эндпоинт connection-capacity.
|
||||||
|
logger.warning("nearby_network_zones query failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return _shape_network_zones(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_network_zones(rows: list) -> list[dict]:
|
||||||
|
"""DISTINCT-строки cad_zouit → гуманизированные зоны, до N ближайших per net_kind.
|
||||||
|
|
||||||
|
Классификацию net_kind (refine) и подпись (human_label) считаем в Python (regex по
|
||||||
|
name_by_doc), затем сортируем по distance ASC и берём _NETWORK_ZONE_PER_KIND на вид.
|
||||||
|
Возвращаемый список отсортирован по (distance_m, net_kind) для стабильного порядка.
|
||||||
|
"""
|
||||||
|
per_kind: dict[str, list[dict]] = {}
|
||||||
|
for r in rows:
|
||||||
|
type_zone = r["type_zone"] or ""
|
||||||
|
name_by_doc = r["name_by_doc"]
|
||||||
|
net_kind = refine_net_kind(type_zone, name_by_doc)
|
||||||
|
if net_kind is None:
|
||||||
|
# type_zone из белого списка, но не классифицировался — пропускаем (не должно).
|
||||||
|
continue
|
||||||
|
distance_m = int(r["distance_m"]) if r["distance_m"] is not None else 0
|
||||||
|
per_kind.setdefault(net_kind, []).append(
|
||||||
|
{
|
||||||
|
"net_kind": net_kind,
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"name_by_doc": name_by_doc,
|
||||||
|
"human_label": human_label(net_kind, name_by_doc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result: list[dict] = []
|
||||||
|
for kind_rows in per_kind.values():
|
||||||
|
kind_rows.sort(key=lambda z: z["distance_m"])
|
||||||
|
result.extend(kind_rows[:_NETWORK_ZONE_PER_KIND])
|
||||||
|
# Стабильный внешний порядок: ближайшее сверху, затем по виду сети.
|
||||||
|
result.sort(key=lambda z: (z["distance_m"], z["net_kind"]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _num(value: object) -> float | None:
|
||||||
|
"""NUMERIC (Decimal) из БД → float | None."""
|
||||||
|
return float(value) if value is not None else None # type: ignore[arg-type]
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue