fix(ptica): act_date — дата акта об изъятии, а не первой попавшейся ссылки (#2464)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy / build-worker (push) Successful in 3m29s
Deploy / deploy (push) Successful in 1m35s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy / build-worker (push) Successful in 3m29s
Deploy / deploy (push) Successful in 1m35s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
Часть эпика #2464. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
508f2d6090
2 changed files with 206 additions and 12 deletions
|
|
@ -37,6 +37,33 @@ _RE_ACT_NUMBER = re.compile(
|
||||||
)
|
)
|
||||||
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
|
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
|
||||||
|
|
||||||
|
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
|
||||||
|
# ссылки на другой документ (#2464). «Сообщение о планируемом изъятии»
|
||||||
|
# открывается списком оснований, где первой строкой почти всегда стоит
|
||||||
|
# «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об
|
||||||
|
# утверждении Генерального плана города»» — Генплан, а не акт об изъятии.
|
||||||
|
# Брать первую дату подряд означало ставить всем участкам дату Генплана.
|
||||||
|
#
|
||||||
|
# Действующее основание изъятия — постановление (Администрации города об
|
||||||
|
# утверждении проекта планировки/межевания либо Правительства области),
|
||||||
|
# поэтому дата принимается, только если слово стоит в предшествующем контексте.
|
||||||
|
# Ссылки-помехи в этих документах — «Решение … Думы» и «Приказ Министерства»,
|
||||||
|
# и ни одна из них слова «постановление» не содержит.
|
||||||
|
#
|
||||||
|
# Пробовал требовать ещё и «администраци»: на пяти прод-документах результат
|
||||||
|
# тот же 5 из 5, но правило ломает законный случай «Постановление № 509-ПП»
|
||||||
|
# (областное постановление без слова «администрация») — он уже закреплён
|
||||||
|
# тестом test_act_date_extracted_from_text. Взято более широкое условие:
|
||||||
|
# на живых данных оно не хуже, а лишнего не отсекает.
|
||||||
|
_ACT_CONTEXT_WORDS = ("постановлени",)
|
||||||
|
# Ширина окна контекста. OCR перемешивает колонки таблицы, и между словами
|
||||||
|
# «Постановление Администрации города» и «от DD.MM.YYYY» вклинивается текст
|
||||||
|
# соседней колонки («…Администрации города документами) Екатеринбурга от
|
||||||
|
# 19.04.2019…»), поэтому окно шире самой фразы (~50 символов). Откалибровано
|
||||||
|
# на пяти прод-документах land_reservation: 80, 120 и 160 дают одинаковые
|
||||||
|
# 5 из 5, выбрана середина.
|
||||||
|
_ACT_CONTEXT_WINDOW = 120
|
||||||
|
|
||||||
# Паттерн цели: «в целях…», «для …», «под строительство …» — best-effort.
|
# Паттерн цели: «в целях…», «для …», «под строительство …» — best-effort.
|
||||||
_RE_PURPOSE = re.compile(
|
_RE_PURPOSE = re.compile(
|
||||||
r"(?:для|в целях?|под)\s+([^.;,\n]{10,120})",
|
r"(?:для|в целях?|под)\s+([^.;,\n]{10,120})",
|
||||||
|
|
@ -207,7 +234,9 @@ def extract_izyatie_records(
|
||||||
|
|
||||||
# Реквизиты акта из заголовка или текста.
|
# Реквизиты акта из заголовка или текста.
|
||||||
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
|
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
|
||||||
act_date = _extract_act_date(doc_title) or _extract_act_date(normalized)
|
act_date = _extract_act_date(doc_title) or _extract_act_date(
|
||||||
|
normalized, require_act_context=True
|
||||||
|
)
|
||||||
purpose = _extract_purpose(doc_title) or _extract_purpose(normalized)
|
purpose = _extract_purpose(doc_title) or _extract_purpose(normalized)
|
||||||
|
|
||||||
# Поиск кад-номеров.
|
# Поиск кад-номеров.
|
||||||
|
|
@ -268,19 +297,35 @@ def _extract_act_number(text: str) -> str | None:
|
||||||
return re.sub(r"\s+", "", m.group(1))
|
return re.sub(r"\s+", "", m.group(1))
|
||||||
|
|
||||||
|
|
||||||
def _extract_act_date(text: str) -> str | None:
|
def _act_context_matches(text: str, pos: int) -> bool:
|
||||||
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE."""
|
"""Стоит ли перед датой упоминание постановления — акта-основания."""
|
||||||
m = _RE_ACT_DATE.search(text)
|
ctx = text[max(0, pos - _ACT_CONTEXT_WINDOW) : pos].lower()
|
||||||
if not m:
|
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
|
||||||
return None
|
|
||||||
|
|
||||||
|
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
|
||||||
|
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
|
||||||
|
|
||||||
|
require_act_context=True — брать только дату, перед которой стоит
|
||||||
|
упоминание постановления (#2464). Нужен для ТЕЛА
|
||||||
|
документа, где первой датой почти всегда идёт ссылка на Генплан-2004.
|
||||||
|
Для заголовка не нужен: там ссылок на посторонние акты нет.
|
||||||
|
|
||||||
|
Если подходящей даты нет, возвращается None. Это сознательно: отсутствие
|
||||||
|
даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать
|
||||||
|
актуальные изъятия, ни сверить срок.
|
||||||
|
"""
|
||||||
|
for m in _RE_ACT_DATE.finditer(text):
|
||||||
|
if require_act_context and not _act_context_matches(text, m.start()):
|
||||||
|
continue
|
||||||
day, month, year = m.group(1), m.group(2), m.group(3)
|
day, month, year = m.group(1), m.group(2), m.group(3)
|
||||||
try:
|
try:
|
||||||
# Валидируем диапазоны.
|
# Валидируем диапазоны.
|
||||||
d, mo, y = int(day), int(month), int(year)
|
d, mo, y = int(day), int(month), int(year)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
|
||||||
return f"{y:04d}-{mo:02d}-{d:02d}"
|
return f"{y:04d}-{mo:02d}-{d:02d}"
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
149
backend/tests/services/scrapers/test_2464_act_date_citation.py
Normal file
149
backend/tests/services/scrapers/test_2464_act_date_citation.py
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""act_date — дата акта об изъятии, а не первой попавшейся ссылки (#2464).
|
||||||
|
|
||||||
|
`_extract_act_date` брал ПЕРВОЕ «от DD.MM.YYYY» во всём OCR-тексте. «Сообщение о
|
||||||
|
планируемом изъятии» открывается списком оснований, и первой строкой там почти
|
||||||
|
всегда стоит
|
||||||
|
|
||||||
|
«Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1
|
||||||
|
«Об утверждении Генерального плана города»»
|
||||||
|
|
||||||
|
— то есть Генплан, а не акт об изъятии. На проде это дало 11 строк из 27
|
||||||
|
(два РАЗНЫХ документа: развязка на Сибирском тракте и улица Энергостроителей)
|
||||||
|
с одной датой 2004-07-06 при проектах 2020 и 2022 годов.
|
||||||
|
|
||||||
|
Проверено не по догадке: все пять исходных PDF были загружены и распознаны тем
|
||||||
|
же трактом, что использует загрузчик (`ocr_pdf_text` в прод-контейнере). Тексты
|
||||||
|
цитат ниже — дословно оттуда, включая перемешивание колонок OCR'ом.
|
||||||
|
|
||||||
|
Правило («постановлени» в 120 символах перед датой) откалибровано на всех пяти
|
||||||
|
документах сразу, а не на одном: окна 80/120/160 дают одинаковые 5 из 5.
|
||||||
|
Более узкий вариант (плюс «администраци») давал те же 5 из 5, но ломал законный
|
||||||
|
случай «Постановление № 509-ПП» — областной акт без слова «администрация»,
|
||||||
|
уже закреплённый тестом test_act_date_extracted_from_text. Взято широкое условие.
|
||||||
|
|
||||||
|
Патченный код прогнан по всем пяти распознанным текстам целиком: 27 записей —
|
||||||
|
ровно столько же, сколько строк в land_reservation, из них 11 меняют дату
|
||||||
|
2004-07-06 на настоящую, 16 остаются прежними.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.scrapers.izyatie_ocr import _extract_act_date, extract_izyatie_records
|
||||||
|
|
||||||
|
# Заголовок раздела — дословно с прода (basis_act в land_reservation).
|
||||||
|
ЗАГОЛОВОК = (
|
||||||
|
"Сообщение о планируемом изъятии земельных участков и объектов недвижимого "
|
||||||
|
"имущества для муниципальных нужд"
|
||||||
|
)
|
||||||
|
КАД = "66:41:0303004:22"
|
||||||
|
|
||||||
|
|
||||||
|
def _дата_записи(текст_документа: str) -> str | None:
|
||||||
|
"""act_date так, как её увидит land_reservation — через сквозной путь.
|
||||||
|
|
||||||
|
Зовём `extract_izyatie_records`, а не приватный хелпер: его сигнатура
|
||||||
|
одинакова на обеих сторонах, поэтому против origin/main тест краснеет
|
||||||
|
НЕВЕРНЫМ ЗНАЧЕНИЕМ (дата Генплана), а не TypeError из-за нового параметра.
|
||||||
|
"""
|
||||||
|
записи = extract_izyatie_records(f"{текст_документа}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
|
||||||
|
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
|
||||||
|
return записи[0]["act_date"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Дословные фрагменты OCR прод-документов ───────────────────────────────────
|
||||||
|
|
||||||
|
# 89adb28a… — развязка Базовый/Комсомольская/Сибирский тракт. На проде дал 2004-07-06.
|
||||||
|
ДОК_РАЗВЯЗКА = (
|
||||||
|
"Администрация города Екатеринбурга\n\n"
|
||||||
|
"— Решение Екатеринбургской городской Думы от\n06.07.2004 № 60/1 «Об утверждении\n"
|
||||||
|
"Генерального плана развития городского округа»;\n\n"
|
||||||
|
"— Приказ Министерства строительства и развития\nинфраструктуры Свердловской области от\n"
|
||||||
|
"30.12.2021 № 746-П;\n\n"
|
||||||
|
"— Постановление Администрации города\nЕкатеринбурга от 27.05.2022 № 1413\n"
|
||||||
|
"«Об утверждении проекта планировки и проекта межевания территории»"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 9b9d9a99… — улица Энергостроителей. На проде тоже дал 2004-07-06.
|
||||||
|
ДОК_ЭНЕРГОСТРОИТЕЛЕЙ = (
|
||||||
|
"Администрация города Екатеринбурга\n\n"
|
||||||
|
"— Решение Екатеринбургской городской Думы от\n06.07.2004 № 60/1 «Об утверждении\n"
|
||||||
|
"Генерального плана развития городского округа»;\n\n"
|
||||||
|
"— Приказ Министерства строительства и развития\nинфраструктуры Свердловской области от\n"
|
||||||
|
"30.12.2021 № 746-П «О внесении изменений в Генеральный план на период до 2025 года»;\n\n"
|
||||||
|
"— Постановление Администрации города\nЕкатеринбурга от 12.02.2020 № 259 «Об утверждении\n"
|
||||||
|
"проекта планировки и проекта межевания территории»"
|
||||||
|
)
|
||||||
|
|
||||||
|
# bf2bb1bd… — OCR перемешал колонки таблицы: слова фразы идут вперемешку.
|
||||||
|
ДОК_ПЕРЕМЕШАННЫЙ = (
|
||||||
|
"Постановление Администрации города утверждении проекта планировки\n"
|
||||||
|
"Екатеринбурга от 29.09.2015 № 2687 территории и проекта межевания\n"
|
||||||
|
"«Об утверждении» (в ред. Постановления Администрации города\n"
|
||||||
|
"Екатеринбурга от 14.07.2022 № 1995)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ccd00215… — то же перемешивание, но короче.
|
||||||
|
ДОК_ПЕРЕМЕШАННЫЙ_2 = (
|
||||||
|
"Реквизиты документов (при наличии таких Постановление Администрации города\n"
|
||||||
|
"документами) Екатеринбурга от 19.04.2019 № 863 «Об\nутверждении проекта межевания территории»"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 188a5c36… — первой же датой идёт нужное постановление.
|
||||||
|
ДОК_ПРЯМОЙ = (
|
||||||
|
"Реквизиты документов (при наличии таких документами)\n"
|
||||||
|
"Постановление Администрации города Екатеринбурга от 24.06.2019 № 1504\n"
|
||||||
|
"«Об утверждении проекта планировки и проекта межевания территории»"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_genplan_citation_is_not_taken_as_act_date() -> None:
|
||||||
|
"""Головной: дата Генплана-2004 не должна становиться датой изъятия.
|
||||||
|
|
||||||
|
На origin/main возвращается '2004-07-06' — дата чужого документа,
|
||||||
|
на 18 лет раньше настоящего основания.
|
||||||
|
"""
|
||||||
|
got = _дата_записи(ДОК_РАЗВЯЗКА)
|
||||||
|
assert got != "2004-07-06", "взята дата Решения Думы об утверждении Генерального плана"
|
||||||
|
assert got == "2022-05-27", f"ожидали дату постановления № 1413, получили {got!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_document_with_same_genplan_citation() -> None:
|
||||||
|
"""Тот же дефект во втором документе — он и дал одинаковую дату у 11 строк."""
|
||||||
|
got = _дата_записи(ДОК_ЭНЕРГОСТРОИТЕЛЕЙ)
|
||||||
|
assert got == "2020-02-12", f"ожидали дату постановления № 259, получили {got!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ministry_order_is_not_taken_either() -> None:
|
||||||
|
"""Контроль: приказ Министерства между Генпланом и постановлением тоже не берётся."""
|
||||||
|
got = _дата_записи(ДОК_РАЗВЯЗКА)
|
||||||
|
assert got != "2021-12-30", "взята дата приказа Министерства строительства"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ocr_column_scramble_still_resolves() -> None:
|
||||||
|
"""Контроль ширины окна: перемешанные OCR'ом колонки не должны ломать разбор.
|
||||||
|
|
||||||
|
Окно шире самой фразы (~50 символов) именно ради этих двух документов:
|
||||||
|
между «Постановление» и «от DD.MM.YYYY» вклинивается текст соседней колонки.
|
||||||
|
"""
|
||||||
|
assert _дата_записи(ДОК_ПЕРЕМЕШАННЫЙ) == "2015-09-29"
|
||||||
|
assert _дата_записи(ДОК_ПЕРЕМЕШАННЫЙ_2) == "2019-04-19"
|
||||||
|
|
||||||
|
|
||||||
|
def test_correct_documents_unchanged() -> None:
|
||||||
|
"""Контроль от переусердствования: там, где на проде было верно, ничего не меняется."""
|
||||||
|
assert _дата_записи(ДОК_ПРЯМОЙ) == "2019-06-24"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_act_citation_gives_none_not_a_wrong_date() -> None:
|
||||||
|
"""Контроль: без основания — None, а не дата постороннего документа.
|
||||||
|
|
||||||
|
Отсутствие даты честнее: по чужой дате нельзя ни отфильтровать актуальные
|
||||||
|
изъятия, ни сверить срок, и она неотличима от настоящей.
|
||||||
|
"""
|
||||||
|
текст = "Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»"
|
||||||
|
assert _дата_записи(текст) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_path_does_not_require_context() -> None:
|
||||||
|
"""Контроль: у заголовка ссылок на чужие акты нет, там правило не применяется."""
|
||||||
|
assert _extract_act_date("Постановление от 27.05.2022 № 1413") == "2022-05-27"
|
||||||
Loading…
Add table
Reference in a new issue