@rennimars/dsh-weknora-memory 0.2.1 → 0.3.1
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 +28 -0
- package/README.md +81 -7
- package/lib/config.js +8 -0
- package/lib/injector.js +41 -2
- package/lib/retrieve.js +23 -48
- package/lib/weknora.js +91 -0
- package/package.json +2 -1
- 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 +478 -0
- package/writers/register-tasks.ps1 +71 -0
- package/writers/run-all.cmd +53 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* writers/distill.mjs —— 已结束的 DSH 会话 → 记忆条目 → WeKnora 库②(kbs.knowledge)。
|
|
4
|
+
*
|
|
5
|
+
* 行为移植自已验证的生产脚本:近期会话按 idle/settle 与 state 的 mtime 筛出候选 → 调 LLM 抽取
|
|
6
|
+
* 结论/约束/偏好/教训/事实 → 校验(kind 白名单、证据必须逐字出现在原文)→ 本地 bigram 去重 →
|
|
7
|
+
* 知识库向量检索复核(同义视为重复、同主题不同结论记为冲突)→ 一条结论一篇文档写入库②。
|
|
8
|
+
*
|
|
9
|
+
* 与 ingest.mjs 共用解析层(walkSessions / extractSession),不再像源脚本那样在运行时把自己的
|
|
10
|
+
* 解析层截取成临时模块——那个做法既写文件又依赖源码文本格式。
|
|
11
|
+
*
|
|
12
|
+
* 契约(writers/README.md):
|
|
13
|
+
* - config.apiUrl 已含 /api/v1,路径里不再写版本前缀;
|
|
14
|
+
* - 一切 HTTP 走 ../lib/weknora.js 的 request();
|
|
15
|
+
* - apiKey 只从环境变量 WEKNORA_API_KEY 取;
|
|
16
|
+
* - 导入本模块不得产生任何副作用,--dry-run 不写库、不写 state、不写任何文件。
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
|
+
import { request } from '../lib/weknora.js';
|
|
23
|
+
import {
|
|
24
|
+
api, chunkCount, createManualDoc, docText, extractSession, fmtDate, fmtTs, listDocs,
|
|
25
|
+
loadWriterConfig, makeLog, num, parseWriterArgs, waitParsed, walkSessions
|
|
26
|
+
} from './ingest.mjs';
|
|
27
|
+
|
|
28
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const PKG_ROOT = path.resolve(HERE, '..');
|
|
30
|
+
const STATE_PATH = path.join(PKG_ROOT, 'data', 'writer-state', 'distill.json');
|
|
31
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
32
|
+
const SID_TAIL = 8;
|
|
33
|
+
|
|
34
|
+
const CARD_TITLE_MAX = 60;
|
|
35
|
+
const CARD_HARD_CHARS = 600;
|
|
36
|
+
const CARD_TARGET_CHARS = 400;
|
|
37
|
+
const CARD_NOTE_MAX_LINES = 2;
|
|
38
|
+
const CARD_NOTE_MAX_CHARS = 40;
|
|
39
|
+
const CARD_QUOTE_MAX = 120;
|
|
40
|
+
const CARD_CLAIM_MAX = 80;
|
|
41
|
+
const CARD_HEADLINE_MAX = 20;
|
|
42
|
+
const MAX_CONFLICTS_KEPT = 500;
|
|
43
|
+
|
|
44
|
+
const SUBJECT_RE = /^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*){0,4}$/;
|
|
45
|
+
const KINDS = new Set(['decision', 'constraint', 'preference', 'lesson', 'fact']);
|
|
46
|
+
const KIND_TITLE = { decision: '决策', constraint: '约束', preference: '偏好', lesson: '教训', fact: '事实' };
|
|
47
|
+
|
|
48
|
+
const USAGE = [
|
|
49
|
+
'用法: node writers/distill.mjs [选项]',
|
|
50
|
+
'',
|
|
51
|
+
'从最近结束的 DSH 会话里抽结论/约束/偏好/教训/事实,一条一篇文档写入 WeKnora 库②(kbs.knowledge)。',
|
|
52
|
+
'',
|
|
53
|
+
'选项:',
|
|
54
|
+
' --dry-run 只打印本次计划:不发 LLM 请求、不写知识库、不写 state、不写任何文件',
|
|
55
|
+
' --config <path> 配置文件路径(默认 writers/writer.config.json,缺失时回退 config.example.json 的默认值)',
|
|
56
|
+
' --verbose, -v 打印每个候选会话与判定细节',
|
|
57
|
+
' --limit <n> 本次最多处理 n 个会话(默认 distill.maxSessionsPerRun)',
|
|
58
|
+
' --since <YYYY-MM-DD> 只处理该日期之后写入过的会话',
|
|
59
|
+
' --only <substr> 只处理路径里包含 substr 的会话',
|
|
60
|
+
' --no-llm 一次 LLM 请求都不发(只走读路径)',
|
|
61
|
+
' --help, -h 打印本帮助并退出',
|
|
62
|
+
'',
|
|
63
|
+
'凭证: apiKey 只从环境变量 WEKNORA_API_KEY 读取;LLM 走 WeKnora 会话网关,不存第二份密钥。',
|
|
64
|
+
'退出码: 0 成功;1 有会话或文档失败;2 配置或连接失败。'
|
|
65
|
+
].join('\n');
|
|
66
|
+
|
|
67
|
+
const shortSid = (sid) => String(sid || '').slice(-SID_TAIL);
|
|
68
|
+
|
|
69
|
+
// ---------------- state ----------------
|
|
70
|
+
|
|
71
|
+
function emptyState() {
|
|
72
|
+
return {
|
|
73
|
+
version: 1,
|
|
74
|
+
sessions: {},
|
|
75
|
+
entries: {},
|
|
76
|
+
conflicts: [],
|
|
77
|
+
counters: { llmCalls: 0, sessionsProcessed: 0, docsWritten: 0, runInChars: 0, runOutChars: 0 }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function loadState(log) {
|
|
82
|
+
if (!fs.existsSync(STATE_PATH)) return emptyState();
|
|
83
|
+
try {
|
|
84
|
+
const raw = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
|
85
|
+
if (!raw || typeof raw !== 'object') throw new Error('不是对象');
|
|
86
|
+
const base = emptyState();
|
|
87
|
+
return {
|
|
88
|
+
version: 1,
|
|
89
|
+
sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},
|
|
90
|
+
entries: raw.entries && typeof raw.entries === 'object' ? raw.entries : {},
|
|
91
|
+
conflicts: Array.isArray(raw.conflicts) ? raw.conflicts : [],
|
|
92
|
+
counters: Object.assign({}, base.counters, raw.counters && typeof raw.counters === 'object' ? raw.counters : {})
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
log.warn('state 损坏,按空状态继续(仍可按知识库既有条目去重): ' + STATE_PATH + '(' + err.message + ')');
|
|
96
|
+
return emptyState();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function saveState(state) {
|
|
101
|
+
fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true });
|
|
102
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------- LLM:WeKnora 会话网关 ----------------
|
|
106
|
+
|
|
107
|
+
function briefJson(value) {
|
|
108
|
+
try { return JSON.stringify(value).slice(0, 200); } catch { return String(value).slice(0, 200); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* knowledge-chat 返回 SSE 流。lib/weknora.js 的 request() 只能把响应当 JSON 或忽略正文,
|
|
113
|
+
* 所以从它提供的 fetchImpl 注入口拿到响应、克隆一份读原文,正文照旧交给 request() 处理:
|
|
114
|
+
* URL 拼接、鉴权头、超时与 reason 映射仍然只有那一份实现。
|
|
115
|
+
*/
|
|
116
|
+
async function llmOnce(ctx, prompt) {
|
|
117
|
+
const cfg = ctx.cfg;
|
|
118
|
+
const created = await api(ctx, 'POST', '/sessions', { title: 'distill ' + new Date().toISOString().slice(0, 16) });
|
|
119
|
+
const chatId = created && created.data && created.data.id;
|
|
120
|
+
if (!chatId) throw new Error('创建 WeKnora 会话未返回 id: ' + briefJson(created));
|
|
121
|
+
|
|
122
|
+
const body = { query: prompt, knowledge_base_ids: [], agent_enabled: false, web_search_enabled: false };
|
|
123
|
+
const modelName = String(cfg.llm && cfg.llm.modelName ? cfg.llm.modelName : '').trim();
|
|
124
|
+
if (modelName) body.model_name = modelName;
|
|
125
|
+
|
|
126
|
+
let raw = '';
|
|
127
|
+
const capture = async (url, init) => {
|
|
128
|
+
const response = await fetch(url, init);
|
|
129
|
+
try { raw = await response.clone().text(); } catch { raw = ''; }
|
|
130
|
+
return response;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const res = await request(cfg, 'POST', '/knowledge-chat/' + encodeURIComponent(chatId), {
|
|
134
|
+
body,
|
|
135
|
+
timeoutMs: Math.max(1000, num(cfg.llm && cfg.llm.timeoutMs, 240000)),
|
|
136
|
+
fetchImpl: capture,
|
|
137
|
+
expect: 'none'
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
const reason = String(res.reason || '');
|
|
141
|
+
const what = reason.indexOf('http_') === 0 ? 'HTTP ' + res.status
|
|
142
|
+
: reason === 'timeout' ? '请求超时'
|
|
143
|
+
: reason === 'transport' ? '无法连接 WeKnora(transport)'
|
|
144
|
+
: '请求失败(' + reason + ')';
|
|
145
|
+
throw new Error('knowledge-chat ' + what + (raw ? ':' + raw.slice(0, 200) : ''));
|
|
146
|
+
}
|
|
147
|
+
const text = parseSseAnswer(raw);
|
|
148
|
+
return { text, inChars: prompt.length, outChars: text.length };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseSseAnswer(raw) {
|
|
152
|
+
let text = '';
|
|
153
|
+
for (const line of String(raw || '').split('\n')) {
|
|
154
|
+
const trimmed = line.trim();
|
|
155
|
+
if (!trimmed || trimmed.indexOf('data:') !== 0) continue;
|
|
156
|
+
const payload = trimmed.slice(5).trim();
|
|
157
|
+
if (!payload || payload === '[DONE]') continue;
|
|
158
|
+
let event;
|
|
159
|
+
try { event = JSON.parse(payload); } catch { continue; }
|
|
160
|
+
if (event && event.response_type === 'answer' && typeof event.content === 'string') text += event.content;
|
|
161
|
+
else if (event && event.response_type === 'error') throw new Error('LLM 返回错误事件: ' + briefJson(event));
|
|
162
|
+
}
|
|
163
|
+
return text;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------- 抽取 prompt ----------------
|
|
167
|
+
|
|
168
|
+
const KIND_DOC = [
|
|
169
|
+
'decision 已定的技术选型、方案、取舍(例:会话记忆与知识分库存放)',
|
|
170
|
+
'constraint 用户明确的要求、禁令、边界(例:不改插件代码)',
|
|
171
|
+
'preference 用户的稳定偏好(例:回答先给结论)',
|
|
172
|
+
'lesson 被验证有效或失败的经验(例:zstd 多帧必须按 magic 切帧)',
|
|
173
|
+
'fact 稳定事实:路径、端口、版本、服务地址、库 id'
|
|
174
|
+
].join('\n');
|
|
175
|
+
|
|
176
|
+
function buildPrompt(sess, cfg) {
|
|
177
|
+
const perTurnChars = Math.max(1, num(cfg.distill.perTurnChars, 1500));
|
|
178
|
+
const maxInputChars = Math.max(1, num(cfg.distill.maxInputChars, 30000));
|
|
179
|
+
const maxEntries = Math.max(1, num(cfg.distill.maxEntriesPerSession, 8));
|
|
180
|
+
const date = fmtDate(sess.createdAt || sess.mtime);
|
|
181
|
+
const turns = [];
|
|
182
|
+
let used = 0;
|
|
183
|
+
for (const turn of sess.turns) {
|
|
184
|
+
const text = turn.text.length > perTurnChars ? turn.text.slice(0, perTurnChars) + '…(截断)' : turn.text;
|
|
185
|
+
const block = '[' + (turn.role === 'user' ? '用户' : '助手') + '] ' + text.replace(/\s+/g, ' ').trim();
|
|
186
|
+
if (used + block.length > maxInputChars) break;
|
|
187
|
+
turns.push(block);
|
|
188
|
+
used += block.length;
|
|
189
|
+
}
|
|
190
|
+
return [
|
|
191
|
+
'你是记忆提炼器。输入是一段已经结束的 DSH 助手会话转录。你的唯一任务是把跨会话仍然成立的结论抽成条目,不做总结,不写过程叙述。',
|
|
192
|
+
'',
|
|
193
|
+
'kind 取值范围(只能取这五个词):',
|
|
194
|
+
KIND_DOC,
|
|
195
|
+
'',
|
|
196
|
+
'输出格式(严格遵守):',
|
|
197
|
+
'只输出一个 JSON 数组,第一个字符是 [,最后一个字符是 ]。不要代码块,不要任何解释文字。数组元素:',
|
|
198
|
+
'{"kind":"fact","subject":"fact.weknora.kb-id","headline":"知识库 id","statement":"...","note":["...","..."],"confidence":0.8,"evidence":"...","date":"YYYY-MM-DD","session_id":"..."}',
|
|
199
|
+
'',
|
|
200
|
+
'字段要求:',
|
|
201
|
+
'1) subject:这张卡的稳定标识,小写点分 2-4 段(只含 a-z 0-9 _ -),例如 fact.weknora.kb-id、decision.sessions-vs-kb;同一主题必须复用同一 subject,不超过 40 字符。',
|
|
202
|
+
'2) headline:这张卡的短标题,不超过 20 字,用来区分同一 subject 下的不同卡(不要重复 subject 里已表达的词)。',
|
|
203
|
+
'3) statement:一句独立可读的中文陈述,20-80 字,不得出现他/它/这个/那个/上面/刚才/这次等指代,必须脱离原对话也能读懂。',
|
|
204
|
+
'4) note:说明最多 2 行(边界条件、适用范围、为什么成立),每行不超过 40 字,不要加 - 或编号前缀;写不出就输出 []。',
|
|
205
|
+
' 整张卡正文(标题+陈述+说明+证据)合计不超过 600 字;超过 400 字即视为偏胖。',
|
|
206
|
+
'5) confidence:0 到 1 之间的小数,表示这条结论的确定程度。',
|
|
207
|
+
'6) evidence:从转录里逐字复制的一句原话,不超过 120 字,不得改写、不得拼接。找不到原话就不要输出这一条。',
|
|
208
|
+
'7) date:该结论出现的日期,格式 YYYY-MM-DD,未知则用会话开始日期。',
|
|
209
|
+
'8) session_id:原样回填下面给出的 session_id。',
|
|
210
|
+
'',
|
|
211
|
+
'收录标准:只收跨会话仍然成立的内容——用户明确的要求/禁令/偏好、已确定的技术选型或方案、被验证的经验教训、稳定的事实(路径、端口、版本、服务地址、库 id)。',
|
|
212
|
+
'丢弃:寒暄、进度播报、工具调用的中间输出、一次性命令结果、临时的调试细节、后来被推翻的想法、任何在原文里找不到逐字证据的推断。',
|
|
213
|
+
'宁少勿多:最多 ' + maxEntries + ' 条;如果整段没有可跨会话复用的结论,输出 []。',
|
|
214
|
+
'',
|
|
215
|
+
'会话元数据:session_id=' + sess.sid + ';会话日期=' + date + ';标题=' + (sess.title || '(无)'),
|
|
216
|
+
'',
|
|
217
|
+
'转录:',
|
|
218
|
+
turns.join('\n')
|
|
219
|
+
].join('\n');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------- 校验 / 去重 ----------------
|
|
223
|
+
|
|
224
|
+
function squash(value) {
|
|
225
|
+
return String(value || '').replace(/\s+/g, '');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normKey(value) {
|
|
229
|
+
const lowered = String(value || '').toLowerCase()
|
|
230
|
+
.replace(/[\s,。、;:,.;:!?!?"'“”‘’()()【】\[\]<>《》·\-—_/\\|]/g, '');
|
|
231
|
+
return lowered.split(String.fromCharCode(96)).join('');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function bigrams(value) {
|
|
235
|
+
const text = normKey(value);
|
|
236
|
+
const out = new Set();
|
|
237
|
+
for (let i = 0; i + 1 < text.length; i++) out.add(text.slice(i, i + 2));
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function similarity(a, b) {
|
|
242
|
+
const A = bigrams(a);
|
|
243
|
+
const B = bigrams(b);
|
|
244
|
+
if (!A.size || !B.size) return 0;
|
|
245
|
+
let overlap = 0;
|
|
246
|
+
for (const gram of A) if (B.has(gram)) overlap++;
|
|
247
|
+
return overlap / (A.size + B.size - overlap);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 只认第一个 [ 到最后一个 ] 之间的数组;解析失败时逐个抓顶层对象兜底。 */
|
|
251
|
+
function parseEntries(raw) {
|
|
252
|
+
const text = String(raw || '');
|
|
253
|
+
const begin = text.indexOf('[');
|
|
254
|
+
const end = text.lastIndexOf(']');
|
|
255
|
+
if (begin < 0 || end <= begin) return { entries: [], error: '没有 JSON 数组' };
|
|
256
|
+
const slice = text.slice(begin, end + 1);
|
|
257
|
+
try { return { entries: JSON.parse(slice), error: '' }; } catch (err) {
|
|
258
|
+
const objs = [];
|
|
259
|
+
const re = /\{[^{}]*\}/g;
|
|
260
|
+
let match;
|
|
261
|
+
while ((match = re.exec(slice))) {
|
|
262
|
+
try { objs.push(JSON.parse(match[0])); } catch { /* skip */ }
|
|
263
|
+
}
|
|
264
|
+
if (!objs.length) return { entries: [], error: 'JSON 解析失败: ' + err.message };
|
|
265
|
+
return { entries: objs, error: '' };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function clipText(value, limit) {
|
|
270
|
+
const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
|
271
|
+
if (text.length <= limit) return text;
|
|
272
|
+
return text.slice(0, limit).replace(/[,。、;:,.;:!?!?\s]+$/, '');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 超长时优先切在句读处,避免把一句话砍成半个词。 */
|
|
276
|
+
function clipClaim(value, limit) {
|
|
277
|
+
const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
|
278
|
+
if (text.length <= limit) return text;
|
|
279
|
+
const head = text.slice(0, limit);
|
|
280
|
+
const match = head.match(/^([\s\S]*[。!?;,、,.!?;])/);
|
|
281
|
+
const cut = match && match[1].length >= Math.floor(limit * 0.5) ? match[1] : head;
|
|
282
|
+
return cut.replace(/[,。、;:,.;:!?!?\s]+$/, '');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function hash8(value) {
|
|
286
|
+
return crypto.createHash('sha1').update(String(value), 'utf8').digest('hex').slice(0, 8);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 只修大小写/分隔符这类表面问题;含中文的 subject 不做猜测式改写。 */
|
|
290
|
+
function repairSubject(raw) {
|
|
291
|
+
const lowered = String(raw == null ? '' : raw).toLowerCase().trim();
|
|
292
|
+
if (!/^[a-z0-9._\-\s]+$/.test(lowered)) return '';
|
|
293
|
+
let text = lowered.replace(/\s+/g, '');
|
|
294
|
+
text = text.replace(/[^a-z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^[-.]+|[-.]+$/g, '');
|
|
295
|
+
text = text.split('.').filter(Boolean).slice(0, 5).join('.');
|
|
296
|
+
if (text.length > 48) text = text.slice(0, 48).replace(/[-.]+$/, '');
|
|
297
|
+
return text;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** LLM 给的 subject 只当建议:正则不过就归一化,仍不过就改写为 <kind>.fallback-<hash8>,绝不丢卡。 */
|
|
301
|
+
function normalizeSubject(raw, kind, statement) {
|
|
302
|
+
const direct = String(raw == null ? '' : raw).trim();
|
|
303
|
+
if (direct.length <= 48 && SUBJECT_RE.test(direct)) return { subject: direct, warn: '' };
|
|
304
|
+
const repaired = repairSubject(raw);
|
|
305
|
+
if (repaired && repaired.length <= 48 && SUBJECT_RE.test(repaired)) {
|
|
306
|
+
return { subject: repaired, warn: 'subject「' + clipText(direct, 40) + '」不合契约正则,已归一化 → ' + repaired };
|
|
307
|
+
}
|
|
308
|
+
const fallback = (KINDS.has(kind) ? kind : 'fact') + '.fallback-' + hash8(statement);
|
|
309
|
+
return { subject: fallback, warn: 'subject「' + clipText(direct, 40) + '」不合契约正则且无法归一化,改写 → ' + fallback };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 既有文档里的条目陈述:旧格式的 ### n. 行优先,否则取正文第一条陈述行。 */
|
|
313
|
+
function statementsFromDoc(text) {
|
|
314
|
+
const out = [];
|
|
315
|
+
const re = /^###[ \t]+\d+\.[ \t]*(.+)$/gm;
|
|
316
|
+
let match;
|
|
317
|
+
while ((match = re.exec(String(text || '')))) {
|
|
318
|
+
const statement = match[1].trim();
|
|
319
|
+
if (statement) out.push(statement);
|
|
320
|
+
}
|
|
321
|
+
if (out.length) return out;
|
|
322
|
+
const body = String(text || '').split('\n').map((line) => line.trim())
|
|
323
|
+
.find((line) => line && !line.startsWith('<!--') && !line.startsWith('#') && !line.startsWith('证据:'));
|
|
324
|
+
if (body) out.push(body);
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------- 事实卡渲染(一张卡 = 一篇文档) ----------------
|
|
329
|
+
|
|
330
|
+
function renderCard(entry, cfg) {
|
|
331
|
+
const titlePrefix = String(cfg.distill.titlePrefix || '');
|
|
332
|
+
const subject = entry.subject;
|
|
333
|
+
const claim = clipClaim(entry.statement, CARD_CLAIM_MAX);
|
|
334
|
+
let headline = clipText(entry.headline, CARD_HEADLINE_MAX) || clipText(claim, 12);
|
|
335
|
+
const headOf = (value) => (value ? subject + ' · ' + value : subject);
|
|
336
|
+
let title = titlePrefix + headOf(headline);
|
|
337
|
+
if (title.length > CARD_TITLE_MAX) {
|
|
338
|
+
const budget = CARD_TITLE_MAX - titlePrefix.length - subject.length - 3;
|
|
339
|
+
headline = budget >= 1 ? headline.slice(0, budget).trim() : '';
|
|
340
|
+
title = titlePrefix + headOf(headline);
|
|
341
|
+
}
|
|
342
|
+
if (title.length > CARD_TITLE_MAX) title = title.slice(0, CARD_TITLE_MAX);
|
|
343
|
+
|
|
344
|
+
const head = headOf(headline);
|
|
345
|
+
const quote = clipText(entry.evidence, CARD_QUOTE_MAX);
|
|
346
|
+
const fields = {
|
|
347
|
+
v: 1, subject, status: 'active', valid_from: entry.date, superseded_by: '',
|
|
348
|
+
confidence: entry.confidence, session: entry.session_id, quote
|
|
349
|
+
};
|
|
350
|
+
const header = '<!-- fact ' + JSON.stringify(fields) + ' -->';
|
|
351
|
+
const assemble = (notes) => {
|
|
352
|
+
const lines = [header, '', '# ' + head, '', claim, ''];
|
|
353
|
+
if (notes.length) lines.push(...notes, '');
|
|
354
|
+
lines.push('证据:' + quote);
|
|
355
|
+
return lines.join('\n');
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// LLM 有时把 statement 原句又写进 note[0];重复行会让卡读起来像复读,按 normKey 去掉。
|
|
359
|
+
const seen = new Set([normKey(claim)]);
|
|
360
|
+
const noteLine = (value) => clipClaim(value, CARD_NOTE_MAX_CHARS);
|
|
361
|
+
const notes = (Array.isArray(entry.note) ? entry.note : [])
|
|
362
|
+
.map(noteLine)
|
|
363
|
+
.filter(Boolean)
|
|
364
|
+
.filter((value) => {
|
|
365
|
+
const key = normKey(value);
|
|
366
|
+
if (!key || seen.has(key)) return false;
|
|
367
|
+
seen.add(key);
|
|
368
|
+
return true;
|
|
369
|
+
})
|
|
370
|
+
.slice(0, CARD_NOTE_MAX_LINES);
|
|
371
|
+
|
|
372
|
+
const fill = [
|
|
373
|
+
'适用范围:' + (KIND_TITLE[entry.kind] || entry.kind) + ' 类结论,记录日期 ' + entry.date,
|
|
374
|
+
'由 distill 从会话转录抽取,逐字证据见文末「证据」行。'
|
|
375
|
+
];
|
|
376
|
+
for (const line of fill) {
|
|
377
|
+
if (notes.length >= CARD_NOTE_MAX_LINES) break;
|
|
378
|
+
notes.push(noteLine(line));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let content = assemble(notes);
|
|
382
|
+
if (content.split('\n').join('').length > CARD_HARD_CHARS) content = assemble([]);
|
|
383
|
+
const chars = content.split('\n').join('').length;
|
|
384
|
+
return { entry, title, header, fields, content, chars };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------- 语义去重(知识库检索 + 条目级 bigram 复核) ----------------
|
|
388
|
+
|
|
389
|
+
function dedupeSettings(cfg) {
|
|
390
|
+
const raw = cfg.distill.semanticDedupe || {};
|
|
391
|
+
return {
|
|
392
|
+
enabled: raw.enabled !== false,
|
|
393
|
+
scoreMin: num(raw.scoreMin, 0.66),
|
|
394
|
+
simMin: 0.2,
|
|
395
|
+
conflictSimMin: 0.1,
|
|
396
|
+
maxResults: 5,
|
|
397
|
+
queryChars: 120
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 单一分数阈值在本部署不可用(同义与不相关都落在 0.64-0.71 窄带),所以两段合取:
|
|
403
|
+
* 检索 top score >= scoreMin 才算「库里已有相近内容」,再用条目级 bigram 区分同义重复与同主题不同结论。
|
|
404
|
+
*/
|
|
405
|
+
async function semanticLookup(ctx, statement, known, sd) {
|
|
406
|
+
if (!sd.enabled) return { verdict: 'new', why: 'semanticDedupe.enabled=false' };
|
|
407
|
+
let hits = [];
|
|
408
|
+
try {
|
|
409
|
+
const json = await api(ctx, 'POST', '/knowledge-search', {
|
|
410
|
+
query: statement.slice(0, sd.queryChars),
|
|
411
|
+
knowledge_base_ids: [ctx.kb],
|
|
412
|
+
max_results: sd.maxResults
|
|
413
|
+
});
|
|
414
|
+
hits = (json && json.data) || [];
|
|
415
|
+
} catch (err) {
|
|
416
|
+
ctx.log.warn('knowledge-search 失败,本条不做语义去重: ' + err.message);
|
|
417
|
+
return { verdict: 'new', why: 'search failed' };
|
|
418
|
+
}
|
|
419
|
+
const top = hits[0] || null;
|
|
420
|
+
const score = top ? Number(top.score || 0) : 0;
|
|
421
|
+
if (!top || score < sd.scoreMin) return { verdict: 'new', score: Number(score.toFixed(4)) };
|
|
422
|
+
|
|
423
|
+
const candidates = [];
|
|
424
|
+
for (const value of known.values()) if (value && typeof value.statement === 'string') candidates.push(value.statement);
|
|
425
|
+
for (const hit of hits) {
|
|
426
|
+
for (const statement2 of statementsFromDoc(hit.content || hit.matched_content || '')) candidates.push(statement2);
|
|
427
|
+
}
|
|
428
|
+
let best = { sim: 0, text: '' };
|
|
429
|
+
for (const candidate of candidates) {
|
|
430
|
+
const sim = similarity(statement, candidate);
|
|
431
|
+
if (sim > best.sim) best = { sim, text: candidate };
|
|
432
|
+
}
|
|
433
|
+
const evidence = {
|
|
434
|
+
score: Number(score.toFixed(4)),
|
|
435
|
+
sim: Number(best.sim.toFixed(3)),
|
|
436
|
+
hit: String(best.text || '').slice(0, 80),
|
|
437
|
+
knowledgeId: top.knowledge_id,
|
|
438
|
+
title: top.knowledge_title
|
|
439
|
+
};
|
|
440
|
+
if (best.sim >= sd.simMin) return Object.assign({ verdict: 'dup' }, evidence);
|
|
441
|
+
if (best.sim >= sd.conflictSimMin) return Object.assign({ verdict: 'conflict' }, evidence);
|
|
442
|
+
return Object.assign({ verdict: 'new' }, evidence);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------- 主流程 ----------------
|
|
446
|
+
|
|
447
|
+
async function distillRun(ctx, args) {
|
|
448
|
+
const cfg = ctx.cfg;
|
|
449
|
+
const log = ctx.log;
|
|
450
|
+
const kb = cfg.kbs.knowledge;
|
|
451
|
+
if (!kb) throw new Error('kbs.knowledge 未配置:请填写库②(知识/记忆)的知识库 id');
|
|
452
|
+
if (!cfg.apiUrl) throw new Error('apiUrl 未配置:应为 http://<host>:<port>/api/v1(含版本前缀)');
|
|
453
|
+
const root = cfg.sessionsRoot;
|
|
454
|
+
if (!root) throw new Error('sessionsRoot 未配置:请填会话转录根目录(相对包根目录,或绝对路径)');
|
|
455
|
+
if (!fs.existsSync(root)) throw new Error('sessionsRoot 不存在: ' + root);
|
|
456
|
+
|
|
457
|
+
const settleMin = Math.max(0, num(cfg.ingest.settleMinutes, 10));
|
|
458
|
+
const maxSessions = Math.max(1, num(args.values.limit, num(cfg.distill.maxSessionsPerRun, 3)));
|
|
459
|
+
const maxDocs = Math.max(1, num(cfg.distill.maxDocsPerRun, 3));
|
|
460
|
+
const maxEntries = Math.max(1, num(cfg.distill.maxEntriesPerSession, 8));
|
|
461
|
+
const titlePrefix = String(cfg.distill.titlePrefix || '');
|
|
462
|
+
const cardPrefix = String(cfg.dream && cfg.dream.titlePrefix ? cfg.dream.titlePrefix : '');
|
|
463
|
+
const pollTimeoutSec = Math.max(1, num(cfg.ingest.pollTimeoutSec, 600));
|
|
464
|
+
const maxAttempts = Math.max(1, num(cfg.ingest.maxAttempts, 3));
|
|
465
|
+
const sd = dedupeSettings(cfg);
|
|
466
|
+
|
|
467
|
+
const state = loadState(log);
|
|
468
|
+
|
|
469
|
+
const sinceTs = args.values.since ? Date.parse(args.values.since + 'T00:00:00') : 0;
|
|
470
|
+
if (args.values.since && !Number.isFinite(sinceTs)) throw new Error('--since 需要 YYYY-MM-DD: ' + args.values.since);
|
|
471
|
+
const only = String(args.values.only || '');
|
|
472
|
+
|
|
473
|
+
log.info('== distill start' + (args.dryRun ? ' DRY-RUN(不发 LLM、不写库、不写 state)' : '')
|
|
474
|
+
+ ' maxSessions=' + maxSessions + ' maxDocs=' + maxDocs + ' settle=' + settleMin + 'm');
|
|
475
|
+
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const candidates = [];
|
|
478
|
+
const skipReasons = [];
|
|
479
|
+
for (const item of walkSessions(root)) {
|
|
480
|
+
if (only && !item.file.includes(only)) continue;
|
|
481
|
+
if (sinceTs && item.mtime < sinceTs) continue;
|
|
482
|
+
const sid = path.basename(path.dirname(item.file));
|
|
483
|
+
const idleMin = (now - item.mtime) / 60000;
|
|
484
|
+
if (idleMin < settleMin) { skipReasons.push(shortSid(sid) + ' 仍在活动(' + Math.round(idleMin) + 'm)'); continue; }
|
|
485
|
+
const prev = state.sessions[sid];
|
|
486
|
+
if (prev && prev.mtime >= item.mtime) { skipReasons.push(shortSid(sid) + ' 未变化'); continue; }
|
|
487
|
+
candidates.push({ file: item.file, mtime: item.mtime, sid });
|
|
488
|
+
if (candidates.length >= maxSessions) break;
|
|
489
|
+
}
|
|
490
|
+
log.info('候选会话 ' + candidates.length + ' 个;跳过 ' + skipReasons.length + ' 个'
|
|
491
|
+
+ (skipReasons.length ? '(' + skipReasons.slice(0, 5).join(';') + (skipReasons.length > 5 ? ' …' : '') + ')' : ''));
|
|
492
|
+
|
|
493
|
+
const existingDocs = await listDocs(ctx, kb);
|
|
494
|
+
const known = new Map();
|
|
495
|
+
for (const [key, value] of Object.entries(state.entries)) {
|
|
496
|
+
if (value && typeof value.statement === 'string') known.set(key, value);
|
|
497
|
+
}
|
|
498
|
+
let memoryDocs = 0;
|
|
499
|
+
let cardDocs = 0;
|
|
500
|
+
for (const doc of existingDocs.values()) {
|
|
501
|
+
const title = String(doc.title || '');
|
|
502
|
+
const isMemory = !!titlePrefix && title.startsWith(titlePrefix);
|
|
503
|
+
const isCard = !!cardPrefix && title.startsWith(cardPrefix);
|
|
504
|
+
if (!isMemory && !isCard) continue;
|
|
505
|
+
if (isMemory) memoryDocs++; else cardDocs++;
|
|
506
|
+
let text = '';
|
|
507
|
+
try {
|
|
508
|
+
text = await docText(ctx, doc.id);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
log.warn('读取既有文档失败 ' + title + ': ' + err.message);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
for (const statement of statementsFromDoc(text)) {
|
|
514
|
+
known.set(normKey(statement), { statement, docId: doc.id, docTitle: title });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
log.info('知识库既有文档 ' + existingDocs.size + ' 篇,其中记忆 ' + memoryDocs + ' 篇、事实卡 ' + cardDocs
|
|
518
|
+
+ ' 张,可解析条目 ' + known.size + ' 条');
|
|
519
|
+
|
|
520
|
+
const collected = [];
|
|
521
|
+
const discards = [];
|
|
522
|
+
const conflicts = [];
|
|
523
|
+
const skippedSemantic = [];
|
|
524
|
+
const subjectWarns = [];
|
|
525
|
+
const written = [];
|
|
526
|
+
let llmCalls = 0;
|
|
527
|
+
let failedRuns = 0;
|
|
528
|
+
let capped = false;
|
|
529
|
+
state.counters.runInChars = 0;
|
|
530
|
+
state.counters.runOutChars = 0;
|
|
531
|
+
|
|
532
|
+
for (const candidate of candidates) {
|
|
533
|
+
let sess;
|
|
534
|
+
try {
|
|
535
|
+
sess = extractSession(candidate.file);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
discards.push({ sid: candidate.sid, why: 'decode: ' + err.message });
|
|
538
|
+
log.error('[decode] ' + shortSid(candidate.sid) + ' ' + err.message);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (!sess.turns.length) {
|
|
542
|
+
discards.push({ sid: candidate.sid, why: '没有可提炼的正文' });
|
|
543
|
+
log.detail('[skip] ' + shortSid(candidate.sid) + ' 没有可提炼的正文');
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const prompt = buildPrompt(sess, cfg);
|
|
547
|
+
|
|
548
|
+
if (args.dryRun || args.noLlm) {
|
|
549
|
+
log.info('[plan] ' + shortSid(candidate.sid) + ' turns=' + sess.turns.length + ' prompt字符=' + prompt.length
|
|
550
|
+
+ (args.dryRun ? '(dry-run:不发 LLM、不写库)' : '(--no-llm)'));
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let answer = '';
|
|
555
|
+
try {
|
|
556
|
+
const reply = await llmOnce(ctx, prompt);
|
|
557
|
+
answer = reply.text;
|
|
558
|
+
llmCalls++;
|
|
559
|
+
state.counters.llmCalls++;
|
|
560
|
+
state.counters.runInChars += reply.inChars;
|
|
561
|
+
state.counters.runOutChars += reply.outChars;
|
|
562
|
+
} catch (err) {
|
|
563
|
+
discards.push({ sid: candidate.sid, why: 'LLM: ' + err.message });
|
|
564
|
+
log.error('[fail] ' + shortSid(candidate.sid) + ' LLM ' + err.message);
|
|
565
|
+
failedRuns++;
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const parsed = parseEntries(answer);
|
|
570
|
+
if (parsed.error) {
|
|
571
|
+
log.error('[fail] ' + shortSid(candidate.sid) + ' 输出解析失败: ' + parsed.error + ' raw=' + answer.slice(0, 200));
|
|
572
|
+
discards.push({ sid: candidate.sid, why: 'parse: ' + parsed.error });
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const flat = squash(sess.turns.map((turn) => turn.text).join('\n'));
|
|
577
|
+
let kept = 0;
|
|
578
|
+
let droppedEvidence = 0;
|
|
579
|
+
let droppedKind = 0;
|
|
580
|
+
let dup = 0;
|
|
581
|
+
for (const raw of parsed.entries) {
|
|
582
|
+
if (!raw || typeof raw.statement !== 'string' || typeof raw.evidence !== 'string') continue;
|
|
583
|
+
const kind = String(raw.kind || '').toLowerCase();
|
|
584
|
+
if (!KINDS.has(kind)) { droppedKind++; continue; }
|
|
585
|
+
const statement = raw.statement.replace(/\s+/g, ' ').trim();
|
|
586
|
+
const evidence = raw.evidence.replace(/\s+/g, ' ').trim();
|
|
587
|
+
if (!statement || evidence.length < 4) { droppedEvidence++; continue; }
|
|
588
|
+
if (!flat.includes(squash(evidence))) {
|
|
589
|
+
droppedEvidence++;
|
|
590
|
+
discards.push({ sid: candidate.sid, why: '证据不在原文', statement: statement.slice(0, 60), evidence: evidence.slice(0, 60) });
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const key = normKey(statement);
|
|
594
|
+
// 卡正文存的是 clipClaim 过的 claim(<=80 字),长陈述必须用同一裁剪形态比对,否则跨轮会重复写卡。
|
|
595
|
+
const keyCard = normKey(clipClaim(statement, CARD_CLAIM_MAX));
|
|
596
|
+
if (known.has(key) || known.has(keyCard)) { dup++; continue; }
|
|
597
|
+
|
|
598
|
+
const semantic = await semanticLookup(ctx, statement, known, sd);
|
|
599
|
+
if (semantic.verdict === 'dup') {
|
|
600
|
+
skippedSemantic.push({ sid: candidate.sid, statement: statement.slice(0, 100), hit: semantic.hit, score: semantic.score, sim: semantic.sim, docTitle: semantic.title || '' });
|
|
601
|
+
log.info('[skip_semantic] ' + statement.slice(0, 36) + ' ≈「' + String(semantic.hit || '').slice(0, 36) + '」 score=' + semantic.score + ' sim=' + semantic.sim);
|
|
602
|
+
dup++;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
let conflicted = null;
|
|
607
|
+
if (semantic.verdict === 'conflict') {
|
|
608
|
+
conflicted = '知识库检索相近但结论不同(score=' + semantic.score + ',sim=' + semantic.sim + '):「' + semantic.hit + '」';
|
|
609
|
+
}
|
|
610
|
+
for (const value of known.values()) {
|
|
611
|
+
if (conflicted) break;
|
|
612
|
+
const sim = similarity(statement, value.statement);
|
|
613
|
+
if (sim >= 0.55 && sim < 0.97) { conflicted = '与既有条目相似度 ' + sim.toFixed(2) + ':' + value.statement; break; }
|
|
614
|
+
}
|
|
615
|
+
if (conflicted) conflicts.push({ new: statement, old: conflicted, sid: candidate.sid });
|
|
616
|
+
|
|
617
|
+
const subject = normalizeSubject(raw.subject, kind, statement);
|
|
618
|
+
if (subject.warn) {
|
|
619
|
+
subjectWarns.push({ sid: candidate.sid, statement: statement.slice(0, 60), warn: subject.warn });
|
|
620
|
+
log.warn(subject.warn);
|
|
621
|
+
}
|
|
622
|
+
const confidence = Number(raw.confidence);
|
|
623
|
+
collected.push({
|
|
624
|
+
kind,
|
|
625
|
+
statement,
|
|
626
|
+
evidence,
|
|
627
|
+
subject: subject.subject,
|
|
628
|
+
headline: typeof raw.headline === 'string' ? raw.headline : '',
|
|
629
|
+
note: Array.isArray(raw.note) ? raw.note.map((item) => String(item)) : [],
|
|
630
|
+
confidence: Number.isFinite(confidence) && confidence >= 0 && confidence <= 1 ? Number(confidence.toFixed(2)) : 0.8,
|
|
631
|
+
date: /^\d{4}-\d{2}-\d{2}$/.test(String(raw.date)) ? String(raw.date) : fmtDate(sess.createdAt || candidate.mtime),
|
|
632
|
+
session_id: candidate.sid,
|
|
633
|
+
title: sess.title || '',
|
|
634
|
+
conflict: conflicted || ''
|
|
635
|
+
});
|
|
636
|
+
known.set(key, { statement });
|
|
637
|
+
kept++;
|
|
638
|
+
if (kept >= maxEntries) { log.detail('[cap] ' + shortSid(candidate.sid) + ' 已达 maxEntriesPerSession=' + maxEntries); break; }
|
|
639
|
+
if (collected.length >= maxDocs) { capped = true; log.info('[cap] 已达 distill.maxDocsPerRun=' + maxDocs + '(本轮不再收条目)'); break; }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
log.info('[llm] ' + shortSid(candidate.sid) + ' 条目=' + parsed.entries.length + ' 采纳=' + kept
|
|
643
|
+
+ ' 重复=' + dup + ' 丢证据=' + droppedEvidence + ' 丢kind=' + droppedKind
|
|
644
|
+
+ ' 输入字符=' + prompt.length + ' 返回字符=' + answer.length);
|
|
645
|
+
state.sessions[candidate.sid] = {
|
|
646
|
+
sid: candidate.sid, mtime: candidate.mtime, lastRunAt: new Date().toISOString(),
|
|
647
|
+
entries: kept, dup, droppedEvidence, file: candidate.file, title: sess.title || ''
|
|
648
|
+
};
|
|
649
|
+
state.counters.sessionsProcessed += 1;
|
|
650
|
+
if (!args.dryRun) saveState(state);
|
|
651
|
+
if (capped) break;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ---- 写库 ----
|
|
655
|
+
if (!collected.length) {
|
|
656
|
+
log.info('== 本轮没有新条目(候选 ' + candidates.length + ',LLM 调用 ' + llmCalls + ' 次)。未写入知识库。');
|
|
657
|
+
} else {
|
|
658
|
+
const cards = collected.slice(0, maxDocs).map((entry) => renderCard(entry, cfg));
|
|
659
|
+
for (const card of cards) {
|
|
660
|
+
if (card.chars > CARD_TARGET_CHARS) {
|
|
661
|
+
log.warn('卡偏胖 ' + card.chars + ' 字符 > 目标 ' + CARD_TARGET_CHARS + '(硬上限 ' + CARD_HARD_CHARS + '):' + card.title);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
for (const card of cards) {
|
|
665
|
+
try {
|
|
666
|
+
const id = await createManualDoc(ctx, kb, card.title, card.content, maxAttempts);
|
|
667
|
+
const parsed = await waitParsed(ctx, id, pollTimeoutSec);
|
|
668
|
+
const chunks = await chunkCount(ctx, id);
|
|
669
|
+
state.counters.docsWritten += 1;
|
|
670
|
+
written.push({ docId: id, title: card.title, subject: card.fields.subject, chunks, parseStatus: parsed.status });
|
|
671
|
+
state.entries[normKey(card.entry.statement)] = {
|
|
672
|
+
statement: card.entry.statement, kind: card.entry.kind, subject: card.fields.subject,
|
|
673
|
+
date: card.entry.date, docId: id, docTitle: card.title
|
|
674
|
+
};
|
|
675
|
+
log.info('[ok] doc id=' + id + ' chunks=' + chunks + ' parse=' + parsed.status
|
|
676
|
+
+ ' subject=' + card.fields.subject + ' title=' + card.title);
|
|
677
|
+
if (chunks < 1) {
|
|
678
|
+
log.error('[fail] chunks=0,写入未生效 doc=' + id + ' title=' + card.title);
|
|
679
|
+
failedRuns++;
|
|
680
|
+
}
|
|
681
|
+
if (!args.dryRun) saveState(state);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
failedRuns++;
|
|
684
|
+
log.error('[fail] 写入失败 ' + card.title + ': ' + (err.message || String(err)));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (conflicts.length) {
|
|
690
|
+
state.conflicts.push(...conflicts.map((item) => ({ at: new Date().toISOString(), new: item.new, old: item.old, sid: item.sid })));
|
|
691
|
+
if (state.conflicts.length > MAX_CONFLICTS_KEPT) state.conflicts = state.conflicts.slice(-MAX_CONFLICTS_KEPT);
|
|
692
|
+
}
|
|
693
|
+
if (!args.dryRun) saveState(state);
|
|
694
|
+
|
|
695
|
+
log.info('== distill end 采纳条目=' + collected.length + ' 文档=' + written.length
|
|
696
|
+
+ ' 冲突=' + conflicts.length + ' 语义跳过=' + skippedSemantic.length + ' subject改写=' + subjectWarns.length
|
|
697
|
+
+ ' 本轮LLM=' + llmCalls + ' 累积LLM=' + state.counters.llmCalls
|
|
698
|
+
+ (args.dryRun ? '(dry-run:未写库、未写 state)' : ''));
|
|
699
|
+
return failedRuns ? 1 : 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export async function run(argv) {
|
|
703
|
+
const list = Array.isArray(argv) ? argv : process.argv.slice(2);
|
|
704
|
+
let args;
|
|
705
|
+
try {
|
|
706
|
+
args = parseWriterArgs(list);
|
|
707
|
+
} catch (err) {
|
|
708
|
+
console.error('[distill] ' + err.message);
|
|
709
|
+
return 2;
|
|
710
|
+
}
|
|
711
|
+
if (args.help) { console.log(USAGE); return 0; }
|
|
712
|
+
if (args.unknown.length) {
|
|
713
|
+
console.error('[distill] 未知参数: ' + args.unknown.join(' ') + '\n' + USAGE);
|
|
714
|
+
return 2;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const log = makeLog('[distill]', args);
|
|
718
|
+
let loaded;
|
|
719
|
+
try {
|
|
720
|
+
loaded = loadWriterConfig(list);
|
|
721
|
+
} catch (err) {
|
|
722
|
+
log.error(err.message);
|
|
723
|
+
return 2;
|
|
724
|
+
}
|
|
725
|
+
for (const note of loaded.notes) log.info('[config] ' + note);
|
|
726
|
+
|
|
727
|
+
const ctx = { cfg: loaded.cfg, log, timeoutMs: REQUEST_TIMEOUT_MS, kb: loaded.cfg.kbs.knowledge };
|
|
728
|
+
try {
|
|
729
|
+
return await distillRun(ctx, args);
|
|
730
|
+
} catch (err) {
|
|
731
|
+
log.error(err && err.message ? err.message : String(err));
|
|
732
|
+
return 2;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const isMain = (() => {
|
|
737
|
+
const entry = process.argv[1];
|
|
738
|
+
if (!entry) return false;
|
|
739
|
+
try { return pathToFileURL(path.resolve(entry)).href === import.meta.url; } catch { return false; }
|
|
740
|
+
})();
|
|
741
|
+
|
|
742
|
+
if (isMain) {
|
|
743
|
+
run().then((code) => { process.exitCode = code; }).catch((err) => {
|
|
744
|
+
console.error('[distill] ' + (err && err.message ? err.message : String(err)));
|
|
745
|
+
process.exitCode = 1;
|
|
746
|
+
});
|
|
747
|
+
}
|