@iola_adm/iola-cli 0.1.5 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +276 -8
package/README.md CHANGED
@@ -78,6 +78,9 @@ iola banner
78
78
  iola agent
79
79
  iola ai doctor
80
80
  iola ai setup ollama
81
+ iola ai ask "Какие школы есть на улице Петрова?"
82
+ iola ai setup openai --model gpt-4.1-mini
83
+ iola ai setup openrouter --model openai/gpt-4.1-mini
81
84
  iola health
82
85
  iola layers
83
86
  iola schools --limit 10
@@ -110,9 +113,44 @@ iola agent
110
113
  /search лицей --limit 3
111
114
  /mcp-info
112
115
  /ai doctor
116
+ /model
117
+ /provider
118
+ /config
119
+ /history
120
+ /clear
113
121
  /exit
114
122
  ```
115
123
 
124
+ Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
125
+
126
+ ## AI-запросы
127
+
128
+ Локальная модель через Ollama:
129
+
130
+ ```bash
131
+ iola ai setup ollama
132
+ iola ai ask "Какие школы есть на улице Петрова?"
133
+ ```
134
+
135
+ OpenAI:
136
+
137
+ ```bash
138
+ set OPENAI_API_KEY=...
139
+ iola ai setup openai --model gpt-4.1-mini
140
+ iola ai ask "Найди школу 29"
141
+ ```
142
+
143
+ OpenRouter:
144
+
145
+ ```bash
146
+ set OPENROUTER_API_KEY=...
147
+ iola ai setup openrouter --model openai/gpt-4.1-mini
148
+ iola ai ask "Покажи контакты лицея"
149
+ ```
150
+
151
+ AI-ответ строится по контексту из публичного API. Если данных в контексте
152
+ недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
153
+
116
154
  ## Назначение
117
155
 
118
156
  Первый релиз CLI дает прямой терминальный доступ к открытым данным и командам
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { mkdir, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline/promises";
@@ -9,6 +9,13 @@ 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 DEFAULT_AI_CONFIG = {
13
+ ai: {
14
+ provider: "ollama",
15
+ model: "llama3.2:1b",
16
+ baseUrl: "http://127.0.0.1:11434",
17
+ },
18
+ };
12
19
  const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
13
20
  │\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
14
21
  │\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
@@ -56,6 +63,7 @@ async function showHelp() {
56
63
  Usage:
57
64
  iola banner
58
65
  iola agent
66
+ iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
59
67
  iola ai doctor [--json]
60
68
  iola ai setup
61
69
  iola ai setup ollama [--yes] [--model MODEL]
@@ -81,6 +89,9 @@ async function startAgent() {
81
89
  console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
82
90
 
83
91
  const rl = readline.createInterface({ input, output, prompt: "iola> " });
92
+ const state = {
93
+ history: [],
94
+ };
84
95
  let closed = false;
85
96
  rl.on("close", () => {
86
97
  closed = true;
@@ -96,7 +107,7 @@ async function startAgent() {
96
107
  }
97
108
 
98
109
  try {
99
- const shouldExit = await handleAgentLine(line);
110
+ const shouldExit = await handleAgentLine(line, state);
100
111
  if (shouldExit) {
101
112
  break;
102
113
  }
@@ -112,9 +123,11 @@ async function startAgent() {
112
123
  }
113
124
  }
114
125
 
115
- async function handleAgentLine(line) {
126
+ async function handleAgentLine(line, state) {
116
127
  if (!line.startsWith("/")) {
117
- console.log("AI-ответы будут добавлены следующим этапом. Сейчас используйте slash-команды, например /search лицей.");
128
+ const answer = await aiAsk([line], { history: state.history });
129
+ state.history.push({ role: "user", content: line });
130
+ state.history.push({ role: "assistant", content: answer });
118
131
  return false;
119
132
  }
120
133
 
@@ -129,6 +142,32 @@ async function handleAgentLine(line) {
129
142
  return false;
130
143
  }
131
144
 
145
+ if (command === "clear") {
146
+ state.history = [];
147
+ console.log("История agent-сессии очищена.");
148
+ return false;
149
+ }
150
+
151
+ if (command === "history") {
152
+ printAgentHistory(state.history);
153
+ return false;
154
+ }
155
+
156
+ if (command === "config") {
157
+ await printAiConfig();
158
+ return false;
159
+ }
160
+
161
+ if (command === "provider") {
162
+ await printAiConfigField("provider");
163
+ return false;
164
+ }
165
+
166
+ if (command === "model") {
167
+ await printAiConfigField("model");
168
+ return false;
169
+ }
170
+
132
171
  if (command === "banner") {
133
172
  showBanner();
134
173
  return false;
@@ -173,8 +212,26 @@ function printAgentHelp() {
173
212
  /mcp-info
174
213
  /ai doctor
175
214
  /ai setup ollama
215
+ /config
216
+ /provider
217
+ /model
218
+ /history
219
+ /clear
176
220
  /banner
177
- /exit`);
221
+ /exit
222
+
223
+ Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
224
+ }
225
+
226
+ function printAgentHistory(history) {
227
+ if (history.length === 0) {
228
+ console.log("История пуста.");
229
+ return;
230
+ }
231
+
232
+ for (const item of history.slice(-10)) {
233
+ console.log(`${item.role}: ${item.content}`);
234
+ }
178
235
  }
179
236
 
180
237
  function safePrompt(rl, closed = false) {
@@ -227,14 +284,22 @@ async function handleAi(args) {
227
284
  if (subcommand === "help") {
228
285
  showBanner();
229
286
  console.log(`AI-команды:
287
+ iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
230
288
  iola ai doctor [--json]
231
289
  iola ai setup
232
290
  iola ai setup ollama [--yes] [--model MODEL]
291
+ iola ai setup openai [--model MODEL]
292
+ iola ai setup openrouter [--model MODEL]
233
293
 
234
294
  Локальная настройка сохраняется в ${CONFIG_FILE}`);
235
295
  return;
236
296
  }
237
297
 
298
+ if (subcommand === "ask") {
299
+ await aiAsk(rest);
300
+ return;
301
+ }
302
+
238
303
  if (subcommand === "doctor") {
239
304
  await aiDoctor(rest);
240
305
  return;
@@ -277,8 +342,17 @@ async function aiSetup(args) {
277
342
  }
278
343
 
279
344
  if (provider === "openai" || provider === "openrouter") {
280
- console.log(`Для ${provider} используйте переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
281
- console.log("AI-запросы через API будут добавлены отдельной командой iola ai ask.");
345
+ const options = parseOptions(args.slice(1));
346
+ const model = options.model || (provider === "openai" ? "gpt-4.1-mini" : "openai/gpt-4.1-mini");
347
+ await saveConfig({
348
+ ai: {
349
+ provider,
350
+ model,
351
+ baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://openrouter.ai/api/v1",
352
+ },
353
+ });
354
+ console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
355
+ console.log(`Ключ задайте через переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
282
356
  return;
283
357
  }
284
358
 
@@ -351,6 +425,167 @@ async function setupOllama(args) {
351
425
  console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
352
426
  }
353
427
 
428
+ async function aiAsk(args, context = {}) {
429
+ const options = parseOptions(args);
430
+ const question = options._.join(" ").trim();
431
+
432
+ if (!question) {
433
+ throw new Error('Текст вопроса обязателен. Пример: iola ai ask "Какие школы есть на улице Петрова?"');
434
+ }
435
+
436
+ const config = await loadConfig();
437
+ const provider = options.provider || config.ai.provider;
438
+ const model = options.model || config.ai.model;
439
+ const providerConfig = {
440
+ ...config.ai,
441
+ provider,
442
+ model,
443
+ };
444
+ const dataContext = await buildDataContext(question);
445
+ const messages = buildAiMessages(question, dataContext, context.history || []);
446
+ const answer = await callAiProvider(providerConfig, messages);
447
+
448
+ console.log(answer);
449
+ return answer;
450
+ }
451
+
452
+ async function buildDataContext(question) {
453
+ const [layers, schools, kindergartens] = await Promise.all([
454
+ fetchJson(`${MCP_BASE_URL}/mcp-version`),
455
+ fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
456
+ fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
457
+ ]);
458
+ const queryTerms = extractSearchTerms(question);
459
+ const schoolItems = findRelevantItems(normalizeItems(schools), queryTerms).slice(0, 8);
460
+ const kindergartenItems = findRelevantItems(normalizeItems(kindergartens), queryTerms).slice(0, 8);
461
+
462
+ return {
463
+ layers: layers.data_layers || [],
464
+ schools: schoolItems.map(selectPublicSummary),
465
+ kindergartens: kindergartenItems.map(selectPublicSummary),
466
+ };
467
+ }
468
+
469
+ function extractSearchTerms(question) {
470
+ const normalized = question
471
+ .toLocaleLowerCase("ru-RU")
472
+ .replace(/[^\p{L}\p{N}\s.-]/gu, " ")
473
+ .split(/\s+/)
474
+ .map((term) => term.trim())
475
+ .filter(Boolean)
476
+ .filter((term) => !["какие", "какая", "какой", "есть", "найди", "покажи", "контакты", "адрес", "телефон", "школы", "школа", "сад", "детский", "детские", "сады", "улица", "ул"].includes(term));
477
+
478
+ return normalized.length > 0 ? normalized : [question];
479
+ }
480
+
481
+ function findRelevantItems(items, terms) {
482
+ return items
483
+ .map((item) => ({
484
+ item,
485
+ score: scoreItem(item, terms),
486
+ }))
487
+ .filter((entry) => entry.score > 0)
488
+ .sort((left, right) => right.score - left.score)
489
+ .map((entry) => entry.item);
490
+ }
491
+
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);
495
+ }
496
+
497
+ function buildAiMessages(question, dataContext, history) {
498
+ const system = [
499
+ "Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
500
+ "Отвечай на русском языке.",
501
+ "Используй только данные из переданного контекста.",
502
+ "Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
503
+ "Не выдумывай адреса, телефоны, лицензии и руководителей.",
504
+ "Отвечай кратко и по делу.",
505
+ ].join(" ");
506
+ const contextText = JSON.stringify(dataContext, null, 2);
507
+ const recentHistory = history.slice(-6);
508
+
509
+ return [
510
+ { role: "system", content: system },
511
+ ...recentHistory,
512
+ {
513
+ role: "user",
514
+ content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
515
+ },
516
+ ];
517
+ }
518
+
519
+ async function callAiProvider(config, messages) {
520
+ if (config.provider === "ollama") {
521
+ return callOllama(config, messages);
522
+ }
523
+
524
+ if (config.provider === "openai") {
525
+ return callOpenAiCompatible(config, messages, process.env.OPENAI_API_KEY, "OpenAI");
526
+ }
527
+
528
+ if (config.provider === "openrouter") {
529
+ return callOpenAiCompatible(config, messages, process.env.OPENROUTER_API_KEY, "OpenRouter");
530
+ }
531
+
532
+ throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
533
+ }
534
+
535
+ async function callOllama(config, messages) {
536
+ let response;
537
+
538
+ try {
539
+ response = await fetch(`${config.baseUrl || "http://127.0.0.1:11434"}/api/chat`, {
540
+ method: "POST",
541
+ headers: { "content-type": "application/json" },
542
+ body: JSON.stringify({
543
+ model: config.model || "llama3.2:1b",
544
+ messages,
545
+ stream: false,
546
+ }),
547
+ });
548
+ } catch {
549
+ throw new Error("Ollama недоступен. Запустите Ollama и проверьте: ollama --version");
550
+ }
551
+
552
+ if (!response.ok) {
553
+ throw new Error(`Ollama request failed: ${response.status} ${response.statusText}. Проверьте "ollama serve" и модель.`);
554
+ }
555
+
556
+ const payload = await response.json();
557
+ return payload.message?.content || "";
558
+ }
559
+
560
+ async function callOpenAiCompatible(config, messages, apiKey, providerName) {
561
+ if (!apiKey) {
562
+ throw new Error(`${providerName} API key не найден. Задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
563
+ }
564
+
565
+ const response = await fetch(`${config.baseUrl}/chat/completions`, {
566
+ method: "POST",
567
+ headers: {
568
+ authorization: `Bearer ${apiKey}`,
569
+ "content-type": "application/json",
570
+ "http-referer": "https://github.com/adm-iola/iola-cli",
571
+ "x-title": "iola-cli",
572
+ },
573
+ body: JSON.stringify({
574
+ model: config.model,
575
+ messages,
576
+ temperature: 0.2,
577
+ }),
578
+ });
579
+
580
+ if (!response.ok) {
581
+ const text = await response.text();
582
+ throw new Error(`${providerName} request failed: ${response.status} ${response.statusText}\n${text}`);
583
+ }
584
+
585
+ const payload = await response.json();
586
+ return payload.choices?.[0]?.message?.content || "";
587
+ }
588
+
354
589
  async function listLayers(args) {
355
590
  const options = parseOptions(args);
356
591
  const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
@@ -490,7 +725,7 @@ function parseOptions(args) {
490
725
  const arg = args[index];
491
726
  if (arg === "--json" || arg === "--yes") {
492
727
  result[arg.slice(2)] = true;
493
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn") {
728
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider") {
494
729
  result[arg.slice(2)] = args[index + 1];
495
730
  index += 1;
496
731
  } else {
@@ -717,6 +952,39 @@ async function saveConfig(value) {
717
952
  await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
718
953
  }
719
954
 
955
+ async function loadConfig() {
956
+ try {
957
+ const text = await readFile(CONFIG_FILE, "utf8");
958
+ return mergeConfig(DEFAULT_AI_CONFIG, JSON.parse(text));
959
+ } catch {
960
+ return DEFAULT_AI_CONFIG;
961
+ }
962
+ }
963
+
964
+ function mergeConfig(base, override) {
965
+ return {
966
+ ...base,
967
+ ...override,
968
+ ai: {
969
+ ...base.ai,
970
+ ...(override.ai || {}),
971
+ },
972
+ };
973
+ }
974
+
975
+ async function printAiConfig() {
976
+ const config = await loadConfig();
977
+ printJson({
978
+ file: CONFIG_FILE,
979
+ ai: config.ai,
980
+ });
981
+ }
982
+
983
+ async function printAiConfigField(field) {
984
+ const config = await loadConfig();
985
+ console.log(config.ai[field] || "-");
986
+ }
987
+
720
988
  function runCommand(command, args, options = {}) {
721
989
  return new Promise((resolve, reject) => {
722
990
  const child = execFile(command, args, {