@iola_adm/iola-cli 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +1 -1
- package/skills/geo/SKILL.md +20 -0
- package/src/cli.js +688 -6
- package/wiki/Home.md +3 -0
- package/wiki/Yandex-Geocoder-API-key.md +72 -0
- package/wiki//320/232/320/276/320/274/320/260/320/275/320/264/321/213.md +15 -0
- package/wiki//320/234/320/260/321/201/321/202/320/265/321/200-/320/275/320/260/321/201/321/202/321/200/320/276/320/271/320/272/320/270.md +8 -0
- package/wiki//320/241/320/272/320/270/320/273/320/273/321/213-/320/264/320/273/321/217-/320/266/320/270/321/202/320/265/320/273/320/265/320/271.md +101 -0
package/README.md
CHANGED
|
@@ -156,6 +156,23 @@ iola ai setup gigachat --model GigaChat-2
|
|
|
156
156
|
|
|
157
157
|
У GigaChat для физических лиц есть Freemium-лимит на токены; для больших объемов используются платные пакеты. У YandexGPT тарификация идет через Yandex Cloud по токенам и квотам аккаунта, актуальные бесплатные гранты или лимиты нужно проверять в консоли Yandex Cloud.
|
|
158
158
|
|
|
159
|
+
Геокодер для пользовательских geo-skills:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
iola geo key set yandex
|
|
163
|
+
iola geo key doctor
|
|
164
|
+
iola geo geocode "Йошкар-Ола, улица Петрова, 15"
|
|
165
|
+
iola geo nearby "Йошкар-Ола, улица Петрова, 15" --dataset all --limit 5
|
|
166
|
+
iola geo distance --from "Петрова 15" --to "школа 7"
|
|
167
|
+
iola geo map-link "школа 7"
|
|
168
|
+
iola geo resolve "садик золотой петушок"
|
|
169
|
+
iola geo route-context "школа 7"
|
|
170
|
+
iola geo services "Йошкар-Ола, улица Петрова, 15"
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Инструкция по получению ключа: [Yandex Geocoder API key](https://github.com/adm-iola/iola-cli/wiki/Yandex-Geocoder-API-key).
|
|
174
|
+
Список сценариев: [Скиллы для жителей](https://github.com/adm-iola/iola-cli/wiki/Скиллы-для-жителей).
|
|
175
|
+
|
|
159
176
|
Зарубежные API-ключи:
|
|
160
177
|
|
|
161
178
|
- OpenAI Platform: регистрация `https://platform.openai.com/`, ключи `https://platform.openai.com/api-keys`;
|
|
@@ -182,6 +199,8 @@ iola version --check
|
|
|
182
199
|
- [Первый запуск](https://github.com/adm-iola/iola-cli/wiki/Первый-запуск)
|
|
183
200
|
- [Мастер настройки](https://github.com/adm-iola/iola-cli/wiki/Мастер-настройки)
|
|
184
201
|
- [AI-профили](https://github.com/adm-iola/iola-cli/wiki/AI-профили)
|
|
202
|
+
- [Yandex Geocoder API key](https://github.com/adm-iola/iola-cli/wiki/Yandex-Geocoder-API-key)
|
|
203
|
+
- [Скиллы для жителей](https://github.com/adm-iola/iola-cli/wiki/Скиллы-для-жителей)
|
|
185
204
|
- [Локальный инструментальный агент](https://github.com/adm-iola/iola-cli/wiki/Локальный-инструментальный-агент)
|
|
186
205
|
- [Skills и toolsets](https://github.com/adm-iola/iola-cli/wiki/Skills-и-toolsets)
|
|
187
206
|
- [Локальные файлы](https://github.com/adm-iola/iola-cli/wiki/Локальные-файлы)
|
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: geo
|
|
3
|
+
description: Geo-сценарии для жителей: что рядом, карта, расстояние, уточнение места и населенного пункта.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Используй geo-функции CLI и Yandex Geocoder API для вопросов о местоположении.
|
|
7
|
+
|
|
8
|
+
Основные пользовательские сценарии:
|
|
9
|
+
|
|
10
|
+
- `nearby` - найти школы, детские сады и другие будущие городские объекты рядом с адресом.
|
|
11
|
+
- `route-context` - объяснить, где находится объект: адрес, ориентиры, что рядом, ссылка на карту.
|
|
12
|
+
- `address-to-services` - по адресу жителя подобрать ближайшие подключенные городские объекты.
|
|
13
|
+
- `map-link` - дать ссылку на карту и координаты.
|
|
14
|
+
- `place-resolver` - уточнить криво написанное место, адрес или название объекта.
|
|
15
|
+
- `distance` - посчитать расстояние по прямой между адресом и объектом или между двумя объектами.
|
|
16
|
+
- `area-filter` - не путать населенные пункты и районы, например Йошкар-Олу и Семеновку.
|
|
17
|
+
|
|
18
|
+
Не придумывай координаты, расстояния и адреса. Если геокодер или слой данных не дал результата, прямо скажи, что данных недостаточно.
|
|
19
|
+
|
|
20
|
+
Маршрут по дорогам не рассчитывается: расстояние в CLI считается по координатам как расстояние по прямой, если явно не подключен отдельный маршрутизатор.
|
package/src/cli.js
CHANGED
|
@@ -228,7 +228,7 @@ const DEFAULT_AI_CONFIG = {
|
|
|
228
228
|
suggestions: true,
|
|
229
229
|
},
|
|
230
230
|
skills: {
|
|
231
|
-
enabled: ["education", "open-data", "reports", "local-model", "local-files", "browser-agent"],
|
|
231
|
+
enabled: ["education", "open-data", "geo", "reports", "local-model", "local-files", "browser-agent"],
|
|
232
232
|
},
|
|
233
233
|
daemon: {
|
|
234
234
|
host: "127.0.0.1",
|
|
@@ -440,6 +440,7 @@ const COMMANDS = new Map([
|
|
|
440
440
|
["health", checkHealth],
|
|
441
441
|
["layers", listLayers],
|
|
442
442
|
["data", handleData],
|
|
443
|
+
["geo", handleGeo],
|
|
443
444
|
["schools", listSchools],
|
|
444
445
|
["kindergartens", listKindergartens],
|
|
445
446
|
["search", searchAll],
|
|
@@ -653,6 +654,9 @@ Usage:
|
|
|
653
654
|
iola kindergartens [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
|
|
654
655
|
iola kindergartens get --inn INN [--json]
|
|
655
656
|
iola search TEXT [--limit 5] [--format table|json|csv]
|
|
657
|
+
iola geo key set yandex
|
|
658
|
+
iola geo key doctor
|
|
659
|
+
iola geo geocode "Йошкар-Ола, ул. Петрова, 15"
|
|
656
660
|
iola mcp-info [--json]
|
|
657
661
|
iola setup codex
|
|
658
662
|
iola onboard
|
|
@@ -1881,6 +1885,7 @@ async function doctor(args = []) {
|
|
|
1881
1885
|
modelAvailable: await checkConfiguredModel({ ai: activeAiProfile }),
|
|
1882
1886
|
openaiKey: process.env.OPENAI_API_KEY ? "env" : secrets.openai?.apiKey ? "local" : "missing",
|
|
1883
1887
|
openrouterKey: process.env.OPENROUTER_API_KEY ? "env" : secrets.openrouter?.apiKey ? "local" : "missing",
|
|
1888
|
+
yandexGeocoderKey: (process.env.YANDEX_GEOCODER_API_KEY || process.env.YANDEX_MAPS_API_KEY) ? "env" : secrets.yandexGeocoder?.apiKey ? "local" : "missing",
|
|
1884
1889
|
ollama: diagnostics.ollama.installed ? diagnostics.ollama.version : "not-installed",
|
|
1885
1890
|
},
|
|
1886
1891
|
skills: {
|
|
@@ -2565,6 +2570,8 @@ async function handleWiki(args) {
|
|
|
2565
2570
|
["Первый запуск", `${base}/Первый-запуск`],
|
|
2566
2571
|
["Мастер настройки", `${base}/Мастер-настройки`],
|
|
2567
2572
|
["AI-профили", `${base}/AI-профили`],
|
|
2573
|
+
["Yandex Geocoder API key", `${base}/Yandex-Geocoder-API-key`],
|
|
2574
|
+
["Скиллы для жителей", `${base}/Скиллы-для-жителей`],
|
|
2568
2575
|
["Локальный инструментальный агент", `${base}/Локальный-инструментальный-агент`],
|
|
2569
2576
|
["Skills и toolsets", `${base}/Skills-и-toolsets`],
|
|
2570
2577
|
["Локальные файлы", `${base}/Локальные-файлы`],
|
|
@@ -5323,6 +5330,652 @@ async function deleteAiKey(provider) {
|
|
|
5323
5330
|
console.log(`Локальный ключ ${provider} удален.`);
|
|
5324
5331
|
}
|
|
5325
5332
|
|
|
5333
|
+
async function handleGeo(args) {
|
|
5334
|
+
const [command, subcommand, ...rest] = args;
|
|
5335
|
+
|
|
5336
|
+
if (command === "key") {
|
|
5337
|
+
await handleGeoKey([subcommand, ...rest]);
|
|
5338
|
+
return;
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5341
|
+
if (command === "geocode") {
|
|
5342
|
+
await geoGeocode([subcommand, ...rest].filter(Boolean));
|
|
5343
|
+
return;
|
|
5344
|
+
}
|
|
5345
|
+
|
|
5346
|
+
if (command === "nearby") {
|
|
5347
|
+
await geoNearby([subcommand, ...rest].filter(Boolean));
|
|
5348
|
+
return;
|
|
5349
|
+
}
|
|
5350
|
+
|
|
5351
|
+
if (command === "distance") {
|
|
5352
|
+
await geoDistance([subcommand, ...rest].filter(Boolean));
|
|
5353
|
+
return;
|
|
5354
|
+
}
|
|
5355
|
+
|
|
5356
|
+
if (command === "map-link") {
|
|
5357
|
+
await geoMapLink([subcommand, ...rest].filter(Boolean));
|
|
5358
|
+
return;
|
|
5359
|
+
}
|
|
5360
|
+
|
|
5361
|
+
if (command === "resolve") {
|
|
5362
|
+
await geoResolve([subcommand, ...rest].filter(Boolean));
|
|
5363
|
+
return;
|
|
5364
|
+
}
|
|
5365
|
+
|
|
5366
|
+
if (command === "route-context") {
|
|
5367
|
+
await geoRouteContext([subcommand, ...rest].filter(Boolean));
|
|
5368
|
+
return;
|
|
5369
|
+
}
|
|
5370
|
+
|
|
5371
|
+
if (command === "services") {
|
|
5372
|
+
await geoServices([subcommand, ...rest].filter(Boolean));
|
|
5373
|
+
return;
|
|
5374
|
+
}
|
|
5375
|
+
|
|
5376
|
+
if (command === "doctor" || command === "status") {
|
|
5377
|
+
await printGeoKeyStatus({ check: command === "doctor" });
|
|
5378
|
+
return;
|
|
5379
|
+
}
|
|
5380
|
+
|
|
5381
|
+
throw new Error(`Команды geo:
|
|
5382
|
+
iola geo key set yandex
|
|
5383
|
+
iola geo key status
|
|
5384
|
+
iola geo key doctor
|
|
5385
|
+
iola geo key delete yandex
|
|
5386
|
+
iola geo geocode "Йошкар-Ола, ул. Петрова, 15"
|
|
5387
|
+
iola geo nearby "Йошкар-Ола, ул. Петрова, 15" --dataset all --limit 5
|
|
5388
|
+
iola geo distance --from "Йошкар-Ола, ул. Петрова, 15" --to "школа 7"
|
|
5389
|
+
iola geo map-link "школа 7"
|
|
5390
|
+
iola geo resolve "садик золотой петушок"
|
|
5391
|
+
iola geo route-context "школа 7"
|
|
5392
|
+
iola geo services "Йошкар-Ола, ул. Петрова, 15"`);
|
|
5393
|
+
}
|
|
5394
|
+
|
|
5395
|
+
async function handleGeoKey(args) {
|
|
5396
|
+
const [action, provider = "yandex"] = args;
|
|
5397
|
+
if (provider !== "yandex") {
|
|
5398
|
+
throw new Error("Сейчас поддерживается только провайдер: yandex");
|
|
5399
|
+
}
|
|
5400
|
+
|
|
5401
|
+
if (action === "set") {
|
|
5402
|
+
await setYandexGeocoderKey();
|
|
5403
|
+
return;
|
|
5404
|
+
}
|
|
5405
|
+
|
|
5406
|
+
if (action === "status") {
|
|
5407
|
+
await printGeoKeyStatus();
|
|
5408
|
+
return;
|
|
5409
|
+
}
|
|
5410
|
+
|
|
5411
|
+
if (action === "doctor" || action === "check") {
|
|
5412
|
+
await printGeoKeyStatus({ check: true });
|
|
5413
|
+
return;
|
|
5414
|
+
}
|
|
5415
|
+
|
|
5416
|
+
if (action === "delete") {
|
|
5417
|
+
const secrets = await loadSecrets();
|
|
5418
|
+
delete secrets.yandexGeocoder;
|
|
5419
|
+
await saveSecrets(secrets);
|
|
5420
|
+
console.log("Локальный ключ yandex geocoder удален.");
|
|
5421
|
+
return;
|
|
5422
|
+
}
|
|
5423
|
+
|
|
5424
|
+
throw new Error("Команды geo key: set yandex, status, doctor, delete yandex.");
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5427
|
+
async function setYandexGeocoderKey() {
|
|
5428
|
+
if (!process.stdin.isTTY) {
|
|
5429
|
+
throw new Error("Для сохранения ключа запустите команду в интерактивном терминале.");
|
|
5430
|
+
}
|
|
5431
|
+
|
|
5432
|
+
const key = (await askText("Введите YANDEX_GEOCODER_API_KEY: ")).trim();
|
|
5433
|
+
if (!key) throw new Error("Ключ пустой, сохранение отменено.");
|
|
5434
|
+
|
|
5435
|
+
const secrets = await loadSecrets();
|
|
5436
|
+
secrets.yandexGeocoder = { apiKey: key };
|
|
5437
|
+
await saveSecrets(secrets);
|
|
5438
|
+
console.log(`Ключ yandex geocoder сохранен локально: ${SECRETS_FILE}`);
|
|
5439
|
+
|
|
5440
|
+
const shouldCheck = await confirm("Проверить ключ запросом к Yandex Geocoder? [Y/n] ");
|
|
5441
|
+
if (shouldCheck) await checkYandexGeocoderKey({ print: true });
|
|
5442
|
+
}
|
|
5443
|
+
|
|
5444
|
+
async function printGeoKeyStatus(options = {}) {
|
|
5445
|
+
const key = await getYandexGeocoderKey();
|
|
5446
|
+
const rows = [{
|
|
5447
|
+
provider: "yandex-geocoder",
|
|
5448
|
+
env: (process.env.YANDEX_GEOCODER_API_KEY || process.env.YANDEX_MAPS_API_KEY) ? "yes" : "no",
|
|
5449
|
+
local: (await loadSecrets()).yandexGeocoder?.apiKey ? "yes" : "no",
|
|
5450
|
+
status: key ? "configured" : "missing",
|
|
5451
|
+
}];
|
|
5452
|
+
printTable(rows, [
|
|
5453
|
+
["provider", "Провайдер"],
|
|
5454
|
+
["env", "Env"],
|
|
5455
|
+
["local", "Локально"],
|
|
5456
|
+
["status", "Статус"],
|
|
5457
|
+
]);
|
|
5458
|
+
if (options.check) await checkYandexGeocoderKey({ print: true });
|
|
5459
|
+
}
|
|
5460
|
+
|
|
5461
|
+
async function geoGeocode(args) {
|
|
5462
|
+
const query = args.join(" ").trim();
|
|
5463
|
+
if (!query) throw new Error('Адрес обязателен. Пример: iola geo geocode "Йошкар-Ола, ул. Петрова, 15"');
|
|
5464
|
+
const result = await callYandexGeocoder(query);
|
|
5465
|
+
if (!result) {
|
|
5466
|
+
console.log("Yandex Geocoder не вернул результат.");
|
|
5467
|
+
return;
|
|
5468
|
+
}
|
|
5469
|
+
printKeyValue(result);
|
|
5470
|
+
}
|
|
5471
|
+
|
|
5472
|
+
async function geoNearby(args) {
|
|
5473
|
+
const options = parseOptions(args);
|
|
5474
|
+
const query = options._.join(" ").trim();
|
|
5475
|
+
if (!query) throw new Error('Адрес обязателен. Пример: iola geo nearby "Йошкар-Ола, ул. Петрова, 15" --dataset schools');
|
|
5476
|
+
const answer = await buildNearbyAnswer(query, {
|
|
5477
|
+
dataset: normalizeGeoDataset(options.dataset || inferGeoDataset(query)),
|
|
5478
|
+
limit: Number(options.limit || 5),
|
|
5479
|
+
radius: Number(options.radius || 0),
|
|
5480
|
+
});
|
|
5481
|
+
console.log(answer);
|
|
5482
|
+
}
|
|
5483
|
+
|
|
5484
|
+
async function geoDistance(args) {
|
|
5485
|
+
const options = parseOptions(args);
|
|
5486
|
+
const from = options.from || options.address || options._.join(" ").split(/\s+(?:до|и|->)\s+/iu)[0]?.trim();
|
|
5487
|
+
const to = options.to || options._.join(" ").split(/\s+(?:до|и|->)\s+/iu)[1]?.trim();
|
|
5488
|
+
if (!from || !to) throw new Error('Нужны две точки. Пример: iola geo distance --from "Петрова 15" --to "школа 7"');
|
|
5489
|
+
const answer = await buildDistanceAnswer(from, to);
|
|
5490
|
+
console.log(answer);
|
|
5491
|
+
}
|
|
5492
|
+
|
|
5493
|
+
async function geoMapLink(args) {
|
|
5494
|
+
const query = args.join(" ").trim();
|
|
5495
|
+
if (!query) throw new Error('Объект или адрес обязателен. Пример: iola geo map-link "школа 7"');
|
|
5496
|
+
const answer = await buildMapLinkAnswer(query);
|
|
5497
|
+
console.log(answer);
|
|
5498
|
+
}
|
|
5499
|
+
|
|
5500
|
+
async function geoResolve(args) {
|
|
5501
|
+
const query = args.join(" ").trim();
|
|
5502
|
+
if (!query) throw new Error('Место или объект обязателен. Пример: iola geo resolve "садик золотой петушок"');
|
|
5503
|
+
const answer = await buildPlaceResolverAnswer(query);
|
|
5504
|
+
console.log(answer);
|
|
5505
|
+
}
|
|
5506
|
+
|
|
5507
|
+
async function geoRouteContext(args) {
|
|
5508
|
+
const query = args.join(" ").trim();
|
|
5509
|
+
if (!query) throw new Error('Объект или адрес обязателен. Пример: iola geo route-context "школа 7"');
|
|
5510
|
+
const answer = await buildRouteContextAnswer(query);
|
|
5511
|
+
console.log(answer);
|
|
5512
|
+
}
|
|
5513
|
+
|
|
5514
|
+
async function geoServices(args) {
|
|
5515
|
+
const options = parseOptions(args);
|
|
5516
|
+
const query = options._.join(" ").trim();
|
|
5517
|
+
if (!query) throw new Error('Адрес обязателен. Пример: iola geo services "Йошкар-Ола, ул. Петрова, 15"');
|
|
5518
|
+
const answer = await buildAddressToServicesAnswer(query, { limit: Number(options.limit || 3) });
|
|
5519
|
+
console.log(answer);
|
|
5520
|
+
}
|
|
5521
|
+
|
|
5522
|
+
async function checkYandexGeocoderKey(options = {}) {
|
|
5523
|
+
try {
|
|
5524
|
+
const result = await callYandexGeocoder("Йошкар-Ола");
|
|
5525
|
+
if (!result?.coordinates) throw new Error("Yandex Geocoder вернул пустой результат.");
|
|
5526
|
+
if (options.print) {
|
|
5527
|
+
console.log(`Yandex Geocoder: ok (${result.name || result.address || result.coordinates})`);
|
|
5528
|
+
}
|
|
5529
|
+
return true;
|
|
5530
|
+
} catch (error) {
|
|
5531
|
+
if (options.print) {
|
|
5532
|
+
console.log(`Yandex Geocoder: error - ${error instanceof Error ? error.message : String(error)}`);
|
|
5533
|
+
}
|
|
5534
|
+
return false;
|
|
5535
|
+
}
|
|
5536
|
+
}
|
|
5537
|
+
|
|
5538
|
+
async function callYandexGeocoder(query) {
|
|
5539
|
+
const apiKey = await getYandexGeocoderKey();
|
|
5540
|
+
if (!apiKey) {
|
|
5541
|
+
throw new Error("Yandex Geocoder API key не найден. Выполните iola geo key set yandex или задайте YANDEX_GEOCODER_API_KEY.");
|
|
5542
|
+
}
|
|
5543
|
+
|
|
5544
|
+
const url = new URL("https://geocode-maps.yandex.ru/v1/");
|
|
5545
|
+
url.searchParams.set("apikey", apiKey);
|
|
5546
|
+
url.searchParams.set("geocode", query);
|
|
5547
|
+
url.searchParams.set("format", "json");
|
|
5548
|
+
url.searchParams.set("lang", "ru_RU");
|
|
5549
|
+
url.searchParams.set("results", "1");
|
|
5550
|
+
|
|
5551
|
+
const response = await fetchYandexGeocoderWithRetry(url);
|
|
5552
|
+
|
|
5553
|
+
if (!response.ok) {
|
|
5554
|
+
const text = await response.text();
|
|
5555
|
+
if (response.status === 403 && /Invalid api key/i.test(text)) {
|
|
5556
|
+
throw new Error(`Yandex Geocoder request failed: ${response.status} ${response.statusText}\nInvalid api key. Если ключ только что создан, подождите до 15 минут: Yandex указывает, что активация ключа может занять до 15 минут.`);
|
|
5557
|
+
}
|
|
5558
|
+
throw new Error(`Yandex Geocoder request failed: ${response.status} ${response.statusText}\n${sanitizeSecretFromText(text, apiKey)}`);
|
|
5559
|
+
}
|
|
5560
|
+
|
|
5561
|
+
const payload = await response.json();
|
|
5562
|
+
const member = payload?.response?.GeoObjectCollection?.featureMember?.[0];
|
|
5563
|
+
const object = member?.GeoObject;
|
|
5564
|
+
if (!object) return null;
|
|
5565
|
+
const point = object.Point?.pos || "";
|
|
5566
|
+
const [lon, lat] = point.split(/\s+/);
|
|
5567
|
+
return {
|
|
5568
|
+
name: object.name || "",
|
|
5569
|
+
address: object.metaDataProperty?.GeocoderMetaData?.text || object.description || "",
|
|
5570
|
+
precision: object.metaDataProperty?.GeocoderMetaData?.precision || "",
|
|
5571
|
+
coordinates: lat && lon ? `${lat}, ${lon}` : point,
|
|
5572
|
+
map: lat && lon ? `https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map` : "",
|
|
5573
|
+
};
|
|
5574
|
+
}
|
|
5575
|
+
|
|
5576
|
+
async function fetchYandexGeocoderWithRetry(url) {
|
|
5577
|
+
let lastError = null;
|
|
5578
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
5579
|
+
try {
|
|
5580
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
|
5581
|
+
if (response.status !== 429 || attempt === 2) return response;
|
|
5582
|
+
await sleep(700 * (attempt + 1));
|
|
5583
|
+
} catch (error) {
|
|
5584
|
+
lastError = error;
|
|
5585
|
+
if (attempt === 2) break;
|
|
5586
|
+
await sleep(500 * (attempt + 1));
|
|
5587
|
+
}
|
|
5588
|
+
}
|
|
5589
|
+
throw new Error(formatProviderFetchError("Yandex Geocoder", lastError));
|
|
5590
|
+
}
|
|
5591
|
+
|
|
5592
|
+
const geoMemoryCache = new Map();
|
|
5593
|
+
|
|
5594
|
+
async function buildGeoDirectAnswer(question) {
|
|
5595
|
+
const normalized = String(question || "").toLocaleLowerCase("ru-RU");
|
|
5596
|
+
if (!isGeoQuestion(normalized)) return "";
|
|
5597
|
+
const place = detectEducationPlace(question);
|
|
5598
|
+
if (place && !place.supported && /(школ|сош|лице|гимнази|сад|детсад|детск\w*\s+сад|садик)/iu.test(normalized)) {
|
|
5599
|
+
return formatUnsupportedEducationGeoPlace(place);
|
|
5600
|
+
}
|
|
5601
|
+
|
|
5602
|
+
try {
|
|
5603
|
+
if (/(расстояни|как далеко|далеко ли|ближе)/iu.test(normalized)) {
|
|
5604
|
+
const pair = extractDistancePair(question);
|
|
5605
|
+
if (pair.from && pair.to) return buildDistanceAnswer(pair.from, pair.to);
|
|
5606
|
+
}
|
|
5607
|
+
|
|
5608
|
+
if (/(карт[аеу]|ссылк.*карт|открыть.*карт)/iu.test(normalized)) {
|
|
5609
|
+
const query = cleanupGeoObjectQuery(question);
|
|
5610
|
+
if (query) return buildMapLinkAnswer(query);
|
|
5611
|
+
}
|
|
5612
|
+
|
|
5613
|
+
if (/(где находится|как пройти|как добраться|что рядом|ориентир)/iu.test(normalized)) {
|
|
5614
|
+
const query = cleanupGeoObjectQuery(question);
|
|
5615
|
+
if (query) return buildRouteContextAnswer(query);
|
|
5616
|
+
}
|
|
5617
|
+
|
|
5618
|
+
if (/(мой адрес|живу|по адресу)/iu.test(normalized)) {
|
|
5619
|
+
const address = extractAddressForNearby(question);
|
|
5620
|
+
if (address) return buildAddressToServicesAnswer(address, { limit: 3 });
|
|
5621
|
+
}
|
|
5622
|
+
|
|
5623
|
+
if (/(рядом|поблизости|ближайш|ближе|около|возле)/iu.test(normalized)) {
|
|
5624
|
+
const address = extractAddressForNearby(question);
|
|
5625
|
+
if (address) return buildNearbyAnswer(address, { dataset: normalizeGeoDataset(inferGeoDataset(question)), limit: 5 });
|
|
5626
|
+
}
|
|
5627
|
+
|
|
5628
|
+
if (/(уточни место|какой населенный пункт|какой район|фильтр.*район|фильтр.*населен|не путай.*населен)/iu.test(normalized)) {
|
|
5629
|
+
const query = cleanupGeoObjectQuery(question);
|
|
5630
|
+
if (query) return buildPlaceResolverAnswer(query);
|
|
5631
|
+
}
|
|
5632
|
+
} catch (error) {
|
|
5633
|
+
if (/Yandex Geocoder API key не найден/iu.test(String(error?.message || ""))) return "";
|
|
5634
|
+
return `Не смог выполнить geo-запрос: ${error instanceof Error ? error.message : String(error)}`;
|
|
5635
|
+
}
|
|
5636
|
+
|
|
5637
|
+
return "";
|
|
5638
|
+
}
|
|
5639
|
+
|
|
5640
|
+
function formatUnsupportedEducationGeoPlace(place) {
|
|
5641
|
+
return `В текущих открытых данных iola-cli есть данные городского округа Йошкар-Ола. Данных по ${place.locative || place.label} в подключенных слоях нет, поэтому не могу надежно ответить по этому объекту.`;
|
|
5642
|
+
}
|
|
5643
|
+
|
|
5644
|
+
function isGeoQuestion(normalized) {
|
|
5645
|
+
return /(рядом|поблизости|ближайш|ближе|расстояни|как далеко|карт[аеу]|где находится|как пройти|как добраться|мой адрес|живу|по адресу|ориентир|уточни место|какой населенный пункт|какой район|фильтр.*район|фильтр.*населен)/iu.test(normalized);
|
|
5646
|
+
}
|
|
5647
|
+
|
|
5648
|
+
async function buildNearbyAnswer(address, options = {}) {
|
|
5649
|
+
const origin = await resolveGeoPoint(address);
|
|
5650
|
+
const dataset = normalizeGeoDataset(options.dataset || "all");
|
|
5651
|
+
const limit = Math.max(1, Number(options.limit || 5));
|
|
5652
|
+
const radius = Number(options.radius || 0);
|
|
5653
|
+
const candidates = await getGeoCandidates(dataset);
|
|
5654
|
+
const ranked = (await asyncMapLimit(candidates, 6, async (item) => {
|
|
5655
|
+
const point = item.address
|
|
5656
|
+
? await geocodeCached(normalizeAddressForGeocoder(item.address)).catch(() => null)
|
|
5657
|
+
: await resolveGeoPoint(item.name).catch(() => null);
|
|
5658
|
+
if (!point?.lat || !point?.lon) return null;
|
|
5659
|
+
const distanceMeters = haversineMeters(origin, point);
|
|
5660
|
+
return { ...item, point, distanceMeters };
|
|
5661
|
+
})).filter(Boolean)
|
|
5662
|
+
.filter((item) => !radius || item.distanceMeters <= radius)
|
|
5663
|
+
.sort((a, b) => a.distanceMeters - b.distanceMeters)
|
|
5664
|
+
.slice(0, limit);
|
|
5665
|
+
|
|
5666
|
+
if (ranked.length === 0) return `Рядом с адресом "${address}" не нашел объектов в подключенных слоях.`;
|
|
5667
|
+
|
|
5668
|
+
return [
|
|
5669
|
+
`Ближайшие объекты к адресу: ${origin.address || address}`,
|
|
5670
|
+
...ranked.map((item, index) => `${index + 1}. ${item.name} — ${formatDistance(item.distanceMeters)}${item.address ? `\n Адрес: ${item.address}` : ""}${item.inn ? `\n ИНН: ${item.inn}` : ""}${item.point.map ? `\n Карта: ${item.point.map}` : ""}`),
|
|
5671
|
+
`Источник: Yandex Geocoder + слои ${dataset === "all" ? "schools, kindergartens" : dataset}.`,
|
|
5672
|
+
].join("\n");
|
|
5673
|
+
}
|
|
5674
|
+
|
|
5675
|
+
async function buildDistanceAnswer(fromQuery, toQuery) {
|
|
5676
|
+
const from = await resolveGeoPoint(fromQuery);
|
|
5677
|
+
const to = await resolveGeoPoint(toQuery);
|
|
5678
|
+
const distance = haversineMeters(from, to);
|
|
5679
|
+
return [
|
|
5680
|
+
`Расстояние по прямой: ${formatDistance(distance)}.`,
|
|
5681
|
+
`От: ${from.name || from.address || fromQuery}${from.address && from.address !== from.name ? ` (${from.address})` : ""}`,
|
|
5682
|
+
`До: ${to.name || to.address || toQuery}${to.address && to.address !== to.name ? ` (${to.address})` : ""}`,
|
|
5683
|
+
to.map ? `Карта точки назначения: ${to.map}` : "",
|
|
5684
|
+
"Это расстояние по координатам, не маршрут по дорогам.",
|
|
5685
|
+
].filter(Boolean).join("\n");
|
|
5686
|
+
}
|
|
5687
|
+
|
|
5688
|
+
async function buildMapLinkAnswer(query) {
|
|
5689
|
+
const point = await resolveGeoPoint(query);
|
|
5690
|
+
return [
|
|
5691
|
+
point.name || query,
|
|
5692
|
+
point.address ? `Адрес: ${point.address}` : "",
|
|
5693
|
+
point.coordinates ? `Координаты: ${point.coordinates}` : "",
|
|
5694
|
+
point.map ? `Карта: ${point.map}` : "",
|
|
5695
|
+
].filter(Boolean).join("\n");
|
|
5696
|
+
}
|
|
5697
|
+
|
|
5698
|
+
async function buildPlaceResolverAnswer(query) {
|
|
5699
|
+
const match = await resolveGeoEntity(query).catch(() => null);
|
|
5700
|
+
if (match) {
|
|
5701
|
+
return [
|
|
5702
|
+
`Нашел объект: ${match.name}`,
|
|
5703
|
+
match.address ? `Адрес: ${match.address}` : "",
|
|
5704
|
+
match.inn ? `ИНН: ${match.inn}` : "",
|
|
5705
|
+
match.layer ? `Слой: ${match.layer}` : "",
|
|
5706
|
+
match.map ? `Карта: ${match.map}` : "",
|
|
5707
|
+
].filter(Boolean).join("\n");
|
|
5708
|
+
}
|
|
5709
|
+
const point = await callYandexGeocoder(query);
|
|
5710
|
+
if (!point) return `Не смог уточнить место: ${query}`;
|
|
5711
|
+
return [
|
|
5712
|
+
`Уточнил место через геокодер: ${point.name || query}`,
|
|
5713
|
+
point.address ? `Адрес: ${point.address}` : "",
|
|
5714
|
+
point.precision ? `Точность: ${point.precision}` : "",
|
|
5715
|
+
point.coordinates ? `Координаты: ${point.coordinates}` : "",
|
|
5716
|
+
point.map ? `Карта: ${point.map}` : "",
|
|
5717
|
+
].filter(Boolean).join("\n");
|
|
5718
|
+
}
|
|
5719
|
+
|
|
5720
|
+
async function buildRouteContextAnswer(query) {
|
|
5721
|
+
const point = await resolveGeoPoint(query);
|
|
5722
|
+
const nearby = await buildNearbyAnswer(point.address || query, { dataset: "all", limit: 3 });
|
|
5723
|
+
return [
|
|
5724
|
+
`${point.name || query}`,
|
|
5725
|
+
point.address ? `Адрес: ${point.address}` : "",
|
|
5726
|
+
point.coordinates ? `Координаты: ${point.coordinates}` : "",
|
|
5727
|
+
point.map ? `Карта: ${point.map}` : "",
|
|
5728
|
+
"",
|
|
5729
|
+
nearby,
|
|
5730
|
+
].filter(Boolean).join("\n");
|
|
5731
|
+
}
|
|
5732
|
+
|
|
5733
|
+
async function buildAddressToServicesAnswer(address, options = {}) {
|
|
5734
|
+
const limit = Math.max(1, Number(options.limit || 3));
|
|
5735
|
+
const schools = await buildNearbyAnswer(address, { dataset: "schools", limit });
|
|
5736
|
+
const kindergartens = await buildNearbyAnswer(address, { dataset: "kindergartens", limit });
|
|
5737
|
+
return [
|
|
5738
|
+
`По адресу "${address}" ближайшие подключенные городские объекты:`,
|
|
5739
|
+
"",
|
|
5740
|
+
"Школы:",
|
|
5741
|
+
stripNearbyHeader(schools),
|
|
5742
|
+
"",
|
|
5743
|
+
"Детские сады:",
|
|
5744
|
+
stripNearbyHeader(kindergartens),
|
|
5745
|
+
].join("\n");
|
|
5746
|
+
}
|
|
5747
|
+
|
|
5748
|
+
function stripNearbyHeader(text) {
|
|
5749
|
+
return String(text || "").split("\n").filter((line) => !line.startsWith("Ближайшие объекты к адресу:")).join("\n");
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
async function resolveGeoPoint(query) {
|
|
5753
|
+
const entity = await resolveGeoEntity(query).catch(() => null);
|
|
5754
|
+
if (entity?.address) {
|
|
5755
|
+
const point = await geocodeCached(normalizeAddressForGeocoder(entity.address));
|
|
5756
|
+
return mergeGeoEntityPoint(entity, point);
|
|
5757
|
+
}
|
|
5758
|
+
return geocodeCached(query);
|
|
5759
|
+
}
|
|
5760
|
+
|
|
5761
|
+
async function resolveGeoEntity(query) {
|
|
5762
|
+
const dataset = normalizeGeoDataset(inferGeoDataset(query));
|
|
5763
|
+
let candidates = await getGeoCandidates(dataset);
|
|
5764
|
+
const normalized = normalizeGeoText(query);
|
|
5765
|
+
const number = extractEntityNumberFromQuestion(query, dataset === "kindergartens" ? "kindergartens" : "schools");
|
|
5766
|
+
const place = detectEducationPlace(query);
|
|
5767
|
+
if (place && !place.supported) return null;
|
|
5768
|
+
if (place?.supported) {
|
|
5769
|
+
const placeMatches = candidates.filter((item) => geoItemMatchesPlace(item, place));
|
|
5770
|
+
if (placeMatches.length > 0) candidates = placeMatches;
|
|
5771
|
+
}
|
|
5772
|
+
if (number) {
|
|
5773
|
+
const exactNumberMatches = candidates.filter((item) => itemNameHasNumber(item, number));
|
|
5774
|
+
if (exactNumberMatches.length === 1) {
|
|
5775
|
+
const point = exactNumberMatches[0].address ? await geocodeCached(normalizeAddressForGeocoder(exactNumberMatches[0].address)).catch(() => null) : null;
|
|
5776
|
+
return mergeGeoEntityPoint(exactNumberMatches[0], point);
|
|
5777
|
+
}
|
|
5778
|
+
if (exactNumberMatches.length > 1) {
|
|
5779
|
+
const scoped = place?.supported
|
|
5780
|
+
? exactNumberMatches.filter((item) => geoItemMatchesPlace(item, place))
|
|
5781
|
+
: exactNumberMatches;
|
|
5782
|
+
const bestExact = scoped[0] || exactNumberMatches[0];
|
|
5783
|
+
const point = bestExact.address ? await geocodeCached(normalizeAddressForGeocoder(bestExact.address)).catch(() => null) : null;
|
|
5784
|
+
return mergeGeoEntityPoint(bestExact, point);
|
|
5785
|
+
}
|
|
5786
|
+
}
|
|
5787
|
+
const scored = candidates.map((item) => ({ ...item, score: scoreGeoCandidate(item, normalized, number, place) }))
|
|
5788
|
+
.filter((item) => item.score > 0)
|
|
5789
|
+
.sort((a, b) => b.score - a.score);
|
|
5790
|
+
const best = scored[0];
|
|
5791
|
+
if (!best) return null;
|
|
5792
|
+
const point = best.address ? await geocodeCached(normalizeAddressForGeocoder(best.address)).catch(() => null) : null;
|
|
5793
|
+
return mergeGeoEntityPoint(best, point);
|
|
5794
|
+
}
|
|
5795
|
+
|
|
5796
|
+
function mergeGeoEntityPoint(entity, point) {
|
|
5797
|
+
if (!point) return entity;
|
|
5798
|
+
return {
|
|
5799
|
+
...entity,
|
|
5800
|
+
geoName: point.name || "",
|
|
5801
|
+
geocodedAddress: point.address || "",
|
|
5802
|
+
precision: point.precision || "",
|
|
5803
|
+
coordinates: point.coordinates || "",
|
|
5804
|
+
map: point.map || "",
|
|
5805
|
+
lat: point.lat,
|
|
5806
|
+
lon: point.lon,
|
|
5807
|
+
};
|
|
5808
|
+
}
|
|
5809
|
+
|
|
5810
|
+
function scoreGeoCandidate(item, normalizedQuery, number, place) {
|
|
5811
|
+
const name = normalizeGeoText(item.name);
|
|
5812
|
+
const address = normalizeGeoText(item.address);
|
|
5813
|
+
let score = 0;
|
|
5814
|
+
if (number && itemNameHasNumber(item, number)) score += 20;
|
|
5815
|
+
for (const token of normalizeGeoText(normalizedQuery).split(/\s+/).filter((part) => part.length >= 3)) {
|
|
5816
|
+
if (name.includes(token)) score += 3;
|
|
5817
|
+
if (address.includes(token)) score += 1;
|
|
5818
|
+
}
|
|
5819
|
+
if (place?.supported && geoItemMatchesPlace(item, place)) score += 8;
|
|
5820
|
+
if (/(сад|детсад|садик)/iu.test(normalizedQuery) && item.layer === "kindergartens") score += 5;
|
|
5821
|
+
if (/(школ|лице|гимнази)/iu.test(normalizedQuery) && item.layer === "schools") score += 5;
|
|
5822
|
+
return score;
|
|
5823
|
+
}
|
|
5824
|
+
|
|
5825
|
+
function geoItemMatchesPlace(item, place) {
|
|
5826
|
+
if (!place?.supported) return false;
|
|
5827
|
+
const haystack = normalizeGeoText(`${item.name || ""} ${item.address || ""}`);
|
|
5828
|
+
const aliases = [place.label, ...(place.aliases || [])].map(normalizeGeoText).filter(Boolean);
|
|
5829
|
+
return aliases.some((alias) => haystack.includes(alias));
|
|
5830
|
+
}
|
|
5831
|
+
|
|
5832
|
+
async function getGeoCandidates(dataset = "all") {
|
|
5833
|
+
const layers = dataset === "all" ? ["schools", "kindergartens"] : [dataset];
|
|
5834
|
+
const result = [];
|
|
5835
|
+
for (const layer of layers) {
|
|
5836
|
+
const items = normalizeItems(await fetchAllApiItems(`${await getApiBaseUrl()}/${DATASETS[layer].endpoint}`))
|
|
5837
|
+
.map(selectPublicSummary)
|
|
5838
|
+
.filter((item) => item.name || item.address)
|
|
5839
|
+
.map((item) => ({ ...item, layer }));
|
|
5840
|
+
result.push(...items);
|
|
5841
|
+
}
|
|
5842
|
+
return result;
|
|
5843
|
+
}
|
|
5844
|
+
|
|
5845
|
+
async function geocodeCached(query) {
|
|
5846
|
+
const key = normalizeGeoText(query);
|
|
5847
|
+
if (geoMemoryCache.has(key)) return geoMemoryCache.get(key);
|
|
5848
|
+
const value = await callYandexGeocoder(query);
|
|
5849
|
+
if (!value) throw new Error(`Yandex Geocoder не вернул результат для: ${query}`);
|
|
5850
|
+
const parsed = parseCoordinates(value.coordinates);
|
|
5851
|
+
const point = { ...value, ...parsed };
|
|
5852
|
+
geoMemoryCache.set(key, point);
|
|
5853
|
+
return point;
|
|
5854
|
+
}
|
|
5855
|
+
|
|
5856
|
+
function parseCoordinates(coordinates) {
|
|
5857
|
+
const [lat, lon] = String(coordinates || "").split(",").map((part) => Number(part.trim()));
|
|
5858
|
+
return { lat, lon };
|
|
5859
|
+
}
|
|
5860
|
+
|
|
5861
|
+
async function asyncMapLimit(items, limit, mapper) {
|
|
5862
|
+
const source = Array.from(items || []);
|
|
5863
|
+
if (source.length === 0) return [];
|
|
5864
|
+
const results = new Array(source.length);
|
|
5865
|
+
let next = 0;
|
|
5866
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, source.length)) }, async () => {
|
|
5867
|
+
while (next < source.length) {
|
|
5868
|
+
const index = next;
|
|
5869
|
+
next += 1;
|
|
5870
|
+
results[index] = await mapper(source[index], index);
|
|
5871
|
+
}
|
|
5872
|
+
});
|
|
5873
|
+
await Promise.all(workers);
|
|
5874
|
+
return results;
|
|
5875
|
+
}
|
|
5876
|
+
|
|
5877
|
+
function haversineMeters(a, b) {
|
|
5878
|
+
const radius = 6371000;
|
|
5879
|
+
const lat1 = toRadians(Number(a.lat));
|
|
5880
|
+
const lat2 = toRadians(Number(b.lat));
|
|
5881
|
+
const deltaLat = toRadians(Number(b.lat) - Number(a.lat));
|
|
5882
|
+
const deltaLon = toRadians(Number(b.lon) - Number(a.lon));
|
|
5883
|
+
const x = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
|
5884
|
+
return 2 * radius * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
|
|
5885
|
+
}
|
|
5886
|
+
|
|
5887
|
+
function toRadians(value) {
|
|
5888
|
+
return value * Math.PI / 180;
|
|
5889
|
+
}
|
|
5890
|
+
|
|
5891
|
+
function formatDistance(meters) {
|
|
5892
|
+
if (!Number.isFinite(meters)) return "-";
|
|
5893
|
+
if (meters < 1000) return `${Math.round(meters)} м`;
|
|
5894
|
+
return `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} км`;
|
|
5895
|
+
}
|
|
5896
|
+
|
|
5897
|
+
function normalizeGeoDataset(value) {
|
|
5898
|
+
const text = String(value || "").toLocaleLowerCase("ru-RU");
|
|
5899
|
+
if (text === "schools" || /школ|лице|гимнази/.test(text)) return "schools";
|
|
5900
|
+
if (text === "kindergartens" || /сад|детсад|садик/.test(text)) return "kindergartens";
|
|
5901
|
+
return "all";
|
|
5902
|
+
}
|
|
5903
|
+
|
|
5904
|
+
function inferGeoDataset(text) {
|
|
5905
|
+
const normalized = String(text || "").toLocaleLowerCase("ru-RU");
|
|
5906
|
+
if (/(сад|детсад|садик)/iu.test(normalized)) return "kindergartens";
|
|
5907
|
+
if (/(школ|лице|гимнази)/iu.test(normalized)) return "schools";
|
|
5908
|
+
return "all";
|
|
5909
|
+
}
|
|
5910
|
+
|
|
5911
|
+
function extractAddressForNearby(question) {
|
|
5912
|
+
const text = String(question || "").trim();
|
|
5913
|
+
const match = text.match(/(?:рядом с|рядом|около|возле|поблизости от|живу на|мой адрес|по адресу)\s+(.+)/iu);
|
|
5914
|
+
if (match?.[1]) return cleanupGeoAddress(match[1].split(/[,;]\s*(?:какие|что|где|кто|дай|покажи)/iu)[0]);
|
|
5915
|
+
return cleanupGeoAddress(text);
|
|
5916
|
+
}
|
|
5917
|
+
|
|
5918
|
+
function extractDistancePair(question) {
|
|
5919
|
+
const text = String(question || "").trim();
|
|
5920
|
+
const explicit = {
|
|
5921
|
+
from: text.match(/(?:от|from)\s+(.+?)\s+(?:до|к|to)\s+(.+)/iu)?.[1],
|
|
5922
|
+
to: text.match(/(?:от|from)\s+(.+?)\s+(?:до|к|to)\s+(.+)/iu)?.[2],
|
|
5923
|
+
};
|
|
5924
|
+
if (explicit.from && explicit.to) return { from: cleanupGeoAddress(explicit.from), to: cleanupGeoAddress(explicit.to) };
|
|
5925
|
+
const parts = text.split(/\s+(?:и|до|->)\s+/iu).map(cleanupGeoAddress).filter(Boolean);
|
|
5926
|
+
return { from: parts[0] || "", to: parts[1] || "" };
|
|
5927
|
+
}
|
|
5928
|
+
|
|
5929
|
+
function cleanupGeoObjectQuery(text) {
|
|
5930
|
+
return cleanupGeoAddress(String(text || "")
|
|
5931
|
+
.replace(/^(?:где находится|как пройти|как добраться|покажи|дай|открой|найди|ссылку на карту|ссылка на карту)\s+/iu, "")
|
|
5932
|
+
.replace(/\b(?:на карте|карту|карта|рядом|что рядом|ориентиры?)\b/giu, ""));
|
|
5933
|
+
}
|
|
5934
|
+
|
|
5935
|
+
function cleanupGeoAddress(text) {
|
|
5936
|
+
return String(text || "")
|
|
5937
|
+
.replace(/[?.!]+$/u, "")
|
|
5938
|
+
.replace(/(^|\s)улице(?=\s|$)/giu, "$1улица")
|
|
5939
|
+
.replace(/\b(?:какие|какой|какая|есть|ближайшие|ближайший|школы|школа|детские сады|детский сад|садики|садик|объекты|городские|учреждения|рядом|поблизости)\b/giu, " ")
|
|
5940
|
+
.replace(/\s+/g, " ")
|
|
5941
|
+
.trim();
|
|
5942
|
+
}
|
|
5943
|
+
|
|
5944
|
+
function normalizeAddressForGeocoder(address) {
|
|
5945
|
+
const source = String(address || "").trim();
|
|
5946
|
+
if (!source) return source;
|
|
5947
|
+
let text = source
|
|
5948
|
+
.replace(/^\s*\d{6},?\s*/u, "")
|
|
5949
|
+
.replace(/\bРоссия,\s*/iu, "")
|
|
5950
|
+
.replace(/\bРоссийская Федерация,\s*/iu, "")
|
|
5951
|
+
.replace(/\bРеспублика Марий Эл,\s*/iu, "")
|
|
5952
|
+
.replace(/\bгородской округ\s+город\s+Йошкар-Ола,\s*/iu, "")
|
|
5953
|
+
.replace(/\bГОРОД ЙОШКАР-ОЛА,\s*/iu, "")
|
|
5954
|
+
.replace(/\bЙошкар-Ола\s+г,\s*/iu, "")
|
|
5955
|
+
.replace(/\bгород\s+Йошкар-Ола,\s*/iu, "Йошкар-Ола, ")
|
|
5956
|
+
.replace(/\bсело\s+Сем[её]новка,\s*/iu, "Йошкар-Ола, Семёновка, ")
|
|
5957
|
+
.replace(/\bдом\s+/giu, "д. ")
|
|
5958
|
+
.replace(/\bулица\s+/giu, "ул. ")
|
|
5959
|
+
.replace(/([А-ЯЁ])\.(?=[А-ЯЁ])/gu, "$1. ")
|
|
5960
|
+
.replace(/\s+/g, " ")
|
|
5961
|
+
.replace(/,\s*,/g, ",")
|
|
5962
|
+
.trim();
|
|
5963
|
+
if (!/(йошкар|сем[её]новк)/iu.test(text)) text = `Йошкар-Ола, ${text}`;
|
|
5964
|
+
return text;
|
|
5965
|
+
}
|
|
5966
|
+
|
|
5967
|
+
function normalizeGeoText(text) {
|
|
5968
|
+
return String(text || "").toLocaleLowerCase("ru-RU").replace(/ё/g, "е").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
5969
|
+
}
|
|
5970
|
+
|
|
5971
|
+
async function getYandexGeocoderKey() {
|
|
5972
|
+
if (process.env.YANDEX_GEOCODER_API_KEY || process.env.YANDEX_MAPS_API_KEY) {
|
|
5973
|
+
return process.env.YANDEX_GEOCODER_API_KEY || process.env.YANDEX_MAPS_API_KEY;
|
|
5974
|
+
}
|
|
5975
|
+
const secrets = await loadSecrets();
|
|
5976
|
+
return secrets.yandexGeocoder?.apiKey || "";
|
|
5977
|
+
}
|
|
5978
|
+
|
|
5326
5979
|
function openDatabase() {
|
|
5327
5980
|
const db = new DatabaseSync(DB_FILE);
|
|
5328
5981
|
db.exec("PRAGMA busy_timeout = 5000;");
|
|
@@ -6735,6 +7388,20 @@ async function aiAsk(args, context = {}) {
|
|
|
6735
7388
|
const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
|
|
6736
7389
|
const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
|
|
6737
7390
|
const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
|
|
7391
|
+
const geoAnswer = await buildGeoDirectAnswer(question);
|
|
7392
|
+
if (geoAnswer) {
|
|
7393
|
+
if (historyEnabled) {
|
|
7394
|
+
recordAskHistory({ question, answer: geoAnswer, providerConfig, dataContext, error: "", sessionId });
|
|
7395
|
+
appendSessionExchange(sessionId, question, geoAnswer, dataContext, "");
|
|
7396
|
+
}
|
|
7397
|
+
emitEvent(options, "answer", { length: geoAnswer.length, sessionId, direct: true, geo: true });
|
|
7398
|
+
if (options.output) {
|
|
7399
|
+
await assertPermission("writeFiles");
|
|
7400
|
+
await writeFile(options.output, geoAnswer, "utf8");
|
|
7401
|
+
}
|
|
7402
|
+
if (!options.quiet) console.log(geoAnswer);
|
|
7403
|
+
return geoAnswer;
|
|
7404
|
+
}
|
|
6738
7405
|
const directAnswer = await buildDirectDataAnswer(question, dataContext);
|
|
6739
7406
|
if (directAnswer) {
|
|
6740
7407
|
if (historyEnabled) {
|
|
@@ -7104,6 +7771,11 @@ async function localToolAsk(question, providerConfig, options) {
|
|
|
7104
7771
|
if (!options.quiet) console.log(casualAnswer);
|
|
7105
7772
|
return casualAnswer;
|
|
7106
7773
|
}
|
|
7774
|
+
const geoAnswer = await buildGeoDirectAnswer(question);
|
|
7775
|
+
if (geoAnswer) {
|
|
7776
|
+
if (!options.quiet) console.log(geoAnswer);
|
|
7777
|
+
return geoAnswer;
|
|
7778
|
+
}
|
|
7107
7779
|
await ensureLocalData();
|
|
7108
7780
|
const personRoleAnswer = buildPersonRoleDirectAnswer(question);
|
|
7109
7781
|
if (personRoleAnswer) {
|
|
@@ -7577,9 +8249,9 @@ function extractEntityNumberFromQuestion(question, layer) {
|
|
|
7577
8249
|
const isKindergarten = layer === "kindergartens";
|
|
7578
8250
|
const isSchool = layer === "schools";
|
|
7579
8251
|
const patterns = isKindergarten
|
|
7580
|
-
? [/(
|
|
8252
|
+
? [/(?:детск[\p{L}\p{N}_-]*\s+сад[\p{L}\p{N}_-]*|детсад[\p{L}\p{N}_-]*|сад[\p{L}\p{N}_-]*)\s*(?:№|номер|n)?\s*(\d{1,4})/iu, /№\s*(\d{1,4})/iu]
|
|
7581
8253
|
: isSchool
|
|
7582
|
-
? [/(
|
|
8254
|
+
? [/(?:школ[\p{L}\p{N}_-]*|сош|гимнази[\p{L}\p{N}_-]*|лице[\p{L}\p{N}_-]*)\s*(?:№|номер|n)?\s*(\d{1,4})/iu, /№\s*(\d{1,4})/iu]
|
|
7583
8255
|
: [/№\s*(\d{1,4})/iu];
|
|
7584
8256
|
for (const pattern of patterns) {
|
|
7585
8257
|
const match = text.match(pattern);
|
|
@@ -9251,6 +9923,11 @@ async function onboard(args = []) {
|
|
|
9251
9923
|
await chooseAndSaveApiModel("gigachat");
|
|
9252
9924
|
}
|
|
9253
9925
|
}
|
|
9926
|
+
if (components.includes("yandex-geocoder")) {
|
|
9927
|
+
if (process.stdin.isTTY) {
|
|
9928
|
+
await setYandexGeocoderKey();
|
|
9929
|
+
}
|
|
9930
|
+
}
|
|
9254
9931
|
if (components.includes("codex")) {
|
|
9255
9932
|
await installCodexIfMissing();
|
|
9256
9933
|
await aiSetup(["codex"]);
|
|
@@ -9299,6 +9976,7 @@ async function chooseOnboardComponents(status = null) {
|
|
|
9299
9976
|
11: "index",
|
|
9300
9977
|
12: "browser",
|
|
9301
9978
|
13: "ollama",
|
|
9979
|
+
14: "yandex-geocoder",
|
|
9302
9980
|
};
|
|
9303
9981
|
return [...selected].map((item) => map[item] || item).filter(Boolean);
|
|
9304
9982
|
} finally {
|
|
@@ -9311,13 +9989,14 @@ function isOnboardExitAnswer(answer) {
|
|
|
9311
9989
|
}
|
|
9312
9990
|
|
|
9313
9991
|
async function getOnboardComponentStatus() {
|
|
9314
|
-
const [config, readiness, browser, archive, codexVersion, ollamaVersion] = await Promise.all([
|
|
9992
|
+
const [config, readiness, browser, archive, codexVersion, ollamaVersion, yandexGeocoderKey] = await Promise.all([
|
|
9315
9993
|
loadConfig(),
|
|
9316
9994
|
getAiReadiness(),
|
|
9317
9995
|
getBrowserStatus(),
|
|
9318
9996
|
findCommand(["7z", "7zz", "7za"], ["--help"]).catch(() => null),
|
|
9319
9997
|
getCommandVersion("codex", ["--version"]),
|
|
9320
9998
|
getOllamaVersion(),
|
|
9999
|
+
getYandexGeocoderKey(),
|
|
9321
10000
|
]);
|
|
9322
10001
|
const workspaceReady = existsSync(PROJECT_CONTEXT_FILE) || existsSync(PROJECT_CONTEXT_DIR_FILE) || existsSync(PROJECT_IOLA_DIR);
|
|
9323
10002
|
const policyReady = (config.toolsets?.enabled || []).includes("analyst");
|
|
@@ -9335,6 +10014,7 @@ async function getOnboardComponentStatus() {
|
|
|
9335
10014
|
archive: Boolean(archive),
|
|
9336
10015
|
index: false,
|
|
9337
10016
|
browser: browser.installed === "yes",
|
|
10017
|
+
"yandex-geocoder": Boolean(yandexGeocoderKey),
|
|
9338
10018
|
};
|
|
9339
10019
|
}
|
|
9340
10020
|
|
|
@@ -9353,6 +10033,7 @@ function onboardComponentRows(status) {
|
|
|
9353
10033
|
["11", "index", "Индекс локальных документов", "настраивается под выбранную папку"],
|
|
9354
10034
|
["12", "browser", "Browser runtime", "Playwright/Chromium установлен"],
|
|
9355
10035
|
["13", "ollama", "Ollama", "опциональный локальный runtime"],
|
|
10036
|
+
["14", "yandex-geocoder", "Yandex Geocoder API", "ключ геокодера сохранен или есть в env"],
|
|
9356
10037
|
];
|
|
9357
10038
|
return rows.map(([number, key, title, hint]) => ({ number, key, title, hint, status: status[key] ? "готово" : "не настроено" }));
|
|
9358
10039
|
}
|
|
@@ -9367,7 +10048,7 @@ function defaultOnboardSelection(status) {
|
|
|
9367
10048
|
}
|
|
9368
10049
|
|
|
9369
10050
|
function defaultOnboardComponents(status) {
|
|
9370
|
-
const map = { 1: "workspace", 2: "policy", 3: "iola", 4: "yandexgpt", 5: "gigachat", 6: "openai", 7: "openrouter", 8: "codex", 9: "codex-mcp", 10: "archive", 11: "index", 12: "browser", 13: "ollama" };
|
|
10051
|
+
const map = { 1: "workspace", 2: "policy", 3: "iola", 4: "yandexgpt", 5: "gigachat", 6: "openai", 7: "openrouter", 8: "codex", 9: "codex-mcp", 10: "archive", 11: "index", 12: "browser", 13: "ollama", 14: "yandex-geocoder" };
|
|
9371
10052
|
return defaultOnboardSelection(status).map((item) => map[item]).filter(Boolean);
|
|
9372
10053
|
}
|
|
9373
10054
|
|
|
@@ -9381,7 +10062,7 @@ function parseOptions(args) {
|
|
|
9381
10062
|
} else if (arg === "--check" || arg === "--upgrade-node") {
|
|
9382
10063
|
result.check = true;
|
|
9383
10064
|
result[arg.slice(2)] = true;
|
|
9384
|
-
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file") {
|
|
10065
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file" || arg === "--from" || arg === "--to" || arg === "--radius" || arg === "--address") {
|
|
9385
10066
|
result[arg.slice(2)] = args[index + 1];
|
|
9386
10067
|
index += 1;
|
|
9387
10068
|
} else {
|
|
@@ -9630,6 +10311,7 @@ function selectSkillsForPrompt(config, question = "", options = {}) {
|
|
|
9630
10311
|
const normalized = String(question || "").toLocaleLowerCase("ru-RU");
|
|
9631
10312
|
if (enabled.has("local-model")) selected.add("local-model");
|
|
9632
10313
|
if (enabled.has("open-data") && shouldUseDataContext(question, options)) selected.add("open-data");
|
|
10314
|
+
if (enabled.has("geo") && isGeoQuestion(normalized)) selected.add("geo");
|
|
9633
10315
|
if (enabled.has("reports") && /(отчет|отчёт|выгруз|csv|xlsx|качество|провер)/iu.test(normalized)) selected.add("reports");
|
|
9634
10316
|
if (enabled.has("local-files") && (options.files || /(файл|папк|readme|документ|архив)/iu.test(normalized))) selected.add("local-files");
|
|
9635
10317
|
if (enabled.has("browser-agent") && /(браузер|сайт|страниц|url|https?:\/\/)/iu.test(normalized)) selected.add("browser-agent");
|
package/wiki/Home.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
- выгрузка CSV/JSON;
|
|
12
12
|
- работа с локальной моделью Ollama;
|
|
13
13
|
- работа с YandexGPT, GigaChat, OpenAI, OpenRouter и Codex CLI;
|
|
14
|
+
- geo-сценарии для жителей через Yandex Geocoder API;
|
|
14
15
|
- подключение публичного MCP-сервера.
|
|
15
16
|
|
|
16
17
|
Быстрый старт:
|
|
@@ -28,6 +29,8 @@ iola ask "найди школу 29"
|
|
|
28
29
|
- [Первый запуск](Первый-запуск)
|
|
29
30
|
- [Мастер настройки](Мастер-настройки)
|
|
30
31
|
- [AI-профили](AI-профили)
|
|
32
|
+
- [Yandex Geocoder API key](Yandex-Geocoder-API-key)
|
|
33
|
+
- [Скиллы для жителей](Скиллы-для-жителей)
|
|
31
34
|
- [Локальный инструментальный агент](Локальный-инструментальный-агент)
|
|
32
35
|
- [Skills и toolsets](Skills-и-toolsets)
|
|
33
36
|
- [Локальные файлы](Локальные-файлы)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Yandex Geocoder API key
|
|
2
|
+
|
|
3
|
+
Эта инструкция нужна для подключения геокодера Яндекса к `iola-cli`.
|
|
4
|
+
|
|
5
|
+
Геокодер нужен жителям для вопросов вида:
|
|
6
|
+
|
|
7
|
+
- что находится рядом с моим адресом;
|
|
8
|
+
- где находится школа или детский сад;
|
|
9
|
+
- какой объект ближе;
|
|
10
|
+
- открыть объект на карте;
|
|
11
|
+
- не перепутать Йошкар-Олу, Семеновку и другие населенные пункты.
|
|
12
|
+
|
|
13
|
+
## Получение ключа
|
|
14
|
+
|
|
15
|
+
1. Откройте Кабинет разработчика Яндекса: `https://developer.tech.yandex.ru/services/`.
|
|
16
|
+
2. Войдите в аккаунт Яндекса.
|
|
17
|
+
3. В списке `API интерфейсы` найдите `API Геокодера`.
|
|
18
|
+
4. Откройте `API Геокодера`.
|
|
19
|
+
5. Проверьте блок тарифа. На момент проверки 02.06.2026 в кабинете был показан тариф `Бесплатный с ограничениями` и счетчик `1000` запросов в сутки.
|
|
20
|
+
6. В блоке `Ключи API` нажмите `Новый ключ`.
|
|
21
|
+
7. В поле `Название ключа` введите понятное имя, например `iola-cli geocoder`.
|
|
22
|
+
8. Нажмите `Добавить ключ`.
|
|
23
|
+
9. После создания ключ появится в списке ключей API Геокодера.
|
|
24
|
+
10. Скопируйте значение ключа. Это значение будет `YANDEX_GEOCODER_API_KEY`.
|
|
25
|
+
|
|
26
|
+
Важно: новый ключ может начать работать не сразу. В документации Яндекса указано, что активация ключа может занять до 15 минут, на практике лучше спокойно подождать 15-30 минут. Если CLI пишет `Invalid api key` сразу после создания ключа, это не обязательно ошибка: подождите и повторите проверку командой `iola geo key doctor`.
|
|
27
|
+
|
|
28
|
+
## Сохранение в CLI
|
|
29
|
+
|
|
30
|
+
Запустите:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
iola geo key set yandex
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
CLI попросит ввести:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
YANDEX_GEOCODER_API_KEY
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Ключ сохраняется локально на устройстве пользователя в `~/.iola/secrets.json`.
|
|
43
|
+
|
|
44
|
+
Проверить сохранение:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
iola geo key status
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Проверить рабочий запрос к API:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
iola geo key doctor
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Проверить геокодирование адреса:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
iola geo geocode "Йошкар-Ола, улица Петрова, 15"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
CLI также понимает переменные окружения:
|
|
63
|
+
|
|
64
|
+
- `YANDEX_GEOCODER_API_KEY`;
|
|
65
|
+
- `YANDEX_MAPS_API_KEY`.
|
|
66
|
+
|
|
67
|
+
## Официальные ссылки
|
|
68
|
+
|
|
69
|
+
- Кабинет разработчика: `https://developer.tech.yandex.ru/services/`
|
|
70
|
+
- API Геокодера: `https://developer.tech.yandex.ru/services/2`
|
|
71
|
+
- Документация Geocoder API: `https://yandex.com/maps-api/docs/geocoder-api/`
|
|
72
|
+
- Формат запроса: `https://yandex.com/maps-api/docs/geocoder-api/request.html`
|
|
@@ -26,6 +26,21 @@ iola card "школа 29"
|
|
|
26
26
|
iola data schools --format csv --output schools.csv
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
Геокодер:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
iola geo key set yandex
|
|
33
|
+
iola geo key status
|
|
34
|
+
iola geo key doctor
|
|
35
|
+
iola geo geocode "Йошкар-Ола, улица Петрова, 15"
|
|
36
|
+
iola geo nearby "Йошкар-Ола, улица Петрова, 15" --dataset all --limit 5
|
|
37
|
+
iola geo distance --from "Петрова 15" --to "школа 7"
|
|
38
|
+
iola geo map-link "школа 7"
|
|
39
|
+
iola geo resolve "садик золотой петушок"
|
|
40
|
+
iola geo route-context "школа 7"
|
|
41
|
+
iola geo services "Йошкар-Ола, улица Петрова, 15"
|
|
42
|
+
```
|
|
43
|
+
|
|
29
44
|
Локальная БД:
|
|
30
45
|
|
|
31
46
|
```bash
|
|
@@ -86,6 +86,14 @@ iola index folder ./docs
|
|
|
86
86
|
|
|
87
87
|
Если runtime уже установлен, пункт показывается как `готово`.
|
|
88
88
|
|
|
89
|
+
### 14. Yandex Geocoder API
|
|
90
|
+
|
|
91
|
+
Сохраняет ключ Yandex Geocoder API локально у пользователя и проверяет его тестовым запросом.
|
|
92
|
+
|
|
93
|
+
Нужен для будущих geo-skills: поиск объектов рядом с адресом, ссылки на карту, определение расстояний и уточнение населенного пункта.
|
|
94
|
+
|
|
95
|
+
Инструкция по получению ключа: [Yandex Geocoder API key](Yandex-Geocoder-API-key).
|
|
96
|
+
|
|
89
97
|
## Повторный запуск
|
|
90
98
|
|
|
91
99
|
Если компонент уже настроен, его можно не выбирать. Если нужно переустановить или обновить компонент, выберите его номер вручную.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Скиллы для жителей
|
|
2
|
+
|
|
3
|
+
Эта страница фиксирует пользовательские skills, которые реализованы и развиваются в `iola-cli`.
|
|
4
|
+
|
|
5
|
+
Цель этих skills - помогать жителю города в бытовых вопросах: где находится объект, что рядом, какой объект ближе, как открыть место на карте. Это не административный аудит данных.
|
|
6
|
+
|
|
7
|
+
## Geo skills
|
|
8
|
+
|
|
9
|
+
Для geo skills нужен ключ `Yandex Geocoder API`.
|
|
10
|
+
|
|
11
|
+
Первый рабочий контур подключен к слоям `schools` и `kindergartens`.
|
|
12
|
+
|
|
13
|
+
Команды:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
iola geo nearby "Йошкар-Ола, улица Петрова, 15" --dataset all --limit 5
|
|
17
|
+
iola geo distance --from "Петрова 15" --to "школа 7"
|
|
18
|
+
iola geo map-link "школа 7"
|
|
19
|
+
iola geo resolve "садик золотой петушок"
|
|
20
|
+
iola geo route-context "школа 7"
|
|
21
|
+
iola geo services "Йошкар-Ола, улица Петрова, 15"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### nearby
|
|
25
|
+
|
|
26
|
+
Находит городские объекты рядом с адресом пользователя.
|
|
27
|
+
|
|
28
|
+
Примеры:
|
|
29
|
+
|
|
30
|
+
- `какие детские сады рядом с Петрова 15`;
|
|
31
|
+
- `какие школы ближе к улице Машиностроителей`;
|
|
32
|
+
- `что из городских учреждений рядом с домом`.
|
|
33
|
+
|
|
34
|
+
Сейчас работает по слоям `schools` и `kindergartens`. Позже можно подключить МФЦ, поликлиники, остановки, спортобъекты и учреждения культуры.
|
|
35
|
+
|
|
36
|
+
### route-context
|
|
37
|
+
|
|
38
|
+
Объясняет, где находится объект, простым языком: адрес, ориентиры, что рядом, ссылка на карту.
|
|
39
|
+
|
|
40
|
+
Это не полноценный навигатор и не замена маршрутизатору. Skill дает понятный контекст места для жителя.
|
|
41
|
+
|
|
42
|
+
### address-to-services
|
|
43
|
+
|
|
44
|
+
По адресу жителя подбирает релевантные городские объекты.
|
|
45
|
+
|
|
46
|
+
Примеры:
|
|
47
|
+
|
|
48
|
+
- ближайшие школы;
|
|
49
|
+
- ближайшие детские сады;
|
|
50
|
+
- будущие слои муниципальных услуг и учреждений.
|
|
51
|
+
|
|
52
|
+
### map-link
|
|
53
|
+
|
|
54
|
+
Добавляет в ответы ссылку на карту.
|
|
55
|
+
|
|
56
|
+
Примеры:
|
|
57
|
+
|
|
58
|
+
- открыть школу на Яндекс.Картах;
|
|
59
|
+
- открыть детский сад на карте;
|
|
60
|
+
- показать точку по координатам.
|
|
61
|
+
|
|
62
|
+
### place-resolver
|
|
63
|
+
|
|
64
|
+
Помогает понять криво написанные адреса, названия и ориентиры.
|
|
65
|
+
|
|
66
|
+
Примеры:
|
|
67
|
+
|
|
68
|
+
- `семеновка школа`;
|
|
69
|
+
- `первомаиская 89`;
|
|
70
|
+
- `садик золотой петушок`;
|
|
71
|
+
- `рядом с вокзалом`.
|
|
72
|
+
|
|
73
|
+
Главная задача - не подставлять похожий, но неправильный объект.
|
|
74
|
+
|
|
75
|
+
### distance
|
|
76
|
+
|
|
77
|
+
Считает расстояние от адреса пользователя до объекта или между объектами.
|
|
78
|
+
|
|
79
|
+
Примеры:
|
|
80
|
+
|
|
81
|
+
- `какой садик ближе к дому`;
|
|
82
|
+
- `как далеко школа 7 от Первомайской 89`;
|
|
83
|
+
- `что ближе: Пчелка или Улыбка`.
|
|
84
|
+
|
|
85
|
+
### area-filter
|
|
86
|
+
|
|
87
|
+
Фильтрует ответы по населенному пункту или району.
|
|
88
|
+
|
|
89
|
+
Примеры:
|
|
90
|
+
|
|
91
|
+
- Йошкар-Ола;
|
|
92
|
+
- Семеновка;
|
|
93
|
+
- другие населенные пункты городского округа, когда они появятся в слоях.
|
|
94
|
+
|
|
95
|
+
Это важно, чтобы не путать объекты с одинаковыми номерами или похожими названиями.
|
|
96
|
+
|
|
97
|
+
## Ограничения
|
|
98
|
+
|
|
99
|
+
- Расстояние считается по прямой между координатами. Это не автомобильный и не пешеходный маршрут.
|
|
100
|
+
- CLI не должен подставлять похожий объект из другого населенного пункта. Если слой не содержит нужный объект, ответ должен прямо говорить, что данных недостаточно.
|
|
101
|
+
- При первом поиске рядом CLI геокодирует объекты слоя. Повторные запросы в той же сессии работают быстрее за счет памяти процесса.
|