diff --git a/backend/app/services/scrapers/izyatie_ocr.py b/backend/app/services/scrapers/izyatie_ocr.py index 5e7b5e67..41cf33c1 100644 --- a/backend/app/services/scrapers/izyatie_ocr.py +++ b/backend/app/services/scrapers/izyatie_ocr.py @@ -30,11 +30,15 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н # 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр. _RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b") -# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП». -_RE_ACT_NUMBER = re.compile( - r"№\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b", - re.UNICODE | re.IGNORECASE, -) +# Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон +# требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга +# его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не +# даёт обрезать чужой номер: из «№ 60/1» не берётся «60», из «№ 12-ПППП» — «12». +_ACT_NUM = r"(\d+(?:\s*[-–]\s*[А-Я]{1,3})?)(?![\w/\-–])" +_RE_ACT_NUMBER = re.compile(rf"№\s*{_ACT_NUM}") +# Номер того же акта, что и выбранная дата: «от DD.MM.YYYY № N» либо «№ N от DD.MM.YYYY». +_RE_ACT_NUMBER_AFTER_DATE = re.compile(rf"\s*№\s*{_ACT_NUM}") +_RE_ACT_NUMBER_BEFORE_DATE = re.compile(rf"№\s*{_ACT_NUM}\s*$") _RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})") # Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не @@ -233,7 +237,9 @@ def extract_izyatie_records( normalized = _normalize_ocr_text(ocr_text) # Реквизиты акта из заголовка или текста. - act_number = _extract_act_number(doc_title) or _extract_act_number(normalized) + act_number = _extract_act_number(doc_title) or _extract_act_number( + normalized, require_act_context=True + ) act_date = _extract_act_date(doc_title) or _extract_act_date( normalized, require_act_context=True ) @@ -289,9 +295,26 @@ def extract_izyatie_records( # ── Вспомогательные функции ──────────────────────────────────────────────────── -def _extract_act_number(text: str) -> str | None: - """Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»).""" - m = _RE_ACT_NUMBER.search(text) +def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None: + """Извлекает номер акта. + + Без require_act_context (заголовок) — первое «№ N» в тексте. + + С require_act_context (тело документа, #2982) — номер ТОГО ЖЕ акта, чью дату + выбирает `_extract_act_date`: «№» вплотную после даты, иначе вплотную перед + ней. Первое «№» в теле — это «Решение Думы № 60/1» (Генплан) или «Приказ + Министерства № 746-П», а не основание изъятия. Номера рядом с датой нет — + None, а не номер соседнего документа. + """ + if not require_act_context: + m = _RE_ACT_NUMBER.search(text) + else: + date_m = _act_date_match(text, require_act_context=True) + if date_m is None: + return None + m = _RE_ACT_NUMBER_AFTER_DATE.match(text, date_m.end()) or ( + _RE_ACT_NUMBER_BEFORE_DATE.search(text, max(0, date_m.start() - 30), date_m.start()) + ) if not m: return None return re.sub(r"\s+", "", m.group(1)) @@ -303,6 +326,17 @@ def _act_context_matches(text: str, pos: int) -> bool: return all(word in ctx for word in _ACT_CONTEXT_WORDS) +def _act_date_match(text: str, *, require_act_context: bool) -> re.Match[str] | None: + """Первое «от DD.MM.YYYY» с допустимой датой (и контекстом постановления, если нужен).""" + for m in _RE_ACT_DATE.finditer(text): + if require_act_context and not _act_context_matches(text, m.start()): + continue + d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) + if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100: + return m + return None + + def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None: """Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE. @@ -315,18 +349,10 @@ def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать актуальные изъятия, ни сверить срок. """ - 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}" - return None + m = _act_date_match(text, require_act_context=require_act_context) + if m is None: + return None + return f"{m.group(3)}-{m.group(2)}-{m.group(1)}" def _extract_purpose(text: str) -> str | None: diff --git a/backend/app/services/scrapers/page_reservation_parser.py b/backend/app/services/scrapers/page_reservation_parser.py index 96ab63e7..1af12ae7 100644 --- a/backend/app/services/scrapers/page_reservation_parser.py +++ b/backend/app/services/scrapers/page_reservation_parser.py @@ -31,8 +31,13 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b") # Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п. # Паттерн намеренно широкий — постановления РФ имеют разные суффиксы. +# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»), +# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий +# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает +# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ», +# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60». _RE_ACT_NUMBER = re.compile( - r"№\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b", + r"№\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])", re.UNICODE, ) diff --git a/backend/app/workers/tasks/izyatie_ocr_ingest.py b/backend/app/workers/tasks/izyatie_ocr_ingest.py index 0a65e9be..45d88d56 100644 --- a/backend/app/workers/tasks/izyatie_ocr_ingest.py +++ b/backend/app/workers/tasks/izyatie_ocr_ingest.py @@ -110,6 +110,25 @@ _UPSERT_NO_ACT_SQL = text( ) +# Прежний разбор того же участка из ТОГО ЖЕ документа, но с другим номером акта (#2982). +# +# act_number входит в ключ конфликта, поэтому, когда разбор номера меняется, новая +# строка с ним не конфликтует со старой и ложится рядом. Так и было бы при починке +# регекса номера: 27 строк на проде записаны с act_number IS NULL, следующий прогон +# добавил бы 27 таких же с номером, а reservation_lookup показал бы каждое изъятие +# дважды. Один документ = один акт (номер разбирается один раз на весь PDF), значит +# строка с тем же (cad_num, doc_url) и другим номером — устаревший разбор той же +# записи, а не второе основание. Участок в двух РАЗНЫХ документах не затрагивается. +_DELETE_STALE_PARSE_SQL = text( + """ + DELETE FROM land_reservation + WHERE cad_num = CAST(:cad_num AS text) + AND doc_url = CAST(:doc_url AS text) + AND act_number IS DISTINCT FROM CAST(:act_number AS text) + """ +) + + def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int: """UPSERT записей в land_reservation. Возвращает число успешно обработанных строк.""" count = 0 @@ -117,6 +136,7 @@ def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int: upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL try: with db.begin_nested(): # SAVEPOINT per-row + db.execute(_DELETE_STALE_PARSE_SQL, row) db.execute(upsert_sql, row) count += 1 except Exception as exc: diff --git a/backend/tests/services/scrapers/test_2982_act_number.py b/backend/tests/services/scrapers/test_2982_act_number.py new file mode 100644 index 00000000..a13eed07 --- /dev/null +++ b/backend/tests/services/scrapers/test_2982_act_number.py @@ -0,0 +1,103 @@ +"""act_number извлекается у постановлений Администрации Екатеринбурга (#2982). + +Регекс номера требовал суффикс областных актов (`-ПП/-ПА/-РП/-ПГ/-ГП/-МО`). У +постановлений Администрации города его нет, поэтому на проде `act_number` пуст у +всех 27 строк `land_reservation` (замер 17.09.2026: 27 строк, заполнено 0). + +Фрагменты — дословный OCR пяти прод-документов из test_2464_act_date_citation +(распознаны `ocr_pdf_text` в прод-контейнере). Ожидаемые номера — из самих PDF: +1413, 259, 2687, 863, 1504. Помехи в тех же текстах — «Решение Думы № 60/1» +(Генплан) и «Приказ Министерства № 746-П»: их номер браться не должен. + +Тест зовёт `extract_izyatie_records` — путь загрузчика — и краснеет на origin/main +неверным значением (None вместо номера), а не ошибкой сигнатуры. +""" + +from __future__ import annotations + +import pytest + +from app.services.scrapers.izyatie_ocr import extract_izyatie_records +from app.services.scrapers.page_reservation_parser import extract_reservations +from tests.services.scrapers.test_2464_act_date_citation import ( + ДОК_ПЕРЕМЕШАННЫЙ, + ДОК_ПЕРЕМЕШАННЫЙ_2, + ДОК_ПРЯМОЙ, + ДОК_РАЗВЯЗКА, + ДОК_ЭНЕРГОСТРОИТЕЛЕЙ, + ЗАГОЛОВОК, + КАД, +) + +ПЯТЬ_ДОКУМЕНТОВ = [ + pytest.param(ДОК_РАЗВЯЗКА, "1413", "2022-05-27", id="развязка"), + pytest.param(ДОК_ЭНЕРГОСТРОИТЕЛЕЙ, "259", "2020-02-12", id="энергостроителей"), + pytest.param(ДОК_ПЕРЕМЕШАННЫЙ, "2687", "2015-09-29", id="павлодарская"), + pytest.param(ДОК_ПЕРЕМЕШАННЫЙ_2, "863", "2019-04-19", id="иркутская"), + pytest.param(ДОК_ПРЯМОЙ, "1504", "2019-06-24", id="татищева"), +] + + +def _запись(текст: str) -> dict: + записи = extract_izyatie_records(f"{текст}\n{КАД}", ЗАГОЛОВОК, "http://x/y") + assert записи, "кад-номер не разобран — тест смотрел бы не туда" + return записи[0] + + +@pytest.mark.parametrize(("текст", "номер", "дата"), ПЯТЬ_ДОКУМЕНТОВ) +def test_act_number_of_ekb_administration_resolution(текст: str, номер: str, дата: str) -> None: + """Головной: номер постановления без суффикса извлекается, и это номер того же акта, + чья дата стоит в act_date, — а не Решения Думы или приказа Министерства.""" + запись = _запись(текст) + assert запись["act_number"] == номер + assert запись["act_date"] == дата + + +@pytest.mark.parametrize(("текст", "номер", "_дата"), ПЯТЬ_ДОКУМЕНТОВ) +def test_page_parser_takes_the_same_numbers(текст: str, номер: str, _дата: str) -> None: + """Второе место того же дефекта — page_reservation_parser (pravo.gov66). + + Там номер — первое «№» в тексте. В двух документах первыми идут «№ 60/1» и + «№ 746-П»: их нельзя ни взять, ни обрезать до «60»/«746». + """ + assert extract_reservations(f"{текст}\n{КАД}")[0].act_number == номер + + +def test_citations_alone_give_no_number() -> None: + """Контроль: без постановления — None, а не номер Генплана или приказа.""" + текст = ( + "Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»;\n" + "Приказ Министерства строительства и развития инфраструктуры Свердловской " + "области от 30.12.2021 № 746-П" + ) + assert _запись(текст)["act_number"] is None + assert extract_reservations(f"{текст}\n{КАД}")[0].act_number is None + + +def test_number_belongs_to_the_act_whose_date_is_taken() -> None: + """Контроль связки: номер берётся у того же акта, что и дата, а не первое «№». + + Тот же документ «развязка», но у приказа Министерства номер без суффикса. + Первое подходящее «№» в тексте — «746», дата при этом у постановления № 1413: + в строке оказалась бы пара реквизитов от двух разных документов. + """ + текст = ДОК_РАЗВЯЗКА.replace("№ 746-П", "№ 746") + запись = _запись(текст) + assert (запись["act_date"], запись["act_number"]) == ("2022-05-27", "1413") + + +def test_page_parser_prefers_own_number_over_cited_regional_act() -> None: + """Контроль порядка: собственный номер в шапке без суффикса не пропускается ради + следующего «№ …-ПП» — номера цитируемого постановления Правительства.""" + текст = ( + "ПРИКАЗ\nот 12.03.2024 № 1234\nОб изъятии земельных участков\n" + "В соответствии с постановлением Правительства Свердловской области " + f"от 26.02.2020 № 100-ПП\n{КАД}" + ) + assert extract_reservations(текст)[0].act_number == "1234" + + +def test_regional_suffix_still_extracted() -> None: + """Контроль от переусердствования: областной номер с суффиксом не потерян.""" + assert _запись("Постановление № 509-ПП от 12.03.2024")["act_number"] == "509-ПП" + assert _запись("Постановление Правительства от 12.03.2024 № 509-ПП")["act_number"] == "509-ПП" diff --git a/backend/tests/sql/test_2982_land_reservation_reparse.py b/backend/tests/sql/test_2982_land_reservation_reparse.py new file mode 100644 index 00000000..23be005c --- /dev/null +++ b/backend/tests/sql/test_2982_land_reservation_reparse.py @@ -0,0 +1,134 @@ +"""Починка разбора номера не удваивает land_reservation (#2982). + +act_number входит в ключ `UNIQUE NULLS NOT DISTINCT (cad_num, act_number)`. На +проде 27 строк записаны с `act_number IS NULL`. Когда номер начинает извлекаться, +строка (участок, «1413») с (участок, NULL) не конфликтует — без очистки прогон +кладёт вторую строку рядом, и reservation_lookup показывает изъятие дважды. + +Проверяется `_upsert_records` загрузчика целиком на временной таблице той же +формы: боевые данные не читаются и не меняются. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from app.workers.tasks.izyatie_ocr_ingest import _upsert_records + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +_TABLE = """ +CREATE TEMP TABLE land_reservation ( + id bigserial PRIMARY KEY, + cad_num text NOT NULL, + reservation_kind text NOT NULL, + basis_act text NOT NULL, + act_number text, + act_date date, + purpose text, + doc_url text, + source text NOT NULL DEFAULT 'page_pdf', + is_active boolean NOT NULL DEFAULT true, + raw_excerpt text, + fetched_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_t UNIQUE NULLS NOT DISTINCT (cad_num, act_number) +) ON COMMIT DROP; +""" + +КАД = "66:41:0303004:22" +ДОК = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8" + + +def _row(cad: str = КАД, act: str | None = "1413", url: str = ДОК) -> dict: + return { + "cad_num": cad, + "reservation_kind": "изъятие", + "basis_act": "Сообщение о планируемом изъятии", + "act_number": act, + "act_date": "2022-05-27", + "purpose": None, + "doc_url": url, + "source": "izyatie_ekb_ocr", + "raw_excerpt": None, + } + + +@pytest.fixture +def db(): + engine = create_engine(_dsn()) + session = sessionmaker(bind=engine)() + try: + session.execute(text(_TABLE)) + n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar() + assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)" + yield session + finally: + session.rollback() + session.close() + engine.dispose() + + +def _rows(db) -> list[tuple[str, str | None, str]]: + return [ + (r[0], r[1], r[2]) + for r in db.execute( + text("SELECT cad_num, act_number, doc_url FROM land_reservation ORDER BY 1, 2, 3") + ).all() + ] + + +def test_numbered_parse_replaces_numberless_row_of_same_document(db) -> None: + """Головной: прод-состояние (номер NULL) + прогон с номером → одна строка с номером.""" + _upsert_records(db, [_row(act=None)]) + assert _rows(db) == [(КАД, None, ДОК)] + + assert _upsert_records(db, [_row(act="1413")]) == 1 + assert _rows(db) == [(КАД, "1413", ДОК)], "строка без номера осталась рядом с новой" + + _upsert_records(db, [_row(act="1413")]) + assert _rows(db) == [(КАД, "1413", ДОК)], "повторный прогон не идемпотентен" + + +def test_same_parcel_in_another_document_is_kept(db) -> None: + """Контроль ширины: участок в ДРУГОМ документе — другое основание, его не трогаем.""" + другой = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5" + _upsert_records(db, [_row(act="259", url=другой)]) + _upsert_records(db, [_row(act=None)]) + _upsert_records(db, [_row(act="1413")]) + assert _rows(db) == [(КАД, "1413", ДОК), (КАД, "259", другой)] + + +def test_other_parcels_of_same_document_are_kept(db) -> None: + """Контроль: чистка идёт по участку, а не по всему документу.""" + _upsert_records(db, [_row(cad="66:41:0303004:23", act=None)]) + _upsert_records(db, [_row(act="1413")]) + assert _rows(db) == [("66:41:0303004:22", "1413", ДОК), ("66:41:0303004:23", None, ДОК)]