ПТИЦА: у изъятий на участке появляется номер постановления, а таблица не удваивается #3568

Merged
bot-backend merged 1 commit from fix/ptica-reservation-act-number into main 2026-09-17 09:16:10 +00:00
Collaborator

Refs #2982

Что было

На проде в land_reservation 27 строк, и act_number пуст у всех 27. Замер 17.09.2026, только SELECT: все строки из источника izyatie_ekb_ocr, 5 документов, count(DISTINCT cad_num) = 27.

Номера в документах есть, но регекс их не видел. Он требовал суффикс областного акта:

r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b"

У постановлений Администрации Екатеринбурга суффикса нет: № 1413, 259, 2687, 863, 1504. Тот же обязательный суффикс стоял во втором месте, в page_reservation_parser.py (об этом комментарий от 28.08).

Что сделано

1. izyatie_ocr.py: номер берётся у того же акта, что и дата. Просто сделать суффикс необязательным недостаточно. Первое «№» в теле документа относится к чужому акту: там стоят «Решение Думы … № 60/1» (Генплан) и «Приказ Министерства … № 746-П». Для даты это уже решено в #2981: берётся первое «от DD.MM.YYYY», перед которым стоит слово «постановлени». Теперь номер берётся из «№» вплотную после этой даты, а если там его нет, то вплотную перед ней («Постановление № 509-ПП от …»). Если рядом с датой номера нет, в поле пишется None, а не номер соседнего документа. Эту связку проверил мутацией. Если заменить её на «первое подходящее №», на двух прод-фрагментах вместо 1413 и 259 получается 746-П.

2. page_reservation_parser.py (pravo.gov66, ручной запуск): суффикс стал необязательным. Раньше номер без суффикса в шапке пропускался, и бралось следующее «№ …-ПП», то есть номер цитируемого постановления. Номер с суффиксом не из списка теперь отбрасывается целиком, а не обрезается: из «№ 218-ФЗ», «№ 746-П» и «№ 60/1» не получится ни «218», ни «746», ни «60».

3. izyatie_ocr_ingest.py: прогон не удваивает таблицу. act_number входит в ключ UNIQUE NULLS NOT DISTINCT (cad_num, act_number). Поэтому строка (участок, «1413») не конфликтует со строкой (участок, NULL). Без очистки пятничный прогон положил бы 27 строк с номером рядом с 27 старыми, и reservation_lookup показывал бы каждое изъятие дважды. Теперь в том же SAVEPOINT перед записью выполняется DELETE … WHERE cad_num = :cad AND doc_url = :doc AND act_number IS DISTINCT FROM :act. Почему условие именно такое: один документ содержит один акт (номер разбирается один раз на весь PDF), значит строка с тем же (участок, документ) и другим номером — это старый разбор той же записи. Участок из другого документа не затрагивается, это проверено тестом и мутацией.

Единица учёта остаётся (участок, акт), как объявлено в м.136 («1 строка = 1 кад-номер в 1 постановлении»). is_active не трогал. На текущих данных смысл таблицы не меняется: у 27 участков по одному документу, так что после прогона строк останется 27. Миграции нет: старые строки исправит сам прогон.

Тесты

  • tests/services/scrapers/test_2982_act_number.py, 14 тестов. Пять дословных OCR-фрагментов прод-документов из test_2464_act_date_citation проходят через extract_izyatie_records. Ожидаемые пары: (1413, 2022-05-27), (259, 2020-02-12), (2687, 2015-09-29), (863, 2019-04-19), (1504, 2019-06-24). Те же фрагменты проходят через extract_reservations. Контроли: одни цитаты дают None; номер привязан к дате; собственный номер в шапке важнее цитаты «№ 100-ПП»; «509-ПП» по-прежнему извлекается.
  • tests/sql/test_2982_land_reservation_reparse.py, 3 теста. Это _upsert_records на временной таблице с тем же констрейнтом. Состояние как на проде (NULL) плюс прогон с номером дают одну строку, и повторный прогон идемпотентен. Участок в другом документе остаётся. Соседний участок того же документа остаётся.
  • Связанные тесты вместе с локальным Postgres 16.4 (TEST_DATABASE_URL): test_2982_*, test_2464_* (3 файла), test_izyatie_ocr, test_page_reservation_parser, test_reservation_ingest, test_reservation_lookup: 114 passed, rc=0 (после rebase на 9cc23c66).
  • Весь сьют backend: uv run python -m pytest tests/ -q --ignore=tests/smoke: 5046 passed, 87 skipped, 4 failed, rc=1. Все 4 падения — известные tests/ops/test_2203_backup_trailer_grep_dashdash.py на macOS (BSD mktemp), к изменению отношения не имеют. uv run ruff check: All checks passed. ruff format --check по изменённым файлам чистый.

Фальсификация

A. Парсеры откатил до origin/main (git show origin/main:… > оба файла), затем восстановил и сверил через diff -q:

E       AssertionError: assert None == '1413'
E       AssertionError: assert None == '259'
E       AssertionError: assert None == '2687'
E       AssertionError: assert None == '863'
E       AssertionError: assert None == '1504'
E       AssertionError: assert '100-ПП' == '1234'
11 failed, 2 passed in 3.35s
rc=1

(Зелёными остались 2 контроля, которые и на main верны: «одни цитаты дают None» и «509-ПП».)

B. Убрал привязку номера к дате (в теле ищется первое подходящее «№»):

E       AssertionError: assert '746-П' == '1413'
E       AssertionError: assert '746-П' == '259'
E       AssertionError: assert '746-П' is None
E       AssertionError: assert ('2022-05-27', '746') == ('2022-05-27', '1413')
rc=1

C. Убрал DELETE из _upsert_records:

E       AssertionError: строка без номера осталась рядом с новой
E         Left contains one more item: ('66:41:0303004:22', None, 'https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8')
FAILED tests/sql/test_2982_land_reservation_reparse.py::test_numbered_parse_replaces_numberless_row_of_same_document
FAILED tests/sql/test_2982_land_reservation_reparse.py::test_same_parcel_in_another_document_is_kept
2 failed, 1 passed in 4.21s
rc=1

D. Расширил DELETE, убрав условие по doc_url:

E         Right contains one more item: ('66:41:0303004:22', '259', 'https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5')
FAILED tests/sql/test_2982_land_reservation_reparse.py::test_same_parcel_in_another_document_is_kept
1 failed, 2 passed in 3.76s
rc=1

Чего здесь НЕТ (честно)

  • Сквозного прогона по пяти PDF я не делал. Этого требует критерий готовности issue. Локально у tesseract нет rus, а исполнять код в прод-контейнере мне нельзя. Тесты идут по дословным OCR-фрагментам, которые собрали в #2981 тем же трактом (ocr_pdf_text в прод-контейнере). Поэтому PR ссылается на issue без автозакрытия: issue закрывается после приёмки на проде (см. ниже).
  • В page_reservation_parser._parse_act_date остался дефект #2981: берётся первое «от DD.MM.YYYY» без контекста. На тех же фрагментах он даёт 2004-07-06 (Генплан). Загрузчик pravo.gov66 запускается вручную и на проде строк не дал, поэтому здесь я это не чинил.

Деплой

  • Правка только в коде, миграций нет. Таска исполняется в worker, а его деплой пересоздаёт отдельным guard-блоком (#3029). Если в kn_scrape_runs или objective_scrape_runs есть свежие прогоны со status='running', пересоздание worker пропускается (в логе деплоя будет WORKER RECREATE SKIPPED), и пятничный прогон пойдёт на старом коде.
  • Поэтому до пятницы проверить код в самом контейнере: docker exec gendesign-worker-1 grep -c _DELETE_STALE_PARSE_SQL /app/app/workers/tasks/izyatie_ocr_ingest.py должен вернуть ≥ 1 (в старом коде этого имени нет). Если grep возвращает 0, нужен ручной recreate worker после окончания прогонов.
  • Не деплоить в пятницу около 07:00 МСК, когда идёт izyatie-ocr-ingest-weekly: OCR пяти PDF занимает несколько минут.

Приёмка на проде

Срок: после первого пятничного прогона izyatie-ocr-ingest-weekly после деплоя. Это 18.09.2026 07:00 МСК, если деплой будет до этого времени, иначе 25.09.2026. Ручной запуск таски — решение владельца.

SELECT count(*), count(act_number), count(DISTINCT cad_num)
  FROM land_reservation WHERE source = 'izyatie_ekb_ocr';
-- ожидается 27 | 27 | 27

SELECT right(doc_url, 32) AS doc, act_number, count(*)
  FROM land_reservation WHERE source = 'izyatie_ekb_ocr' GROUP BY 1, 2 ORDER BY 1;
-- 188a5c36… 1504 (7) · 89adb28a… 1413 (9) · 9b9d9a99… 259 (2) · bf2bb1bd… 2687 (2) · ccd00215… 863 (7)

Как читать результат: count(*) > 27 значит, что очистка не сработала. count(act_number) < 27 значит, что настоящий OCR расходится с фрагментами. Обе величины 27, но max(fetched_at) всё ещё 07.06: прогон не состоялся, и нули ничего не доказывают. В логах gendesign-worker-1 за время прогона должны быть строки extract_izyatie_records: … (act=1413) и так далее.

🤖 Generated with Claude Code

Refs #2982 ## Что было На проде в `land_reservation` 27 строк, и `act_number` пуст у всех 27. Замер 17.09.2026, только SELECT: все строки из источника `izyatie_ekb_ocr`, 5 документов, `count(DISTINCT cad_num) = 27`. Номера в документах есть, но регекс их не видел. Он требовал суффикс областного акта: ``` r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b" ``` У постановлений Администрации Екатеринбурга суффикса нет: № 1413, 259, 2687, 863, 1504. Тот же обязательный суффикс стоял во втором месте, в `page_reservation_parser.py` (об этом комментарий от 28.08). ## Что сделано **1. `izyatie_ocr.py`: номер берётся у того же акта, что и дата.** Просто сделать суффикс необязательным недостаточно. Первое «№» в теле документа относится к чужому акту: там стоят «Решение Думы … № 60/1» (Генплан) и «Приказ Министерства … № 746-П». Для даты это уже решено в #2981: берётся первое «от DD.MM.YYYY», перед которым стоит слово «постановлени». Теперь номер берётся из «№» вплотную после этой даты, а если там его нет, то вплотную перед ней («Постановление № 509-ПП от …»). Если рядом с датой номера нет, в поле пишется None, а не номер соседнего документа. Эту связку проверил мутацией. Если заменить её на «первое подходящее №», на двух прод-фрагментах вместо `1413` и `259` получается `746-П`. **2. `page_reservation_parser.py` (pravo.gov66, ручной запуск): суффикс стал необязательным.** Раньше номер без суффикса в шапке пропускался, и бралось следующее «№ …-ПП», то есть номер цитируемого постановления. Номер с суффиксом не из списка теперь отбрасывается целиком, а не обрезается: из «№ 218-ФЗ», «№ 746-П» и «№ 60/1» не получится ни «218», ни «746», ни «60». **3. `izyatie_ocr_ingest.py`: прогон не удваивает таблицу.** `act_number` входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. Поэтому строка (участок, «1413») не конфликтует со строкой (участок, NULL). Без очистки пятничный прогон положил бы 27 строк с номером рядом с 27 старыми, и `reservation_lookup` показывал бы каждое изъятие дважды. Теперь в том же SAVEPOINT перед записью выполняется `DELETE … WHERE cad_num = :cad AND doc_url = :doc AND act_number IS DISTINCT FROM :act`. Почему условие именно такое: один документ содержит один акт (номер разбирается один раз на весь PDF), значит строка с тем же (участок, документ) и другим номером — это старый разбор той же записи. Участок из **другого** документа не затрагивается, это проверено тестом и мутацией. Единица учёта остаётся (участок, акт), как объявлено в м.136 («1 строка = 1 кад-номер в 1 постановлении»). `is_active` не трогал. На текущих данных смысл таблицы не меняется: у 27 участков по одному документу, так что после прогона строк останется 27. Миграции нет: старые строки исправит сам прогон. ## Тесты - `tests/services/scrapers/test_2982_act_number.py`, 14 тестов. Пять дословных OCR-фрагментов прод-документов из `test_2464_act_date_citation` проходят через `extract_izyatie_records`. Ожидаемые пары: (1413, 2022-05-27), (259, 2020-02-12), (2687, 2015-09-29), (863, 2019-04-19), (1504, 2019-06-24). Те же фрагменты проходят через `extract_reservations`. Контроли: одни цитаты дают None; номер привязан к дате; собственный номер в шапке важнее цитаты «№ 100-ПП»; «509-ПП» по-прежнему извлекается. - `tests/sql/test_2982_land_reservation_reparse.py`, 3 теста. Это `_upsert_records` на временной таблице с тем же констрейнтом. Состояние как на проде (NULL) плюс прогон с номером дают одну строку, и повторный прогон идемпотентен. Участок в другом документе остаётся. Соседний участок того же документа остаётся. - Связанные тесты вместе с локальным Postgres 16.4 (`TEST_DATABASE_URL`): `test_2982_*`, `test_2464_*` (3 файла), `test_izyatie_ocr`, `test_page_reservation_parser`, `test_reservation_ingest`, `test_reservation_lookup`: **114 passed, rc=0** (после rebase на `9cc23c66`). - Весь сьют `backend`: `uv run python -m pytest tests/ -q --ignore=tests/smoke`: **5046 passed, 87 skipped, 4 failed, rc=1**. Все 4 падения — известные `tests/ops/test_2203_backup_trailer_grep_dashdash.py` на macOS (BSD mktemp), к изменению отношения не имеют. `uv run ruff check`: All checks passed. `ruff format --check` по изменённым файлам чистый. ## Фальсификация **A. Парсеры откатил до origin/main** (`git show origin/main:… >` оба файла), затем восстановил и сверил через `diff -q`: ``` E AssertionError: assert None == '1413' E AssertionError: assert None == '259' E AssertionError: assert None == '2687' E AssertionError: assert None == '863' E AssertionError: assert None == '1504' E AssertionError: assert '100-ПП' == '1234' 11 failed, 2 passed in 3.35s rc=1 ``` (Зелёными остались 2 контроля, которые и на main верны: «одни цитаты дают None» и «509-ПП».) **B. Убрал привязку номера к дате** (в теле ищется первое подходящее «№»): ``` E AssertionError: assert '746-П' == '1413' E AssertionError: assert '746-П' == '259' E AssertionError: assert '746-П' is None E AssertionError: assert ('2022-05-27', '746') == ('2022-05-27', '1413') rc=1 ``` **C. Убрал `DELETE` из `_upsert_records`:** ``` E AssertionError: строка без номера осталась рядом с новой E Left contains one more item: ('66:41:0303004:22', None, 'https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8') FAILED tests/sql/test_2982_land_reservation_reparse.py::test_numbered_parse_replaces_numberless_row_of_same_document FAILED tests/sql/test_2982_land_reservation_reparse.py::test_same_parcel_in_another_document_is_kept 2 failed, 1 passed in 4.21s rc=1 ``` **D. Расширил `DELETE`, убрав условие по `doc_url`:** ``` E Right contains one more item: ('66:41:0303004:22', '259', 'https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5') FAILED tests/sql/test_2982_land_reservation_reparse.py::test_same_parcel_in_another_document_is_kept 1 failed, 2 passed in 3.76s rc=1 ``` ## Чего здесь НЕТ (честно) - **Сквозного прогона по пяти PDF я не делал.** Этого требует критерий готовности issue. Локально у tesseract нет `rus`, а исполнять код в прод-контейнере мне нельзя. Тесты идут по дословным OCR-фрагментам, которые собрали в #2981 тем же трактом (`ocr_pdf_text` в прод-контейнере). Поэтому PR ссылается на issue без автозакрытия: issue закрывается после приёмки на проде (см. ниже). - В `page_reservation_parser._parse_act_date` остался дефект #2981: берётся первое «от DD.MM.YYYY» без контекста. На тех же фрагментах он даёт 2004-07-06 (Генплан). Загрузчик pravo.gov66 запускается вручную и на проде строк не дал, поэтому здесь я это не чинил. ## Деплой - Правка только в коде, миграций нет. Таска исполняется в `worker`, а его деплой пересоздаёт отдельным guard-блоком (#3029). Если в `kn_scrape_runs` или `objective_scrape_runs` есть свежие прогоны со `status='running'`, пересоздание worker пропускается (в логе деплоя будет `WORKER RECREATE SKIPPED`), и пятничный прогон пойдёт на старом коде. - Поэтому до пятницы проверить код в самом контейнере: `docker exec gendesign-worker-1 grep -c _DELETE_STALE_PARSE_SQL /app/app/workers/tasks/izyatie_ocr_ingest.py` должен вернуть ≥ 1 (в старом коде этого имени нет). Если grep возвращает 0, нужен ручной recreate worker после окончания прогонов. - Не деплоить в пятницу около 07:00 МСК, когда идёт `izyatie-ocr-ingest-weekly`: OCR пяти PDF занимает несколько минут. ## Приёмка на проде Срок: после первого пятничного прогона `izyatie-ocr-ingest-weekly` после деплоя. Это **18.09.2026 07:00 МСК**, если деплой будет до этого времени, иначе **25.09.2026**. Ручной запуск таски — решение владельца. ```sql SELECT count(*), count(act_number), count(DISTINCT cad_num) FROM land_reservation WHERE source = 'izyatie_ekb_ocr'; -- ожидается 27 | 27 | 27 SELECT right(doc_url, 32) AS doc, act_number, count(*) FROM land_reservation WHERE source = 'izyatie_ekb_ocr' GROUP BY 1, 2 ORDER BY 1; -- 188a5c36… 1504 (7) · 89adb28a… 1413 (9) · 9b9d9a99… 259 (2) · bf2bb1bd… 2687 (2) · ccd00215… 863 (7) ``` Как читать результат: `count(*) > 27` значит, что очистка не сработала. `count(act_number) < 27` значит, что настоящий OCR расходится с фрагментами. Обе величины 27, но max(`fetched_at`) всё ещё 07.06: прогон не состоялся, и нули ничего не доказывают. В логах `gendesign-worker-1` за время прогона должны быть строки `extract_izyatie_records: … (act=1413)` и так далее. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 08:25:18 +00:00
fix(ptica): номер акта в land_reservation извлекается у постановлений Администрации ЕКБ (#2982)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m31s
CI / backend-tests (pull_request) Successful in 6m44s
7501fef5c3
Регекс номера требовал суффикс областных актов (-ПП/-ПА/-РП/-ПГ/-ГП/-МО),
а у постановлений Администрации Екатеринбурга его нет: на проде act_number
пуст у всех 27 строк (17.09.2026).

- izyatie_ocr: номер берётся у того же акта, чью дату выбирает
  _extract_act_date (вплотную после «от DD.MM.YYYY», иначе перед ней).
  Первое «№» в теле — «Решение Думы № 60/1» или «Приказ № 746-П».
- page_reservation_parser: суффикс необязателен; номер с суффиксом не из
  списка («218-ФЗ», «746-П», «60/1») отбрасывается целиком, а не обрезается.
- izyatie_ocr_ingest: перед записью удаляется прежний разбор того же
  участка из того же документа с другим номером. act_number в ключе
  конфликта, без этого прогон положил бы 27 строк с номером рядом с 27
  строками без номера.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend merged commit d9a5bfe71f into main 2026-09-17 09:16:10 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3568
No description provided.