@sanqianx/project-knowledge 5.0.16-rc.1 → 5.0.16-rc.3
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +5 -0
- package/_modules/knowledge-engine/README.md +2 -2
- package/_modules/knowledge-engine/package.json +1 -1
- package/_modules/knowledge-engine/src/retrieval.js +119 -24
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +80 -24
- package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +8 -6
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +105 -64
- package/_site/_test/agent-capture-mcp-ui-test.js +67 -0
- package/_site/_test/agent-capture-ui-test.js +2 -1
- package/_site/_test/integration-adapters-test.js +7 -1
- package/_site/_test/integration-runtime-binding-a2-test.js +566 -0
- package/_site/_test/integration-runtime-binding-a3-test.js +227 -0
- package/_site/_test/integration-runtime-binding-test.js +473 -0
- package/_site/_test/native-context-integrations-test.js +1 -1
- package/_site/_test/release-version-sync-test.js +12 -1
- package/_site/lib/integration-installer.js +325 -8
- package/_site/lib/module-bridge.js +6 -2
- package/_site/scripts/sync-product-content.js +1 -1
- package/_site/scripts/sync-release-version.js +9 -5
- package/_site/scripts/verify-product-runtime.js +1 -1
- package/docs/leader-glm-workbench-playbook.md +89 -0
- package/docs/release-5.0.16-acceptance.md +63 -0
- package/module-runtime-manifest.json +41 -39
- package/package.json +5 -3
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +7 -6
- package/plugins/project-knowledge/scripts/mcp-launcher.cjs +78 -0
- package/ui/product.js +3 -1
- package/vendor-manifest.json +8 -8
package/README.md
CHANGED
|
@@ -339,6 +339,11 @@ and real-index smoke tests are reported separately. No mobile matrix or new
|
|
|
339
339
|
Windows installer is claimed. Full crash-stage combinations and the final
|
|
340
340
|
switch of a user's existing data require separate acceptance.
|
|
341
341
|
|
|
342
|
+
See the [Leader / GLM Workbench playbook](docs/leader-glm-workbench-playbook.md)
|
|
343
|
+
for task dispatch and independent review, and the
|
|
344
|
+
[5.0.16 acceptance record](docs/release-5.0.16-acceptance.md) for this round's
|
|
345
|
+
actual results and remaining gates. These documents are not a release approval.
|
|
346
|
+
|
|
342
347
|
## License
|
|
343
348
|
|
|
344
349
|
[Apache-2.0](LICENSE). See [NOTICE](NOTICE).
|
|
@@ -76,7 +76,7 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
76
76
|
|
|
77
77
|
开发前 `/api/context` 复用正式检索与记忆适用范围校验,不运行分析模型、不写知识树。默认本项目优先、相关时补充所属领域;适用记忆独立准备,即使需求没有偏好关键词也保留。完整注入最多 8,000 字符,记忆最多 3,000 字符;剩余事实预算本项目保留 70%,领域参考最多 30%,不凑满低质量结果。候选、忽略、失效及未同步投影不进入上下文,向量故障明确标为 `file-fallback`。输入的凭据样式在向量查询前脱敏,不记录原需求或知识正文。读取日志的 `knowledge.context.prepared` 只证明准备完成,不能冒充模型送达证据。
|
|
78
78
|
|
|
79
|
-
检索排序 `project-knowledge/ranking-v2` 在当前文件片段上使用中文分词 BM25 与向量片段 RRF 融合,不直接叠加原始分数。vector-hub 的可选 `resultLevel=chunk` 返回 LF
|
|
79
|
+
检索排序 `project-knowledge/ranking-v2` 在当前文件片段上使用中文分词 BM25 与向量片段 RRF 融合,不直接叠加原始分数。vector-hub 的可选 `resultLevel=chunk` 返回 LF 规范化正文哈希及半开字符范围;匹配只影响相应片段,不扩散至整篇。已校验范围不足半个词法分块时,引擎从当前规范正文按同一半开坐标系截取独立片段候选,进入同一 RRF 融合,纯语义命中不因分块尺寸不一致而丢失;重复段落按文档顺序定位,周期性重复文本不得把后续分块拉回更早的重复位置伪造向量证据。词法分块器为版本 2:标题边界围栏感知,代码围栏内的 `#` 注释不再切成伪小节,分块几何随版本号封入 documentHash。既有 v5 数据的 version-1 词法缓存因此哈希失配,经当前文件、版本与哈希校验走安全 delta overlay 按需重建派生索引;知识正文与来源不改写,不做全量分析,也不强制全量 embedding 索引重建,向量行按正文哈希独立校验不受影响。健康信息只记录召回、合并、截取与各类拒绝计数,不回显知识正文。原文变化、删除、项目归属变化及记忆失效都会重新验证;未知或未标定模型不能只凭高向量分返回知识。`retrieval-policy.json` 的 embo-01 / 1536 维门槛来自固定语料的训练问题族,测试问题族不参与选择;不是对任意语料准确率的保证。
|
|
80
80
|
|
|
81
81
|
普通搜索每篇最多两个互补片段,精确提交与源文件仍优先。`search` 增加 `intent`、`rankingVersion`、`matchStatus`、`currentProjectStatus` 和 `matchReason` 等兼容字段;最近进度优先当前项目维护历史,时间来源区分提交依据、晋升和 mtime 回退。问候不进行主题召回,短追问仅复用同项目同会话五分钟内仍有效的引用,引用缓存只在内存中且有界,不保存原对话。索引故障与“本次无可靠匹配”不等于知识为空。
|
|
82
82
|
|
|
@@ -90,7 +90,7 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
90
90
|
- 无头分析在隔离进程中运行,只读冻结证据,只写本次 staging。产物必须通过路径、内容 hash 和依据校验,只有晋升路径写最终项目知识。
|
|
91
91
|
- 归档对话和检索内容只作证据,不能覆盖后台维护指令。SDK 返回成功文字但未生成 manifest 时,记录 `ANALYSIS_OUTPUT_MISSING` 并保留证据供重试,不误报晋升完成。
|
|
92
92
|
- 空知识库是正常首用状态。输出合同明确 UTF-8、一级标题和非空正文;仅缺标题的非空文档在 staging 中确定性补齐并重新封印哈希,空正文、非法路径、占位内容或不匹配哈希仍拒绝晋升。
|
|
93
|
-
- 占位检测区分未完成正文与源码示例:HTML 的 `placeholder=`
|
|
93
|
+
- 占位检测区分未完成正文与源码示例:HTML 的 `placeholder=` 属性、行内代码和有正文解释的围栏代码不被误判为未完成知识;只有标题或仅用代码包裹未完成标记的文档仍拒绝。裸 `placeholder` 术语只在 UI 语境加完整事实谓语时放行——中文事实动词(属性、校验、描述等)或英文完整说明谓语(describes、specifies 等),且句内不得出现待补、后续补齐等待填措辞;空引号或仅省略号的样例只在紧邻该术语或其限定词(属性说明、值为、value is 等)之后才拒绝,同句其它字段的空值不计入。`TBD` 仅在讨论标记机制本身的完整规则清单事实中放行(如"均未改动""之外不再有其它"且其后有实际文本),正则、围栏等技术名词本身不构成豁免,计划谓语(实现 TBD、方案待定)仍拒绝。校验不改写有效原文。
|
|
94
94
|
- 具体失败原因、失败步骤和提交依据进入运行详情及 `log/v2` 诊断。受外壳守护时通过 IPC 交给外壳现有日志系统;独立运行保存在 `<data>/logs/`,不写模型凭据和原始证据。升级不会重新运行已失败提交,用户可显式重试失败阶段。
|
|
95
95
|
- 数据目录有单实例租约,目标知识目录有写入锁。关闭或超时等待子进程实际退出,再释放写入锁。
|
|
96
96
|
- 晋升与索引分别记录。索引失败保留已晋升知识并显示待修复,不重新调用模型。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanqianx/ai-coding-knowledge-engine",
|
|
3
|
-
"version": "0.2.7-rc.
|
|
3
|
+
"version": "0.2.7-rc.6",
|
|
4
4
|
"description": "Knowledge engine module for project-knowledge: commit-boundary triggered knowledge analysis/generation (trigger plane) and retrieval for development assistants (retrieval plane).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -3,12 +3,30 @@ const fs = require('node:fs');
|
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
const { KnowledgeRetrievalService } = require('./runtime/knowledge-retrieval-service');
|
|
6
|
-
const { listMarkdownFiles, isExcludedKnowledgePath, CHUNKER_VERSION } = require('./runtime/markdown-knowledge-indexer');
|
|
6
|
+
const { listMarkdownFiles, isExcludedKnowledgePath, CHUNKER_VERSION, lineFenceState, headingOf } = require('./runtime/markdown-knowledge-indexer');
|
|
7
7
|
const { DomainError } = require('./runtime/contracts');
|
|
8
|
+
const { sha256 } = require('./runtime/content-hash');
|
|
8
9
|
const ranking = require('./retrieval-ranking');
|
|
9
10
|
const fileNames = query => [...new Set((String(query).match(/[\p{L}\p{N}_./\\-]+\.(?:cjs|mjs|js|jsx|ts|tsx|py|cs|cpp|c|h|hpp|rs|go|html|css|scss|json|ya?ml|toml|md|txt|sh|ps1)\b/giu) || []).map(value => path.posix.basename(value.replace(/\\/g, '/')).toLowerCase()))].slice(0, 20);
|
|
10
11
|
const mentionsFile = (candidate, name) => new RegExp(`(?<![\\p{L}\\p{N}_-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}_.-])`, 'iu').test([candidate.entryId, candidate.title, candidate.chunkText, ...(candidate.metadata?.sourcePaths || [])].join('\n'));
|
|
11
12
|
|
|
13
|
+
const headingPathAt = (body, pos) => {
|
|
14
|
+
const headings = [];
|
|
15
|
+
let fence = null;
|
|
16
|
+
let offset = 0;
|
|
17
|
+
for (const line of String(body).split('\n')) {
|
|
18
|
+
const lineStart = offset;
|
|
19
|
+
offset += line.length + 1;
|
|
20
|
+
if (lineStart > pos) break;
|
|
21
|
+
fence = lineFenceState(line, fence);
|
|
22
|
+
if (fence) continue;
|
|
23
|
+
const heading = headingOf(line);
|
|
24
|
+
if (heading) { headings.length = heading.level - 1; headings[heading.level - 1] = heading.text; }
|
|
25
|
+
}
|
|
26
|
+
return headings.filter(Boolean);
|
|
27
|
+
};
|
|
28
|
+
const overlapOf = (aStart, aEnd, bStart, bEnd) => Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart));
|
|
29
|
+
|
|
12
30
|
class EngineRetrieval extends KnowledgeRetrievalService {
|
|
13
31
|
constructor(options) { super(options); this.store = options.store; this.memories = options.memories; this.vectorUrl = options.vectorUrl || 'http://127.0.0.1:8787'; this.retrievalPolicy = options.retrievalPolicy || require('./retrieval-policy.json'); }
|
|
14
32
|
async retrieve(input = {}) {
|
|
@@ -23,11 +41,23 @@ class EngineRetrieval extends KnowledgeRetrievalService {
|
|
|
23
41
|
const key = `${c.spaceId}:${c.entryId}`;
|
|
24
42
|
try {
|
|
25
43
|
if (!bodies.has(key)) bodies.set(key, ranking.bodyText(fs.readFileSync(path.join(c.knowledgePath, c.entryId), 'utf8')));
|
|
26
|
-
const body = bodies.get(key), start = body.indexOf(c.chunkText);
|
|
27
|
-
c.bodyHash = ranking.bodyHash(body); c.bodyLength = body.length; c.startPos = start; c.endPos = start < 0 ? -1 : start + c.chunkText.length;
|
|
28
44
|
} catch { scanned.candidates.delete(id); }
|
|
29
45
|
}
|
|
30
|
-
|
|
46
|
+
// The canonical chunker emits authoritative half-open offsets into the same
|
|
47
|
+
// body coordinate system. A position is accepted only when the current
|
|
48
|
+
// body still slices to the exact chunk text; anything else degrades to
|
|
49
|
+
// unpositioned instead of guessing by search.
|
|
50
|
+
for (const c of scanned.candidates.values()) {
|
|
51
|
+
const body = bodies.get(`${c.spaceId}:${c.entryId}`);
|
|
52
|
+
const positioned = Boolean(body) && Number.isInteger(c.chunkStart) && Number.isInteger(c.chunkEnd)
|
|
53
|
+
&& c.chunkStart >= 0 && c.chunkEnd > c.chunkStart && c.chunkEnd <= body.length && body.slice(c.chunkStart, c.chunkEnd) === c.chunkText;
|
|
54
|
+
c.bodyHash = body ? ranking.bodyHash(body) : null;
|
|
55
|
+
c.bodyLength = body ? body.length : 0;
|
|
56
|
+
c.startPos = positioned ? c.chunkStart : -1;
|
|
57
|
+
c.endPos = positioned ? c.chunkEnd : -1;
|
|
58
|
+
}
|
|
59
|
+
const lexicalChunkCount = scanned.candidates.size;
|
|
60
|
+
if (!['greeting', 'followup'].includes(ranking.intent(signals.indexQuery))) await this.recallIndex(scopes, signals.indexQuery, scanned.candidates, health, bodies);
|
|
31
61
|
else health.index = 'not-requested';
|
|
32
62
|
if (stamp !== scopeStamp(this.store.getProject(project.projectId))) throw new DomainError('PROJECT_SCOPE_CHANGED', '项目归属或绑定已变化,请重新检索。', { status: 409 });
|
|
33
63
|
for (const [id, c] of scanned.candidates) {
|
|
@@ -58,7 +88,7 @@ class EngineRetrieval extends KnowledgeRetrievalService {
|
|
|
58
88
|
if (checked.get(key)) candidates.push(c);
|
|
59
89
|
if (candidates.length >= this.candidateLimit) break;
|
|
60
90
|
}
|
|
61
|
-
return { project, scopes, signals, health, candidates, totalCurrentChunks:
|
|
91
|
+
return { project, scopes, signals, health, candidates, totalCurrentChunks: lexicalChunkCount, intent: ranking.intent(signals.indexQuery) };
|
|
62
92
|
}
|
|
63
93
|
signalSet(input = {}) {
|
|
64
94
|
const signals = super.signalSet(input);
|
|
@@ -122,13 +152,21 @@ class EngineRetrieval extends KnowledgeRetrievalService {
|
|
|
122
152
|
result.scopeHealth.push(...extra.scopeHealth);
|
|
123
153
|
return result;
|
|
124
154
|
}
|
|
125
|
-
async recallIndex(scopes, query, current, health) {
|
|
155
|
+
async recallIndex(scopes, query, current, health, bodies) {
|
|
126
156
|
if (!query) { health.index = 'unavailable'; return; }
|
|
127
157
|
// File identifiers, like commit IDs, select current facts before semantic
|
|
128
158
|
// ranking. A not-yet-known filename still permits analogous background.
|
|
129
159
|
const named = fileNames(query), known = named.filter(name => [...current.values()].some(candidate => mentionsFile(candidate, name)));
|
|
130
160
|
for (const candidate of current.values()) { candidate.knownFileQuery = known.length > 0; candidate.fileReferences = known.filter(name => mentionsFile(candidate, name)); }
|
|
131
161
|
const mappings = scopes.map(s => ({ scope: s, registration: this.store.getProject(s.projectId) || (() => { const shared = this.store.state.sharedScopes.find(p => p.id === s.projectId); return shared ? { vectorProjectId: `scope-${shared.kind}-${shared.id}` } : null; })() })).filter(m => m.registration);
|
|
162
|
+
const recall = { rows: 0, merges: 0, slices: 0, rejected: {} };
|
|
163
|
+
const reject = reason => { recall.rejected[reason] = (recall.rejected[reason] || 0) + 1; };
|
|
164
|
+
const anchors = new Map();
|
|
165
|
+
for (const candidate of current.values()) {
|
|
166
|
+
const key = `${candidate.spaceId}:${candidate.entryId}`;
|
|
167
|
+
if (!anchors.has(key)) anchors.set(key, candidate);
|
|
168
|
+
}
|
|
169
|
+
const pending = [];
|
|
132
170
|
try {
|
|
133
171
|
const url = new URL('/api/search', this.vectorUrl);
|
|
134
172
|
url.searchParams.set('q', query);
|
|
@@ -145,33 +183,90 @@ class EngineRetrieval extends KnowledgeRetrievalService {
|
|
|
145
183
|
// Vectors only contribute a rank. Displayed text/hash always comes from
|
|
146
184
|
// current canonical files, so deleted or stale index bodies cannot leak.
|
|
147
185
|
for (const row of payload.results || []) {
|
|
186
|
+
recall.rows += 1;
|
|
148
187
|
const mapping = mappings.find(m => (m.registration.vectorProjectId || m.scope.projectId) === row.project);
|
|
149
|
-
if (!mapping) continue;
|
|
188
|
+
if (!mapping) { reject('unknown-project'); continue; }
|
|
150
189
|
const uri = row.uri || row.documentUri || '';
|
|
151
190
|
const entry = path.isAbsolute(uri) ? path.relative(mapping.scope.knowledgePath, uri).replace(/\\/g, '/') : String(uri).replace(/\\/g, '/');
|
|
191
|
+
const key = `${mapping.scope.spaceId}:${entry}`;
|
|
192
|
+
const anchor = anchors.get(key);
|
|
193
|
+
if (!anchor || !bodies || !bodies.has(key)) { reject('no-current-document'); continue; }
|
|
194
|
+
const valid = row.scoreType === 'cosine' && row.bodyHash === anchor.bodyHash && Number.isInteger(row.startPos) && Number.isInteger(row.endPos)
|
|
195
|
+
&& row.startPos >= 0 && row.endPos > row.startPos && row.endPos <= anchor.bodyLength;
|
|
196
|
+
if (!valid) { reject(row.bodyHash === anchor.bodyHash ? 'invalid-range' : 'body-hash-mismatch'); continue; }
|
|
197
|
+
const value = Number(row.score);
|
|
198
|
+
if (!Number.isFinite(value) || value < -1 || value > 1) { reject('invalid-score'); continue; }
|
|
152
199
|
for (const candidate of current.values()) {
|
|
153
|
-
if (candidate.spaceId
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
for (const range of ranges) { covered += Math.max(0, range[1] - Math.max(end, range[0])); end = Math.max(end, range[1]); }
|
|
166
|
-
if (covered >= candidate.chunkText.length * 0.5) { candidate.indexChannels = ['vector-hub']; candidate.vectorVerified = true; }
|
|
167
|
-
if (calibrated) { candidate.vectorCalibrated = true; candidate.vectorMinScore = calibrated.minScore; }
|
|
168
|
-
}
|
|
200
|
+
if (candidate.fromVectorRange || candidate.spaceId !== mapping.scope.spaceId || candidate.entryId !== entry || candidate.startPos < 0) continue;
|
|
201
|
+
const overlap = Math.max(0, Math.min(candidate.endPos, row.endPos) - Math.max(candidate.startPos, row.startPos));
|
|
202
|
+
if (!overlap) continue;
|
|
203
|
+
recall.merges += 1;
|
|
204
|
+
candidate.indexScore = Math.max(candidate.indexScore || 0, Math.max(0, value));
|
|
205
|
+
candidate.vectorRanges ||= [];
|
|
206
|
+
candidate.vectorRanges.push([Math.max(candidate.startPos, row.startPos), Math.min(candidate.endPos, row.endPos)]);
|
|
207
|
+
const ranges = [...candidate.vectorRanges].sort((a, b) => a[0] - b[0]);
|
|
208
|
+
let covered = 0, end = -1;
|
|
209
|
+
for (const range of ranges) { covered += Math.max(0, range[1] - Math.max(end, range[0])); end = Math.max(end, range[1]); }
|
|
210
|
+
if (covered >= candidate.chunkText.length * 0.5) { candidate.indexChannels = ['vector-hub']; candidate.vectorVerified = true; }
|
|
211
|
+
if (calibrated) { candidate.vectorCalibrated = true; candidate.vectorMinScore = calibrated.minScore; }
|
|
169
212
|
}
|
|
213
|
+
const sliceText = bodies.get(key).slice(row.startPos, row.endPos);
|
|
214
|
+
if (!sliceText.trim()) { reject('empty-slice'); continue; }
|
|
215
|
+
let duplicate = false;
|
|
216
|
+
for (const candidate of current.values()) {
|
|
217
|
+
if (!candidate.fromVectorRange || candidate.spaceId !== mapping.scope.spaceId || candidate.entryId !== entry) continue;
|
|
218
|
+
if (overlapOf(candidate.startPos, candidate.endPos, row.startPos, row.endPos) * 2 >= Math.min(candidate.endPos - candidate.startPos, row.endPos - row.startPos)) { duplicate = true; break; }
|
|
219
|
+
}
|
|
220
|
+
for (const prior of pending) {
|
|
221
|
+
if (prior.scope.spaceId !== mapping.scope.spaceId || prior.entry !== entry) continue;
|
|
222
|
+
if (overlapOf(prior.row.startPos, prior.row.endPos, row.startPos, row.endPos) * 2 >= Math.min(prior.row.endPos - prior.row.startPos, row.endPos - row.startPos)) { duplicate = true; break; }
|
|
223
|
+
}
|
|
224
|
+
if (duplicate) continue;
|
|
225
|
+
pending.push({ scope: mapping.scope, key, entry, anchor, row, sliceText, value, calibrated });
|
|
226
|
+
}
|
|
227
|
+
for (const p of pending) {
|
|
228
|
+
recall.slices += 1;
|
|
229
|
+
const recordId = `${p.scope.spaceId}:${p.entry}:vector:${p.row.startPos}-${p.row.endPos}`;
|
|
230
|
+
current.set(recordId, {
|
|
231
|
+
recordId,
|
|
232
|
+
spaceId: p.scope.spaceId,
|
|
233
|
+
projectId: p.scope.projectId,
|
|
234
|
+
scopeReason: p.scope.reason,
|
|
235
|
+
// A slice inherits the anchor candidate's own scope weight: a project
|
|
236
|
+
// memory keeps project weight even inside a lower-weight shared scope.
|
|
237
|
+
scopeWeight: p.anchor.scopeWeight,
|
|
238
|
+
fromVectorRange: true,
|
|
239
|
+
knowledgePath: p.scope.knowledgePath,
|
|
240
|
+
entryId: p.entry,
|
|
241
|
+
entryType: p.anchor.entryType,
|
|
242
|
+
chunkId: `${p.entry}:vector:${p.row.startPos}-${p.row.endPos}`,
|
|
243
|
+
chunkOrder: null,
|
|
244
|
+
title: p.anchor.title,
|
|
245
|
+
headingPath: headingPathAt(bodies.get(p.key), p.row.startPos),
|
|
246
|
+
chunkText: p.sliceText,
|
|
247
|
+
documentHash: p.anchor.documentHash,
|
|
248
|
+
contentHash: sha256(p.sliceText),
|
|
249
|
+
metadata: p.anchor.metadata,
|
|
250
|
+
fromOverlay: p.anchor.fromOverlay,
|
|
251
|
+
bodyHash: p.anchor.bodyHash,
|
|
252
|
+
bodyLength: p.anchor.bodyLength,
|
|
253
|
+
startPos: p.row.startPos,
|
|
254
|
+
endPos: p.row.endPos,
|
|
255
|
+
indexScore: Math.min(1, Math.max(0, p.value)),
|
|
256
|
+
indexChannels: ['vector-hub'],
|
|
257
|
+
vectorRanges: [[p.row.startPos, p.row.endPos]],
|
|
258
|
+
vectorVerified: true,
|
|
259
|
+
knownFileQuery: known.length > 0,
|
|
260
|
+
fileReferences: known.filter(name => mentionsFile({ entryId: p.entry, title: p.anchor.title, chunkText: p.sliceText, metadata: p.anchor.metadata }, name)),
|
|
261
|
+
...(p.calibrated ? { vectorCalibrated: true, vectorMinScore: p.calibrated.minScore } : {}),
|
|
262
|
+
});
|
|
170
263
|
}
|
|
171
264
|
health.index = payload.projectsSkipped?.length ? 'partial' : 'ok';
|
|
172
|
-
health.verifiedVectorChunks = [...current.values()].filter(c => c.vectorVerified).length;
|
|
265
|
+
health.verifiedVectorChunks = [...current.values()].filter(c => c.vectorVerified && !c.fromVectorRange).length;
|
|
173
266
|
health.vectorMode = payload.resultLevel === 'chunk' ? 'verified-chunks' : 'legacy-document-results';
|
|
174
267
|
} catch { health.index = 'degraded'; health.reason = 'vector-hub-unavailable'; }
|
|
268
|
+
health.vectorRecall = recall;
|
|
269
|
+
health.verifiedVectorSlices = recall.slices;
|
|
175
270
|
}
|
|
176
271
|
async search(input = {}) {
|
|
177
272
|
const registration = this.resolve(input);
|
|
@@ -48,7 +48,26 @@ function decodeUtf8(buffer) {
|
|
|
48
48
|
// A quoted sample is exempt only when the same sentence of the original
|
|
49
49
|
// document names a concrete UI field or attribute context; a quoted marker
|
|
50
50
|
// without that context is still unfinished prose, whatever it quotes.
|
|
51
|
-
const UI_CONTEXT = /(?:<(?:input|textarea|select)\b|#[a-zA-Z][\w-]*|\b(?:input|textarea|select|field|form|dialog|ui|html|attribute)\b|属性|字段|输入|表单|对话框|界面|控件|提示|文案)/i;
|
|
51
|
+
const UI_CONTEXT = /(?:<(?:input|textarea|select)\b|#[a-zA-Z][\w-]*|\b(?:input|textarea|select|field|form|dialog|ui|html|attribute)\b|属性|字段|输入|表单|对话框|界面|控件|提示|文案)/i;
|
|
52
|
+
const FACTUAL_TERM = /(?:事实|属性|校验|验证|描述|说明|区分|识别|判定|误判|放行|拒绝|豁免|术语|样例|示例|规则|正则|边界)/;
|
|
53
|
+
// Full predicate inflections only (noun forms like "description" do not
|
|
54
|
+
// vouch for an attribute by themselves).
|
|
55
|
+
const FACTUAL_TERM_EN = /\b(?:describ(?:es?|ed|ing)|specif(?:ies|ied|ying)|defin(?:es?|ed|ing)|indicat(?:es?|ed|ing)|displa(?:ys?|yed|ying)|provid(?:es?|ed|ing)|show(?:s?|n|ing)|control(?:s?|led|ling)|explain(?:s?|ed|ing)|document(?:s?|ed|ing))\b/i;
|
|
56
|
+
const INCOMPLETE_LATER = /(?:待补|待填|待定|待完成|待完善|后续补充|后续补齐|后续再说|尚未|还没|暂未|fill this in|later|\bpending\b|\bunfinished\b|\bnot\s+yet\b)/i;
|
|
57
|
+
const EMPTY_SAMPLE = /[「"'“][ \t]*[..…]*[ \t]*[」"'”]/;
|
|
58
|
+
// An empty sample counts only inside this placeholder's own noun phrase:
|
|
59
|
+
// right beside the term or behind a short qualifier such as 属性说明 or
|
|
60
|
+
// "value is"; once a real predicate starts, later empty values belong to
|
|
61
|
+
// other fields and must not veto this mention.
|
|
62
|
+
const OWN_EMPTY_SAMPLE = new RegExp(
|
|
63
|
+
'^[ \\t]*(?:的[ \\t]*)?(?:默认值|属性|字段|输入|提示|文案|样例|样本|示例|说明|描述|值|attribute|value|sample|text|label)?'
|
|
64
|
+
+ '[ \\t]*(?:说明|描述|样例|样本|示例|文案|值)?[ \\t]*(?:是|为|is|=|:|:)?[ \\t]*' + EMPTY_SAMPLE.source,
|
|
65
|
+
'i'
|
|
66
|
+
);
|
|
67
|
+
// A TBD mention passes only inside a complete factual predicate about the
|
|
68
|
+
// marker machinery itself: an unchanged or nothing-beyond claim with real
|
|
69
|
+
// text after the mention. Technical nouns alone never vouch for TBD.
|
|
70
|
+
const TBD_COMPLETE_FACT = /(?:未改动|未调整|未变化|未修改|保持不变|保持原样|之外不再|不再有其它|没有其它)/;
|
|
52
71
|
|
|
53
72
|
function sentenceAround(text, index) {
|
|
54
73
|
const delimiters = /[。!?;\n.!?;]/g;
|
|
@@ -72,25 +91,60 @@ function maskCoded(content) {
|
|
|
72
91
|
.replace(/(`+)[^\n]*?\1/g, span => span.replace(/[^\n]/g, ' '));
|
|
73
92
|
}
|
|
74
93
|
|
|
75
|
-
function unfinishedPlaceholder(prose, source) {
|
|
76
|
-
const re = /\bplaceholder\b[ \t]*(=|[「"'“][^」"'”]*[」"'”])?/gi;
|
|
77
|
-
let match;
|
|
78
|
-
while ((match = re.exec(prose))) {
|
|
79
|
-
if (match[1] === '=') continue;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
function unfinishedPlaceholder(prose, source) {
|
|
95
|
+
const re = /\bplaceholder\b[ \t]*(=|[「"'“][^」"'”]*[」"'”]|[「"'“])?/gi;
|
|
96
|
+
let match;
|
|
97
|
+
while ((match = re.exec(prose))) {
|
|
98
|
+
if (match[1] === '=') continue;
|
|
99
|
+
// The same sentence governs the decision; inline code stays masked so it
|
|
100
|
+
// can lend neither context nor samples.
|
|
101
|
+
const sentence = sentenceAround(source, match.index);
|
|
102
|
+
const masked = maskCoded(sentence.text);
|
|
103
|
+
// An empty or ellipsis-only quoted sample is an unfinished hole only in
|
|
104
|
+
// this placeholder's own noun phrase: directly after the term or after
|
|
105
|
+
// a qualifier such as 属性说明 or "value is". Empty values owned by
|
|
106
|
+
// another field later in the sentence never veto the mention.
|
|
107
|
+
const rest = masked.slice(match.index - sentence.start + match[0].length).split(/[,,]/)[0];
|
|
108
|
+
if (OWN_EMPTY_SAMPLE.test(rest)) return match[0];
|
|
109
|
+
// A quote that opens right beside the placeholder yet never closes is an
|
|
110
|
+
// unfinished sample, not an attribute mention.
|
|
111
|
+
if (match[1] && match[1].length === 1) return match[0];
|
|
112
|
+
if (match[1]) {
|
|
113
|
+
const sample = match[1].slice(1, -1).trim();
|
|
114
|
+
if (!sample || /^[..…]+$/.test(sample)) return match[0];
|
|
115
|
+
// Context must come from this very sentence, outside the quoted value,
|
|
116
|
+
// so neither an earlier twin hit nor the sample itself can vouch for it.
|
|
117
|
+
const quoteOffset = match.index + match[0].length - match[1].length - sentence.start;
|
|
118
|
+
const context = sentence.text.slice(0, quoteOffset) + ' '.repeat(match[1].length) + sentence.text.slice(quoteOffset + match[1].length);
|
|
119
|
+
if (UI_CONTEXT.test(context)) continue;
|
|
120
|
+
} else {
|
|
121
|
+
// A bare term passes only as a factual description of the UI attribute:
|
|
122
|
+
// UI context in the raw sentence plus a factual predicate in the prose
|
|
123
|
+
// (Chinese verb or complete English description verb) with no
|
|
124
|
+
// to-be-filled wording.
|
|
125
|
+
if (UI_CONTEXT.test(sentence.text) && (FACTUAL_TERM.test(masked) || FACTUAL_TERM_EN.test(masked)) && !INCOMPLETE_LATER.test(masked)) continue;
|
|
126
|
+
}
|
|
127
|
+
return match[0];
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// TBD may be named only as the topic of a complete rule-list fact, never as
|
|
133
|
+
// the predicate of a planning sentence. The completion predicate must sit in
|
|
134
|
+
// the same comma clause after the mention: 、 enumerations stay inside the
|
|
135
|
+
// clause, and a fact from a neighbouring clause never vouches for this TBD.
|
|
136
|
+
function unfinishedTbd(prose, source) {
|
|
137
|
+
const re = /\bTBD\b/gi;
|
|
138
|
+
let match;
|
|
139
|
+
while ((match = re.exec(prose))) {
|
|
140
|
+
const sentence = sentenceAround(source, match.index);
|
|
141
|
+
const masked = maskCoded(sentence.text);
|
|
142
|
+
const clause = masked.slice(match.index - sentence.start + match[0].length).split(/[,,]/)[0];
|
|
143
|
+
if (TBD_COMPLETE_FACT.test(clause) && !INCOMPLETE_LATER.test(masked)) continue;
|
|
144
|
+
return match[0];
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
94
148
|
|
|
95
149
|
function validateMarkdown(content, relativePath) {
|
|
96
150
|
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(content)) {
|
|
@@ -100,10 +154,12 @@ function validateMarkdown(content, relativePath) {
|
|
|
100
154
|
// UI attributes and quoted source samples describe real changes; they are
|
|
101
155
|
// not unfinished prose. Quoted samples still need a UI context in the same
|
|
102
156
|
// sentence, so fencing a placeholder cannot make it valid knowledge.
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
157
|
+
const hardMarker = /(?:\[TODO:|\bTODO:\s)/i;
|
|
158
|
+
const tbd = /\bTBD\b/i;
|
|
159
|
+
const prose = maskCoded(content);
|
|
160
|
+
const bare = !prose.replace(/^#{1,6}[ \t]+.*$/gm, '').trim();
|
|
161
|
+
if (hardMarker.test(prose) || unfinishedTbd(prose, content) || unfinishedPlaceholder(prose, content)
|
|
162
|
+
|| bare && (hardMarker.test(content) || tbd.test(content) || unfinishedPlaceholder(content, content))) {
|
|
107
163
|
throw new DomainError('INVALID_ARGUMENT', `Placeholder knowledge is not allowed: ${relativePath}.`);
|
|
108
164
|
}
|
|
109
165
|
const fences = content.match(/^```/gm) || [];
|
|
@@ -140,12 +140,14 @@ class KnowledgeRetrievalService {
|
|
|
140
140
|
knowledgePath: scope.knowledgePath,
|
|
141
141
|
entryId,
|
|
142
142
|
entryType: inferEntryType(entryId),
|
|
143
|
-
chunkId,
|
|
144
|
-
chunkOrder: chunk.chunkOrder,
|
|
145
|
-
title,
|
|
146
|
-
headingPath: chunk.headingPath,
|
|
147
|
-
chunkText: chunk.chunkText,
|
|
148
|
-
|
|
143
|
+
chunkId,
|
|
144
|
+
chunkOrder: chunk.chunkOrder,
|
|
145
|
+
title,
|
|
146
|
+
headingPath: chunk.headingPath,
|
|
147
|
+
chunkText: chunk.chunkText,
|
|
148
|
+
chunkStart: Number.isInteger(chunk.startPos) ? chunk.startPos : null,
|
|
149
|
+
chunkEnd: Number.isInteger(chunk.endPos) ? chunk.endPos : null,
|
|
150
|
+
documentHash,
|
|
149
151
|
contentHash: documentSha256(chunk.chunkText),
|
|
150
152
|
metadata,
|
|
151
153
|
fromOverlay,
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const { sha256 } = require('./content-hash');
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { sha256 } = require('./content-hash');
|
|
4
|
+
const { bodyText } = require('../retrieval-ranking');
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
// Version 2: fence-aware heading boundaries changed real chunk geometry (a
|
|
7
|
+
// fenced '#' comment no longer splits a section), so version-1 lexical caches
|
|
8
|
+
// must miss their sealed document hash and rebuild through the delta overlay.
|
|
9
|
+
const CHUNKER_VERSION = 2;
|
|
6
10
|
const DEFAULT_MAX_CHARS = 1200;
|
|
7
11
|
const DEFAULT_OVERLAP_CHARS = 120;
|
|
8
12
|
const EXCLUDED_DIRS = new Set(['.git', '_ai', '_backup', 'node_modules', 'dev-conversations']);
|
|
@@ -43,67 +47,102 @@ function parseKnowledgeMetadata(markdown) {
|
|
|
43
47
|
};
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
function splitLongText(text, maxChars, overlapChars) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
if (clean
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
function splitLongText(text, maxChars, overlapChars, base = 0) {
|
|
51
|
+
const raw = String(text || '');
|
|
52
|
+
const clean = raw.trim();
|
|
53
|
+
if (!clean) return [];
|
|
54
|
+
const origin = base + (raw.length - raw.trimStart().length);
|
|
55
|
+
if (clean.length <= maxChars) return [{ text: clean, start: origin, end: origin + clean.length }];
|
|
56
|
+
const parts = [];
|
|
57
|
+
let start = 0;
|
|
58
|
+
while (start < clean.length) {
|
|
53
59
|
let end = Math.min(start + maxChars, clean.length);
|
|
54
60
|
if (end < clean.length) {
|
|
55
61
|
const floor = start + Math.floor(maxChars * 0.6);
|
|
56
62
|
const boundary = Math.max(clean.lastIndexOf('\n\n', end), clean.lastIndexOf('。', end), clean.lastIndexOf('. ', end));
|
|
57
63
|
if (boundary >= floor) end = boundary + 1;
|
|
58
64
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
65
|
+
const piece = clean.slice(start, end);
|
|
66
|
+
const trimmed = piece.trim();
|
|
67
|
+
if (trimmed) {
|
|
68
|
+
const lead = piece.length - piece.trimStart().length;
|
|
69
|
+
parts.push({ text: trimmed, start: origin + start + lead, end: origin + start + lead + trimmed.length });
|
|
70
|
+
}
|
|
71
|
+
if (end >= clean.length) break;
|
|
72
|
+
start = Math.max(start + 1, end - overlapChars);
|
|
73
|
+
}
|
|
74
|
+
return parts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Heading and fence parsing must stay identical to the slice heading scan in
|
|
78
|
+
// retrieval.js so vector slices report the same heading paths as chunks.
|
|
79
|
+
function lineFenceState(line, fence) {
|
|
80
|
+
const marker = /^([ \t]*)(`{3,}|~{3,})(.*)$/.exec(line);
|
|
81
|
+
if (!marker) return fence;
|
|
82
|
+
if (!fence) return marker[2];
|
|
83
|
+
if (fence[0] === marker[2][0] && marker[2].length >= fence.length && !marker[3].trim()) return null;
|
|
84
|
+
return fence;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function headingOf(line) {
|
|
88
|
+
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
89
|
+
return match ? { level: match[1].length, text: match[2].trim() } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function chunkMarkdown(markdown, options = {}) {
|
|
93
|
+
const maxChars = Number(options.maxChars || DEFAULT_MAX_CHARS);
|
|
94
|
+
const overlapChars = Math.min(Number(options.overlapChars || DEFAULT_OVERLAP_CHARS), Math.floor(maxChars / 3));
|
|
95
|
+
const body = bodyText(markdown);
|
|
96
|
+
const lines = body.split('\n');
|
|
97
|
+
const headings = [];
|
|
98
|
+
const sections = [];
|
|
99
|
+
let buffer = [];
|
|
100
|
+
let bufferStart = 0;
|
|
101
|
+
let sectionHeadings = [];
|
|
102
|
+
let fence = null;
|
|
103
|
+
|
|
104
|
+
const flush = () => {
|
|
105
|
+
const text = buffer.join('\n');
|
|
106
|
+
const trimmed = text.trim();
|
|
107
|
+
if (trimmed) {
|
|
108
|
+
const lead = text.length - text.trimStart().length;
|
|
109
|
+
const trail = text.length - text.trimEnd().length;
|
|
110
|
+
sections.push({ headingPath: [...sectionHeadings], text: trimmed, start: bufferStart + lead, end: bufferStart + text.length - trail });
|
|
111
|
+
}
|
|
112
|
+
buffer = [];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let offset = 0;
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
const lineStart = offset;
|
|
118
|
+
offset += line.length + 1;
|
|
119
|
+
fence = lineFenceState(line, fence);
|
|
120
|
+
// Only real headings outside code fences split sections; a fenced '#'
|
|
121
|
+
// comment is plain content.
|
|
122
|
+
const heading = fence ? null : headingOf(line);
|
|
123
|
+
if (heading) {
|
|
124
|
+
flush();
|
|
125
|
+
headings.length = heading.level - 1;
|
|
126
|
+
headings[heading.level - 1] = heading.text;
|
|
127
|
+
sectionHeadings = headings.filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
if (!buffer.length) bufferStart = lineStart;
|
|
130
|
+
buffer.push(line);
|
|
131
|
+
}
|
|
132
|
+
flush();
|
|
133
|
+
|
|
134
|
+
const chunks = [];
|
|
135
|
+
for (const section of sections) {
|
|
136
|
+
for (const part of splitLongText(section.text, maxChars, overlapChars, section.start)) {
|
|
137
|
+
chunks.push({
|
|
138
|
+
chunkOrder: chunks.length,
|
|
139
|
+
headingPath: section.headingPath,
|
|
140
|
+
chunkText: part.text,
|
|
141
|
+
startPos: part.start,
|
|
142
|
+
endPos: part.end,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
107
146
|
return chunks;
|
|
108
147
|
}
|
|
109
148
|
|
|
@@ -223,11 +262,13 @@ class MarkdownKnowledgeIndexer {
|
|
|
223
262
|
}
|
|
224
263
|
}
|
|
225
264
|
|
|
226
|
-
module.exports = {
|
|
227
|
-
MarkdownKnowledgeIndexer,
|
|
228
|
-
CHUNKER_VERSION,
|
|
229
|
-
chunkMarkdown,
|
|
230
|
-
|
|
265
|
+
module.exports = {
|
|
266
|
+
MarkdownKnowledgeIndexer,
|
|
267
|
+
CHUNKER_VERSION,
|
|
268
|
+
chunkMarkdown,
|
|
269
|
+
lineFenceState,
|
|
270
|
+
headingOf,
|
|
271
|
+
listMarkdownFiles,
|
|
231
272
|
isExcludedKnowledgePath,
|
|
232
273
|
isDerivedIndex,
|
|
233
274
|
inferEntryType,
|