feat(ingest+поиск): приём ZIP через интерфейс, регистронезависимый поиск, чистка очереди
- Загрузка архива (§4.1): POST /ingest распаковывает ZIP, дораспознаёт и догружает прайсы в базу (append). Виджет в админке. Кэш эмбеддингов справочника (dict_emb.npy) — догрузка считает только новый файл, эмбеддинги best-effort с откатом на fuzzy при сбое Gemini. - Поиск по name_norm (lowercase): «УЗИ» и «узи» дают одинаковый результат — SQLite LIKE не сворачивает регистр для кириллицы. - Фильтр названий: код-мнемоники («МОЗО.15») больше не попадают в очередь. - /stats считается из базы — цифры на дашборде обновляются после загрузки. - Деплой через Dockerfile (зависимости в образе, код монтируется томом). - ruff: ядро и весь репозиторий проходят проверку. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
PDF с битым текстовым слоем помечается как scan_pdf — его добирает путь через Vision.
|
||||
Любой сбой чтения логируется, но не роняет обработку остального архива.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -26,7 +27,9 @@ def extract(path: str, use_vision: bool = True) -> ParsedDocument:
|
||||
"""
|
||||
name = Path(path).name
|
||||
fmt = readers.detect_format(path)
|
||||
doc = ParsedDocument(file_name=name, file_format=fmt, partner_name=readers.partner_from_name(name))
|
||||
doc = ParsedDocument(
|
||||
file_name=name, file_format=fmt, partner_name=readers.partner_from_name(name)
|
||||
)
|
||||
|
||||
year = readers.year_from_name(name)
|
||||
if year:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
в числах, многотарифные колонки цен, строки-заголовки секций и шапку не в первой
|
||||
строке таблицы.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
@@ -85,7 +86,12 @@ def is_real_name(text) -> bool:
|
||||
"""
|
||||
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)
|
||||
if cyrillic < 3 or looks_like_code(s):
|
||||
return False
|
||||
# Код-мнемоника: цифры + только заглавные буквы + без пробела (напр. «МОЗО.15») — не услуга.
|
||||
if any(c.isdigit() for c in s) and " " not in s and not any(c.islower() for c in s):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def classify_price_tier(header_text) -> str | None:
|
||||
|
||||
+22
-5
@@ -9,6 +9,7 @@
|
||||
- настоящая цена медуслуги — сотни и тысячи тенге, а не мелкое число;
|
||||
- название услуги — самая «кириллическая» колонка, код — цифро-точечная.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
@@ -97,7 +98,11 @@ def _assign_columns(grid: Grid, header_idx: int):
|
||||
|
||||
# --- колонка названия: явный заголовок, иначе самая кириллическая ---
|
||||
name_col = next(
|
||||
(c for c in range(ncol) if c not in price_cols and any(k in header[c].lower() for k in _NAME_KEYS)),
|
||||
(
|
||||
c
|
||||
for c in range(ncol)
|
||||
if c not in price_cols and any(k in header[c].lower() for k in _NAME_KEYS)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if name_col is None:
|
||||
@@ -109,7 +114,9 @@ def _assign_columns(grid: Grid, header_idx: int):
|
||||
(
|
||||
c
|
||||
for c in range(ncol)
|
||||
if c != name_col and c not in price_cols and any(k in header[c].lower() for k in _CODE_HEADER_KEYS)
|
||||
if c != name_col
|
||||
and c not in price_cols
|
||||
and any(k in header[c].lower() for k in _CODE_HEADER_KEYS)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -123,7 +130,11 @@ def _assign_columns(grid: Grid, header_idx: int):
|
||||
break
|
||||
|
||||
unit_col = next(
|
||||
(c for c in range(ncol) if c not in price_cols and any(k in header[c].lower() for k in _UNIT_KEYS)),
|
||||
(
|
||||
c
|
||||
for c in range(ncol)
|
||||
if c not in price_cols and any(k in header[c].lower() for k in _UNIT_KEYS)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return name_col, code_col, unit_col, price_cols
|
||||
@@ -165,10 +176,16 @@ def extract_rows_from_grid(grid: Grid) -> list[RawRow]:
|
||||
RawRow(
|
||||
service_name_raw=name,
|
||||
service_code_source=(
|
||||
r[code_col] if code_col is not None and code_col < len(r) and r[code_col] else None
|
||||
r[code_col]
|
||||
if code_col is not None and code_col < len(r) and r[code_col]
|
||||
else None
|
||||
),
|
||||
prices=prices,
|
||||
unit=(r[unit_col] if unit_col is not None and unit_col < len(r) and r[unit_col] else None),
|
||||
unit=(
|
||||
r[unit_col]
|
||||
if unit_col is not None and unit_col < len(r) and r[unit_col]
|
||||
else None
|
||||
),
|
||||
section=section,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
многолистовых книг и многотабличных PDF. Сырой текст нужен для аудита и для
|
||||
детектора битого текстового слоя.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
@@ -29,14 +30,19 @@ def partner_from_name(name: str) -> str:
|
||||
|
||||
def detect_format(path: str) -> str:
|
||||
ext = Path(path).suffix.lower()
|
||||
return {".xlsx": "xlsx", ".xls": "xls", ".docx": "docx", ".pdf": "pdf"}.get(ext, ext.lstrip("."))
|
||||
return {".xlsx": "xlsx", ".xls": "xls", ".docx": "docx", ".pdf": "pdf"}.get(
|
||||
ext, ext.lstrip(".")
|
||||
)
|
||||
|
||||
|
||||
def docx_grids(path: str) -> tuple[list[Grid], str]:
|
||||
import docx
|
||||
|
||||
document = docx.Document(path)
|
||||
grids = [[[clean(cell.text) for cell in row.cells] for row in table.rows] for table in document.tables]
|
||||
grids = [
|
||||
[[clean(cell.text) for cell in row.cells] for row in table.rows]
|
||||
for table in document.tables
|
||||
]
|
||||
text = "\n".join(p.text for p in document.paragraphs)
|
||||
return grids, text
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
вернуть позиции прайса строго по JSON-схеме. Требует переменную окружения
|
||||
GEMINI_API_KEY; модель задаётся через GEMINI_MODEL (по умолчанию gemini-2.0-flash).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
Reference in New Issue
Block a user