# 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 стр."}' ```