feat(etl): нормализация на Gemini-эмбеддингах (API), Vision подключён
- эмбеддинги переведены с локального torch на Gemini API (лёгкий контейнер) GeminiEmbedder: gemini-embedding-001, 768d, RETRIEVAL query/document - фильтр настоящих названий: коды/числа не уходят в сопоставление - порог косинуса 0.70 → ~73% автонормализации (цель ТЗ ≥70%), остальное в unmatched - Vision (gemini-2.5-flash, новый SDK) подключён в диспетчер для скан/нулевых PDF; проверено: Клиника 5 — 34 чистые позиции со страницы - зависимости: убран torch/sentence-transformers, добавлен google-genai+numpy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ PDF с битым текстовым слоем помечается как scan
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -53,6 +54,19 @@ def extract(path: str) -> ParsedDocument:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
doc.parse_log.append(f"ошибка разбора таблицы: {type(exc).__name__}: {exc}")
|
||||
|
||||
if not doc.rows and doc.file_format != "scan_pdf":
|
||||
doc.parse_log.append("позиции не извлечены — возможно, нужен Vision или иной парсер")
|
||||
# Трудные PDF (скан, битый слой, нулевой разбор) добираем через Gemini Vision.
|
||||
needs_vision = fmt == "pdf" and (doc.file_format == "scan_pdf" or not doc.rows)
|
||||
if needs_vision and os.environ.get("GEMINI_API_KEY"):
|
||||
try:
|
||||
from etl.extractors.vision import extract_with_vision
|
||||
|
||||
vision_rows = extract_with_vision(path)
|
||||
doc.rows.extend(vision_rows)
|
||||
doc.file_format = "scan_pdf"
|
||||
doc.parse_log.append(f"Vision добрал {len(vision_rows)} позиций")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
doc.parse_log.append(f"Vision не сработал: {type(exc).__name__}: {exc}")
|
||||
|
||||
if not doc.rows:
|
||||
doc.parse_log.append("позиции не извлечены")
|
||||
return doc
|
||||
|
||||
@@ -76,6 +76,18 @@ def looks_like_code(text) -> bool:
|
||||
return bool(s) and len(s) <= 16 and bool(_CODE_RE.match(s))
|
||||
|
||||
|
||||
def is_real_name(text) -> bool:
|
||||
"""Похоже ли значение на настоящее название услуги, а не на код или число.
|
||||
|
||||
Защищает нормализацию от мусора: коды («E04.000.012»), номера и цены, по ошибке
|
||||
попавшие в колонку названия, не должны уходить в сопоставление со справочником.
|
||||
Требуем минимум три кириллические буквы и не код-подобную форму.
|
||||
"""
|
||||
s = clean(text)
|
||||
cyrillic = sum(1 for ch in s if "а" <= ch.lower() <= "я" or ch.lower() == "ё")
|
||||
return cyrillic >= 3 and not looks_like_code(s)
|
||||
|
||||
|
||||
def classify_price_tier(header_text) -> str | None:
|
||||
"""По тексту заголовка ценовой колонки определить тип тарифа."""
|
||||
h = clean(header_text).lower()
|
||||
|
||||
@@ -25,6 +25,7 @@ from etl.extractors.common import ( # noqa: E402
|
||||
classify_price_tier,
|
||||
clean,
|
||||
find_header_row,
|
||||
is_real_name,
|
||||
looks_like_code,
|
||||
parse_price,
|
||||
)
|
||||
@@ -157,8 +158,8 @@ def extract_rows_from_grid(grid: Grid) -> list[RawRow]:
|
||||
continue
|
||||
|
||||
name = r[name_col] if name_col < len(r) else ""
|
||||
if not name or not prices:
|
||||
continue # без названия или без цены это не позиция прайса
|
||||
if not prices or not is_real_name(name):
|
||||
continue # не позиция: нет цены либо в «названии» код/число, а не услуга
|
||||
|
||||
rows.append(
|
||||
RawRow(
|
||||
|
||||
@@ -33,9 +33,9 @@ _PROMPT = """Ты извлекаешь позиции из прайс-листа
|
||||
|
||||
def _render_pages(path: str, max_pages: int, dpi: int) -> list[bytes]:
|
||||
"""Отрисовать первые страницы PDF в PNG-картинки."""
|
||||
import fitz # PyMuPDF
|
||||
import pymupdf # PyMuPDF
|
||||
|
||||
document = fitz.open(path)
|
||||
document = pymupdf.open(path)
|
||||
images = []
|
||||
for page in document[:max_pages]:
|
||||
images.append(page.get_pixmap(dpi=dpi).tobytes("png"))
|
||||
@@ -75,16 +75,19 @@ def extract_with_vision(path: str, max_pages: int = 12, dpi: int = 140) -> list[
|
||||
if not api_key:
|
||||
raise RuntimeError("нет GEMINI_API_KEY в окружении")
|
||||
|
||||
import google.generativeai as genai
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
genai.configure(api_key=api_key)
|
||||
model = genai.GenerativeModel(os.environ.get("GEMINI_MODEL", "gemini-2.0-flash"))
|
||||
client = genai.Client(api_key=api_key)
|
||||
model = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
|
||||
config = types.GenerateContentConfig(response_mime_type="application/json", temperature=0)
|
||||
|
||||
rows: list[RawRow] = []
|
||||
for png in _render_pages(path, max_pages, dpi):
|
||||
response = model.generate_content(
|
||||
[_PROMPT, {"mime_type": "image/png", "data": png}],
|
||||
generation_config={"response_mime_type": "application/json", "temperature": 0},
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=[types.Part.from_bytes(data=png, mime_type="image/png"), _PROMPT],
|
||||
config=config,
|
||||
)
|
||||
rows.extend(_parse_response(response.text))
|
||||
return rows
|
||||
|
||||
Reference in New Issue
Block a user