@iola_adm/iola-cli 0.1.7 → 0.1.9
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 +41 -6
- package/package.json +1 -1
- package/src/cli.js +443 -19
package/README.md
CHANGED
|
@@ -62,6 +62,7 @@ npx -y @iola_adm/iola-cli ai setup ollama
|
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
64
|
npx -y @iola_adm/iola-cli help
|
|
65
|
+
npx -y @iola_adm/iola-cli init --yes
|
|
65
66
|
```
|
|
66
67
|
|
|
67
68
|
Глобальная установка:
|
|
@@ -69,6 +70,7 @@ npx -y @iola_adm/iola-cli help
|
|
|
69
70
|
```bash
|
|
70
71
|
npm install -g @iola_adm/iola-cli
|
|
71
72
|
iola help
|
|
73
|
+
iola init
|
|
72
74
|
```
|
|
73
75
|
|
|
74
76
|
## Команды
|
|
@@ -76,9 +78,17 @@ iola help
|
|
|
76
78
|
```bash
|
|
77
79
|
iola banner
|
|
78
80
|
iola agent
|
|
81
|
+
iola chat
|
|
82
|
+
iola init
|
|
83
|
+
iola update
|
|
84
|
+
iola version --check
|
|
85
|
+
iola data schools --limit 10
|
|
86
|
+
iola data kindergartens --search "29"
|
|
87
|
+
iola data schools --format csv
|
|
79
88
|
iola ai doctor
|
|
80
89
|
iola ai setup ollama
|
|
81
90
|
iola ai ask "Какие школы есть на улице Петрова?"
|
|
91
|
+
iola ai context "школа 29"
|
|
82
92
|
iola ai key set openai
|
|
83
93
|
iola ai key status
|
|
84
94
|
iola ai setup openai --model gpt-4.1-mini
|
|
@@ -86,6 +96,7 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
|
|
|
86
96
|
iola health
|
|
87
97
|
iola layers
|
|
88
98
|
iola schools --limit 10
|
|
99
|
+
iola schools --format csv
|
|
89
100
|
iola schools get --inn 1215067180
|
|
90
101
|
iola kindergartens --search "29"
|
|
91
102
|
iola kindergartens get --inn 1215077421 --json
|
|
@@ -95,7 +106,8 @@ iola setup codex
|
|
|
95
106
|
```
|
|
96
107
|
|
|
97
108
|
По умолчанию команды выводят компактную таблицу. Для полного ответа API
|
|
98
|
-
используйте `--json`.
|
|
109
|
+
используйте `--json` или `--format json`. Для выгрузки используйте
|
|
110
|
+
`--format csv`.
|
|
99
111
|
|
|
100
112
|
## Интерактивный режим
|
|
101
113
|
|
|
@@ -109,21 +121,30 @@ iola agent
|
|
|
109
121
|
/help
|
|
110
122
|
/health
|
|
111
123
|
/layers
|
|
124
|
+
/data schools --limit 10
|
|
112
125
|
/schools --limit 10
|
|
113
126
|
/schools get --inn 1215067180
|
|
114
127
|
/kindergartens --search 29
|
|
115
128
|
/search лицей --limit 3
|
|
116
129
|
/mcp-info
|
|
117
130
|
/ai doctor
|
|
131
|
+
/context школа 29
|
|
132
|
+
/use ollama
|
|
133
|
+
/use openai
|
|
134
|
+
/key status
|
|
135
|
+
/key set openai
|
|
118
136
|
/model
|
|
119
137
|
/provider
|
|
120
138
|
/config
|
|
121
139
|
/history
|
|
122
140
|
/clear
|
|
141
|
+
/update
|
|
142
|
+
/init
|
|
123
143
|
/exit
|
|
124
144
|
```
|
|
125
145
|
|
|
126
146
|
Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
|
|
147
|
+
`iola chat` запускает тот же интерактивный режим.
|
|
127
148
|
|
|
128
149
|
## AI-запросы
|
|
129
150
|
|
|
@@ -150,6 +171,20 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
|
|
|
150
171
|
iola ai ask "Покажи контакты лицея"
|
|
151
172
|
```
|
|
152
173
|
|
|
174
|
+
Проверить, какие данные попадут в AI-контекст:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
iola ai context "школа 29"
|
|
178
|
+
iola ai context "1215067180" --json
|
|
179
|
+
iola ai context "улица Петрова"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Поиск контекста учитывает номера учреждений, ИНН и улицы.
|
|
183
|
+
|
|
184
|
+
AI-ответ строится по контексту из публичного API. Ассистент получает краткий
|
|
185
|
+
список источников контекста и должен указывать слой, название и ИНН, если
|
|
186
|
+
отвечает по конкретным организациям.
|
|
187
|
+
|
|
153
188
|
Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
|
|
154
189
|
|
|
155
190
|
```text
|
|
@@ -169,14 +204,14 @@ iola ai key delete openai
|
|
|
169
204
|
`OPENROUTER_API_KEY`) и сохранен локальный ключ, CLI использует переменную
|
|
170
205
|
окружения как более приоритетную.
|
|
171
206
|
|
|
172
|
-
|
|
173
|
-
|
|
207
|
+
Если данных в контексте недостаточно, ассистент должен сообщить об этом, а не
|
|
208
|
+
выдумывать сведения.
|
|
174
209
|
|
|
175
210
|
## Назначение
|
|
176
211
|
|
|
177
|
-
|
|
178
|
-
подключения MCP/skill
|
|
179
|
-
|
|
212
|
+
CLI дает прямой терминальный доступ к открытым данным городского округа,
|
|
213
|
+
командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter,
|
|
214
|
+
интерактивному агентному режиму, экспорту данных и проверке обновлений.
|
|
180
215
|
|
|
181
216
|
## Переменные окружения
|
|
182
217
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -17,6 +17,16 @@ const DEFAULT_AI_CONFIG = {
|
|
|
17
17
|
baseUrl: "http://127.0.0.1:11434",
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
|
+
const DATASETS = {
|
|
21
|
+
schools: {
|
|
22
|
+
title: "Школы",
|
|
23
|
+
endpoint: "schools",
|
|
24
|
+
},
|
|
25
|
+
kindergartens: {
|
|
26
|
+
title: "Детские сады",
|
|
27
|
+
endpoint: "kindergartens",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
20
30
|
const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
|
|
21
31
|
│\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
|
|
22
32
|
│\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
|
|
@@ -34,11 +44,15 @@ const BANNER = `\x1b[38;5;45m┌────────────────
|
|
|
34
44
|
const COMMANDS = new Map([
|
|
35
45
|
["help", showHelp],
|
|
36
46
|
["version", showVersion],
|
|
47
|
+
["update", checkUpdate],
|
|
37
48
|
["banner", showBanner],
|
|
38
49
|
["agent", startAgent],
|
|
50
|
+
["chat", startAgent],
|
|
39
51
|
["ai", handleAi],
|
|
52
|
+
["init", initCli],
|
|
40
53
|
["health", checkHealth],
|
|
41
54
|
["layers", listLayers],
|
|
55
|
+
["data", handleData],
|
|
42
56
|
["schools", listSchools],
|
|
43
57
|
["kindergartens", listKindergartens],
|
|
44
58
|
["search", searchAll],
|
|
@@ -64,7 +78,12 @@ async function showHelp() {
|
|
|
64
78
|
Usage:
|
|
65
79
|
iola banner
|
|
66
80
|
iola agent
|
|
81
|
+
iola chat
|
|
82
|
+
iola init
|
|
83
|
+
iola update
|
|
84
|
+
iola data LAYER [--limit 10] [--search TEXT] [--format table|json|csv]
|
|
67
85
|
iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
|
|
86
|
+
iola ai context TEXT [--json]
|
|
68
87
|
iola ai key set openai
|
|
69
88
|
iola ai key set openrouter
|
|
70
89
|
iola ai key status
|
|
@@ -74,11 +93,11 @@ Usage:
|
|
|
74
93
|
iola ai setup ollama [--yes] [--model MODEL]
|
|
75
94
|
iola health [--json]
|
|
76
95
|
iola layers [--json]
|
|
77
|
-
iola schools [--limit 10] [--search TEXT] [--json]
|
|
96
|
+
iola schools [--limit 10] [--search TEXT] [--format table|json|csv]
|
|
78
97
|
iola schools get --inn INN [--json]
|
|
79
|
-
iola kindergartens [--limit 10] [--search TEXT] [--json]
|
|
98
|
+
iola kindergartens [--limit 10] [--search TEXT] [--format table|json|csv]
|
|
80
99
|
iola kindergartens get --inn INN [--json]
|
|
81
|
-
iola search TEXT [--limit 5] [--json]
|
|
100
|
+
iola search TEXT [--limit 5] [--format table|json|csv]
|
|
82
101
|
iola mcp-info [--json]
|
|
83
102
|
iola setup codex
|
|
84
103
|
iola version
|
|
@@ -163,6 +182,21 @@ async function handleAgentLine(line, state) {
|
|
|
163
182
|
return false;
|
|
164
183
|
}
|
|
165
184
|
|
|
185
|
+
if (command === "context") {
|
|
186
|
+
await aiContext(args);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (command === "use") {
|
|
191
|
+
await useAiProvider(args);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (command === "key") {
|
|
196
|
+
await handleAiKey(args);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
166
200
|
if (command === "provider") {
|
|
167
201
|
await printAiConfigField("provider");
|
|
168
202
|
return false;
|
|
@@ -178,6 +212,16 @@ async function handleAgentLine(line, state) {
|
|
|
178
212
|
return false;
|
|
179
213
|
}
|
|
180
214
|
|
|
215
|
+
if (command === "update") {
|
|
216
|
+
await checkUpdate(args);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (command === "init") {
|
|
221
|
+
await initCli(args);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
181
225
|
if (command === "ai") {
|
|
182
226
|
await handleAi(args);
|
|
183
227
|
return false;
|
|
@@ -186,6 +230,7 @@ async function handleAgentLine(line, state) {
|
|
|
186
230
|
const mapped = {
|
|
187
231
|
health: ["health", args],
|
|
188
232
|
layers: ["layers", args],
|
|
233
|
+
data: ["data", args],
|
|
189
234
|
schools: ["schools", args],
|
|
190
235
|
kindergartens: ["kindergartens", args],
|
|
191
236
|
search: ["search", args],
|
|
@@ -209,20 +254,28 @@ function printAgentHelp() {
|
|
|
209
254
|
/help
|
|
210
255
|
/health
|
|
211
256
|
/layers
|
|
257
|
+
/data schools --limit 10
|
|
212
258
|
/schools --limit 10
|
|
213
259
|
/schools get --inn 1215067180
|
|
214
260
|
/kindergartens --search 29
|
|
215
261
|
/kindergartens get --inn 1215077421
|
|
216
262
|
/search лицей --limit 3
|
|
217
263
|
/mcp-info
|
|
264
|
+
/context школа 29
|
|
218
265
|
/ai doctor
|
|
219
266
|
/ai setup ollama
|
|
267
|
+
/use openai
|
|
268
|
+
/use ollama
|
|
269
|
+
/key status
|
|
270
|
+
/key set openai
|
|
220
271
|
/config
|
|
221
272
|
/provider
|
|
222
273
|
/model
|
|
223
274
|
/history
|
|
224
275
|
/clear
|
|
225
276
|
/banner
|
|
277
|
+
/update
|
|
278
|
+
/init
|
|
226
279
|
/exit
|
|
227
280
|
|
|
228
281
|
Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
|
|
@@ -261,9 +314,43 @@ function showBanner() {
|
|
|
261
314
|
console.log("открытые данные • MCP • локальный AI");
|
|
262
315
|
}
|
|
263
316
|
|
|
264
|
-
async function showVersion() {
|
|
317
|
+
async function showVersion(args = []) {
|
|
318
|
+
const options = parseOptions(args);
|
|
265
319
|
const packageJson = await import("../package.json", { with: { type: "json" } });
|
|
266
320
|
console.log(packageJson.default.version);
|
|
321
|
+
|
|
322
|
+
if (options.check) {
|
|
323
|
+
await checkUpdate([]);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function checkUpdate() {
|
|
328
|
+
const packageJson = await import("../package.json", { with: { type: "json" } });
|
|
329
|
+
const current = packageJson.default.version;
|
|
330
|
+
const latest = await getLatestNpmVersion(packageJson.default.name);
|
|
331
|
+
|
|
332
|
+
if (!latest) {
|
|
333
|
+
console.log("Не удалось проверить npm-версию.");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const comparison = compareVersions(latest, current);
|
|
338
|
+
|
|
339
|
+
if (comparison > 0) {
|
|
340
|
+
console.log(`Доступна новая версия: ${latest}`);
|
|
341
|
+
console.log("Обновление:");
|
|
342
|
+
console.log(` npm install -g ${packageJson.default.name}@latest`);
|
|
343
|
+
console.log("Или запуск без установки:");
|
|
344
|
+
console.log(` npx -y ${packageJson.default.name}@latest help`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (comparison < 0) {
|
|
349
|
+
console.log(`Локальная версия ${current} новее опубликованной npm latest ${latest}.`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
console.log(`Установлена актуальная версия: ${current}`);
|
|
267
354
|
}
|
|
268
355
|
|
|
269
356
|
async function checkHealth(args) {
|
|
@@ -283,6 +370,41 @@ async function checkHealth(args) {
|
|
|
283
370
|
});
|
|
284
371
|
}
|
|
285
372
|
|
|
373
|
+
async function initCli(args = []) {
|
|
374
|
+
const options = parseOptions(args);
|
|
375
|
+
|
|
376
|
+
showBanner();
|
|
377
|
+
console.log("Проверка окружения");
|
|
378
|
+
printKeyValue({
|
|
379
|
+
node: process.version,
|
|
380
|
+
npm: await getCommandVersion("npm", ["--version"]),
|
|
381
|
+
api: await probeEndpoint(`${MCP_BASE_URL}/mcp-health`),
|
|
382
|
+
mcp: MCP_BASE_URL,
|
|
383
|
+
});
|
|
384
|
+
console.log("");
|
|
385
|
+
|
|
386
|
+
await aiDoctor(options.json ? ["--json"] : []);
|
|
387
|
+
|
|
388
|
+
if (!process.stdin.isTTY || options.yes) {
|
|
389
|
+
console.log("");
|
|
390
|
+
console.log("Для настройки AI используйте:");
|
|
391
|
+
console.log(" iola ai setup ollama");
|
|
392
|
+
console.log(" iola ai key set openai");
|
|
393
|
+
console.log(" iola ai setup openai --model gpt-4.1-mini");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
console.log("");
|
|
398
|
+
const configureAi = await confirm("Настроить AI-провайдер сейчас? [Y/n] ");
|
|
399
|
+
|
|
400
|
+
if (configureAi) {
|
|
401
|
+
await aiSetup([]);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
console.log("");
|
|
405
|
+
await checkUpdate();
|
|
406
|
+
}
|
|
407
|
+
|
|
286
408
|
async function handleAi(args) {
|
|
287
409
|
const [subcommand = "help", ...rest] = args;
|
|
288
410
|
|
|
@@ -290,6 +412,7 @@ async function handleAi(args) {
|
|
|
290
412
|
showBanner();
|
|
291
413
|
console.log(`AI-команды:
|
|
292
414
|
iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
|
|
415
|
+
iola ai context TEXT [--json]
|
|
293
416
|
iola ai key set openai
|
|
294
417
|
iola ai key set openrouter
|
|
295
418
|
iola ai key status
|
|
@@ -309,6 +432,11 @@ async function handleAi(args) {
|
|
|
309
432
|
return;
|
|
310
433
|
}
|
|
311
434
|
|
|
435
|
+
if (subcommand === "context") {
|
|
436
|
+
await aiContext(rest);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
312
440
|
if (subcommand === "key") {
|
|
313
441
|
await handleAiKey(rest);
|
|
314
442
|
return;
|
|
@@ -366,7 +494,8 @@ async function aiSetup(args) {
|
|
|
366
494
|
},
|
|
367
495
|
});
|
|
368
496
|
console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
|
|
369
|
-
console.log(`Ключ
|
|
497
|
+
console.log(`Ключ сохраните командой: iola ai key set ${provider}`);
|
|
498
|
+
console.log(`Также можно использовать переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
|
|
370
499
|
return;
|
|
371
500
|
}
|
|
372
501
|
|
|
@@ -403,6 +532,53 @@ async function handleAiKey(args) {
|
|
|
403
532
|
iola ai key delete openai|openrouter`);
|
|
404
533
|
}
|
|
405
534
|
|
|
535
|
+
async function useAiProvider(args) {
|
|
536
|
+
const [provider] = args;
|
|
537
|
+
|
|
538
|
+
if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter") {
|
|
539
|
+
throw new Error("Провайдер должен быть ollama, openai или openrouter.");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const config = await loadConfig();
|
|
543
|
+
const defaultModel = {
|
|
544
|
+
ollama: config.ai.provider === "ollama" ? config.ai.model : "llama3.2:1b",
|
|
545
|
+
openai: config.ai.provider === "openai" ? config.ai.model : "gpt-4.1-mini",
|
|
546
|
+
openrouter: config.ai.provider === "openrouter" ? config.ai.model : "openai/gpt-4.1-mini",
|
|
547
|
+
}[provider];
|
|
548
|
+
|
|
549
|
+
await saveConfig({
|
|
550
|
+
ai: {
|
|
551
|
+
provider,
|
|
552
|
+
model: defaultModel,
|
|
553
|
+
baseUrl: provider === "ollama"
|
|
554
|
+
? "http://127.0.0.1:11434"
|
|
555
|
+
: provider === "openai"
|
|
556
|
+
? "https://api.openai.com/v1"
|
|
557
|
+
: "https://openrouter.ai/api/v1",
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
console.log(`AI-провайдер переключен: ${provider}, модель: ${defaultModel}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function aiContext(args) {
|
|
565
|
+
const options = parseOptions(args);
|
|
566
|
+
const query = options._.join(" ").trim();
|
|
567
|
+
|
|
568
|
+
if (!query) {
|
|
569
|
+
throw new Error('Текст запроса обязателен. Пример: iola ai context "школа 29"');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const context = await buildDataContext(query);
|
|
573
|
+
|
|
574
|
+
if (options.json) {
|
|
575
|
+
printJson(context);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
printContext(context);
|
|
580
|
+
}
|
|
581
|
+
|
|
406
582
|
async function setAiKey(provider) {
|
|
407
583
|
assertKeyProvider(provider);
|
|
408
584
|
|
|
@@ -550,11 +726,23 @@ async function buildDataContext(question) {
|
|
|
550
726
|
fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
|
|
551
727
|
]);
|
|
552
728
|
const queryTerms = extractSearchTerms(question);
|
|
553
|
-
const
|
|
554
|
-
const
|
|
729
|
+
const patterns = extractStructuredPatterns(question);
|
|
730
|
+
const includeSchools = patterns.targetLayers.length === 0 || patterns.targetLayers.includes("schools");
|
|
731
|
+
const includeKindergartens = patterns.targetLayers.length === 0 || patterns.targetLayers.includes("kindergartens");
|
|
732
|
+
const schoolItems = includeSchools
|
|
733
|
+
? findRelevantItems(normalizeItems(schools), queryTerms, patterns, "schools").slice(0, 8)
|
|
734
|
+
: [];
|
|
735
|
+
const kindergartenItems = includeKindergartens
|
|
736
|
+
? findRelevantItems(normalizeItems(kindergartens), queryTerms, patterns, "kindergartens").slice(0, 8)
|
|
737
|
+
: [];
|
|
555
738
|
|
|
556
739
|
return {
|
|
557
740
|
layers: layers.data_layers || [],
|
|
741
|
+
query: {
|
|
742
|
+
text: question,
|
|
743
|
+
terms: queryTerms,
|
|
744
|
+
patterns,
|
|
745
|
+
},
|
|
558
746
|
schools: schoolItems.map(selectPublicSummary),
|
|
559
747
|
kindergartens: kindergartenItems.map(selectPublicSummary),
|
|
560
748
|
};
|
|
@@ -572,29 +760,94 @@ function extractSearchTerms(question) {
|
|
|
572
760
|
return normalized.length > 0 ? normalized : [question];
|
|
573
761
|
}
|
|
574
762
|
|
|
575
|
-
function
|
|
763
|
+
function extractStructuredPatterns(question) {
|
|
764
|
+
const normalized = question.toLocaleLowerCase("ru-RU");
|
|
765
|
+
const numbers = [...new Set([...normalized.matchAll(/\b\d{1,3}\b/g)].map((match) => match[0]))];
|
|
766
|
+
const inns = [...new Set([...normalized.matchAll(/\b\d{10,12}\b/g)].map((match) => match[0]))];
|
|
767
|
+
const targetLayers = [];
|
|
768
|
+
if (/(^|[^а-яёa-z])(школа|школы|лицей|лицея|гимназия|гимназии)(?=$|[^а-яёa-z])/iu.test(normalized)) {
|
|
769
|
+
targetLayers.push("schools");
|
|
770
|
+
}
|
|
771
|
+
if (/(^|[^а-яёa-z])(сад|сады|детсад|детский|детские|доу|мбдоу)(?=$|[^а-яёa-z])/iu.test(normalized)) {
|
|
772
|
+
targetLayers.push("kindergartens");
|
|
773
|
+
}
|
|
774
|
+
const streetMatches = [
|
|
775
|
+
...normalized.matchAll(/(?:улица|ул\.?)\s+([а-яёa-z0-9 .-]+)/giu),
|
|
776
|
+
...normalized.matchAll(/([а-яёa-z0-9 .-]+)\s+(?:улица|ул\.?)/giu),
|
|
777
|
+
];
|
|
778
|
+
const streets = [...new Set(streetMatches.map((match) => cleanupPattern(match[1])).filter(Boolean))];
|
|
779
|
+
|
|
780
|
+
return { numbers, inns, streets, targetLayers: [...new Set(targetLayers)] };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function cleanupPattern(value) {
|
|
784
|
+
return value
|
|
785
|
+
.replace(/\b(школа|школы|сад|детский|детские|сады|лицей|гимназия|контакты|телефон|адрес|найди|покажи)\b/giu, " ")
|
|
786
|
+
.replace(/\s+/g, " ")
|
|
787
|
+
.trim();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function findRelevantItems(items, terms, patterns, layer) {
|
|
576
791
|
return items
|
|
577
792
|
.map((item) => ({
|
|
578
793
|
item,
|
|
579
|
-
score: scoreItem(item, terms),
|
|
794
|
+
score: scoreItem(item, terms, patterns, layer),
|
|
580
795
|
}))
|
|
581
796
|
.filter((entry) => entry.score > 0)
|
|
582
797
|
.sort((left, right) => right.score - left.score)
|
|
583
798
|
.map((entry) => entry.item);
|
|
584
799
|
}
|
|
585
800
|
|
|
586
|
-
function scoreItem(item, terms) {
|
|
587
|
-
const
|
|
588
|
-
|
|
801
|
+
function scoreItem(item, terms, patterns, layer) {
|
|
802
|
+
const summary = selectPublicSummary(item);
|
|
803
|
+
const text = JSON.stringify(summary).toLocaleLowerCase("ru-RU");
|
|
804
|
+
const name = String(summary.name || "").toLocaleLowerCase("ru-RU");
|
|
805
|
+
const address = String(summary.address || "").toLocaleLowerCase("ru-RU");
|
|
806
|
+
const generalTerms = terms.filter((term) => !/^\d+$/.test(term));
|
|
807
|
+
let score = generalTerms.reduce((value, term) => value + (text.includes(term.toLocaleLowerCase("ru-RU")) ? 1 : 0), 0);
|
|
808
|
+
|
|
809
|
+
for (const inn of patterns.inns) {
|
|
810
|
+
if (String(summary.inn) === inn) {
|
|
811
|
+
score += 20;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
for (const number of patterns.numbers) {
|
|
816
|
+
const numberPatterns = [
|
|
817
|
+
`№ ${number}`,
|
|
818
|
+
`№${number}`,
|
|
819
|
+
`школа ${number}`,
|
|
820
|
+
`сад ${number}`,
|
|
821
|
+
`лицей ${number}`,
|
|
822
|
+
`гимназия ${number}`,
|
|
823
|
+
];
|
|
824
|
+
|
|
825
|
+
if (numberPatterns.some((pattern) => name.includes(pattern))) {
|
|
826
|
+
score += 12;
|
|
827
|
+
if (patterns.targetLayers.length === 0 || patterns.targetLayers.includes(layer)) {
|
|
828
|
+
score += 5;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
for (const street of patterns.streets) {
|
|
834
|
+
if (street && address.includes(street)) {
|
|
835
|
+
score += 8;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return score;
|
|
589
840
|
}
|
|
590
841
|
|
|
591
842
|
function buildAiMessages(question, dataContext, history) {
|
|
843
|
+
const sourceLines = buildSourceLines(dataContext);
|
|
592
844
|
const system = [
|
|
593
845
|
"Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
|
|
594
846
|
"Отвечай на русском языке.",
|
|
595
847
|
"Используй только данные из переданного контекста.",
|
|
596
848
|
"Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
|
|
597
849
|
"Не выдумывай адреса, телефоны, лицензии и руководителей.",
|
|
850
|
+
"Если отвечаешь по конкретным организациям, укажи источник в конце: слой, название и ИНН.",
|
|
598
851
|
"Отвечай кратко и по делу.",
|
|
599
852
|
].join(" ");
|
|
600
853
|
const contextText = JSON.stringify(dataContext, null, 2);
|
|
@@ -605,11 +858,26 @@ function buildAiMessages(question, dataContext, history) {
|
|
|
605
858
|
...recentHistory,
|
|
606
859
|
{
|
|
607
860
|
role: "user",
|
|
608
|
-
content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
|
|
861
|
+
content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nКраткие источники контекста:\n${sourceLines}\n\nВопрос пользователя: ${question}`,
|
|
609
862
|
},
|
|
610
863
|
];
|
|
611
864
|
}
|
|
612
865
|
|
|
866
|
+
function buildSourceLines(dataContext) {
|
|
867
|
+
const rows = [
|
|
868
|
+
...dataContext.schools.map((item) => ({ layer: "schools", ...item })),
|
|
869
|
+
...dataContext.kindergartens.map((item) => ({ layer: "kindergartens", ...item })),
|
|
870
|
+
];
|
|
871
|
+
|
|
872
|
+
if (rows.length === 0) {
|
|
873
|
+
return "Совпавших организаций нет.";
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return rows
|
|
877
|
+
.map((item) => `- ${item.layer}: ${item.name || "-"}; ИНН ${item.inn || "-"}; адрес ${item.address || "-"}`)
|
|
878
|
+
.join("\n");
|
|
879
|
+
}
|
|
880
|
+
|
|
613
881
|
async function callAiProvider(config, messages) {
|
|
614
882
|
if (config.provider === "ollama") {
|
|
615
883
|
return callOllama(config, messages);
|
|
@@ -737,6 +1005,28 @@ async function listKindergartens(args) {
|
|
|
737
1005
|
await listDataset("kindergartens", args);
|
|
738
1006
|
}
|
|
739
1007
|
|
|
1008
|
+
async function handleData(args) {
|
|
1009
|
+
const [dataset, ...rest] = args;
|
|
1010
|
+
|
|
1011
|
+
if (!dataset) {
|
|
1012
|
+
console.log("Доступные слои:");
|
|
1013
|
+
printTable(Object.entries(DATASETS).map(([id, value]) => ({ id, name: value.title })), [
|
|
1014
|
+
["id", "ID"],
|
|
1015
|
+
["name", "Название"],
|
|
1016
|
+
]);
|
|
1017
|
+
console.log("");
|
|
1018
|
+
console.log("Пример:");
|
|
1019
|
+
console.log(" iola data schools --limit 10");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (!DATASETS[dataset]) {
|
|
1024
|
+
throw new Error(`Неизвестный слой: ${dataset}. Доступно: ${Object.keys(DATASETS).join(", ")}`);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
await listDataset(dataset, rest);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
740
1030
|
async function listDataset(dataset, args) {
|
|
741
1031
|
const options = parseOptions(args);
|
|
742
1032
|
|
|
@@ -749,16 +1039,21 @@ async function listDataset(dataset, args) {
|
|
|
749
1039
|
params.set("limit", options.limit || "20");
|
|
750
1040
|
params.set("offset", options.offset || "0");
|
|
751
1041
|
|
|
752
|
-
const data = await fetchJson(`${API_BASE_URL}/${dataset}?${params}`);
|
|
1042
|
+
const data = await fetchJson(`${API_BASE_URL}/${DATASETS[dataset].endpoint}?${params}`);
|
|
753
1043
|
const items = normalizeItems(data);
|
|
754
1044
|
const filtered = options.search ? filterItems(items, options.search) : items;
|
|
755
1045
|
const limited = filtered.slice(0, Number(options.limit || 20));
|
|
756
1046
|
|
|
757
|
-
if (options.json) {
|
|
1047
|
+
if (options.json || options.format === "json") {
|
|
758
1048
|
printJson(limited);
|
|
759
1049
|
return;
|
|
760
1050
|
}
|
|
761
1051
|
|
|
1052
|
+
if (options.format === "csv") {
|
|
1053
|
+
printCsv(limited.map(selectPublicSummary));
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
762
1057
|
printDatasetTable(limited);
|
|
763
1058
|
}
|
|
764
1059
|
|
|
@@ -767,7 +1062,7 @@ async function getDatasetItem(dataset, options) {
|
|
|
767
1062
|
throw new Error(`INN is required. Example: iola ${dataset} get --inn 1215067180`);
|
|
768
1063
|
}
|
|
769
1064
|
|
|
770
|
-
const data = await fetchJson(`${API_BASE_URL}/${dataset}?limit=500&offset=0`);
|
|
1065
|
+
const data = await fetchJson(`${API_BASE_URL}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
|
|
771
1066
|
const item = normalizeItems(data).find((entry) => String(entry.inn) === String(options.inn));
|
|
772
1067
|
|
|
773
1068
|
if (!item) {
|
|
@@ -801,11 +1096,19 @@ async function searchAll(args) {
|
|
|
801
1096
|
kindergartens: filterItems(normalizeItems(kindergartens), query).slice(0, limit),
|
|
802
1097
|
};
|
|
803
1098
|
|
|
804
|
-
if (options.json) {
|
|
1099
|
+
if (options.json || options.format === "json") {
|
|
805
1100
|
printJson(result);
|
|
806
1101
|
return;
|
|
807
1102
|
}
|
|
808
1103
|
|
|
1104
|
+
if (options.format === "csv") {
|
|
1105
|
+
printCsv([
|
|
1106
|
+
...result.schools.map((item) => ({ layer: "schools", ...selectPublicSummary(item) })),
|
|
1107
|
+
...result.kindergartens.map((item) => ({ layer: "kindergartens", ...selectPublicSummary(item) })),
|
|
1108
|
+
]);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
809
1112
|
console.log("Школы");
|
|
810
1113
|
printDatasetTable(result.schools);
|
|
811
1114
|
console.log("");
|
|
@@ -832,7 +1135,9 @@ function parseOptions(args) {
|
|
|
832
1135
|
const arg = args[index];
|
|
833
1136
|
if (arg === "--json" || arg === "--yes") {
|
|
834
1137
|
result[arg.slice(2)] = true;
|
|
835
|
-
} else if (arg === "--
|
|
1138
|
+
} else if (arg === "--check") {
|
|
1139
|
+
result.check = true;
|
|
1140
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--format") {
|
|
836
1141
|
result[arg.slice(2)] = args[index + 1];
|
|
837
1142
|
index += 1;
|
|
838
1143
|
} else {
|
|
@@ -985,6 +1290,66 @@ async function getOllamaVersion() {
|
|
|
985
1290
|
}
|
|
986
1291
|
}
|
|
987
1292
|
|
|
1293
|
+
async function getCommandVersion(command, args) {
|
|
1294
|
+
try {
|
|
1295
|
+
const { stdout } = await runCommand(command, args);
|
|
1296
|
+
return stdout.trim() || "installed";
|
|
1297
|
+
} catch {
|
|
1298
|
+
if (process.platform === "win32" && !command.endsWith(".cmd")) {
|
|
1299
|
+
try {
|
|
1300
|
+
const { stdout } = await runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `${command} ${args.join(" ")}`]);
|
|
1301
|
+
return stdout.trim() || "installed";
|
|
1302
|
+
} catch {
|
|
1303
|
+
return "не найден";
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return "не найден";
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function probeEndpoint(url) {
|
|
1312
|
+
try {
|
|
1313
|
+
const response = await fetch(url, { headers: { accept: "application/json" } });
|
|
1314
|
+
return response.ok ? "доступен" : `${response.status} ${response.statusText}`;
|
|
1315
|
+
} catch {
|
|
1316
|
+
return "недоступен";
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function getLatestNpmVersion(packageName) {
|
|
1321
|
+
try {
|
|
1322
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
|
|
1323
|
+
headers: { accept: "application/json" },
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
if (!response.ok) {
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const payload = await response.json();
|
|
1331
|
+
return payload.version || null;
|
|
1332
|
+
} catch {
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function compareVersions(left, right) {
|
|
1338
|
+
const leftParts = String(left).split(".").map(Number);
|
|
1339
|
+
const rightParts = String(right).split(".").map(Number);
|
|
1340
|
+
const length = Math.max(leftParts.length, rightParts.length);
|
|
1341
|
+
|
|
1342
|
+
for (let index = 0; index < length; index += 1) {
|
|
1343
|
+
const diff = (leftParts[index] || 0) - (rightParts[index] || 0);
|
|
1344
|
+
|
|
1345
|
+
if (diff !== 0) {
|
|
1346
|
+
return diff > 0 ? 1 : -1;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
return 0;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
988
1353
|
function recommendOllamaModel(diagnostics) {
|
|
989
1354
|
const ramGb = diagnostics.ramGb || 0;
|
|
990
1355
|
const vramGb = diagnostics.gpu.vramGb || 0;
|
|
@@ -1055,8 +1420,10 @@ async function confirm(question) {
|
|
|
1055
1420
|
}
|
|
1056
1421
|
|
|
1057
1422
|
async function saveConfig(value) {
|
|
1423
|
+
const current = await loadConfig();
|
|
1424
|
+
const merged = mergeConfig(current, value);
|
|
1058
1425
|
await mkdir(CONFIG_DIR, { recursive: true });
|
|
1059
|
-
await writeFile(CONFIG_FILE, `${JSON.stringify(
|
|
1426
|
+
await writeFile(CONFIG_FILE, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
1060
1427
|
}
|
|
1061
1428
|
|
|
1062
1429
|
async function loadConfig() {
|
|
@@ -1100,6 +1467,45 @@ async function printAiConfig() {
|
|
|
1100
1467
|
});
|
|
1101
1468
|
}
|
|
1102
1469
|
|
|
1470
|
+
function printContext(context) {
|
|
1471
|
+
const layerNames = context.layers.map((layer) => layer.name || layer.id || String(layer));
|
|
1472
|
+
console.log(`Запрос: ${context.query.text}`);
|
|
1473
|
+
console.log(`Слова поиска: ${context.query.terms.length > 0 ? context.query.terms.join(", ") : "-"}`);
|
|
1474
|
+
console.log(`Номера: ${context.query.patterns.numbers.length > 0 ? context.query.patterns.numbers.join(", ") : "-"}`);
|
|
1475
|
+
console.log(`ИНН: ${context.query.patterns.inns.length > 0 ? context.query.patterns.inns.join(", ") : "-"}`);
|
|
1476
|
+
console.log(`Улицы: ${context.query.patterns.streets.length > 0 ? context.query.patterns.streets.join(", ") : "-"}`);
|
|
1477
|
+
console.log(`Целевые слои: ${context.query.patterns.targetLayers.length > 0 ? context.query.patterns.targetLayers.join(", ") : "все"}`);
|
|
1478
|
+
console.log("");
|
|
1479
|
+
console.log(`Слои данных: ${layerNames.length > 0 ? layerNames.join(", ") : "-"}`);
|
|
1480
|
+
console.log("");
|
|
1481
|
+
|
|
1482
|
+
if (context.schools.length > 0) {
|
|
1483
|
+
console.log("Школы в контексте:");
|
|
1484
|
+
printTable(context.schools, [
|
|
1485
|
+
["name", "Название"],
|
|
1486
|
+
["address", "Адрес"],
|
|
1487
|
+
["phone", "Телефон"],
|
|
1488
|
+
["inn", "ИНН"],
|
|
1489
|
+
]);
|
|
1490
|
+
} else {
|
|
1491
|
+
console.log("Школы в контексте: нет совпадений");
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
console.log("");
|
|
1495
|
+
|
|
1496
|
+
if (context.kindergartens.length > 0) {
|
|
1497
|
+
console.log("Детские сады в контексте:");
|
|
1498
|
+
printTable(context.kindergartens, [
|
|
1499
|
+
["name", "Название"],
|
|
1500
|
+
["address", "Адрес"],
|
|
1501
|
+
["phone", "Телефон"],
|
|
1502
|
+
["inn", "ИНН"],
|
|
1503
|
+
]);
|
|
1504
|
+
} else {
|
|
1505
|
+
console.log("Детские сады в контексте: нет совпадений");
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1103
1509
|
async function printAiConfigField(field) {
|
|
1104
1510
|
const config = await loadConfig();
|
|
1105
1511
|
console.log(config.ai[field] || "-");
|
|
@@ -1149,6 +1555,24 @@ function printJson(value) {
|
|
|
1149
1555
|
console.log(JSON.stringify(value, null, 2));
|
|
1150
1556
|
}
|
|
1151
1557
|
|
|
1558
|
+
function printCsv(rows) {
|
|
1559
|
+
if (rows.length === 0) {
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
1564
|
+
console.log(columns.map(csvCell).join(","));
|
|
1565
|
+
|
|
1566
|
+
for (const row of rows) {
|
|
1567
|
+
console.log(columns.map((column) => csvCell(row[column])).join(","));
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function csvCell(value) {
|
|
1572
|
+
const text = value == null ? "" : String(value);
|
|
1573
|
+
return `"${text.replace(/"/g, "\"\"")}"`;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1152
1576
|
function printDatasetTable(items) {
|
|
1153
1577
|
printTable(items.map(selectPublicSummary), [
|
|
1154
1578
|
["inn", "ИНН"],
|