@customize-agent/knowledge 4.3.5 → 4.3.6
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/dist/cleaning/text-cleaner.js +5 -4
- package/dist/core/index-state-store.d.ts +2 -0
- package/dist/core/index-state-store.js +8 -0
- package/dist/core/knowledge-base-manager.d.ts +22 -0
- package/dist/core/knowledge-base-manager.js +75 -4
- package/dist/embedding/local-reranker.d.ts +14 -0
- package/dist/embedding/local-reranker.js +87 -0
- package/dist/embedding/rerank-worker-thread.d.ts +1 -0
- package/dist/embedding/rerank-worker-thread.js +74 -0
- package/dist/extraction/content-extractor.d.ts +2 -0
- package/dist/extraction/content-extractor.js +68 -27
- package/dist/extraction/text-encoding.d.ts +21 -0
- package/dist/extraction/text-encoding.js +62 -0
- package/package.json +1 -1
|
@@ -396,14 +396,15 @@ export function cleanExtractedText(input) {
|
|
|
396
396
|
index += run;
|
|
397
397
|
continue;
|
|
398
398
|
}
|
|
399
|
-
// 3.
|
|
400
|
-
|
|
399
|
+
// 3. 页眉/页脚/图框标题栏高重复行(CAD 豁免:图纸标注重复出现是数据本身——
|
|
400
|
+
// 门窗编号「FM1524」、材料规格等会随楼层重复标注,非页眉页脚;CAD 图框噪声已由 K3 规则覆盖)
|
|
401
|
+
if (!isCad && isHeaderFooterLine(trimmed, lineCounts.get(trimmed) ?? 1)) {
|
|
401
402
|
drop(line, s => { s.headerFooterLines += 1; });
|
|
402
403
|
index += 1;
|
|
403
404
|
continue;
|
|
404
405
|
}
|
|
405
|
-
// 4.
|
|
406
|
-
if (isPageNumberLine(trimmed, totalLines)) {
|
|
406
|
+
// 4. 纯页码行(CAD 豁免:图纸纯数字行是尺寸/标高/门窗表数值,非页码)
|
|
407
|
+
if (!isCad && isPageNumberLine(trimmed, totalLines)) {
|
|
407
408
|
drop(line, s => { s.pageNumberLines += 1; });
|
|
408
409
|
index += 1;
|
|
409
410
|
continue;
|
|
@@ -103,6 +103,8 @@ export declare class IndexStateStore {
|
|
|
103
103
|
/** 更新已验证文件的时间戳和状态 */
|
|
104
104
|
updateVerified(relativePath: string, mtime: number): void;
|
|
105
105
|
listRecords(): IndexStateRecord[];
|
|
106
|
+
/** 仅取去重后的集合名:走 idx_kb_state_collection 索引,比 listRecords 全行扫描+对象映射廉价得多 */
|
|
107
|
+
listCollectionNames(): string[];
|
|
106
108
|
enqueueIndexJob(job: {
|
|
107
109
|
id: string;
|
|
108
110
|
relativePath: string;
|
|
@@ -63,6 +63,14 @@ export class IndexStateStore {
|
|
|
63
63
|
`).all();
|
|
64
64
|
return rows.map(row => this.rowToRecord(row));
|
|
65
65
|
}
|
|
66
|
+
/** 仅取去重后的集合名:走 idx_kb_state_collection 索引,比 listRecords 全行扫描+对象映射廉价得多 */
|
|
67
|
+
listCollectionNames() {
|
|
68
|
+
const rows = this.db.prepare(`
|
|
69
|
+
SELECT DISTINCT collection_name FROM kb_index_state
|
|
70
|
+
WHERE status != 'deleted'
|
|
71
|
+
`).all();
|
|
72
|
+
return rows.map(row => String(row.collection_name));
|
|
73
|
+
}
|
|
66
74
|
enqueueIndexJob(job) {
|
|
67
75
|
const now = Date.now();
|
|
68
76
|
this.db.prepare(`
|
|
@@ -51,6 +51,9 @@ export declare class KnowledgeBaseManager {
|
|
|
51
51
|
private queryExpansionActive;
|
|
52
52
|
private readonly queryExpansionWaiters;
|
|
53
53
|
private onProgress?;
|
|
54
|
+
/** 集合名缓存:避免每次向量搜索都全表扫描 kb_index_state;TTL 兜底 worker 子进程跨进程写入的新集合 */
|
|
55
|
+
private collectionNamesCache;
|
|
56
|
+
private static readonly COLLECTION_NAMES_TTL_MS;
|
|
54
57
|
constructor(options: KnowledgeBaseManagerOptions);
|
|
55
58
|
initialize(): void;
|
|
56
59
|
forceReindexAll(options?: {
|
|
@@ -118,9 +121,16 @@ export declare class KnowledgeBaseManager {
|
|
|
118
121
|
}): Promise<DiffResult>;
|
|
119
122
|
addFile(sourcePath: string, targetRelativePath?: string): Promise<DiffResult>;
|
|
120
123
|
getUploadRelativePath(fileName: string, targetRelativePath?: string): string;
|
|
124
|
+
/**
|
|
125
|
+
* @deprecated 生产上传链路已切换为 stageUploadedFilePaths + 后台索引 worker(不落内存 buffer、支持上传会话等待)。
|
|
126
|
+
* 本同步路径仅保留给测试与 CLI 使用;新代码请走 stageUploadedFilePaths。
|
|
127
|
+
*/
|
|
121
128
|
uploadFile(fileName: string, content: Buffer, targetRelativePath?: string, onProgress?: (progress: KnowledgeIndexProgress) => void, options?: {
|
|
122
129
|
vectorMode?: 'sync' | 'defer';
|
|
123
130
|
}): Promise<DiffResult>;
|
|
131
|
+
/**
|
|
132
|
+
* @deprecated 生产上传链路已切换为 stageUploadedFilePaths(路径落盘,不占内存)。仅测试/CLI 使用。
|
|
133
|
+
*/
|
|
124
134
|
stageUploadedFiles(files: Array<{
|
|
125
135
|
fileName: string;
|
|
126
136
|
content: Buffer;
|
|
@@ -131,6 +141,9 @@ export declare class KnowledgeBaseManager {
|
|
|
131
141
|
sourcePath: string;
|
|
132
142
|
targetRelativePath?: string;
|
|
133
143
|
}>, operationId?: string, offset?: number, uploadComplete?: boolean): Promise<import("./index-state-store.js").KnowledgeIndexJob[]>;
|
|
144
|
+
/**
|
|
145
|
+
* @deprecated 同步上传+索引一体化旧路径,仅测试/CLI 使用;生产请用 stageUploadedFilePaths + 后台 worker。
|
|
146
|
+
*/
|
|
134
147
|
uploadFiles(files: Array<{
|
|
135
148
|
fileName: string;
|
|
136
149
|
content: Buffer;
|
|
@@ -199,7 +212,16 @@ export declare class KnowledgeBaseManager {
|
|
|
199
212
|
private reportProgress;
|
|
200
213
|
private updateJobsForFile;
|
|
201
214
|
private ensureVectorStore;
|
|
215
|
+
/** 本进程写入新记录时即时补充集合名缓存(不等 TTL),保证紧随其后的搜索立即可见 */
|
|
216
|
+
private noteCollectionName;
|
|
217
|
+
/** 为全部已知集合确保向量存储已加载;集合名集合带 2s TTL 缓存,避免每次搜索全表扫描 */
|
|
218
|
+
private ensureAllVectorStores;
|
|
202
219
|
private deleteVectorFile;
|
|
220
|
+
/** 登记删除失败的孤儿向量(去重、限量 500 条防止 metadata 膨胀) */
|
|
221
|
+
private noteOrphanVector;
|
|
222
|
+
private listOrphanVectors;
|
|
223
|
+
/** 清扫孤儿向量:重试删除此前失败的条目,成功的出队,仍失败的留待下次(best-effort,不阻断索引主流程) */
|
|
224
|
+
private sweepOrphanVectors;
|
|
203
225
|
private consumePendingVectorRelativePaths;
|
|
204
226
|
private ensureVectorIndexFresh;
|
|
205
227
|
uploadSessionIsOpen(operationId: string): boolean;
|
|
@@ -48,6 +48,9 @@ export class KnowledgeBaseManager {
|
|
|
48
48
|
queryExpansionActive = 0;
|
|
49
49
|
queryExpansionWaiters = [];
|
|
50
50
|
onProgress;
|
|
51
|
+
/** 集合名缓存:避免每次向量搜索都全表扫描 kb_index_state;TTL 兜底 worker 子进程跨进程写入的新集合 */
|
|
52
|
+
collectionNamesCache = null;
|
|
53
|
+
static COLLECTION_NAMES_TTL_MS = 2000;
|
|
51
54
|
constructor(options) {
|
|
52
55
|
this.scope = options.scope;
|
|
53
56
|
this.projectRoot = options.projectRoot;
|
|
@@ -196,6 +199,7 @@ export class KnowledgeBaseManager {
|
|
|
196
199
|
errorMessage: metadataOnly ? undefined : reason,
|
|
197
200
|
metadataJson: JSON.stringify({ mimeType: file.mimeType, ...extraction.metadata, warnings: extraction.warnings, metadataOnly }),
|
|
198
201
|
});
|
|
202
|
+
this.noteCollectionName(collectionName);
|
|
199
203
|
continue;
|
|
200
204
|
}
|
|
201
205
|
// K1 入库前清洗(源头治理):解析完成后、分块入库前移除确定性噪声(页眉页脚/目录/页码/
|
|
@@ -310,6 +314,7 @@ export class KnowledgeBaseManager {
|
|
|
310
314
|
format: file.format,
|
|
311
315
|
collectionName,
|
|
312
316
|
});
|
|
317
|
+
this.noteCollectionName(collectionName);
|
|
313
318
|
vectorRelativePaths.push(file.relativePath);
|
|
314
319
|
changedCollectionNames.add(collectionName);
|
|
315
320
|
this.updateJobsForFile(file.relativePath, options.vectorMode === 'defer' ? 'SUCCESS' : 'INDEXING', options.vectorMode === 'defer' ? 100 : 85, options.vectorMode === 'defer' ? '解析和切片已完成' : '等待向量入库');
|
|
@@ -490,8 +495,7 @@ export class KnowledgeBaseManager {
|
|
|
490
495
|
};
|
|
491
496
|
}
|
|
492
497
|
async semanticSearch(query, options = {}) {
|
|
493
|
-
|
|
494
|
-
this.ensureVectorStore(record.collectionName);
|
|
498
|
+
this.ensureAllVectorStores();
|
|
495
499
|
const queryEmbedding = await this.embeddingProvider.embedQuery(query);
|
|
496
500
|
const search = new FederationSearch(this.vectorStores);
|
|
497
501
|
try {
|
|
@@ -565,9 +569,16 @@ export class KnowledgeBaseManager {
|
|
|
565
569
|
getUploadRelativePath(fileName, targetRelativePath) {
|
|
566
570
|
return this.validateUploadRelativePath(targetRelativePath ? this.normalizeRelativePath(targetRelativePath) : this.defaultUploadRelativePath(fileName));
|
|
567
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* @deprecated 生产上传链路已切换为 stageUploadedFilePaths + 后台索引 worker(不落内存 buffer、支持上传会话等待)。
|
|
574
|
+
* 本同步路径仅保留给测试与 CLI 使用;新代码请走 stageUploadedFilePaths。
|
|
575
|
+
*/
|
|
568
576
|
async uploadFile(fileName, content, targetRelativePath, onProgress, options = {}) {
|
|
569
577
|
return this.uploadFiles([{ fileName, content, targetRelativePath }], onProgress, options);
|
|
570
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* @deprecated 生产上传链路已切换为 stageUploadedFilePaths(路径落盘,不占内存)。仅测试/CLI 使用。
|
|
581
|
+
*/
|
|
571
582
|
async stageUploadedFiles(files, operationId = `upload-${Date.now()}`) {
|
|
572
583
|
this.initialize();
|
|
573
584
|
const jobs = [];
|
|
@@ -595,6 +606,9 @@ export class KnowledgeBaseManager {
|
|
|
595
606
|
}
|
|
596
607
|
return jobs;
|
|
597
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* @deprecated 同步上传+索引一体化旧路径,仅测试/CLI 使用;生产请用 stageUploadedFilePaths + 后台 worker。
|
|
611
|
+
*/
|
|
598
612
|
async uploadFiles(files, onProgress, options = {}) {
|
|
599
613
|
const jobs = await this.stageUploadedFiles(files);
|
|
600
614
|
return this.incrementalIndex({ onProgress, vectorMode: options.vectorMode, onlyRelativePaths: jobs.map(job => job.relativePath) });
|
|
@@ -638,6 +652,8 @@ export class KnowledgeBaseManager {
|
|
|
638
652
|
return this.store.listIgnoreRules();
|
|
639
653
|
}
|
|
640
654
|
async indexVectors(options = {}) {
|
|
655
|
+
// 先清扫此前删除失败的孤儿向量(重试幂等,失败留队下次再试)
|
|
656
|
+
await this.sweepOrphanVectors();
|
|
641
657
|
const pendingRelativePaths = !options.rebuild && !options.relativePath && !options.relativePaths?.length
|
|
642
658
|
? this.consumePendingVectorRelativePaths()
|
|
643
659
|
: [];
|
|
@@ -1144,6 +1160,19 @@ export class KnowledgeBaseManager {
|
|
|
1144
1160
|
const safeName = collectionName.replace(/[^a-zA-Z0-9_.-]/gu, '_');
|
|
1145
1161
|
this.vectorStores.set(collectionName, new HNSWVectorStore(collectionName, path.join(this.vectorRoot, `${safeName}.hnsw`), this.embeddingProvider.dimensions));
|
|
1146
1162
|
}
|
|
1163
|
+
/** 本进程写入新记录时即时补充集合名缓存(不等 TTL),保证紧随其后的搜索立即可见 */
|
|
1164
|
+
noteCollectionName(collectionName) {
|
|
1165
|
+
this.collectionNamesCache?.names.add(collectionName);
|
|
1166
|
+
}
|
|
1167
|
+
/** 为全部已知集合确保向量存储已加载;集合名集合带 2s TTL 缓存,避免每次搜索全表扫描 */
|
|
1168
|
+
ensureAllVectorStores() {
|
|
1169
|
+
const now = Date.now();
|
|
1170
|
+
if (!this.collectionNamesCache || now - this.collectionNamesCache.fetchedAt > KnowledgeBaseManager.COLLECTION_NAMES_TTL_MS) {
|
|
1171
|
+
this.collectionNamesCache = { names: new Set(this.store.listCollectionNames()), fetchedAt: now };
|
|
1172
|
+
}
|
|
1173
|
+
for (const name of this.collectionNamesCache.names)
|
|
1174
|
+
this.ensureVectorStore(name);
|
|
1175
|
+
}
|
|
1147
1176
|
async deleteVectorFile(collectionName, relativePath) {
|
|
1148
1177
|
this.ensureVectorStore(collectionName);
|
|
1149
1178
|
try {
|
|
@@ -1152,7 +1181,50 @@ export class KnowledgeBaseManager {
|
|
|
1152
1181
|
catch (error) {
|
|
1153
1182
|
this.store.setMetadata('vector_index_status', 'error');
|
|
1154
1183
|
this.store.setMetadata('vector_index_error', error instanceof Error ? error.message : String(error));
|
|
1184
|
+
// 删除失败会留下孤儿向量(已删文件的内容仍可被召回),登记待清扫队列由 indexVectors 重试
|
|
1185
|
+
this.noteOrphanVector(collectionName, relativePath);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
/** 登记删除失败的孤儿向量(去重、限量 500 条防止 metadata 膨胀) */
|
|
1189
|
+
noteOrphanVector(collectionName, relativePath) {
|
|
1190
|
+
const pending = this.listOrphanVectors();
|
|
1191
|
+
if (pending.some(item => item.collectionName === collectionName && item.relativePath === relativePath))
|
|
1192
|
+
return;
|
|
1193
|
+
pending.push({ collectionName, relativePath });
|
|
1194
|
+
this.store.setMetadata('vector_orphan_pending', JSON.stringify(pending.slice(-500)));
|
|
1195
|
+
}
|
|
1196
|
+
listOrphanVectors() {
|
|
1197
|
+
const raw = this.store.getMetadata('vector_orphan_pending');
|
|
1198
|
+
if (!raw)
|
|
1199
|
+
return [];
|
|
1200
|
+
try {
|
|
1201
|
+
const parsed = JSON.parse(raw);
|
|
1202
|
+
if (!Array.isArray(parsed))
|
|
1203
|
+
return [];
|
|
1204
|
+
return parsed.filter((item) => typeof item === 'object' && item !== null
|
|
1205
|
+
&& typeof item.collectionName === 'string'
|
|
1206
|
+
&& typeof item.relativePath === 'string');
|
|
1207
|
+
}
|
|
1208
|
+
catch {
|
|
1209
|
+
return [];
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
/** 清扫孤儿向量:重试删除此前失败的条目,成功的出队,仍失败的留待下次(best-effort,不阻断索引主流程) */
|
|
1213
|
+
async sweepOrphanVectors() {
|
|
1214
|
+
const pending = this.listOrphanVectors();
|
|
1215
|
+
if (pending.length === 0)
|
|
1216
|
+
return;
|
|
1217
|
+
const remaining = [];
|
|
1218
|
+
for (const item of pending) {
|
|
1219
|
+
this.ensureVectorStore(item.collectionName);
|
|
1220
|
+
try {
|
|
1221
|
+
await this.vectorStores.get(item.collectionName)?.deleteByFilePath(item.relativePath);
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
remaining.push(item);
|
|
1225
|
+
}
|
|
1155
1226
|
}
|
|
1227
|
+
this.store.setMetadata('vector_orphan_pending', remaining.length > 0 ? JSON.stringify(remaining) : '');
|
|
1156
1228
|
}
|
|
1157
1229
|
consumePendingVectorRelativePaths() {
|
|
1158
1230
|
const raw = this.store.getMetadata('vector_pending_relative_paths');
|
|
@@ -1173,8 +1245,7 @@ export class KnowledgeBaseManager {
|
|
|
1173
1245
|
async ensureVectorIndexFresh(chunkCount, options = {}) {
|
|
1174
1246
|
if (chunkCount === 0)
|
|
1175
1247
|
return;
|
|
1176
|
-
|
|
1177
|
-
this.ensureVectorStore(record.collectionName);
|
|
1248
|
+
this.ensureAllVectorStores();
|
|
1178
1249
|
if (options.rebuild || [...this.vectorStores.values()].some(store => store.needsRebuild?.())) {
|
|
1179
1250
|
await this.indexVectors({ rebuild: true });
|
|
1180
1251
|
return;
|
|
@@ -3,7 +3,21 @@ export declare class LocalReranker {
|
|
|
3
3
|
private static loadingPromise;
|
|
4
4
|
private static disabledUntil;
|
|
5
5
|
private static modelName;
|
|
6
|
+
/** rerank 推理 worker 线程(懒加载常驻);unavailable 后不再重建,本进程内回退主线程推理 */
|
|
7
|
+
private static worker;
|
|
8
|
+
private static workerUnavailable;
|
|
9
|
+
private static workerNextId;
|
|
10
|
+
private static workerPending;
|
|
11
|
+
/** 单批(≤30 候选)推理超时:超时按失败处理,由调用方 catch 回落启发式重排 */
|
|
12
|
+
private static readonly WORKER_TIMEOUT_MS;
|
|
6
13
|
static getInstance(): Promise<any>;
|
|
14
|
+
/**
|
|
15
|
+
* worker 线程路径开关:默认开启(cross-encoder ONNX 推理在 worker 线程执行,主线程零阻塞);
|
|
16
|
+
* KB_RERANKER_WORKER=0/false 显式回退主线程推理(排查 worker 问题时的对照通道)
|
|
17
|
+
*/
|
|
18
|
+
private static workerEnabled;
|
|
19
|
+
private static getWorker;
|
|
20
|
+
private static rerankInWorker;
|
|
7
21
|
/**
|
|
8
22
|
* 对多条文本和查询进行相关性重排
|
|
9
23
|
*/
|
|
@@ -2,11 +2,20 @@ import { pipeline } from '@huggingface/transformers';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { Worker } from 'node:worker_threads';
|
|
5
7
|
export class LocalReranker {
|
|
6
8
|
static instance = null;
|
|
7
9
|
static loadingPromise = null;
|
|
8
10
|
static disabledUntil = 0;
|
|
9
11
|
static modelName = process.env.KB_RERANKER_MODEL || 'Xenova/bge-reranker-base';
|
|
12
|
+
/** rerank 推理 worker 线程(懒加载常驻);unavailable 后不再重建,本进程内回退主线程推理 */
|
|
13
|
+
static worker = null;
|
|
14
|
+
static workerUnavailable = false;
|
|
15
|
+
static workerNextId = 0;
|
|
16
|
+
static workerPending = new Map();
|
|
17
|
+
/** 单批(≤30 候选)推理超时:超时按失败处理,由调用方 catch 回落启发式重排 */
|
|
18
|
+
static WORKER_TIMEOUT_MS = 180_000;
|
|
10
19
|
static async getInstance() {
|
|
11
20
|
if (this.instance)
|
|
12
21
|
return this.instance;
|
|
@@ -35,12 +44,90 @@ export class LocalReranker {
|
|
|
35
44
|
});
|
|
36
45
|
return this.loadingPromise;
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* worker 线程路径开关:默认开启(cross-encoder ONNX 推理在 worker 线程执行,主线程零阻塞);
|
|
49
|
+
* KB_RERANKER_WORKER=0/false 显式回退主线程推理(排查 worker 问题时的对照通道)
|
|
50
|
+
*/
|
|
51
|
+
static workerEnabled() {
|
|
52
|
+
const raw = process.env.KB_RERANKER_WORKER;
|
|
53
|
+
return raw !== '0' && raw !== 'false';
|
|
54
|
+
}
|
|
55
|
+
static getWorker() {
|
|
56
|
+
if (this.worker)
|
|
57
|
+
return this.worker;
|
|
58
|
+
if (this.workerUnavailable)
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
// dist 编译产物同目录下存在 rerank-worker-thread.js;源码直跑(无编译产物)时回落主线程推理
|
|
62
|
+
const workerPath = fileURLToPath(new URL('./rerank-worker-thread.js', import.meta.url));
|
|
63
|
+
if (!fs.existsSync(workerPath)) {
|
|
64
|
+
this.workerUnavailable = true;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const worker = new Worker(workerPath);
|
|
68
|
+
// unref:worker 不阻止主进程退出(常驻模型加载不应拖住进程生命周期)
|
|
69
|
+
worker.unref();
|
|
70
|
+
worker.on('message', (response) => {
|
|
71
|
+
const pending = this.workerPending.get(response.id);
|
|
72
|
+
if (!pending)
|
|
73
|
+
return;
|
|
74
|
+
this.workerPending.delete(response.id);
|
|
75
|
+
clearTimeout(pending.timer);
|
|
76
|
+
if (response.error)
|
|
77
|
+
pending.reject(new Error(response.error));
|
|
78
|
+
else
|
|
79
|
+
pending.resolve(response.scores || []);
|
|
80
|
+
});
|
|
81
|
+
worker.on('error', (error) => {
|
|
82
|
+
// worker 崩溃:拒绝全部在途请求(调用方回退主线程推理),本进程内不再重建 worker
|
|
83
|
+
this.workerUnavailable = true;
|
|
84
|
+
this.worker = null;
|
|
85
|
+
for (const pending of this.workerPending.values()) {
|
|
86
|
+
clearTimeout(pending.timer);
|
|
87
|
+
pending.reject(error);
|
|
88
|
+
}
|
|
89
|
+
this.workerPending.clear();
|
|
90
|
+
worker.terminate().catch(() => { });
|
|
91
|
+
});
|
|
92
|
+
this.worker = worker;
|
|
93
|
+
return worker;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
this.workerUnavailable = true;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
static rerankInWorker(query, texts) {
|
|
101
|
+
const worker = this.getWorker();
|
|
102
|
+
if (!worker)
|
|
103
|
+
return Promise.reject(new Error('rerank worker unavailable'));
|
|
104
|
+
const id = this.workerNextId++;
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
this.workerPending.delete(id);
|
|
108
|
+
reject(new Error('rerank worker timeout'));
|
|
109
|
+
}, this.WORKER_TIMEOUT_MS);
|
|
110
|
+
timer.unref?.();
|
|
111
|
+
this.workerPending.set(id, { resolve, reject, timer });
|
|
112
|
+
worker.postMessage({ id, query, texts });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
38
115
|
/**
|
|
39
116
|
* 对多条文本和查询进行相关性重排
|
|
40
117
|
*/
|
|
41
118
|
static async rerank(query, texts) {
|
|
42
119
|
if (!texts.length)
|
|
43
120
|
return [];
|
|
121
|
+
// 优先走 worker 线程(默认):ONNX 推理不阻塞主线程事件循环;
|
|
122
|
+
// worker 不可用/失败时回退主线程推理(与历史行为一致,KB_ENABLE_LOCAL_RERANKER=false 总开关仍生效)
|
|
123
|
+
if (this.workerEnabled() && process.env.KB_ENABLE_LOCAL_RERANKER !== 'false') {
|
|
124
|
+
try {
|
|
125
|
+
return await this.rerankInWorker(query, texts);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// worker 路径失败,回落主线程推理
|
|
129
|
+
}
|
|
130
|
+
}
|
|
44
131
|
const ranker = await this.getInstance();
|
|
45
132
|
const scores = [];
|
|
46
133
|
const safeQuery = this.takeHeadTail(query, 240);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalReranker 的 worker 线程宿主:cross-encoder(bge-reranker-base)ONNX 推理是 CPU 密集计算,
|
|
3
|
+
* 在主线程执行会阻塞事件循环(生成场景数百组查询 × 30 候选的推理曾阻塞主线程 20-60 分钟,
|
|
4
|
+
* HTTP 无响应、前端误判卡死)。推理整体下沉到本 worker,主线程仅做消息收发;
|
|
5
|
+
* 请求串行处理(单次推理占满 worker 线程,并发只叠加内存无吞吐收益),模型懒加载一次常驻。
|
|
6
|
+
*/
|
|
7
|
+
import { parentPort } from 'node:worker_threads';
|
|
8
|
+
import { pipeline } from '@huggingface/transformers';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
let rankerPromise = null;
|
|
13
|
+
/** 模型加载失败熔断:60 秒内快速失败,避免每个请求都重试一次分钟级加载 */
|
|
14
|
+
let loadDisabledUntil = 0;
|
|
15
|
+
function loadRanker() {
|
|
16
|
+
if (Date.now() < loadDisabledUntil)
|
|
17
|
+
return Promise.reject(new Error('rerank worker model load is temporarily disabled after failure'));
|
|
18
|
+
if (!rankerPromise) {
|
|
19
|
+
rankerPromise = (async () => {
|
|
20
|
+
const cacheDir = process.env.TRANSFORMERS_CACHE || path.join(os.homedir(), '.customize-agent', 'models');
|
|
21
|
+
if (!fs.existsSync(cacheDir)) {
|
|
22
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
process.env.TRANSFORMERS_CACHE = cacheDir;
|
|
25
|
+
return pipeline('text-classification', process.env.KB_RERANKER_MODEL || 'Xenova/bge-reranker-base', { dtype: 'q8' });
|
|
26
|
+
})().catch(error => {
|
|
27
|
+
rankerPromise = null;
|
|
28
|
+
loadDisabledUntil = Date.now() + 60_000;
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return rankerPromise;
|
|
33
|
+
}
|
|
34
|
+
function takeHeadTail(text, maxLength) {
|
|
35
|
+
if (text.length <= maxLength)
|
|
36
|
+
return text;
|
|
37
|
+
const headLength = Math.ceil(maxLength * 0.65);
|
|
38
|
+
const tailLength = maxLength - headLength;
|
|
39
|
+
return `${text.slice(0, headLength)}\n...\n${text.slice(-tailLength)}`;
|
|
40
|
+
}
|
|
41
|
+
function extractScore(output) {
|
|
42
|
+
const first = Array.isArray(output) ? output[0] : output;
|
|
43
|
+
if (Array.isArray(first))
|
|
44
|
+
return extractScore(first);
|
|
45
|
+
const score = Number(first?.score ?? 0);
|
|
46
|
+
return Number.isFinite(score) ? score : 0;
|
|
47
|
+
}
|
|
48
|
+
async function handle(request) {
|
|
49
|
+
const ranker = await loadRanker();
|
|
50
|
+
const scores = [];
|
|
51
|
+
const safeQuery = takeHeadTail(request.query, 240);
|
|
52
|
+
for (const text of request.texts) {
|
|
53
|
+
try {
|
|
54
|
+
const out = await ranker({ text: safeQuery, text_pair: takeHeadTail(text, 1400) });
|
|
55
|
+
scores.push(extractScore(out));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
scores.push(0);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return scores;
|
|
62
|
+
}
|
|
63
|
+
let queue = Promise.resolve();
|
|
64
|
+
parentPort?.on('message', (request) => {
|
|
65
|
+
queue = queue.then(async () => {
|
|
66
|
+
try {
|
|
67
|
+
const scores = await handle(request);
|
|
68
|
+
parentPort?.postMessage({ id: request.id, scores });
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
parentPort?.postMessage({ id: request.id, error: error instanceof Error ? error.message : String(error) });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -15,6 +15,8 @@ export declare class ContentExtractor {
|
|
|
15
15
|
private cleanCadReadableText;
|
|
16
16
|
private isLikelyGarbledCadText;
|
|
17
17
|
private isReadableCadValue;
|
|
18
|
+
/** 图层/块名可读性:在通用可读性过滤外,排除纯数字名称(默认图层「0」等 CAD 内部标识) */
|
|
19
|
+
private isUsableCadName;
|
|
18
20
|
private cleanExtractedText;
|
|
19
21
|
private textScore;
|
|
20
22
|
/** 统计图纸提取片段中的实际字符数据量(汉字/字母/数字),用于判断"无字符数据不入库" */
|
|
@@ -5,8 +5,12 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
6
|
import { resolveAndImport, resolvePackage } from './module-resolver.js';
|
|
7
7
|
import { createOcrProvider } from './ocr-providers.js';
|
|
8
|
+
import { decodeTextBuffer, normalizeSymbolicPua } from './text-encoding.js';
|
|
8
9
|
const CAD_INTERNAL_TOKEN_RE = /\b(?:TDbPipe|TDbPipeValve|TDbPipeFitting|TDbWellh|AcDb[\w:]+|Dwg\w+|ObjectId|Handle|ByLayer|Continuous|Model|Layout\d*|MLEADERSTYLE|AppInfoHistory|AppInfoDataList|ObjectDBX|Classes|DICTIONARYVARP|ObjFreeSpaceP|AuxHeaderT|\$AUDIT_BAD_\w+)\b/giu;
|
|
9
|
-
|
|
10
|
+
// 注意:不再整行排除纯数字(\d+)——真实图纸的尺寸标注/标高/门窗表数值常为纯数字行
|
|
11
|
+
// (如「100」「2.900」),整行排除会误杀图纸数据;图层/块名等内部纯数字标识在提取处
|
|
12
|
+
// 单独排除(extractDxf 的 layers/blocks 过滤),不受此正则影响
|
|
13
|
+
const CAD_INTERNAL_LINE_RE = /^(?:TDb\w+|AcDb[\w:]+|\$AUDIT_BAD_\w+|[A-F0-9]{8,}|Model|Layout\d*|ByLayer|Continuous|MLEADERSTYLE|ObjectDBX)$/iu;
|
|
10
14
|
/** DWG 二进制误读产生的 C1 控制字符(U+0080-U+009F,如 GBK 半字节),正常图纸标注不会出现 */
|
|
11
15
|
const CAD_C1_CONTROL_RE = /[\u0080-\u009F]/gu;
|
|
12
16
|
/** AutoCAD 控制码:%%c=Φ、%%d=°、%%p=±、%%132 等数字码=Φ,统一解码为可读符号 */
|
|
@@ -87,8 +91,10 @@ export class ContentExtractor {
|
|
|
87
91
|
warnings.push(...result.warnings);
|
|
88
92
|
}
|
|
89
93
|
else if (this.isTextReadable(file)) {
|
|
90
|
-
|
|
94
|
+
const decoded = decodeTextBuffer(fs.readFileSync(file.absolutePath));
|
|
95
|
+
text = decoded.text;
|
|
91
96
|
metadata.extractionMode = 'plain_text';
|
|
97
|
+
metadata.encoding = decoded.encoding;
|
|
92
98
|
metadata.vectorizable = true;
|
|
93
99
|
}
|
|
94
100
|
else {
|
|
@@ -281,8 +287,13 @@ export class ContentExtractor {
|
|
|
281
287
|
const cleaned = this.cleanCadReadableText(value);
|
|
282
288
|
return cleaned.length >= 2 && !CAD_INTERNAL_LINE_RE.test(cleaned) && /[\p{Script=Han}\p{Letter}\d]/u.test(cleaned) && !this.isLikelyGarbledCadText(cleaned);
|
|
283
289
|
}
|
|
290
|
+
/** 图层/块名可读性:在通用可读性过滤外,排除纯数字名称(默认图层「0」等 CAD 内部标识) */
|
|
291
|
+
isUsableCadName(value) {
|
|
292
|
+
return this.isReadableCadValue(value) && !/^-?\d+(?:\.\d+)?$/u.test(value.trim());
|
|
293
|
+
}
|
|
284
294
|
cleanExtractedText(value, file) {
|
|
285
|
-
|
|
295
|
+
// 符号字体私用区字符(CAD SHX 直径符号、PDF Wingdings 选项框等)统一映射回标准符号
|
|
296
|
+
const normalized = [...normalizeSymbolicPua(value)]
|
|
286
297
|
.filter(char => {
|
|
287
298
|
const code = char.charCodeAt(0);
|
|
288
299
|
// U+FFFF 非字符:PDF 表单空白下划线/占位符的提取产物,无检索意义,统一剔除
|
|
@@ -312,7 +323,7 @@ export class ContentExtractor {
|
|
|
312
323
|
const warnings = [];
|
|
313
324
|
const ext = path.extname(file.absolutePath).toLowerCase();
|
|
314
325
|
if (ext === '.dxf')
|
|
315
|
-
return await this.extractDxf(file, fs.readFileSync(file.absolutePath
|
|
326
|
+
return await this.extractDxf(file, decodeTextBuffer(fs.readFileSync(file.absolutePath)).text, metadata);
|
|
316
327
|
if (ext === '.dwg') {
|
|
317
328
|
const converted = await this.tryConvertDwgToDxf(file.absolutePath);
|
|
318
329
|
if (converted?.dxfText) {
|
|
@@ -323,10 +334,10 @@ export class ContentExtractor {
|
|
|
323
334
|
warnings.push(...(converted?.warnings ?? ['未检测到可用 DWG→DXF 转换器,使用内置图纸可读文本抽取']));
|
|
324
335
|
}
|
|
325
336
|
if (file.format === 'autocad' && ext === '.dxf') {
|
|
326
|
-
const raw = fs.readFileSync(file.absolutePath
|
|
327
|
-
const layers = this.matchAll(raw, /\n\s*8\s*\n([^\n]+)/gu).filter(value => this.
|
|
337
|
+
const raw = decodeTextBuffer(fs.readFileSync(file.absolutePath)).text;
|
|
338
|
+
const layers = this.matchAll(raw, /\n\s*8\s*\n([^\n]+)/gu).filter(value => this.isUsableCadName(value)).slice(0, 300);
|
|
328
339
|
const textEntities = this.extractDxfTextAnnotations(raw).slice(0, 500);
|
|
329
|
-
const blocks = this.matchAll(raw, /\n\s*2\s*\n([^\n]+)/gu).filter(value => this.
|
|
340
|
+
const blocks = this.matchAll(raw, /\n\s*2\s*\n([^\n]+)/gu).filter(value => this.isUsableCadName(value)).slice(0, 300);
|
|
330
341
|
const entityTypes = this.matchAll(raw, /\n\s*0\s*\n([A-Z][A-Z0-9_]+)/gu).slice(0, 1000);
|
|
331
342
|
const uniqueLayers = Array.from(new Set(layers));
|
|
332
343
|
const uniqueBlocks = Array.from(new Set(blocks));
|
|
@@ -338,9 +349,11 @@ export class ContentExtractor {
|
|
|
338
349
|
metadata.blockNames = uniqueBlocks.slice(0, 80);
|
|
339
350
|
metadata.entityTypeCount = uniqueEntityTypes.length;
|
|
340
351
|
metadata.entityTypes = uniqueEntityTypes.slice(0, 80);
|
|
341
|
-
|
|
352
|
+
// 判空口径只统计标注文本:图层/块名是 CAD 内部结构信息,图纸「空数据」= 无文字标注。
|
|
353
|
+
// 把图层/块名计入字符数会让空图纸(仅图层结构、无任何标注)错误入库
|
|
354
|
+
const characterDataCount = this.countCadCharacterData(textEntities.map(annotation => annotation.text));
|
|
342
355
|
if (characterDataCount < MIN_CAD_CHARACTER_DATA) {
|
|
343
|
-
//
|
|
356
|
+
// 图纸无字符数据(无文字标注),不入库——空数据图纸直接过滤,仅元数据可查
|
|
344
357
|
metadata.contentCoverage = 'cad_no_extractable_text';
|
|
345
358
|
metadata.characterDataCount = characterDataCount;
|
|
346
359
|
warnings.push(`${file.format} DXF 未提取到字符数据(仅 ${characterDataCount} 个可读字符),图纸内容未入库`);
|
|
@@ -362,7 +375,7 @@ export class ContentExtractor {
|
|
|
362
375
|
};
|
|
363
376
|
}
|
|
364
377
|
if (file.format === 'step') {
|
|
365
|
-
const raw = fs.readFileSync(file.absolutePath
|
|
378
|
+
const raw = decodeTextBuffer(fs.readFileSync(file.absolutePath)).text;
|
|
366
379
|
const products = this.matchAll(raw, /PRODUCT\s*\(\s*'([^']*)'\s*,\s*'([^']*)'/giu).slice(0, 300);
|
|
367
380
|
const materials = this.matchAll(raw, /MATERIAL[^']*'([^']+)'/giu).slice(0, 120);
|
|
368
381
|
const entities = this.matchAll(raw, /#\d+\s*=\s*([A-Z0-9_]+)/gu).slice(0, 1000);
|
|
@@ -387,7 +400,7 @@ export class ContentExtractor {
|
|
|
387
400
|
};
|
|
388
401
|
}
|
|
389
402
|
if (file.format === 'iges') {
|
|
390
|
-
const raw = fs.readFileSync(file.absolutePath
|
|
403
|
+
const raw = decodeTextBuffer(fs.readFileSync(file.absolutePath)).text;
|
|
391
404
|
const names = this.matchAll(raw, /'([^']{2,120})'/gu).slice(0, 500);
|
|
392
405
|
const entityTypes = this.matchAll(raw, /^\s*(\d{3,4})\s*,/gmu).slice(0, 1000);
|
|
393
406
|
const uniqueIgesTypes = Array.from(new Set(entityTypes));
|
|
@@ -411,7 +424,7 @@ export class ContentExtractor {
|
|
|
411
424
|
// OLE 属性集(<prop_set ...>)是 DWG 文件内部的二进制属性结构,不含图纸标注字符,
|
|
412
425
|
// 属于解析噪声而非字符数据,直接排除(不锚定行首:误读字符可能混在片段开头)
|
|
413
426
|
const withoutPropSets = binaryFragments.filter(value => !/<prop_set\b/u.test(value));
|
|
414
|
-
let readable = withoutPropSets.filter(value => this.
|
|
427
|
+
let readable = withoutPropSets.filter(value => this.isUsableCadName(value)).slice(0, 5000);
|
|
415
428
|
// 文档级字符词频过滤:真实标注文字在图纸中会重复出现(标题/图层/图名等),
|
|
416
429
|
// 随机二进制噪声片段中每个字符几乎只出现一次(孤立字符);短片段若全部由
|
|
417
430
|
// 孤立字符构成即为随机噪声,丢弃
|
|
@@ -468,17 +481,23 @@ export class ContentExtractor {
|
|
|
468
481
|
async extractDxf(file, raw, metadata) {
|
|
469
482
|
const warnings = [];
|
|
470
483
|
let parsed;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
484
|
+
// dxf-parser 对缺少坐标组码的残缺实体(无 10/20 的 LINE/CIRCLE/POLYLINE)存在解析
|
|
485
|
+
// 死循环缺陷,无限循环不抛异常、try-catch 无法兜住;其解析结果仅用于 metadata 标记,
|
|
486
|
+
// 不参与文本提取。无任何标注实体的图纸(空图纸)必然判空不入库,跳过 parseSync
|
|
487
|
+
// 避免触发库死循环;含标注实体的图纸按正常路径解析
|
|
488
|
+
if (/(?:^|\r?\n)\s*0\s*\r?\n(?:TEXT|MTEXT|DIMENSION|LEADER|ATTRIB)\b/u.test(raw)) {
|
|
489
|
+
try {
|
|
490
|
+
const mod = await resolveAndImport('dxf-parser');
|
|
491
|
+
const Parser = mod.default ?? mod;
|
|
492
|
+
parsed = new Parser().parseSync(raw);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
warnings.push('dxf-parser 解析失败,已使用 DXF 文本结构抽取回退');
|
|
496
|
+
}
|
|
478
497
|
}
|
|
479
|
-
const layers = this.matchAll(raw, /(?:^|\r?\n)\s*8\s*\r?\n([^\r\n]+)/gu).filter(value => this.
|
|
498
|
+
const layers = this.matchAll(raw, /(?:^|\r?\n)\s*8\s*\r?\n([^\r\n]+)/gu).filter(value => this.isUsableCadName(value)).slice(0, 300);
|
|
480
499
|
const textEntities = this.extractDxfTextAnnotations(raw).slice(0, 5000);
|
|
481
|
-
const blocks = this.matchAll(raw, /(?:^|\r?\n)\s*2\s*\r?\n([^\r\n]+)/gu).filter(value => this.
|
|
500
|
+
const blocks = this.matchAll(raw, /(?:^|\r?\n)\s*2\s*\r?\n([^\r\n]+)/gu).filter(value => this.isUsableCadName(value)).slice(0, 300);
|
|
482
501
|
const entityTypes = this.matchAll(raw, /(?:^|\r?\n)\s*0\s*\r?\n([A-Z][A-Z0-9_]+)/gu).slice(0, 1200);
|
|
483
502
|
const uniqueLayers = Array.from(new Set(layers));
|
|
484
503
|
const uniqueBlocks = Array.from(new Set(blocks));
|
|
@@ -491,7 +510,9 @@ export class ContentExtractor {
|
|
|
491
510
|
metadata.entityTypeCount = uniqueEntityTypes.length;
|
|
492
511
|
metadata.entityTypes = uniqueEntityTypes.slice(0, 80);
|
|
493
512
|
metadata.parsedByDxfParser = Boolean(parsed);
|
|
494
|
-
|
|
513
|
+
// 判空口径只统计标注文本:图层/块名是 CAD 内部结构信息,图纸「空数据」= 无文字标注。
|
|
514
|
+
// 把图层/块名计入字符数会让空图纸(仅图层结构、无任何标注)错误入库
|
|
515
|
+
const characterDataCount = this.countCadCharacterData(textEntities.map(annotation => annotation.text));
|
|
495
516
|
if (characterDataCount < MIN_CAD_CHARACTER_DATA) {
|
|
496
517
|
// 图纸无字符数据(无文字标注、图层/块名均为内部默认值),不入库
|
|
497
518
|
metadata.contentCoverage = 'cad_no_extractable_text';
|
|
@@ -526,20 +547,38 @@ export class ContentExtractor {
|
|
|
526
547
|
return [`图纸节点: ${fileName}`, ...annotations.map(item => item.text)];
|
|
527
548
|
}
|
|
528
549
|
extractDxfTextAnnotations(raw) {
|
|
529
|
-
|
|
550
|
+
// ATTRIB(块属性)是门窗表/材料表/标题栏数据的载体(块插入时的属性值实体),
|
|
551
|
+
// 此前遗漏导致整表数据丢失(真实图纸回归:门窗表仅剩零散标注)
|
|
552
|
+
const entities = raw.split(/(?:^|\r?\n)\s*0\s*\r?\n/u).filter(section => /^(?:TEXT|MTEXT|DIMENSION|LEADER|ATTRIB)/u.test(section.trim()));
|
|
530
553
|
return entities.flatMap(section => {
|
|
531
|
-
const
|
|
554
|
+
const entityType = section.trim().split(/\s+/u)[0];
|
|
555
|
+
let rawText;
|
|
556
|
+
if (entityType === 'ATTRIB') {
|
|
557
|
+
// 属性实体:组码 2 是属性标签名(门窗表/材料表的列名),组码 1 是属性值,
|
|
558
|
+
// 标签与值是独立语义字段(「型号 M1021」「高度 2100」),空格连接而非续段拼接
|
|
559
|
+
const tag = /(?:^|\r?\n)\s*2\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim() ?? '';
|
|
560
|
+
const value = /(?:^|\r?\n)\s*1\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim() ?? '';
|
|
561
|
+
rawText = `${tag} ${value}`;
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
// MTEXT 多段文本:组码 1 是首段(≤255 字符),组码 3 是后续续段(每段 ≤250 字符),
|
|
565
|
+
// 必须按序拼接才是完整文字(此前只取首个组码 1/3,多段标注丢失大半内容);
|
|
566
|
+
// DIMENSION 组码 1/3 是显式尺寸文字与后缀,TEXT/LEADER 组码 3 罕见但同按序收集
|
|
567
|
+
rawText = [...section.matchAll(/(?:^|\r?\n)\s*(?:1|3)\s*\r?\n([^\r\n]+)/gu)].map(match => match[1]).join('');
|
|
568
|
+
}
|
|
569
|
+
const text = this.cleanCadReadableText(rawText);
|
|
532
570
|
if (!text || !this.isReadableCadValue(text))
|
|
533
571
|
return [];
|
|
534
572
|
// 图层/块名同样要过可读性过滤:DXF 里 GBK 误读(Ïä¹ñ)或纯数字/内部
|
|
535
573
|
// 标识(11、AcDb...)会直接混进节点文本,不合格时置空由语义节点回退
|
|
536
574
|
const layer = /(?:^|\r?\n)\s*8\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim() ?? '';
|
|
537
|
-
|
|
575
|
+
// ATTRIB 的组码 2 是属性标签名而非块名,不可当块名使用
|
|
576
|
+
const block = entityType === 'ATTRIB' ? '' : /(?:^|\r?\n)\s*2\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim() ?? '';
|
|
538
577
|
return [{
|
|
539
578
|
text,
|
|
540
579
|
layer: layer && this.isReadableCadValue(layer) ? layer : undefined,
|
|
541
580
|
block: block && this.isReadableCadValue(block) ? block : undefined,
|
|
542
|
-
entityType
|
|
581
|
+
entityType,
|
|
543
582
|
x: Number(/(?:^|\r?\n)\s*10\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]),
|
|
544
583
|
y: Number(/(?:^|\r?\n)\s*20\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]),
|
|
545
584
|
}].map(item => ({ ...item, x: Number.isFinite(item.x) ? item.x : undefined, y: Number.isFinite(item.y) ? item.y : undefined }));
|
|
@@ -834,7 +873,8 @@ export class ContentExtractor {
|
|
|
834
873
|
].join('\n');
|
|
835
874
|
}
|
|
836
875
|
extractDelimitedText(file) {
|
|
837
|
-
const
|
|
876
|
+
const decoded = decodeTextBuffer(fs.readFileSync(file.absolutePath));
|
|
877
|
+
const raw = decoded.text;
|
|
838
878
|
const delimiter = file.format === 'tsv' ? '\t' : ',';
|
|
839
879
|
const rows = raw.split(/\r?\n/u).filter(line => line.trim().length > 0);
|
|
840
880
|
const header = rows[0] ? this.parseDelimitedLine(rows[0], delimiter) : [];
|
|
@@ -850,6 +890,7 @@ export class ContentExtractor {
|
|
|
850
890
|
extractionMode: 'delimited_text_structured',
|
|
851
891
|
semanticExtractionMode: 'delimited_markdown_table',
|
|
852
892
|
vectorizable: true,
|
|
893
|
+
encoding: decoded.encoding,
|
|
853
894
|
delimiter: file.format === 'tsv' ? 'tab' : 'comma',
|
|
854
895
|
rowCount: rows.length,
|
|
855
896
|
columnCount: header.length,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文本编码自动检测与符号字体私用区(PUA)规范化。
|
|
3
|
+
*
|
|
4
|
+
* 背景:
|
|
5
|
+
* - 知识库常混入 GBK 编码文本(中文 CAD 日志、导出 DXF、旧 CSV 等),按 UTF-8 读取会产生大
|
|
6
|
+
* 量 U+FFFD 替换符乱码;Node 的 TextDecoder 内置 gbk 解码器,无需额外依赖。
|
|
7
|
+
* - CAD SHX 字体与 PDF 符号字体(Wingdings 等)的字符在提取时会被映射到 Unicode 私用区
|
|
8
|
+
* 码位(U+E000-U+F8FF),直接入库后检索/阅读均为不可见乱码,需映射回标准符号。
|
|
9
|
+
*/
|
|
10
|
+
/** 将符号字体私用区字符映射回标准 Unicode 符号,正常文本中不出现 PUA,替换是安全的 */
|
|
11
|
+
export declare function normalizeSymbolicPua(value: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* 文本编码自动检测解码:BOM(UTF-16LE/BE)→ UTF-8 → GBK。
|
|
14
|
+
* UTF-8 解码替换符率高于 2% 时判定为 GBK 等非 UTF-8 编码;
|
|
15
|
+
* GBK 对绝大多数字节序列都能成功解码,因此再比较两种解码结果的中文占比,
|
|
16
|
+
* 只有 GBK 中文语义明显更强时才采用 GBK,避免把含个别非法字节的 UTF-8 文本误转。
|
|
17
|
+
*/
|
|
18
|
+
export declare function decodeTextBuffer(buffer: Buffer): {
|
|
19
|
+
text: string;
|
|
20
|
+
encoding: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文本编码自动检测与符号字体私用区(PUA)规范化。
|
|
3
|
+
*
|
|
4
|
+
* 背景:
|
|
5
|
+
* - 知识库常混入 GBK 编码文本(中文 CAD 日志、导出 DXF、旧 CSV 等),按 UTF-8 读取会产生大
|
|
6
|
+
* 量 U+FFFD 替换符乱码;Node 的 TextDecoder 内置 gbk 解码器,无需额外依赖。
|
|
7
|
+
* - CAD SHX 字体与 PDF 符号字体(Wingdings 等)的字符在提取时会被映射到 Unicode 私用区
|
|
8
|
+
* 码位(U+E000-U+F8FF),直接入库后检索/阅读均为不可见乱码,需映射回标准符号。
|
|
9
|
+
*/
|
|
10
|
+
/** 符号字体私用区 → 标准 Unicode 符号映射(已用实际 PDF/DXF 字形渲染验证) */
|
|
11
|
+
const SYMBOLIC_PUA_MAP = {
|
|
12
|
+
0xE000: 'Φ', // AutoCAD SHX 字体直径符号 %%c 经 DXF 转换的私用区码位
|
|
13
|
+
0xE002: 'Φ', // AutoCAD SHX 字体直径符号 %%c 的另一私用区码位(不同 SHX 字体)
|
|
14
|
+
0xF052: '●', // Wingdings 2 码位 0x52 实心圆(招标文件选项标记/绿建星级标记)
|
|
15
|
+
0xF0A3: '□', // Wingdings 2 码位 0xA3 空心方框(招标文件选项框)
|
|
16
|
+
};
|
|
17
|
+
/** 将符号字体私用区字符映射回标准 Unicode 符号,正常文本中不出现 PUA,替换是安全的 */
|
|
18
|
+
export function normalizeSymbolicPua(value) {
|
|
19
|
+
let result = '';
|
|
20
|
+
let dirty = false;
|
|
21
|
+
for (const char of value) {
|
|
22
|
+
const mapped = SYMBOLIC_PUA_MAP[char.charCodeAt(0)];
|
|
23
|
+
if (mapped) {
|
|
24
|
+
result += mapped;
|
|
25
|
+
dirty = true;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
result += char;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return dirty ? result : value;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 文本编码自动检测解码:BOM(UTF-16LE/BE)→ UTF-8 → GBK。
|
|
35
|
+
* UTF-8 解码替换符率高于 2% 时判定为 GBK 等非 UTF-8 编码;
|
|
36
|
+
* GBK 对绝大多数字节序列都能成功解码,因此再比较两种解码结果的中文占比,
|
|
37
|
+
* 只有 GBK 中文语义明显更强时才采用 GBK,避免把含个别非法字节的 UTF-8 文本误转。
|
|
38
|
+
*/
|
|
39
|
+
export function decodeTextBuffer(buffer) {
|
|
40
|
+
// UTF-16 BOM 检测(部分 Windows 工具导出的 DXF/CSV 为 UTF-16)
|
|
41
|
+
if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) {
|
|
42
|
+
return { text: new TextDecoder('utf-16le').decode(buffer).replace(/^\uFEFF/u, ''), encoding: 'utf-16le' };
|
|
43
|
+
}
|
|
44
|
+
if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) {
|
|
45
|
+
return { text: new TextDecoder('utf-16be').decode(buffer).replace(/^\uFEFF/u, ''), encoding: 'utf-16be' };
|
|
46
|
+
}
|
|
47
|
+
const utf8Text = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
|
|
48
|
+
const replacementRatio = (utf8Text.match(/\uFFFD/gu) ?? []).length / Math.max(1, utf8Text.length);
|
|
49
|
+
if (replacementRatio <= 0.02)
|
|
50
|
+
return { text: utf8Text, encoding: 'utf-8' };
|
|
51
|
+
try {
|
|
52
|
+
const gbkText = new TextDecoder('gbk', { fatal: true }).decode(buffer);
|
|
53
|
+
const cjkRatio = (value) => (value.match(/[\u4E00-\u9FFF]/gu) ?? []).length / Math.max(1, value.length);
|
|
54
|
+
if (replacementRatio > 0.3 || cjkRatio(gbkText) > cjkRatio(utf8Text)) {
|
|
55
|
+
return { text: gbkText, encoding: 'gbk' };
|
|
56
|
+
}
|
|
57
|
+
return { text: utf8Text, encoding: 'utf-8' };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { text: utf8Text, encoding: 'utf-8' };
|
|
61
|
+
}
|
|
62
|
+
}
|