- process-level tests: seed a real EДДС Bulk NDJSON, start the daemon, run the full-registry selfcheck, canonical HTTP sample, real address-log queries, strict house identity, input limits, load and graceful shutdown - production data stays outside the repo (ADDRKIT_E2E_REGISTRY/LOG); all temporary files live in t.TempDir() - add `make e2e` and `make check` (vet + race tests + dependency guard)
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.
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")
Быстрый старт
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 без сервиса:
./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 |
версия данных |
Запрос:
{"address": "Ленина 5", "city": "Бердск"}
city необязателен и переопределяет город, найденный в тексте. Лимит адреса —
512 символов, ответ на слишком длинный ввод — 400.
Только город, без улицы:
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).
{
"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». Это сделано намеренно — молча
резолвить против полупустых полей хуже, чем не стартовать.
Порядок обновления:
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-конфига, ни часового пояса в ядре.
Тесты
make test # go test -race ./...
make check # vet + race tests + проверка, что ядро не тянет events-адаптеры
Проверка парсера на реальном реестре (вне репозитория):
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().
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:
ничего не индексируется, не обновляется и не удаляется.
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: сеть не нужна.
./bin/addrkit eval -db registry.db -sample 800 -seed 1
Категории: регистр, г. Город, лишние дом/пунктуация, ё, сокращения и
перестановка типа улицы, синоним «корпус», опечатка в названии, потеря
порядкового/литеры/дроби/строения.
Ключевая инварианта: found всегда указывает на запись из индекса. Если
информации не хватает (потерян номер дома, литера или строение), результат
уходит в partial/ambiguous/not_found, но не превращается в чужой адрес.
Самопроверка (addrkit selfcheck)
Прогоняет весь реестр на самого себя и показывает долю точных совпадений и
примеры не-found. Это самый быстрый способ заметить деградацию парсера
после изменения данных.
Локальный dev
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 стр."}'