fix(ptica): тип постановления — по первому упоминанию, а не по порядку проверок (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
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 9s
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 1m56s
CI / backend-tests (pull_request) Successful in 17m12s

`_detect_kind` проверял «резервир» первым и возвращал «резервирование»
безусловно. Постановление об изъятии, где резервирование упомянуто
вскользь — типовая формулировка «ранее зарезервированных земель», ссылка
на утративший силу акт, — классифицировалось как резервирование.

Ошибка не единичная: `kind` в `extract_reservations` один на весь
документ, поэтому неверный тип уходит в КАЖДУЮ строку land_reservation
по этому акту.

Побеждает то слово, что встретилось раньше. Тема документа стоит в
заголовке, поэтому позиция — сигнал сильнее порядка проверок, и он
симметричен: заголовок «О резервировании» так же выигрывает у «изъятия»
в теле. Простая смена порядка проверок этой симметрии не даёт — на неё
поставлен отдельный контроль.

Текущих ошибок на проде нет, и это измерено: в land_reservation 27
строк, все из источника izyatie_ekb_ocr, все «изъятие», ни в одной
выдержке слова «резервир» не встречается. Правка закрывает возможность,
а не чинит существующую порчу.

Двусторонне: против origin/main два теста красные с конкретным неверным
значением (`assert 'резервирование' == 'изъятие'`). Контроли —
симметрия заголовка, одиночные маркеры, откат к default_kind — зелёные с
обеих сторон. Формулировки в тестах взяты с прода дословно (basis_act).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-20 17:49:03 +05:00
parent 610ed20395
commit 71f27fee7d
2 changed files with 100 additions and 3 deletions

View file

@ -127,10 +127,24 @@ def _parse_act_date(text: str) -> date | None:
def _detect_kind(text: str, default_kind: str) -> str:
"""Определяет тип операции: 'резервирование' | 'изъятие' | default_kind."""
if _RE_REZERV.search(text):
"""Определяет тип операции: 'резервирование' | 'изъятие' | default_kind.
Побеждает то слово, что встретилось РАНЬШЕ, а не то, что стоит выше в
коде (#2464). Прежний безусловный приоритет «резервир» переклассифицировал
ВЕСЬ документ все участки разом, если постановление об изъятии хоть
раз ссылалось на резервирование (типовая формулировка «ранее
зарезервированных земель», ссылка на утративший силу акт). Тема документа
стоит в заголовке, поэтому позиция первого упоминания сигнал сильнее
порядка проверок, и он симметричен: заголовок «О резервировании» так же
выигрывает у «изъятия» в теле.
"""
m_rez = _RE_REZERV.search(text)
m_izy = _RE_IZYAT.search(text)
if m_rez and m_izy:
return "резервирование" if m_rez.start() < m_izy.start() else "изъятие"
if m_rez:
return "резервирование"
if _RE_IZYAT.search(text):
if m_izy:
return "изъятие"
return default_kind

View file

@ -0,0 +1,83 @@
"""Тип постановления определяется по первому упоминанию, а не по порядку проверок (#2464).
`_detect_kind` проверял «резервир» ПЕРВЫМ и возвращал «резервирование» безусловно.
Постановление об изъятии, где резервирование упомянуто вскользь типовая
формулировка «ранее зарезервированных земель», ссылка на утративший силу акт,
переклассифицировало **весь документ**: `kind` в `extract_reservations` один на
все участки, поэтому ошибка размножается на каждую строку `land_reservation`.
Побеждает то слово, что встретилось раньше. Тема документа стоит в заголовке,
поэтому позиция сигнал сильнее порядка проверок, и он симметричен: заголовок
«О резервировании» так же выигрывает у «изъятия» в теле.
Формулировки взяты с прода: `basis_act` в `land_reservation` (источник
`izyatie_ekb_ocr`, 27 строк, все «изъятие») начинается со слов «Сообщение о
планируемом изъятии земельных участков».
"""
from __future__ import annotations
from app.services.scrapers.page_reservation_parser import (
_detect_kind,
extract_reservations,
)
# Заголовок — с прода, дословно.
_ЗАГОЛОВОКЗЪЯТИЕ = (
"Сообщение о планируемом изъятии земельных участков и объектов недвижимого "
"имущества для муниципальных нужд в целях строительства улицы Энергостроителей"
)
def test_izyatie_in_title_wins_over_later_rezervirovanie() -> None:
"""Головной: упоминание резервирования в теле не переписывает тему заголовка.
На origin/main возвращается «резервирование» неверное значение, а не отказ.
"""
text = (
f"{_ЗАГОЛОВОКЗЪЯТИЕ}. Изъятию подлежат участки, ранее зарезервированные "
"постановлением о резервировании земель от 12.03.2019."
)
assert (
_detect_kind(text, "изъятие") == "изъятие"
), "документ об изъятии классифицирован по случайному упоминанию в теле"
def test_all_parcels_of_the_document_are_affected() -> None:
"""Ошибка типа размножается на КАЖДЫЙ участок — kind в документе один на всех."""
text = (
f"{_ЗАГОЛОВОКЗЪЯТИЕ}. Изъять земельные участки с кадастровыми номерами "
"66:41:0303004:22, 66:41:0303004:23, 66:41:0206032:8499, ранее "
"зарезервированные постановлением о резервировании земель."
)
records = extract_reservations(text, default_kind="изъятие")
assert len(records) == 3, f"разобрано участков: {len(records)}"
kinds = {r.reservation_kind for r in records}
assert kinds == {"изъятие"}, f"типы участков: {kinds} — ошибка ушла бы во все строки"
def test_rezervirovanie_in_title_still_wins() -> None:
"""Контроль симметрии: заголовок о резервировании выигрывает у изъятия в теле.
Ловит «починку» через простую смену порядка проверок она сломала бы этот случай.
"""
text = (
"Постановление о резервировании земель для муниципальных нужд. "
"Зарезервированные участки не подлежат изъятию до истечения срока."
)
assert _detect_kind(text, "изъятие") == "резервирование"
def test_single_keyword_documents_unchanged() -> None:
"""Контроль: документы с одним словом-маркером распознаются как раньше."""
assert _detect_kind("Об изъятии земельного участка", "резервирование") == "изъятие"
assert _detect_kind("О резервировании земель", "изъятие") == "резервирование"
def test_no_keyword_falls_back_to_default() -> None:
"""Контроль: без маркеров остаётся default_kind, а не выдуманный тип."""
assert _detect_kind("Об утверждении схемы расположения участка", "изъятие") == "изъятие"
assert (
_detect_kind("Об утверждении схемы расположения участка", "резервирование")
== "резервирование"
)