Maksim Totmin 9b85df9f6c test(e2e): add a prod-scale Novosibirsk suite
- 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)
2026-10-27 14:36:08 +07:00

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 стр."}'
S
Description
No description provided
Readme MIT
223 KiB
Languages
Go 99.3%
Makefile 0.7%