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:
2026-06-27 11:35:12 +05:00
parent 89928511ba
commit 69a9884db5
25 changed files with 2217 additions and 182 deletions
+22 -5
View File
@@ -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,
)
)