@rennimars/dsh-weknora-memory 0.2.0 → 0.3.0
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/CHANGELOG.md +26 -1
- package/README.md +145 -29
- package/lib/retrieve.js +23 -48
- package/lib/weknora.js +80 -0
- package/package.json +7 -3
- package/writers/README.md +24 -0
- package/writers/config.example.json +57 -0
- package/writers/distill.mjs +747 -0
- package/writers/dream.mjs +787 -0
- package/writers/ingest.mjs +689 -0
- package/writers/profile-writer.mjs +468 -0
- package/writers/register-tasks.ps1 +71 -0
- package/writers/run-all.cmd +53 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* writers/ingest.mjs —— DSH 会话转录原样写入 WeKnora 库①(kbs.session)。
|
|
4
|
+
*
|
|
5
|
+
* 行为移植自已验证的生产脚本:walk 会话目录(每个会话目录只取最新的一份转录)→ 按 zstd
|
|
6
|
+
* magic 切多帧解压 → 只收 user(source.kind === 'user')与 assistant 的文本 → 按 maxDocBytes
|
|
7
|
+
* 分片渲染 → 用「state 记录的 id / 精确标题 / [会话尾号] 标记」定位既有文档,命中就
|
|
8
|
+
* PUT /knowledge/manual/<id> 复用同一篇,只有真正新建才 POST manual。
|
|
9
|
+
*
|
|
10
|
+
* 契约(writers/README.md):
|
|
11
|
+
* - config.apiUrl 已含 /api/v1,路径里不再写版本前缀;
|
|
12
|
+
* - 一切 HTTP 走 ../lib/weknora.js 的 request(),这里不拼 header、不写 fetch;
|
|
13
|
+
* - apiKey 只从环境变量 WEKNORA_API_KEY 取;
|
|
14
|
+
* - 本模块被 distill.mjs 复用解析层,导入不得产生任何副作用(不读配置、不写文件、不联网)。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import zlib from 'node:zlib';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
+
import { request, baseUrlOf } from '../lib/weknora.js';
|
|
22
|
+
|
|
23
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const PKG_ROOT = path.resolve(HERE, '..');
|
|
25
|
+
const CONFIG_NAME = 'writer.config.json';
|
|
26
|
+
const EXAMPLE_NAME = 'config.example.json';
|
|
27
|
+
const STATE_PATH = path.join(PKG_ROOT, 'data', 'writer-state', 'ingest.json');
|
|
28
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
29
|
+
const PAGE_SIZE = 100;
|
|
30
|
+
const MAX_PAGES = 20;
|
|
31
|
+
const POLL_INTERVAL_MS = 2000;
|
|
32
|
+
const BYTE_HEADROOM = 0.98;
|
|
33
|
+
const TITLE_SLICE = 50;
|
|
34
|
+
const SID_TAIL = 8;
|
|
35
|
+
|
|
36
|
+
const USAGE = [
|
|
37
|
+
'用法: node writers/ingest.mjs [选项]',
|
|
38
|
+
'',
|
|
39
|
+
'把已结束且空闲 >= settleMinutes 的 DSH 会话转录原样写入 WeKnora(库① kbs.session)。',
|
|
40
|
+
'已存在的文档按 state 的 id / 精确标题 / [会话尾号] 标记定位,走 PUT 复用同一篇,可重复执行。',
|
|
41
|
+
'',
|
|
42
|
+
'选项:',
|
|
43
|
+
' --dry-run 只打印本次计划:不写知识库、不写 state、不写任何文件',
|
|
44
|
+
' --config <path> 配置文件路径(默认 writers/writer.config.json,缺失时回退 config.example.json 的默认值)',
|
|
45
|
+
' --verbose, -v 打印每个会话的判定细节(跳过原因、字节数、分片数)',
|
|
46
|
+
' --limit <n> 本次最多处理 n 个会话(默认不限制)',
|
|
47
|
+
' --only <substr> 只处理路径里包含 substr 的会话',
|
|
48
|
+
' --settle <minutes> 覆盖 ingest.settleMinutes:会话最后一次写入后需空闲多少分钟',
|
|
49
|
+
' --help, -h 打印本帮助并退出',
|
|
50
|
+
'',
|
|
51
|
+
'凭证: apiKey 只从环境变量 WEKNORA_API_KEY 读取,配置文件里不放密钥。',
|
|
52
|
+
'退出码: 0 成功;1 有会话失败;2 配置或连接失败。'
|
|
53
|
+
].join('\n');
|
|
54
|
+
|
|
55
|
+
// ---------------- 参数与配置 ----------------
|
|
56
|
+
|
|
57
|
+
const BOOLEAN_FLAGS = {
|
|
58
|
+
'--dry-run': 'dryRun', '--dry': 'dryRun', '--verbose': 'verbose', '-v': 'verbose',
|
|
59
|
+
'--no-llm': 'noLlm', '--help': 'help', '-h': 'help'
|
|
60
|
+
};
|
|
61
|
+
const VALUED_FLAGS = new Set(['--config', '--limit', '--only', '--settle', '--since']);
|
|
62
|
+
|
|
63
|
+
export function parseWriterArgs(argv) {
|
|
64
|
+
const out = { help: false, dryRun: false, verbose: false, noLlm: false, values: {}, unknown: [] };
|
|
65
|
+
const list = Array.isArray(argv) ? argv : [];
|
|
66
|
+
for (let i = 0; i < list.length; i++) {
|
|
67
|
+
const item = list[i];
|
|
68
|
+
if (BOOLEAN_FLAGS[item]) { out[BOOLEAN_FLAGS[item]] = true; continue; }
|
|
69
|
+
if (VALUED_FLAGS.has(item)) {
|
|
70
|
+
const value = list[i + 1];
|
|
71
|
+
if (value === undefined || value.startsWith('--')) throw new Error('参数 ' + item + ' 缺少取值');
|
|
72
|
+
out.values[item.slice(2)] = value;
|
|
73
|
+
i++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
out.unknown.push(item);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function makeLog(prefix, opts) {
|
|
82
|
+
const verbose = !!(opts && opts.verbose);
|
|
83
|
+
const emit = (stream, tag, msg) => stream.write(prefix + (tag ? ' ' + tag : '') + ' ' + msg + '\n');
|
|
84
|
+
return {
|
|
85
|
+
info: (msg) => emit(process.stdout, '', msg),
|
|
86
|
+
detail: (msg) => { if (verbose) emit(process.stdout, '', msg); },
|
|
87
|
+
warn: (msg) => emit(process.stderr, '[warn]', msg),
|
|
88
|
+
error: (msg) => emit(process.stderr, '[error]', msg)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readJson(file, what) {
|
|
93
|
+
let raw = '';
|
|
94
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
95
|
+
catch (e) { throw new Error('读取' + what + '失败: ' + file + '(' + e.message + ')'); }
|
|
96
|
+
try { return JSON.parse(raw); }
|
|
97
|
+
catch (e) { throw new Error(what + '不是合法 JSON: ' + file + '(' + e.message + ')'); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function section(base, over) {
|
|
101
|
+
return Object.assign({}, base && typeof base === 'object' ? base : {}, over && typeof over === 'object' ? over : {});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 相对包根目录解析(与插件读侧 profile.path 的口径一致)。 */
|
|
105
|
+
function resolveFromRoot(value) {
|
|
106
|
+
const text = String(value == null ? '' : value).trim();
|
|
107
|
+
if (!text) return '';
|
|
108
|
+
return path.isAbsolute(text) ? text : path.resolve(PKG_ROOT, text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function num(value, fallback) {
|
|
112
|
+
const n = Number(value);
|
|
113
|
+
return Number.isFinite(n) ? n : fallback;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 配置 = config.example.json 的默认值 + 用户配置(--config 或 writers/writer.config.json)的覆盖。
|
|
118
|
+
* apiKey 永远只从 WEKNORA_API_KEY 取:文件里即使写了也不读。
|
|
119
|
+
*/
|
|
120
|
+
export function loadWriterConfig(argv) {
|
|
121
|
+
const args = parseWriterArgs(argv);
|
|
122
|
+
const notes = [];
|
|
123
|
+
const exampleFile = path.join(HERE, EXAMPLE_NAME);
|
|
124
|
+
let file = '';
|
|
125
|
+
if (args.values.config) {
|
|
126
|
+
file = path.resolve(process.cwd(), args.values.config);
|
|
127
|
+
if (!fs.existsSync(file)) throw new Error('配置文件不存在: ' + file);
|
|
128
|
+
} else {
|
|
129
|
+
file = path.join(HERE, CONFIG_NAME);
|
|
130
|
+
if (!fs.existsSync(file)) {
|
|
131
|
+
file = exampleFile;
|
|
132
|
+
notes.push('未找到 writers/' + CONFIG_NAME + ',改用 ' + EXAMPLE_NAME + ' 的默认值');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const base = readJson(exampleFile, '默认配置');
|
|
136
|
+
const user = file === exampleFile ? {} : readJson(file, '配置文件');
|
|
137
|
+
const baseIngest = base.ingest || {};
|
|
138
|
+
const userIngest = user.ingest || {};
|
|
139
|
+
const baseDistill = base.distill || {};
|
|
140
|
+
const userDistill = user.distill || {};
|
|
141
|
+
|
|
142
|
+
const cfg = {
|
|
143
|
+
apiUrl: String(user.apiUrl != null ? user.apiUrl : base.apiUrl || '').trim(),
|
|
144
|
+
apiKey: String(process.env.WEKNORA_API_KEY || '').trim(),
|
|
145
|
+
sessionsRoot: resolveFromRoot(user.sessionsRoot != null ? user.sessionsRoot : base.sessionsRoot),
|
|
146
|
+
kbs: {
|
|
147
|
+
session: String((user.kbs && user.kbs.session) || (base.kbs && base.kbs.session) || ''),
|
|
148
|
+
knowledge: String((user.kbs && user.kbs.knowledge) || (base.kbs && base.kbs.knowledge) || ''),
|
|
149
|
+
material: String((user.kbs && user.kbs.material) || (base.kbs && base.kbs.material) || '')
|
|
150
|
+
},
|
|
151
|
+
ingest: section(baseIngest, userIngest),
|
|
152
|
+
distill: Object.assign(section(baseDistill, userDistill), {
|
|
153
|
+
semanticDedupe: section(baseDistill.semanticDedupe, userDistill.semanticDedupe)
|
|
154
|
+
}),
|
|
155
|
+
dream: section(base.dream, user.dream),
|
|
156
|
+
llm: section(base.llm, user.llm)
|
|
157
|
+
};
|
|
158
|
+
if (!cfg.apiKey) notes.push('环境变量 WEKNORA_API_KEY 未设置:本次请求不带 X-API-Key');
|
|
159
|
+
return { cfg, file, notes };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------- 转录解析 ----------------
|
|
163
|
+
|
|
164
|
+
const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd];
|
|
165
|
+
|
|
166
|
+
export function decodeTranscript(file) {
|
|
167
|
+
if (typeof zlib.zstdDecompressSync !== 'function') {
|
|
168
|
+
throw new Error('当前 Node 不支持 zstd 解压(需要 Node 22.15+ / 23.8+ 才有 zlib.zstdDecompressSync)');
|
|
169
|
+
}
|
|
170
|
+
const buf = fs.readFileSync(file);
|
|
171
|
+
const starts = [];
|
|
172
|
+
for (let i = 0; i + 4 <= buf.length; i++) {
|
|
173
|
+
if (buf[i] === ZSTD_MAGIC[0] && buf[i + 1] === ZSTD_MAGIC[1] && buf[i + 2] === ZSTD_MAGIC[2] && buf[i + 3] === ZSTD_MAGIC[3]) starts.push(i);
|
|
174
|
+
}
|
|
175
|
+
if (!starts.length) throw new Error('转录里找不到 zstd 帧: ' + file);
|
|
176
|
+
const out = [];
|
|
177
|
+
let badFrames = 0;
|
|
178
|
+
for (let k = 0; k < starts.length; k++) {
|
|
179
|
+
const begin = starts[k];
|
|
180
|
+
const end = k + 1 < starts.length ? starts[k + 1] : buf.length;
|
|
181
|
+
try { out.push(zlib.zstdDecompressSync(buf.subarray(begin, end))); } catch { badFrames++; }
|
|
182
|
+
}
|
|
183
|
+
const text = Buffer.concat(out).toString('utf8');
|
|
184
|
+
const objs = [];
|
|
185
|
+
let badLines = 0;
|
|
186
|
+
for (const line of text.split('\n')) {
|
|
187
|
+
if (!line.trim()) continue;
|
|
188
|
+
try { objs.push(JSON.parse(line)); } catch { badLines++; }
|
|
189
|
+
}
|
|
190
|
+
return { objs, frames: starts.length, badFrames, badLines };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function textOf(items) {
|
|
194
|
+
return (items || [])
|
|
195
|
+
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
196
|
+
.map((part) => part.text)
|
|
197
|
+
.join('\n')
|
|
198
|
+
.trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function extractSession(file) {
|
|
202
|
+
const { objs, frames, badFrames, badLines } = decodeTranscript(file);
|
|
203
|
+
const meta = objs.find((o) => o.type === 'session') || {};
|
|
204
|
+
const titles = objs
|
|
205
|
+
.filter((o) => o.type === 'session/title' && o.data && o.data.title)
|
|
206
|
+
.map((o) => o.data.title);
|
|
207
|
+
const turns = [];
|
|
208
|
+
for (const o of objs) {
|
|
209
|
+
if (o.type === 'user/message') {
|
|
210
|
+
const kind = o.data && o.data.source ? o.data.source.kind : undefined;
|
|
211
|
+
if (kind !== 'user') continue;
|
|
212
|
+
const text = textOf(o.data.content);
|
|
213
|
+
if (text) turns.push({ role: 'user', time: o.time, text });
|
|
214
|
+
} else if (o.type === 'assistant/message') {
|
|
215
|
+
const text = textOf(o.data && o.data.message ? o.data.message.content : []);
|
|
216
|
+
if (text) turns.push({ role: 'assistant', time: o.time, text });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
sid: meta.id || path.basename(path.dirname(file)),
|
|
221
|
+
cwd: meta.cwd || '',
|
|
222
|
+
createdAt: meta.createdAt || (turns[0] && turns[0].time) || 0,
|
|
223
|
+
title: titles.length ? titles[titles.length - 1] : '',
|
|
224
|
+
turns,
|
|
225
|
+
frames,
|
|
226
|
+
badFrames,
|
|
227
|
+
badLines,
|
|
228
|
+
mtime: fs.statSync(file).mtimeMs,
|
|
229
|
+
origin: meta.origin || null,
|
|
230
|
+
file
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const SESSION_RE = /^session\.v([34])\.jsonl\.zstd$/;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 每个会话目录只保留一份转录:v4 迁移重写过旧会话,会在同一目录留下一个陈旧的 v3。
|
|
238
|
+
* 取 mtime 最新者;并列时先比格式版本,再比文件大小。
|
|
239
|
+
*/
|
|
240
|
+
export function walkSessions(root) {
|
|
241
|
+
const best = new Map();
|
|
242
|
+
const stack = [root];
|
|
243
|
+
while (stack.length) {
|
|
244
|
+
const dir = stack.pop();
|
|
245
|
+
let entries = [];
|
|
246
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
247
|
+
for (const entry of entries) {
|
|
248
|
+
const full = path.join(dir, entry.name);
|
|
249
|
+
if (entry.isDirectory()) { stack.push(full); continue; }
|
|
250
|
+
const match = SESSION_RE.exec(entry.name);
|
|
251
|
+
if (!match) continue;
|
|
252
|
+
let stat;
|
|
253
|
+
try { stat = fs.statSync(full); } catch { continue; }
|
|
254
|
+
const cand = { file: full, v: Number(match[1]), mtime: stat.mtimeMs, size: stat.size };
|
|
255
|
+
const cur = best.get(dir);
|
|
256
|
+
if (!cur || cand.mtime > cur.mtime
|
|
257
|
+
|| (cand.mtime === cur.mtime && (cand.v > cur.v || (cand.v === cur.v && cand.size > cur.size)))) {
|
|
258
|
+
best.set(dir, cand);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return [...best.values()].sort((a, b) => b.mtime - a.mtime);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------- 渲染 ----------------
|
|
266
|
+
|
|
267
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
268
|
+
|
|
269
|
+
export function fmtDate(ms) {
|
|
270
|
+
if (!ms) return '未知';
|
|
271
|
+
const d = new Date(ms);
|
|
272
|
+
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function fmtTs(ms) {
|
|
276
|
+
if (!ms) return '未知';
|
|
277
|
+
const d = new Date(ms);
|
|
278
|
+
return fmtDate(ms) + ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function cleanTitle(value) {
|
|
282
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function docTitle(titlePrefix, sess, part, total) {
|
|
286
|
+
const base = titlePrefix + fmtDate(sess.createdAt).slice(0, 10) + ' '
|
|
287
|
+
+ cleanTitle(sess.title || sess.sid).slice(0, TITLE_SLICE) + ' [' + sess.sid.slice(-SID_TAIL) + ']';
|
|
288
|
+
return total > 1 ? base + ' (第 ' + part + ' 部分)' : base;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderTurn(index, turn) {
|
|
292
|
+
return '### 第 ' + index + ' 轮\n\n' + (turn.role === 'user' ? '用户' : '助手') + ':\n\n' + turn.text + '\n';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 一轮 = 一条用户消息 + 其后的助手消息。 */
|
|
296
|
+
function toRounds(turns) {
|
|
297
|
+
const rounds = [];
|
|
298
|
+
let current = null;
|
|
299
|
+
for (const turn of turns) {
|
|
300
|
+
if (turn.role === 'user' || !current) { current = []; rounds.push(current); }
|
|
301
|
+
current.push(turn);
|
|
302
|
+
}
|
|
303
|
+
return rounds;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function buildDocs(sess, opts) {
|
|
307
|
+
const titlePrefix = String(opts.titlePrefix || '');
|
|
308
|
+
const budget = Math.floor(num(opts.maxDocBytes, 200000) * BYTE_HEADROOM);
|
|
309
|
+
const header = (part, total) => '# ' + docTitle(titlePrefix, sess, part, total) + '\n\n'
|
|
310
|
+
+ '- 会话 ID:' + sess.sid + '\n'
|
|
311
|
+
+ '- 工作目录:' + (sess.cwd || '未知') + '\n'
|
|
312
|
+
+ '- 开始时间:' + fmtTs(sess.createdAt) + '\n'
|
|
313
|
+
+ '- 消息条数:' + sess.turns.length + '(用户 ' + sess.turns.filter((t) => t.role === 'user').length + ' 条)\n'
|
|
314
|
+
+ '- 来源:DSH 会话转录 ' + path.basename(sess.file) + '(' + sess.frames + ' 帧)\n'
|
|
315
|
+
+ (total > 1 ? '- 本部分:' + part + '/' + total + '\n' : '')
|
|
316
|
+
+ '\n## 对话\n\n';
|
|
317
|
+
|
|
318
|
+
const blocks = [];
|
|
319
|
+
let index = 1;
|
|
320
|
+
for (const round of toRounds(sess.turns)) {
|
|
321
|
+
for (const turn of round) blocks.push(renderTurn(index, turn));
|
|
322
|
+
index++;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const parts = [];
|
|
326
|
+
let current = [];
|
|
327
|
+
let size = 0;
|
|
328
|
+
for (const block of blocks) {
|
|
329
|
+
const bytes = Buffer.byteLength(block, 'utf8');
|
|
330
|
+
if (current.length && size + bytes > budget) { parts.push(current); current = []; size = 0; }
|
|
331
|
+
current.push(block);
|
|
332
|
+
size += bytes;
|
|
333
|
+
}
|
|
334
|
+
if (current.length) parts.push(current);
|
|
335
|
+
const total = parts.length || 1;
|
|
336
|
+
return (parts.length ? parts : [[]]).map((part, i) => header(i + 1, total) + part.join('\n'));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sha256(text) {
|
|
340
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------- HTTP(只经 lib/weknora.js) ----------------
|
|
344
|
+
|
|
345
|
+
export class ApiError extends Error {
|
|
346
|
+
constructor(message, info) {
|
|
347
|
+
super(message);
|
|
348
|
+
this.name = 'ApiError';
|
|
349
|
+
this.status = info && info.status;
|
|
350
|
+
this.reason = info && info.reason;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function briefJson(value) {
|
|
355
|
+
try { return JSON.stringify(value).slice(0, 200); } catch { return String(value).slice(0, 200); }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function api(ctx, method, target, body) {
|
|
359
|
+
const res = await request(ctx.cfg, method, target, { body, timeoutMs: ctx.timeoutMs || REQUEST_TIMEOUT_MS });
|
|
360
|
+
if (res.ok) return res.body;
|
|
361
|
+
const reason = String(res.reason || '');
|
|
362
|
+
const what = reason.indexOf('http_') === 0 ? 'HTTP ' + res.status
|
|
363
|
+
: reason === 'timeout' ? '请求超时'
|
|
364
|
+
: reason === 'transport' ? '无法连接 WeKnora(transport)'
|
|
365
|
+
: reason === 'no_api_url' ? 'apiUrl 未配置'
|
|
366
|
+
: '请求失败(' + reason + ')';
|
|
367
|
+
const where = baseUrlOf(ctx.cfg) ? ' @ ' + baseUrlOf(ctx.cfg) : '';
|
|
368
|
+
throw new ApiError(what + ': ' + method + ' ' + target + where, { status: res.status, reason });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function sleep(ms) {
|
|
372
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** 库里所有文档:标题 → 文档对象(分页读全)。 */
|
|
376
|
+
export async function listDocs(ctx, kbId) {
|
|
377
|
+
const map = new Map();
|
|
378
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
379
|
+
const target = '/knowledge-bases/' + encodeURIComponent(kbId) + '/knowledge?page=' + page + '&page_size=' + PAGE_SIZE;
|
|
380
|
+
const json = await api(ctx, 'GET', target);
|
|
381
|
+
const data = (json && json.data) || [];
|
|
382
|
+
for (const doc of data) if (doc && doc.title) map.set(doc.title, doc);
|
|
383
|
+
if (data.length < PAGE_SIZE) break;
|
|
384
|
+
}
|
|
385
|
+
return map;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function chunkCount(ctx, id) {
|
|
389
|
+
const json = await api(ctx, 'GET', '/chunks/' + encodeURIComponent(id) + '?page=1&page_size=' + PAGE_SIZE);
|
|
390
|
+
if (json && typeof json.total === 'number') return json.total;
|
|
391
|
+
return Array.isArray(json && json.data) ? json.data.length : 0;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 文档正文:把分片内容按页拼回纯文本。 */
|
|
395
|
+
export async function docText(ctx, id) {
|
|
396
|
+
const parts = [];
|
|
397
|
+
for (let page = 1; page <= 10; page++) {
|
|
398
|
+
const json = await api(ctx, 'GET', '/chunks/' + encodeURIComponent(id) + '?page=' + page + '&page_size=' + PAGE_SIZE);
|
|
399
|
+
const data = (json && json.data) || [];
|
|
400
|
+
for (const chunk of data) parts.push(chunk && chunk.content ? chunk.content : '');
|
|
401
|
+
if (data.length < PAGE_SIZE) break;
|
|
402
|
+
}
|
|
403
|
+
return parts.join('\n');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function waitParsed(ctx, id, timeoutSec) {
|
|
407
|
+
const deadline = Date.now() + Math.max(1, num(timeoutSec, 600)) * 1000;
|
|
408
|
+
let last = '';
|
|
409
|
+
while (Date.now() < deadline) {
|
|
410
|
+
const json = await api(ctx, 'GET', '/knowledge/' + encodeURIComponent(id));
|
|
411
|
+
const doc = (json && json.data) || {};
|
|
412
|
+
last = String(doc.parse_status || '');
|
|
413
|
+
if (last === 'completed') return { status: last, error: '' };
|
|
414
|
+
if (last === 'failed' || last === 'error') return { status: last, error: String(doc.error_message || '') };
|
|
415
|
+
await sleep(POLL_INTERVAL_MS);
|
|
416
|
+
}
|
|
417
|
+
return { status: 'timeout(' + last + ')', error: '' };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function createManualDoc(ctx, kbId, title, content, maxAttempts) {
|
|
421
|
+
const attempts = Math.max(1, num(maxAttempts, 1));
|
|
422
|
+
const body = { title, content, status: 'publish', channel: 'web', file_name: title + '.md' };
|
|
423
|
+
const target = '/knowledge-bases/' + encodeURIComponent(kbId) + '/knowledge/manual';
|
|
424
|
+
let lastErr = null;
|
|
425
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
426
|
+
try {
|
|
427
|
+
const json = await api(ctx, 'POST', target, body);
|
|
428
|
+
const doc = (json && json.data) || json || {};
|
|
429
|
+
if (!doc.id) throw new ApiError('创建文档未返回 id: ' + briefJson(json), {});
|
|
430
|
+
return doc.id;
|
|
431
|
+
} catch (err) {
|
|
432
|
+
lastErr = err;
|
|
433
|
+
if (err.status && err.status < 500 && err.status !== 429) break;
|
|
434
|
+
if (attempt < attempts) await sleep(1000 * attempt);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
throw lastErr || new Error('创建文档失败');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 重传正文:manual 文档走 PUT,复用既有 id,不新建文档。 */
|
|
441
|
+
export async function updateManualDoc(ctx, id, title, content) {
|
|
442
|
+
return api(ctx, 'PUT', '/knowledge/manual/' + encodeURIComponent(id), {
|
|
443
|
+
title, content, status: 'publish', channel: 'web', file_name: title + '.md'
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------- state ----------------
|
|
448
|
+
|
|
449
|
+
export function loadState(file, log) {
|
|
450
|
+
const empty = { version: 1, sessions: {} };
|
|
451
|
+
if (!fs.existsSync(file)) return empty;
|
|
452
|
+
try {
|
|
453
|
+
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
454
|
+
if (!state || typeof state !== 'object') throw new Error('不是对象');
|
|
455
|
+
if (!state.sessions || typeof state.sessions !== 'object') state.sessions = {};
|
|
456
|
+
state.version = 1;
|
|
457
|
+
return state;
|
|
458
|
+
} catch (err) {
|
|
459
|
+
if (log) log.warn('state 损坏,按空状态继续(仍可按标题幂等): ' + file + '(' + err.message + ')');
|
|
460
|
+
return empty;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function saveState(file, state) {
|
|
465
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
466
|
+
fs.writeFileSync(file, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------- 主流程 ----------------
|
|
470
|
+
|
|
471
|
+
function countPrefixed(existing, titlePrefix) {
|
|
472
|
+
let count = 0;
|
|
473
|
+
for (const title of existing.keys()) if (title.startsWith(titlePrefix)) count++;
|
|
474
|
+
return count;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** 一个分片对应的既有文档:state 记录的 id > 精确标题 > [尾号] 标记(会话标题漂移后仍命中同一篇)。 */
|
|
478
|
+
function resolveTarget(sess, existing, part, total, prev, titlePrefix) {
|
|
479
|
+
const rec = prev && Array.isArray(prev.docs) ? prev.docs.find((d) => d.part === part) : null;
|
|
480
|
+
if (rec && rec.knowledgeId) {
|
|
481
|
+
for (const [title, doc] of existing) if (doc.id === rec.knowledgeId) return { doc, title, via: 'state-id' };
|
|
482
|
+
}
|
|
483
|
+
const live = docTitle(titlePrefix, sess, part, total);
|
|
484
|
+
if (existing.has(live)) return { doc: existing.get(live), title: live, via: 'title' };
|
|
485
|
+
const tag = '[' + sess.sid.slice(-SID_TAIL) + ']';
|
|
486
|
+
for (const [title, doc] of existing) {
|
|
487
|
+
if (!title.includes(tag)) continue;
|
|
488
|
+
const match = /\(第 (\d+) 部分\)$/.exec(title);
|
|
489
|
+
if ((match ? Number(match[1]) : 1) === part) return { doc, title, via: 'tag' };
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const shortSid = (sid) => String(sid || '').slice(-SID_TAIL);
|
|
495
|
+
|
|
496
|
+
async function ingestRun(ctx, args) {
|
|
497
|
+
const cfg = ctx.cfg;
|
|
498
|
+
const log = ctx.log;
|
|
499
|
+
const kb = cfg.kbs.session;
|
|
500
|
+
if (!kb) throw new Error('kbs.session 未配置:请填写库①(会话记录)的知识库 id');
|
|
501
|
+
if (!cfg.apiUrl) throw new Error('apiUrl 未配置:应为 http://<host>:<port>/api/v1(含版本前缀)');
|
|
502
|
+
const root = cfg.sessionsRoot;
|
|
503
|
+
if (!root) throw new Error('sessionsRoot 未配置:请填会话转录根目录(相对包根目录,或绝对路径)');
|
|
504
|
+
if (!fs.existsSync(root)) throw new Error('sessionsRoot 不存在: ' + root);
|
|
505
|
+
|
|
506
|
+
const settleMin = Math.max(0, num(args.values.settle, num(cfg.ingest.settleMinutes, 10)));
|
|
507
|
+
const limit = Math.max(0, num(args.values.limit, 0));
|
|
508
|
+
const only = String(args.values.only || '');
|
|
509
|
+
const titlePrefix = String(cfg.ingest.titlePrefix || '');
|
|
510
|
+
const maxDocBytes = Math.max(1024, num(cfg.ingest.maxDocBytes, 200000));
|
|
511
|
+
const maxSessionDocs = Math.max(1, num(cfg.ingest.maxSessionDocs, 150));
|
|
512
|
+
const maxAttempts = Math.max(1, num(cfg.ingest.maxAttempts, 3));
|
|
513
|
+
const pollTimeoutSec = Math.max(1, num(cfg.ingest.pollTimeoutSec, 600));
|
|
514
|
+
const skipSubagent = cfg.ingest.skipSubagentSessions !== false;
|
|
515
|
+
|
|
516
|
+
let files = walkSessions(root);
|
|
517
|
+
if (only) files = files.filter((item) => item.file.includes(only));
|
|
518
|
+
if (limit) files = files.slice(0, limit);
|
|
519
|
+
|
|
520
|
+
log.info('== run start sessions=' + files.length + (args.dryRun ? ' DRY-RUN(不写库、不写 state)' : ''));
|
|
521
|
+
|
|
522
|
+
const state = loadState(STATE_PATH, log);
|
|
523
|
+
const existing = await listDocs(ctx, kb);
|
|
524
|
+
const baseDocCount = countPrefixed(existing, titlePrefix);
|
|
525
|
+
|
|
526
|
+
const imported = [];
|
|
527
|
+
const skipped = [];
|
|
528
|
+
const failed = [];
|
|
529
|
+
let created = 0;
|
|
530
|
+
|
|
531
|
+
for (const item of files) {
|
|
532
|
+
const file = item.file;
|
|
533
|
+
let sess;
|
|
534
|
+
try {
|
|
535
|
+
sess = extractSession(file);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
const sid = path.basename(path.dirname(file));
|
|
538
|
+
failed.push({ sid, why: 'decode: ' + err.message });
|
|
539
|
+
log.error('[decode] ' + shortSid(sid) + ' ' + err.message);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (skipSubagent && sess.origin === 'subagent') {
|
|
543
|
+
skipped.push({ sid: sess.sid, why: 'subagent session' });
|
|
544
|
+
log.detail('[skip] ' + shortSid(sess.sid) + ' 子代理会话(origin=subagent),不入库');
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (!sess.turns.length) {
|
|
548
|
+
skipped.push({ sid: sess.sid, why: 'no text' });
|
|
549
|
+
log.detail('[skip] ' + shortSid(sess.sid) + ' 没有可用正文');
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const docs = buildDocs(sess, { titlePrefix, maxDocBytes });
|
|
554
|
+
const hash = sha256(docs.join('\n\u0000\n'));
|
|
555
|
+
const prev = state.sessions[sess.sid] || null;
|
|
556
|
+
const titles = docs.map((doc, i) => docTitle(titlePrefix, sess, i + 1, docs.length));
|
|
557
|
+
const targets = docs.map((doc, i) => resolveTarget(sess, existing, i + 1, docs.length, prev, titlePrefix));
|
|
558
|
+
const changed = !!(prev && prev.hash !== hash);
|
|
559
|
+
|
|
560
|
+
if (prev && !changed && targets.every(Boolean)) {
|
|
561
|
+
skipped.push({ sid: sess.sid, why: 'unchanged' });
|
|
562
|
+
log.detail('[skip] ' + shortSid(sess.sid) + ' 内容未变化');
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const idleMin = (Date.now() - sess.mtime) / 60000;
|
|
567
|
+
if (idleMin < settleMin) {
|
|
568
|
+
skipped.push({ sid: sess.sid, why: 'active' });
|
|
569
|
+
log.detail('[skip] ' + shortSid(sess.sid) + ' 仍在活动(空闲 ' + Math.round(idleMin) + ' 分钟 < ' + settleMin + ')');
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const bytes = docs.reduce((sum, doc) => sum + Buffer.byteLength(doc, 'utf8'), 0);
|
|
574
|
+
if (args.dryRun) {
|
|
575
|
+
log.info('[dry] ' + shortSid(sess.sid) + ' turns=' + sess.turns.length + ' parts=' + docs.length
|
|
576
|
+
+ ' bytes=' + bytes + ' ' + (changed ? 'REFRESH' : 'NEW') + ' title=' + titles[0]);
|
|
577
|
+
imported.push({ sid: sess.sid, action: changed ? 'would-refresh' : 'would-create', parts: docs.length, bytes });
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const rec = {
|
|
582
|
+
sid: sess.sid, hash, file, cwd: sess.cwd, title: sess.title,
|
|
583
|
+
origin: sess.origin, docs: [], updatedAt: new Date().toISOString()
|
|
584
|
+
};
|
|
585
|
+
try {
|
|
586
|
+
if (prev && Array.isArray(prev.docs) && prev.docs.length > docs.length) {
|
|
587
|
+
log.warn(shortSid(sess.sid) + ' 分片数减少 ' + prev.docs.length + '->' + docs.length + ',多余文档未删除(需人工处理)');
|
|
588
|
+
}
|
|
589
|
+
for (let i = 0; i < docs.length; i++) {
|
|
590
|
+
const part = i + 1;
|
|
591
|
+
const title = titles[i];
|
|
592
|
+
const target = targets[i];
|
|
593
|
+
const prevRec = prev && Array.isArray(prev.docs) ? prev.docs.find((d) => d.part === part) : null;
|
|
594
|
+
const prevChunks = (prevRec && prevRec.chunks) || 0;
|
|
595
|
+
let id = '';
|
|
596
|
+
let action = '';
|
|
597
|
+
if (target) {
|
|
598
|
+
id = target.doc.id;
|
|
599
|
+
if (!changed) {
|
|
600
|
+
action = 'reused';
|
|
601
|
+
} else {
|
|
602
|
+
await updateManualDoc(ctx, id, title, docs[i]);
|
|
603
|
+
action = 'refreshed';
|
|
604
|
+
if (target.title !== title) existing.delete(target.title);
|
|
605
|
+
existing.set(title, Object.assign({}, target.doc, { id, title }));
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
if (baseDocCount + created >= maxSessionDocs) {
|
|
609
|
+
throw new Error('已达 maxSessionDocs=' + maxSessionDocs + '(既有 ' + baseDocCount + ' 篇 + 本轮新建 ' + created + ' 篇);请抬高 ingest.maxSessionDocs');
|
|
610
|
+
}
|
|
611
|
+
id = await createManualDoc(ctx, kb, title, docs[i], maxAttempts);
|
|
612
|
+
created++;
|
|
613
|
+
action = 'created';
|
|
614
|
+
existing.set(title, { id, title });
|
|
615
|
+
}
|
|
616
|
+
const parsed = await waitParsed(ctx, id, pollTimeoutSec);
|
|
617
|
+
const chunks = await chunkCount(ctx, id);
|
|
618
|
+
if (chunks < 1) {
|
|
619
|
+
throw new Error('索引不可用: ' + title + ' status=' + parsed.status + ' chunks=' + chunks + (parsed.error ? ' ' + parsed.error : ''));
|
|
620
|
+
}
|
|
621
|
+
if (parsed.status !== 'completed') {
|
|
622
|
+
log.warn(shortSid(sess.sid) + ' part=' + part + ' 尚未完成解析: ' + parsed.status + '(chunks=' + chunks + ')');
|
|
623
|
+
}
|
|
624
|
+
rec.docs.push({ part, title, knowledgeId: id, chunks, parseStatus: parsed.status, action });
|
|
625
|
+
imported.push({ sid: sess.sid, part, id, action, chunks, title });
|
|
626
|
+
log.info('[' + action + '] ' + shortSid(sess.sid) + ' part=' + part + '/' + docs.length
|
|
627
|
+
+ ' chunks=' + (action === 'refreshed' ? prevChunks + '->' : '') + chunks + ' id=' + id + ' title=' + title);
|
|
628
|
+
}
|
|
629
|
+
state.sessions[sess.sid] = rec;
|
|
630
|
+
saveState(STATE_PATH, state);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
failed.push({ sid: sess.sid, why: err.message || String(err) });
|
|
633
|
+
log.error('[fail] ' + shortSid(sess.sid) + ' ' + (err.message || String(err)));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!args.dryRun) saveState(STATE_PATH, state);
|
|
638
|
+
log.info('== run end imported=' + imported.length + ' skipped=' + skipped.length + ' failed=' + failed.length
|
|
639
|
+
+ (args.dryRun ? '(dry-run:未写库、未写 state)' : ' kbDocs=' + existing.size));
|
|
640
|
+
for (const item of failed) log.error('[failed] ' + shortSid(item.sid) + ' ' + item.why);
|
|
641
|
+
return failed.length ? 1 : 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export async function run(argv) {
|
|
645
|
+
const list = Array.isArray(argv) ? argv : process.argv.slice(2);
|
|
646
|
+
let args;
|
|
647
|
+
try {
|
|
648
|
+
args = parseWriterArgs(list);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
console.error('[ingest] ' + err.message);
|
|
651
|
+
return 2;
|
|
652
|
+
}
|
|
653
|
+
if (args.help) { console.log(USAGE); return 0; }
|
|
654
|
+
if (args.unknown.length) {
|
|
655
|
+
console.error('[ingest] 未知参数: ' + args.unknown.join(' ') + '\n' + USAGE);
|
|
656
|
+
return 2;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const log = makeLog('[ingest]', args);
|
|
660
|
+
let loaded;
|
|
661
|
+
try {
|
|
662
|
+
loaded = loadWriterConfig(list);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
log.error(err.message);
|
|
665
|
+
return 2;
|
|
666
|
+
}
|
|
667
|
+
for (const note of loaded.notes) log.info('[config] ' + note);
|
|
668
|
+
|
|
669
|
+
const ctx = { cfg: loaded.cfg, log, timeoutMs: REQUEST_TIMEOUT_MS };
|
|
670
|
+
try {
|
|
671
|
+
return await ingestRun(ctx, args);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
log.error(err && err.message ? err.message : String(err));
|
|
674
|
+
return 2;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const isMain = (() => {
|
|
679
|
+
const entry = process.argv[1];
|
|
680
|
+
if (!entry) return false;
|
|
681
|
+
try { return pathToFileURL(path.resolve(entry)).href === import.meta.url; } catch { return false; }
|
|
682
|
+
})();
|
|
683
|
+
|
|
684
|
+
if (isMain) {
|
|
685
|
+
run().then((code) => { process.exitCode = code; }).catch((err) => {
|
|
686
|
+
console.error('[ingest] ' + (err && err.message ? err.message : String(err)));
|
|
687
|
+
process.exitCode = 1;
|
|
688
|
+
});
|
|
689
|
+
}
|