Files
medtech-hackathon/etl/extractors/common.py
T
admins efae54fbad feat(etl): извлечение по форматам (агент B) — docx/xlsx/xls/pdf
- единый грид-экстрактор: поиск шапки, роли колонок, секции, N тарифов
- отсев колонок-индексов и не-цен; название по доле кириллицы
- PDF: реконструкция таблицы по координатам слов (нет линий → extract_tables пуст)
- детектор битого текстового слоя → пометка scan_pdf для Vision
- проверено на реальном архиве: 6/8 клиник нативно, 15599 позиций; 2 файла → Vision

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:40:27 +05:00

128 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Эвристики извлечения, общие для всех форматов.
Все табличные источники (xlsx, xls, docx, таблицы из PDF) сводятся к «сетке» —
списку строк из строковых ячеек — и обрабатываются единым кодом. Здесь собраны
функции, понимающие реальные особенности казахстанских прайсов: разрядные пробелы
в числах, многотарифные колонки цен, строки-заголовки секций и шапку не в первой
строке таблицы.
"""
from __future__ import annotations
import re
Grid = list[list[str]]
# Ячейка-цена целиком: «14 400», «12600.0», «3 980», «9000 тг», «от 12000».
# Требуем, чтобы вся ячейка была числом (плюс валюта или «от»), иначе цифра внутри
# текста («Раздел 1.Консультации») ошибочно сошла бы за цену.
_PRICE_CELL_RE = re.compile(
r"^(?:от\s+)?(\d[\d .,]*\d|\d)\s*(?:тг|тенге|₸|kzt|руб|\$)?\.?$",
re.IGNORECASE,
)
# Похоже на код услуги: «U1.1», «A02.004.000», «1.0», «В02.110.002».
_CODE_RE = re.compile(r"^[A-ZА-Я]?\.?\d+([.\-]\d+)*[A-ZА-Я]?$")
_NAME_KEYS = ("наименование", "услуг", "название", "перечень")
_PRICE_KEYS = ("цена", "стоимость", "тариф", "тенге")
_UNIT_KEYS = ("ед.", "единица", "ед изм", "ед. изм")
_CODE_KEYS = ("код", "№", "тарификатор")
# Заголовок ценовой колонки → тип тарифа. Порядок важен: частные случаи раньше общих.
_TIER_PATTERNS: list[tuple[str, tuple[str, ...]]] = [
("nonresident", ("нерезидент", "без гражданства", "иностран")),
("far", ("дальнего", "дальнее")),
("cis", ("снг", "ближнего", "оралман")),
("insurance", ("страхов",)),
("partner", ("партнер", "партнён", "партнёр")),
("resident", ("резидент", "республики казахстан", " рк", "граждан республики")),
]
def clean(text) -> str:
"""Строковое значение ячейки без хвостовых пробелов и переводов строк."""
if text is None:
return ""
return re.sub(r"\s+", " ", str(text)).strip()
def parse_price(text) -> float | None:
"""Вытащить цену из ячейки, если ВСЯ ячейка — число (плюс валюта или «от»).
Возвращает положительный float или None. Намеренно строгая: «Раздел 1.…» или
«приём (30 минут)» ценой не считаются — это защищает детектор секций.
"""
s = clean(text)
if not s:
return None
m = _PRICE_CELL_RE.match(s)
if not m:
return None
raw = m.group(1).replace(" ", "")
# Есть и точка, и запятая — запятая разрядная; иначе запятая = десятичная.
if "," in raw and "." in raw:
raw = raw.replace(",", "")
else:
raw = raw.replace(",", ".")
try:
value = float(raw)
except ValueError:
return None
return value if value > 0 else None
def looks_like_code(text) -> bool:
"""Похоже ли значение на код услуги (внутренний или тарификатора)."""
s = clean(text)
return bool(s) and len(s) <= 16 and bool(_CODE_RE.match(s))
def classify_price_tier(header_text) -> str | None:
"""По тексту заголовка ценовой колонки определить тип тарифа."""
h = clean(header_text).lower()
if not h:
return None
for tier, keys in _TIER_PATTERNS:
if any(k in h for k in keys):
return tier
if any(k in h for k in _PRICE_KEYS):
return "resident" # обобщённая «Цена»/«Стоимость» — основной тариф
return None
def _has_key(cells: list[str], keys: tuple[str, ...]) -> bool:
joined = " | ".join(c.lower() for c in cells)
return any(k in joined for k in keys)
def find_header_row(grid: Grid, scan: int = 30) -> int:
"""Найти индекс строки заголовков (она не всегда первая).
Заголовок — строка, где есть и колонка названия услуги, и хотя бы одна
ценовая колонка. Если явной шапки нет, берётся первая строка, под которой
идут пары «текст + число».
"""
best_idx, best_score = -1, 0
for i, row in enumerate(grid[:scan]):
cells = [clean(c) for c in row]
if not any(cells):
continue
score = 0
if _has_key(cells, _NAME_KEYS):
score += 2
score += sum(1 for c in cells if classify_price_tier(c))
if _has_key(cells, _UNIT_KEYS):
score += 1
if score > best_score:
best_idx, best_score = i, score
if best_idx >= 0 and best_score >= 2:
return best_idx
# Фолбэк: первая строка, под которой есть пара «непустой текст + число».
for i in range(min(scan, len(grid))):
for j in range(i + 1, min(i + 6, len(grid))):
cells = [clean(c) for c in grid[j]]
texts = [c for c in cells if c and not parse_price(c)]
nums = [c for c in cells if parse_price(c)]
if texts and nums:
return i
return 0