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
+1
View File
@@ -1,4 +1,5 @@
"""Нормализация извлечённых позиций к справочнику услуг."""
from etl.normalize.matcher import Matcher
__all__ = ["Matcher"]
+4 -1
View File
@@ -4,6 +4,7 @@
раздувается и не держит модель в RAM. Размерность 768 выбрана, чтобы вектор лез в
HNSW-индекс pgvector. Требуется переменная окружения GEMINI_API_KEY.
"""
from __future__ import annotations
import os
@@ -54,7 +55,9 @@ class GeminiEmbedder:
chunk = items[start : start + self.batch]
for attempt in range(8):
try:
response = self._client.models.embed_content(model=self.model, contents=chunk, config=config)
response = self._client.models.embed_content(
model=self.model, contents=chunk, config=config
)
vectors.extend(embedding.values for embedding in response.embeddings)
break
except Exception as exc: # пауза и повтор при превышении квоты (429)
+15 -5
View File
@@ -11,6 +11,7 @@
клиник встречаются с лишним хвостом («A02.020.000.2»), поэтому сравниваем по
канонической части кода тарификатора.
"""
from __future__ import annotations
import re
@@ -37,6 +38,7 @@ class Matcher:
embedder=None,
emb_threshold: float = 0.70,
fuzzy_threshold: int = 88,
dict_emb=None,
):
self.services = services
self.by_code = {s.tarificator_code: s for s in services if s.tarificator_code}
@@ -47,8 +49,10 @@ class Matcher:
self.emb_threshold = emb_threshold
self.fuzzy_threshold = fuzzy_threshold
self.embedder = embedder
self.dict_emb = None
if embedder is not None:
# Готовые эмбеддинги справочника (dict_emb) переиспользуются между прогонами:
# при догрузке нового прайса не нужно заново векторизовать тысячи услуг.
self.dict_emb = dict_emb
if embedder is not None and self.dict_emb is None:
self.dict_emb = embedder.encode(
[s.name_ru for s in services],
normalize_embeddings=True,
@@ -65,7 +69,9 @@ class Matcher:
best = process.extractOne(name_norm, self.names_norm, scorer=fuzz.token_set_ratio)
if best and best[1] >= self.fuzzy_threshold:
return MatchResult(
service_id=self.services[best[2]].service_id, method="fuzzy", confidence=best[1] / 100
service_id=self.services[best[2]].service_id,
method="fuzzy",
confidence=best[1] / 100,
)
return MatchResult()
@@ -78,12 +84,16 @@ class Matcher:
for i, (name, code) in enumerate(zip(names, codes, strict=True)):
service = self._match_by_code(code)
if service:
results[i] = MatchResult(service_id=service.service_id, method="code", confidence=1.0)
results[i] = MatchResult(
service_id=service.service_id, method="code", confidence=1.0
)
continue
name_norm = normalize_name(name)
exact = self.by_norm.get(name_norm)
if exact:
results[i] = MatchResult(service_id=exact.service_id, method="exact", confidence=1.0)
results[i] = MatchResult(
service_id=exact.service_id, method="exact", confidence=1.0
)
continue
pending_idx.append(i)
pending_norm.append(name_norm)