Files
Maksim Totmin 17ff5c591f 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
2026-10-27 14:35:51 +07:00

313 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# addrkit
**Детерминированный многогородской сервис нормализации и поиска адресов.**
Разбирает вольные реплики (дроби, литеры, корпуса, строения, опечатки),
сопоставляет их с каноническим реестром и отдаёт ответ **только из данных** —
LLM не участвует в пути корректности.
Сервис делает одну вещь: превращает свободный адрес в канонический. Он не
знает ни про отключения, ни про аварии, ни про другие события. Если такие
данные нужны, их источник — отдельный сервис, который использует addrkit как
нормализатор.
## Что умеет
- **Города.** Город — часть модели и ключа улицы. «Ленина 5» в Новосибирске и
в Бердске — разные адреса; без указания города сервис честно вернёт
`ambiguous`, а не выберет наугад.
- **Алиасы городов.** `нск`, `новосиб`, `новосибирская область`, `нсо` — всё
это настраивается, а не зашито в код.
- **Строгий дом.** Дробь, литера, корпус и строение должны совпасть. Запрос
«1 стр. 2» не может вернуть «1».
- **Безопасные статусы.** `found`, `partial`, `ambiguous`, `not_found`.
Сервис лучше скажет «уточните», чем подставит чужой дом.
- **Опечатки.** Нечёткое сопоставление названия улицы с порогом и явной
зоной неоднозначности.
- **Опциональный LLM-rewriter.** Может предложить варианты написания, но
ответ всегда берётся из реестра, а вариант принимается только если меняет
*написание* улицы, не трогая город, порядковый номер и дом.
- **Офлайн.** Один статический бинарь + SQLite. В пути резолва нет сети.
## Архитектура
Гексагональная: домен чистый и без I/O, всё внешнее — в адаптерах.
```
cmd/addrkitd сервис (HTTP)
cmd/addrkit CLI: seed | verify | resolve | eval | selfcheck
internal/
domain/address нормализация, словарь городов, разбор адреса
domain/match сопоставление с индексом, статусы ← ядро
adapters/registrystore SQLite (modernc, без CGO)
adapters/llm опциональный rewriter (OpenAI-совместимый)
adapters/httpapi версионированный JSON API
config конфиг + ${VAR} + словарь городов
seed сборка индекса из дампа реестра
eval offline-оценка качества
pkg/addrkit публичный фасад для использования как библиотеки
```
Поток запроса:
```
"нск, 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")
```
## Быстрый старт
```bash
make build
# 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
./bin/addrkitd -config config.json
# 3. Проверить.
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 -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, city, district, confidence, candidates[], data_version}` |
| POST | `/v1/city` | только город: `нск` → `Новосибирск` |
| GET | `/v1/cities` | канонические города, присутствующие в индексе |
| GET | `/v1/healthz` | живость |
| GET | `/v1/readyz` | готовность (индекс загружен) |
| GET | `/v1/version` | версия данных |
Запрос:
```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).
```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
```
## Принятые решения
- **Детерминизм.** Один вход — один выход, порядок кандидатов стабилен:
индекс сортирует записи при сборке, SQL-запрос тоже имеет `ORDER BY`.
- **Дом и строение сравниваются строго.** Литера/дробь/корпус/строение должны
совпасть; иначе `partial` или `ambiguous`, но не «похожий» дом.
- **Город — часть ключа.** Одинаковые улицы разных городов не смешиваются.
- **LLM — опция, выключена по умолчанию.** Модель не может «выдумать» адрес;
ошибки rewriter'а логируются, но не меняют детерминированный ответ.
- **Данные.** Сервис владеет своим индексом, построенным из дампа реестра.
Источник истины и регламент обновления — предмет отдельного согласования.
- **Границы ввода.** Адрес ограничен 512 символами, токен нечёткого
сопоставления — 64; это защищает CPU от патологического ввода.
- **Сервис не делает событий.** Ни `/v1/outages`, ни portal-конфига, ни
часового пояса в ядре.
## Тесты
```bash
make test # go test -race ./...
make check # vet + race tests + проверка, что ядро не тянет events-адаптеры
```
Проверка парсера на реальном реестре (вне репозитория):
```bash
ADDRKIT_REGISTRY=/path/to/address_registry.ndjson \
go test ./internal/seed -run TestRealRegistryParseRate -v
```
### 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`)
Детерминированный harness генерирует «гражданские» искажения канонических
адресов и измеряет, как резолвер справляется. Это offline: сеть не нужна.
```bash
./bin/addrkit eval -db registry.db -sample 800 -seed 1
```
Категории: регистр, `г. Город`, лишние `дом`/пунктуация, `ё`, сокращения и
перестановка типа улицы, синоним «корпус», опечатка в названии, потеря
порядкового/литеры/дроби/строения.
Ключевая инварианта: **`found` всегда указывает на запись из индекса**. Если
информации не хватает (потерян номер дома, литера или строение), результат
уходит в `partial`/`ambiguous`/`not_found`, но не превращается в чужой адрес.
## Самопроверка (`addrkit selfcheck`)
Прогоняет весь реестр на самого себя и показывает долю точных совпадений и
примеры не-`found`. Это самый быстрый способ заметить деградацию парсера
после изменения данных.
## Локальный dev
```bash
make build
./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 стр."}'
```