@iola_adm/iola-cli 0.1.100 → 0.1.102
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 +1 -1
- package/src/cli.js +333 -18
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { appendFile, copyFile, cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { emitKeypressEvents } from "node:readline";
|
|
8
8
|
import readline from "node:readline/promises";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
9
10
|
import { stdin as input, stdout as output } from "node:process";
|
|
10
11
|
import { DatabaseSync } from "node:sqlite";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
@@ -21,9 +22,11 @@ const LAST_GOOD_CONFIG_FILE = path.join(CONFIG_DIR, "config.last-good.json");
|
|
|
21
22
|
const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
|
|
22
23
|
const DB_FILE = path.join(CONFIG_DIR, "iola.db");
|
|
23
24
|
const DB_SCHEMA_VERSION = 8;
|
|
24
|
-
const IOLA_LOCAL_MODEL = "iola-router-
|
|
25
|
-
const IOLA_LOCAL_OLLAMA_MODEL =
|
|
25
|
+
const IOLA_LOCAL_MODEL = "iola-router:qwen3-1.7b-v4-q8";
|
|
26
|
+
const IOLA_LOCAL_OLLAMA_MODEL = IOLA_LOCAL_MODEL;
|
|
26
27
|
const IOLA_ROUTER_HF_REPO = process.env.IOLA_ROUTER_HF_REPO || "LMSerg/iola-1b-router-2026-05-28-merged";
|
|
28
|
+
const IOLA_ROUTER_GGUF_REPO = process.env.IOLA_ROUTER_GGUF_REPO || "LMSerg/iola-router-qwen3-1.7b-v4-gguf";
|
|
29
|
+
const IOLA_ROUTER_GGUF_FILE = process.env.IOLA_ROUTER_GGUF_FILE || "iola-router-qwen3-1.7b-v4-q8_0.gguf";
|
|
27
30
|
const IOLA_MODEL_DIR = path.join(CONFIG_DIR, "models", "router");
|
|
28
31
|
const IOLA_MODEL_RUNTIME_DIR = path.join(CONFIG_DIR, "model-runtime");
|
|
29
32
|
const IOLA_MODEL_RUNNER = path.resolve(__dirname, "iola_hf_runner.py");
|
|
@@ -127,7 +130,10 @@ const DEFAULT_AI_CONFIG = {
|
|
|
127
130
|
local: {
|
|
128
131
|
provider: "iola",
|
|
129
132
|
model: IOLA_LOCAL_MODEL,
|
|
130
|
-
|
|
133
|
+
runtime: "ollama",
|
|
134
|
+
baseUrl: "http://127.0.0.1:11434",
|
|
135
|
+
ggufRepo: IOLA_ROUTER_GGUF_REPO,
|
|
136
|
+
ggufFile: IOLA_ROUTER_GGUF_FILE,
|
|
131
137
|
modelDir: IOLA_MODEL_DIR,
|
|
132
138
|
},
|
|
133
139
|
openai: {
|
|
@@ -469,7 +475,12 @@ async function maybeRefreshIolaModelForCommand(command, args = []) {
|
|
|
469
475
|
const profile = config.ai.profiles?.[getActiveProfileName(config)];
|
|
470
476
|
if (profile?.provider !== "iola" && config.ai.provider !== "iola") return;
|
|
471
477
|
await ensureIolaModelFresh({
|
|
478
|
+
runtime: profile?.runtime,
|
|
472
479
|
repo: profile?.repo || IOLA_ROUTER_HF_REPO,
|
|
480
|
+
ggufRepo: profile?.ggufRepo,
|
|
481
|
+
ggufFile: profile?.ggufFile,
|
|
482
|
+
model: profile?.model,
|
|
483
|
+
baseUrl: profile?.baseUrl,
|
|
473
484
|
modelDir: profile?.modelDir || IOLA_MODEL_DIR,
|
|
474
485
|
quiet: true,
|
|
475
486
|
}).catch((error) => {
|
|
@@ -4503,7 +4514,11 @@ function buildProfileFromOptions(provider, options) {
|
|
|
4503
4514
|
}
|
|
4504
4515
|
|
|
4505
4516
|
if (provider === "iola") {
|
|
4517
|
+
profile.runtime = options.runtime || defaults.runtime || "ollama";
|
|
4518
|
+
profile.baseUrl = options["base-url"] || defaults.baseUrl || "http://127.0.0.1:11434";
|
|
4506
4519
|
profile.repo = options.repo || defaults.repo || IOLA_ROUTER_HF_REPO;
|
|
4520
|
+
profile.ggufRepo = options["gguf-repo"] || defaults.ggufRepo || IOLA_ROUTER_GGUF_REPO;
|
|
4521
|
+
profile.ggufFile = options["gguf-file"] || defaults.ggufFile || IOLA_ROUTER_GGUF_FILE;
|
|
4507
4522
|
profile.modelDir = options["model-dir"] || defaults.modelDir || IOLA_MODEL_DIR;
|
|
4508
4523
|
}
|
|
4509
4524
|
|
|
@@ -4692,7 +4707,7 @@ async function switchModelTarget(target, model) {
|
|
|
4692
4707
|
const config = await loadConfig();
|
|
4693
4708
|
const provider = target === "local" ? "iola" : target;
|
|
4694
4709
|
if (provider === "iola") {
|
|
4695
|
-
await ensureIolaModelFresh({ quiet: false });
|
|
4710
|
+
await ensureIolaModelFresh({ model, quiet: false });
|
|
4696
4711
|
}
|
|
4697
4712
|
if (provider === "ollama") {
|
|
4698
4713
|
const ready = await ensureOllamaModelAvailable(model, config);
|
|
@@ -6160,16 +6175,29 @@ async function setupOllama(args) {
|
|
|
6160
6175
|
async function setupIolaLocal(args) {
|
|
6161
6176
|
const options = parseOptions(args);
|
|
6162
6177
|
const repo = options.repo || IOLA_ROUTER_HF_REPO;
|
|
6178
|
+
const ggufRepo = options["gguf-repo"] || IOLA_ROUTER_GGUF_REPO;
|
|
6179
|
+
const ggufFile = options["gguf-file"] || IOLA_ROUTER_GGUF_FILE;
|
|
6163
6180
|
const modelDir = options["model-dir"] || IOLA_MODEL_DIR;
|
|
6164
6181
|
const profileName = options.name || "local";
|
|
6165
6182
|
const optional = Boolean(options.optional);
|
|
6183
|
+
const runtime = options.runtime || "ollama";
|
|
6184
|
+
const model = options.model || IOLA_LOCAL_MODEL;
|
|
6166
6185
|
|
|
6167
6186
|
if (optional && process.env.CI === "true") {
|
|
6168
6187
|
return;
|
|
6169
6188
|
}
|
|
6170
6189
|
|
|
6171
6190
|
try {
|
|
6172
|
-
await ensureIolaModelFresh({
|
|
6191
|
+
await ensureIolaModelFresh({
|
|
6192
|
+
runtime,
|
|
6193
|
+
repo,
|
|
6194
|
+
ggufRepo,
|
|
6195
|
+
ggufFile,
|
|
6196
|
+
modelDir,
|
|
6197
|
+
model,
|
|
6198
|
+
force: true,
|
|
6199
|
+
quiet: Boolean(options.quiet),
|
|
6200
|
+
});
|
|
6173
6201
|
} catch (error) {
|
|
6174
6202
|
if (!optional) throw error;
|
|
6175
6203
|
console.warn(`IOLA local model не установлена: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -6181,13 +6209,18 @@ async function setupIolaLocal(args) {
|
|
|
6181
6209
|
...config.ai,
|
|
6182
6210
|
activeProfile: profileName,
|
|
6183
6211
|
provider: "iola",
|
|
6184
|
-
model
|
|
6212
|
+
model,
|
|
6213
|
+
baseUrl: "http://127.0.0.1:11434",
|
|
6185
6214
|
profiles: {
|
|
6186
6215
|
...(config.ai.profiles || {}),
|
|
6187
6216
|
[profileName]: {
|
|
6188
6217
|
provider: "iola",
|
|
6189
|
-
model
|
|
6218
|
+
model,
|
|
6219
|
+
runtime,
|
|
6220
|
+
baseUrl: "http://127.0.0.1:11434",
|
|
6190
6221
|
repo,
|
|
6222
|
+
ggufRepo,
|
|
6223
|
+
ggufFile,
|
|
6191
6224
|
modelDir,
|
|
6192
6225
|
},
|
|
6193
6226
|
},
|
|
@@ -6197,9 +6230,9 @@ async function setupIolaLocal(args) {
|
|
|
6197
6230
|
if (options.quiet) return;
|
|
6198
6231
|
console.log("");
|
|
6199
6232
|
console.log("IOLA local mode готов:");
|
|
6200
|
-
console.log(` runtime: Python transformers/peft`);
|
|
6201
|
-
console.log(` model: ${
|
|
6202
|
-
console.log(` Hugging Face: ${repo}`);
|
|
6233
|
+
console.log(` runtime: ${runtime === "transformers" ? "Python transformers/peft" : "Ollama GGUF"}`);
|
|
6234
|
+
console.log(` model: ${model}`);
|
|
6235
|
+
console.log(` Hugging Face: ${runtime === "transformers" ? repo : ggufRepo}`);
|
|
6203
6236
|
console.log(` cache: ${modelDir}`);
|
|
6204
6237
|
console.log(" точные данные: https://apiiola.yasg.ru/api/v1/resolve-entity-field");
|
|
6205
6238
|
}
|
|
@@ -6479,7 +6512,15 @@ async function localToolAsk(question, providerConfig, options) {
|
|
|
6479
6512
|
}
|
|
6480
6513
|
}
|
|
6481
6514
|
const runId = `run-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
6482
|
-
|
|
6515
|
+
let result;
|
|
6516
|
+
try {
|
|
6517
|
+
result = await executeToolPlan(validated, { ...options, runId });
|
|
6518
|
+
} catch (error) {
|
|
6519
|
+
const friendlyError = formatToolExecutionError(error, validated);
|
|
6520
|
+
if (!friendlyError) throw error;
|
|
6521
|
+
if (!options.quiet) console.log(friendlyError);
|
|
6522
|
+
return friendlyError;
|
|
6523
|
+
}
|
|
6483
6524
|
const answer = formatToolResult(result, options);
|
|
6484
6525
|
|
|
6485
6526
|
if (!options["no-history"] && isFeatureEnabled("sqlite-history")) {
|
|
@@ -6653,7 +6694,7 @@ function normalizeIolaRouterPlan(raw, question, options = {}) {
|
|
|
6653
6694
|
if (casualAnswer) return { directAnswer: casualAnswer };
|
|
6654
6695
|
return inferToolPlan(question, options);
|
|
6655
6696
|
}
|
|
6656
|
-
return { steps: [{ tool, args: payload.args || {} }] };
|
|
6697
|
+
return { steps: [{ tool, args: { ...(payload.args || {}), source_question: question } }] };
|
|
6657
6698
|
}
|
|
6658
6699
|
if (payload.action === "direct_answer") {
|
|
6659
6700
|
return { directAnswer: payload.answer || "" };
|
|
@@ -6750,14 +6791,18 @@ async function resolvePublicEntityField(args = {}) {
|
|
|
6750
6791
|
inn: args.inn,
|
|
6751
6792
|
field: requestedField,
|
|
6752
6793
|
must_refute_user_value: args.must_refute_user_value,
|
|
6794
|
+
source_question: args.source_question,
|
|
6753
6795
|
};
|
|
6754
6796
|
try {
|
|
6755
|
-
|
|
6797
|
+
const resolved = await postJson(endpoint, stripInternalResolveArgs(payload));
|
|
6798
|
+
return await correctResolvedEntityByQuestionName(resolved, payload) || resolved;
|
|
6756
6799
|
} catch (error) {
|
|
6757
6800
|
const fallbackField = pickResolveFieldFallback(requestedField, error);
|
|
6758
6801
|
if (fallbackField && fallbackField !== requestedField) {
|
|
6759
6802
|
try {
|
|
6760
|
-
|
|
6803
|
+
const fallbackPayload = { ...payload, field: fallbackField };
|
|
6804
|
+
const resolved = await postJson(endpoint, stripInternalResolveArgs(fallbackPayload));
|
|
6805
|
+
return await correctResolvedEntityByQuestionName(resolved, fallbackPayload) || resolved;
|
|
6761
6806
|
} catch (retryError) {
|
|
6762
6807
|
const resolvedBySearch = await resolvePublicEntityFieldViaSearch({ ...payload, field: fallbackField }, retryError);
|
|
6763
6808
|
if (resolvedBySearch) return resolvedBySearch;
|
|
@@ -6779,12 +6824,84 @@ async function resolvePublicEntityFieldViaSearch(payload, originalError) {
|
|
|
6779
6824
|
const candidates = await searchPublicEntities({ layer: payload.layer, query, limit: 10 });
|
|
6780
6825
|
const candidate = pickResolvedEntityCandidate(candidates, payload);
|
|
6781
6826
|
if (!candidate?.inn) return null;
|
|
6782
|
-
return postJson(`${await getApiBaseUrl()}/resolve-entity-field`, {
|
|
6827
|
+
return postJson(`${await getApiBaseUrl()}/resolve-entity-field`, stripInternalResolveArgs({
|
|
6783
6828
|
layer: payload.layer,
|
|
6784
6829
|
inn: candidate.inn,
|
|
6785
6830
|
field: payload.field,
|
|
6786
6831
|
must_refute_user_value: payload.must_refute_user_value,
|
|
6787
|
-
});
|
|
6832
|
+
}));
|
|
6833
|
+
}
|
|
6834
|
+
|
|
6835
|
+
function stripInternalResolveArgs(payload) {
|
|
6836
|
+
const { source_question: _sourceQuestion, ...publicPayload } = payload || {};
|
|
6837
|
+
return publicPayload;
|
|
6838
|
+
}
|
|
6839
|
+
|
|
6840
|
+
async function correctResolvedEntityByQuestionName(resolved, payload) {
|
|
6841
|
+
const questionNameQuery = extractEntityNameQueryFromQuestion(payload.source_question, payload.layer);
|
|
6842
|
+
if (!questionNameQuery) return null;
|
|
6843
|
+
const resolvedEntity = resolved?.entity || resolved || {};
|
|
6844
|
+
if (entityNameMatchesQuery(resolvedEntity.name, questionNameQuery)) return null;
|
|
6845
|
+
|
|
6846
|
+
const candidates = await searchPublicEntities({ layer: payload.layer, query: questionNameQuery, limit: 5 });
|
|
6847
|
+
const candidate = pickNamedEntityCandidate(candidates, questionNameQuery);
|
|
6848
|
+
if (!candidate?.inn || candidate.inn === resolvedEntity.inn) return null;
|
|
6849
|
+
|
|
6850
|
+
return postJson(`${await getApiBaseUrl()}/resolve-entity-field`, stripInternalResolveArgs({
|
|
6851
|
+
layer: payload.layer,
|
|
6852
|
+
inn: candidate.inn,
|
|
6853
|
+
field: payload.field,
|
|
6854
|
+
must_refute_user_value: payload.must_refute_user_value,
|
|
6855
|
+
}));
|
|
6856
|
+
}
|
|
6857
|
+
|
|
6858
|
+
function extractEntityNameQueryFromQuestion(question, layer) {
|
|
6859
|
+
let text = String(question || "").toLocaleLowerCase("ru-RU");
|
|
6860
|
+
const correction = text.match(/(?:просил|просила|просили)\s+(.+?)\s+а\s+не(?:\s|$)/iu);
|
|
6861
|
+
if (correction?.[1]) text = correction[1];
|
|
6862
|
+
|
|
6863
|
+
const stopWords = new Set([
|
|
6864
|
+
"а", "в", "во", "где", "же", "и", "или", "как", "какая", "какие", "какой", "кто", "на", "не",
|
|
6865
|
+
"найди", "находится", "подскажи", "покажи", "просил", "скажи", "так", "там", "это",
|
|
6866
|
+
"адрес", "директор", "директора", "заведующая", "заведующий", "инн", "почта", "сайт", "телефон",
|
|
6867
|
+
"гимназия", "детсад", "детсада", "детский", "лицей", "мбдоу", "мбоу", "сад", "сада", "садик",
|
|
6868
|
+
"сош", "школа", "школе", "школу", "школы",
|
|
6869
|
+
]);
|
|
6870
|
+
const tokens = [...text.normalize("NFC").matchAll(/[\p{L}\d]+/gu)]
|
|
6871
|
+
.map((match) => normalizeEntityText(match[0]))
|
|
6872
|
+
.filter((token) => token && !stopWords.has(token) && !/^\d+$/.test(token));
|
|
6873
|
+
const uniqueTokens = [...new Set(tokens)];
|
|
6874
|
+
if (uniqueTokens.length === 0) return "";
|
|
6875
|
+
if (uniqueTokens.length === 1 && uniqueTokens[0].length < 5) return "";
|
|
6876
|
+
return uniqueTokens.join(" ");
|
|
6877
|
+
}
|
|
6878
|
+
|
|
6879
|
+
function pickNamedEntityCandidate(candidates, query) {
|
|
6880
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
6881
|
+
const tokens = entityQueryTokens(query);
|
|
6882
|
+
const exact = candidates.find((item) => entityNameMatchesQuery(item.name, query));
|
|
6883
|
+
if (exact) return exact;
|
|
6884
|
+
if (candidates.length === 1 && Number(candidates[0].score || 0) >= 0.5) return candidates[0];
|
|
6885
|
+
return candidates.find((item) => {
|
|
6886
|
+
const name = normalizeEntityText(item.name || "");
|
|
6887
|
+
return Number(item.score || 0) >= 0.8 && tokens.filter((token) => name.includes(token)).length >= Math.ceil(tokens.length / 2);
|
|
6888
|
+
}) || null;
|
|
6889
|
+
}
|
|
6890
|
+
|
|
6891
|
+
function entityNameMatchesQuery(name, query) {
|
|
6892
|
+
const normalizedName = normalizeEntityText(name || "");
|
|
6893
|
+
const tokens = entityQueryTokens(query);
|
|
6894
|
+
return tokens.length > 0 && tokens.every((token) => normalizedName.includes(token));
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6897
|
+
function entityQueryTokens(query) {
|
|
6898
|
+
return [...String(query || "").matchAll(/[\p{L}\d]+/gu)]
|
|
6899
|
+
.map((match) => normalizeEntityText(match[0]))
|
|
6900
|
+
.filter(Boolean);
|
|
6901
|
+
}
|
|
6902
|
+
|
|
6903
|
+
function normalizeEntityText(text) {
|
|
6904
|
+
return String(text || "").toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
|
|
6788
6905
|
}
|
|
6789
6906
|
|
|
6790
6907
|
function buildEntitySearchQuery(layer, number) {
|
|
@@ -6847,6 +6964,24 @@ function parseErrorJsonDetails(error) {
|
|
|
6847
6964
|
}
|
|
6848
6965
|
}
|
|
6849
6966
|
|
|
6967
|
+
function formatToolExecutionError(error, plan) {
|
|
6968
|
+
const details = parseErrorJsonDetails(error);
|
|
6969
|
+
if (details?.error !== "entity_not_found") return "";
|
|
6970
|
+
|
|
6971
|
+
const step = (plan?.steps || []).find((item) => item.tool === "resolve_entity_field" || item.tool === "search_entities");
|
|
6972
|
+
const args = step?.args || {};
|
|
6973
|
+
const layer = normalizeEntityLayer(args.layer);
|
|
6974
|
+
const number = args.entity_number ?? args.number;
|
|
6975
|
+
const name = args.entity_name || args.name;
|
|
6976
|
+
const entityLabel = layer === "kindergartens" ? "детский сад" : "школу";
|
|
6977
|
+
const selector = number !== undefined && number !== null && number !== ""
|
|
6978
|
+
? `${entityLabel} № ${number}`
|
|
6979
|
+
: name
|
|
6980
|
+
? `${entityLabel} "${name}"`
|
|
6981
|
+
: "такую организацию";
|
|
6982
|
+
return `В открытом слое не нашел ${selector}. Проверьте номер или название.`;
|
|
6983
|
+
}
|
|
6984
|
+
|
|
6850
6985
|
function availableToolNames(options = {}) {
|
|
6851
6986
|
const names = new Set(LOCAL_TOOLS);
|
|
6852
6987
|
for (const tool of getLocalMcpToolNames()) names.add(tool);
|
|
@@ -7457,6 +7592,28 @@ async function callAiProvider(config, messages) {
|
|
|
7457
7592
|
}
|
|
7458
7593
|
|
|
7459
7594
|
async function callIolaLocal(config, messages) {
|
|
7595
|
+
if ((config.runtime || "ollama") !== "transformers") {
|
|
7596
|
+
const model = config.model || IOLA_LOCAL_MODEL;
|
|
7597
|
+
await ensureIolaModelFresh({
|
|
7598
|
+
runtime: "ollama",
|
|
7599
|
+
model,
|
|
7600
|
+
baseUrl: config.baseUrl,
|
|
7601
|
+
modelDir: config.modelDir || IOLA_MODEL_DIR,
|
|
7602
|
+
ggufRepo: config.ggufRepo || IOLA_ROUTER_GGUF_REPO,
|
|
7603
|
+
ggufFile: config.ggufFile || IOLA_ROUTER_GGUF_FILE,
|
|
7604
|
+
quiet: true,
|
|
7605
|
+
});
|
|
7606
|
+
const routerMessages = withIolaRouterSystemPrompt(messages);
|
|
7607
|
+
return callOllama({
|
|
7608
|
+
...config,
|
|
7609
|
+
provider: "ollama",
|
|
7610
|
+
model,
|
|
7611
|
+
temperature: 0,
|
|
7612
|
+
numPredict: Number(config.numPredict || 128),
|
|
7613
|
+
qwenNoThink: true,
|
|
7614
|
+
}, routerMessages);
|
|
7615
|
+
}
|
|
7616
|
+
|
|
7460
7617
|
const runtime = await ensureIolaModelRuntime({ quiet: true });
|
|
7461
7618
|
const repo = config.repo || IOLA_ROUTER_HF_REPO;
|
|
7462
7619
|
const modelDir = config.modelDir || IOLA_MODEL_DIR;
|
|
@@ -7482,11 +7639,16 @@ async function callIolaLocal(config, messages) {
|
|
|
7482
7639
|
}
|
|
7483
7640
|
|
|
7484
7641
|
async function hasUsableIolaModel() {
|
|
7642
|
+
if (await hasOllamaModel(IOLA_LOCAL_MODEL)) return true;
|
|
7485
7643
|
const state = readConfigLayerSync(getIolaModelStateFile(IOLA_MODEL_DIR));
|
|
7486
|
-
return Boolean(state?.repo && state?.revision && existsSync(IOLA_MODEL_DIR));
|
|
7644
|
+
return Boolean(state?.runtime === "transformers" && state?.repo && state?.revision && existsSync(IOLA_MODEL_DIR));
|
|
7487
7645
|
}
|
|
7488
7646
|
|
|
7489
7647
|
async function ensureIolaModelFresh(options = {}) {
|
|
7648
|
+
if ((options.runtime || "ollama") !== "transformers") {
|
|
7649
|
+
return ensureIolaOllamaModelFresh(options);
|
|
7650
|
+
}
|
|
7651
|
+
|
|
7490
7652
|
const repo = options.repo || IOLA_ROUTER_HF_REPO;
|
|
7491
7653
|
const modelDir = options.modelDir || IOLA_MODEL_DIR;
|
|
7492
7654
|
await mkdir(modelDir, { recursive: true });
|
|
@@ -7526,6 +7688,142 @@ function getIolaModelStateFile(modelDir = IOLA_MODEL_DIR) {
|
|
|
7526
7688
|
return path.join(modelDir, "manifest.json");
|
|
7527
7689
|
}
|
|
7528
7690
|
|
|
7691
|
+
async function ensureIolaOllamaModelFresh(options = {}) {
|
|
7692
|
+
const model = options.model || IOLA_LOCAL_MODEL;
|
|
7693
|
+
const modelDir = options.modelDir || IOLA_MODEL_DIR;
|
|
7694
|
+
const repo = options.ggufRepo || IOLA_ROUTER_GGUF_REPO;
|
|
7695
|
+
const ggufFile = options.ggufFile || IOLA_ROUTER_GGUF_FILE;
|
|
7696
|
+
const baseUrl = options.baseUrl || "http://127.0.0.1:11434";
|
|
7697
|
+
const ollamaCommand = await resolveOllamaCommand();
|
|
7698
|
+
if (!ollamaCommand) {
|
|
7699
|
+
throw new Error("Ollama не найден. Установите Ollama и повторите: iola ai setup iola --yes");
|
|
7700
|
+
}
|
|
7701
|
+
|
|
7702
|
+
await mkdir(modelDir, { recursive: true });
|
|
7703
|
+
const stateFile = getIolaModelStateFile(modelDir);
|
|
7704
|
+
const state = readConfigLayerSync(stateFile) || {};
|
|
7705
|
+
const remote = await getRemoteIolaModelRevision(repo).catch(() => null);
|
|
7706
|
+
const installed = await hasOllamaModel(model, baseUrl);
|
|
7707
|
+
if (installed && !options.force && (!state.revision || state.runtime !== "ollama" || state.model !== model || state.repo !== repo)) {
|
|
7708
|
+
const nextState = {
|
|
7709
|
+
repo,
|
|
7710
|
+
ggufFile,
|
|
7711
|
+
revision: remote?.sha || state.revision || `local-${Date.now()}`,
|
|
7712
|
+
installedAt: state.installedAt || new Date().toISOString(),
|
|
7713
|
+
runtime: "ollama",
|
|
7714
|
+
model,
|
|
7715
|
+
};
|
|
7716
|
+
await writeFile(stateFile, `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
|
|
7717
|
+
return nextState;
|
|
7718
|
+
}
|
|
7719
|
+
const stale = options.force || !installed || state.runtime !== "ollama" || state.model !== model || state.repo !== repo || !state.revision || (remote?.sha && remote.sha !== state.revision);
|
|
7720
|
+
if (!stale) return state;
|
|
7721
|
+
|
|
7722
|
+
if (!options.quiet) {
|
|
7723
|
+
const reason = installed ? "обновляю" : "устанавливаю";
|
|
7724
|
+
console.log(`IOLA local model: ${reason} ${model}`);
|
|
7725
|
+
console.log(`Источник GGUF: https://huggingface.co/${repo}`);
|
|
7726
|
+
}
|
|
7727
|
+
|
|
7728
|
+
const ggufPath = path.join(modelDir, ggufFile);
|
|
7729
|
+
const modelfilePath = path.join(modelDir, "Modelfile");
|
|
7730
|
+
const url = `https://huggingface.co/${repo}/resolve/main/${encodeURIComponent(ggufFile)}`;
|
|
7731
|
+
if (options.force || !existsSync(ggufPath)) {
|
|
7732
|
+
await downloadFile(url, ggufPath, { quiet: options.quiet });
|
|
7733
|
+
}
|
|
7734
|
+
|
|
7735
|
+
await writeFile(modelfilePath, buildIolaOllamaModelfile(ggufPath), "utf8");
|
|
7736
|
+
await runCommand(ollamaCommand, ["create", model, "-f", modelfilePath], { inherit: !options.quiet });
|
|
7737
|
+
|
|
7738
|
+
const nextState = {
|
|
7739
|
+
repo,
|
|
7740
|
+
ggufFile,
|
|
7741
|
+
revision: remote?.sha || state.revision || `local-${Date.now()}`,
|
|
7742
|
+
installedAt: new Date().toISOString(),
|
|
7743
|
+
runtime: "ollama",
|
|
7744
|
+
model,
|
|
7745
|
+
};
|
|
7746
|
+
await writeFile(stateFile, `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
|
|
7747
|
+
return nextState;
|
|
7748
|
+
}
|
|
7749
|
+
|
|
7750
|
+
async function hasOllamaModel(model, baseUrl = "http://127.0.0.1:11434") {
|
|
7751
|
+
try {
|
|
7752
|
+
const response = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(2000) });
|
|
7753
|
+
if (!response.ok) return false;
|
|
7754
|
+
const payload = await response.json();
|
|
7755
|
+
return (payload.models || []).some((item) => item.name === model);
|
|
7756
|
+
} catch {
|
|
7757
|
+
return false;
|
|
7758
|
+
}
|
|
7759
|
+
}
|
|
7760
|
+
|
|
7761
|
+
async function downloadFile(url, targetPath, options = {}) {
|
|
7762
|
+
const response = await fetch(url, {
|
|
7763
|
+
headers: { "user-agent": "@iola_adm/iola-cli" },
|
|
7764
|
+
});
|
|
7765
|
+
if (!response.ok || !response.body) {
|
|
7766
|
+
throw new Error(`Не удалось скачать модель: ${response.status} ${response.statusText} (${url})`);
|
|
7767
|
+
}
|
|
7768
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
7769
|
+
if (!options.quiet) console.log(`Скачиваю модель: ${targetPath}`);
|
|
7770
|
+
await new Promise((resolve, reject) => {
|
|
7771
|
+
const file = createWriteStream(targetPath);
|
|
7772
|
+
Readable.fromWeb(response.body).pipe(file);
|
|
7773
|
+
file.on("finish", resolve);
|
|
7774
|
+
file.on("error", reject);
|
|
7775
|
+
});
|
|
7776
|
+
}
|
|
7777
|
+
|
|
7778
|
+
function buildIolaOllamaModelfile(ggufPath) {
|
|
7779
|
+
return `FROM ${ggufPath}
|
|
7780
|
+
|
|
7781
|
+
TEMPLATE """{{- if .System }}<|im_start|>system
|
|
7782
|
+
{{ .System }}<|im_end|>
|
|
7783
|
+
{{ end -}}
|
|
7784
|
+
{{- range .Messages }}
|
|
7785
|
+
{{- if eq .Role "user" }}<|im_start|>user
|
|
7786
|
+
{{ .Content }}<|im_end|>
|
|
7787
|
+
{{ else if eq .Role "assistant" }}<|im_start|>assistant
|
|
7788
|
+
{{ .Content }}<|im_end|>
|
|
7789
|
+
{{ end -}}
|
|
7790
|
+
{{ end -}}
|
|
7791
|
+
<|im_start|>assistant
|
|
7792
|
+
"""
|
|
7793
|
+
|
|
7794
|
+
PARAMETER temperature 0
|
|
7795
|
+
PARAMETER repeat_penalty 1
|
|
7796
|
+
PARAMETER stop <|im_start|>
|
|
7797
|
+
PARAMETER stop <|im_end|>
|
|
7798
|
+
`;
|
|
7799
|
+
}
|
|
7800
|
+
|
|
7801
|
+
function withIolaRouterSystemPrompt(messages = []) {
|
|
7802
|
+
const normalized = messages.map((message) => ({ ...message }));
|
|
7803
|
+
const hasSystem = normalized.some((message) => message.role === "system");
|
|
7804
|
+
const withNoThink = normalized.map((message, index) => {
|
|
7805
|
+
if (message.role === "user" && index === normalized.findLastIndex((item) => item.role === "user")) {
|
|
7806
|
+
return { ...message, content: `${message.content}\n/no_think` };
|
|
7807
|
+
}
|
|
7808
|
+
return message;
|
|
7809
|
+
});
|
|
7810
|
+
return hasSystem ? withNoThink : [{ role: "system", content: IOLA_ROUTER_SYSTEM_PROMPT }, ...withNoThink];
|
|
7811
|
+
}
|
|
7812
|
+
|
|
7813
|
+
const IOLA_ROUTER_SYSTEM_PROMPT = `You are the IOLA CLI router for public open data of Yoshkar-Ola.
|
|
7814
|
+
Return only valid JSON. No markdown, no prose.
|
|
7815
|
+
Allowed actions:
|
|
7816
|
+
- {"action":"tool_call","tool":"resolve_entity_field","args":{"layer":"schools|kindergartens","entity_number":1,"field":"address|phone|email|website|inn|head|license_status"}}
|
|
7817
|
+
- {"action":"tool_call","tool":"search_entities","args":{"layer":"schools|kindergartens","query":"..."}}
|
|
7818
|
+
- {"action":"tool_call","tool":"rag_search","args":{"query":"...","collections":["city_history","official_documents"]}}
|
|
7819
|
+
- {"action":"tool_call","tool":"get_current_official","args":{"jurisdiction":"yoshkar_ola","office_query":"..."}}
|
|
7820
|
+
- {"action":"tool_call","tool":"get_official_by_date","args":{"jurisdiction":"yoshkar_ola","office_query":"...","date":"YYYY or date"}}
|
|
7821
|
+
- {"action":"clarify","question":"..."}
|
|
7822
|
+
- {"action":"refuse","reason":"field_not_public"}
|
|
7823
|
+
- {"action":"direct_answer","answer":"..."}
|
|
7824
|
+
Never invent current officials, salaries, private data, addresses, phones, websites, heads, or license statuses.
|
|
7825
|
+
Use tool calls for public entity data. Use refuse for non-public fields.`;
|
|
7826
|
+
|
|
7529
7827
|
async function ensureIolaModelRuntime(options = {}) {
|
|
7530
7828
|
const python = await getIolaRuntimePython();
|
|
7531
7829
|
if (python && await checkIolaPythonDeps(python)) return { python };
|
|
@@ -7652,8 +7950,10 @@ async function callOllama(config, messages) {
|
|
|
7652
7950
|
model: config.model || "llama3.2:1b",
|
|
7653
7951
|
messages,
|
|
7654
7952
|
stream: false,
|
|
7953
|
+
think: config.qwenNoThink ? false : undefined,
|
|
7655
7954
|
options: {
|
|
7656
7955
|
temperature: Number(config.temperature ?? 0.1),
|
|
7956
|
+
num_predict: config.numPredict ? Number(config.numPredict) : undefined,
|
|
7657
7957
|
},
|
|
7658
7958
|
}),
|
|
7659
7959
|
});
|
|
@@ -10027,6 +10327,21 @@ function sanitizeConfig(config) {
|
|
|
10027
10327
|
if (Array.isArray(next.skills?.enabled) && next.skills.enabled.includes("open-data") && !next.skills.enabled.includes("education")) {
|
|
10028
10328
|
next.skills.enabled = ["education", ...next.skills.enabled];
|
|
10029
10329
|
}
|
|
10330
|
+
const localProfile = next.ai?.profiles?.local;
|
|
10331
|
+
if (localProfile?.provider === "iola") {
|
|
10332
|
+
if (!localProfile.runtime || localProfile.model === "iola-router-1b") {
|
|
10333
|
+
localProfile.runtime = "ollama";
|
|
10334
|
+
localProfile.model = IOLA_LOCAL_MODEL;
|
|
10335
|
+
localProfile.baseUrl = localProfile.baseUrl || "http://127.0.0.1:11434";
|
|
10336
|
+
localProfile.ggufRepo = localProfile.ggufRepo || IOLA_ROUTER_GGUF_REPO;
|
|
10337
|
+
localProfile.ggufFile = localProfile.ggufFile || IOLA_ROUTER_GGUF_FILE;
|
|
10338
|
+
localProfile.modelDir = localProfile.modelDir || IOLA_MODEL_DIR;
|
|
10339
|
+
}
|
|
10340
|
+
}
|
|
10341
|
+
if (next.ai?.activeProfile === "local" && next.ai.provider === "iola" && next.ai.model === "iola-router-1b") {
|
|
10342
|
+
next.ai.model = IOLA_LOCAL_MODEL;
|
|
10343
|
+
next.ai.baseUrl = next.ai.baseUrl || "http://127.0.0.1:11434";
|
|
10344
|
+
}
|
|
10030
10345
|
return next;
|
|
10031
10346
|
}
|
|
10032
10347
|
|