- 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
313 lines
17 KiB
Markdown
313 lines
17 KiB
Markdown
# 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 стр."}'
|
||
```
|