@iola_adm/iola-cli 0.1.7 → 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 +19 -3
  2. package/package.json +1 -1
  3. package/src/cli.js +195 -7
package/README.md CHANGED
@@ -79,6 +79,7 @@ iola agent
79
79
  iola ai doctor
80
80
  iola ai setup ollama
81
81
  iola ai ask "Какие школы есть на улице Петрова?"
82
+ iola ai context "школа 29"
82
83
  iola ai key set openai
83
84
  iola ai key status
84
85
  iola ai setup openai --model gpt-4.1-mini
@@ -115,6 +116,11 @@ iola agent
115
116
  /search лицей --limit 3
116
117
  /mcp-info
117
118
  /ai doctor
119
+ /context школа 29
120
+ /use ollama
121
+ /use openai
122
+ /key status
123
+ /key set openai
118
124
  /model
119
125
  /provider
120
126
  /config
@@ -150,6 +156,16 @@ iola ai setup openrouter --model openai/gpt-4.1-mini
150
156
  iola ai ask "Покажи контакты лицея"
151
157
  ```
152
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
+
153
169
  Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
154
170
 
155
171
  ```text
@@ -174,9 +190,9 @@ AI-ответ строится по контексту из публичного
174
190
 
175
191
  ## Назначение
176
192
 
177
- Первый релиз CLI дает прямой терминальный доступ к открытым данным и командам
178
- подключения MCP/skill. Дальше планируется добавить режим AI-запросов через
179
- ключ пользователя для OpenAI/OpenRouter и интерактивный агентный режим.
193
+ CLI дает прямой терминальный доступ к открытым данным городского округа,
194
+ командам подключения MCP/skill, AI-запросам через Ollama/OpenAI/OpenRouter и
195
+ интерактивному агентному режиму.
180
196
 
181
197
  ## Переменные окружения
182
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.7",
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
@@ -65,6 +65,7 @@ Usage:
65
65
  iola banner
66
66
  iola agent
67
67
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
68
+ iola ai context TEXT [--json]
68
69
  iola ai key set openai
69
70
  iola ai key set openrouter
70
71
  iola ai key status
@@ -163,6 +164,21 @@ async function handleAgentLine(line, state) {
163
164
  return false;
164
165
  }
165
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
+
166
182
  if (command === "provider") {
167
183
  await printAiConfigField("provider");
168
184
  return false;
@@ -215,8 +231,13 @@ function printAgentHelp() {
215
231
  /kindergartens get --inn 1215077421
216
232
  /search лицей --limit 3
217
233
  /mcp-info
234
+ /context школа 29
218
235
  /ai doctor
219
236
  /ai setup ollama
237
+ /use openai
238
+ /use ollama
239
+ /key status
240
+ /key set openai
220
241
  /config
221
242
  /provider
222
243
  /model
@@ -290,6 +311,7 @@ async function handleAi(args) {
290
311
  showBanner();
291
312
  console.log(`AI-команды:
292
313
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
314
+ iola ai context TEXT [--json]
293
315
  iola ai key set openai
294
316
  iola ai key set openrouter
295
317
  iola ai key status
@@ -309,6 +331,11 @@ async function handleAi(args) {
309
331
  return;
310
332
  }
311
333
 
334
+ if (subcommand === "context") {
335
+ await aiContext(rest);
336
+ return;
337
+ }
338
+
312
339
  if (subcommand === "key") {
313
340
  await handleAiKey(rest);
314
341
  return;
@@ -403,6 +430,53 @@ async function handleAiKey(args) {
403
430
  iola ai key delete openai|openrouter`);
404
431
  }
405
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
+
406
480
  async function setAiKey(provider) {
407
481
  assertKeyProvider(provider);
408
482
 
@@ -550,11 +624,23 @@ async function buildDataContext(question) {
550
624
  fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
551
625
  ]);
552
626
  const queryTerms = extractSearchTerms(question);
553
- const schoolItems = findRelevantItems(normalizeItems(schools), queryTerms).slice(0, 8);
554
- 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
+ : [];
555
636
 
556
637
  return {
557
638
  layers: layers.data_layers || [],
639
+ query: {
640
+ text: question,
641
+ terms: queryTerms,
642
+ patterns,
643
+ },
558
644
  schools: schoolItems.map(selectPublicSummary),
559
645
  kindergartens: kindergartenItems.map(selectPublicSummary),
560
646
  };
@@ -572,20 +658,83 @@ function extractSearchTerms(question) {
572
658
  return normalized.length > 0 ? normalized : [question];
573
659
  }
574
660
 
575
- 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) {
576
689
  return items
577
690
  .map((item) => ({
578
691
  item,
579
- score: scoreItem(item, terms),
692
+ score: scoreItem(item, terms, patterns, layer),
580
693
  }))
581
694
  .filter((entry) => entry.score > 0)
582
695
  .sort((left, right) => right.score - left.score)
583
696
  .map((entry) => entry.item);
584
697
  }
585
698
 
586
- function scoreItem(item, terms) {
587
- const text = JSON.stringify(selectPublicSummary(item)).toLocaleLowerCase("ru-RU");
588
- 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;
589
738
  }
590
739
 
591
740
  function buildAiMessages(question, dataContext, history) {
@@ -1100,6 +1249,45 @@ async function printAiConfig() {
1100
1249
  });
1101
1250
  }
1102
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
+
1103
1291
  async function printAiConfigField(field) {
1104
1292
  const config = await loadConfig();
1105
1293
  console.log(config.ai[field] || "-");