@iola_adm/iola-cli 0.2.1 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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
@@ -6695,7 +6695,7 @@ async function aiAsk(args, context = {}) {
6695
6695
  const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
6696
6696
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
6697
6697
  const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
6698
- const directAnswer = buildDirectDataAnswer(question, dataContext);
6698
+ const directAnswer = await buildDirectDataAnswer(question, dataContext);
6699
6699
  if (directAnswer) {
6700
6700
  if (historyEnabled) {
6701
6701
  recordAskHistory({ question, answer: directAnswer, providerConfig, dataContext, error: "", sessionId });
@@ -6758,10 +6758,12 @@ async function aiAsk(args, context = {}) {
6758
6758
  return answer;
6759
6759
  }
6760
6760
 
6761
- function buildDirectDataAnswer(question, dataContext) {
6761
+ async function buildDirectDataAnswer(question, dataContext) {
6762
6762
  const normalized = question.toLocaleLowerCase("ru-RU");
6763
6763
  const requestedFields = detectDirectDataFields(normalized);
6764
6764
  if (requestedFields.length === 0) return "";
6765
+ const educationAnswer = await buildDeterministicEducationAnswer(question, requestedFields);
6766
+ if (educationAnswer) return educationAnswer;
6765
6767
  const rows = [
6766
6768
  ...dataContext.schools.map((item) => ({ layer: "schools", layerName: "школы", ...item })),
6767
6769
  ...dataContext.kindergartens.map((item) => ({ layer: "kindergartens", layerName: "детские сады", ...item })),
@@ -6791,6 +6793,140 @@ function detectDirectDataFields(normalizedQuestion) {
6791
6793
  return [...new Set(fields)];
6792
6794
  }
6793
6795
 
6796
+ async function buildDeterministicEducationAnswer(question, requestedFields) {
6797
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
6798
+ const layer = /(сад|детсад|детск\w*\s+сад|садик)/iu.test(normalized)
6799
+ ? "kindergartens"
6800
+ : /(школ|сош|лице|гимнази)/iu.test(normalized)
6801
+ ? "schools"
6802
+ : "";
6803
+ if (!layer) return "";
6804
+
6805
+ const requests = extractEducationFactRequests(question, requestedFields, layer);
6806
+ if (requests.length === 0) return "";
6807
+
6808
+ const items = normalizeItems(await fetchAllApiItems(`${await getApiBaseUrl()}/${DATASETS[layer].endpoint}`))
6809
+ .map((item) => ({ layer, layerName: layer === "schools" ? "школы" : "детские сады", ...selectPublicSummary(item) }));
6810
+ const answers = [];
6811
+
6812
+ for (const request of requests) {
6813
+ const answer = resolveEducationFactRequest(request, items, layer);
6814
+ if (answer) answers.push(answer);
6815
+ }
6816
+
6817
+ return answers.filter(Boolean).join("\n");
6818
+ }
6819
+
6820
+ function extractEducationFactRequests(question, requestedFields, layer) {
6821
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
6822
+ const segments = splitEducationQuestionSegments(question);
6823
+ const requests = [];
6824
+
6825
+ for (const segment of segments) {
6826
+ const segmentFields = detectDirectDataFields(segment.toLocaleLowerCase("ru-RU"));
6827
+ const fields = segmentFields.length > 0 ? segmentFields : (segments.length === 1 ? requestedFields : []);
6828
+ if (fields.length === 0) continue;
6829
+ const place = detectEducationPlace(segment) || detectEducationPlace(question);
6830
+ const number = extractEntityNumberFromQuestion(segment, layer)
6831
+ || (segments.length === 1 ? extractEntityNumberFromQuestion(question, layer) : "");
6832
+ const hasEntitySignal = Boolean(number || place || /(школ|сош|лице|гимнази|сад|детсад|детск\w*\s+сад|садик)/iu.test(segment));
6833
+ if (!hasEntitySignal) continue;
6834
+ requests.push({ segment, fields, number, place });
6835
+ }
6836
+
6837
+ if (requests.length === 0 && requestedFields.length > 0 && /(школ|сош|лице|гимнази|сад|детсад|детск\w*\s+сад|садик)/iu.test(normalized)) {
6838
+ requests.push({
6839
+ segment: question,
6840
+ fields: requestedFields,
6841
+ number: extractEntityNumberFromQuestion(question, layer),
6842
+ place: detectEducationPlace(question),
6843
+ });
6844
+ }
6845
+
6846
+ return requests;
6847
+ }
6848
+
6849
+ function splitEducationQuestionSegments(question) {
6850
+ const text = String(question || "").trim();
6851
+ if (!text) return [];
6852
+ return text
6853
+ .split(/\s+(?:и|а также|,|;)\s+(?=(?:кто|какой|какая|адрес|телефон|инн|сайт|почт|email|директ|руковод|завед|где))/iu)
6854
+ .map((part) => part.trim())
6855
+ .filter(Boolean);
6856
+ }
6857
+
6858
+ function detectEducationPlace(text) {
6859
+ const normalized = normalizeEntityText(text || "");
6860
+ if (/(козьмодемьянск|козьмодемьянск[аеуом]?|козьмодемьянск\w*)/iu.test(normalized)) {
6861
+ return { id: "kozmodemyansk", label: "Козьмодемьянск", locative: "Козьмодемьянске", supported: false, aliases: ["козьмодемьянск", "козмодемьянск"] };
6862
+ }
6863
+ if (/(семеновк|семёновк)/iu.test(normalized)) {
6864
+ return { id: "semenovka", label: "Семёновка", locative: "Семёновке", supported: true, aliases: ["семеновк", "семёновк"] };
6865
+ }
6866
+ if (/(йошкар|йошкар-ола|йошкар ола)/iu.test(normalized)) {
6867
+ return { id: "yoshkar_ola", label: "Йошкар-Ола", locative: "Йошкар-Оле", supported: true, aliases: ["йошкар", "йошкар-ола", "йошкар ола"] };
6868
+ }
6869
+ return null;
6870
+ }
6871
+
6872
+ function resolveEducationFactRequest(request, items, layer) {
6873
+ const entityLabel = layer === "schools" ? "школу" : "детский сад";
6874
+ if (request.place && !request.place.supported) {
6875
+ return `В текущих открытых данных iola-cli есть данные городского округа Йошкар-Ола. Данных по ${request.place.locative || request.place.label} в этом слое нет, поэтому ответить по этому объекту не могу.`;
6876
+ }
6877
+
6878
+ let candidates = items;
6879
+ if (request.place) {
6880
+ candidates = candidates.filter((item) => itemMatchesPlace(item, request.place));
6881
+ }
6882
+
6883
+ if (request.number) {
6884
+ const exactByNumber = candidates.filter((item) => itemNameHasNumber(item, request.number));
6885
+ if (exactByNumber.length === 0) {
6886
+ if (request.place && candidates.length > 0) {
6887
+ return [
6888
+ `Точную ${entityLabel} № ${request.number} в ${request.place.locative || request.place.label} в открытом слое не нашел.`,
6889
+ `В ${request.place.locative || request.place.label} есть:`,
6890
+ ...candidates.slice(0, 5).map((item) => `- ${getDirectDataItemName(item)}${item.address ? `; адрес: ${item.address}` : ""}${item.inn ? `; ИНН ${item.inn}` : ""}`),
6891
+ ].join("\n");
6892
+ }
6893
+ return `В открытом слое не нашел ${entityLabel} № ${request.number}.`;
6894
+ }
6895
+ candidates = exactByNumber;
6896
+ }
6897
+
6898
+ if (candidates.length === 0) {
6899
+ const placeText = request.place ? ` в ${request.place.locative || request.place.label}` : "";
6900
+ return `В открытом слое не нашел ${entityLabel}${placeText}.`;
6901
+ }
6902
+
6903
+ if (candidates.length > 1 && !request.number) {
6904
+ return [
6905
+ `Нашел несколько подходящих записей${request.place ? ` для ${request.place.locative || request.place.label}` : ""}:`,
6906
+ ...candidates.slice(0, 5).flatMap((item) => [
6907
+ `- ${getDirectDataItemName(item)}`,
6908
+ ...request.fields.map((field) => ` ${formatDirectDataField(field, item)}`).filter(Boolean),
6909
+ ` Источник: слой ${item.layer}, ИНН ${item.inn || "-"}.`,
6910
+ ]),
6911
+ ].join("\n");
6912
+ }
6913
+
6914
+ const item = candidates[0];
6915
+ const lines = request.fields.map((field) => formatDirectDataField(field, item)).filter(Boolean);
6916
+ if (lines.length === 0) return "";
6917
+ return [
6918
+ ...lines,
6919
+ `Источник: слой ${item.layer}, ${getDirectDataItemName(item)}, ИНН ${item.inn || "-"}.`,
6920
+ ].join("\n");
6921
+ }
6922
+
6923
+ function itemMatchesPlace(item, place) {
6924
+ if (!place) return true;
6925
+ const text = normalizeEntityText(`${item.name || ""} ${item.address || ""} ${item.legal_address || ""} ${item.fns_full_name || ""} ${item.fns_short_name || ""}`);
6926
+ if (place.id === "yoshkar_ola") return /йошкар|йошкар-ола|йошкар ола/u.test(text) && !/семеновк/u.test(text);
6927
+ return place.aliases.some((alias) => text.includes(normalizeEntityText(alias)));
6928
+ }
6929
+
6794
6930
  function pickDirectDataItem(question, dataContext, rows) {
6795
6931
  const patterns = dataContext.query?.patterns || extractStructuredPatterns(question);
6796
6932
  const targetLayers = patterns.targetLayers || [];
@@ -84,4 +84,18 @@ if (deletePlan.willKeep.includes("npm package files")) {
84
84
  throw new Error("delete dry-run should not keep npm package files");
85
85
  }
86
86
 
87
+ const multiSchoolAnswer = await runCli(["ask", "Кто директор школы № 2 и адрес школы № 7?", "--profile", "yandexgpt", "--no-history"]);
88
+ assertIncludes(multiSchoolAnswer, "Адамова Наталья Васильевна", "multi school answer");
89
+ assertIncludes(multiSchoolAnswer, "улица Первомайская, дом 89", "multi school answer");
90
+ assertNotIncludes(multiSchoolAnswer, "улица Осипенко, дом 46\nИсточник: слой schools, МБОУ \"Средняя общеобразовательная школа № 7", "multi school answer");
91
+
92
+ const externalTownAnswer = await runCli(["ask", "Адрес школы № 1 Козьмодемьянска", "--profile", "yandexgpt", "--no-history"]);
93
+ assertIncludes(externalTownAnswer, "Данных по Козьмодемьянске", "external town answer");
94
+ assertNotIncludes(externalTownAnswer, "улица Петрова, дом 15", "external town answer");
95
+
96
+ const semenovkaAnswer = await runCli(["ask", "Адрес школы № 1 Семеновки", "--profile", "yandexgpt", "--no-history"]);
97
+ assertIncludes(semenovkaAnswer, "Точную школу № 1 в Семёновке", "semenovka answer");
98
+ assertIncludes(semenovkaAnswer, "село Семёновка", "semenovka answer");
99
+ assertNotIncludes(semenovkaAnswer, "улица Петрова, дом 15", "semenovka answer");
100
+
87
101
  console.log("smoke tests passed");
@@ -29,40 +29,126 @@ iola ask "найди школы на Петрова"
29
29
 
30
30
  ### YandexGPT
31
31
 
32
- Официальные страницы:
32
+ Для CLI нужны две вещи: API key и ID каталога Yandex Cloud.
33
+
34
+ Пошаговая настройка:
35
+
36
+ 1. Откройте консоль Yandex Cloud: `https://console.yandex.cloud/`.
37
+ 2. Войдите в аккаунт Яндекса.
38
+ 3. Если Yandex Cloud попросит создать облако, создайте облако. Название может быть любым, например `iola-cli`.
39
+ 4. Откройте каталог внутри облака. Если каталога нет, создайте каталог. Название может быть любым, например `default`.
40
+ 5. На верхней панели страницы каталога найдите название каталога и его идентификатор. В нашем примере рядом с `default` был показан ID вида `b1g8...`.
41
+ 6. Нажмите кнопку `Копировать` рядом с идентификатором каталога. Это значение будет `YANDEXGPT_FOLDER_ID`.
42
+ 7. В левом меню или через поиск сервисов откройте `Identity and Access Management`.
43
+ 8. Внутри `Identity and Access Management` откройте раздел `Сервисные аккаунты`.
44
+ 9. Нажмите `Создать сервисный аккаунт`.
45
+ 10. В поле `Имя` введите понятное имя, например `iola-cli`.
46
+ 11. В поле `Описание` можно написать `API-доступ iola-cli к YandexGPT`.
47
+ 12. В блоке `Роли в каталоге` нажмите `Добавить роль`.
48
+ 13. В поле поиска роли введите `ai.languageModels.user`.
49
+ 14. В найденной группе `ai.languageModels` выберите роль `user`. В форме должна появиться роль `ai.languageModels.user`.
50
+ 15. Нажмите `Создать`.
51
+ 16. После создания откройте созданный сервисный аккаунт `iola-cli`.
52
+ 17. На странице сервисного аккаунта нажмите кнопку `Создать новый ключ`.
53
+ 18. В выпадающем меню выберите `Создать API-ключ`. Не выбирайте `Создать статический ключ доступа` и не выбирайте `Создать авторизованный ключ`.
54
+ 19. В поле `Описание` напишите, например, `iola-cli YandexGPT API key`.
55
+ 20. В поле `Область действия` выберите `yc.ai.foundationModels.execute`. Если список пустой, начните вводить это значение в поле фильтра и выберите найденный вариант.
56
+ 21. Поле `Срок действия` можно оставить пустым или выбрать дату окончания действия ключа по вашей политике безопасности.
57
+ 22. Нажмите `Создать`.
58
+ 23. Yandex Cloud покажет `Идентификатор ключа` и `Ваш секретный ключ`. Секретный ключ показывается только один раз.
59
+ 24. Скопируйте `Ваш секретный ключ` и сохраните его локально. Это значение будет `YANDEXGPT_API_KEY`.
60
+ 25. Сохраните ключ в CLI:
33
61
 
34
- - документация Foundation Models: `https://yandex.cloud/ru/docs/foundation-models/`;
35
- - аутентификация API: `https://yandex.cloud/ru/docs/ai-studio/api-ref/authentication`;
36
- - тарифы: `https://yandex.cloud/ru/docs/foundation-models/pricing`.
62
+ ```bash
63
+ iola ai key set yandexgpt
64
+ ```
65
+
66
+ CLI попросит ввести:
37
67
 
38
- Для CLI нужны API key и ID каталога Yandex Cloud:
68
+ - `YANDEXGPT_API_KEY` - API-ключ сервисного аккаунта;
69
+ - `YANDEXGPT_FOLDER_ID` - ID каталога Yandex Cloud.
70
+
71
+ Важно: `Идентификатор ключа` - это не API-ключ для CLI. Для CLI нужен именно `Ваш секретный ключ`. Если закрыть окно создания ключа, секретный ключ больше нельзя посмотреть. В этом случае удалите старый API-ключ и создайте новый.
72
+
73
+ После сохранения ключа выберите профиль и модель:
39
74
 
40
75
  ```bash
41
- iola ai key set yandexgpt
42
76
  iola ai setup yandexgpt --model yandexgpt-lite/latest
43
77
  ```
44
78
 
79
+ Через интерактивное меню:
80
+
81
+ ```text
82
+ /model
83
+ 2. Российские AI (YandexGPT/GigaChat)
84
+ 1. YandexGPT API
85
+ ```
86
+
87
+ Доступные модели в CLI:
88
+
89
+ - `yandexgpt-lite/latest` - быстрый и более дешевый вариант;
90
+ - `yandexgpt/latest` - YandexGPT Pro latest;
91
+ - `yandexgpt/rc` - release candidate.
92
+
45
93
  CLI также понимает env-переменные `YANDEXGPT_API_KEY` или `YANDEX_CLOUD_API_KEY`, а для каталога - `YANDEXGPT_FOLDER_ID` или `YANDEX_CLOUD_FOLDER_ID`.
46
94
 
47
95
  ### GigaChat
48
96
 
49
- Официальные страницы:
97
+ Для CLI нужен authorization key. Это не одноразовый OAuth access token: CLI сам получает OAuth-токен перед запросом, используя сохраненный authorization key.
50
98
 
51
- - документация GigaChat: `https://developers.sber.ru/docs/ru/gigachat/overview`;
52
- - получение OAuth-токена: `https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/post-token`;
53
- - тарифы: `https://developers.sber.ru/docs/ru/gigachat/tariffs`.
99
+ Пошаговая настройка:
54
100
 
55
- Для CLI нужен authorization key:
101
+ 1. Перейдите на портал разработчиков Сбера: `https://developers.sber.ru/`.
102
+ 2. Войдите в аккаунт.
103
+ 3. Откройте раздел GigaChat.
104
+ 4. Если нужно, подключите доступ к GigaChat API для физического лица или организации.
105
+ 5. Откройте кабинет/проект, в котором доступны учетные данные GigaChat API.
106
+ 6. Найдите authorization key для REST API. В документации GigaChat он используется в OAuth-запросе в заголовке `Authorization: Basic ...`.
107
+ 7. Скопируйте authorization key и сохраните его локально в CLI:
56
108
 
57
109
  ```bash
58
110
  iola ai key set gigachat
111
+ ```
112
+
113
+ CLI попросит ввести:
114
+
115
+ - `GIGACHAT_AUTH_KEY` - authorization key;
116
+ - `scope` - по умолчанию `GIGACHAT_API_PERS` для персонального доступа.
117
+
118
+ После сохранения ключа выберите профиль и модель:
119
+
120
+ ```bash
59
121
  iola ai setup gigachat --model GigaChat-2
60
122
  ```
61
123
 
124
+ Через интерактивное меню:
125
+
126
+ ```text
127
+ /model
128
+ 2. Российские AI (YandexGPT/GigaChat)
129
+ 2. GigaChat API
130
+ ```
131
+
132
+ Доступные модели в CLI:
133
+
134
+ - `GigaChat-2` - основная модель;
135
+ - `GigaChat-2-Pro` - повышенное качество;
136
+ - `GigaChat-2-Max` - максимальное качество;
137
+ - `GigaChat` - legacy/fallback.
138
+
62
139
  CLI также понимает env-переменные `GIGACHAT_AUTH_KEY` или `GIGACHAT_API_KEY`. По умолчанию используется scope `GIGACHAT_API_PERS`; при необходимости его можно задать через `GIGACHAT_SCOPE`.
63
140
 
64
141
  По тарифам: у GigaChat для физических лиц есть Freemium-лимит на токены; для больших объемов используются платные пакеты. У YandexGPT тарификация идет через Yandex Cloud по токенам и квотам аккаунта, поэтому актуальные бесплатные гранты или лимиты нужно проверять в консоли Yandex Cloud.
65
142
 
143
+ Официальная документация:
144
+
145
+ - Yandex Foundation Models: `https://yandex.cloud/ru/docs/foundation-models/`;
146
+ - Yandex AI Studio authentication: `https://yandex.cloud/ru/docs/ai-studio/api-ref/authentication`;
147
+ - Yandex Foundation Models pricing: `https://yandex.cloud/ru/docs/foundation-models/pricing`;
148
+ - GigaChat overview: `https://developers.sber.ru/docs/ru/gigachat/overview`;
149
+ - GigaChat OAuth token: `https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/post-token`;
150
+ - GigaChat tariffs: `https://developers.sber.ru/docs/ru/gigachat/tariffs`.
151
+
66
152
  ## OpenAI
67
153
 
68
154
  Получение ключа OpenAI Platform: