@kira905/dsh-memory-toolkit 0.1.2 → 0.2.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 +130 -1
- package/README.en.md +225 -2
- package/README.md +208 -2
- package/cordis.patch.yml +32 -0
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +11 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// 会话日志 → 候选结论条(零依赖)。
|
|
2
|
+
//
|
|
3
|
+
// 一个纯文本 .jsonl 进去,一批「能回指来源」的候选出来:
|
|
4
|
+
// 每条候选都带 `sourceRef = {sessionId, file, line, offset, anchor}`,
|
|
5
|
+
// anchor 是原文前 48 个字 —— 将来任何人拿到这条记忆,都能回原文把这一句找出来对质。
|
|
6
|
+
//
|
|
7
|
+
// 为什么不做摘要式写入:摘要是推断,回指才是证据。本模块**只挑句子、不改写句子**,
|
|
8
|
+
// 模型那一层(session-distill.mjs)才允许合成,且合成时必须报出自己引用了哪几条候选。
|
|
9
|
+
//
|
|
10
|
+
// 抽不抽的口径(全部是确定性的、可复现的规则,不是"感觉像结论就抽"):
|
|
11
|
+
// 抽:明确的决定 / 结论 / 口径 / 偏好 / 修复方案,且长度落在 [minChars, maxChars];
|
|
12
|
+
// 不抽:寒暄、疑问句、过程叙述(先/然后/接着…)、推测(我觉得/可能/大概…)、
|
|
13
|
+
// 工具与图片段、超过 maxChars 的整段转述、以及归一化后与本批已有的重复句。
|
|
14
|
+
// 每一条被挡下的都按原因计数,跑完一次性报出来 —— 让你看得见"抽了多少、漏了什么"。
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import zlib from 'node:zlib';
|
|
19
|
+
|
|
20
|
+
export const SOURCE_SCHEMA = 1;
|
|
21
|
+
|
|
22
|
+
/** 抽句的缺省旋钮。调它们会改「抽出来的东西」,所以只做显式旋钮,不改代码。 */
|
|
23
|
+
export const DEFAULTS = Object.freeze({
|
|
24
|
+
minChars: 12,
|
|
25
|
+
maxChars: 240,
|
|
26
|
+
anchorChars: 48,
|
|
27
|
+
maxCandidates: 200,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** `.zstd` 的两种后缀(DSH 的会话日志是 `.jsonl.zstd`)。 */
|
|
31
|
+
export const ZSTD_SUFFIXES = Object.freeze(['.zstd', '.zst']);
|
|
32
|
+
|
|
33
|
+
/** 压缩日志读不动时给的人话(既当提示也当报错正文,绝不静默吞掉)。 */
|
|
34
|
+
export const ZSTD_HINT = [
|
|
35
|
+
'这是 zstd 压缩日志,不是一行一个事件的纯文本 .jsonl。',
|
|
36
|
+
'请先解压再喂,例如:',
|
|
37
|
+
' zstd -d 会话.jsonl.zstd -o 会话.jsonl',
|
|
38
|
+
'(本机没装 zstd 的话,node 22.15+ / 24 自带 zstd,本工具会自己逐帧解;解不开就是文件坏了或不是 zstd。)',
|
|
39
|
+
].join('\n');
|
|
40
|
+
|
|
41
|
+
// ── zstd:零依赖读压缩输入 ──────────────────────────────────────────────────
|
|
42
|
+
// node 内置 zstd 只解**第一帧**就停(DSH 的会话日志每次追加写一帧 ⇒ 多帧)。
|
|
43
|
+
// 所以这里自己走一遍帧结构算每帧长度,再逐帧交给 zstd 解,拼起来才是完整日志。
|
|
44
|
+
|
|
45
|
+
const FRAME_MAGIC = 0xfd2fb528;
|
|
46
|
+
const SKIPPABLE_MASK = 0xfffffff0;
|
|
47
|
+
const SKIPPABLE_MAGIC = 0x184d2a50;
|
|
48
|
+
|
|
49
|
+
function looksZstdName(file) {
|
|
50
|
+
const lower = String(file).toLowerCase();
|
|
51
|
+
return ZSTD_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hasZstdApi() {
|
|
55
|
+
return typeof zlib.zstdDecompressSync === 'function';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 逐帧算出 zstd 文件里每帧的 [start, end)。
|
|
60
|
+
* 只读帧头与块头(都不压缩),不解内容 —— 所以又快又准。
|
|
61
|
+
*/
|
|
62
|
+
export function zstdFrameSpans(buf) {
|
|
63
|
+
const spans = [];
|
|
64
|
+
let at = 0;
|
|
65
|
+
while (at + 4 <= buf.length) {
|
|
66
|
+
const magic = buf.readUInt32LE(at);
|
|
67
|
+
if ((magic & SKIPPABLE_MASK) === SKIPPABLE_MAGIC) {
|
|
68
|
+
if (at + 8 > buf.length) return { ok: false, reason: `skippable 帧头不完整(偏移 ${at})` };
|
|
69
|
+
const size = buf.readUInt32LE(at + 4);
|
|
70
|
+
spans.push({ start: at, end: at + 8 + size, kind: 'skippable' });
|
|
71
|
+
at += 8 + size;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (magic !== FRAME_MAGIC) return { ok: false, reason: `偏移 ${at} 不是 zstd 帧头(magic=${magic.toString(16)})` };
|
|
75
|
+
|
|
76
|
+
const descriptor = buf[at + 4];
|
|
77
|
+
if ((descriptor & 0x08) !== 0) return { ok: false, reason: `偏移 ${at} 的帧头把保留位置了 1,按规范应视为损坏` };
|
|
78
|
+
const fcsFlag = descriptor >> 6;
|
|
79
|
+
const singleSegment = (descriptor >> 5) & 1;
|
|
80
|
+
const hasChecksum = (descriptor >> 2) & 1;
|
|
81
|
+
const dictFlag = descriptor & 3;
|
|
82
|
+
|
|
83
|
+
let cursor = at + 5;
|
|
84
|
+
if (!singleSegment) cursor += 1; // Window_Descriptor
|
|
85
|
+
cursor += [0, 1, 2, 4][dictFlag]; // Dictionary_ID
|
|
86
|
+
const fcsBytes = fcsFlag === 0 ? (singleSegment ? 1 : 0) : [0, 2, 4, 8][fcsFlag];
|
|
87
|
+
cursor += fcsBytes;
|
|
88
|
+
|
|
89
|
+
for (;;) {
|
|
90
|
+
if (cursor + 3 > buf.length) return { ok: false, reason: '块头越界(文件被截断?)' };
|
|
91
|
+
const header = buf[cursor] | (buf[cursor + 1] << 8) | (buf[cursor + 2] << 16);
|
|
92
|
+
const isLast = header & 1;
|
|
93
|
+
const blockType = (header >> 1) & 3;
|
|
94
|
+
const blockSize = header >>> 3;
|
|
95
|
+
cursor += 3;
|
|
96
|
+
if (blockType === 3) return { ok: false, reason: '出现保留块类型(文件损坏)' };
|
|
97
|
+
// RLE 块只有 1 字节内容;raw / compressed 都是 blockSize 字节
|
|
98
|
+
cursor += blockType === 1 ? 1 : blockSize;
|
|
99
|
+
if (isLast) break;
|
|
100
|
+
}
|
|
101
|
+
if (hasChecksum) cursor += 4;
|
|
102
|
+
if (cursor > buf.length) return { ok: false, reason: '帧尾越界(文件被截断?)' };
|
|
103
|
+
spans.push({ start: at, end: cursor, kind: 'frame' });
|
|
104
|
+
at = cursor;
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, spans };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 解 zstd(多帧逐帧解)。失败一律抛带人话的错误 —— 调用方看得见,不会拿到半截数据。 */
|
|
110
|
+
export function decompressZstd(buf) {
|
|
111
|
+
if (!hasZstdApi()) {
|
|
112
|
+
const error = new Error(`当前 node(${process.version})没有内置 zstd API,无法直接读压缩日志。\n${ZSTD_HINT}`);
|
|
113
|
+
error.code = 'ZSTD_UNSUPPORTED';
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const walked = zstdFrameSpans(buf);
|
|
117
|
+
if (!walked.ok) {
|
|
118
|
+
const error = new Error(`zstd 帧结构读不动:${walked.reason}\n${ZSTD_HINT}`);
|
|
119
|
+
error.code = 'ZSTD_CORRUPT';
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (!walked.spans.length) {
|
|
123
|
+
const error = new Error(`文件里没有 zstd 帧(0 字节?)\n${ZSTD_HINT}`);
|
|
124
|
+
error.code = 'ZSTD_EMPTY';
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
const chunks = [];
|
|
128
|
+
let frames = 0;
|
|
129
|
+
for (const span of walked.spans) {
|
|
130
|
+
if (span.kind === 'skippable') continue;
|
|
131
|
+
try {
|
|
132
|
+
chunks.push(zlib.zstdDecompressSync(buf.subarray(span.start, span.end)));
|
|
133
|
+
frames += 1;
|
|
134
|
+
} catch (failure) {
|
|
135
|
+
const error = new Error(`第 ${frames + 1} 帧解压失败(偏移 ${span.start}):${failure && failure.message ? failure.message : failure}\n${ZSTD_HINT}`);
|
|
136
|
+
error.code = 'ZSTD_CORRUPT';
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { text: Buffer.concat(chunks).toString('utf8'), frames, spans: walked.spans.length };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 读输入文件:普通文本原样读;zstd(按后缀或按帧头认)逐帧解。
|
|
145
|
+
* @returns {{ text:string, compressed:boolean, frames:number, note:string|null }}
|
|
146
|
+
*/
|
|
147
|
+
export function readSourceText(file) {
|
|
148
|
+
if (!fs.existsSync(file)) {
|
|
149
|
+
const error = new Error(`输入文件不存在:${file}`);
|
|
150
|
+
error.code = 'SOURCE_MISSING';
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
const buf = fs.readFileSync(file);
|
|
154
|
+
const magicZstd = buf.length >= 4 && buf.readUInt32LE(0) === FRAME_MAGIC;
|
|
155
|
+
const compressed = looksZstdName(file) || magicZstd;
|
|
156
|
+
if (!compressed) return { text: buf.toString('utf8'), compressed: false, frames: 0, note: null };
|
|
157
|
+
const out = decompressZstd(buf);
|
|
158
|
+
return {
|
|
159
|
+
text: out.text,
|
|
160
|
+
compressed: true,
|
|
161
|
+
frames: out.frames,
|
|
162
|
+
note: `输入是 zstd 压缩日志:已用 node 内置 zstd 逐帧解开(${out.frames} 帧 / ${out.spans} 段),按解压后的文本抽句。`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── 逐行切(带行号 + 字节偏移) ──────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** 切成「非空行」清单;offset 是该行首字节在解压后文本里的字节偏移。 */
|
|
169
|
+
export function splitSourceLines(text) {
|
|
170
|
+
const lines = [];
|
|
171
|
+
let cursor = 0;
|
|
172
|
+
let lineNo = 0;
|
|
173
|
+
let byteOffset = 0;
|
|
174
|
+
const source = String(text ?? '');
|
|
175
|
+
while (cursor <= source.length) {
|
|
176
|
+
const nl = source.indexOf('\n', cursor);
|
|
177
|
+
const end = nl === -1 ? source.length : nl;
|
|
178
|
+
const raw = source.slice(cursor, end).replace(/\r$/, '');
|
|
179
|
+
lineNo += 1;
|
|
180
|
+
if (raw.trim()) lines.push({ line: lineNo, offset: byteOffset, raw });
|
|
181
|
+
byteOffset += Buffer.byteLength(source.slice(cursor, end), 'utf8') + (nl === -1 ? 0 : 1);
|
|
182
|
+
if (nl === -1) break;
|
|
183
|
+
cursor = nl + 1;
|
|
184
|
+
}
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 事件里的正文:认几种常见形态,工具/图片段一律跳过 ──────────────────────
|
|
189
|
+
|
|
190
|
+
const SKIP_BLOCK_TYPES = /tool|image|thinking|reasoning|attachment/i;
|
|
191
|
+
|
|
192
|
+
function collectTexts(node, sink, depth = 0) {
|
|
193
|
+
if (node === null || node === undefined || depth > 5) return;
|
|
194
|
+
if (typeof node === 'string') {
|
|
195
|
+
if (node.trim()) sink.push(node);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (typeof node !== 'object') return;
|
|
199
|
+
if (Array.isArray(node)) {
|
|
200
|
+
for (const item of node) collectTexts(item, sink, depth + 1);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const kind = String(node.type ?? '');
|
|
204
|
+
if (SKIP_BLOCK_TYPES.test(kind)) return; // 工具调用 / 工具结果 / 图片 / 思考流,不是结论
|
|
205
|
+
for (const key of ['text', 'content', 'message', 'parts', 'body']) {
|
|
206
|
+
if (node[key] !== undefined) collectTexts(node[key], sink, depth + 1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 从一个事件对象里挖出正文段落(顺序按字段顺序,够稳定可复现)。 */
|
|
211
|
+
export function textsOfEvent(event) {
|
|
212
|
+
const sink = [];
|
|
213
|
+
collectTexts(event, sink);
|
|
214
|
+
const role = String(event?.role ?? event?.message?.role ?? event?.author?.role ?? '').toLowerCase();
|
|
215
|
+
return { role, texts: sink };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── 判句:抽什么、不抽什么 ──────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/** 结论类线索词 → kind。命中即认;同一句命中多族时按「规则 > 决定 > 修复 > 偏好」排。 */
|
|
221
|
+
const CUE_FAMILIES = Object.freeze([
|
|
222
|
+
{
|
|
223
|
+
kind: 'rule',
|
|
224
|
+
words: ['一律', '必须', '不许', '禁止', '只能', '统一', '约定', '口径', '判据', '默认', '铁律', '宁可不', '不要用'],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
kind: 'decision',
|
|
228
|
+
words: ['决定', '定了', '拍板', '改成', '采用', '定为', '结论', '方案是', '取舍', '选它', '先用', '就按', '整批', '按批', '别一条条'],
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
kind: 'fix',
|
|
232
|
+
words: ['根因', '修复', '修掉', '根治', '绕过', '补上', '罪魁', '坑在'],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
kind: 'preference',
|
|
236
|
+
words: ['喜欢', '不喜欢', '习惯', '偏好', '我更想', '别给我', '我要的是'],
|
|
237
|
+
},
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const SOCIAL_HEAD = /^(你好|您好|谢谢|多谢|辛苦|哈哈|呵呵|嘿嘿|好的|好呀|收到|嗯|行吧|在吗|晚安|早安|早上好|晚上好|没事)/;
|
|
241
|
+
const PROCESS_HEAD = /^(先|然后|接着|之后|于是|所以我就|现在我先|下面我)/;
|
|
242
|
+
// 「我先把…然后…接着…最后…」这种流水账:即便句尾偶然撞上"决定/结论"也是过程叙述,不是结论。
|
|
243
|
+
// 只认「然后 + 接着/随后/最后」这种连着三段的走法,避免把"先跑测试再提交"这类真规则误杀。
|
|
244
|
+
const PROCESS_BODY = /然后[^。]{0,30}(接着|随后|最后)|先[^。]{0,20}然后[^。]{0,30}最后/;
|
|
245
|
+
const SPECULATION = /(我觉得|我感觉|我猜|可能|也许|大概|估计|应该是|似乎|好像|说不定)/;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 判一句:能不能当候选。
|
|
249
|
+
* @returns {{ok:boolean, kind:string|null, reason:string|null, hits:string[]}}
|
|
250
|
+
*/
|
|
251
|
+
export function judgeSentence(text, opts = {}) {
|
|
252
|
+
const limits = { ...DEFAULTS, ...opts };
|
|
253
|
+
const body = String(text ?? '').trim();
|
|
254
|
+
if (!body) return { ok: false, kind: null, reason: 'empty', hits: [] };
|
|
255
|
+
if (body.length < limits.minChars) return { ok: false, kind: null, reason: 'too-short', hits: [] };
|
|
256
|
+
if (body.length > limits.maxChars) return { ok: false, kind: null, reason: 'too-long', hits: [] };
|
|
257
|
+
if (/[??]$/.test(body)) return { ok: false, kind: null, reason: 'question', hits: [] };
|
|
258
|
+
if (SOCIAL_HEAD.test(body)) return { ok: false, kind: null, reason: 'social', hits: [] };
|
|
259
|
+
if (PROCESS_HEAD.test(body)) return { ok: false, kind: null, reason: 'process', hits: [] };
|
|
260
|
+
if (PROCESS_BODY.test(body)) return { ok: false, kind: null, reason: 'process', hits: [] };
|
|
261
|
+
|
|
262
|
+
const hits = [];
|
|
263
|
+
for (const family of CUE_FAMILIES) {
|
|
264
|
+
for (const word of family.words) {
|
|
265
|
+
if (body.includes(word) && !hits.includes(word)) hits.push(word);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!hits.length) {
|
|
269
|
+
return { ok: false, kind: null, reason: SPECULATION.test(body) ? 'speculation' : 'no-cue', hits: [] };
|
|
270
|
+
}
|
|
271
|
+
if (SPECULATION.test(body) && !(CUE_FAMILIES[0].words.some((word) => body.includes(word)) || CUE_FAMILIES[1].words.some((word) => body.includes(word)))) {
|
|
272
|
+
// 光有"可能/我觉得"、没有规则或决定词撑着 ⇒ 是推测,不是结论
|
|
273
|
+
return { ok: false, kind: null, reason: 'speculation', hits };
|
|
274
|
+
}
|
|
275
|
+
const kind = CUE_FAMILIES.find((family) => family.words.some((word) => hits.includes(word)))?.kind ?? 'rule';
|
|
276
|
+
return { ok: true, kind, reason: null, hits };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 句子级切分:按句末标点与换行切,保留原序(偏移靠累加还原)。 */
|
|
280
|
+
export function splitSentences(paragraph) {
|
|
281
|
+
const source = String(paragraph ?? '');
|
|
282
|
+
const out = [];
|
|
283
|
+
let cursor = 0;
|
|
284
|
+
for (let at = 0; at < source.length; at += 1) {
|
|
285
|
+
if (/[。!!??;;\n]/.test(source[at])) {
|
|
286
|
+
const piece = source.slice(cursor, at + 1);
|
|
287
|
+
out.push({ text: piece, at: cursor });
|
|
288
|
+
cursor = at + 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (cursor < source.length) out.push({ text: source.slice(cursor), at: cursor });
|
|
292
|
+
return out.map((item) => ({ ...item, text: item.text.trim() })).filter((item) => item.text);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const normalize = (text) => String(text ?? '').replace(/[\s,。!?;:、,.!?;:'"“”‘’()()]/g, '');
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 主入口:读一个 .jsonl(或 .zstd),返回候选条 + 逐类计数。
|
|
299
|
+
*
|
|
300
|
+
* @param {{file?:string, text?:string, sessionId?:string, defaults?:object, now?:number}} input
|
|
301
|
+
* @returns {{ok:boolean, sessionId:string, file:string|null, compressed:boolean, note:string|null,
|
|
302
|
+
* candidates:Array, lines:number, events:number, skipped:object, warnings:string[]}}
|
|
303
|
+
*/
|
|
304
|
+
export function extractCandidates(input = {}) {
|
|
305
|
+
const limits = { ...DEFAULTS, ...(input.defaults ?? {}) };
|
|
306
|
+
const warnings = [];
|
|
307
|
+
let text = input.text;
|
|
308
|
+
let compressed = false;
|
|
309
|
+
let note = null;
|
|
310
|
+
const file = input.file ? path.resolve(input.file) : null;
|
|
311
|
+
|
|
312
|
+
if (text === undefined) {
|
|
313
|
+
if (!file) throw new Error('extractCandidates 需要 file 或 text 其中之一');
|
|
314
|
+
const read = readSourceText(file);
|
|
315
|
+
text = read.text;
|
|
316
|
+
compressed = read.compressed;
|
|
317
|
+
note = read.note;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const lines = splitSourceLines(text);
|
|
321
|
+
const skipped = {
|
|
322
|
+
'bad-json': 0, 'tool-event': 0, 'no-text': 0, 'too-short': 0, 'too-long': 0,
|
|
323
|
+
question: 0, social: 0, process: 0, speculation: 0, 'no-cue': 0, duplicate: 0, 'over-cap': 0,
|
|
324
|
+
};
|
|
325
|
+
const candidates = [];
|
|
326
|
+
const seen = new Set();
|
|
327
|
+
let sessionId = String(input.sessionId ?? '');
|
|
328
|
+
let events = 0;
|
|
329
|
+
let badSample = null;
|
|
330
|
+
|
|
331
|
+
for (const line of lines) {
|
|
332
|
+
let event;
|
|
333
|
+
try {
|
|
334
|
+
event = JSON.parse(line.raw);
|
|
335
|
+
} catch {
|
|
336
|
+
skipped['bad-json'] += 1;
|
|
337
|
+
if (!badSample) badSample = line.raw.slice(0, 80);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
events += 1;
|
|
341
|
+
// session 事件的场次 id:本仓日志用 `id`,别的宿主可能用 `sessionId` ⇒ 两种都认(否则回指会退化成「文件名+行号」)。
|
|
342
|
+
if (!sessionId && event?.type === 'session') {
|
|
343
|
+
const sessEventId = event?.id ?? event?.sessionId;
|
|
344
|
+
if (sessEventId) sessionId = String(sessEventId);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const { role, texts } = textsOfEvent(event);
|
|
348
|
+
if (role === 'tool' || role === 'system') {
|
|
349
|
+
skipped['tool-event'] += 1;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!texts.length) {
|
|
353
|
+
skipped['no-text'] += 1;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const paragraph of texts) {
|
|
358
|
+
for (const sentence of splitSentences(paragraph)) {
|
|
359
|
+
const verdict = judgeSentence(sentence.text, limits);
|
|
360
|
+
if (!verdict.ok) {
|
|
361
|
+
skipped[verdict.reason] = Number(skipped[verdict.reason] ?? 0) + 1;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const fingerprint = normalize(sentence.text);
|
|
365
|
+
if (!fingerprint || seen.has(fingerprint)) {
|
|
366
|
+
skipped.duplicate += 1;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
seen.add(fingerprint);
|
|
370
|
+
if (candidates.length >= limits.maxCandidates) {
|
|
371
|
+
skipped['over-cap'] += 1;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
candidates.push({
|
|
375
|
+
text: sentence.text,
|
|
376
|
+
kind: verdict.kind,
|
|
377
|
+
chars: sentence.text.length,
|
|
378
|
+
cues: verdict.hits,
|
|
379
|
+
sourceRef: {
|
|
380
|
+
sessionId: sessionId || 'unknown',
|
|
381
|
+
file,
|
|
382
|
+
line: line.line,
|
|
383
|
+
offset: line.offset,
|
|
384
|
+
charOffset: sentence.at,
|
|
385
|
+
anchor: sentence.text.slice(0, limits.anchorChars),
|
|
386
|
+
role: role || 'unknown',
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!sessionId) {
|
|
394
|
+
sessionId = 'unknown';
|
|
395
|
+
warnings.push('日志里没有 type=session 的事件、也没给 --session-id ⇒ 来源一律标 unknown(回指会退化成"文件名+行号")');
|
|
396
|
+
}
|
|
397
|
+
if (badSample) warnings.push(`有 ${skipped['bad-json']} 行不是合法 JSON(首例:${badSample})——这些行整行跳过,没有静默当成正文`);
|
|
398
|
+
if (skipped['over-cap']) warnings.push(`候选超过上限 ${limits.maxCandidates} 条:多出来的 ${skipped['over-cap']} 条没进本批(调 --max-candidates 才能放宽)`);
|
|
399
|
+
for (const item of candidates) item.sourceRef.sessionId = sessionId;
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
ok: true,
|
|
403
|
+
sessionId,
|
|
404
|
+
file,
|
|
405
|
+
compressed,
|
|
406
|
+
note,
|
|
407
|
+
candidates,
|
|
408
|
+
lines: lines.length,
|
|
409
|
+
events,
|
|
410
|
+
skipped,
|
|
411
|
+
warnings,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 给报告/人话用的计数一行(空类不占位)。 */
|
|
416
|
+
export function summarizeSkipped(skipped) {
|
|
417
|
+
const parts = Object.entries(skipped ?? {})
|
|
418
|
+
.filter(([, count]) => Number(count) > 0)
|
|
419
|
+
.map(([reason, count]) => `${reason} ${count}`);
|
|
420
|
+
return parts.length ? parts.join(' · ') : '无';
|
|
421
|
+
}
|