@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +333 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.100",
3
+ "version": "0.1.102",
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) => {
@@ -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({ 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
+ });
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: IOLA_LOCAL_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: IOLA_LOCAL_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: ${IOLA_LOCAL_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
- const result = await executeToolPlan(validated, { ...options, runId });
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
- return await postJson(endpoint, payload);
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
- return await postJson(endpoint, { ...payload, field: fallbackField });
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