refactor!: make addrkit a multi-city address normalizer
- remove the events/outages module, ArcGIS adapter, portal config and event statuses - add a configurable city dictionary/parser and make City part of StreetKey - seed: read city/city_name, namespace IDs by city, keep structures distinct - fix reliability: schema v2 with fail-closed load, deterministic ordering, input limits, config validation, atomic DB publish, LLM response caps - add read-only shadow mode comparing the old ES path with addrkit - breaking: registry.db must be re-seeded; config has no portal section
This commit is contained in:
@@ -1,36 +1,32 @@
|
||||
# addrkit
|
||||
|
||||
**Универсальный детерминированный сервис нормализации и поиска адресов.**
|
||||
**Детерминированный многогородской сервис нормализации и поиска адресов.**
|
||||
Разбирает вольные реплики (дроби, литеры, корпуса, строения, опечатки),
|
||||
сопоставляет их с каноническим реестром и отдаёт ответ **только из данных** —
|
||||
LLM не участвует в пути корректности.
|
||||
|
||||
Ядро продукта — **только адрес**: оно не привязано ни к ЕДДС, ни к отключениям.
|
||||
Опциональный модуль «**события по дому**» (отключения, аварии, плановые работы —
|
||||
данные, привязанные к конкретному дому) подключается отдельно; ЕДДС — лишь один
|
||||
из его потребителей.
|
||||
Сервис делает одну вещь: превращает свободный адрес в канонический. Он не
|
||||
знает ни про отключения, ни про аварии, ни про другие события. Если такие
|
||||
данные нужны, их источник — отдельный сервис, который использует addrkit как
|
||||
нормализатор.
|
||||
|
||||
Проект на Go, живёт офлайн (один статический бинарь + SQLite), без MCP в
|
||||
рантайме.
|
||||
## Что умеет
|
||||
|
||||
## Позиционирование
|
||||
|
||||
- **Ядро (универсальное):** нормализация, структурирование и поиск адреса по
|
||||
реестру, статусы `found/partial/ambiguous/not_found`, версия данных.
|
||||
Годится для колл-центра, доставки, CRM, аналитики, любого робота.
|
||||
- **Опциональный модуль «события по дому»:** получает канонический адрес из
|
||||
ядра и отдаёт события, **привязанные к этому дому** (совпадение дома, дроби,
|
||||
литеры и корпуса; строение игнорируется). Источник — plug-in
|
||||
(`outages.Source`): сегодня ArcGIS-портал ЕДДС, завтра любой фид (ES, SQL,
|
||||
API). Ядро про события ничего не знает.
|
||||
|
||||
## Зачем
|
||||
|
||||
Существующая логика поиска адреса в диалоговом сценарии страдала от
|
||||
нечёткого поиска по всей строке (`fuzziness: AUTO`, `size:1`), из-за чего
|
||||
дробь/корпус/литера могли дать неверный дом. `addrkit` разделяет задачу на
|
||||
компоненты, сопоставляет дом строго и вместо угадывания возвращает явный
|
||||
статус (`partial`/`ambiguous`).
|
||||
- **Города.** Город — часть модели и ключа улицы. «Ленина 5» в Новосибирске и
|
||||
в Бердске — разные адреса; без указания города сервис честно вернёт
|
||||
`ambiguous`, а не выберет наугад.
|
||||
- **Алиасы городов.** `нск`, `новосиб`, `новосибирская область`, `нсо` — всё
|
||||
это настраивается, а не зашито в код.
|
||||
- **Строгий дом.** Дробь, литера, корпус и строение должны совпасть. Запрос
|
||||
«1 стр. 2» не может вернуть «1».
|
||||
- **Безопасные статусы.** `found`, `partial`, `ambiguous`, `not_found`.
|
||||
Сервис лучше скажет «уточните», чем подставит чужой дом.
|
||||
- **Опечатки.** Нечёткое сопоставление названия улицы с порогом и явной
|
||||
зоной неоднозначности.
|
||||
- **Опциональный LLM-rewriter.** Может предложить варианты написания, но
|
||||
ответ всегда берётся из реестра, а вариант принимается только если меняет
|
||||
*написание* улицы, не трогая город, порядковый номер и дом.
|
||||
- **Офлайн.** Один статический бинарь + SQLite. В пути резолва нет сети.
|
||||
|
||||
## Архитектура
|
||||
|
||||
@@ -40,24 +36,44 @@ LLM не участвует в пути корректности.
|
||||
cmd/addrkitd сервис (HTTP)
|
||||
cmd/addrkit CLI: seed | verify | resolve | eval | selfcheck
|
||||
internal/
|
||||
domain/address нормализация и разбор адреса (чистые функции)
|
||||
domain/address нормализация, словарь городов, разбор адреса
|
||||
domain/match сопоставление с индексом, статусы ← ядро
|
||||
domain/outages «события по дому»: фильтр по дому ← опциональный модуль
|
||||
adapters/registrystore SQLite (modernc, без CGO)
|
||||
adapters/arcgis клиент источника событий (timeout/retry) ← пример plug-in
|
||||
adapters/llm опциональный rewriter (OpenAI-совместимый)
|
||||
adapters/httpapi версионированный JSON API
|
||||
config конфиг + ${VAR}
|
||||
config конфиг + ${VAR} + словарь городов
|
||||
seed сборка индекса из дампа реестра
|
||||
eval offline-оценка качества
|
||||
pkg/addrkit публичный фасад для использования как библиотеки
|
||||
```
|
||||
|
||||
Поток запроса:
|
||||
|
||||
```
|
||||
"2-я Станционная улица 1/1 стр."
|
||||
-> address.Parse -> {ordinal:2, name:станционная, house:1, frac:1}
|
||||
-> match.Resolve -> exact street -> exact house -> FOUND
|
||||
-> (опц. модуль «события по дому») -> фильтр по дому -> ответ
|
||||
"нск, 2-я Станционная улица 1/1 стр."
|
||||
-> address.Parser -> {city: Новосибирск, ordinal: 2, name: станционная, house: 1, frac: 1}
|
||||
-> match.Resolve -> exact city -> exact street -> exact house -> FOUND
|
||||
```
|
||||
|
||||
`match.Index` неизменяемый, резолвер не хранит состояния. Индекс собирается
|
||||
один раз при старте и безопасен для конкурентных чтений.
|
||||
|
||||
Если нормализатор нужен другому проекту как библиотека, используйте
|
||||
`pkg/addrkit`: он не делает I/O и не требует HTTP или SQLite.
|
||||
|
||||
```go
|
||||
parser := addrkit.NewParser(
|
||||
addrkit.NewDictionary([]addrkit.City{{Name: "Новосибирск", Aliases: []string{"нск"}}}),
|
||||
"",
|
||||
)
|
||||
parsed := parser.Parse("нск, Ленина 5")
|
||||
|
||||
resolver := addrkit.NewResolverWithOptions(
|
||||
addrkit.NewIndex(version, entries, addrkit.DefaultOptions()),
|
||||
nil,
|
||||
addrkit.ResolverOptions{Parser: parser},
|
||||
)
|
||||
result := resolver.Resolve(ctx, "нск, Ленина 5")
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
@@ -65,72 +81,127 @@ internal/
|
||||
```bash
|
||||
make build
|
||||
|
||||
# 1. Собрать индекс из дампа реестра (Bulk NDJSON или JSONL).
|
||||
./bin/addrkit seed -in registry.ndjson -out registry.db -version 2026-10-01
|
||||
# 1. Собрать индекс из дампа реестра.
|
||||
# -city задаёт город для строк без поля city;
|
||||
# -cities — необязательный JSON-справочник алиасов.
|
||||
./bin/addrkit seed -in registry.ndjson -out registry.db \
|
||||
-version 2026-10-01 -city "Новосибирск"
|
||||
|
||||
# 2. Запустить сервис.
|
||||
cp config.example.json config.json
|
||||
ADDRKIT_PORTAL_URL='https://map.example/.../101/query' ./bin/addrkitd -config config.json
|
||||
./bin/addrkitd -config config.json
|
||||
|
||||
# 3. Проверить.
|
||||
curl -s localhost:6171/v1/resolve -d '{"address":"2-я Станционная улица 1/1 стр."}'
|
||||
curl -s localhost:6171/v1/outages -d '{"address":"станционная 30а к9/1"}'
|
||||
curl -s localhost:6171/v1/resolve -d '{"address":"нск, 2-я Станционная 1/1 стр."}'
|
||||
curl -s localhost:6171/v1/cities
|
||||
```
|
||||
|
||||
CLI без сервиса:
|
||||
|
||||
```bash
|
||||
./bin/addrkit resolve -db registry.db "кошурникова 11/1"
|
||||
./bin/addrkit resolve -db registry.db -city "Новосибирск" "кошурникова 11/1"
|
||||
./bin/addrkit verify -db registry.db
|
||||
./bin/addrkit selfcheck -db registry.db
|
||||
./bin/addrkit shadow -db registry.db -es https://es:9200 -sample 300
|
||||
```
|
||||
|
||||
## API v1
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|---|---|---|
|
||||
| POST | `/v1/resolve` | **[ядро]** адрес → `{status, address, district, confidence, candidates[]}` |
|
||||
| POST | `/v1/outages` | **[опц. модуль]** адрес → канонический адрес + события/отключения по дому |
|
||||
| POST | `/v1/resolve` | адрес → `{status, address, city, district, confidence, candidates[], data_version}` |
|
||||
| POST | `/v1/city` | только город: `нск` → `Новосибирск` |
|
||||
| GET | `/v1/cities` | канонические города, присутствующие в индексе |
|
||||
| GET | `/v1/healthz` | живость |
|
||||
| GET | `/v1/readyz` | готовность (индекс загружен) |
|
||||
| GET | `/v1/version` | версия данных |
|
||||
|
||||
Статусы: `found`, `partial`, `ambiguous`, `not_found`, `portal_timeout`,
|
||||
`infra_error`.
|
||||
Запрос:
|
||||
|
||||
## Конфигурация
|
||||
```json
|
||||
{"address": "Ленина 5", "city": "Бердск"}
|
||||
```
|
||||
|
||||
`city` необязателен и переопределяет город, найденный в тексте. Лимит адреса —
|
||||
512 символов, ответ на слишком длинный ввод — `400`.
|
||||
|
||||
Только город, без улицы:
|
||||
|
||||
```bash
|
||||
curl -s localhost:6171/v1/city -d '{"query":"нск"}'
|
||||
# {"status":"found","city":"Новосибирск"}
|
||||
```
|
||||
|
||||
Это же доступно как библиотека: `addrkit.NewDictionary(...).MatchCity("нск")`.
|
||||
|
||||
Статусы: `found`, `partial`, `ambiguous`, `not_found`.
|
||||
|
||||
## Города и конфигурация
|
||||
|
||||
`config.json` (см. `config.example.json`). Значения поддерживают `${VAR}`;
|
||||
незаданная переменная = ошибка запуска (fail closed). Секреты — только из
|
||||
окружения.
|
||||
незаданная переменная = ошибка запуска (fail closed).
|
||||
|
||||
Промпт LLM-rewriter **не зашит в код**: встроенный дефолт лежит в
|
||||
`internal/adapters/llm/default_prompt.txt` и компилируется в бинарь (`go:embed`).
|
||||
Переопределить можно в конфиге: `llm.system_prompt` (инлайн) или
|
||||
`llm.system_prompt_file` (путь к файлу; приоритетнее инлайна).
|
||||
```json
|
||||
{
|
||||
"addr": ":6171",
|
||||
"data_path": "registry.db",
|
||||
"cities": [
|
||||
{"name": "Новосибирск", "aliases": ["нск", "новосиб", "новосибирская область", "нсо"]},
|
||||
{"name": "Бердск"}
|
||||
],
|
||||
"default_city": "Новосибирск",
|
||||
"resolve": {"fuzzy_threshold": 0.82, "ambiguity_margin": 0.06, "max_candidates": 5}
|
||||
}
|
||||
```
|
||||
|
||||
- `cities` — словарь канонических названий и алиасов. Один и тот же алиас не
|
||||
может принадлежать двум городам: конфиг не загрузится.
|
||||
- `default_city` — удобство для одногородского деплоя: запрос «Ленина 5» без
|
||||
города будет отнесён к нему. В многогородском деплое оставьте поле пустым:
|
||||
угадывание города — это ровно та ошибка, которую сервис не должен делать.
|
||||
- `resolve.ambiguity_margin` — насколько близкие улицы считаются
|
||||
неоднозначными. Значение по умолчанию одно для сервиса, CLI и eval.
|
||||
|
||||
Справочник городов можно держать отдельным файлом (`addrkit seed -cities
|
||||
cities.json`); формат — JSON-массив тех же объектов.
|
||||
|
||||
## Обновление данных и совместимость
|
||||
|
||||
`registry.db` содержит `schema_version`. Формат ключа улицы включает город,
|
||||
поэтому база, собранная старой версией, **не откроется** новым бинарём: в
|
||||
ошибке будет сказано «re-run addrkit seed». Это сделано намеренно — молча
|
||||
резолвить против полупустых полей хуже, чем не стартовать.
|
||||
|
||||
Порядок обновления:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./bin/addrkit seed -in registry.ndjson -out registry.db.new -city "Новосибирск"
|
||||
mv registry.db.new registry.db
|
||||
systemctl restart addrkitd
|
||||
```
|
||||
|
||||
## Принятые решения
|
||||
|
||||
- **Детерминизм.** Один вход — один выход, порядок кандидатов стабилен.
|
||||
`resolve` не зависит от сети.
|
||||
- **Дом сравнивается строго.** Литера/дробь/корпус должны совпасть; иначе
|
||||
`partial`, а не «похожий» дом.
|
||||
- **LLM — опция, выключена по умолчанию.** `adapters/llm` лишь предлагает
|
||||
варианты записи адреса; результат всегда берётся из реестра, поэтому модель
|
||||
не может «выдумать» адрес. Вариант принимается только если он исправляет
|
||||
*написание* улицы, не меняя слова, порядковый и дом (`compatibleVariant`).
|
||||
- **Ядро универсально, события по дому — опциональный модуль.** Ядро
|
||||
(`domain/address`, `domain/match`) не знает ни про ЕДДС, ни про отключения;
|
||||
источник событий — plug-in (`outages.Source`), поэтому модуль переиспользуем
|
||||
для любых данных, привязанных к дому.
|
||||
- **Детерминизм.** Один вход — один выход, порядок кандидатов стабилен:
|
||||
индекс сортирует записи при сборке, SQL-запрос тоже имеет `ORDER BY`.
|
||||
- **Дом и строение сравниваются строго.** Литера/дробь/корпус/строение должны
|
||||
совпасть; иначе `partial` или `ambiguous`, но не «похожий» дом.
|
||||
- **Город — часть ключа.** Одинаковые улицы разных городов не смешиваются.
|
||||
- **LLM — опция, выключена по умолчанию.** Модель не может «выдумать» адрес;
|
||||
ошибки rewriter'а логируются, но не меняют детерминированный ответ.
|
||||
- **Данные.** Сервис владеет своим индексом, построенным из дампа реестра.
|
||||
Источник истины и регламент обновления — предмет отдельного согласования.
|
||||
- **Часовой пояс.** Даты отключений трактуются в `Asia/Novosibirsk` (UTC+7).
|
||||
- **Границы ввода.** Адрес ограничен 512 символами, токен нечёткого
|
||||
сопоставления — 64; это защищает CPU от патологического ввода.
|
||||
- **Сервис не делает событий.** Ни `/v1/outages`, ни portal-конфига, ни
|
||||
часового пояса в ядре.
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
make test # go test -race ./...
|
||||
make test # go test -race ./...
|
||||
make check # vet + race tests + проверка, что ядро не тянет events-адаптеры
|
||||
```
|
||||
|
||||
Проверка парсера на реальном реестре (вне репозитория):
|
||||
@@ -140,8 +211,72 @@ ADDRKIT_REGISTRY=/path/to/address_registry.ndjson \
|
||||
go test ./internal/seed -run TestRealRegistryParseRate -v
|
||||
```
|
||||
|
||||
На дампе из 73 936 строк парсер распознаёт **99.98 %** (14 строк — заведомо
|
||||
битые форматы; они не угадываются, а попадают в отчёт `stats.unparsed_sample`).
|
||||
### E2E на реальном снимке ЕДДС
|
||||
|
||||
Процессные тесты собирают `addrkit` и `addrkitd`, сеют временную БД из
|
||||
настоящего Bulk NDJSON-дампа, поднимают HTTP-сервис и проверяют весь реестр
|
||||
(`selfcheck`), выборку канонических адресов по HTTP, реальные запросы из
|
||||
`edds_address_log`, алиасы города, строгость дома/строения, лимиты и нагрузку
|
||||
(10 000 запросов, p99, RSS, 0 неверных адресов).
|
||||
|
||||
Данные содержат PII и **не копируются в репозиторий**: путь передаётся
|
||||
переменной окружения, временные файлы живут в `t.TempDir()`.
|
||||
|
||||
```bash
|
||||
ADDRKIT_E2E_REGISTRY=/path/to/address_registry.ndjson \
|
||||
ADDRKIT_E2E_LOG=/path/to/edds_address_log.ndjson \
|
||||
make e2e
|
||||
```
|
||||
|
||||
Опционально: `ADDRKIT_E2E_CITY` (по умолчанию `Новосибирск`),
|
||||
`ADDRKIT_E2E_SAMPLE` (каноническая HTTP-выборка, по умолчанию 2000),
|
||||
`ADDRKIT_E2E_LOAD` (нагрузочные запросы, по умолчанию 10 000).
|
||||
|
||||
Тест проверяет **канонический ввод** — то, что старый конвейер ЕДДС передавал в
|
||||
Elasticsearch после SEE. Сырые реплики с числительными словами
|
||||
(`выставочная шестнадцать`, `сто дробь один`) остаются зоной SEE/LLM: для них
|
||||
тест измеряет долю `found` и не требует 100 %.
|
||||
|
||||
### Shadow: старый ES-путь против нового addrkit
|
||||
|
||||
`addrkit shadow` прогоняет один и тот же запрос через **старый ES-поиск**
|
||||
(ровно тот запрос, который делает робот ЕДДС: `multi_match`, `fuzziness: AUTO`,
|
||||
`size: 1`) и через новый локальный резолвер. В ES уходят **только `_search`**:
|
||||
ничего не индексируется, не обновляется и не удаляется.
|
||||
|
||||
```bash
|
||||
export ADDRKIT_ES_URL=https://es:9200
|
||||
export ADDRKIT_ES_USER=elastic
|
||||
export ADDRKIT_ES_PASSWORD=...
|
||||
|
||||
# Сгенерированные реалистичные искажения реестра
|
||||
./bin/addrkit shadow -db registry.db -city "Новосибирск" \
|
||||
-es "$ADDRKIT_ES_URL" -sample 1000 -workers 8 -out shadow-generated.json
|
||||
|
||||
# Реальные реплики из прод-лога (read-only, последние N документов)
|
||||
./bin/addrkit shadow -db registry.db -city "Новосибирск" \
|
||||
-es "$ADDRKIT_ES_URL" -log-es-index edds_address_log -log-limit 3000 \
|
||||
-workers 8 -out shadow-log.json
|
||||
```
|
||||
|
||||
Старый путь получает ту же предобработку, что и в роботе (`-old-preprocess`,
|
||||
по умолчанию включено): нижний регистр и удаление административных слов
|
||||
(`город`, `новосибирск`, районы, `дом`, `номер`). Это делает сравнение честным.
|
||||
|
||||
Ключевые поля отчёта:
|
||||
|
||||
- `new.wrong` — новый путь вернул другой дом; **должно быть 0**, команда
|
||||
завершается с кодом 1;
|
||||
- `compare.old_wrong_new_safe` — старый путь угадал дом, новый отказался
|
||||
(`partial`/`ambiguous`/`not_found`);
|
||||
- `compare.old_wrong_new_correct` — старый ошибся, новый ответил верно;
|
||||
- `compare.old_correct_new_not_exact` — цена строгости: старый угадал верно,
|
||||
новый отказался или потерял информацию; эти случаи уходят в fallback
|
||||
(оператор/LLM), их нужно смотреть глазами.
|
||||
|
||||
`-log` принимает локальный bulk NDJSON, `-log-es-index` — читает лог прямо из
|
||||
ES. Для сырых реплик SEE-преобразование числительных не воспроизводится,
|
||||
поэтому лог-режим показывает нижнюю границу, а не боевую точность пайплайна.
|
||||
|
||||
## Оценка качества (`addrkit eval`)
|
||||
|
||||
@@ -152,53 +287,26 @@ ADDRKIT_REGISTRY=/path/to/address_registry.ndjson \
|
||||
./bin/addrkit eval -db registry.db -sample 800 -seed 1
|
||||
```
|
||||
|
||||
Категории и результат на реальном реестре (по 800 адресам на seed):
|
||||
Категории: регистр, `г. Город`, лишние `дом`/пунктуация, `ё`, сокращения и
|
||||
перестановка типа улицы, синоним «корпус», опечатка в названии, потеря
|
||||
порядкового/литеры/дроби/строения.
|
||||
|
||||
| Категория | Пример искажения | Точность |
|
||||
|---|---|---|
|
||||
| baseline | канонический адрес как есть | 100 % |
|
||||
| case_lower / case_upper | регистр | 100 % |
|
||||
| city_prefix | `г. Новосибирск, …` | 100 % |
|
||||
| filler_dom | `… дом 5` | 100 % |
|
||||
| punctuation | лишние `,`/пробелы | 100 % |
|
||||
| yo | `ё` вместо `е` | 100 % |
|
||||
| type_abbrev | `ул.`/`пер.`/`пр-т` | 100 % |
|
||||
| type_removed | улица без слова «улица» | 100 % |
|
||||
| type_front | `улица Станционная 1` | 100 % |
|
||||
| corpus_synonym | `корпус` вместо `к` | 100 % |
|
||||
| structure_drop | без `стр.` | 100 % |
|
||||
| **typo** | опечатка в названии улицы | ~78–82 % (остальное — `ambiguous`) |
|
||||
| drop_ordinal / drop_letter / drop_fraction | потеря `2-я` / литеры / дроби | потери (lossy) |
|
||||
|
||||
Ключевой результат: **0 случаев, когда сервис подставил неверный дом**
|
||||
(«safety violations») на десятках тысяч сгенерированных кейсов. Опечатки либо
|
||||
распознаются, либо честно уходят в `ambiguous`/`not_found`, но не превращаются
|
||||
в чужой адрес.
|
||||
|
||||
С подключённым LLM-rewriter'ом (опция, `llm.enabled`) точность на опечатках
|
||||
поднимается до **~90 %** при том же нуле неверных адресов (вариант модели
|
||||
принимается только если исправляет лишь *написание* улицы — см. «Принятые
|
||||
решения»).
|
||||
Ключевая инварианта: **`found` всегда указывает на запись из индекса**. Если
|
||||
информации не хватает (потерян номер дома, литера или строение), результат
|
||||
уходит в `partial`/`ambiguous`/`not_found`, но не превращается в чужой адрес.
|
||||
|
||||
## Самопроверка (`addrkit selfcheck`)
|
||||
|
||||
Прогоняет **весь** реестр на самого себя (не выборку):
|
||||
|
||||
```bash
|
||||
./bin/addrkit selfcheck -db registry.db
|
||||
```
|
||||
|
||||
На реестре 72 825 записей: **72 811 (99.98 %) резолвятся точно**, 14 — `partial`
|
||||
(битые форматы дома: `27с1`, `32 к 1/4-1/6`, `112с729`), 0 `not_found`,
|
||||
0 неверных.
|
||||
Прогоняет весь реестр на самого себя и показывает долю точных совпадений и
|
||||
примеры не-`found`. Это самый быстрый способ заметить деградацию парсера
|
||||
после изменения данных.
|
||||
|
||||
## Локальный dev
|
||||
|
||||
```bash
|
||||
make build
|
||||
./bin/addrkit seed -in registry.ndjson -out dev/registry.db -version 2026-10-01
|
||||
cp config.example.json dev/config.json # укажите data_path и portal.url
|
||||
./bin/addrkit seed -in registry.ndjson -out dev/registry.db -city "Новосибирск"
|
||||
cp config.example.json dev/config.json # укажите data_path
|
||||
./bin/addrkitd -config dev/config.json
|
||||
curl -s localhost:6171/v1/resolve -d '{"address":"2-я Станционная улица 1/1 стр."}'
|
||||
curl -s localhost:6171/v1/resolve -d '{"address":"нск, 2-я Станционная 1/1 стр."}'
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user