@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +391 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.98",
3
+ "version": "0.1.101",
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
@@ -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-1b";
25
- const IOLA_LOCAL_OLLAMA_MODEL = "gemma3:1b";
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
- repo: IOLA_ROUTER_HF_REPO,
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
- clearAgentStatusBar(state);
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.write("\r\x1b[0K\n");
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({ repo, modelDir, force: true, quiet: Boolean(options.quiet) });
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: IOLA_LOCAL_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: IOLA_LOCAL_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: ${IOLA_LOCAL_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 (/(директор|руководител|заведующ|кто возглавляет)/iu.test(normalizedQuestion)) fields.push("head");
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 payload = await postJson(`${await getApiBaseUrl()}/resolve-entity-field`, {
6666
- layer: normalizeEntityLayer(args.layer),
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: normalizeEntityField(args.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
- return payload;
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("директор") || value.includes("руковод")) return "head";
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