@iola_adm/iola-cli 0.1.14 → 0.1.17
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/README.md +118 -0
- package/bin/iola.js +0 -0
- package/package.json +1 -1
- package/src/cli.js +830 -15
package/README.md
CHANGED
|
@@ -99,6 +99,26 @@ iola features enable api-cache
|
|
|
99
99
|
iola mcp status
|
|
100
100
|
iola mcp list
|
|
101
101
|
iola mcp install codex
|
|
102
|
+
iola cache status
|
|
103
|
+
iola cache warm
|
|
104
|
+
iola cache clear
|
|
105
|
+
iola sync
|
|
106
|
+
iola sync status
|
|
107
|
+
iola diff schools
|
|
108
|
+
iola search "Петрова" --local
|
|
109
|
+
iola search "Петрова" --local --fts
|
|
110
|
+
iola card "школа 29"
|
|
111
|
+
iola quality
|
|
112
|
+
iola quality missing-phones
|
|
113
|
+
iola data schools --where address=Петрова --save schools-petrova
|
|
114
|
+
iola views
|
|
115
|
+
iola view schools-petrova --format csv --output schools-petrova.csv
|
|
116
|
+
iola views delete schools-petrova
|
|
117
|
+
iola report missing-phones
|
|
118
|
+
iola privacy
|
|
119
|
+
iola backup create
|
|
120
|
+
iola alias add petrova "data schools --where address=Петрова --columns name,address,phone"
|
|
121
|
+
iola run "выгрузи школы на Петрова в csv"
|
|
102
122
|
iola config get
|
|
103
123
|
iola config set api.baseUrl https://apiiola.yasg.ru/api/v1
|
|
104
124
|
iola config reset
|
|
@@ -107,6 +127,8 @@ iola version --check
|
|
|
107
127
|
iola ask "Найди школу 29"
|
|
108
128
|
iola ask "Найди школу 29" --profile codex --events --output answer.txt
|
|
109
129
|
iola ask "Найди школу 29" --schema json --no-history
|
|
130
|
+
iola ask "выгрузи школы на Петрова в csv" --profile local --tools --reasoning verify
|
|
131
|
+
iola data schools --format csv --output schools.csv
|
|
110
132
|
iola data schools --limit 10
|
|
111
133
|
iola data kindergartens --search "29"
|
|
112
134
|
iola data schools --where address=Петрова --columns name,address,phone
|
|
@@ -350,6 +372,102 @@ iola ask "Найди школу 29" --schema json
|
|
|
350
372
|
iola ask "Найди школу 29" --output answer.txt
|
|
351
373
|
```
|
|
352
374
|
|
|
375
|
+
## Кеш, локальный поиск и выборки
|
|
376
|
+
|
|
377
|
+
API-ответы можно кешировать локально:
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
iola cache status
|
|
381
|
+
iola cache warm
|
|
382
|
+
iola cache clear
|
|
383
|
+
iola data schools --cache
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Локальная синхронизация сохраняет открытые слои в SQLite и позволяет искать без
|
|
387
|
+
повторного обращения к API:
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
iola sync
|
|
391
|
+
iola sync status
|
|
392
|
+
iola diff
|
|
393
|
+
iola search "Петрова" --local
|
|
394
|
+
iola search "школа Петрова" --local --fts
|
|
395
|
+
iola data schools --local --search "лицей"
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Сохраненные выборки:
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
iola data schools --where address=Петрова --columns name,address,phone --save schools-petrova
|
|
402
|
+
iola views
|
|
403
|
+
iola view schools-petrova
|
|
404
|
+
iola view schools-petrova --format csv --output schools-petrova.csv
|
|
405
|
+
iola views delete schools-petrova
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Отчеты, backup и алиасы:
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
iola report schools-summary
|
|
412
|
+
iola report education-contacts
|
|
413
|
+
iola report missing-phones
|
|
414
|
+
iola report licenses
|
|
415
|
+
iola privacy
|
|
416
|
+
iola backup create
|
|
417
|
+
iola alias add petrova "data schools --where address=Петрова --columns name,address,phone"
|
|
418
|
+
iola petrova
|
|
419
|
+
iola run "выгрузи школы на Петрова в csv"
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Локальный tool-agent для слабых моделей
|
|
423
|
+
|
|
424
|
+
Для локального профиля Ollama доступен режим `--tools`. Он сделан специально
|
|
425
|
+
для маленьких моделей, которые хуже отвечают свободным текстом, но могут быть
|
|
426
|
+
полезны как планировщик действий.
|
|
427
|
+
|
|
428
|
+
В этом режиме CLI не доверяет модели напрямую. Модель предлагает JSON-план,
|
|
429
|
+
CLI валидирует список разрешенных tools и сам выполняет действия через
|
|
430
|
+
проверенные локальные функции:
|
|
431
|
+
|
|
432
|
+
- `search_local`
|
|
433
|
+
- `get_card`
|
|
434
|
+
- `export_data`
|
|
435
|
+
- `run_report`
|
|
436
|
+
- `save_view`
|
|
437
|
+
|
|
438
|
+
Пример:
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
iola ask "выгрузи школы на Петрова в csv" --profile local --tools
|
|
442
|
+
iola ask "найди детсады без телефона" --profile local --tools --reasoning verify
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Режимы:
|
|
446
|
+
|
|
447
|
+
```bash
|
|
448
|
+
--reasoning fast # один план
|
|
449
|
+
--reasoning verify # план с валидацией результата
|
|
450
|
+
--reasoning vote # несколько вариантов, выбирается валидный
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
OpenAI, OpenRouter и Codex работают как раньше. `--tools` применяется только
|
|
454
|
+
к локальному Ollama-профилю, чтобы не менять поведение внешних провайдеров.
|
|
455
|
+
|
|
456
|
+
Карточки, качество данных и изменения:
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
iola card schools 1215067180
|
|
460
|
+
iola card "школа 29"
|
|
461
|
+
iola quality
|
|
462
|
+
iola quality schools
|
|
463
|
+
iola quality missing-phones
|
|
464
|
+
iola quality invalid-emails
|
|
465
|
+
iola quality duplicate-inn
|
|
466
|
+
iola sync status
|
|
467
|
+
iola diff
|
|
468
|
+
iola diff schools
|
|
469
|
+
```
|
|
470
|
+
|
|
353
471
|
## Переменные окружения
|
|
354
472
|
|
|
355
473
|
```bash
|
package/bin/iola.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -95,6 +95,18 @@ const COMMANDS = new Map([
|
|
|
95
95
|
["fork", forkSession],
|
|
96
96
|
["features", handleFeatures],
|
|
97
97
|
["mcp", handleMcp],
|
|
98
|
+
["cache", handleCache],
|
|
99
|
+
["sync", handleSync],
|
|
100
|
+
["diff", handleDiff],
|
|
101
|
+
["views", handleViews],
|
|
102
|
+
["view", handleView],
|
|
103
|
+
["card", handleCard],
|
|
104
|
+
["quality", handleQuality],
|
|
105
|
+
["report", handleReport],
|
|
106
|
+
["privacy", handlePrivacy],
|
|
107
|
+
["backup", handleBackup],
|
|
108
|
+
["alias", handleAlias],
|
|
109
|
+
["run", runNaturalLanguage],
|
|
98
110
|
["config", handleConfig],
|
|
99
111
|
["banner", showBanner],
|
|
100
112
|
["agent", startAgent],
|
|
@@ -122,6 +134,11 @@ export async function main(argv) {
|
|
|
122
134
|
const handler = COMMANDS.get(command);
|
|
123
135
|
|
|
124
136
|
if (!handler) {
|
|
137
|
+
const alias = getAlias(command);
|
|
138
|
+
if (alias) {
|
|
139
|
+
await main([...splitCommandLine(alias.command), ...args]);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
125
142
|
throw new Error(`Unknown command: ${command}\nRun "iola help" to see available commands.`);
|
|
126
143
|
}
|
|
127
144
|
|
|
@@ -147,12 +164,26 @@ Usage:
|
|
|
147
164
|
iola fork SESSION_ID [TEXT]
|
|
148
165
|
iola features list|enable|disable
|
|
149
166
|
iola mcp list|status|install|remove
|
|
167
|
+
iola cache status|warm|clear
|
|
168
|
+
iola sync [--dataset schools|kindergartens]
|
|
169
|
+
iola sync status
|
|
170
|
+
iola diff [schools|kindergartens]
|
|
171
|
+
iola card schools 1215067180
|
|
172
|
+
iola card "школа 29"
|
|
173
|
+
iola quality [schools|kindergartens|missing-phones|invalid-emails|duplicate-inn]
|
|
174
|
+
iola views
|
|
175
|
+
iola view NAME [--format table|json|csv] [--output FILE]
|
|
176
|
+
iola report schools-summary|education-contacts|missing-phones|licenses
|
|
177
|
+
iola privacy
|
|
178
|
+
iola backup create
|
|
179
|
+
iola alias add NAME COMMAND
|
|
180
|
+
iola run "выгрузи школы на Петрова в csv"
|
|
150
181
|
iola config get
|
|
151
182
|
iola config set api.baseUrl URL
|
|
152
183
|
iola config set api.mcpBaseUrl URL
|
|
153
184
|
iola config reset
|
|
154
185
|
iola update
|
|
155
|
-
iola ask TEXT [--profile NAME] [--model MODEL] [--output FILE] [--schema json|table] [--events] [--no-history]
|
|
186
|
+
iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history]
|
|
156
187
|
iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
|
|
157
188
|
iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
|
|
158
189
|
iola ai context TEXT [--json]
|
|
@@ -291,6 +322,21 @@ async function handleAgentLine(line, state) {
|
|
|
291
322
|
return false;
|
|
292
323
|
}
|
|
293
324
|
|
|
325
|
+
if (command === "cache") {
|
|
326
|
+
await handleCache(args);
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (command === "sync") {
|
|
331
|
+
await handleSync(args);
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (command === "diff" || command === "card" || command === "quality" || command === "views" || command === "view" || command === "report" || command === "privacy" || command === "backup" || command === "alias" || command === "run") {
|
|
336
|
+
await COMMANDS.get(command)(args);
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
294
340
|
if (command === "config") {
|
|
295
341
|
await handleConfig(args.length > 0 ? args : ["get"]);
|
|
296
342
|
return false;
|
|
@@ -376,6 +422,9 @@ async function handleAgentLine(line, state) {
|
|
|
376
422
|
fork: ["fork", args],
|
|
377
423
|
features: ["features", args],
|
|
378
424
|
mcp: ["mcp", args],
|
|
425
|
+
cache: ["cache", args],
|
|
426
|
+
sync: ["sync", args],
|
|
427
|
+
diff: ["diff", args],
|
|
379
428
|
config: ["config", args],
|
|
380
429
|
layers: ["layers", args],
|
|
381
430
|
data: ["data", args],
|
|
@@ -407,6 +456,12 @@ function printAgentHelp() {
|
|
|
407
456
|
/resume SESSION_ID
|
|
408
457
|
/features list
|
|
409
458
|
/mcp status
|
|
459
|
+
/cache status
|
|
460
|
+
/sync
|
|
461
|
+
/diff
|
|
462
|
+
/card школа 29
|
|
463
|
+
/quality
|
|
464
|
+
/views
|
|
410
465
|
/config get
|
|
411
466
|
/config set api.baseUrl URL
|
|
412
467
|
/layers
|
|
@@ -1046,6 +1101,196 @@ async function handleMcp(args) {
|
|
|
1046
1101
|
throw new Error("Команды mcp: status, list, install codex, remove codex.");
|
|
1047
1102
|
}
|
|
1048
1103
|
|
|
1104
|
+
async function handleCache(args) {
|
|
1105
|
+
const [action = "status"] = args;
|
|
1106
|
+
if (action === "status") {
|
|
1107
|
+
printKeyValue(getCacheStatus());
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (action === "clear") {
|
|
1111
|
+
clearCache();
|
|
1112
|
+
console.log("Кеш очищен.");
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (action === "warm") {
|
|
1116
|
+
const result = await warmCache();
|
|
1117
|
+
printKeyValue(result);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
throw new Error("Команды cache: status, warm, clear.");
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function handleSync(args) {
|
|
1124
|
+
const [action] = args;
|
|
1125
|
+
if (action === "status") {
|
|
1126
|
+
printTable(getSyncStatus(), [
|
|
1127
|
+
["dataset", "Слой"],
|
|
1128
|
+
["records", "Записей"],
|
|
1129
|
+
["last_sync", "Последний sync"],
|
|
1130
|
+
["status", "Статус"],
|
|
1131
|
+
]);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const options = parseOptions(args);
|
|
1135
|
+
const datasets = options.dataset ? [options.dataset] : Object.keys(DATASETS);
|
|
1136
|
+
const rows = [];
|
|
1137
|
+
for (const dataset of datasets) {
|
|
1138
|
+
rows.push(await syncDataset(dataset));
|
|
1139
|
+
}
|
|
1140
|
+
printTable(rows, [
|
|
1141
|
+
["dataset", "Слой"],
|
|
1142
|
+
["records", "Записей"],
|
|
1143
|
+
["status", "Статус"],
|
|
1144
|
+
["message", "Сообщение"],
|
|
1145
|
+
]);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async function handleDiff(args) {
|
|
1149
|
+
const [dataset] = args;
|
|
1150
|
+
const rows = listSyncChanges(dataset);
|
|
1151
|
+
printTable(rows, [
|
|
1152
|
+
["created_at", "Дата"],
|
|
1153
|
+
["dataset", "Слой"],
|
|
1154
|
+
["change_type", "Тип"],
|
|
1155
|
+
["record_key", "Ключ"],
|
|
1156
|
+
["summary", "Сводка"],
|
|
1157
|
+
]);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async function handleCard(args) {
|
|
1161
|
+
await ensureLocalData();
|
|
1162
|
+
const options = parseOptions(args);
|
|
1163
|
+
const query = args.join(" ").trim();
|
|
1164
|
+
if (!query) throw new Error('Пример: iola card "школа 29"');
|
|
1165
|
+
const item = findCard(query);
|
|
1166
|
+
if (!item) throw new Error(`Объект не найден: ${query}`);
|
|
1167
|
+
if (options.json) {
|
|
1168
|
+
printJson(item);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
printKeyValue(item);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function handleQuality(args) {
|
|
1175
|
+
const [scope = "all"] = args;
|
|
1176
|
+
await ensureLocalData();
|
|
1177
|
+
const rows = runQuality(scope);
|
|
1178
|
+
printTable(rows, [
|
|
1179
|
+
["check", "Проверка"],
|
|
1180
|
+
["dataset", "Слой"],
|
|
1181
|
+
["count", "Кол-во"],
|
|
1182
|
+
["sample", "Пример"],
|
|
1183
|
+
]);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function handleViews(args) {
|
|
1187
|
+
const [action, name] = args;
|
|
1188
|
+
if (action === "delete" || action === "remove") {
|
|
1189
|
+
deleteSavedView(name);
|
|
1190
|
+
console.log(`View удален: ${name}`);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const rows = listSavedViews();
|
|
1194
|
+
printTable(rows, [
|
|
1195
|
+
["name", "Имя"],
|
|
1196
|
+
["dataset", "Слой"],
|
|
1197
|
+
["created_at", "Создано"],
|
|
1198
|
+
]);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function handleView(args) {
|
|
1202
|
+
const [name, ...rest] = args;
|
|
1203
|
+
if (!name) {
|
|
1204
|
+
throw new Error("Имя view обязательно.");
|
|
1205
|
+
}
|
|
1206
|
+
const view = getSavedView(name);
|
|
1207
|
+
const query = JSON.parse(view.query_json);
|
|
1208
|
+
await listDataset(view.dataset, [...(query.args || []), ...rest]);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async function handleReport(args) {
|
|
1212
|
+
const [name] = args;
|
|
1213
|
+
await ensureLocalData();
|
|
1214
|
+
if (name === "schools-summary") {
|
|
1215
|
+
printTable(getLocalSummaryRows("schools"), [["metric", "Показатель"], ["value", "Значение"]]);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (name === "education-contacts") {
|
|
1219
|
+
printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }), "name,address,phone,email,website");
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (name === "missing-phones") {
|
|
1223
|
+
printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }).filter((item) => !item.phone || item.phone === "-"));
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (name === "licenses") {
|
|
1227
|
+
printDatasetTable(searchLocalRecords("", { dataset: "all", limit: 500 }), "name,license_number,license_status");
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
throw new Error("Отчеты: schools-summary, education-contacts, missing-phones, licenses.");
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
async function handlePrivacy() {
|
|
1234
|
+
printKeyValue({
|
|
1235
|
+
config: CONFIG_FILE,
|
|
1236
|
+
secrets: SECRETS_FILE,
|
|
1237
|
+
sqlite: DB_FILE,
|
|
1238
|
+
api: await getApiBaseUrl(),
|
|
1239
|
+
mcp: await getMcpBaseUrl(),
|
|
1240
|
+
keys_in_sqlite: "no",
|
|
1241
|
+
history_clear: "iola history clear",
|
|
1242
|
+
db_reset: "iola db reset",
|
|
1243
|
+
delete_openai_key: "iola ai key delete openai",
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async function handleBackup(args) {
|
|
1248
|
+
const [action = "create", fileArg] = args;
|
|
1249
|
+
if (action !== "create") {
|
|
1250
|
+
throw new Error("Пока доступно: iola backup create [FILE]");
|
|
1251
|
+
}
|
|
1252
|
+
const file = fileArg || path.join(CONFIG_DIR, `iola-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
|
|
1253
|
+
const payload = {
|
|
1254
|
+
created_at: new Date().toISOString(),
|
|
1255
|
+
config: await loadConfig(),
|
|
1256
|
+
db: exportDbSnapshot(),
|
|
1257
|
+
};
|
|
1258
|
+
await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
1259
|
+
console.log(`Backup создан: ${file}`);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async function handleAlias(args) {
|
|
1263
|
+
const [action, name, ...commandParts] = args;
|
|
1264
|
+
if (action === "list" || !action) {
|
|
1265
|
+
printTable(listAliases(), [["name", "Алиас"], ["command", "Команда"]]);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (action === "add") {
|
|
1269
|
+
if (!name || commandParts.length === 0) {
|
|
1270
|
+
throw new Error('Пример: iola alias add petrova "data schools --where address=Петрова"');
|
|
1271
|
+
}
|
|
1272
|
+
saveAlias(name, commandParts.join(" "));
|
|
1273
|
+
console.log(`Алиас сохранен: ${name}`);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
if (action === "delete" || action === "remove") {
|
|
1277
|
+
deleteAlias(name);
|
|
1278
|
+
console.log(`Алиас удален: ${name}`);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
throw new Error("Команды alias: list, add NAME COMMAND, delete NAME.");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
async function runNaturalLanguage(args) {
|
|
1285
|
+
const text = args.join(" ").trim();
|
|
1286
|
+
if (!text) {
|
|
1287
|
+
throw new Error('Пример: iola run "выгрузи школы на Петрова в csv"');
|
|
1288
|
+
}
|
|
1289
|
+
const command = inferCommandFromText(text);
|
|
1290
|
+
console.log(`> iola ${command.join(" ")}`);
|
|
1291
|
+
await main(command);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1049
1294
|
async function aiDoctor(args) {
|
|
1050
1295
|
const options = parseOptions(args);
|
|
1051
1296
|
const diagnostics = await getLocalDiagnostics();
|
|
@@ -1600,6 +1845,39 @@ function initDatabase() {
|
|
|
1600
1845
|
query_json TEXT NOT NULL,
|
|
1601
1846
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1602
1847
|
);
|
|
1848
|
+
CREATE TABLE IF NOT EXISTS local_records (
|
|
1849
|
+
dataset TEXT NOT NULL,
|
|
1850
|
+
record_key TEXT NOT NULL,
|
|
1851
|
+
record_json TEXT NOT NULL,
|
|
1852
|
+
searchable_text TEXT NOT NULL,
|
|
1853
|
+
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1854
|
+
PRIMARY KEY(dataset, record_key)
|
|
1855
|
+
);
|
|
1856
|
+
CREATE INDEX IF NOT EXISTS idx_local_records_dataset ON local_records(dataset);
|
|
1857
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS local_records_fts USING fts5(dataset, record_key, searchable_text);
|
|
1858
|
+
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
1859
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1860
|
+
dataset TEXT NOT NULL,
|
|
1861
|
+
records INTEGER NOT NULL,
|
|
1862
|
+
status TEXT NOT NULL,
|
|
1863
|
+
message TEXT,
|
|
1864
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1865
|
+
);
|
|
1866
|
+
CREATE TABLE IF NOT EXISTS sync_changes (
|
|
1867
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1868
|
+
dataset TEXT NOT NULL,
|
|
1869
|
+
record_key TEXT NOT NULL,
|
|
1870
|
+
change_type TEXT NOT NULL,
|
|
1871
|
+
old_json TEXT,
|
|
1872
|
+
new_json TEXT,
|
|
1873
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1874
|
+
);
|
|
1875
|
+
CREATE INDEX IF NOT EXISTS idx_sync_changes_dataset_created_at ON sync_changes(dataset, created_at DESC);
|
|
1876
|
+
CREATE TABLE IF NOT EXISTS aliases (
|
|
1877
|
+
name TEXT PRIMARY KEY,
|
|
1878
|
+
command TEXT NOT NULL,
|
|
1879
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1880
|
+
);
|
|
1603
1881
|
`);
|
|
1604
1882
|
db.prepare(`
|
|
1605
1883
|
INSERT INTO meta(key, value) VALUES ('schema_version', ?)
|
|
@@ -1626,12 +1904,16 @@ function getDbStatus() {
|
|
|
1626
1904
|
const schema = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
1627
1905
|
const history = db.prepare("SELECT COUNT(*) AS count FROM ask_history").get();
|
|
1628
1906
|
const sessions = db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
|
|
1907
|
+
const local = db.prepare("SELECT COUNT(*) AS count FROM local_records").get();
|
|
1908
|
+
const cache = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
|
|
1629
1909
|
return {
|
|
1630
1910
|
status: "ok",
|
|
1631
1911
|
file: DB_FILE,
|
|
1632
1912
|
schema: schema?.value || "-",
|
|
1633
1913
|
history: history?.count ?? 0,
|
|
1634
1914
|
sessions: sessions?.count ?? 0,
|
|
1915
|
+
local_records: local?.count ?? 0,
|
|
1916
|
+
cache: cache?.count ?? 0,
|
|
1635
1917
|
};
|
|
1636
1918
|
} finally {
|
|
1637
1919
|
db.close();
|
|
@@ -1862,6 +2144,399 @@ function setFeatureEnabled(name, enabled) {
|
|
|
1862
2144
|
}
|
|
1863
2145
|
}
|
|
1864
2146
|
|
|
2147
|
+
async function fetchJsonMaybeCached(url, options = {}) {
|
|
2148
|
+
if (!options.cache && !isFeatureEnabled("api-cache")) {
|
|
2149
|
+
return fetchJson(url);
|
|
2150
|
+
}
|
|
2151
|
+
const cached = getCachedResponse(url);
|
|
2152
|
+
if (cached) {
|
|
2153
|
+
return cached;
|
|
2154
|
+
}
|
|
2155
|
+
const payload = await fetchJson(url);
|
|
2156
|
+
setCachedResponse(url, payload, 3600);
|
|
2157
|
+
return payload;
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function getCachedResponse(url) {
|
|
2161
|
+
initDatabase();
|
|
2162
|
+
const db = openDatabase();
|
|
2163
|
+
try {
|
|
2164
|
+
const row = db.prepare("SELECT response_json, expires_at FROM api_cache WHERE key = ?").get(cacheKey(url));
|
|
2165
|
+
if (!row) return null;
|
|
2166
|
+
if (row.expires_at && new Date(row.expires_at) < new Date()) return null;
|
|
2167
|
+
return JSON.parse(row.response_json);
|
|
2168
|
+
} finally {
|
|
2169
|
+
db.close();
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function setCachedResponse(url, payload, ttlSeconds) {
|
|
2174
|
+
initDatabase();
|
|
2175
|
+
const db = openDatabase();
|
|
2176
|
+
try {
|
|
2177
|
+
const expires = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
2178
|
+
db.prepare(`
|
|
2179
|
+
INSERT INTO api_cache(key, url, response_json, expires_at, created_at)
|
|
2180
|
+
VALUES (?, ?, ?, ?, datetime('now'))
|
|
2181
|
+
ON CONFLICT(key) DO UPDATE SET response_json = excluded.response_json, expires_at = excluded.expires_at, created_at = excluded.created_at
|
|
2182
|
+
`).run(cacheKey(url), url, JSON.stringify(payload), expires);
|
|
2183
|
+
} finally {
|
|
2184
|
+
db.close();
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
function cacheKey(url) {
|
|
2189
|
+
return Buffer.from(url).toString("base64url");
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function getCacheStatus() {
|
|
2193
|
+
initDatabase();
|
|
2194
|
+
const db = openDatabase();
|
|
2195
|
+
try {
|
|
2196
|
+
const row = db.prepare("SELECT COUNT(*) AS count FROM api_cache").get();
|
|
2197
|
+
return { status: "ok", entries: row?.count ?? 0 };
|
|
2198
|
+
} finally {
|
|
2199
|
+
db.close();
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
function clearCache() {
|
|
2204
|
+
initDatabase();
|
|
2205
|
+
const db = openDatabase();
|
|
2206
|
+
try {
|
|
2207
|
+
db.exec("DELETE FROM api_cache");
|
|
2208
|
+
} finally {
|
|
2209
|
+
db.close();
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
async function warmCache() {
|
|
2214
|
+
const urls = [
|
|
2215
|
+
`${await getMcpBaseUrl()}/mcp-version`,
|
|
2216
|
+
`${await getMcpBaseUrl()}/mcp-health`,
|
|
2217
|
+
`${await getApiBaseUrl()}/schools?limit=100&offset=0`,
|
|
2218
|
+
`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`,
|
|
2219
|
+
];
|
|
2220
|
+
for (const url of urls) {
|
|
2221
|
+
setCachedResponse(url, await fetchJson(url), 3600);
|
|
2222
|
+
}
|
|
2223
|
+
return { status: "ok", entries: urls.length };
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
async function syncDataset(dataset) {
|
|
2227
|
+
if (!DATASETS[dataset]) {
|
|
2228
|
+
throw new Error(`Неизвестный слой: ${dataset}`);
|
|
2229
|
+
}
|
|
2230
|
+
try {
|
|
2231
|
+
const payload = await fetchJson(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?limit=500&offset=0`);
|
|
2232
|
+
const items = normalizeItems(payload);
|
|
2233
|
+
saveLocalRecords(dataset, items);
|
|
2234
|
+
recordSyncRun(dataset, items.length, "ok", "");
|
|
2235
|
+
return { dataset, records: items.length, status: "ok", message: "" };
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2238
|
+
recordSyncRun(dataset, 0, "error", message);
|
|
2239
|
+
return { dataset, records: 0, status: "error", message };
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
function saveLocalRecords(dataset, items) {
|
|
2244
|
+
initDatabase();
|
|
2245
|
+
const db = openDatabase();
|
|
2246
|
+
try {
|
|
2247
|
+
const oldRows = db.prepare("SELECT record_key, record_json FROM local_records WHERE dataset = ?").all(dataset);
|
|
2248
|
+
const oldMap = new Map(oldRows.map((row) => [row.record_key, row.record_json]));
|
|
2249
|
+
const newKeys = new Set();
|
|
2250
|
+
db.prepare("DELETE FROM local_records WHERE dataset = ?").run(dataset);
|
|
2251
|
+
db.prepare("DELETE FROM local_records_fts WHERE dataset = ?").run(dataset);
|
|
2252
|
+
const insert = db.prepare("INSERT INTO local_records(dataset, record_key, record_json, searchable_text, synced_at) VALUES (?, ?, ?, ?, datetime('now'))");
|
|
2253
|
+
const insertFts = db.prepare("INSERT INTO local_records_fts(dataset, record_key, searchable_text) VALUES (?, ?, ?)");
|
|
2254
|
+
const insertChange = db.prepare("INSERT INTO sync_changes(dataset, record_key, change_type, old_json, new_json) VALUES (?, ?, ?, ?, ?)");
|
|
2255
|
+
for (const item of items) {
|
|
2256
|
+
const summary = selectPublicSummary(item);
|
|
2257
|
+
const key = String(summary.inn || item.id || `${dataset}-${Math.random()}`);
|
|
2258
|
+
newKeys.add(key);
|
|
2259
|
+
const newJson = JSON.stringify(item);
|
|
2260
|
+
const oldJson = oldMap.get(key);
|
|
2261
|
+
if (!oldJson) insertChange.run(dataset, key, "added", null, newJson);
|
|
2262
|
+
else if (oldJson !== newJson) insertChange.run(dataset, key, "changed", oldJson, newJson);
|
|
2263
|
+
const text = JSON.stringify(summary).toLocaleLowerCase("ru-RU");
|
|
2264
|
+
insert.run(dataset, key, newJson, text);
|
|
2265
|
+
insertFts.run(dataset, key, text);
|
|
2266
|
+
}
|
|
2267
|
+
for (const [key, oldJson] of oldMap.entries()) {
|
|
2268
|
+
if (!newKeys.has(key)) insertChange.run(dataset, key, "removed", oldJson, null);
|
|
2269
|
+
}
|
|
2270
|
+
} finally {
|
|
2271
|
+
db.close();
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
function recordSyncRun(dataset, records, status, message) {
|
|
2276
|
+
initDatabase();
|
|
2277
|
+
const db = openDatabase();
|
|
2278
|
+
try {
|
|
2279
|
+
db.prepare("INSERT INTO sync_runs(dataset, records, status, message) VALUES (?, ?, ?, ?)").run(dataset, records, status, message);
|
|
2280
|
+
} finally {
|
|
2281
|
+
db.close();
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
async function ensureLocalData() {
|
|
2286
|
+
const status = getDbStatus();
|
|
2287
|
+
if (Number(status.local_records || 0) === 0) {
|
|
2288
|
+
await handleSync([]);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function searchLocalRecords(query, options = {}) {
|
|
2293
|
+
initDatabase();
|
|
2294
|
+
const db = openDatabase();
|
|
2295
|
+
const dataset = options.dataset || "all";
|
|
2296
|
+
const limit = Number(options.limit || 20);
|
|
2297
|
+
try {
|
|
2298
|
+
if (options.fts && query) {
|
|
2299
|
+
const ftsQuery = query.split(/\s+/).filter(Boolean).map((term) => `"${term.replace(/"/g, "")}"`).join(" ");
|
|
2300
|
+
const params = dataset === "all" ? [ftsQuery, limit] : [ftsQuery, dataset, limit];
|
|
2301
|
+
const sql = dataset === "all"
|
|
2302
|
+
? "SELECT r.record_json FROM local_records_fts f JOIN local_records r ON r.dataset=f.dataset AND r.record_key=f.record_key WHERE local_records_fts MATCH ? LIMIT ?"
|
|
2303
|
+
: "SELECT r.record_json FROM local_records_fts f JOIN local_records r ON r.dataset=f.dataset AND r.record_key=f.record_key WHERE local_records_fts MATCH ? AND f.dataset = ? LIMIT ?";
|
|
2304
|
+
return db.prepare(sql).all(...params).map((row) => selectPublicSummary(JSON.parse(row.record_json)));
|
|
2305
|
+
}
|
|
2306
|
+
const params = [];
|
|
2307
|
+
let sql = "SELECT dataset, record_json FROM local_records";
|
|
2308
|
+
const where = [];
|
|
2309
|
+
if (dataset !== "all") {
|
|
2310
|
+
where.push("dataset = ?");
|
|
2311
|
+
params.push(dataset);
|
|
2312
|
+
}
|
|
2313
|
+
if (query) {
|
|
2314
|
+
where.push("searchable_text LIKE ?");
|
|
2315
|
+
params.push(`%${query.toLocaleLowerCase("ru-RU")}%`);
|
|
2316
|
+
}
|
|
2317
|
+
if (where.length) sql += ` WHERE ${where.join(" AND ")}`;
|
|
2318
|
+
sql += " ORDER BY dataset, record_key LIMIT ?";
|
|
2319
|
+
params.push(limit);
|
|
2320
|
+
return db.prepare(sql).all(...params).map((row) => selectPublicSummary(JSON.parse(row.record_json)));
|
|
2321
|
+
} finally {
|
|
2322
|
+
db.close();
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function getSyncStatus() {
|
|
2327
|
+
initDatabase();
|
|
2328
|
+
const db = openDatabase();
|
|
2329
|
+
try {
|
|
2330
|
+
return Object.keys(DATASETS).map((dataset) => {
|
|
2331
|
+
const records = db.prepare("SELECT COUNT(*) AS count FROM local_records WHERE dataset = ?").get(dataset);
|
|
2332
|
+
const run = db.prepare("SELECT status, created_at FROM sync_runs WHERE dataset = ? ORDER BY id DESC LIMIT 1").get(dataset);
|
|
2333
|
+
return { dataset, records: records?.count || 0, last_sync: run?.created_at || "-", status: run?.status || "never" };
|
|
2334
|
+
});
|
|
2335
|
+
} finally {
|
|
2336
|
+
db.close();
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function listSyncChanges(dataset) {
|
|
2341
|
+
initDatabase();
|
|
2342
|
+
const db = openDatabase();
|
|
2343
|
+
try {
|
|
2344
|
+
const rows = dataset
|
|
2345
|
+
? db.prepare("SELECT * FROM sync_changes WHERE dataset = ? ORDER BY id DESC LIMIT 50").all(dataset)
|
|
2346
|
+
: db.prepare("SELECT * FROM sync_changes ORDER BY id DESC LIMIT 50").all();
|
|
2347
|
+
return rows.map((row) => ({
|
|
2348
|
+
...row,
|
|
2349
|
+
summary: summarizeChange(row),
|
|
2350
|
+
}));
|
|
2351
|
+
} finally {
|
|
2352
|
+
db.close();
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
function summarizeChange(row) {
|
|
2357
|
+
const payload = row.new_json || row.old_json;
|
|
2358
|
+
if (!payload) return "-";
|
|
2359
|
+
try {
|
|
2360
|
+
const item = selectPublicSummary(JSON.parse(payload));
|
|
2361
|
+
return item.name || item.inn || "-";
|
|
2362
|
+
} catch {
|
|
2363
|
+
return "-";
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function findCard(query) {
|
|
2368
|
+
const normalized = query.toLocaleLowerCase("ru-RU");
|
|
2369
|
+
const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
|
|
2370
|
+
const inn = normalized.match(/\b\d{10,12}\b/)?.[0];
|
|
2371
|
+
const number = normalized.match(/\b\d{1,3}\b/)?.[0];
|
|
2372
|
+
const rows = searchLocalRecords(inn || number || query, { dataset, limit: 20, fts: false });
|
|
2373
|
+
if (inn) return rows.find((row) => String(row.inn) === inn) || null;
|
|
2374
|
+
if (number) return rows.find((row) => String(row.name || "").includes(`№ ${number}`) || String(row.name || "").includes(`№${number}`)) || rows[0] || null;
|
|
2375
|
+
return rows[0] || null;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function runQuality(scope) {
|
|
2379
|
+
const datasets = ["schools", "kindergartens"];
|
|
2380
|
+
const rows = [];
|
|
2381
|
+
for (const dataset of datasets) {
|
|
2382
|
+
if (scope !== "all" && scope !== dataset && !scope.includes("-")) continue;
|
|
2383
|
+
const records = searchLocalRecords("", { dataset, limit: 1000 });
|
|
2384
|
+
const missingPhones = records.filter((item) => !item.phone || item.phone === "-");
|
|
2385
|
+
const invalidEmails = records.filter((item) => item.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(item.email));
|
|
2386
|
+
const innCounts = new Map();
|
|
2387
|
+
for (const item of records) innCounts.set(item.inn, (innCounts.get(item.inn) || 0) + 1);
|
|
2388
|
+
const duplicateInn = records.filter((item) => item.inn && innCounts.get(item.inn) > 1);
|
|
2389
|
+
if (scope === "all" || scope === dataset || scope === "missing-phones") rows.push({ check: "missing-phones", dataset, count: missingPhones.length, sample: missingPhones[0]?.name || "-" });
|
|
2390
|
+
if (scope === "all" || scope === dataset || scope === "invalid-emails") rows.push({ check: "invalid-emails", dataset, count: invalidEmails.length, sample: invalidEmails[0]?.email || "-" });
|
|
2391
|
+
if (scope === "all" || scope === dataset || scope === "duplicate-inn") rows.push({ check: "duplicate-inn", dataset, count: duplicateInn.length, sample: duplicateInn[0]?.inn || "-" });
|
|
2392
|
+
}
|
|
2393
|
+
return rows;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
function saveView(name, dataset, args) {
|
|
2397
|
+
initDatabase();
|
|
2398
|
+
const db = openDatabase();
|
|
2399
|
+
try {
|
|
2400
|
+
db.prepare(`
|
|
2401
|
+
INSERT INTO saved_views(name, dataset, query_json) VALUES (?, ?, ?)
|
|
2402
|
+
ON CONFLICT(name) DO UPDATE SET dataset = excluded.dataset, query_json = excluded.query_json
|
|
2403
|
+
`).run(name, dataset, JSON.stringify({ args }));
|
|
2404
|
+
} finally {
|
|
2405
|
+
db.close();
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function listSavedViews() {
|
|
2410
|
+
initDatabase();
|
|
2411
|
+
const db = openDatabase();
|
|
2412
|
+
try {
|
|
2413
|
+
return db.prepare("SELECT name, dataset, created_at FROM saved_views ORDER BY name").all();
|
|
2414
|
+
} finally {
|
|
2415
|
+
db.close();
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
function getSavedView(name) {
|
|
2420
|
+
initDatabase();
|
|
2421
|
+
const db = openDatabase();
|
|
2422
|
+
try {
|
|
2423
|
+
const row = db.prepare("SELECT * FROM saved_views WHERE name = ?").get(name);
|
|
2424
|
+
if (!row) throw new Error(`View не найден: ${name}`);
|
|
2425
|
+
return row;
|
|
2426
|
+
} finally {
|
|
2427
|
+
db.close();
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function deleteSavedView(name) {
|
|
2432
|
+
if (!name) {
|
|
2433
|
+
throw new Error("Имя view обязательно.");
|
|
2434
|
+
}
|
|
2435
|
+
initDatabase();
|
|
2436
|
+
const db = openDatabase();
|
|
2437
|
+
try {
|
|
2438
|
+
db.prepare("DELETE FROM saved_views WHERE name = ?").run(name);
|
|
2439
|
+
} finally {
|
|
2440
|
+
db.close();
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function getLocalSummaryRows(dataset) {
|
|
2445
|
+
const rows = searchLocalRecords("", { dataset, limit: 1000 });
|
|
2446
|
+
return [
|
|
2447
|
+
{ metric: "records", value: rows.length },
|
|
2448
|
+
{ metric: "with_phone", value: rows.filter((row) => row.phone && row.phone !== "-").length },
|
|
2449
|
+
{ metric: "with_email", value: rows.filter((row) => row.email).length },
|
|
2450
|
+
{ metric: "with_website", value: rows.filter((row) => row.website).length },
|
|
2451
|
+
];
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function exportDbSnapshot() {
|
|
2455
|
+
initDatabase();
|
|
2456
|
+
return {
|
|
2457
|
+
db: getDbStatus(),
|
|
2458
|
+
views: listSavedViews(),
|
|
2459
|
+
aliases: listAliases(),
|
|
2460
|
+
features: listFeatures(),
|
|
2461
|
+
sessions: listSessions(100),
|
|
2462
|
+
history: listHistory(100),
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
function listAliases() {
|
|
2467
|
+
initDatabase();
|
|
2468
|
+
const db = openDatabase();
|
|
2469
|
+
try {
|
|
2470
|
+
return db.prepare("SELECT name, command FROM aliases ORDER BY name").all();
|
|
2471
|
+
} finally {
|
|
2472
|
+
db.close();
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function getAlias(name) {
|
|
2477
|
+
try {
|
|
2478
|
+
initDatabase();
|
|
2479
|
+
const db = openDatabase();
|
|
2480
|
+
try {
|
|
2481
|
+
return db.prepare("SELECT name, command FROM aliases WHERE name = ?").get(name);
|
|
2482
|
+
} finally {
|
|
2483
|
+
db.close();
|
|
2484
|
+
}
|
|
2485
|
+
} catch {
|
|
2486
|
+
return null;
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
function saveAlias(name, command) {
|
|
2491
|
+
initDatabase();
|
|
2492
|
+
const db = openDatabase();
|
|
2493
|
+
try {
|
|
2494
|
+
db.prepare("INSERT INTO aliases(name, command) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET command = excluded.command").run(name, command);
|
|
2495
|
+
} finally {
|
|
2496
|
+
db.close();
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
function deleteAlias(name) {
|
|
2501
|
+
initDatabase();
|
|
2502
|
+
const db = openDatabase();
|
|
2503
|
+
try {
|
|
2504
|
+
db.prepare("DELETE FROM aliases WHERE name = ?").run(name);
|
|
2505
|
+
} finally {
|
|
2506
|
+
db.close();
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function inferCommandFromText(text) {
|
|
2511
|
+
const normalized = text.toLocaleLowerCase("ru-RU");
|
|
2512
|
+
const dataset = normalized.includes("сад") ? "kindergartens" : "schools";
|
|
2513
|
+
const command = ["data", dataset];
|
|
2514
|
+
const street = normalized.match(/(?:на|по|улица|ул\.?)\s+([а-яёa-z-]+)/iu)?.[1];
|
|
2515
|
+
if (street) command.push("--where", `address=${street}`);
|
|
2516
|
+
if (normalized.includes("csv")) command.push("--format", "csv");
|
|
2517
|
+
if (normalized.includes("json")) command.push("--format", "json");
|
|
2518
|
+
const output = text.match(/(?:в файл|файл)\s+([^\s]+)/iu)?.[1];
|
|
2519
|
+
if (output) command.push("--output", output);
|
|
2520
|
+
return command;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
async function outputData(value, options, format) {
|
|
2524
|
+
const text = format === "csv" ? toCsv(value) : `${JSON.stringify(value, null, 2)}\n`;
|
|
2525
|
+
if (options.output) {
|
|
2526
|
+
await writeFile(options.output, text, "utf8");
|
|
2527
|
+
console.log(`Файл сохранен: ${options.output}`);
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
process.stdout.write(text);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function toCsv(rows) {
|
|
2534
|
+
const list = Array.isArray(rows) ? rows : [rows];
|
|
2535
|
+
if (list.length === 0) return "";
|
|
2536
|
+
const columns = [...new Set(list.flatMap((row) => Object.keys(row)))];
|
|
2537
|
+
return `${columns.map(csvCell).join(",")}\n${list.map((row) => columns.map((column) => csvCell(row[column])).join(",")).join("\n")}\n`;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
1865
2540
|
function assertKeyProvider(provider) {
|
|
1866
2541
|
if (provider !== "openai" && provider !== "openrouter") {
|
|
1867
2542
|
throw new Error("Провайдер должен быть openai или openrouter.");
|
|
@@ -1951,6 +2626,9 @@ async function aiAsk(args, context = {}) {
|
|
|
1951
2626
|
|
|
1952
2627
|
const config = await loadConfig();
|
|
1953
2628
|
const providerConfig = resolveAiProfile(config, options);
|
|
2629
|
+
if (options.tools && providerConfig.provider === "ollama") {
|
|
2630
|
+
return localToolAsk(question, providerConfig, options);
|
|
2631
|
+
}
|
|
1954
2632
|
applyRuntimeConfig(providerConfig, options.config);
|
|
1955
2633
|
const dataContext = await buildDataContext(question);
|
|
1956
2634
|
emitEvent(options, "context_loaded", { schools: dataContext.schools.length, kindergartens: dataContext.kindergartens.length });
|
|
@@ -2014,6 +2692,132 @@ function resolveAiProfile(config, options = {}) {
|
|
|
2014
2692
|
};
|
|
2015
2693
|
}
|
|
2016
2694
|
|
|
2695
|
+
async function localToolAsk(question, providerConfig, options) {
|
|
2696
|
+
await ensureLocalData();
|
|
2697
|
+
const plan = await buildLocalToolPlan(question, providerConfig, options);
|
|
2698
|
+
const validated = validateToolPlan(plan);
|
|
2699
|
+
const result = await executeToolPlan(validated);
|
|
2700
|
+
const answer = formatToolResult(result, options);
|
|
2701
|
+
|
|
2702
|
+
if (!options["no-history"] && isFeatureEnabled("sqlite-history")) {
|
|
2703
|
+
recordAskHistory({
|
|
2704
|
+
question,
|
|
2705
|
+
answer,
|
|
2706
|
+
providerConfig,
|
|
2707
|
+
dataContext: { tool_plan: validated, tool_result: result },
|
|
2708
|
+
error: "",
|
|
2709
|
+
sessionId: null,
|
|
2710
|
+
});
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
emitEvent(options, "tool_plan", { plan: validated });
|
|
2714
|
+
if (options.output) await writeFile(options.output, answer, "utf8");
|
|
2715
|
+
if (options.format === "json" || options.schema === "json") {
|
|
2716
|
+
printJson({ answer, plan: validated, result });
|
|
2717
|
+
} else {
|
|
2718
|
+
console.log(answer);
|
|
2719
|
+
}
|
|
2720
|
+
return answer;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
async function buildLocalToolPlan(question, providerConfig, options) {
|
|
2724
|
+
const mode = options.reasoning || "verify";
|
|
2725
|
+
const prompt = [
|
|
2726
|
+
"Ты планировщик CLI iola. Верни только JSON.",
|
|
2727
|
+
"Доступные tools: search_local, get_card, export_data, run_report, save_view.",
|
|
2728
|
+
"Схема: {\"steps\":[{\"tool\":\"search_local\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
|
|
2729
|
+
"Для выгрузки CSV добавь export_data с format=csv и output, если пользователь назвал файл.",
|
|
2730
|
+
`Вопрос: ${question}`,
|
|
2731
|
+
].join("\n");
|
|
2732
|
+
|
|
2733
|
+
try {
|
|
2734
|
+
const raw = await callOllama(providerConfig, [{ role: "user", content: prompt }]);
|
|
2735
|
+
const parsed = parseJsonObject(raw);
|
|
2736
|
+
if (mode === "vote") {
|
|
2737
|
+
return chooseBestPlan([parsed, inferToolPlan(question)]);
|
|
2738
|
+
}
|
|
2739
|
+
return parsed;
|
|
2740
|
+
} catch {
|
|
2741
|
+
return inferToolPlan(question);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function parseJsonObject(text) {
|
|
2746
|
+
const match = String(text).match(/\{[\s\S]*\}/);
|
|
2747
|
+
if (!match) throw new Error("JSON-план не найден.");
|
|
2748
|
+
return JSON.parse(match[0]);
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
function inferToolPlan(question) {
|
|
2752
|
+
const normalized = question.toLocaleLowerCase("ru-RU");
|
|
2753
|
+
const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
|
|
2754
|
+
const steps = [];
|
|
2755
|
+
if (normalized.includes("без телефона")) {
|
|
2756
|
+
steps.push({ tool: "run_report", args: { name: "missing-phones" } });
|
|
2757
|
+
} else {
|
|
2758
|
+
const query = normalized.match(/петрова|школ[а-яё ]*\d+|сад[а-яё ]*\d+|лицей[а-яё ]*\d+/iu)?.[0] || question;
|
|
2759
|
+
steps.push({ tool: "search_local", args: { dataset, query, limit: 20 } });
|
|
2760
|
+
}
|
|
2761
|
+
if (normalized.includes("csv") || normalized.includes("выгруз")) {
|
|
2762
|
+
steps.push({ tool: "export_data", args: { format: "csv", output: normalized.match(/([a-z0-9_-]+\.csv)/i)?.[1] || "iola-export.csv" } });
|
|
2763
|
+
}
|
|
2764
|
+
return { steps };
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
function chooseBestPlan(plans) {
|
|
2768
|
+
return plans.find((plan) => {
|
|
2769
|
+
try {
|
|
2770
|
+
validateToolPlan(plan);
|
|
2771
|
+
return true;
|
|
2772
|
+
} catch {
|
|
2773
|
+
return false;
|
|
2774
|
+
}
|
|
2775
|
+
}) || plans.at(-1);
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
function validateToolPlan(plan) {
|
|
2779
|
+
const allowed = new Set(["search_local", "get_card", "export_data", "run_report", "save_view"]);
|
|
2780
|
+
if (!plan || !Array.isArray(plan.steps)) throw new Error("Некорректный tool-plan.");
|
|
2781
|
+
for (const step of plan.steps) {
|
|
2782
|
+
if (!allowed.has(step.tool)) throw new Error(`Недопустимый tool: ${step.tool}`);
|
|
2783
|
+
}
|
|
2784
|
+
return plan;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
async function executeToolPlan(plan) {
|
|
2788
|
+
let current = [];
|
|
2789
|
+
const outputs = [];
|
|
2790
|
+
for (const step of plan.steps) {
|
|
2791
|
+
if (step.tool === "search_local") {
|
|
2792
|
+
current = searchLocalRecords(step.args?.query || "", { dataset: step.args?.dataset || "all", limit: step.args?.limit || 20, fts: true });
|
|
2793
|
+
outputs.push({ tool: step.tool, rows: current.length });
|
|
2794
|
+
} else if (step.tool === "get_card") {
|
|
2795
|
+
const card = findCard(step.args?.query || "");
|
|
2796
|
+
current = card ? [card] : [];
|
|
2797
|
+
outputs.push({ tool: step.tool, rows: current.length });
|
|
2798
|
+
} else if (step.tool === "run_report") {
|
|
2799
|
+
current = runQuality(step.args?.name || "all");
|
|
2800
|
+
outputs.push({ tool: step.tool, rows: current.length });
|
|
2801
|
+
} else if (step.tool === "save_view") {
|
|
2802
|
+
saveView(step.args?.name, step.args?.dataset || "all", step.args?.args || []);
|
|
2803
|
+
outputs.push({ tool: step.tool, saved: step.args?.name });
|
|
2804
|
+
} else if (step.tool === "export_data") {
|
|
2805
|
+
const text = step.args?.format === "json" ? JSON.stringify(current, null, 2) : toCsv(current);
|
|
2806
|
+
await writeFile(step.args?.output || "iola-export.csv", text, "utf8");
|
|
2807
|
+
outputs.push({ tool: step.tool, output: step.args?.output || "iola-export.csv", rows: current.length });
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
return { rows: current, outputs };
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
function formatToolResult(result, options) {
|
|
2814
|
+
if (options.schema === "json") return JSON.stringify(result, null, 2);
|
|
2815
|
+
const exported = result.outputs.find((item) => item.output);
|
|
2816
|
+
if (exported) return `Готово. Файл сохранен: ${exported.output}. Записей: ${exported.rows}`;
|
|
2817
|
+
if (!result.rows.length) return "Данных не найдено.";
|
|
2818
|
+
return result.rows.slice(0, 10).map((row) => `${row.name || row.check}: ${row.address || row.count || ""}`).join("\n");
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2017
2821
|
function applyRuntimeConfig(target, value) {
|
|
2018
2822
|
if (!value) {
|
|
2019
2823
|
return;
|
|
@@ -2396,20 +3200,26 @@ async function listDataset(dataset, args) {
|
|
|
2396
3200
|
params.set("limit", options.limit || "20");
|
|
2397
3201
|
params.set("offset", options.offset || "0");
|
|
2398
3202
|
|
|
2399
|
-
const data =
|
|
2400
|
-
|
|
3203
|
+
const data = options.local
|
|
3204
|
+
? searchLocalRecords(options.search || options._.join(" ") || "", { dataset, limit: Number(options.limit || 20), fts: options.fts })
|
|
3205
|
+
: normalizeItems(await fetchJsonMaybeCached(`${await getApiBaseUrl()}/${DATASETS[dataset].endpoint}?${params}`, options));
|
|
3206
|
+
const items = data;
|
|
2401
3207
|
const filtered = applyDatasetFilters(items, options);
|
|
2402
3208
|
const limited = filtered.slice(0, Number(options.limit || 20));
|
|
2403
3209
|
const summarized = limited.map(selectPublicSummary);
|
|
2404
3210
|
const projected = projectColumns(summarized, options.columns);
|
|
3211
|
+
if (options.save) {
|
|
3212
|
+
saveView(options.save, dataset, args.filter((arg) => arg !== "--save" && arg !== options.save));
|
|
3213
|
+
console.log(`View сохранен: ${options.save}`);
|
|
3214
|
+
}
|
|
2405
3215
|
|
|
2406
3216
|
if (options.json || options.format === "json") {
|
|
2407
|
-
|
|
3217
|
+
await outputData(projected, options, "json");
|
|
2408
3218
|
return;
|
|
2409
3219
|
}
|
|
2410
3220
|
|
|
2411
3221
|
if (options.format === "csv") {
|
|
2412
|
-
|
|
3222
|
+
await outputData(projected, options, "csv");
|
|
2413
3223
|
return;
|
|
2414
3224
|
}
|
|
2415
3225
|
|
|
@@ -2444,27 +3254,32 @@ async function searchAll(args) {
|
|
|
2444
3254
|
throw new Error('Search text is required. Example: iola search "лицей"');
|
|
2445
3255
|
}
|
|
2446
3256
|
|
|
2447
|
-
const [schools, kindergartens] = await Promise.all([
|
|
2448
|
-
fetchJson(`${await getApiBaseUrl()}/schools?limit=100&offset=0`),
|
|
2449
|
-
fetchJson(`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`),
|
|
2450
|
-
]);
|
|
2451
|
-
|
|
2452
3257
|
const limit = Number(options.limit || 5);
|
|
3258
|
+
const [schools, kindergartens] = options.local
|
|
3259
|
+
? [
|
|
3260
|
+
searchLocalRecords(query, { dataset: "schools", limit, fts: options.fts }),
|
|
3261
|
+
searchLocalRecords(query, { dataset: "kindergartens", limit, fts: options.fts }),
|
|
3262
|
+
]
|
|
3263
|
+
: await Promise.all([
|
|
3264
|
+
fetchJsonMaybeCached(`${await getApiBaseUrl()}/schools?limit=100&offset=0`, options),
|
|
3265
|
+
fetchJsonMaybeCached(`${await getApiBaseUrl()}/kindergartens?limit=100&offset=0`, options),
|
|
3266
|
+
]);
|
|
3267
|
+
|
|
2453
3268
|
const result = {
|
|
2454
3269
|
schools: projectColumns(filterItems(normalizeItems(schools), query).slice(0, limit).map(selectPublicSummary), options.columns),
|
|
2455
3270
|
kindergartens: projectColumns(filterItems(normalizeItems(kindergartens), query).slice(0, limit).map(selectPublicSummary), options.columns),
|
|
2456
3271
|
};
|
|
2457
3272
|
|
|
2458
3273
|
if (options.json || options.format === "json") {
|
|
2459
|
-
|
|
3274
|
+
await outputData(result, options, "json");
|
|
2460
3275
|
return;
|
|
2461
3276
|
}
|
|
2462
3277
|
|
|
2463
3278
|
if (options.format === "csv") {
|
|
2464
|
-
|
|
3279
|
+
await outputData([
|
|
2465
3280
|
...result.schools.map((item) => ({ layer: "schools", ...item })),
|
|
2466
3281
|
...result.kindergartens.map((item) => ({ layer: "kindergartens", ...item })),
|
|
2467
|
-
]);
|
|
3282
|
+
], options, "csv");
|
|
2468
3283
|
return;
|
|
2469
3284
|
}
|
|
2470
3285
|
|
|
@@ -2492,12 +3307,12 @@ function parseOptions(args) {
|
|
|
2492
3307
|
|
|
2493
3308
|
for (let index = 0; index < args.length; index += 1) {
|
|
2494
3309
|
const arg = args[index];
|
|
2495
|
-
if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all") {
|
|
3310
|
+
if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--fts") {
|
|
2496
3311
|
result[arg.slice(2)] = true;
|
|
2497
3312
|
} else if (arg === "--check" || arg === "--upgrade-node") {
|
|
2498
3313
|
result.check = true;
|
|
2499
3314
|
result[arg.slice(2)] = true;
|
|
2500
|
-
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config") {
|
|
3315
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning") {
|
|
2501
3316
|
result[arg.slice(2)] = args[index + 1];
|
|
2502
3317
|
index += 1;
|
|
2503
3318
|
} else {
|