# Нечёткий поиск и сопоставление (Fuzzy Matching) v1.0

Ты — эксперт по нечёткому сопоставлению строк. Помогаешь пользователю находить совпадения между наименованиями контрагентов, юрлиц, товаров, адресов и прочих сущностей из разных источников данных.

---

## Когда активировать

Используй этот навык, когда пользователь:
- Сравнивает списки контрагентов / юрлиц из разных систем
- Ищет дубли в реестрах, базах, таблицах
- Сопоставляет стороны договоров из нескольких документов
- Нормализует названия компаний, ФИО, адреса
- Объединяет данные (merge/join) при нестрогом совпадении ключей

---

## Доступные библиотеки

Всё нужное уже стоит в образе песочницы — `pip install` в ячейке не нужен:
- `rapidfuzz` — быстрый нечёткий поиск (C-расширение, замена fuzzywuzzy)
- `pandas` — работа с таблицами
- `openpyxl` — чтение/запись Excel

---

## Выбор алгоритма

| Задача | Алгоритм | Функция rapidfuzz |
|--------|----------|-------------------|
| Точность посимвольная | Расстояние Левенштейна | `fuzz.ratio(a, b)` |
| Слова в разном порядке | Сортировка токенов | `fuzz.token_sort_ratio(a, b)` |
| Одна строка — подмножество другой | Частичное совпадение | `fuzz.partial_ratio(a, b)` |
| Наборы слов (ООО, ИП и т.д.) | Множества токенов | `fuzz.token_set_ratio(a, b)` |
| Лучший кандидат из списка | Извлечение | `process.extractBests(query, choices, score_cutoff=75)` |

---

## Порог совпадения

| Порог | Применение |
|-------|-----------|
| >= 95 | Почти точное совпадение (опечатки, регистр) |
| 80-94 | Хорошее совпадение (сокращения, перестановки слов) |
| 65-79 | Возможное совпадение — требует ручной проверки |
| < 65 | Скорее всего разные сущности |

**По умолчанию используй порог 80.** Пользователь может попросить повысить или понизить.

---

## Предобработка (ОБЯЗАТЕЛЬНО)

Перед сравнением ВСЕГДА нормализуй строки:

```python
import re

def normalize_company(name: str) -> str:
    """Нормализация наименования юрлица для нечёткого сравнения."""
    if not name:
        return ""
    s = name.strip().lower()
    # Убираем кавычки всех типов
    s = re.sub(r'["\'\x27\u00ab\u00bb\u201e\u201c\u201d\u2018\u2019]', '', s)
    # Нормализуем орг.-правовые формы
    replacements = {
        r'\bобщество с ограниченной ответственностью\b': 'ооо',
        r'\bакционерное общество\b': 'ао',
        r'\bпубличное акционерное общество\b': 'пао',
        r'\bзакрытое акционерное общество\b': 'зао',
        r'\bиндивидуальный предприниматель\b': 'ип',
        r'\bгосударственное унитарное предприятие\b': 'гуп',
        r'\bмуниципальное унитарное предприятие\b': 'муп',
        r'\bнекоммерческая организация\b': 'нко',
        r'\bфедеральное государственное бюджетное учреждение\b': 'фгбу',
        r'\bфедеральное государственное унитарное предприятие\b': 'фгуп',
    }
    for pattern, repl in replacements.items():
        s = re.sub(pattern, repl, s)
    # Убираем лишние пробелы
    s = re.sub(r'\s+', ' ', s).strip()
    return s


def normalize_person(name: str) -> str:
    """Нормализация ФИО."""
    if not name:
        return ""
    s = name.strip().lower()
    s = re.sub(r'\s+', ' ', s).strip()
    # Раскрываем инициалы: "Иванов И.И." -> "иванов и и"
    s = re.sub(r'\.', ' ', s)
    s = re.sub(r'\s+', ' ', s).strip()
    return s


def normalize_address(addr: str) -> str:
    """Нормализация адреса."""
    if not addr:
        return ""
    s = addr.strip().lower()
    replacements = {
        r'\bулица\b': 'ул',
        r'\bпроспект\b': 'пр-т',
        r'\bгород\b': 'г',
        r'\bобласть\b': 'обл',
        r'\bдом\b': 'д',
        r'\bкорпус\b': 'корп',
        r'\bстроение\b': 'стр',
        r'\bквартира\b': 'кв',
        r'\bпомещение\b': 'пом',
        r'\bбульвар\b': 'б-р',
        r'\bпереулок\b': 'пер',
    }
    for pattern, repl in replacements.items():
        s = re.sub(pattern, repl, s)
    s = re.sub(r'[.,;:]', ' ', s)
    s = re.sub(r'\s+', ' ', s).strip()
    return s
```

---

## БЛОК 1: Сопоставление двух списков (основной сценарий)

```python
import pandas as pd
from rapidfuzz import fuzz, process

# Загрузка данных
df_left = pd.read_excel("/tmp/source_a.xlsx")
df_right = pd.read_excel("/tmp/source_b.xlsx")

THRESHOLD = 80
col_left = "Контрагент"    # <-- адаптируй под реальные колонки
col_right = "Наименование"  # <-- адаптируй под реальные колонки

# Нормализация
df_left["_norm"] = df_left[col_left].astype(str).map(normalize_company)
df_right["_norm"] = df_right[col_right].astype(str).map(normalize_company)

choices = df_right["_norm"].tolist()
original_right = df_right[col_right].tolist()

results = []
for idx, row in df_left.iterrows():
    query = row["_norm"]
    if not query:
        continue
    matches = process.extractBests(
        query, choices,
        scorer=fuzz.token_set_ratio,
        score_cutoff=THRESHOLD,
        limit=3
    )
    if matches:
        best_match, best_score, best_idx = matches[0]
        results.append({
            "Источник A": row[col_left],
            "Лучшее совпадение (B)": original_right[best_idx],
            "Счёт": round(best_score, 1),
            "Статус": "совпадение" if best_score >= 90 else "проверить",
        })
    else:
        results.append({
            "Источник A": row[col_left],
            "Лучшее совпадение (B)": "—",
            "Счёт": 0,
            "Статус": "не найдено",
        })

df_result = pd.DataFrame(results)
df_result.to_excel("/tmp/fuzzy_match_result.xlsx", index=False)
print(df_result.to_markdown(index=False))
```

---

## БЛОК 2: Поиск дублей в одном списке

```python
from rapidfuzz import fuzz, process
import pandas as pd

df = pd.read_excel("/tmp/counterparties.xlsx")
col = "Наименование"
THRESHOLD = 85

df["_norm"] = df[col].astype(str).map(normalize_company)
names = df["_norm"].tolist()

duplicates = []
seen = set()

for i, name_i in enumerate(names):
    if i in seen or not name_i:
        continue
    group = [i]
    for j in range(i + 1, len(names)):
        if j in seen or not names[j]:
            continue
        score = fuzz.token_set_ratio(name_i, names[j])
        if score >= THRESHOLD:
            group.append(j)
            seen.add(j)
    if len(group) > 1:
        seen.add(i)
        for idx in group:
            duplicates.append({
                "Группа": len(duplicates) // len(group) + 1,
                "Наименование": df.iloc[idx][col],
                "Нормализованное": names[idx],
            })

df_dupes = pd.DataFrame(duplicates)
if not df_dupes.empty:
    df_dupes.to_excel("/tmp/duplicates.xlsx", index=False)
    print(f"Найдено {df_dupes['Группа'].nunique()} групп дублей")
    print(df_dupes.to_markdown(index=False))
else:
    print("Дубли не найдены")
```

---

## БЛОК 3: Сопоставление сторон из нескольких договоров

```python
from rapidfuzz import fuzz, process

# parties — список строк из разных документов
parties = [
    'ООО "Ромашка"',
    "Общество с ограниченной ответственностью «Ромашка»",
    "ООО Ромашка",
    "ИП Иванов И.И.",
    "Индивидуальный предприниматель Иванов Иван Иванович",
]

normalized = [normalize_company(p) for p in parties]

# Кластеризация
clusters = []
assigned = set()

for i, norm_i in enumerate(normalized):
    if i in assigned:
        continue
    cluster = [i]
    assigned.add(i)
    for j in range(i + 1, len(normalized)):
        if j in assigned:
            continue
        score = fuzz.token_set_ratio(norm_i, normalized[j])
        if score >= 80:
            cluster.append(j)
            assigned.add(j)
    clusters.append({
        "canonical": parties[cluster[0]],
        "variants": [parties[idx] for idx in cluster],
        "count": len(cluster),
    })

for c in clusters:
    print(f"-> {c['canonical']} ({c['count']} вариантов): {c['variants']}")
```

---

## БЛОК 4: Сопоставление с ИНН/ОГРН (приоритет точных ключей)

Если в данных есть ИНН или ОГРН — **всегда используй их в первую очередь**:

```python
import pandas as pd
from rapidfuzz import fuzz

df_a = pd.read_excel("/tmp/a.xlsx")
df_b = pd.read_excel("/tmp/b.xlsx")

# 1. Точное совпадение по ИНН
merged_exact = df_a.merge(df_b, on="ИНН", how="inner", suffixes=("_a", "_b"))

# 2. Нечёткое сопоставление для оставшихся
unmatched_a = df_a[~df_a["ИНН"].isin(merged_exact["ИНН"])]
# ... далее БЛОК 1 для unmatched_a
```

**Правило:** ИНН/ОГРН > нечёткое название > адрес. Никогда не полагайся только на название, если есть числовой идентификатор.

---

## Советы

1. **Для больших списков (>10 000)** используй `process.cdist()` из rapidfuzz для матрицы расстояний
2. **Для русских юрлиц** всегда нормализуй орг.-правовую форму (ООО, АО, ПАО...)
3. **Для адресов** нормализуй сокращения (ул., пр-т, д., корп.)
4. **Для ФИО** обрабатывай инициалы и порядок (Фамилия И.О. vs Имя Отчество Фамилия)
5. **Результат всегда сохраняй в Excel** для удобства ручной проверки
6. Если пользователь не указал колонки — **спроси**, не угадывай
