- core = address normalization/resolution (universal, no EDDS coupling) - "events by house" (outages) framed as an optional plugin module - README: positioning, API table marks /v1/outages optional, selfcheck section, updated typo numbers and LLM variant-guard note
205 lines
11 KiB
Markdown
205 lines
11 KiB
Markdown
# addrkit
|
||
|
||
**Универсальный детерминированный сервис нормализации и поиска адресов.**
|
||
Разбирает вольные реплики (дроби, литеры, корпуса, строения, опечатки),
|
||
сопоставляет их с каноническим реестром и отдаёт ответ **только из данных** —
|
||
LLM не участвует в пути корректности.
|
||
|
||
Ядро продукта — **только адрес**: оно не привязано ни к ЕДДС, ни к отключениям.
|
||
Опциональный модуль «**события по дому**» (отключения, аварии, плановые работы —
|
||
данные, привязанные к конкретному дому) подключается отдельно; ЕДДС — лишь один
|
||
из его потребителей.
|
||
|
||
Проект на Go, живёт офлайн (один статический бинарь + SQLite), без MCP в
|
||
рантайме.
|
||
|
||
## Позиционирование
|
||
|
||
- **Ядро (универсальное):** нормализация, структурирование и поиск адреса по
|
||
реестру, статусы `found/partial/ambiguous/not_found`, версия данных.
|
||
Годится для колл-центра, доставки, CRM, аналитики, любого робота.
|
||
- **Опциональный модуль «события по дому»:** получает канонический адрес из
|
||
ядра и отдаёт события, **привязанные к этому дому** (совпадение дома, дроби,
|
||
литеры и корпуса; строение игнорируется). Источник — plug-in
|
||
(`outages.Source`): сегодня ArcGIS-портал ЕДДС, завтра любой фид (ES, SQL,
|
||
API). Ядро про события ничего не знает.
|
||
|
||
## Зачем
|
||
|
||
Существующая логика поиска адреса в диалоговом сценарии страдала от
|
||
нечёткого поиска по всей строке (`fuzziness: AUTO`, `size:1`), из-за чего
|
||
дробь/корпус/литера могли дать неверный дом. `addrkit` разделяет задачу на
|
||
компоненты, сопоставляет дом строго и вместо угадывания возвращает явный
|
||
статус (`partial`/`ambiguous`).
|
||
|
||
## Архитектура
|
||
|
||
Гексагональная: домен чистый и без I/O, всё внешнее — в адаптерах.
|
||
|
||
```
|
||
cmd/addrkitd сервис (HTTP)
|
||
cmd/addrkit CLI: seed | verify | resolve | eval | selfcheck
|
||
internal/
|
||
domain/address нормализация и разбор адреса (чистые функции)
|
||
domain/match сопоставление с индексом, статусы ← ядро
|
||
domain/outages «события по дому»: фильтр по дому ← опциональный модуль
|
||
adapters/registrystore SQLite (modernc, без CGO)
|
||
adapters/arcgis клиент источника событий (timeout/retry) ← пример plug-in
|
||
adapters/llm опциональный rewriter (OpenAI-совместимый)
|
||
adapters/httpapi версионированный JSON API
|
||
config конфиг + ${VAR}
|
||
seed сборка индекса из дампа реестра
|
||
```
|
||
|
||
Поток запроса:
|
||
|
||
```
|
||
"2-я Станционная улица 1/1 стр."
|
||
-> address.Parse -> {ordinal:2, name:станционная, house:1, frac:1}
|
||
-> match.Resolve -> exact street -> exact house -> FOUND
|
||
-> (опц. модуль «события по дому») -> фильтр по дому -> ответ
|
||
```
|
||
|
||
## Быстрый старт
|
||
|
||
```bash
|
||
make build
|
||
|
||
# 1. Собрать индекс из дампа реестра (Bulk NDJSON или JSONL).
|
||
./bin/addrkit seed -in registry.ndjson -out registry.db -version 2026-10-01
|
||
|
||
# 2. Запустить сервис.
|
||
cp config.example.json config.json
|
||
ADDRKIT_PORTAL_URL='https://map.example/.../101/query' ./bin/addrkitd -config config.json
|
||
|
||
# 3. Проверить.
|
||
curl -s localhost:6171/v1/resolve -d '{"address":"2-я Станционная улица 1/1 стр."}'
|
||
curl -s localhost:6171/v1/outages -d '{"address":"станционная 30а к9/1"}'
|
||
```
|
||
|
||
CLI без сервиса:
|
||
|
||
```bash
|
||
./bin/addrkit resolve -db registry.db "кошурникова 11/1"
|
||
./bin/addrkit verify -db registry.db
|
||
./bin/addrkit selfcheck -db registry.db
|
||
```
|
||
|
||
## API v1
|
||
|
||
| Метод | Путь | Назначение |
|
||
|---|---|---|
|
||
| POST | `/v1/resolve` | **[ядро]** адрес → `{status, address, district, confidence, candidates[]}` |
|
||
| POST | `/v1/outages` | **[опц. модуль]** адрес → канонический адрес + события/отключения по дому |
|
||
| GET | `/v1/healthz` | живость |
|
||
| GET | `/v1/readyz` | готовность (индекс загружен) |
|
||
| GET | `/v1/version` | версия данных |
|
||
|
||
Статусы: `found`, `partial`, `ambiguous`, `not_found`, `portal_timeout`,
|
||
`infra_error`.
|
||
|
||
## Конфигурация
|
||
|
||
`config.json` (см. `config.example.json`). Значения поддерживают `${VAR}`;
|
||
незаданная переменная = ошибка запуска (fail closed). Секреты — только из
|
||
окружения.
|
||
|
||
Промпт LLM-rewriter **не зашит в код**: встроенный дефолт лежит в
|
||
`internal/adapters/llm/default_prompt.txt` и компилируется в бинарь (`go:embed`).
|
||
Переопределить можно в конфиге: `llm.system_prompt` (инлайн) или
|
||
`llm.system_prompt_file` (путь к файлу; приоритетнее инлайна).
|
||
|
||
## Принятые решения
|
||
|
||
- **Детерминизм.** Один вход — один выход, порядок кандидатов стабилен.
|
||
`resolve` не зависит от сети.
|
||
- **Дом сравнивается строго.** Литера/дробь/корпус должны совпасть; иначе
|
||
`partial`, а не «похожий» дом.
|
||
- **LLM — опция, выключена по умолчанию.** `adapters/llm` лишь предлагает
|
||
варианты записи адреса; результат всегда берётся из реестра, поэтому модель
|
||
не может «выдумать» адрес. Вариант принимается только если он исправляет
|
||
*написание* улицы, не меняя слова, порядковый и дом (`compatibleVariant`).
|
||
- **Ядро универсально, события по дому — опциональный модуль.** Ядро
|
||
(`domain/address`, `domain/match`) не знает ни про ЕДДС, ни про отключения;
|
||
источник событий — plug-in (`outages.Source`), поэтому модуль переиспользуем
|
||
для любых данных, привязанных к дому.
|
||
- **Данные.** Сервис владеет своим индексом, построенным из дампа реестра.
|
||
Источник истины и регламент обновления — предмет отдельного согласования.
|
||
- **Часовой пояс.** Даты отключений трактуются в `Asia/Novosibirsk` (UTC+7).
|
||
|
||
## Тесты
|
||
|
||
```bash
|
||
make test # go test -race ./...
|
||
```
|
||
|
||
Проверка парсера на реальном реестре (вне репозитория):
|
||
|
||
```bash
|
||
ADDRKIT_REGISTRY=/path/to/address_registry.ndjson \
|
||
go test ./internal/seed -run TestRealRegistryParseRate -v
|
||
```
|
||
|
||
На дампе из 73 936 строк парсер распознаёт **99.98 %** (14 строк — заведомо
|
||
битые форматы; они не угадываются, а попадают в отчёт `stats.unparsed_sample`).
|
||
|
||
## Оценка качества (`addrkit eval`)
|
||
|
||
Детерминированный harness генерирует «гражданские» искажения канонических
|
||
адресов и измеряет, как резолвер справляется. Это offline: сеть не нужна.
|
||
|
||
```bash
|
||
./bin/addrkit eval -db registry.db -sample 800 -seed 1
|
||
```
|
||
|
||
Категории и результат на реальном реестре (по 800 адресам на seed):
|
||
|
||
| Категория | Пример искажения | Точность |
|
||
|---|---|---|
|
||
| baseline | канонический адрес как есть | 100 % |
|
||
| case_lower / case_upper | регистр | 100 % |
|
||
| city_prefix | `г. Новосибирск, …` | 100 % |
|
||
| filler_dom | `… дом 5` | 100 % |
|
||
| punctuation | лишние `,`/пробелы | 100 % |
|
||
| yo | `ё` вместо `е` | 100 % |
|
||
| type_abbrev | `ул.`/`пер.`/`пр-т` | 100 % |
|
||
| type_removed | улица без слова «улица» | 100 % |
|
||
| type_front | `улица Станционная 1` | 100 % |
|
||
| corpus_synonym | `корпус` вместо `к` | 100 % |
|
||
| structure_drop | без `стр.` | 100 % |
|
||
| **typo** | опечатка в названии улицы | ~78–82 % (остальное — `ambiguous`) |
|
||
| drop_ordinal / drop_letter / drop_fraction | потеря `2-я` / литеры / дроби | потери (lossy) |
|
||
|
||
Ключевой результат: **0 случаев, когда сервис подставил неверный дом**
|
||
(«safety violations») на десятках тысяч сгенерированных кейсов. Опечатки либо
|
||
распознаются, либо честно уходят в `ambiguous`/`not_found`, но не превращаются
|
||
в чужой адрес.
|
||
|
||
С подключённым LLM-rewriter'ом (опция, `llm.enabled`) точность на опечатках
|
||
поднимается до **~90 %** при том же нуле неверных адресов (вариант модели
|
||
принимается только если исправляет лишь *написание* улицы — см. «Принятые
|
||
решения»).
|
||
|
||
## Самопроверка (`addrkit selfcheck`)
|
||
|
||
Прогоняет **весь** реестр на самого себя (не выборку):
|
||
|
||
```bash
|
||
./bin/addrkit selfcheck -db registry.db
|
||
```
|
||
|
||
На реестре 72 825 записей: **72 811 (99.98 %) резолвятся точно**, 14 — `partial`
|
||
(битые форматы дома: `27с1`, `32 к 1/4-1/6`, `112с729`), 0 `not_found`,
|
||
0 неверных.
|
||
|
||
## Локальный dev
|
||
|
||
```bash
|
||
make build
|
||
./bin/addrkit seed -in registry.ndjson -out dev/registry.db -version 2026-10-01
|
||
cp config.example.json dev/config.json # укажите data_path и portal.url
|
||
./bin/addrkitd -config dev/config.json
|
||
curl -s localhost:6171/v1/resolve -d '{"address":"2-я Станционная улица 1/1 стр."}'
|
||
```
|
||
|