@customize-agent/knowledge 4.3.4 → 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.
@@ -396,14 +396,15 @@ export function cleanExtractedText(input) {
396
396
  index += run;
397
397
  continue;
398
398
  }
399
- // 3. 页眉/页脚/图框标题栏高重复行
400
- if (isHeaderFooterLine(trimmed, lineCounts.get(trimmed) ?? 1)) {
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
- for (const record of this.store.listRecords())
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
  : [];
@@ -856,6 +872,10 @@ export class KnowledgeBaseManager {
856
872
  rerankBoost += 160;
857
873
  if (normalizedPhrase.length >= 4 && normalizedContent.includes(normalizedPhrase))
858
874
  rerankBoost += 220;
875
+ // 时间类查询下,含具体日期/时刻的 chunk 是精确答案(如「获取时间:2026年8月13日」),
876
+ // 应优先于仅含规则条款的程序句(「距投标截止时间不足15日」类无具体时间)
877
+ if (/(时间|日期|工期|截止|开标|获取|递交)/u.test(query) && /20\d{2}年\d{1,2}月\d{1,2}日|\d{1,2}时\d{1,2}分/u.test(item.content))
878
+ rerankBoost += 60;
859
879
  const titleText = `${item.titlePath ?? ''}\n${item.sectionTitle ?? ''}`.toLowerCase();
860
880
  const normalizedTitle = this.normalizeSearchText(titleText);
861
881
  let matchedTermCount = 0;
@@ -873,7 +893,7 @@ export class KnowledgeBaseManager {
873
893
  rerankBoost += 22;
874
894
  }
875
895
  if (matchedTermCount >= 2)
876
- rerankBoost += matchedTermCount * 18;
896
+ rerankBoost += matchedTermCount * 30;
877
897
  for (const label of factLabels) {
878
898
  const normalizedLabel = this.normalizeSearchText(label);
879
899
  if (normalizedContent.includes(normalizedLabel))
@@ -881,8 +901,11 @@ export class KnowledgeBaseManager {
881
901
  if (normalizedTitle.includes(normalizedLabel))
882
902
  rerankBoost += 120;
883
903
  }
904
+ // 表格块惩罚仅针对「无任何查询词命中」的表格(清单特征块命中查询词时应保留竞争力);
905
+ // 表格行(清单特征「混凝土强度等级:C35」)在向量空间天然弱势,RRF 名次扁平下
906
+ // 若再吃 -80 惩罚,会被普通段落(绿建专篇)挤到后面,丢失精确词命中优势
884
907
  if (item.chunkKind === 'table')
885
- rerankBoost += wantsTable ? 25 : -80;
908
+ rerankBoost += wantsTable ? 25 : (matchedTermCount > 0 ? -10 : -80);
886
909
  if (item.chunkKind === 'metadata')
887
910
  rerankBoost += wantsDrawing ? 60 : -100;
888
911
  if (item.chunkKind === 'data')
@@ -893,7 +916,9 @@ export class KnowledgeBaseManager {
893
916
  rerankBoost -= wantsDrawing ? 60 : 160;
894
917
  }
895
918
  if (/工作表:|COL\d+|专业工程暂估价计价表|分部分项工程量清单计价表|材料(工程设备)暂估单价一览表/u.test(item.content)) {
896
- rerankBoost -= wantsTable ? 35 : 140;
919
+ // 计价表行无查询词命中是纯标题噪音(-140);命中查询词的清单特征行
920
+ // (「混凝土强度等级:C35」)是用户直接目标,仅轻惩罚维持正文优先
921
+ rerankBoost -= wantsTable ? 35 : (matchedTermCount > 0 ? 20 : 140);
897
922
  }
898
923
  if (/第\s*\d+\s*页\s*共\s*\d+\s*页|PDF\s*第\s*\d+\s*页/iu.test(item.content) && factLabels.length > 0)
899
924
  rerankBoost -= 12;
@@ -921,8 +946,12 @@ export class KnowledgeBaseManager {
921
946
  }
922
947
  queryTerms(query) {
923
948
  const base = query.toLowerCase().split(/[\s,,。;;::、]+/u).filter(Boolean);
949
+ // 字母数字与汉字连续串按边界拆词(与 store.expandSearchTerms 的边界拆分一致):
950
+ // 「C35混凝土」须拆出「c35」「混凝土」,重排层才能识别清单特征块的双词命中,
951
+ // 否则 terms 只有整串连写短语,对「强度等级:C35」类特征块零命中、错吃 table 惩罚
952
+ const splitParts = base.flatMap(term => term.split(/([\p{Script=Han}]+)/u).filter(part => part.length > 0));
924
953
  const labels = this.queryFactLabels(query).map(label => label.toLowerCase());
925
- return [...new Set([...base, ...labels].filter(term => term.length > 0))];
954
+ return [...new Set([...base, ...splitParts, ...labels].filter(term => term.length > 0))];
926
955
  }
927
956
  queryFactLabels(query) {
928
957
  const labels = [];
@@ -944,6 +973,12 @@ export class KnowledgeBaseManager {
944
973
  labels.push('招标人', '项目业主', '建设单位');
945
974
  if (/临水|临电|临时水电|水电接引|接驳点|挂表计量|施工水电/u.test(query))
946
975
  labels.push('临水临电', '临时水电接引', '施工水电接引费', '接驳点挂表计量', '挂表计量');
976
+ if (/获取时间|获取方式/u.test(query))
977
+ labels.push('获取时间', '招标文件获取时间');
978
+ if (/投标截止|截止时间/u.test(query))
979
+ labels.push('投标截止时间', '投标文件递交的截止时间');
980
+ if (/开标时间|开标地点|开标/u.test(query))
981
+ labels.push('开标时间和地点', '开标时间', '开标地点');
947
982
  if (/场地限制|材料堆场|办公区|生活区|加工区/u.test(query))
948
983
  labels.push('场地限制', '不具备材料堆场', '搭设加工区', '搭设办公区', '搭设生活区');
949
984
  if (/拆除|修补|破损处|改造维修/u.test(query))
@@ -1125,6 +1160,19 @@ export class KnowledgeBaseManager {
1125
1160
  const safeName = collectionName.replace(/[^a-zA-Z0-9_.-]/gu, '_');
1126
1161
  this.vectorStores.set(collectionName, new HNSWVectorStore(collectionName, path.join(this.vectorRoot, `${safeName}.hnsw`), this.embeddingProvider.dimensions));
1127
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
+ }
1128
1176
  async deleteVectorFile(collectionName, relativePath) {
1129
1177
  this.ensureVectorStore(collectionName);
1130
1178
  try {
@@ -1133,7 +1181,50 @@ export class KnowledgeBaseManager {
1133
1181
  catch (error) {
1134
1182
  this.store.setMetadata('vector_index_status', 'error');
1135
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
+ }
1136
1226
  }
1227
+ this.store.setMetadata('vector_orphan_pending', remaining.length > 0 ? JSON.stringify(remaining) : '');
1137
1228
  }
1138
1229
  consumePendingVectorRelativePaths() {
1139
1230
  const raw = this.store.getMetadata('vector_pending_relative_paths');
@@ -1154,8 +1245,7 @@ export class KnowledgeBaseManager {
1154
1245
  async ensureVectorIndexFresh(chunkCount, options = {}) {
1155
1246
  if (chunkCount === 0)
1156
1247
  return;
1157
- for (const record of this.store.listRecords())
1158
- this.ensureVectorStore(record.collectionName);
1248
+ this.ensureAllVectorStores();
1159
1249
  if (options.rebuild || [...this.vectorStores.values()].some(store => store.needsRebuild?.())) {
1160
1250
  await this.indexVectors({ rebuild: true });
1161
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
- const CAD_INTERNAL_LINE_RE = /^(?:TDb\w+|AcDb[\w:]+|\$AUDIT_BAD_\w+|[A-F0-9]{8,}|\d+|Model|Layout\d*|ByLayer|Continuous|MLEADERSTYLE|ObjectDBX)$/iu;
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
- text = fs.readFileSync(file.absolutePath, 'utf8');
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
- const normalized = [...value]
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, 'utf8'), metadata);
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, 'utf8');
327
- const layers = this.matchAll(raw, /\n\s*8\s*\n([^\n]+)/gu).filter(value => this.isReadableCadValue(value)).slice(0, 300);
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.isReadableCadValue(value)).slice(0, 300);
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
- const characterDataCount = this.countCadCharacterData([...textEntities.map(annotation => annotation.text), ...uniqueLayers, ...uniqueBlocks]);
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, 'utf8');
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, 'utf8');
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.isReadableCadValue(value)).slice(0, 5000);
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
- try {
472
- const mod = await resolveAndImport('dxf-parser');
473
- const Parser = mod.default ?? mod;
474
- parsed = new Parser().parseSync(raw);
475
- }
476
- catch {
477
- warnings.push('dxf-parser 解析失败,已使用 DXF 文本结构抽取回退');
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.isReadableCadValue(value)).slice(0, 300);
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.isReadableCadValue(value)).slice(0, 300);
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
- const characterDataCount = this.countCadCharacterData([...textEntities.map(annotation => annotation.text), ...uniqueLayers, ...uniqueBlocks]);
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
- const entities = raw.split(/(?:^|\r?\n)\s*0\s*\r?\n/u).filter(section => /^(?:TEXT|MTEXT|DIMENSION|LEADER)/u.test(section.trim()));
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 text = this.cleanCadReadableText(/(?:^|\r?\n)\s*(?:1|3)\s*\r?\n([^\r\n]+)/u.exec(section)?.[1] ?? '');
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
- const block = /(?:^|\r?\n)\s*2\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim() ?? '';
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: section.trim().split(/\s+/u)[0],
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 raw = fs.readFileSync(file.absolutePath, 'utf8');
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@customize-agent/knowledge",
3
- "version": "4.3.4",
3
+ "version": "4.3.6",
4
4
  "description": "Local knowledge base infrastructure for customize-agent",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",