@iola_adm/iola-cli 0.1.6 → 0.1.8

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.
Files changed (3) hide show
  1. package/README.md +42 -5
  2. package/package.json +1 -1
  3. package/src/cli.js +317 -9
package/README.md CHANGED
@@ -79,6 +79,9 @@ iola agent
79
79
  iola ai doctor
80
80
  iola ai setup ollama
81
81
  iola ai ask "Какие школы есть на улице Петрова?"
82
+ iola ai context "школа 29"
83
+ iola ai key set openai
84
+ iola ai key status
82
85
  iola ai setup openai --model gpt-4.1-mini
83
86
  iola ai setup openrouter --model openai/gpt-4.1-mini
84
87
  iola health
@@ -113,6 +116,11 @@ iola agent
113
116
  /search лицей --limit 3
114
117
  /mcp-info
115
118
  /ai doctor
119
+ /context школа 29
120
+ /use ollama
121
+ /use openai
122
+ /key status
123
+ /key set openai
116
124
  /model
117
125
  /provider
118
126
  /config
@@ -135,7 +143,7 @@ iola ai ask "Какие школы есть на улице Петрова?"
135
143
  OpenAI:
136
144
 
137
145
  ```bash
138
- set OPENAI_API_KEY=...
146
+ iola ai key set openai
139
147
  iola ai setup openai --model gpt-4.1-mini
140
148
  iola ai ask "Найди школу 29"
141
149
  ```
@@ -143,19 +151,48 @@ iola ai ask "Найди школу 29"
143
151
  OpenRouter:
144
152
 
145
153
  ```bash
146
- set OPENROUTER_API_KEY=...
154
+ iola ai key set openrouter
147
155
  iola ai setup openrouter --model openai/gpt-4.1-mini
148
156
  iola ai ask "Покажи контакты лицея"
149
157
  ```
150
158
 
159
+ Проверить, какие данные попадут в AI-контекст:
160
+
161
+ ```bash
162
+ iola ai context "школа 29"
163
+ iola ai context "1215067180" --json
164
+ iola ai context "улица Петрова"
165
+ ```
166
+
167
+ Поиск контекста учитывает номера учреждений, ИНН и улицы.
168
+
169
+ Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
170
+
171
+ ```text
172
+ %USERPROFILE%\.iola\secrets.json
173
+ ```
174
+
175
+ Управление ключами:
176
+
177
+ ```bash
178
+ iola ai key set openai
179
+ iola ai key set openrouter
180
+ iola ai key status
181
+ iola ai key delete openai
182
+ ```
183
+
184
+ Если одновременно задана переменная окружения (`OPENAI_API_KEY` или
185
+ `OPENROUTER_API_KEY`) и сохранен локальный ключ, CLI использует переменную
186
+ окружения как более приоритетную.
187
+
151
188
  AI-ответ строится по контексту из публичного API. Если данных в контексте
152
189
  недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
153
190
 
154
191
  ## Назначение
155
192
 
156
- Первый релиз CLI дает прямой терминальный доступ к открытым данным и командам
157
- подключения MCP/skill. Дальше планируется добавить режим AI-запросов через
158
- ключ пользователя для OpenAI/OpenRouter и интерактивный агентный режим.
193
+ CLI дает прямой терминальный доступ к открытым данным городского округа,
194
+ командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter и
195
+ интерактивному агентному режиму.
159
196
 
160
197
  ## Переменные окружения
161
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
package/src/cli.js CHANGED
@@ -9,6 +9,7 @@ const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/a
9
9
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
10
10
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
11
11
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
12
+ const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
12
13
  const DEFAULT_AI_CONFIG = {
13
14
  ai: {
14
15
  provider: "ollama",
@@ -64,6 +65,11 @@ Usage:
64
65
  iola banner
65
66
  iola agent
66
67
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
68
+ iola ai context TEXT [--json]
69
+ iola ai key set openai
70
+ iola ai key set openrouter
71
+ iola ai key status
72
+ iola ai key delete openai|openrouter
67
73
  iola ai doctor [--json]
68
74
  iola ai setup
69
75
  iola ai setup ollama [--yes] [--model MODEL]
@@ -158,6 +164,21 @@ async function handleAgentLine(line, state) {
158
164
  return false;
159
165
  }
160
166
 
167
+ if (command === "context") {
168
+ await aiContext(args);
169
+ return false;
170
+ }
171
+
172
+ if (command === "use") {
173
+ await useAiProvider(args);
174
+ return false;
175
+ }
176
+
177
+ if (command === "key") {
178
+ await handleAiKey(args);
179
+ return false;
180
+ }
181
+
161
182
  if (command === "provider") {
162
183
  await printAiConfigField("provider");
163
184
  return false;
@@ -210,8 +231,13 @@ function printAgentHelp() {
210
231
  /kindergartens get --inn 1215077421
211
232
  /search лицей --limit 3
212
233
  /mcp-info
234
+ /context школа 29
213
235
  /ai doctor
214
236
  /ai setup ollama
237
+ /use openai
238
+ /use ollama
239
+ /key status
240
+ /key set openai
215
241
  /config
216
242
  /provider
217
243
  /model
@@ -285,6 +311,11 @@ async function handleAi(args) {
285
311
  showBanner();
286
312
  console.log(`AI-команды:
287
313
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
314
+ iola ai context TEXT [--json]
315
+ iola ai key set openai
316
+ iola ai key set openrouter
317
+ iola ai key status
318
+ iola ai key delete openai|openrouter
288
319
  iola ai doctor [--json]
289
320
  iola ai setup
290
321
  iola ai setup ollama [--yes] [--model MODEL]
@@ -300,6 +331,16 @@ async function handleAi(args) {
300
331
  return;
301
332
  }
302
333
 
334
+ if (subcommand === "context") {
335
+ await aiContext(rest);
336
+ return;
337
+ }
338
+
339
+ if (subcommand === "key") {
340
+ await handleAiKey(rest);
341
+ return;
342
+ }
343
+
303
344
  if (subcommand === "doctor") {
304
345
  await aiDoctor(rest);
305
346
  return;
@@ -364,6 +405,133 @@ async function aiSetup(args) {
364
405
  throw new Error(`Unknown AI provider: ${provider}`);
365
406
  }
366
407
 
408
+ async function handleAiKey(args) {
409
+ const [action, provider] = args;
410
+
411
+ if (action === "set") {
412
+ await setAiKey(provider);
413
+ return;
414
+ }
415
+
416
+ if (action === "status") {
417
+ await printAiKeyStatus();
418
+ return;
419
+ }
420
+
421
+ if (action === "delete") {
422
+ await deleteAiKey(provider);
423
+ return;
424
+ }
425
+
426
+ throw new Error(`Unknown key command. Use:
427
+ iola ai key set openai
428
+ iola ai key set openrouter
429
+ iola ai key status
430
+ iola ai key delete openai|openrouter`);
431
+ }
432
+
433
+ async function useAiProvider(args) {
434
+ const [provider] = args;
435
+
436
+ if (provider !== "ollama" && provider !== "openai" && provider !== "openrouter") {
437
+ throw new Error("Провайдер должен быть ollama, openai или openrouter.");
438
+ }
439
+
440
+ const config = await loadConfig();
441
+ const defaultModel = {
442
+ ollama: config.ai.provider === "ollama" ? config.ai.model : "llama3.2:1b",
443
+ openai: config.ai.provider === "openai" ? config.ai.model : "gpt-4.1-mini",
444
+ openrouter: config.ai.provider === "openrouter" ? config.ai.model : "openai/gpt-4.1-mini",
445
+ }[provider];
446
+
447
+ await saveConfig({
448
+ ai: {
449
+ provider,
450
+ model: defaultModel,
451
+ baseUrl: provider === "ollama"
452
+ ? "http://127.0.0.1:11434"
453
+ : provider === "openai"
454
+ ? "https://api.openai.com/v1"
455
+ : "https://openrouter.ai/api/v1",
456
+ },
457
+ });
458
+
459
+ console.log(`AI-провайдер переключен: ${provider}, модель: ${defaultModel}`);
460
+ }
461
+
462
+ async function aiContext(args) {
463
+ const options = parseOptions(args);
464
+ const query = options._.join(" ").trim();
465
+
466
+ if (!query) {
467
+ throw new Error('Текст запроса обязателен. Пример: iola ai context "школа 29"');
468
+ }
469
+
470
+ const context = await buildDataContext(query);
471
+
472
+ if (options.json) {
473
+ printJson(context);
474
+ return;
475
+ }
476
+
477
+ printContext(context);
478
+ }
479
+
480
+ async function setAiKey(provider) {
481
+ assertKeyProvider(provider);
482
+
483
+ if (!process.stdin.isTTY) {
484
+ throw new Error("Для сохранения ключа запустите команду в интерактивном терминале.");
485
+ }
486
+
487
+ const envName = provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY";
488
+ const rl = readline.createInterface({ input, output });
489
+
490
+ try {
491
+ const key = (await rl.question(`Введите ${envName}: `)).trim();
492
+
493
+ if (!key) {
494
+ throw new Error("Ключ пустой, сохранение отменено.");
495
+ }
496
+
497
+ const secrets = await loadSecrets();
498
+ secrets[provider] = { apiKey: key };
499
+ await saveSecrets(secrets);
500
+ console.log(`Ключ ${provider} сохранен локально: ${SECRETS_FILE}`);
501
+ } finally {
502
+ rl.close();
503
+ }
504
+ }
505
+
506
+ async function printAiKeyStatus() {
507
+ const secrets = await loadSecrets();
508
+ const rows = ["openai", "openrouter"].map((provider) => ({
509
+ provider,
510
+ env: provider === "openai" ? (process.env.OPENAI_API_KEY ? "yes" : "no") : (process.env.OPENROUTER_API_KEY ? "yes" : "no"),
511
+ local: secrets[provider]?.apiKey ? "yes" : "no",
512
+ }));
513
+
514
+ printTable(rows, [
515
+ ["provider", "Провайдер"],
516
+ ["env", "Env"],
517
+ ["local", "Локально"],
518
+ ]);
519
+ }
520
+
521
+ async function deleteAiKey(provider) {
522
+ assertKeyProvider(provider);
523
+ const secrets = await loadSecrets();
524
+ delete secrets[provider];
525
+ await saveSecrets(secrets);
526
+ console.log(`Локальный ключ ${provider} удален.`);
527
+ }
528
+
529
+ function assertKeyProvider(provider) {
530
+ if (provider !== "openai" && provider !== "openrouter") {
531
+ throw new Error("Провайдер должен быть openai или openrouter.");
532
+ }
533
+ }
534
+
367
535
  async function chooseAiProvider() {
368
536
  console.log("Выберите режим AI:");
369
537
  console.log("1. Локальная модель через Ollama");
@@ -456,11 +624,23 @@ async function buildDataContext(question) {
456
624
  fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
457
625
  ]);
458
626
  const queryTerms = extractSearchTerms(question);
459
- const schoolItems = findRelevantItems(normalizeItems(schools), queryTerms).slice(0, 8);
460
- const kindergartenItems = findRelevantItems(normalizeItems(kindergartens), queryTerms).slice(0, 8);
627
+ const patterns = extractStructuredPatterns(question);
628
+ const includeSchools = patterns.targetLayers.length === 0 || patterns.targetLayers.includes("schools");
629
+ const includeKindergartens = patterns.targetLayers.length === 0 || patterns.targetLayers.includes("kindergartens");
630
+ const schoolItems = includeSchools
631
+ ? findRelevantItems(normalizeItems(schools), queryTerms, patterns, "schools").slice(0, 8)
632
+ : [];
633
+ const kindergartenItems = includeKindergartens
634
+ ? findRelevantItems(normalizeItems(kindergartens), queryTerms, patterns, "kindergartens").slice(0, 8)
635
+ : [];
461
636
 
462
637
  return {
463
638
  layers: layers.data_layers || [],
639
+ query: {
640
+ text: question,
641
+ terms: queryTerms,
642
+ patterns,
643
+ },
464
644
  schools: schoolItems.map(selectPublicSummary),
465
645
  kindergartens: kindergartenItems.map(selectPublicSummary),
466
646
  };
@@ -478,20 +658,83 @@ function extractSearchTerms(question) {
478
658
  return normalized.length > 0 ? normalized : [question];
479
659
  }
480
660
 
481
- function findRelevantItems(items, terms) {
661
+ function extractStructuredPatterns(question) {
662
+ const normalized = question.toLocaleLowerCase("ru-RU");
663
+ const numbers = [...new Set([...normalized.matchAll(/\b\d{1,3}\b/g)].map((match) => match[0]))];
664
+ const inns = [...new Set([...normalized.matchAll(/\b\d{10,12}\b/g)].map((match) => match[0]))];
665
+ const targetLayers = [];
666
+ if (/(^|[^а-яёa-z])(школа|школы|лицей|лицея|гимназия|гимназии)(?=$|[^а-яёa-z])/iu.test(normalized)) {
667
+ targetLayers.push("schools");
668
+ }
669
+ if (/(^|[^а-яёa-z])(сад|сады|детсад|детский|детские|доу|мбдоу)(?=$|[^а-яёa-z])/iu.test(normalized)) {
670
+ targetLayers.push("kindergartens");
671
+ }
672
+ const streetMatches = [
673
+ ...normalized.matchAll(/(?:улица|ул\.?)\s+([а-яёa-z0-9 .-]+)/giu),
674
+ ...normalized.matchAll(/([а-яёa-z0-9 .-]+)\s+(?:улица|ул\.?)/giu),
675
+ ];
676
+ const streets = [...new Set(streetMatches.map((match) => cleanupPattern(match[1])).filter(Boolean))];
677
+
678
+ return { numbers, inns, streets, targetLayers: [...new Set(targetLayers)] };
679
+ }
680
+
681
+ function cleanupPattern(value) {
682
+ return value
683
+ .replace(/\b(школа|школы|сад|детский|детские|сады|лицей|гимназия|контакты|телефон|адрес|найди|покажи)\b/giu, " ")
684
+ .replace(/\s+/g, " ")
685
+ .trim();
686
+ }
687
+
688
+ function findRelevantItems(items, terms, patterns, layer) {
482
689
  return items
483
690
  .map((item) => ({
484
691
  item,
485
- score: scoreItem(item, terms),
692
+ score: scoreItem(item, terms, patterns, layer),
486
693
  }))
487
694
  .filter((entry) => entry.score > 0)
488
695
  .sort((left, right) => right.score - left.score)
489
696
  .map((entry) => entry.item);
490
697
  }
491
698
 
492
- function scoreItem(item, terms) {
493
- const text = JSON.stringify(selectPublicSummary(item)).toLocaleLowerCase("ru-RU");
494
- return terms.reduce((score, term) => score + (text.includes(term.toLocaleLowerCase("ru-RU")) ? 1 : 0), 0);
699
+ function scoreItem(item, terms, patterns, layer) {
700
+ const summary = selectPublicSummary(item);
701
+ const text = JSON.stringify(summary).toLocaleLowerCase("ru-RU");
702
+ const name = String(summary.name || "").toLocaleLowerCase("ru-RU");
703
+ const address = String(summary.address || "").toLocaleLowerCase("ru-RU");
704
+ const generalTerms = terms.filter((term) => !/^\d+$/.test(term));
705
+ let score = generalTerms.reduce((value, term) => value + (text.includes(term.toLocaleLowerCase("ru-RU")) ? 1 : 0), 0);
706
+
707
+ for (const inn of patterns.inns) {
708
+ if (String(summary.inn) === inn) {
709
+ score += 20;
710
+ }
711
+ }
712
+
713
+ for (const number of patterns.numbers) {
714
+ const numberPatterns = [
715
+ `№ ${number}`,
716
+ `№${number}`,
717
+ `школа ${number}`,
718
+ `сад ${number}`,
719
+ `лицей ${number}`,
720
+ `гимназия ${number}`,
721
+ ];
722
+
723
+ if (numberPatterns.some((pattern) => name.includes(pattern))) {
724
+ score += 12;
725
+ if (patterns.targetLayers.length === 0 || patterns.targetLayers.includes(layer)) {
726
+ score += 5;
727
+ }
728
+ }
729
+ }
730
+
731
+ for (const street of patterns.streets) {
732
+ if (street && address.includes(street)) {
733
+ score += 8;
734
+ }
735
+ }
736
+
737
+ return score;
495
738
  }
496
739
 
497
740
  function buildAiMessages(question, dataContext, history) {
@@ -522,11 +765,11 @@ async function callAiProvider(config, messages) {
522
765
  }
523
766
 
524
767
  if (config.provider === "openai") {
525
- return callOpenAiCompatible(config, messages, process.env.OPENAI_API_KEY, "OpenAI");
768
+ return callOpenAiCompatible(config, messages, await getApiKey("openai"), "OpenAI");
526
769
  }
527
770
 
528
771
  if (config.provider === "openrouter") {
529
- return callOpenAiCompatible(config, messages, process.env.OPENROUTER_API_KEY, "OpenRouter");
772
+ return callOpenAiCompatible(config, messages, await getApiKey("openrouter"), "OpenRouter");
530
773
  }
531
774
 
532
775
  throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
@@ -586,6 +829,19 @@ async function callOpenAiCompatible(config, messages, apiKey, providerName) {
586
829
  return payload.choices?.[0]?.message?.content || "";
587
830
  }
588
831
 
832
+ async function getApiKey(provider) {
833
+ if (provider === "openai" && process.env.OPENAI_API_KEY) {
834
+ return process.env.OPENAI_API_KEY;
835
+ }
836
+
837
+ if (provider === "openrouter" && process.env.OPENROUTER_API_KEY) {
838
+ return process.env.OPENROUTER_API_KEY;
839
+ }
840
+
841
+ const secrets = await loadSecrets();
842
+ return secrets[provider]?.apiKey || "";
843
+ }
844
+
589
845
  async function listLayers(args) {
590
846
  const options = parseOptions(args);
591
847
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
@@ -972,6 +1228,19 @@ function mergeConfig(base, override) {
972
1228
  };
973
1229
  }
974
1230
 
1231
+ async function loadSecrets() {
1232
+ try {
1233
+ return JSON.parse(await readFile(SECRETS_FILE, "utf8"));
1234
+ } catch {
1235
+ return {};
1236
+ }
1237
+ }
1238
+
1239
+ async function saveSecrets(value) {
1240
+ await mkdir(CONFIG_DIR, { recursive: true });
1241
+ await writeFile(SECRETS_FILE, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
1242
+ }
1243
+
975
1244
  async function printAiConfig() {
976
1245
  const config = await loadConfig();
977
1246
  printJson({
@@ -980,6 +1249,45 @@ async function printAiConfig() {
980
1249
  });
981
1250
  }
982
1251
 
1252
+ function printContext(context) {
1253
+ const layerNames = context.layers.map((layer) => layer.name || layer.id || String(layer));
1254
+ console.log(`Запрос: ${context.query.text}`);
1255
+ console.log(`Слова поиска: ${context.query.terms.length > 0 ? context.query.terms.join(", ") : "-"}`);
1256
+ console.log(`Номера: ${context.query.patterns.numbers.length > 0 ? context.query.patterns.numbers.join(", ") : "-"}`);
1257
+ console.log(`ИНН: ${context.query.patterns.inns.length > 0 ? context.query.patterns.inns.join(", ") : "-"}`);
1258
+ console.log(`Улицы: ${context.query.patterns.streets.length > 0 ? context.query.patterns.streets.join(", ") : "-"}`);
1259
+ console.log(`Целевые слои: ${context.query.patterns.targetLayers.length > 0 ? context.query.patterns.targetLayers.join(", ") : "все"}`);
1260
+ console.log("");
1261
+ console.log(`Слои данных: ${layerNames.length > 0 ? layerNames.join(", ") : "-"}`);
1262
+ console.log("");
1263
+
1264
+ if (context.schools.length > 0) {
1265
+ console.log("Школы в контексте:");
1266
+ printTable(context.schools, [
1267
+ ["name", "Название"],
1268
+ ["address", "Адрес"],
1269
+ ["phone", "Телефон"],
1270
+ ["inn", "ИНН"],
1271
+ ]);
1272
+ } else {
1273
+ console.log("Школы в контексте: нет совпадений");
1274
+ }
1275
+
1276
+ console.log("");
1277
+
1278
+ if (context.kindergartens.length > 0) {
1279
+ console.log("Детские сады в контексте:");
1280
+ printTable(context.kindergartens, [
1281
+ ["name", "Название"],
1282
+ ["address", "Адрес"],
1283
+ ["phone", "Телефон"],
1284
+ ["inn", "ИНН"],
1285
+ ]);
1286
+ } else {
1287
+ console.log("Детские сады в контексте: нет совпадений");
1288
+ }
1289
+ }
1290
+
983
1291
  async function printAiConfigField(field) {
984
1292
  const config = await loadConfig();
985
1293
  console.log(config.ai[field] || "-");