lume-dsh-plugin 0.7.3 → 0.8.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 +460 -0
- package/README.md +245 -275
- package/lib/client.js +242 -181
- package/lib/core/card.js +11 -9
- package/lib/core/citations.js +235 -0
- package/lib/core/coverage.js +149 -0
- package/lib/core/dialogue-mining.js +49 -11
- package/lib/core/knowledge.js +165 -0
- package/lib/core/leak-detector.js +1 -3
- package/lib/core/ledger.js +129 -18
- package/lib/core/manifest.js +3 -1
- package/lib/core/memory-id.js +105 -0
- package/lib/core/metrics.js +270 -0
- package/lib/core/persona-limits.js +25 -0
- package/lib/core/scope.js +124 -0
- package/lib/core/signals.js +285 -5
- package/lib/core/task-memory.js +143 -0
- package/lib/core/text.js +45 -3
- package/lib/host/backfill.js +218 -0
- package/lib/host/bootstrap.js +130 -0
- package/lib/host/boundary.js +1 -3
- package/lib/host/clauses.js +180 -0
- package/lib/host/config.js +7 -0
- package/lib/host/diag.js +44 -7
- package/lib/host/distill-prompt.js +365 -0
- package/lib/host/distill.js +22 -348
- package/lib/host/extraction.js +11 -3
- package/lib/host/host-context.js +1 -0
- package/lib/host/host-events.js +100 -0
- package/lib/host/identity.js +12 -29
- package/lib/host/inbound.js +133 -0
- package/lib/host/injection.js +2 -6
- package/lib/host/llm-aux.js +130 -0
- package/lib/host/llm-route.js +3 -0
- package/lib/host/methods.js +182 -11
- package/lib/host/metrics-log.js +169 -0
- package/lib/host/notices.js +62 -0
- package/lib/host/project-access.js +210 -0
- package/lib/host/project.js +153 -2
- package/lib/host/prompt-blocks.js +113 -0
- package/lib/host/protocol.js +144 -11
- package/lib/host/reflection.js +28 -5
- package/lib/host/registry.js +0 -4
- package/lib/host/requirements-scan.js +108 -0
- package/lib/host/rpc-bridge.js +23 -4
- package/lib/host/rpc.js +1 -1
- package/lib/host/sections.js +48 -0
- package/lib/host/session-deps.js +27 -0
- package/lib/host/session-events.js +371 -0
- package/lib/host/session-runtime.js +18 -5
- package/lib/host/thinking.js +10 -1
- package/lib/host/tools.js +367 -0
- package/lib/host/triggers.js +30 -6
- package/lib/host/turn-boundary.js +116 -0
- package/lib/host/wiring.js +280 -0
- package/lib/host/workspace-map.js +81 -0
- package/lib/index.js +334 -836
- package/package.json +13 -4
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 引用-证据对齐(citation gate):回答里引用的「文件:行」必须落在本会话**真正读到过**的范围里。
|
|
3
|
+
*
|
|
4
|
+
* 现场事故(2026-09-23 turn 18):模型判「例外是 status(优惠状态):它不是人工填的,
|
|
5
|
+
* 系统按生失效时间自动置无效(`WtpfGoodsPrepertyDefServiceImpl:159-160` 的注释)」,
|
|
6
|
+
* 并据此把「这一列接不接 Excel」丢给用户拍。用户反问后它回读代码,自己承认「我上轮说错,收回」:
|
|
7
|
+
* 159-160 是 **`if (resultCode.isEmpty())` 单条新增分支**的注释,Excel 导入路径的
|
|
8
|
+
* `setStatus(tmp.get("优惠状态"))` 在 **534 行**。
|
|
9
|
+
*
|
|
10
|
+
* 为什么加「要核实」的散文没用:它**引用了一个真实存在的行号**,它以为自己核实过了。
|
|
11
|
+
* 唯一能戳破的做法是把它"看过的范围"变成可对照的事实——所以这里维护证据索引,
|
|
12
|
+
* 只回答一个问题:**你引用的这行,你这次真的打开过吗?**
|
|
13
|
+
*
|
|
14
|
+
* 取舍:只对本会话**碰过的文件**生效(没碰过的文件可能来自用户消息或外部文档,不算错),
|
|
15
|
+
* 且只在回答里出现排除性/决策性措辞时才检查——不能变成每轮都响的噪音。
|
|
16
|
+
*/
|
|
17
|
+
/** 文件被完整读过(没有 offset/limit 的 read)时用的哨兵上限。 */
|
|
18
|
+
export const FULLY_READ = Number.MAX_SAFE_INTEGER;
|
|
19
|
+
/** 归一化路径:`b2i\a\B\Foo.java` 与 `Foo.java` 视为同一个文件。 */
|
|
20
|
+
export function pathKey(file) {
|
|
21
|
+
const raw = String(file ?? "")
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/\\/g, "/");
|
|
24
|
+
const base = raw.split("/").filter(Boolean).pop() ?? "";
|
|
25
|
+
return base.toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
export function newEvidenceIndex() {
|
|
28
|
+
return new Map();
|
|
29
|
+
}
|
|
30
|
+
function push(index, file, window) {
|
|
31
|
+
const key = pathKey(file);
|
|
32
|
+
if (!key)
|
|
33
|
+
return;
|
|
34
|
+
const list = index.get(key) ?? [];
|
|
35
|
+
list.push(window);
|
|
36
|
+
// 合并相邻/重叠窗口,避免"读过 1-100 两次"就撑出几十条清单(提示里要列给人看)。
|
|
37
|
+
list.sort((a, b) => a.from - b.from);
|
|
38
|
+
const merged = [];
|
|
39
|
+
for (const item of list) {
|
|
40
|
+
const last = merged[merged.length - 1];
|
|
41
|
+
if (last && item.from <= last.to + 1)
|
|
42
|
+
last.to = Math.max(last.to, item.to);
|
|
43
|
+
else
|
|
44
|
+
merged.push({ ...item });
|
|
45
|
+
}
|
|
46
|
+
index.set(key, merged.slice(-24));
|
|
47
|
+
}
|
|
48
|
+
/** 记录一次 read 类调用覆盖的范围;没有 offset/limit 视为整文件读过。 */
|
|
49
|
+
export function recordReadArgs(index, args) {
|
|
50
|
+
if (!args || typeof args !== "object")
|
|
51
|
+
return;
|
|
52
|
+
const record = args;
|
|
53
|
+
let file = "";
|
|
54
|
+
for (const key of ["file_path", "filePath", "path", "file", "filename", "notebook_path"]) {
|
|
55
|
+
const value = record[key];
|
|
56
|
+
if (typeof value === "string" && value.trim()) {
|
|
57
|
+
file = value.trim();
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!file)
|
|
62
|
+
return;
|
|
63
|
+
const rawOffset = Number(record.offset);
|
|
64
|
+
const rawLimit = Number(record.limit);
|
|
65
|
+
if (!Number.isFinite(rawOffset) && !Number.isFinite(rawLimit)) {
|
|
66
|
+
push(index, file, { from: 1, to: FULLY_READ });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const from = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 1;
|
|
70
|
+
const to = Number.isFinite(rawLimit) && rawLimit > 0 ? from + Math.floor(rawLimit) - 1 : FULLY_READ;
|
|
71
|
+
push(index, file, { from, to });
|
|
72
|
+
}
|
|
73
|
+
/** 从工具结果文本里记录「路径:行」命中(grep 输出、带行号的清单)。 */
|
|
74
|
+
export function recordResultText(index, text) {
|
|
75
|
+
const body = String(text ?? "");
|
|
76
|
+
if (!body)
|
|
77
|
+
return;
|
|
78
|
+
const re = /([A-Za-z0-9_./\\-]+\.[A-Za-z0-9]{1,8})[::](\d{1,6})(?=[:\s))]|$)/g;
|
|
79
|
+
for (const match of body.matchAll(re)) {
|
|
80
|
+
const line = Number(match[2]);
|
|
81
|
+
if (!Number.isFinite(line) || line <= 0)
|
|
82
|
+
continue;
|
|
83
|
+
push(index, match[1], { from: line, to: line });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** 把引用里的文件名解析到索引里的 key:先精确 basename,再容忍 `Foo.java` ↔ `Foo` 这类简写。 */
|
|
87
|
+
function resolveKey(index, file) {
|
|
88
|
+
const key = pathKey(file);
|
|
89
|
+
if (!key)
|
|
90
|
+
return null;
|
|
91
|
+
if (index.has(key))
|
|
92
|
+
return key;
|
|
93
|
+
for (const candidate of index.keys()) {
|
|
94
|
+
const dot = candidate.lastIndexOf(".");
|
|
95
|
+
if (dot > 0 && candidate.slice(0, dot) === key)
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/** 抽取「文件:行」引用。刻意只用 ASCII 正则 + 事后过滤,避免中文标点字符类在编码上出岔子。 */
|
|
101
|
+
export function extractCitations(index, text) {
|
|
102
|
+
const body = String(text ?? "");
|
|
103
|
+
if (!body)
|
|
104
|
+
return [];
|
|
105
|
+
const out = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const push = (file, key, line) => {
|
|
108
|
+
const id = `${key}:${line}`;
|
|
109
|
+
if (seen.has(id))
|
|
110
|
+
return;
|
|
111
|
+
seen.add(id);
|
|
112
|
+
out.push({ file, key, line });
|
|
113
|
+
};
|
|
114
|
+
const scan = /([A-Za-z0-9_./\\-]+)[::](\d{1,6})(?:\s*[-~]\s*(\d{1,6}))?/g;
|
|
115
|
+
for (const match of body.matchAll(scan)) {
|
|
116
|
+
const token = match[1];
|
|
117
|
+
if (!/^[A-Za-z]/.test(token))
|
|
118
|
+
continue; // 排除 12:34 这类时间/比例
|
|
119
|
+
const key = resolveKey(index, token);
|
|
120
|
+
if (!key)
|
|
121
|
+
continue;
|
|
122
|
+
const start = Number(match[2]);
|
|
123
|
+
push(token, key, start);
|
|
124
|
+
if (match[3])
|
|
125
|
+
push(token, key, Number(match[3]));
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 只在回答里出现**排除性 / 决策性**措辞时才做引用核对:这类句子的错误代价最高
|
|
131
|
+
* (把用户带向错误方案、或让用户在假前提上做决定),而普通陈述句不值得每轮都查。
|
|
132
|
+
*/
|
|
133
|
+
export function shouldCheckCitations(text) {
|
|
134
|
+
return /例外|不能|不支持|无法|只能|一定要|要你定|你拍|需要你决定|建议不|不建议|风险|必须|没有理由/.test(String(text ?? ""));
|
|
135
|
+
}
|
|
136
|
+
/** 回答里引用过、但本会话从未读到过的行。 */
|
|
137
|
+
export function unsupportedCitations(index, text) {
|
|
138
|
+
if (!shouldCheckCitations(text))
|
|
139
|
+
return [];
|
|
140
|
+
return extractCitations(index, text).filter((item) => !covers(index, item.key, item.line));
|
|
141
|
+
}
|
|
142
|
+
export function covers(index, key, line) {
|
|
143
|
+
const windows = index.get(key);
|
|
144
|
+
if (!windows)
|
|
145
|
+
return false;
|
|
146
|
+
return windows.some((window) => line >= window.from && line <= window.to);
|
|
147
|
+
}
|
|
148
|
+
/** 渲染某文件读到过的范围(给人看的一句事实,不是训话)。 */
|
|
149
|
+
export function formatWindows(index, key, limit = 6) {
|
|
150
|
+
const windows = index.get(key) ?? [];
|
|
151
|
+
const shown = windows
|
|
152
|
+
.slice(-limit)
|
|
153
|
+
.map((window) => (window.to >= FULLY_READ ? "整文件" : window.from === window.to ? `${window.from}` : `${window.from}-${window.to}`));
|
|
154
|
+
return shown.join("、");
|
|
155
|
+
}
|
|
156
|
+
// ── 断言-证据对齐(claim gate)──────────────────────────────────────────────
|
|
157
|
+
//
|
|
158
|
+
// 现场事故(2026-09-23):文档里写「列表查询的 resultMap 里 create_id/modify_id 都没映射,
|
|
159
|
+
// 查不出来」——**事实是已映射**(DO result 第 12-15 行),而这条错误论据正是「必须另开一列」
|
|
160
|
+
// 这个核心决策的依据。引用核对管不了"没有行号的否定断言",这里补机械判据:
|
|
161
|
+
// · 否定断言指向的代码符号本会话从未在任何工具结果里出现过 → 根本没核实过(必顶);
|
|
162
|
+
// · 出现过、但断言没给行号,且句子带排除/决策措辞 → 要求补行号。
|
|
163
|
+
// 机械判不了"看过但仍判断错"(那要靠并列事实让人判),但能挡住"没看就断言"。
|
|
164
|
+
/** 太泛的符号不算(出现在任何句子里都不构成"关于代码的断言")。 */
|
|
165
|
+
const SYMBOL_STOP = new Set([
|
|
166
|
+
"ok",
|
|
167
|
+
"true",
|
|
168
|
+
"false",
|
|
169
|
+
"null",
|
|
170
|
+
"undefined",
|
|
171
|
+
"number",
|
|
172
|
+
"string",
|
|
173
|
+
"boolean",
|
|
174
|
+
"result",
|
|
175
|
+
"status",
|
|
176
|
+
"message",
|
|
177
|
+
"error",
|
|
178
|
+
]);
|
|
179
|
+
/** 代码符号:下划线命名、驼峰命名,或反引号包裹的名字。 */
|
|
180
|
+
export function symbolsIn(text) {
|
|
181
|
+
const raw = typeof text === "string" ? text : "";
|
|
182
|
+
if (!raw)
|
|
183
|
+
return [];
|
|
184
|
+
const out = new Set();
|
|
185
|
+
for (const match of raw.matchAll(/`([A-Za-z_][A-Za-z0-9_.]{2,})`/g))
|
|
186
|
+
out.add(match[1]);
|
|
187
|
+
for (const match of raw.matchAll(/\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+\b/g))
|
|
188
|
+
out.add(match[0]);
|
|
189
|
+
for (const match of raw.matchAll(/\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/g))
|
|
190
|
+
out.add(match[0]);
|
|
191
|
+
return [...out].filter((symbol) => symbol.length >= 4 && !SYMBOL_STOP.has(symbol.toLowerCase()));
|
|
192
|
+
}
|
|
193
|
+
/** 否定词:断言"它不存在/没做/不支持"。 */
|
|
194
|
+
const NEGATIVE_RE = /(不存在|没有|没映射|没注册|没实现|未映射|未注册|未定义|不支持|尚未|缺少|看不到|查不到|无映射|没做)/;
|
|
195
|
+
/** 排除/决策措辞:这类否定断言直接支撑结论,要求给出处。 */
|
|
196
|
+
const HARD_CLAIM_RE = /(例外|不能|不该|不支持|不存在|没映射|未映射|未注册|要你定|需要你拍)/;
|
|
197
|
+
/** 记录「本会话在工具结果里真见过」的标识符(否定断言的核实底线;只记名字不记内容)。 */
|
|
198
|
+
export function recordSymbols(store, text, cap = 4000) {
|
|
199
|
+
for (const symbol of symbolsIn(text)) {
|
|
200
|
+
if (store.size >= cap)
|
|
201
|
+
break;
|
|
202
|
+
store.add(symbol);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** 挑出"没核实过就下的否定断言"(给了行号的交给引用核对)。 */
|
|
206
|
+
export function unsupportedClaims(index, seen, text, limit = 3) {
|
|
207
|
+
const raw = typeof text === "string" ? text : "";
|
|
208
|
+
if (!raw)
|
|
209
|
+
return [];
|
|
210
|
+
const out = [];
|
|
211
|
+
const used = new Set();
|
|
212
|
+
for (const sentence of raw.split(/(?<=[。;;!?\n])/)) {
|
|
213
|
+
if (!NEGATIVE_RE.test(sentence))
|
|
214
|
+
continue;
|
|
215
|
+
if (extractCitations(index, sentence).length > 0)
|
|
216
|
+
continue;
|
|
217
|
+
const hard = HARD_CLAIM_RE.test(sentence);
|
|
218
|
+
for (const symbol of symbolsIn(sentence)) {
|
|
219
|
+
if (used.has(symbol))
|
|
220
|
+
continue;
|
|
221
|
+
const known = seen.has(symbol);
|
|
222
|
+
if (!known) {
|
|
223
|
+
used.add(symbol);
|
|
224
|
+
out.push({ symbol, sentence: sentence.trim().slice(0, 120), reason: "unseen" });
|
|
225
|
+
}
|
|
226
|
+
else if (hard) {
|
|
227
|
+
used.add(symbol);
|
|
228
|
+
out.push({ symbol, sentence: sentence.trim().slice(0, 120), reason: "no-line" });
|
|
229
|
+
}
|
|
230
|
+
if (out.length >= limit)
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 需求覆盖核对(机械部分):把「交付物到底覆盖了需求的哪几条」从模型的自证,变成插件按**原文**做的外部对照。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要(2026-09-23 现场):交付文档自称「覆盖需求三全部条目,8 条全有落点,已验证」,而那张对照表
|
|
5
|
+
* 是模型**自己切条目、自己填落点**——没有外部事实参与,等于自评。同一次交付里实际藏着三类问题:
|
|
6
|
+
* ① 与需求原文矛盾(需求第 4 条说历史权限人「待运营梳理后提供」,文档写成「权限人由后端写入」→ 批量写错数据);
|
|
7
|
+
* ② 论据错(文档称 resultMap 没映射 create_id,实际已映射);
|
|
8
|
+
* ③ 落点错(把条件加到 whereSql,而列表查询在 _exp.xml 的 whereGoodsA → 静默失效)。
|
|
9
|
+
* 这三类都不是"少写一节"的显性缺失,只有把**需求原句**与**交付物里的句子**摆在一起才看得出来。
|
|
10
|
+
*
|
|
11
|
+
* 本模块只做机械能做的事:切条目、找提及、查图形引用、查悬空章节号。**语义正确性判不了**,不装。
|
|
12
|
+
*/
|
|
13
|
+
const TOP_ITEM_RE = /^\s*(\d{1,2})\s*[、..]\s*(.*)$/;
|
|
14
|
+
const SUB_ITEM_RE = /^\s*[((]\s*(\d{1,2})\s*[))]\s*(.*)$/;
|
|
15
|
+
const SECTION_RE = /^\s*[一二三四五六七八九十]+\s*[、..]/;
|
|
16
|
+
/**
|
|
17
|
+
* 按用户原文的编号切条目——**条目由插件切,不用模型的总结**。
|
|
18
|
+
* 顶层用 `1、`,子项用 `(1)`;`(图一)` 这类不是条目(只作为图形引用信号)。
|
|
19
|
+
*/
|
|
20
|
+
export function splitRequirementItems(text) {
|
|
21
|
+
// 落账时换行会被压成空格(实测 lume_project.json 里就是「…新增权限人字段 (1)列表页…」),
|
|
22
|
+
// 所以先按**编号**把换行重建出来再切。「见 2.6」这类引用不会被误切(点号后接数字不算编号)。
|
|
23
|
+
const rebuilt = String(text ?? "")
|
|
24
|
+
.replace(/\s+(?=\d{1,2}\s*、)/g, "\n")
|
|
25
|
+
.replace(/\s+(?=[((]\s*\d{1,2}\s*[))])/g, "\n")
|
|
26
|
+
.replace(/\s+(?=\d{1,2}\s*[..]\s*(?!\d))/g, "\n");
|
|
27
|
+
const lines = rebuilt.split(/\r?\n/);
|
|
28
|
+
const items = [];
|
|
29
|
+
let topLabel = "";
|
|
30
|
+
for (const raw of lines) {
|
|
31
|
+
const line = raw.trim();
|
|
32
|
+
if (!line || /^[-—=*#>`|]+$/.test(line))
|
|
33
|
+
continue;
|
|
34
|
+
const sub = SUB_ITEM_RE.exec(line);
|
|
35
|
+
if (sub) {
|
|
36
|
+
const label = topLabel ? `${topLabel}(${sub[1]})` : sub[1];
|
|
37
|
+
if (sub[2].trim())
|
|
38
|
+
items.push({ index: items.length + 1, label, text: sub[2].trim() });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const top = TOP_ITEM_RE.exec(line);
|
|
42
|
+
if (top && !SECTION_RE.test(line)) {
|
|
43
|
+
topLabel = top[1];
|
|
44
|
+
if (top[2].trim())
|
|
45
|
+
items.push({ index: items.length + 1, label: topLabel, text: top[2].trim() });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// 无编号的续行:并到上一条(原文里常见的换行折行)
|
|
49
|
+
if (items.length > 0 && !SECTION_RE.test(line) && !/^[((]?图/.test(line)) {
|
|
50
|
+
const last = items[items.length - 1];
|
|
51
|
+
if (last.text.length < 300)
|
|
52
|
+
last.text = `${last.text}${line}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return items;
|
|
56
|
+
}
|
|
57
|
+
/** 交付物里提到该条需求的句子(命中判据:与原文共享**未被泛化词污染的三元组**,中文无需分词)。 */
|
|
58
|
+
const GENERIC_GRAM_RE = /[的了和与或、,。;::??!!"'"'()()\s]|需求|本次|新增|调整|支持|可以|需要|具体|提供|之后|例如|当前|展示|进行|内容|要求|相关|直接|默认/;
|
|
59
|
+
export function ngrams(text, n) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let i = 0; i + n <= text.length; i++) {
|
|
62
|
+
const gram = text.slice(i, i + n);
|
|
63
|
+
if (GENERIC_GRAM_RE.test(gram))
|
|
64
|
+
continue; // 含虚词/标点/泛化词的片段不算
|
|
65
|
+
if (new Set(gram).size < n)
|
|
66
|
+
continue;
|
|
67
|
+
out.push(gram);
|
|
68
|
+
}
|
|
69
|
+
return [...new Set(out)];
|
|
70
|
+
}
|
|
71
|
+
function sentencesOf(artifact) {
|
|
72
|
+
return artifact
|
|
73
|
+
.split(/\r?\n|[。;;]/)
|
|
74
|
+
.filter((s) => !/^\s*#{1,6}\s/.test(s)) // 标题行不算"说法"
|
|
75
|
+
.map((s) => s.replace(/^\s*[|>-]*\s*/, "").trim())
|
|
76
|
+
.filter((s) => s.length >= 4);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 逐条找「交付物里提到这条的句子」。取不到就是取不到(如实说"未找到提及"),
|
|
80
|
+
* 不替模型判定"你漏了"——并列展示之后由它/用户判。
|
|
81
|
+
*/
|
|
82
|
+
export function coverageRows(items, artifact) {
|
|
83
|
+
const sentences = sentencesOf(artifact);
|
|
84
|
+
return items.map((item) => {
|
|
85
|
+
const gram3 = ngrams(item.text, 3);
|
|
86
|
+
const gram4 = ngrams(item.text, 4);
|
|
87
|
+
const scored = [];
|
|
88
|
+
for (const sentence of sentences) {
|
|
89
|
+
const hits4 = gram4.filter((gram) => sentence.includes(gram));
|
|
90
|
+
const hits = hits4.length > 0 ? hits4 : gram3.filter((gram) => sentence.includes(gram));
|
|
91
|
+
if (hits.length === 0)
|
|
92
|
+
continue;
|
|
93
|
+
hits.sort((a, b) => b.length - a.length);
|
|
94
|
+
scored.push({ text: sentence.slice(0, 110), gram: hits[0], tier: hits4.length > 0 ? 0 : 1 });
|
|
95
|
+
}
|
|
96
|
+
// 有强命中的条目就只用强命中(弱片段如「量数据」会把真正的句子挤掉)
|
|
97
|
+
const pool = scored.some((s) => s.tier === 0) ? scored.filter((s) => s.tier === 0) : scored;
|
|
98
|
+
pool.sort((a, b) => b.gram.length - a.gram.length);
|
|
99
|
+
return { label: item.label, text: item.text, mentions: pool.slice(0, 3).map(({ text, gram }) => ({ text, gram })) };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** 需求里引用了图/附件(交互细节常在图里,最容易漏)。 */
|
|
103
|
+
export function hasFigureRefs(text) {
|
|
104
|
+
return /图[一二三四五六七八九十\d]|如图|截图|附件/.test(String(text ?? ""));
|
|
105
|
+
}
|
|
106
|
+
/** 交付文案里引用的章节号是否真的存在于交付物里(落点编造/章节被删的机械判据)。 */
|
|
107
|
+
export function danglingSectionRefs(deliveryText, artifact) {
|
|
108
|
+
const headings = new Set();
|
|
109
|
+
const headingRe = /^#{1,6}\s*(\d{1,2}(?:\.\d{1,2}){0,2})/gm;
|
|
110
|
+
let m;
|
|
111
|
+
while ((m = headingRe.exec(artifact)) !== null)
|
|
112
|
+
headings.add(m[1]);
|
|
113
|
+
if (headings.size < 3)
|
|
114
|
+
return []; // 交付物没有稳定编号体系 → 这条核对不成立
|
|
115
|
+
const refs = new Set();
|
|
116
|
+
const refRe = /\b(\d{1,2}\.\d{1,2}(?:\.\d{1,2})?)\b/g;
|
|
117
|
+
while ((m = refRe.exec(String(deliveryText ?? ""))) !== null)
|
|
118
|
+
refs.add(m[1]);
|
|
119
|
+
return [...refs].filter((ref) => !headings.has(ref) && ![...headings].some((h) => h.startsWith(`${ref}.`)));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 「评审意见」的判别:用户把评审(或自己写的评审)整段贴进来时,它不是需求原文。
|
|
123
|
+
* 现场:需求原文锚点被轮出表外,表里塞满评审粘贴 → 覆盖核对把评审条目当需求逐条列了(误导)。
|
|
124
|
+
*/
|
|
125
|
+
export function looksLikeReview(text) {
|
|
126
|
+
return /评审|必须处理|认同|反驳|优先级|开发文档|文档\s*\d|第\s*\d+\s*条|我核了|全文|核实结果|高优先级/.test(String(text ?? ""));
|
|
127
|
+
}
|
|
128
|
+
/** 「需求原文」的判别:有逐条编号结构,且不是评审。 */
|
|
129
|
+
export function isRequirementStatement(text) {
|
|
130
|
+
const body = String(text ?? "");
|
|
131
|
+
if (looksLikeReview(body))
|
|
132
|
+
return false;
|
|
133
|
+
const numbered = (body.match(/\d{1,2}\s*[、..]\s*(?!\d)/g) ?? []).length;
|
|
134
|
+
const subbed = (body.match(/[((]\s*\d{1,2}\s*[))]/g) ?? []).length;
|
|
135
|
+
if (numbered + subbed >= 2)
|
|
136
|
+
return true;
|
|
137
|
+
return body.length >= 200 && /需求[一二三四五六七八九十\d]|新增|调整为|验收|批量导入/.test(body);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 从锚点里挑出**需求原文语料**(覆盖核对只该对着它做)。
|
|
141
|
+
* 表里混着闲聊与评审粘贴,所以:过滤评审 → 取最长的需求陈述 → 都没有就返回空串(**宁可不做也不做错**)。
|
|
142
|
+
*/
|
|
143
|
+
export function pickRequirementCorpus(items) {
|
|
144
|
+
const candidates = items.map((i) => String(i.text ?? "")).filter((t) => isRequirementStatement(t));
|
|
145
|
+
if (candidates.length === 0)
|
|
146
|
+
return "";
|
|
147
|
+
candidates.sort((a, b) => b.length - a.length);
|
|
148
|
+
return candidates[0];
|
|
149
|
+
}
|
|
@@ -202,7 +202,9 @@ function cleanChatContent(lines) {
|
|
|
202
202
|
}
|
|
203
203
|
/** 聊天记录模式挖掘:目标说话人(hint 或最高频)为台词主源,其余归用户侧。 */
|
|
204
204
|
export function mineChatLog(chat, hint) {
|
|
205
|
-
const target = hint?.trim()
|
|
205
|
+
const target = hint?.trim()
|
|
206
|
+
? (chat.speakers.find((s) => s === hint.trim() || s.includes(hint.trim()) || hint.trim().includes(s)) ?? null)
|
|
207
|
+
: null;
|
|
206
208
|
const speaker = target ?? chat.speakers[0] ?? null;
|
|
207
209
|
const targetLines = chat.messages.filter((m) => m.speaker === speaker).map((m) => m.text);
|
|
208
210
|
const otherLines = chat.messages.filter((m) => m.speaker !== speaker).map((m) => m.text);
|
|
@@ -219,7 +221,7 @@ export function mineChatLog(chat, hint) {
|
|
|
219
221
|
};
|
|
220
222
|
for (const m of chat.messages) {
|
|
221
223
|
if (m.speaker === speaker) {
|
|
222
|
-
const gap = pendingAt && m.timestamp ?
|
|
224
|
+
const gap = pendingAt && m.timestamp ? toMillis(m.timestamp) - toMillis(pendingAt) : null;
|
|
223
225
|
// 超过 6 小时视为新话题,不能把前一天的闲聊拼成当前回复的上下文。
|
|
224
226
|
if (pendingUser.length > 0 && m.text.length <= 240 && (gap === null || (gap >= 0 && gap <= 6 * 60 * 60 * 1000))) {
|
|
225
227
|
pairs.push({ user: pendingUser.join(" ").slice(-240), assistant: m.text });
|
|
@@ -235,12 +237,15 @@ export function mineChatLog(chat, hint) {
|
|
|
235
237
|
}
|
|
236
238
|
// 契约不能只看目标单边台词:保留每条目标消息前后的小窗口,学习触发条件、
|
|
237
239
|
// 关系距离和情绪转折。窗口只进蒸馏 prompt,不会污染最终 few-shot 语料。
|
|
238
|
-
const targetIndexes = chat.messages.map((m, i) => m.speaker === speaker ? i : -1).filter((i) => i >= 0);
|
|
240
|
+
const targetIndexes = chat.messages.map((m, i) => (m.speaker === speaker ? i : -1)).filter((i) => i >= 0);
|
|
239
241
|
const contexts = [];
|
|
240
242
|
for (const i of evenSample(targetIndexes, 16)) {
|
|
241
243
|
const start = Math.max(0, i - 3);
|
|
242
244
|
const end = Math.min(chat.messages.length, i + 2);
|
|
243
|
-
const window = chat.messages
|
|
245
|
+
const window = chat.messages
|
|
246
|
+
.slice(start, end)
|
|
247
|
+
.map((m) => `${m.speaker === speaker ? "目标" : "用户"}:${m.text}`)
|
|
248
|
+
.join("\n");
|
|
244
249
|
contexts.push(window.slice(0, 700));
|
|
245
250
|
}
|
|
246
251
|
const targetTexts = targetLines;
|
|
@@ -275,7 +280,12 @@ export function mineChatLog(chat, hint) {
|
|
|
275
280
|
return null;
|
|
276
281
|
};
|
|
277
282
|
// 用户给目标的备注(如「老公」)= 用户如何称呼目标;目标消息里的称呼词(如「老婆」)= 目标如何称呼用户
|
|
278
|
-
const userToTarget = [
|
|
283
|
+
const userToTarget = [
|
|
284
|
+
...new Set([
|
|
285
|
+
...RELATION_WORDS.filter((w) => speaker !== null && speaker.includes(w)),
|
|
286
|
+
...userLines.map(greetingOf).filter((w) => Boolean(w)),
|
|
287
|
+
]),
|
|
288
|
+
].slice(0, 6);
|
|
279
289
|
const targetToUser = [...new Set(targetLinesAll.map(greetingOf).filter((w) => Boolean(w)))].slice(0, 6);
|
|
280
290
|
const relationship = { userToTarget, targetToUser };
|
|
281
291
|
return {
|
|
@@ -296,15 +306,43 @@ export function mineChatLog(chat, hint) {
|
|
|
296
306
|
}
|
|
297
307
|
/** 关系称呼词:出现在对话中即揭示双方关系定位。 */
|
|
298
308
|
const RELATION_WORDS = [
|
|
299
|
-
"老公",
|
|
300
|
-
"
|
|
301
|
-
"
|
|
302
|
-
"
|
|
303
|
-
"
|
|
309
|
+
"老公",
|
|
310
|
+
"老婆",
|
|
311
|
+
"媳妇",
|
|
312
|
+
"弟妹",
|
|
313
|
+
"宝宝",
|
|
314
|
+
"宝贝",
|
|
315
|
+
"亲爱的",
|
|
316
|
+
"爸爸",
|
|
317
|
+
"妈妈",
|
|
318
|
+
"爸",
|
|
319
|
+
"妈",
|
|
320
|
+
"爹",
|
|
321
|
+
"娘",
|
|
322
|
+
"哥",
|
|
323
|
+
"姐",
|
|
324
|
+
"弟",
|
|
325
|
+
"妹",
|
|
326
|
+
"哥哥",
|
|
327
|
+
"姐姐",
|
|
328
|
+
"弟弟",
|
|
329
|
+
"妹妹",
|
|
330
|
+
"师傅",
|
|
331
|
+
"师父",
|
|
332
|
+
"老板",
|
|
333
|
+
"同事",
|
|
334
|
+
"闺蜜",
|
|
335
|
+
"兄弟",
|
|
336
|
+
"哥们",
|
|
337
|
+
"姐妹",
|
|
338
|
+
"女神",
|
|
339
|
+
"男神",
|
|
340
|
+
"前男/女友",
|
|
341
|
+
"前男友",
|
|
342
|
+
"前女友",
|
|
304
343
|
];
|
|
305
344
|
/** 真实事件信号:生日/纪念/年份/岁数/共同经历/对方身份事实/约定。 */
|
|
306
345
|
const MEMORY_EVENT_RE = /生日|纪念|周年|过完生日|周岁|去[^。,]{0,12}(过|去|玩|旅游)|第一次|那一年|去年|前年|过年|春节|中秋|国庆|跨年|毕业|结婚|认识[^。,]{0,10}年|领养|搬[^。,]{0,6}家|换工作|辞职|入职|生[了过][^。,]{0,8}(孩子|小孩|女儿|儿子)|考[上完研][^。,]{0,8}|我做|我是[^。,]{0,10}(医生|老师|老师|程序员|设计师)|我[在学过][^。,]{0,10}(编程|画画|钢琴|吉他)/;
|
|
307
|
-
export const MEMORY_POINT_CAP = 12;
|
|
308
346
|
/** 对话流单条消息字数上限(超限截断,保留开头)。 */
|
|
309
347
|
const FLOW_LINE_CAP = 160;
|
|
310
348
|
/** 对话流总字数预算:聊天记录可达 20 万字,记忆提炼 prompt 吃不下全文;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 明面上要拒绝的内容:**带值的**密钥/密码/令牌/连接串,以及"某密钥可解"这类结论。
|
|
3
|
+
*
|
|
4
|
+
* 刻意区分「凭证名」与「凭证值」:`-Djasypt.encryptor.password` 只是参数名(正当约定,该留),
|
|
5
|
+
* 而 `password=ENC(…)`、`jdbc:postgresql://…`、`api_key` 这类才是要挡的。
|
|
6
|
+
* 第一版把前者也挡了——测试与现场清理都抓到了这个误杀。
|
|
7
|
+
*/
|
|
8
|
+
const SENSITIVE_RE = /(password|passwd|pwd|secret|token)\s*[:=]|ENC\(|BEGIN [A-Z ]*PRIVATE KEY|jdbc:[a-z]+:\/\/|connection\s*string|api[_-]?key|private[_-]?key|access[_-]?key|密钥|凭证|实测可解/i;
|
|
9
|
+
export function looksSensitive(text) {
|
|
10
|
+
return SENSITIVE_RE.test(String(text ?? ""));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 证据锚点:没有这些东西的句子只是议论,不该进跨会话知识。
|
|
14
|
+
* 文件锚点刻意放宽到「任意非空白 token + 扩展名」——现场句子常是 `doc/<需求>/08-建表语句(表名).sql`,
|
|
15
|
+
* 用 `\w` 匹配会把中文与括号挡掉(测试抓到的第一版漏洞)。
|
|
16
|
+
*/
|
|
17
|
+
const ANCHOR_RE = /([A-Za-z]:\\|\/[\w.-]+\/)|[^\s/\\|,。;]+\.(java|xml|sql|yml|yaml|json|md|ts|tsx|js|py|go|cs|kt|properties|sh|ps1)\b|\b[A-Z][A-Z0-9_]{4,}\b|\b(mvn|gradle|npm|pnpm|yarn|docker|kubectl|psql|mysql|redis-cli|python|pip|dotnet|go|cargo|make|git|icacls|chmod|rsync|systemctl|curl)\b/;
|
|
18
|
+
/**
|
|
19
|
+
* 死路判据:谓词 + 对象/证据。
|
|
20
|
+
*
|
|
21
|
+
* 为什么加锚点(2026-09-24 审核指出误报):原来只要有「不可用」就算死路,于是我们自己的诊断文案
|
|
22
|
+
* ——「落点不可用:宿主没提供 DSH_HOME…」——被记成死路,污染了最值钱的一类知识。
|
|
23
|
+
* 现在只认**明确的"怎么都不行"谓词**(行不通/跑不了/用不了/不可行/已经不行,以及带对象的"不支持")。
|
|
24
|
+
*
|
|
25
|
+
* 为什么连「不可用」都删了(二审指出):第一版用「负向断言豁免名单」挡自己的文案,看着像修好了,
|
|
26
|
+
* 其实「不可用」依旧单独立案——「身份域不可用」「载具不可用」照收不误,换个词同一个 bug 原样再来。
|
|
27
|
+
* 判据必须是**谓词 + 对象**,而不是「某个词 + 一张例外表」。
|
|
28
|
+
*/
|
|
29
|
+
const DEADEND_RE = /(?:行不通|跑不了|用不了|不可行|已经不行)[^。;\n]{0,40}|(?:不支持|不再维护|unsupported|not supported)[^。;\n]{0,40}(?:[A-Za-z0-9_./\\-]{3,}|版本|依赖|命令|报错|平台)/i;
|
|
30
|
+
/** 四类机械可判的事实。刻意写窄:宁可漏掉,也不要把建议/议论灌进知识库。 */
|
|
31
|
+
const KIND_RULES = [
|
|
32
|
+
// 构建/测试:必须出现"命令"语义(否则"构建通过"这种临时结果不值得跨会话留)
|
|
33
|
+
{ kind: "build", re: /(构建|编译|打包|build)[^。;\n]{0,30}(命令|用\s*\S{2,30}\s*(执行|跑)|是\s*\S{2,30})/i },
|
|
34
|
+
{ kind: "test", re: /(测试|用例|单测|test)[^。;\n]{0,30}(命令|用\s*\S{2,30}\s*(执行|跑)|入口是)/i },
|
|
35
|
+
// 死路:说清"行不通",这是最值钱的一类(避免重复踩)。
|
|
36
|
+
// 判据 = 谓词 + 锚点(2026-09-24 审核指出误报):
|
|
37
|
+
// 「不可用」太泛,我们自己的诊断文案就带它——「落点不可用:宿主没提供 DSH_HOME…」曾被记成死路,
|
|
38
|
+
// 直接污染了最值钱的一类。现在要么是明确的"跑不了/行不通"表述,要么「不支持」类必须带具体对象。
|
|
39
|
+
{ kind: "deadend", re: DEADEND_RE },
|
|
40
|
+
// 约定:只认"项目/仓库/团队 + 一律/必须/统一"这种规范性表述
|
|
41
|
+
{ kind: "convention", re: /(约定|规范|一律|统一|必须|禁止)[^。;\n]{0,60}/ },
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* 明确的"别记"特征:
|
|
45
|
+
* - 给人建议(我们只沉淀**事实**,不沉淀建议);
|
|
46
|
+
* - 提问(问题不是知识);
|
|
47
|
+
* - **宿主运行时快照**(它经 user/message 通道投递,里面全是 policy 文本与路径)。
|
|
48
|
+
*/
|
|
49
|
+
/** 工具输出里的脚手架行:不是事实,是检索/回显的格式(`63: …`、`Line 164: …`、`Found 3 of 9 matches`)。 */
|
|
50
|
+
const SCAFFOLD_RE = /(Found \d+ of \d+ matches|^\s*Line\s*\d+\s*[::]|^\s*L\d{2,}\s*[::]|\b\d{1,5}\s*[::]\s)/;
|
|
51
|
+
/** 纯路径 / 纯标识符行:只有定位信息、没有事实内容(现场噪音最大的一类)。 */
|
|
52
|
+
const PATH_ONLY_RE = /^[\w\\./:\-()()<>@$\u4e00-\u9fa5]+$/;
|
|
53
|
+
/** 去掉行首的编号/引用标记,让真句子上来参与判据与存储。 */
|
|
54
|
+
function stripScaffold(line) {
|
|
55
|
+
return line
|
|
56
|
+
.replace(/^\s*(?:Line\s*\d+\s*[::]|\d{1,5}\s*[::]|>|#|\*|-)\s*/, "")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
const REJECT_RE = /(建议你|你可以|请把|请给|你应该|需要你|那条|这条|上述|前面那|刚才那)|Current runtime context|runtime context|file policy|workspace-write|approval policy|supersedes earlier|^\s*(#|【|一、|二、|三、)/;
|
|
61
|
+
/**
|
|
62
|
+
* **形状**拒收:代码/测试产物/表格行/清单片段——它们不是句子,也不是事实。
|
|
63
|
+
*
|
|
64
|
+
* 为什么必须有(2026-09-24 真机数据,外部审核指出「它在往 DSH 嘴里塞垃圾」):
|
|
65
|
+
* Lume 工作区 18 条知识里 11 条是本仓开发过程的产物——vitest 用例名(`✓ test/tools.test.ts > …`)、
|
|
66
|
+
* 源码注释(`/** 可省略:…`)、CHANGELOG 句子、markdown 表格行、测试代码(`expect(…)`)。
|
|
67
|
+
* 根因:这套形状过滤当时**只对非 tool 来源生效**,而噪音恰好全走 tool 通道(读文件/跑测试的输出)。
|
|
68
|
+
* 现在一律先过形状闸,再谈判据。
|
|
69
|
+
*/
|
|
70
|
+
const TEST_RUN_RE = /(?:^|\s)[✓✗×]\s|\b\d+\s*ms\s*$|test\/[\w./-]+\.test\.ts\s*[>›]/;
|
|
71
|
+
const CODE_SHAPE_RE = /(expect\(|\.toBe\(|\.toHaveLength\(|=>|\/\*\*|\*\/|^\s*\/\/|\{\s*\"|^\s*\+\s*\w|^\s*const\s|^\s*let\s|\t|\u0060\u0060\u0060)/;
|
|
72
|
+
/** 表格行(`| a | b |`):文档片段不是事实。 */
|
|
73
|
+
const TABLE_ROW_RE = /^\s*\|/;
|
|
74
|
+
/**
|
|
75
|
+
* 复制粘贴的命令行(真机剩的那条噪音就是这个形状:`npm run lint # 架构规则(…)`)。
|
|
76
|
+
* 形状清单永远补不全,所以这条按**类别**拦:命令行开头 + 注释符,或含 shell 的管道/重定向。
|
|
77
|
+
*/
|
|
78
|
+
const COMMAND_LINE_RE = /^\s*(?:npm|pnpm|npx|yarn|node|git|python|pip|mvn|gradle|go|cargo|make|tsc|vitest|jest|dotnet|docker|kubectl|psql|curl)\b[^\n]*#|2>&1|\|\s*(?:head|tail|grep|findstr|Select-String|Select-Object)\b/;
|
|
79
|
+
/** 清单/引用/标题片段(`- x`、`* x`、`> x`、`# x`、`3. x`、`③ x`):脱离上下文没有意义。 */
|
|
80
|
+
const LIST_FRAGMENT_RE = /^\s*(?:[-*+>#]\s|\d+[.)]\s|[①-⑳])/;
|
|
81
|
+
/** 问句不收:以问号收尾的句子是问题,不是可复用事实。 */
|
|
82
|
+
const QUESTION_RE = /[??]\s*$/;
|
|
83
|
+
const MIN_LEN = 12;
|
|
84
|
+
const MAX_LEN = 200;
|
|
85
|
+
/**
|
|
86
|
+
* 从一段文本里挑出值得跨会话保留的项目事实。
|
|
87
|
+
*
|
|
88
|
+
* @param text 工具结果或助手可见文本
|
|
89
|
+
* @param options.userText 用户原话——与它高度重合的句子不回记(用户说过的不是"沉淀")
|
|
90
|
+
* @param options.max 单次最多产出(默认 2;调用方还会按会话总上限再收一次)
|
|
91
|
+
*/
|
|
92
|
+
export function extractKnowledgeCandidates(text, options = {}) {
|
|
93
|
+
const raw = String(text ?? "");
|
|
94
|
+
if (!raw || raw.length < MIN_LEN)
|
|
95
|
+
return [];
|
|
96
|
+
const max = options.max ?? 2;
|
|
97
|
+
const source = options.source ?? "tool";
|
|
98
|
+
// 用户的规范陈述判据:比工具/助手更严——它会被当成权威跨会话复用,收错了代价最大
|
|
99
|
+
const USER_RULE_RE = /(必须|一律|统一|禁止|不要|别用|不能|唯一|按\s*\S{2,20}\s*(做|来|办)|约定|规范|标准是)/;
|
|
100
|
+
const userText = String(options.userText ?? "");
|
|
101
|
+
const out = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
// 按行/句切:保留带路径的行,去掉空行与纯装饰行
|
|
104
|
+
for (const piece of raw.split(/[\n。;;]+/)) {
|
|
105
|
+
if (out.length >= max)
|
|
106
|
+
break;
|
|
107
|
+
const sentence = stripScaffold(piece);
|
|
108
|
+
if (sentence.length < MIN_LEN || sentence.length > MAX_LEN)
|
|
109
|
+
continue;
|
|
110
|
+
// 现场噪音三类:检索脚手架、纯路径行、代码/文档的引用碎片
|
|
111
|
+
// 形状闸:对所有来源都生效(这一条就是上一版漏掉的那半扇门)
|
|
112
|
+
if (TEST_RUN_RE.test(sentence) || CODE_SHAPE_RE.test(sentence) || TABLE_ROW_RE.test(sentence) || COMMAND_LINE_RE.test(sentence))
|
|
113
|
+
continue;
|
|
114
|
+
if (SCAFFOLD_RE.test(piece) && !/[。,、]|必须|一致|不要|禁止/.test(sentence))
|
|
115
|
+
continue;
|
|
116
|
+
if (PATH_ONLY_RE.test(sentence))
|
|
117
|
+
continue;
|
|
118
|
+
if (REJECT_RE.test(sentence))
|
|
119
|
+
continue;
|
|
120
|
+
if (QUESTION_RE.test(sentence))
|
|
121
|
+
continue;
|
|
122
|
+
if (looksSensitive(sentence))
|
|
123
|
+
continue;
|
|
124
|
+
if (!ANCHOR_RE.test(sentence))
|
|
125
|
+
continue;
|
|
126
|
+
// 用户自己说过的话不算沉淀(那是锚点该管的)
|
|
127
|
+
if (userText && userText.includes(sentence.slice(0, 40)))
|
|
128
|
+
continue;
|
|
129
|
+
const rule = KIND_RULES.find((item) => item.re.test(sentence));
|
|
130
|
+
// 来源分流:用户来源只收「规范陈述」(否则会把需求描述当成项目知识);
|
|
131
|
+
// 助手来源不采对话性句子(「我们/你要不要」那是交互,不是项目事实)。
|
|
132
|
+
if (source === "user" && !USER_RULE_RE.test(sentence))
|
|
133
|
+
continue;
|
|
134
|
+
if (source === "assistant" && /(我们|咱|你我|请问|要不要|这轮|这一轮|注入块|我前面|我刚才|上面我)/.test(sentence))
|
|
135
|
+
continue;
|
|
136
|
+
// 非工具来源额外降噪(真机精度循环结果:这两类最容易混进片段与"一次性动作"):
|
|
137
|
+
// - 一次性动作(改成/补一句/过一遍/复核/同步到)是任务步骤,不是可复用知识;
|
|
138
|
+
// - 片段续写词(同理/另外/同时/还有)与引用符号(§、连续 →、✅、L\d+-\d+)说明它不是完整句子;
|
|
139
|
+
// - 表格行/清单行不是知识。
|
|
140
|
+
if (source !== "tool") {
|
|
141
|
+
if (/(改成|改为|补一句|补上|加一句|过一遍|复核|同步到|替换成|写成|落库到)/.test(sentence))
|
|
142
|
+
continue;
|
|
143
|
+
if (/^(同理|另外|同时|还有|以及|此外|且)/.test(sentence))
|
|
144
|
+
continue;
|
|
145
|
+
if (/(§|\u2705|L\d{2,}-\d{2,}|→.*→)/.test(sentence))
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// 清单片段:工具/助手来源一律不收;**用户来源放行**(用户贴的规范条目就是一等公民)
|
|
149
|
+
if (source !== "user" && LIST_FRAGMENT_RE.test(sentence))
|
|
150
|
+
continue;
|
|
151
|
+
if (!rule)
|
|
152
|
+
continue;
|
|
153
|
+
// 助手来源的**死路**不收:那是对"能不能做"的判断,必须由真实执行结果或用户原话支撑。
|
|
154
|
+
// (真机里 5 条「死路」全是助手在讨论自己的判据——「一句话里没有任何行不通的语义…」。)
|
|
155
|
+
// 约定/构建/测试仍收:项目约定常常只在助手归纳时才成型,这是原设计的明文意图。
|
|
156
|
+
if (source === "assistant" && rule.kind === "deadend")
|
|
157
|
+
continue;
|
|
158
|
+
const key = sentence.slice(0, 60);
|
|
159
|
+
if (seen.has(key))
|
|
160
|
+
continue;
|
|
161
|
+
seen.add(key);
|
|
162
|
+
out.push({ kind: rule.kind, text: sentence.slice(0, MAX_LEN) });
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
@@ -10,9 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
/** 剥去 fenced 代码块与行内代码——代码内容不属于「说话方式」。 */
|
|
12
12
|
export function stripCode(text) {
|
|
13
|
-
return text
|
|
14
|
-
.replace(/```[\s\S]*?```/g, " ")
|
|
15
|
-
.replace(/`[^`\n]*`/g, " ");
|
|
13
|
+
return text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, " ");
|
|
16
14
|
}
|
|
17
15
|
export const DEFAULT_LEAK_THRESHOLD = { distinctWords: 2, singleWordCount: 3 };
|
|
18
16
|
/**
|