From 9a4acb67d1c25a09e55fc1cf160cf7557060f007 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 21:24:32 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20act=5Fdate=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=20=D0=B0=D0=BA=D1=82=D0=B0=20=D0=BE=D0=B1=20?= =?UTF-8?q?=D0=B8=D0=B7=D1=8A=D1=8F=D1=82=D0=B8=D0=B8,=20=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D0=B2=D1=88=D0=B5=D0=B9=D1=81=D1=8F=20=D1=81=D1=81?= =?UTF-8?q?=D1=8B=D0=BB=D0=BA=D0=B8=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_extract_act_date` брал ПЕРВОЕ «от DD.MM.YYYY» во всём OCR-тексте. «Сообщение о планируемом изъятии» открывается списком оснований, и первой строкой там стоит «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении Генерального плана города»» — Генплан, а не акт об изъятии. На проде это дало 11 строк из 27 с датой 2004-07-06 при проектах 2020 и 2022 годов, причём одну и ту же дату получили ДВА разных документа (развязка на Сибирском тракте и улица Энергостроителей). Совпадение даты у несвязанных актов и было первым признаком, что дата не своя. Дата принимается, только если в 120 символах перед ней стоит слово «постановлени». Ссылки-помехи в этих документах — «Решение … Думы» и «Приказ Министерства» — его не содержат. Окно шире самой фразы, потому что OCR перемешивает колонки таблицы и вклинивает в неё чужой текст («…Администрации города документами) Екатеринбурга от 19.04.2019…»). Если подходящей даты нет — None. Дата чужого документа хуже пустоты: по ней нельзя ни отфильтровать актуальные изъятия, ни сверить срок, и она неотличима от настоящей. Калибровка не на одном образце: все пять исходных PDF загружены и распознаны тем же трактом, что использует загрузчик (ocr_pdf_text в прод-контейнере). Окна 80/120/160 дают одинаковые 5 из 5. Более узкое правило (плюс «администраци») давало те же 5 из 5, но ломало законный случай «Постановление № 509-ПП» — областной акт без слова «администрация», уже закреплённый тестом test_act_date_extracted_from_text; взято широкое. Сквозная проверка: патченный код прогнан по всем пяти распознанным текстам целиком — 27 записей, ровно столько же, сколько строк в land_reservation; 11 меняют 2004-07-06 на настоящую дату, 16 не двигаются. Двусторонне: против origin/main три теста красные с реальным неверным значением ('2004-07-06'), ни одного TypeError — тесты идут через extract_izyatie_records, чья сигнатура одинакова на обеих сторонах. Co-Authored-By: Claude Opus 5 --- backend/app/services/scrapers/izyatie_ocr.py | 69 ++++++-- .../scrapers/test_2464_act_date_citation.py | 149 ++++++++++++++++++ 2 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 backend/tests/services/scrapers/test_2464_act_date_citation.py diff --git a/backend/app/services/scrapers/izyatie_ocr.py b/backend/app/services/scrapers/izyatie_ocr.py index 2205e52e..5e7b5e67 100644 --- a/backend/app/services/scrapers/izyatie_ocr.py +++ b/backend/app/services/scrapers/izyatie_ocr.py @@ -37,6 +37,33 @@ _RE_ACT_NUMBER = re.compile( ) _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. _RE_PURPOSE = re.compile( 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_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) # Поиск кад-номеров. @@ -268,19 +297,35 @@ def _extract_act_number(text: str) -> str | None: return re.sub(r"\s+", "", m.group(1)) -def _extract_act_date(text: str) -> str | None: - """Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.""" - m = _RE_ACT_DATE.search(text) - if not m: - return None - day, month, year = m.group(1), m.group(2), m.group(3) - try: - # Валидируем диапазоны. - d, mo, y = int(day), int(month), int(year) +def _act_context_matches(text: str, pos: int) -> bool: + """Стоит ли перед датой упоминание постановления — акта-основания.""" + ctx = text[max(0, pos - _ACT_CONTEXT_WINDOW) : pos].lower() + return all(word in ctx for word in _ACT_CONTEXT_WORDS) + + +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) + try: + # Валидируем диапазоны. + d, mo, y = int(day), int(month), int(year) + except ValueError: + continue if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100: return f"{y:04d}-{mo:02d}-{d:02d}" - except ValueError: - pass return None diff --git a/backend/tests/services/scrapers/test_2464_act_date_citation.py b/backend/tests/services/scrapers/test_2464_act_date_citation.py new file mode 100644 index 00000000..17adb51b --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_act_date_citation.py @@ -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"