@iola_adm/iola-cli 0.1.98 → 0.1.101
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 +391 -22
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) => {
|
|
@@ -899,8 +910,7 @@ async function startAgentRawInput() {
|
|
|
899
910
|
}
|
|
900
911
|
} finally {
|
|
901
912
|
clearAgentInputArea(state);
|
|
902
|
-
|
|
903
|
-
finishAgentTerminalLine();
|
|
913
|
+
finishAgentTerminalLine(state);
|
|
904
914
|
if (!wasRaw) input.setRawMode(false);
|
|
905
915
|
input.pause();
|
|
906
916
|
}
|
|
@@ -1425,9 +1435,21 @@ function clearAgentStatusBar(state) {
|
|
|
1425
1435
|
state.statusRows = 0;
|
|
1426
1436
|
}
|
|
1427
1437
|
|
|
1428
|
-
function finishAgentTerminalLine() {
|
|
1438
|
+
function finishAgentTerminalLine(state = null) {
|
|
1429
1439
|
if (!output.isTTY) return;
|
|
1430
|
-
output.
|
|
1440
|
+
const rows = Number(output.rows || state?.statusRows || 0);
|
|
1441
|
+
output.write("\x1b[r");
|
|
1442
|
+
if (rows >= 1) {
|
|
1443
|
+
output.write(`\x1b[${rows};1H\x1b[2K\n`);
|
|
1444
|
+
} else {
|
|
1445
|
+
output.write("\r\x1b[0K\n");
|
|
1446
|
+
}
|
|
1447
|
+
if (state) {
|
|
1448
|
+
state.statusBar = false;
|
|
1449
|
+
state.statusRows = 0;
|
|
1450
|
+
state.renderedInputLines = 0;
|
|
1451
|
+
state.renderedLines = 0;
|
|
1452
|
+
}
|
|
1431
1453
|
}
|
|
1432
1454
|
|
|
1433
1455
|
function startActivityIndicator(label = "работаю") {
|
|
@@ -4492,7 +4514,11 @@ function buildProfileFromOptions(provider, options) {
|
|
|
4492
4514
|
}
|
|
4493
4515
|
|
|
4494
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";
|
|
4495
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;
|
|
4496
4522
|
profile.modelDir = options["model-dir"] || defaults.modelDir || IOLA_MODEL_DIR;
|
|
4497
4523
|
}
|
|
4498
4524
|
|
|
@@ -4681,7 +4707,7 @@ async function switchModelTarget(target, model) {
|
|
|
4681
4707
|
const config = await loadConfig();
|
|
4682
4708
|
const provider = target === "local" ? "iola" : target;
|
|
4683
4709
|
if (provider === "iola") {
|
|
4684
|
-
await ensureIolaModelFresh({ quiet: false });
|
|
4710
|
+
await ensureIolaModelFresh({ model, quiet: false });
|
|
4685
4711
|
}
|
|
4686
4712
|
if (provider === "ollama") {
|
|
4687
4713
|
const ready = await ensureOllamaModelAvailable(model, config);
|
|
@@ -6149,16 +6175,29 @@ async function setupOllama(args) {
|
|
|
6149
6175
|
async function setupIolaLocal(args) {
|
|
6150
6176
|
const options = parseOptions(args);
|
|
6151
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;
|
|
6152
6180
|
const modelDir = options["model-dir"] || IOLA_MODEL_DIR;
|
|
6153
6181
|
const profileName = options.name || "local";
|
|
6154
6182
|
const optional = Boolean(options.optional);
|
|
6183
|
+
const runtime = options.runtime || "ollama";
|
|
6184
|
+
const model = options.model || IOLA_LOCAL_MODEL;
|
|
6155
6185
|
|
|
6156
6186
|
if (optional && process.env.CI === "true") {
|
|
6157
6187
|
return;
|
|
6158
6188
|
}
|
|
6159
6189
|
|
|
6160
6190
|
try {
|
|
6161
|
-
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
|
+
});
|
|
6162
6201
|
} catch (error) {
|
|
6163
6202
|
if (!optional) throw error;
|
|
6164
6203
|
console.warn(`IOLA local model не установлена: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -6170,13 +6209,18 @@ async function setupIolaLocal(args) {
|
|
|
6170
6209
|
...config.ai,
|
|
6171
6210
|
activeProfile: profileName,
|
|
6172
6211
|
provider: "iola",
|
|
6173
|
-
model
|
|
6212
|
+
model,
|
|
6213
|
+
baseUrl: "http://127.0.0.1:11434",
|
|
6174
6214
|
profiles: {
|
|
6175
6215
|
...(config.ai.profiles || {}),
|
|
6176
6216
|
[profileName]: {
|
|
6177
6217
|
provider: "iola",
|
|
6178
|
-
model
|
|
6218
|
+
model,
|
|
6219
|
+
runtime,
|
|
6220
|
+
baseUrl: "http://127.0.0.1:11434",
|
|
6179
6221
|
repo,
|
|
6222
|
+
ggufRepo,
|
|
6223
|
+
ggufFile,
|
|
6180
6224
|
modelDir,
|
|
6181
6225
|
},
|
|
6182
6226
|
},
|
|
@@ -6186,9 +6230,9 @@ async function setupIolaLocal(args) {
|
|
|
6186
6230
|
if (options.quiet) return;
|
|
6187
6231
|
console.log("");
|
|
6188
6232
|
console.log("IOLA local mode готов:");
|
|
6189
|
-
console.log(` runtime: Python transformers/peft`);
|
|
6190
|
-
console.log(` model: ${
|
|
6191
|
-
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}`);
|
|
6192
6236
|
console.log(` cache: ${modelDir}`);
|
|
6193
6237
|
console.log(" точные данные: https://apiiola.yasg.ru/api/v1/resolve-entity-field");
|
|
6194
6238
|
}
|
|
@@ -6302,7 +6346,7 @@ function buildDirectDataAnswer(question, dataContext) {
|
|
|
6302
6346
|
|
|
6303
6347
|
function detectDirectDataFields(normalizedQuestion) {
|
|
6304
6348
|
const fields = [];
|
|
6305
|
-
if (/(
|
|
6349
|
+
if (/(директ|руководител|заведующ|кто возглавляет)/iu.test(normalizedQuestion)) fields.push("head");
|
|
6306
6350
|
if (/(сайт|website|url|ссылка)/iu.test(normalizedQuestion)) fields.push("website");
|
|
6307
6351
|
if (/(телефон|номер телефона|позвонить)/iu.test(normalizedQuestion)) fields.push("phone");
|
|
6308
6352
|
if (/(почт|email|e-mail|имейл|электронн)/iu.test(normalizedQuestion)) fields.push("email");
|
|
@@ -6448,6 +6492,11 @@ async function localToolAsk(question, providerConfig, options) {
|
|
|
6448
6492
|
return casualAnswer;
|
|
6449
6493
|
}
|
|
6450
6494
|
await ensureLocalData();
|
|
6495
|
+
const personRoleAnswer = buildPersonRoleDirectAnswer(question);
|
|
6496
|
+
if (personRoleAnswer) {
|
|
6497
|
+
if (!options.quiet) console.log(personRoleAnswer);
|
|
6498
|
+
return personRoleAnswer;
|
|
6499
|
+
}
|
|
6451
6500
|
const plan = await buildLocalToolPlan(question, providerConfig, options);
|
|
6452
6501
|
if (plan.directAnswer) {
|
|
6453
6502
|
if (!options.quiet) console.log(plan.directAnswer);
|
|
@@ -6524,6 +6573,68 @@ function buildCasualDirectAnswer(question) {
|
|
|
6524
6573
|
return "";
|
|
6525
6574
|
}
|
|
6526
6575
|
|
|
6576
|
+
function buildPersonRoleDirectAnswer(question) {
|
|
6577
|
+
const normalized = String(question || "").toLocaleLowerCase("ru-RU");
|
|
6578
|
+
const asksHead = /(директ|руководител|заведующ|возглавляет)/iu.test(normalized);
|
|
6579
|
+
const asksOrganization = /(какой|какая|где|чего|организац|учрежден|школ|сад|детсад|гимнази|лицей)/iu.test(normalized);
|
|
6580
|
+
if (!asksHead || !asksOrganization) return "";
|
|
6581
|
+
|
|
6582
|
+
const nameTokens = extractPersonNameTokens(question);
|
|
6583
|
+
if (nameTokens.length < 2) return "";
|
|
6584
|
+
|
|
6585
|
+
const dataset = normalized.includes("сад") || normalized.includes("детсад")
|
|
6586
|
+
? "kindergartens"
|
|
6587
|
+
: normalized.includes("школ") || normalized.includes("гимнази") || normalized.includes("лицей")
|
|
6588
|
+
? "schools"
|
|
6589
|
+
: "all";
|
|
6590
|
+
const rows = searchLocalRecords("", { dataset, limit: 10000 })
|
|
6591
|
+
.filter((row) => personTokensMatchHead(row.head, nameTokens));
|
|
6592
|
+
|
|
6593
|
+
if (rows.length === 0) {
|
|
6594
|
+
return `В открытых данных не нашел руководителя с ФИО: ${formatPersonTokens(nameTokens)}.`;
|
|
6595
|
+
}
|
|
6596
|
+
|
|
6597
|
+
const exactRows = rows.filter((row) => personTokensMatchHead(row.head, nameTokens, { strict: true }));
|
|
6598
|
+
const matches = exactRows.length > 0 ? exactRows : rows;
|
|
6599
|
+
if (matches.length === 1) {
|
|
6600
|
+
const row = matches[0];
|
|
6601
|
+
return [
|
|
6602
|
+
`${row.head} является руководителем: ${row.name}.`,
|
|
6603
|
+
row.address ? `Адрес: ${row.address}` : "",
|
|
6604
|
+
row.inn ? `ИНН: ${row.inn}` : "",
|
|
6605
|
+
"Источник: открытый слой образования.",
|
|
6606
|
+
].filter(Boolean).join("\n");
|
|
6607
|
+
}
|
|
6608
|
+
|
|
6609
|
+
return [
|
|
6610
|
+
`Нашел несколько организаций для ФИО ${formatPersonTokens(nameTokens)}:`,
|
|
6611
|
+
...matches.slice(0, 10).map((row) => `- ${row.head}: ${row.name}${row.inn ? `, ИНН ${row.inn}` : ""}`),
|
|
6612
|
+
].join("\n");
|
|
6613
|
+
}
|
|
6614
|
+
|
|
6615
|
+
function extractPersonNameTokens(question) {
|
|
6616
|
+
const stopWords = new Set([
|
|
6617
|
+
"так", "а", "и", "или", "кто", "что", "какой", "какая", "какого", "какой-то", "это",
|
|
6618
|
+
"директор", "директора", "директором", "руководитель", "руководителем", "заведующая", "заведующий",
|
|
6619
|
+
"школа", "школы", "школе", "сад", "сада", "детский", "детского", "гимназия", "лицей",
|
|
6620
|
+
"является", "возглавляет", "найди", "покажи",
|
|
6621
|
+
]);
|
|
6622
|
+
return [...String(question || "").toLocaleLowerCase("ru-RU").matchAll(/\p{L}{3,}/gu)]
|
|
6623
|
+
.map((match) => match[0])
|
|
6624
|
+
.filter((token) => !stopWords.has(token));
|
|
6625
|
+
}
|
|
6626
|
+
|
|
6627
|
+
function personTokensMatchHead(head, tokens, options = {}) {
|
|
6628
|
+
const headTokens = new Set([...String(head || "").toLocaleLowerCase("ru-RU").matchAll(/\p{L}{3,}/gu)].map((match) => match[0]));
|
|
6629
|
+
if (options.strict) return tokens.every((token) => headTokens.has(token));
|
|
6630
|
+
const headText = [...headTokens].join(" ");
|
|
6631
|
+
return tokens.every((token) => headTokens.has(token) || headText.includes(token));
|
|
6632
|
+
}
|
|
6633
|
+
|
|
6634
|
+
function formatPersonTokens(tokens) {
|
|
6635
|
+
return tokens.map(capitalizeFirst).join(" ");
|
|
6636
|
+
}
|
|
6637
|
+
|
|
6527
6638
|
function printToolPlan(plan) {
|
|
6528
6639
|
console.log("План выполнения:");
|
|
6529
6640
|
plan.steps.forEach((step, index) => {
|
|
@@ -6662,15 +6773,68 @@ async function searchPublicEntities(args = {}) {
|
|
|
6662
6773
|
}
|
|
6663
6774
|
|
|
6664
6775
|
async function resolvePublicEntityField(args = {}) {
|
|
6665
|
-
const
|
|
6666
|
-
|
|
6776
|
+
const endpoint = `${await getApiBaseUrl()}/resolve-entity-field`;
|
|
6777
|
+
const requestedField = normalizeEntityField(args.field);
|
|
6778
|
+
const layer = normalizeEntityLayer(args.layer);
|
|
6779
|
+
const payload = {
|
|
6780
|
+
layer,
|
|
6667
6781
|
entity_number: args.entity_number ?? args.number,
|
|
6668
6782
|
entity_name: args.entity_name || args.name,
|
|
6669
6783
|
inn: args.inn,
|
|
6670
|
-
field:
|
|
6784
|
+
field: requestedField,
|
|
6671
6785
|
must_refute_user_value: args.must_refute_user_value,
|
|
6786
|
+
};
|
|
6787
|
+
try {
|
|
6788
|
+
return await postJson(endpoint, payload);
|
|
6789
|
+
} catch (error) {
|
|
6790
|
+
const fallbackField = pickResolveFieldFallback(requestedField, error);
|
|
6791
|
+
if (fallbackField && fallbackField !== requestedField) {
|
|
6792
|
+
try {
|
|
6793
|
+
return await postJson(endpoint, { ...payload, field: fallbackField });
|
|
6794
|
+
} catch (retryError) {
|
|
6795
|
+
const resolvedBySearch = await resolvePublicEntityFieldViaSearch({ ...payload, field: fallbackField }, retryError);
|
|
6796
|
+
if (resolvedBySearch) return resolvedBySearch;
|
|
6797
|
+
throw retryError;
|
|
6798
|
+
}
|
|
6799
|
+
}
|
|
6800
|
+
const resolvedBySearch = await resolvePublicEntityFieldViaSearch(payload, error);
|
|
6801
|
+
if (resolvedBySearch) return resolvedBySearch;
|
|
6802
|
+
throw error;
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
|
|
6806
|
+
async function resolvePublicEntityFieldViaSearch(payload, originalError) {
|
|
6807
|
+
const details = parseErrorJsonDetails(originalError);
|
|
6808
|
+
if (details?.error !== "entity_not_found") return null;
|
|
6809
|
+
if (payload.inn) return null;
|
|
6810
|
+
const query = payload.entity_name || buildEntitySearchQuery(payload.layer, payload.entity_number);
|
|
6811
|
+
if (!query) return null;
|
|
6812
|
+
const candidates = await searchPublicEntities({ layer: payload.layer, query, limit: 10 });
|
|
6813
|
+
const candidate = pickResolvedEntityCandidate(candidates, payload);
|
|
6814
|
+
if (!candidate?.inn) return null;
|
|
6815
|
+
return postJson(`${await getApiBaseUrl()}/resolve-entity-field`, {
|
|
6816
|
+
layer: payload.layer,
|
|
6817
|
+
inn: candidate.inn,
|
|
6818
|
+
field: payload.field,
|
|
6819
|
+
must_refute_user_value: payload.must_refute_user_value,
|
|
6672
6820
|
});
|
|
6673
|
-
|
|
6821
|
+
}
|
|
6822
|
+
|
|
6823
|
+
function buildEntitySearchQuery(layer, number) {
|
|
6824
|
+
if (number === undefined || number === null || number === "") return "";
|
|
6825
|
+
const label = layer === "kindergartens" ? "детский сад" : "школа";
|
|
6826
|
+
return `${label} ${number}`;
|
|
6827
|
+
}
|
|
6828
|
+
|
|
6829
|
+
function pickResolvedEntityCandidate(candidates, payload) {
|
|
6830
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
6831
|
+
const number = payload.entity_number === undefined || payload.entity_number === null ? "" : String(payload.entity_number);
|
|
6832
|
+
if (number) {
|
|
6833
|
+
const exact = candidates.find((item) => itemNameHasNumber(item, number));
|
|
6834
|
+
if (exact) return exact;
|
|
6835
|
+
}
|
|
6836
|
+
if (candidates.length === 1) return candidates[0];
|
|
6837
|
+
return candidates.find((item) => Number(item.score || 0) > 0) || candidates[0];
|
|
6674
6838
|
}
|
|
6675
6839
|
|
|
6676
6840
|
function normalizeEntityLayer(layer) {
|
|
@@ -6682,7 +6846,7 @@ function normalizeEntityLayer(layer) {
|
|
|
6682
6846
|
|
|
6683
6847
|
function normalizeEntityField(field) {
|
|
6684
6848
|
const value = String(field || "").toLocaleLowerCase("ru-RU");
|
|
6685
|
-
if (value === "director" || value === "head" || value.includes("
|
|
6849
|
+
if (value === "director" || value === "directors" || value === "directs" || value === "direct" || value === "head" || value === "head_name" || value.includes("директ") || value.includes("руковод")) return "director";
|
|
6686
6850
|
if (value === "site" || value === "url" || value === "website" || value.includes("сайт")) return "website";
|
|
6687
6851
|
if (value === "mail" || value === "email" || value.includes("почт")) return "email";
|
|
6688
6852
|
if (value === "phone" || value.includes("тел")) return "phone";
|
|
@@ -6691,6 +6855,31 @@ function normalizeEntityField(field) {
|
|
|
6691
6855
|
return value || "name";
|
|
6692
6856
|
}
|
|
6693
6857
|
|
|
6858
|
+
function pickResolveFieldFallback(requestedField, error) {
|
|
6859
|
+
const details = parseErrorJsonDetails(error);
|
|
6860
|
+
if (details?.error !== "field_not_public") return "";
|
|
6861
|
+
const publicFields = new Set((details.public_fields || []).map((field) => String(field)));
|
|
6862
|
+
const aliases = {
|
|
6863
|
+
director: ["director", "head_name", "head"],
|
|
6864
|
+
head: ["director", "head_name", "head"],
|
|
6865
|
+
head_name: ["director", "head_name", "head"],
|
|
6866
|
+
license_status: ["license_status", "license_number", "license_date"],
|
|
6867
|
+
}[requestedField] || [];
|
|
6868
|
+
return aliases.find((field) => field !== requestedField && publicFields.has(field)) || "";
|
|
6869
|
+
}
|
|
6870
|
+
|
|
6871
|
+
function parseErrorJsonDetails(error) {
|
|
6872
|
+
const text = error instanceof Error ? error.message : String(error || "");
|
|
6873
|
+
const match = text.match(/\{[\s\S]*\}$/);
|
|
6874
|
+
if (!match) return null;
|
|
6875
|
+
try {
|
|
6876
|
+
const parsed = JSON.parse(match[0]);
|
|
6877
|
+
return parsed.detail || parsed;
|
|
6878
|
+
} catch {
|
|
6879
|
+
return null;
|
|
6880
|
+
}
|
|
6881
|
+
}
|
|
6882
|
+
|
|
6694
6883
|
function availableToolNames(options = {}) {
|
|
6695
6884
|
const names = new Set(LOCAL_TOOLS);
|
|
6696
6885
|
for (const tool of getLocalMcpToolNames()) names.add(tool);
|
|
@@ -7301,6 +7490,28 @@ async function callAiProvider(config, messages) {
|
|
|
7301
7490
|
}
|
|
7302
7491
|
|
|
7303
7492
|
async function callIolaLocal(config, messages) {
|
|
7493
|
+
if ((config.runtime || "ollama") !== "transformers") {
|
|
7494
|
+
const model = config.model || IOLA_LOCAL_MODEL;
|
|
7495
|
+
await ensureIolaModelFresh({
|
|
7496
|
+
runtime: "ollama",
|
|
7497
|
+
model,
|
|
7498
|
+
baseUrl: config.baseUrl,
|
|
7499
|
+
modelDir: config.modelDir || IOLA_MODEL_DIR,
|
|
7500
|
+
ggufRepo: config.ggufRepo || IOLA_ROUTER_GGUF_REPO,
|
|
7501
|
+
ggufFile: config.ggufFile || IOLA_ROUTER_GGUF_FILE,
|
|
7502
|
+
quiet: true,
|
|
7503
|
+
});
|
|
7504
|
+
const routerMessages = withIolaRouterSystemPrompt(messages);
|
|
7505
|
+
return callOllama({
|
|
7506
|
+
...config,
|
|
7507
|
+
provider: "ollama",
|
|
7508
|
+
model,
|
|
7509
|
+
temperature: 0,
|
|
7510
|
+
numPredict: Number(config.numPredict || 128),
|
|
7511
|
+
qwenNoThink: true,
|
|
7512
|
+
}, routerMessages);
|
|
7513
|
+
}
|
|
7514
|
+
|
|
7304
7515
|
const runtime = await ensureIolaModelRuntime({ quiet: true });
|
|
7305
7516
|
const repo = config.repo || IOLA_ROUTER_HF_REPO;
|
|
7306
7517
|
const modelDir = config.modelDir || IOLA_MODEL_DIR;
|
|
@@ -7326,11 +7537,16 @@ async function callIolaLocal(config, messages) {
|
|
|
7326
7537
|
}
|
|
7327
7538
|
|
|
7328
7539
|
async function hasUsableIolaModel() {
|
|
7540
|
+
if (await hasOllamaModel(IOLA_LOCAL_MODEL)) return true;
|
|
7329
7541
|
const state = readConfigLayerSync(getIolaModelStateFile(IOLA_MODEL_DIR));
|
|
7330
|
-
return Boolean(state?.repo && state?.revision && existsSync(IOLA_MODEL_DIR));
|
|
7542
|
+
return Boolean(state?.runtime === "transformers" && state?.repo && state?.revision && existsSync(IOLA_MODEL_DIR));
|
|
7331
7543
|
}
|
|
7332
7544
|
|
|
7333
7545
|
async function ensureIolaModelFresh(options = {}) {
|
|
7546
|
+
if ((options.runtime || "ollama") !== "transformers") {
|
|
7547
|
+
return ensureIolaOllamaModelFresh(options);
|
|
7548
|
+
}
|
|
7549
|
+
|
|
7334
7550
|
const repo = options.repo || IOLA_ROUTER_HF_REPO;
|
|
7335
7551
|
const modelDir = options.modelDir || IOLA_MODEL_DIR;
|
|
7336
7552
|
await mkdir(modelDir, { recursive: true });
|
|
@@ -7370,6 +7586,142 @@ function getIolaModelStateFile(modelDir = IOLA_MODEL_DIR) {
|
|
|
7370
7586
|
return path.join(modelDir, "manifest.json");
|
|
7371
7587
|
}
|
|
7372
7588
|
|
|
7589
|
+
async function ensureIolaOllamaModelFresh(options = {}) {
|
|
7590
|
+
const model = options.model || IOLA_LOCAL_MODEL;
|
|
7591
|
+
const modelDir = options.modelDir || IOLA_MODEL_DIR;
|
|
7592
|
+
const repo = options.ggufRepo || IOLA_ROUTER_GGUF_REPO;
|
|
7593
|
+
const ggufFile = options.ggufFile || IOLA_ROUTER_GGUF_FILE;
|
|
7594
|
+
const baseUrl = options.baseUrl || "http://127.0.0.1:11434";
|
|
7595
|
+
const ollamaCommand = await resolveOllamaCommand();
|
|
7596
|
+
if (!ollamaCommand) {
|
|
7597
|
+
throw new Error("Ollama не найден. Установите Ollama и повторите: iola ai setup iola --yes");
|
|
7598
|
+
}
|
|
7599
|
+
|
|
7600
|
+
await mkdir(modelDir, { recursive: true });
|
|
7601
|
+
const stateFile = getIolaModelStateFile(modelDir);
|
|
7602
|
+
const state = readConfigLayerSync(stateFile) || {};
|
|
7603
|
+
const remote = await getRemoteIolaModelRevision(repo).catch(() => null);
|
|
7604
|
+
const installed = await hasOllamaModel(model, baseUrl);
|
|
7605
|
+
if (installed && !options.force && (!state.revision || state.runtime !== "ollama" || state.model !== model || state.repo !== repo)) {
|
|
7606
|
+
const nextState = {
|
|
7607
|
+
repo,
|
|
7608
|
+
ggufFile,
|
|
7609
|
+
revision: remote?.sha || state.revision || `local-${Date.now()}`,
|
|
7610
|
+
installedAt: state.installedAt || new Date().toISOString(),
|
|
7611
|
+
runtime: "ollama",
|
|
7612
|
+
model,
|
|
7613
|
+
};
|
|
7614
|
+
await writeFile(stateFile, `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
|
|
7615
|
+
return nextState;
|
|
7616
|
+
}
|
|
7617
|
+
const stale = options.force || !installed || state.runtime !== "ollama" || state.model !== model || state.repo !== repo || !state.revision || (remote?.sha && remote.sha !== state.revision);
|
|
7618
|
+
if (!stale) return state;
|
|
7619
|
+
|
|
7620
|
+
if (!options.quiet) {
|
|
7621
|
+
const reason = installed ? "обновляю" : "устанавливаю";
|
|
7622
|
+
console.log(`IOLA local model: ${reason} ${model}`);
|
|
7623
|
+
console.log(`Источник GGUF: https://huggingface.co/${repo}`);
|
|
7624
|
+
}
|
|
7625
|
+
|
|
7626
|
+
const ggufPath = path.join(modelDir, ggufFile);
|
|
7627
|
+
const modelfilePath = path.join(modelDir, "Modelfile");
|
|
7628
|
+
const url = `https://huggingface.co/${repo}/resolve/main/${encodeURIComponent(ggufFile)}`;
|
|
7629
|
+
if (options.force || !existsSync(ggufPath)) {
|
|
7630
|
+
await downloadFile(url, ggufPath, { quiet: options.quiet });
|
|
7631
|
+
}
|
|
7632
|
+
|
|
7633
|
+
await writeFile(modelfilePath, buildIolaOllamaModelfile(ggufPath), "utf8");
|
|
7634
|
+
await runCommand(ollamaCommand, ["create", model, "-f", modelfilePath], { inherit: !options.quiet });
|
|
7635
|
+
|
|
7636
|
+
const nextState = {
|
|
7637
|
+
repo,
|
|
7638
|
+
ggufFile,
|
|
7639
|
+
revision: remote?.sha || state.revision || `local-${Date.now()}`,
|
|
7640
|
+
installedAt: new Date().toISOString(),
|
|
7641
|
+
runtime: "ollama",
|
|
7642
|
+
model,
|
|
7643
|
+
};
|
|
7644
|
+
await writeFile(stateFile, `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
|
|
7645
|
+
return nextState;
|
|
7646
|
+
}
|
|
7647
|
+
|
|
7648
|
+
async function hasOllamaModel(model, baseUrl = "http://127.0.0.1:11434") {
|
|
7649
|
+
try {
|
|
7650
|
+
const response = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(2000) });
|
|
7651
|
+
if (!response.ok) return false;
|
|
7652
|
+
const payload = await response.json();
|
|
7653
|
+
return (payload.models || []).some((item) => item.name === model);
|
|
7654
|
+
} catch {
|
|
7655
|
+
return false;
|
|
7656
|
+
}
|
|
7657
|
+
}
|
|
7658
|
+
|
|
7659
|
+
async function downloadFile(url, targetPath, options = {}) {
|
|
7660
|
+
const response = await fetch(url, {
|
|
7661
|
+
headers: { "user-agent": "@iola_adm/iola-cli" },
|
|
7662
|
+
});
|
|
7663
|
+
if (!response.ok || !response.body) {
|
|
7664
|
+
throw new Error(`Не удалось скачать модель: ${response.status} ${response.statusText} (${url})`);
|
|
7665
|
+
}
|
|
7666
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
7667
|
+
if (!options.quiet) console.log(`Скачиваю модель: ${targetPath}`);
|
|
7668
|
+
await new Promise((resolve, reject) => {
|
|
7669
|
+
const file = createWriteStream(targetPath);
|
|
7670
|
+
Readable.fromWeb(response.body).pipe(file);
|
|
7671
|
+
file.on("finish", resolve);
|
|
7672
|
+
file.on("error", reject);
|
|
7673
|
+
});
|
|
7674
|
+
}
|
|
7675
|
+
|
|
7676
|
+
function buildIolaOllamaModelfile(ggufPath) {
|
|
7677
|
+
return `FROM ${ggufPath}
|
|
7678
|
+
|
|
7679
|
+
TEMPLATE """{{- if .System }}<|im_start|>system
|
|
7680
|
+
{{ .System }}<|im_end|>
|
|
7681
|
+
{{ end -}}
|
|
7682
|
+
{{- range .Messages }}
|
|
7683
|
+
{{- if eq .Role "user" }}<|im_start|>user
|
|
7684
|
+
{{ .Content }}<|im_end|>
|
|
7685
|
+
{{ else if eq .Role "assistant" }}<|im_start|>assistant
|
|
7686
|
+
{{ .Content }}<|im_end|>
|
|
7687
|
+
{{ end -}}
|
|
7688
|
+
{{ end -}}
|
|
7689
|
+
<|im_start|>assistant
|
|
7690
|
+
"""
|
|
7691
|
+
|
|
7692
|
+
PARAMETER temperature 0
|
|
7693
|
+
PARAMETER repeat_penalty 1
|
|
7694
|
+
PARAMETER stop <|im_start|>
|
|
7695
|
+
PARAMETER stop <|im_end|>
|
|
7696
|
+
`;
|
|
7697
|
+
}
|
|
7698
|
+
|
|
7699
|
+
function withIolaRouterSystemPrompt(messages = []) {
|
|
7700
|
+
const normalized = messages.map((message) => ({ ...message }));
|
|
7701
|
+
const hasSystem = normalized.some((message) => message.role === "system");
|
|
7702
|
+
const withNoThink = normalized.map((message, index) => {
|
|
7703
|
+
if (message.role === "user" && index === normalized.findLastIndex((item) => item.role === "user")) {
|
|
7704
|
+
return { ...message, content: `${message.content}\n/no_think` };
|
|
7705
|
+
}
|
|
7706
|
+
return message;
|
|
7707
|
+
});
|
|
7708
|
+
return hasSystem ? withNoThink : [{ role: "system", content: IOLA_ROUTER_SYSTEM_PROMPT }, ...withNoThink];
|
|
7709
|
+
}
|
|
7710
|
+
|
|
7711
|
+
const IOLA_ROUTER_SYSTEM_PROMPT = `You are the IOLA CLI router for public open data of Yoshkar-Ola.
|
|
7712
|
+
Return only valid JSON. No markdown, no prose.
|
|
7713
|
+
Allowed actions:
|
|
7714
|
+
- {"action":"tool_call","tool":"resolve_entity_field","args":{"layer":"schools|kindergartens","entity_number":1,"field":"address|phone|email|website|inn|head|license_status"}}
|
|
7715
|
+
- {"action":"tool_call","tool":"search_entities","args":{"layer":"schools|kindergartens","query":"..."}}
|
|
7716
|
+
- {"action":"tool_call","tool":"rag_search","args":{"query":"...","collections":["city_history","official_documents"]}}
|
|
7717
|
+
- {"action":"tool_call","tool":"get_current_official","args":{"jurisdiction":"yoshkar_ola","office_query":"..."}}
|
|
7718
|
+
- {"action":"tool_call","tool":"get_official_by_date","args":{"jurisdiction":"yoshkar_ola","office_query":"...","date":"YYYY or date"}}
|
|
7719
|
+
- {"action":"clarify","question":"..."}
|
|
7720
|
+
- {"action":"refuse","reason":"field_not_public"}
|
|
7721
|
+
- {"action":"direct_answer","answer":"..."}
|
|
7722
|
+
Never invent current officials, salaries, private data, addresses, phones, websites, heads, or license statuses.
|
|
7723
|
+
Use tool calls for public entity data. Use refuse for non-public fields.`;
|
|
7724
|
+
|
|
7373
7725
|
async function ensureIolaModelRuntime(options = {}) {
|
|
7374
7726
|
const python = await getIolaRuntimePython();
|
|
7375
7727
|
if (python && await checkIolaPythonDeps(python)) return { python };
|
|
@@ -7496,8 +7848,10 @@ async function callOllama(config, messages) {
|
|
|
7496
7848
|
model: config.model || "llama3.2:1b",
|
|
7497
7849
|
messages,
|
|
7498
7850
|
stream: false,
|
|
7851
|
+
think: config.qwenNoThink ? false : undefined,
|
|
7499
7852
|
options: {
|
|
7500
7853
|
temperature: Number(config.temperature ?? 0.1),
|
|
7854
|
+
num_predict: config.numPredict ? Number(config.numPredict) : undefined,
|
|
7501
7855
|
},
|
|
7502
7856
|
}),
|
|
7503
7857
|
});
|
|
@@ -9871,6 +10225,21 @@ function sanitizeConfig(config) {
|
|
|
9871
10225
|
if (Array.isArray(next.skills?.enabled) && next.skills.enabled.includes("open-data") && !next.skills.enabled.includes("education")) {
|
|
9872
10226
|
next.skills.enabled = ["education", ...next.skills.enabled];
|
|
9873
10227
|
}
|
|
10228
|
+
const localProfile = next.ai?.profiles?.local;
|
|
10229
|
+
if (localProfile?.provider === "iola") {
|
|
10230
|
+
if (!localProfile.runtime || localProfile.model === "iola-router-1b") {
|
|
10231
|
+
localProfile.runtime = "ollama";
|
|
10232
|
+
localProfile.model = IOLA_LOCAL_MODEL;
|
|
10233
|
+
localProfile.baseUrl = localProfile.baseUrl || "http://127.0.0.1:11434";
|
|
10234
|
+
localProfile.ggufRepo = localProfile.ggufRepo || IOLA_ROUTER_GGUF_REPO;
|
|
10235
|
+
localProfile.ggufFile = localProfile.ggufFile || IOLA_ROUTER_GGUF_FILE;
|
|
10236
|
+
localProfile.modelDir = localProfile.modelDir || IOLA_MODEL_DIR;
|
|
10237
|
+
}
|
|
10238
|
+
}
|
|
10239
|
+
if (next.ai?.activeProfile === "local" && next.ai.provider === "iola" && next.ai.model === "iola-router-1b") {
|
|
10240
|
+
next.ai.model = IOLA_LOCAL_MODEL;
|
|
10241
|
+
next.ai.baseUrl = next.ai.baseUrl || "http://127.0.0.1:11434";
|
|
10242
|
+
}
|
|
9874
10243
|
return next;
|
|
9875
10244
|
}
|
|
9876
10245
|
|