feat(день2): экран верификации с предложениями + обучение, детектор переплаты
- очередь верификации: предложенный кандидат (топ эмбеддинг), подтвердить/другое/пропустить - POST /match пополняет синонимы (learned_synonym) — обучающаяся нормализация - сравнение: медиана + разброс цен + отклонение клиники (переплата/выгода) - эмбеддер: дедуп названий + ретрай при 429 (квота Gemini) - развёрнуто на med.secondbrain.tools Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,8 @@ class GeminiEmbedder:
|
||||
RETRIEVAL_QUERY: так несвязанные названия расходятся сильнее, чем при
|
||||
SEMANTIC_SIMILARITY, и порог отсечения работает надёжнее.
|
||||
"""
|
||||
import time
|
||||
|
||||
from google.genai import types
|
||||
|
||||
items = [t if t and t.strip() else " " for t in texts]
|
||||
@@ -50,8 +52,16 @@ class GeminiEmbedder:
|
||||
vectors: list[list[float]] = []
|
||||
for start in range(0, len(items), self.batch):
|
||||
chunk = items[start : start + self.batch]
|
||||
response = self._client.models.embed_content(model=self.model, contents=chunk, config=config)
|
||||
vectors.extend(embedding.values for embedding in response.embeddings)
|
||||
for attempt in range(8):
|
||||
try:
|
||||
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)
|
||||
if ("RESOURCE_EXHAUSTED" in str(exc) or "429" in str(exc)) and attempt < 7:
|
||||
time.sleep(20)
|
||||
continue
|
||||
raise
|
||||
|
||||
array = np.array(vectors, dtype=np.float32)
|
||||
if normalize_embeddings:
|
||||
|
||||
@@ -89,23 +89,35 @@ class Matcher:
|
||||
pending_norm.append(name_norm)
|
||||
|
||||
if pending_norm and self.embedder is not None and self.dict_emb is not None:
|
||||
# Дедуп: одинаковые названия эмбеддим один раз (экономит запросы и квоту API).
|
||||
pending_names = [names[i] for i in pending_idx]
|
||||
unique: dict[str, int] = {}
|
||||
for nm in pending_names:
|
||||
unique.setdefault(nm, len(unique))
|
||||
query = self.embedder.encode(
|
||||
[names[i] for i in pending_idx],
|
||||
normalize_embeddings=True,
|
||||
task_type="RETRIEVAL_QUERY",
|
||||
list(unique), normalize_embeddings=True, task_type="RETRIEVAL_QUERY"
|
||||
)
|
||||
sims = query @ self.dict_emb.T # косинус по нормированным векторам
|
||||
best_idx = sims.argmax(axis=1)
|
||||
best_score = sims.max(axis=1)
|
||||
for k, i in enumerate(pending_idx):
|
||||
if best_score[k] >= self.emb_threshold:
|
||||
u = unique[pending_names[k]]
|
||||
suggested = self.services[int(best_idx[u])].service_id
|
||||
score = float(best_score[u])
|
||||
if score >= self.emb_threshold:
|
||||
results[i] = MatchResult(
|
||||
service_id=self.services[int(best_idx[k])].service_id,
|
||||
service_id=suggested,
|
||||
method="embedding",
|
||||
confidence=float(best_score[k]),
|
||||
confidence=score,
|
||||
suggested_service_id=suggested,
|
||||
suggested_score=score,
|
||||
)
|
||||
else:
|
||||
results[i] = self._match_by_fuzzy(pending_norm[k])
|
||||
# Не дотянул до порога — в очередь, но кандидата сохраняем оператору.
|
||||
fallback = self._match_by_fuzzy(pending_norm[k])
|
||||
fallback.suggested_service_id = suggested
|
||||
fallback.suggested_score = score
|
||||
results[i] = fallback
|
||||
else:
|
||||
for k, i in enumerate(pending_idx):
|
||||
results[i] = self._match_by_fuzzy(pending_norm[k])
|
||||
|
||||
Reference in New Issue
Block a user