@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
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 || [];
|
package/test/smoke-test.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
```bash
|
|
63
|
+
iola ai key set yandexgpt
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
CLI попросит ввести:
|
|
37
67
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|