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

Merged
bot-backend merged 1 commit from fix/ptica-reservation-act-number into main 2026-09-17 09:16:10 +00:00
5 changed files with 310 additions and 22 deletions
Showing only changes of commit 7501fef5c3 - Show all commits

View file

@ -30,11 +30,15 @@ _EXCERPT_RADIUS = 100 # символов вокруг первого кад-н
# 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр. # 66:41:NNNNNNN:NN — регион:район:квартал:номер. Квартал 6 или 7 цифр.
_RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b") _RE_CAD_NUM = re.compile(r"\b(66:\d{2}:\d{6,7}:\d+)\b")
# Паттерн номера акта: «№ NNN от DD.MM.YYYY» или «№ NNN-ПП». # Номер акта: «№ 1413», «№ 509-ПП». Суффикс необязателен (#2982): прежний шаблон
_RE_ACT_NUMBER = re.compile( # требовал областной (-ПП/-ПА/…), а у постановлений Администрации Екатеринбурга
r"\s*(\d[\d\s]*[\-–—]?\s*(?:ПП|ПА|РП|ПГ|ГП|МО))\b", # его нет — на проде номер не извлёкся ни у одной из 27 строк. Хвост (?!…) не
re.UNICODE | re.IGNORECASE, # даёт обрезать чужой номер: из «№ 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})") _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) 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( act_date = _extract_act_date(doc_title) or _extract_act_date(
normalized, require_act_context=True normalized, require_act_context=True
) )
@ -289,9 +295,26 @@ def extract_izyatie_records(
# ── Вспомогательные функции ──────────────────────────────────────────────────── # ── Вспомогательные функции ────────────────────────────────────────────────────
def _extract_act_number(text: str) -> str | None: def _extract_act_number(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает номер акта из текста (первое вхождение «№ NNN-ПП»).""" """Извлекает номер акта.
Без 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) 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: if not m:
return None return None
return re.sub(r"\s+", "", m.group(1)) 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) 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: def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE. """Извлекает дату акта «от 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): m = _act_date_match(text, require_act_context=require_act_context)
if require_act_context and not _act_context_matches(text, m.start()): if m is None:
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 return None
return f"{m.group(3)}-{m.group(2)}-{m.group(1)}"
def _extract_purpose(text: str) -> str | None: def _extract_purpose(text: str) -> str | None:

View file

@ -31,8 +31,13 @@ _RE_CAD_NUM = re.compile(r"\b(\d{2}:\d{2}:\d{6,7}:\d+)\b")
# Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п. # Номер постановления: 509-ПП, 1234-ПА, 55-ПП, NNN-РП и т.п.
# Паттерн намеренно широкий — постановления РФ имеют разные суффиксы. # Паттерн намеренно широкий — постановления РФ имеют разные суффиксы.
# Суффикс необязателен (#2982): у муниципальных актов и приказов его нет («№ 1413»),
# и прежний шаблон пропускал собственный номер акта в шапке, забирая следующий
# «№ …-ПП» — номер чужого, цитируемого постановления. Хвост (?!…) отбрасывает
# номер с суффиксом не из списка целиком, а не обрезает его: «№ 218-ФЗ»,
# «№ 746-П» и «№ 60/1» не дают ни «218», ни «746», ни «60».
_RE_ACT_NUMBER = re.compile( _RE_ACT_NUMBER = re.compile(
r"\s*(\d+[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))\b", r"\s*(\d+(?:[\s\-–—]?(?:[А-Яа-яA-Za-z]+[\-–—]?)*(?:ПП|ПА|РП|ПГ|ГП|МО))?)(?![\w/\-–—])",
re.UNICODE, re.UNICODE,
) )

View file

@ -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: def _upsert_records(db: Session, records: list[dict[str, Any]]) -> int:
"""UPSERT записей в land_reservation. Возвращает число успешно обработанных строк.""" """UPSERT записей в land_reservation. Возвращает число успешно обработанных строк."""
count = 0 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 upsert_sql = _UPSERT_WITH_ACT_SQL if row.get("act_number") else _UPSERT_NO_ACT_SQL
try: try:
with db.begin_nested(): # SAVEPOINT per-row with db.begin_nested(): # SAVEPOINT per-row
db.execute(_DELETE_STALE_PARSE_SQL, row)
db.execute(upsert_sql, row) db.execute(upsert_sql, row)
count += 1 count += 1
except Exception as exc: except Exception as exc:

View file

@ -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-ПП"

View file

@ -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, ДОК)]