feat: Фаза 0 — каркас, контракты, загрузчик справочника
- db/migrations/001_init.sql — схема (4 сущности, prices jsonb, pgvector/pg_trgm) - contracts/models.py — общие модели (RawRow, ParsedDocument, MatchResult, *Out) - etl/dictionary.py — загрузчик справочника (1281 услуга, нормализация) - api/main.py — FastAPI: 7 эндпоинтов ТЗ + /stats (контракт) - infra/ — docker-compose (pg+pgvector, api, web), Caddyfile - scripts/proof/phase0_proof.py — проверка каскада на реальных данных (35% без эмбеддингов) - data/ в .gitignore: данные клиник в публичный репо не коммитятся Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Скопируй в .env и заполни значения. Файл .env в .gitignore — в публичный
|
||||
# репозиторий секреты не попадают.
|
||||
|
||||
# Пароль базы данных PostgreSQL
|
||||
DB_PASSWORD=
|
||||
|
||||
# Строка подключения к базе (для локального запуска API вне Docker)
|
||||
DATABASE_URL=postgresql://medarchive:CHANGE_ME@localhost:5432/medarchive
|
||||
|
||||
# Ключ Gemini для распознавания сложных страниц (Vision). Получить заранее.
|
||||
GEMINI_API_KEY=
|
||||
+2
-3
@@ -24,7 +24,6 @@ pitch/deck/reveal/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Данные (реальные прайсы НЕ коммитим; синтетика в fixtures/raw — коммитим)
|
||||
data/raw/
|
||||
data/uploads/
|
||||
# Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим
|
||||
data/
|
||||
*.sqlite
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"""MedArchive API — эндпоинты ТЗ §4.5 плюс /stats для дашборда.
|
||||
|
||||
Здесь зафиксирован контракт API (пути, параметры, модели ответа). Реализация
|
||||
запросов к БД — задача агента D; пока эндпоинты возвращают пустые заготовки,
|
||||
чтобы приложение поднималось и фронт мог разрабатываться на сгенерированной
|
||||
OpenAPI-схеме.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from contracts.models import PartnerOut, PriceOut, ServiceOut, Stats # noqa: E402
|
||||
|
||||
app = FastAPI(
|
||||
title="MedArchive API",
|
||||
version="0.1.0",
|
||||
description="Поиск услуг и цен по архиву прайсов клиник-партнёров.",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/services", response_model=list[ServiceOut], summary="Услуги справочника")
|
||||
def list_services(specialty: str | None = Query(None, description="Фильтр по специальности")):
|
||||
return [] # TODO(D): выборка из таблицы service
|
||||
|
||||
|
||||
@app.get("/services/{service_id}/partners", response_model=list[PriceOut],
|
||||
summary="Кто оказывает услугу и по какой цене")
|
||||
def service_partners(service_id: str):
|
||||
return [] # TODO(D): партнёры с ценами по услуге (ядро WOW-сравнения)
|
||||
|
||||
|
||||
@app.get("/partners", response_model=list[PartnerOut], summary="Партнёры")
|
||||
def list_partners(city: str | None = None, is_active: bool | None = None):
|
||||
return [] # TODO(D)
|
||||
|
||||
|
||||
@app.get("/partners/{partner_id}/services", response_model=list[PriceOut],
|
||||
summary="Все услуги партнёра с ценами")
|
||||
def partner_services(partner_id: str):
|
||||
return [] # TODO(D)
|
||||
|
||||
|
||||
@app.get("/search", summary="Полнотекстовый поиск по услугам и партнёрам")
|
||||
def search(q: str = Query(..., min_length=1)):
|
||||
return {"services": [], "partners": []} # TODO(D)
|
||||
|
||||
|
||||
@app.get("/unmatched", summary="Несопоставленные позиции (для операторов)")
|
||||
def unmatched(limit: int = 50, offset: int = 0):
|
||||
return [] # TODO(D)
|
||||
|
||||
|
||||
@app.post("/match", summary="Ручное сопоставление позиции со справочником")
|
||||
def manual_match(item_id: str, service_id: str):
|
||||
return {"item_id": item_id, "service_id": service_id, "status": "todo"} # TODO(D)
|
||||
|
||||
|
||||
@app.get("/stats", response_model=Stats, summary="Сводка качества обработки")
|
||||
def stats():
|
||||
return Stats() # TODO(D): реальные метрики из БД
|
||||
|
||||
|
||||
@app.get("/health", summary="Проверка живости")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Общие модели данных — контракт между агентами (Pydantic v2).
|
||||
|
||||
`RawRow` и `ParsedDocument` — выход любого парсера (граница агентов B → C).
|
||||
`MatchResult` — выход сопоставления (агент C). `*Out`-модели — выдача API (агент D),
|
||||
её же потребляет фронт (агент E). Любая правка этих типов проходит через оркестратора.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Карта тарифов: ключ — тип цены, значение — сумма в KZT.
|
||||
# Возможные ключи: resident, nonresident, cis, far, insurance, partner.
|
||||
PriceTiers = dict[str, float]
|
||||
|
||||
|
||||
class RawRow(BaseModel):
|
||||
"""Одна сырая строка прайса — единый выход любого парсера."""
|
||||
|
||||
service_name_raw: str
|
||||
service_code_source: str | None = None
|
||||
prices: PriceTiers = Field(default_factory=dict)
|
||||
unit: str | None = None
|
||||
section: str | None = None # заголовок секции — контекст специальности
|
||||
|
||||
|
||||
class ParsedDocument(BaseModel):
|
||||
"""Результат разбора одного файла прайса."""
|
||||
|
||||
file_name: str
|
||||
file_format: str # pdf / scan_pdf / docx / xlsx / xls
|
||||
partner_name: str | None = None
|
||||
city: str | None = None
|
||||
effective_date: date | None = None
|
||||
rows: list[RawRow] = Field(default_factory=list)
|
||||
raw_content: str = ""
|
||||
parse_log: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MatchResult(BaseModel):
|
||||
"""Результат сопоставления строки со справочником."""
|
||||
|
||||
service_id: str | None = None
|
||||
method: str | None = None # code / exact / embedding / fuzzy / manual
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
# --- выдача API ---
|
||||
class ServiceOut(BaseModel):
|
||||
service_id: str
|
||||
specialty: str
|
||||
name_ru: str
|
||||
tarificator_code: str | None = None
|
||||
|
||||
|
||||
class PartnerOut(BaseModel):
|
||||
partner_id: str
|
||||
name: str
|
||||
city: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class PriceOut(BaseModel):
|
||||
partner_id: str
|
||||
partner_name: str
|
||||
price_resident_kzt: float | None = None
|
||||
price_nonresident_kzt: float | None = None
|
||||
prices: PriceTiers = Field(default_factory=dict)
|
||||
effective_date: date | None = None
|
||||
|
||||
|
||||
class Stats(BaseModel):
|
||||
"""Сводка для дашборда и отчёта о качестве."""
|
||||
|
||||
documents_total: int = 0
|
||||
documents_done: int = 0
|
||||
items_total: int = 0
|
||||
auto_matched_pct: float = 0.0
|
||||
unmatched_total: int = 0
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Схема MedArchive. Четыре сущности из ТЗ §3, адаптированные под реальные данные:
|
||||
-- многотарифные цены (резидент/нерезидент/СНГ/…) хранятся в prices (jsonb),
|
||||
-- а два главных тарифа продублированы отдельными колонками под ТЗ и API.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector для семантического сопоставления
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- триграммы для нечёткого поиска
|
||||
|
||||
-- Эталонный справочник услуг (1281 позиция, загружается из xlsx).
|
||||
CREATE TABLE service (
|
||||
service_id text PRIMARY KEY, -- «<ID>-<Code>» из справочника
|
||||
specialty text NOT NULL DEFAULT '', -- специальность = категория + сужение поиска
|
||||
name_ru text NOT NULL,
|
||||
name_norm text NOT NULL, -- нормализованная форма для точного совпадения
|
||||
tarificator_code text, -- код тарификатора, если задан
|
||||
synonyms jsonb NOT NULL DEFAULT '[]'::jsonb, -- наращиваем при ручной верификации
|
||||
embedding vector(384), -- эмбеддинг name_ru (multilingual)
|
||||
is_active boolean NOT NULL DEFAULT true
|
||||
);
|
||||
CREATE INDEX service_tarificator_idx ON service (tarificator_code) WHERE tarificator_code IS NOT NULL;
|
||||
CREATE INDEX service_name_trgm_idx ON service USING gin (name_ru gin_trgm_ops);
|
||||
CREATE INDEX service_embedding_idx ON service USING hnsw (embedding vector_cosine_ops);
|
||||
|
||||
-- Клиника-партнёр.
|
||||
CREATE TABLE partner (
|
||||
partner_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
city text,
|
||||
address text,
|
||||
bin varchar(12), -- БИН организации, для дедупликации
|
||||
contact_email text,
|
||||
contact_phone text,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX partner_bin_idx ON partner (bin) WHERE bin IS NOT NULL;
|
||||
|
||||
-- Исходный прайс-документ (один файл = прайс одной клиники на дату).
|
||||
CREATE TABLE price_document (
|
||||
doc_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
partner_id uuid NOT NULL REFERENCES partner(partner_id),
|
||||
file_name text NOT NULL,
|
||||
file_format text NOT NULL, -- pdf / scan_pdf / docx / xlsx / xls
|
||||
effective_date date, -- дата вступления в силу (год из имени файла)
|
||||
parsed_at timestamptz,
|
||||
parse_status text NOT NULL DEFAULT 'pending', -- pending/processing/done/error/needs_review
|
||||
parse_log text,
|
||||
raw_content text -- сырой извлечённый текст для аудита
|
||||
);
|
||||
|
||||
-- Позиция прайса (услуга + цены). Это рабочая лошадка системы.
|
||||
CREATE TABLE price_item (
|
||||
item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
doc_id uuid NOT NULL REFERENCES price_document(doc_id),
|
||||
partner_id uuid NOT NULL REFERENCES partner(partner_id), -- денормализация ради скорости
|
||||
service_name_raw text NOT NULL, -- название как в документе
|
||||
service_code_source text, -- код из источника (внутренний или тарификатора)
|
||||
service_id text REFERENCES service(service_id), -- нормализованная услуга (nullable)
|
||||
prices jsonb NOT NULL DEFAULT '{}'::jsonb, -- {resident, nonresident, cis, far, insurance, partner}
|
||||
price_resident_kzt numeric, -- главный тариф, продублирован под ТЗ/API
|
||||
price_nonresident_kzt numeric,
|
||||
currency_original text NOT NULL DEFAULT 'KZT',
|
||||
unit text,
|
||||
effective_date date,
|
||||
map_method text, -- code/exact/embedding/fuzzy/manual
|
||||
map_confidence numeric,
|
||||
is_verified boolean NOT NULL DEFAULT false,
|
||||
verification_note text,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX price_item_service_idx ON price_item (service_id);
|
||||
CREATE INDEX price_item_partner_idx ON price_item (partner_id);
|
||||
CREATE INDEX price_item_unmatched_idx ON price_item (service_id) WHERE service_id IS NULL;
|
||||
CREATE INDEX price_item_raw_trgm_idx ON price_item USING gin (service_name_raw gin_trgm_ops);
|
||||
-- Активная позиция уникальна по клинике, названию и дате прайса (версионирование — через is_active).
|
||||
CREATE UNIQUE INDEX price_item_active_idx
|
||||
ON price_item (partner_id, service_name_raw, effective_date) WHERE is_active;
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Загрузчик целевого справочника медицинских услуг.
|
||||
|
||||
Справочник (`Справочник услуг.xlsx`) — это эталон, к которому нормализуются все
|
||||
извлечённые из прайсов позиции. Колонки исходного файла:
|
||||
|
||||
ID | Специальность | Code | Name_ru | TarificatrCode
|
||||
|
||||
Синонимов в справочнике нет, поэтому автоматическое сопоставление опирается на
|
||||
код тарификатора (точное совпадение) и на эмбеддинги названий. Этот модуль
|
||||
отвечает только за загрузку и подготовку индексов; само сопоставление — в
|
||||
`etl/normalize`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# Код тарификатора в формате РК: буква + три группы цифр, например «A02.004.000».
|
||||
TARIFICATOR_RE = re.compile(r"^[A-ZА-Я]\d{2}\.\d{3}\.\d{3}$")
|
||||
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def normalize_name(text: str | None) -> str:
|
||||
"""Привести название услуги к форме для сравнения.
|
||||
|
||||
Нижний регистр, унификация «ё»→«е», дефисы и кратные пробелы схлопываются в
|
||||
один пробел. Исходное написание при этом нигде не теряется — нормализованная
|
||||
форма используется только как ключ сопоставления.
|
||||
"""
|
||||
s = (text or "").strip().lower().replace("ё", "е")
|
||||
s = s.replace("-", " ")
|
||||
return _WHITESPACE_RE.sub(" ", s).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Service:
|
||||
"""Одна запись эталонного справочника услуг."""
|
||||
|
||||
service_id: str # стабильный идентификатор «<ID>-<Code>»
|
||||
specialty: str # специальность — служит и категорией, и сужением поиска
|
||||
name_ru: str # официальное название
|
||||
tarificator_code: str | None # код тарификатора (если задан)
|
||||
name_norm: str = field(default="") # нормализованная форма name_ru
|
||||
|
||||
|
||||
def load_dictionary(path: str | Path) -> list[Service]:
|
||||
"""Загрузить справочник из xlsx и вернуть список услуг с нормализованными именами.
|
||||
|
||||
Заголовки ищутся по именам колонок, а не по позиции, — так загрузчик переживёт
|
||||
перестановку столбцов. Строки с пустым названием пропускаются.
|
||||
"""
|
||||
workbook = load_workbook(path, read_only=True, data_only=True)
|
||||
sheet = workbook.active
|
||||
rows = sheet.iter_rows(values_only=True)
|
||||
|
||||
header = [str(c).strip() if c is not None else "" for c in next(rows)]
|
||||
col = {name: header.index(name) for name in header if name}
|
||||
name_idx = col.get("Name_ru")
|
||||
if name_idx is None:
|
||||
raise ValueError(f"В справочнике нет колонки Name_ru. Заголовки: {header}")
|
||||
|
||||
services: list[Service] = []
|
||||
for row in rows:
|
||||
raw_name = row[name_idx]
|
||||
if not raw_name or not str(raw_name).strip():
|
||||
continue
|
||||
|
||||
tar_raw = row[col["TarificatrCode"]] if "TarificatrCode" in col else None
|
||||
tar = str(tar_raw).strip() if tar_raw else None
|
||||
if tar and not TARIFICATOR_RE.match(tar):
|
||||
tar = None # отбрасываем мусорные коды, оставляем только валидный формат
|
||||
|
||||
service = Service(
|
||||
service_id=f"{row[col['ID']]}-{row[col['Code']]}",
|
||||
specialty=str(row[col["Специальность"]]).strip() if row[col["Специальность"]] else "",
|
||||
name_ru=str(raw_name).strip(),
|
||||
tarificator_code=tar,
|
||||
)
|
||||
service.name_norm = normalize_name(service.name_ru)
|
||||
services.append(service)
|
||||
|
||||
return services
|
||||
@@ -0,0 +1,15 @@
|
||||
# Маршрутизация MedArchive за Caddy на Selectel KZ.
|
||||
# /api/* — бэкенд, /demo — презентация (reveal.js), остальное — приложение.
|
||||
# Reload только через stdin (sed -i ломает bind mount).
|
||||
med.secondbrain.tools {
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8000
|
||||
}
|
||||
handle /demo* {
|
||||
root * /srv
|
||||
file_server
|
||||
}
|
||||
handle {
|
||||
reverse_proxy web:3000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Стек MedArchive: PostgreSQL+pgvector, API (FastAPI), фронт (Next.js).
|
||||
# Деплой — на Selectel KZ за общим Caddy (см. Caddyfile). Dockerfile'ы api/web
|
||||
# добавляет агент F. Секреты — только из .env (в репозиторий не попадают).
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: medarchive
|
||||
POSTGRES_USER: medarchive
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
- ../db/migrations:/docker-entrypoint-initdb.d:ro # схема накатывается при инициализации
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U medarchive -d medarchive"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: infra/api.Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql://medarchive:${DB_PASSWORD}@db:5432/medarchive
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ../web
|
||||
dockerfile: ../infra/web.Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE: /api
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "medarchive"
|
||||
version = "0.1.0"
|
||||
description = "Автоматическая обработка архива прайсов клиник-партнёров и база услуг с ценами"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic>=2.9",
|
||||
"psycopg[binary]>=3.2",
|
||||
"python-multipart>=0.0.12", # загрузка ZIP-архива
|
||||
"openpyxl>=3.1", # xlsx
|
||||
"xlrd>=2.0", # старый xls
|
||||
"python-docx>=1.1", # docx (+ принятие правок)
|
||||
"pdfplumber>=0.11", # PDF с текстовым слоем
|
||||
"pymupdf>=1.24", # рендер страниц для Vision
|
||||
"rapidfuzz>=3.10", # нечёткое сопоставление
|
||||
"sentence-transformers>=3.3", # многоязычные эмбеддинги
|
||||
"google-generativeai>=0.8", # Gemini Vision
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"] # стиль, ошибки, импорты, апгрейды, баги
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Phase 0 proof: справочник загружается, реальный прайс парсится, каскад работает.
|
||||
|
||||
Считает базовый процент автосопоставления БЕЗ эмбеддингов (только код тарификатора +
|
||||
точное совпадение + RapidFuzz). Эмбеддинги в боевом каскаде поднимут этот процент —
|
||||
здесь нужен нижний ориентир, чтобы убедиться, что цель 70% достижима.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO))
|
||||
|
||||
import docx
|
||||
from rapidfuzz import fuzz, process
|
||||
|
||||
from etl.dictionary import load_dictionary, normalize_name
|
||||
|
||||
svcs = load_dictionary(REPO / "data/reference/dictionary.xlsx")
|
||||
print(f"Справочник: {len(svcs)} услуг | с кодом тарификатора: {sum(1 for s in svcs if s.tarificator_code)}")
|
||||
|
||||
by_code = {s.tarificator_code: s for s in svcs if s.tarificator_code}
|
||||
by_norm: dict[str, object] = {}
|
||||
for s in svcs:
|
||||
by_norm.setdefault(s.name_norm, s)
|
||||
names_norm = [s.name_norm for s in svcs]
|
||||
|
||||
# --- разобрать реальный прайс Клиники 1 (docx, таблица «Код | Наименование | Стоимость») ---
|
||||
doc = docx.Document(REPO / "data/raw/Клиника 1 прайс 2024.docx")
|
||||
table = doc.tables[0]
|
||||
|
||||
items = []
|
||||
for row in table.rows:
|
||||
cells = [c.text.strip() for c in row.cells]
|
||||
if len(cells) < 3:
|
||||
continue
|
||||
code, name, price = cells[0], cells[1], cells[2]
|
||||
if code == name == price: # строка-заголовок секции (объединённые ячейки)
|
||||
continue
|
||||
if name.lower() == "наименование услуги":
|
||||
continue
|
||||
if not re.search(r"\d", price): # строка без цены — не позиция
|
||||
continue
|
||||
items.append((code, name, price))
|
||||
|
||||
print(f"Клиника 1 (2024): извлечено позиций — {len(items)}")
|
||||
|
||||
THRESHOLD = 88
|
||||
matched = by_code_n = by_exact_n = by_fuzzy_n = 0
|
||||
examples_ok, examples_miss = [], []
|
||||
|
||||
for code, name, price in items:
|
||||
nn = normalize_name(name)
|
||||
if code in by_code:
|
||||
matched += 1; by_code_n += 1
|
||||
continue
|
||||
if nn in by_norm:
|
||||
matched += 1; by_exact_n += 1
|
||||
continue
|
||||
best = process.extractOne(nn, names_norm, scorer=fuzz.token_set_ratio)
|
||||
if best and best[1] >= THRESHOLD:
|
||||
matched += 1; by_fuzzy_n += 1
|
||||
if len(examples_ok) < 6:
|
||||
examples_ok.append((name, svcs[best[2]].name_ru, round(best[1])))
|
||||
elif len(examples_miss) < 6 and best:
|
||||
examples_miss.append((name, svcs[best[2]].name_ru, round(best[1])))
|
||||
|
||||
pct = 100 * matched / max(1, len(items))
|
||||
print(f"\nАВТО-СОПОСТАВЛЕНО (без эмбеддингов): {matched}/{len(items)} = {pct:.0f}%")
|
||||
print(f" код тарификатора: {by_code_n} | точное имя: {by_exact_n} | fuzzy≥{THRESHOLD}: {by_fuzzy_n}")
|
||||
print("\nпримеры совпадений:")
|
||||
for raw, canon, sc in examples_ok:
|
||||
print(f" «{raw[:46]}» → «{canon[:46]}» ({sc})")
|
||||
print("\nпримеры в очередь unmatched (лучший кандидат ниже порога):")
|
||||
for raw, canon, sc in examples_miss:
|
||||
print(f" «{raw[:46]}» → лучший «{canon[:40]}» ({sc})")
|
||||
Reference in New Issue
Block a user