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:
+53
-9
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -38,6 +39,14 @@ def db() -> sqlite3.Connection:
|
||||
return conn
|
||||
|
||||
|
||||
_WS = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _norm(text: str | None) -> str:
|
||||
"""Нормализованная форма названия (для запоминания синонима при верификации)."""
|
||||
return _WS.sub(" ", (text or "").strip().lower().replace("ё", "е").replace("-", " ")).strip()
|
||||
|
||||
|
||||
def _price_out(row: sqlite3.Row) -> PriceOut:
|
||||
return PriceOut(
|
||||
partner_id=row["partner_id"],
|
||||
@@ -136,37 +145,72 @@ def search(q: str = Query(..., min_length=1), limit: int = 20):
|
||||
return {"services": services, "partners": partners}
|
||||
|
||||
|
||||
@app.get("/unmatched", summary="Несопоставленные позиции (для операторов)")
|
||||
@app.get("/unmatched", summary="Очередь верификации: позиции с предложенным кандидатом")
|
||||
def unmatched(limit: int = 50, offset: int = 0):
|
||||
sql = """SELECT pi.item_id, pi.service_name_raw, pi.prices, p.name AS partner_name
|
||||
FROM price_item pi JOIN partner p ON pi.partner_id = p.partner_id
|
||||
"""Несопоставленные позиции с лучшим кандидатом из справочника (сортировка — по уверенности)."""
|
||||
sql = """SELECT pi.item_id, pi.service_name_raw, pi.prices, pi.unit, p.name AS partner_name,
|
||||
pi.suggested_service_id, pi.suggested_score,
|
||||
s.name_ru AS suggested_name, s.specialty AS suggested_specialty
|
||||
FROM price_item pi
|
||||
JOIN partner p ON pi.partner_id = p.partner_id
|
||||
LEFT JOIN service s ON pi.suggested_service_id = s.service_id
|
||||
WHERE pi.service_id IS NULL AND pi.is_active = 1
|
||||
ORDER BY pi.item_id LIMIT ? OFFSET ?"""
|
||||
ORDER BY pi.suggested_score DESC, pi.item_id
|
||||
LIMIT ? OFFSET ?"""
|
||||
with db() as conn:
|
||||
return [
|
||||
{
|
||||
"item_id": r["item_id"],
|
||||
"service_name_raw": r["service_name_raw"],
|
||||
"partner_name": r["partner_name"],
|
||||
"unit": r["unit"],
|
||||
"prices": json.loads(r["prices"] or "{}"),
|
||||
"suggested": (
|
||||
{
|
||||
"service_id": r["suggested_service_id"],
|
||||
"name_ru": r["suggested_name"],
|
||||
"specialty": r["suggested_specialty"],
|
||||
"score": r["suggested_score"],
|
||||
}
|
||||
if r["suggested_service_id"]
|
||||
else None
|
||||
),
|
||||
}
|
||||
for r in conn.execute(sql, [limit, offset])
|
||||
]
|
||||
|
||||
|
||||
@app.post("/match", summary="Ручное сопоставление позиции со справочником")
|
||||
@app.get("/unmatched/count", summary="Сколько позиций ещё в очереди верификации")
|
||||
def unmatched_count():
|
||||
with db() as conn:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM price_item WHERE service_id IS NULL AND is_active = 1"
|
||||
).fetchone()[0]
|
||||
learned = conn.execute("SELECT COUNT(*) FROM learned_synonym").fetchone()[0]
|
||||
return {"remaining": n, "learned_synonyms": learned}
|
||||
|
||||
|
||||
@app.post("/match", summary="Ручное сопоставление позиции (с дообучением)")
|
||||
def manual_match(item_id: int, service_id: str):
|
||||
with db() as conn:
|
||||
if not conn.execute("SELECT 1 FROM service WHERE service_id = ?", [service_id]).fetchone():
|
||||
raise HTTPException(404, "услуга справочника не найдена")
|
||||
cur = conn.execute(
|
||||
row = conn.execute(
|
||||
"SELECT service_name_raw FROM price_item WHERE item_id = ?", [item_id]
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "позиция не найдена")
|
||||
conn.execute(
|
||||
"UPDATE price_item SET service_id = ?, map_method = 'manual', map_confidence = 1.0 WHERE item_id = ?",
|
||||
[service_id, item_id],
|
||||
)
|
||||
# Дообучение: запоминаем синоним — следующие прогоны сопоставят его автоматически.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO learned_synonym (name_norm, service_id) VALUES (?, ?)",
|
||||
[_norm(row["service_name_raw"]), service_id],
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "позиция не найдена")
|
||||
return {"item_id": item_id, "service_id": service_id, "status": "matched"}
|
||||
return {"item_id": item_id, "service_id": service_id, "status": "matched", "learned": True}
|
||||
|
||||
|
||||
@app.get("/stats", response_model=Stats, summary="Сводка качества обработки")
|
||||
|
||||
Reference in New Issue
Block a user