@iola_adm/iola-cli 0.1.100 → 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 +225 -12
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.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) => {
@@ -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
  }
@@ -7457,6 +7490,28 @@ async function callAiProvider(config, messages) {
7457
7490
  }
7458
7491
 
7459
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
+
7460
7515
  const runtime = await ensureIolaModelRuntime({ quiet: true });
7461
7516
  const repo = config.repo || IOLA_ROUTER_HF_REPO;
7462
7517
  const modelDir = config.modelDir || IOLA_MODEL_DIR;
@@ -7482,11 +7537,16 @@ async function callIolaLocal(config, messages) {
7482
7537
  }
7483
7538
 
7484
7539
  async function hasUsableIolaModel() {
7540
+ if (await hasOllamaModel(IOLA_LOCAL_MODEL)) return true;
7485
7541
  const state = readConfigLayerSync(getIolaModelStateFile(IOLA_MODEL_DIR));
7486
- return Boolean(state?.repo && state?.revision && existsSync(IOLA_MODEL_DIR));
7542
+ return Boolean(state?.runtime === "transformers" && state?.repo && state?.revision && existsSync(IOLA_MODEL_DIR));
7487
7543
  }
7488
7544
 
7489
7545
  async function ensureIolaModelFresh(options = {}) {
7546
+ if ((options.runtime || "ollama") !== "transformers") {
7547
+ return ensureIolaOllamaModelFresh(options);
7548
+ }
7549
+
7490
7550
  const repo = options.repo || IOLA_ROUTER_HF_REPO;
7491
7551
  const modelDir = options.modelDir || IOLA_MODEL_DIR;
7492
7552
  await mkdir(modelDir, { recursive: true });
@@ -7526,6 +7586,142 @@ function getIolaModelStateFile(modelDir = IOLA_MODEL_DIR) {
7526
7586
  return path.join(modelDir, "manifest.json");
7527
7587
  }
7528
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
+
7529
7725
  async function ensureIolaModelRuntime(options = {}) {
7530
7726
  const python = await getIolaRuntimePython();
7531
7727
  if (python && await checkIolaPythonDeps(python)) return { python };
@@ -7652,8 +7848,10 @@ async function callOllama(config, messages) {
7652
7848
  model: config.model || "llama3.2:1b",
7653
7849
  messages,
7654
7850
  stream: false,
7851
+ think: config.qwenNoThink ? false : undefined,
7655
7852
  options: {
7656
7853
  temperature: Number(config.temperature ?? 0.1),
7854
+ num_predict: config.numPredict ? Number(config.numPredict) : undefined,
7657
7855
  },
7658
7856
  }),
7659
7857
  });
@@ -10027,6 +10225,21 @@ function sanitizeConfig(config) {
10027
10225
  if (Array.isArray(next.skills?.enabled) && next.skills.enabled.includes("open-data") && !next.skills.enabled.includes("education")) {
10028
10226
  next.skills.enabled = ["education", ...next.skills.enabled];
10029
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
+ }
10030
10243
  return next;
10031
10244
  }
10032
10245