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:
@@ -1,4 +1,5 @@
|
||||
"""Нормализация извлечённых позиций к справочнику услуг."""
|
||||
|
||||
from etl.normalize.matcher import Matcher
|
||||
|
||||
__all__ = ["Matcher"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user