@customize-agent/knowledge 4.0.43 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -48,6 +48,12 @@ export declare class TextChunker {
|
|
|
48
48
|
private extractMarkdownTableBlocks;
|
|
49
49
|
private extractMarkdownTableRowRange;
|
|
50
50
|
private splitMarkdownTable;
|
|
51
|
+
/** 按行边界拆分超预算表块:表头行与数据行均保持完整;单行超预算也保持完整,不做窗口硬切 */
|
|
52
|
+
private splitTableByRowBoundary;
|
|
53
|
+
/** 表头行数(表头行 + 分隔行),非标准表格返回 0 */
|
|
54
|
+
private tableHeaderLineCount;
|
|
55
|
+
/** 计算切分片段在原文本中的起始偏移(顺序扫描,避免共享表头前缀导致 indexOf 重复命中) */
|
|
56
|
+
private chunkPartStartOffsets;
|
|
51
57
|
private enforceCandidateLimit;
|
|
52
58
|
private buildTitlePaths;
|
|
53
59
|
private withHeader;
|
|
@@ -413,12 +413,58 @@ export class TextChunker {
|
|
|
413
413
|
}
|
|
414
414
|
if (current.length > 0)
|
|
415
415
|
chunks.push([...header, ...current].join('\n'));
|
|
416
|
-
|
|
416
|
+
// 行原子性:超出预算的表块按行边界拆分并保留表头,绝不切开数据行(旧 splitByWindow 会把行切成碎片)
|
|
417
|
+
return chunks.flatMap(chunk => this.estimateTokens(chunk) > maxTokens ? this.splitTableByRowBoundary(chunk, header.length, maxTokens) : [chunk]);
|
|
418
|
+
}
|
|
419
|
+
/** 按行边界拆分超预算表块:表头行与数据行均保持完整;单行超预算也保持完整,不做窗口硬切 */
|
|
420
|
+
splitTableByRowBoundary(chunk, headerLineCount, maxTokens) {
|
|
421
|
+
const lines = chunk.split(/\r?\n/u).filter(Boolean);
|
|
422
|
+
const header = lines.slice(0, Math.min(headerLineCount, lines.length - 1));
|
|
423
|
+
const rows = lines.slice(header.length);
|
|
424
|
+
if (rows.length <= 1)
|
|
425
|
+
return [chunk];
|
|
426
|
+
const mid = Math.ceil(rows.length / 2);
|
|
427
|
+
const left = [...header, ...rows.slice(0, mid)].join('\n');
|
|
428
|
+
const right = [...header, ...rows.slice(mid)].join('\n');
|
|
429
|
+
return [left, right].flatMap(part => this.estimateTokens(part) > maxTokens ? this.splitTableByRowBoundary(part, header.length, maxTokens) : [part]);
|
|
430
|
+
}
|
|
431
|
+
/** 表头行数(表头行 + 分隔行),非标准表格返回 0 */
|
|
432
|
+
tableHeaderLineCount(text) {
|
|
433
|
+
const lines = text.trim().split(/\r?\n/u);
|
|
434
|
+
const separatorIndex = lines.findIndex(line => /^\s*\|?\s*:?-{3,}:?\s*\|/u.test(line));
|
|
435
|
+
return separatorIndex > 0 ? separatorIndex + 1 : 0;
|
|
436
|
+
}
|
|
437
|
+
/** 计算切分片段在原文本中的起始偏移(顺序扫描,避免共享表头前缀导致 indexOf 重复命中) */
|
|
438
|
+
chunkPartStartOffsets(text, parts) {
|
|
439
|
+
const offsets = [];
|
|
440
|
+
let cursor = 0;
|
|
441
|
+
for (const part of parts) {
|
|
442
|
+
const index = text.indexOf(part, cursor);
|
|
443
|
+
offsets.push(index >= 0 ? index : cursor);
|
|
444
|
+
cursor = index >= 0 ? index + part.length : cursor;
|
|
445
|
+
}
|
|
446
|
+
return offsets;
|
|
417
447
|
}
|
|
418
448
|
enforceCandidateLimit(candidates, config) {
|
|
419
449
|
return candidates.flatMap(candidate => {
|
|
420
450
|
if (this.estimateTokens(candidate.text) <= config.maxChunkSize)
|
|
421
451
|
return [candidate];
|
|
452
|
+
// 表格候选:按行边界拆分并保留表头,避免 splitByWindow 把数据行切成碎片
|
|
453
|
+
if (candidate.kind === 'table') {
|
|
454
|
+
const headerLineCount = this.tableHeaderLineCount(candidate.text);
|
|
455
|
+
if (headerLineCount > 0) {
|
|
456
|
+
const parts = this.splitTableByRowBoundary(candidate.text, headerLineCount, config.maxChunkSize);
|
|
457
|
+
const offsets = this.chunkPartStartOffsets(candidate.text, parts);
|
|
458
|
+
return parts.map((text, index) => ({
|
|
459
|
+
...candidate,
|
|
460
|
+
text,
|
|
461
|
+
childIndex: candidate.childIndex + index,
|
|
462
|
+
startChar: candidate.startChar + (offsets[index] ?? 0),
|
|
463
|
+
endChar: candidate.startChar + (offsets[index] ?? 0) + text.length,
|
|
464
|
+
rowRange: candidate.rowRange ? `${candidate.rowRange}#${index + 1}` : undefined,
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
422
468
|
return this.splitByWindow(candidate.text, config.maxChunkSize, config.overlap).map((text, index) => ({
|
|
423
469
|
...candidate,
|
|
424
470
|
text,
|
|
@@ -1201,13 +1201,14 @@ export class KnowledgeBaseManager {
|
|
|
1201
1201
|
}
|
|
1202
1202
|
hasUsableContent(text, metadata) {
|
|
1203
1203
|
const coverage = String(metadata.contentCoverage ?? '');
|
|
1204
|
-
if (['metadata', 'metadata_filename', 'pdf_metadata_only', 'office_zip_empty_text', 'office_zip_failed'].includes(coverage))
|
|
1204
|
+
if (['metadata', 'metadata_filename', 'pdf_metadata_only', 'office_zip_empty_text', 'office_zip_failed', 'cad_no_extractable_text'].includes(coverage))
|
|
1205
1205
|
return false;
|
|
1206
1206
|
return text.trim().length > 0;
|
|
1207
1207
|
}
|
|
1208
1208
|
isMetadataOnlyNonBlocking(file, metadata) {
|
|
1209
1209
|
const coverage = String(metadata.contentCoverage ?? '');
|
|
1210
|
-
return file.category === 'image' && ['image_too_small_for_ocr', 'ocr_no_text'].includes(coverage)
|
|
1210
|
+
return (file.category === 'image' && ['image_too_small_for_ocr', 'ocr_no_text'].includes(coverage))
|
|
1211
|
+
|| (file.category === 'cad' && coverage === 'cad_no_extractable_text');
|
|
1211
1212
|
}
|
|
1212
1213
|
defaultUploadRelativePath(fileName) {
|
|
1213
1214
|
const classification = this.classifier.classifyVirtual(fileName);
|
|
@@ -17,6 +17,8 @@ export declare class ContentExtractor {
|
|
|
17
17
|
private isReadableCadValue;
|
|
18
18
|
private cleanExtractedText;
|
|
19
19
|
private textScore;
|
|
20
|
+
/** 统计图纸提取片段中的实际字符数据量(汉字/字母/数字),用于判断"无字符数据不入库" */
|
|
21
|
+
private countCadCharacterData;
|
|
20
22
|
private extractCad;
|
|
21
23
|
private extractDxf;
|
|
22
24
|
private buildCadSemanticNodes;
|
|
@@ -5,9 +5,15 @@ 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
|
-
const CAD_INTERNAL_TOKEN_RE = /\b(?:TDbPipe|TDbPipeValve|TDbPipeFitting|TDbWellh|AcDb\w+|Dwg\w+|ObjectId|Handle|ByLayer|Continuous|Model|Layout\d
|
|
9
|
-
const CAD_INTERNAL_LINE_RE = /^(?:TDb\w+|AcDb\w+|[A-F0-9]{8,}|\d+|Model|Layout\d*|ByLayer|Continuous)$/iu;
|
|
10
|
-
|
|
8
|
+
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
|
+
/** DWG 二进制误读产生的 C1 控制字符(U+0080-U+009F,如 GBK 半字节),正常图纸标注不会出现 */
|
|
11
|
+
const CAD_C1_CONTROL_RE = /[\u0080-\u009F]/gu;
|
|
12
|
+
/** AutoCAD 控制码:%%c=Φ、%%d=°、%%p=±、%%132 等数字码=Φ,统一解码为可读符号 */
|
|
13
|
+
const CAD_CONTROL_CODE_RE = /%%(?:c|d|p|\d{2,3})/giu;
|
|
14
|
+
const CAD_DOMAIN_SIGNAL_RE = /工程|项目|施工|建筑|结构|装饰|电气|给排水|消防|暖通|平面|立面|剖面|节点|详图|材料|尺寸|标高|轴线|图层|门窗|墙|地面|顶面|照明|配电|弱电|空调|卫生间|楼梯|屋面|基础|柱|梁|板|图号|设计|说明|轴|电|井|土|夯/u;
|
|
15
|
+
/** 图纸兜底解析可读字符(汉字/字母/数字)的最低总量,低于该值视为无字符数据,不入库 */
|
|
16
|
+
const MIN_CAD_CHARACTER_DATA = 32;
|
|
11
17
|
const OCR_NATIVE_NOISE_PATTERNS = [/^Image too small to scale!!/u, /^Line cannot be recognized!!$/u];
|
|
12
18
|
/** 文件内容提取器,支持文档、表格、图片、CAD 等多种文件格式的内容抽取 */
|
|
13
19
|
export class ContentExtractor {
|
|
@@ -142,6 +148,8 @@ export class ContentExtractor {
|
|
|
142
148
|
}
|
|
143
149
|
cleanCadReadableText(value) {
|
|
144
150
|
return value
|
|
151
|
+
.replace(CAD_C1_CONTROL_RE, ' ')
|
|
152
|
+
.replace(CAD_CONTROL_CODE_RE, code => (code.toLowerCase() === '%%d' ? '°' : code.toLowerCase() === '%%p' ? '±' : 'Φ'))
|
|
145
153
|
.replace(CAD_INTERNAL_TOKEN_RE, '')
|
|
146
154
|
.replace(/\b(?:LINE|LWPOLYLINE|POLYLINE|INSERT|HATCH|CIRCLE|ARC|DIMENSION|TEXT|MTEXT)\b/giu, '')
|
|
147
155
|
.replace(/\s+/gu, ' ')
|
|
@@ -162,6 +170,13 @@ export class ContentExtractor {
|
|
|
162
170
|
const hasDomainSignal = CAD_DOMAIN_SIGNAL_RE.test(compact);
|
|
163
171
|
const hasCommonTextShape = /[,。;:、,.\-/()()]|\d+(?:\.\d+)?\s*(?:mm|cm|m|㎡|%|°)?/iu.test(compact);
|
|
164
172
|
const latinVowelCount = chars.filter(char => /[aAeEiIoOuU]/u.test(char)).length;
|
|
173
|
+
// 替换符(U+FFFD):解码失败的标志,正常标注不会出现
|
|
174
|
+
if (chars.includes('\uFFFD'))
|
|
175
|
+
return true;
|
|
176
|
+
// 罕见符号(箭头补充、CJK 部首、杂项数学/圈符/地图符号):二进制误读产物,
|
|
177
|
+
// 正常图纸标注只用常用标点与工程符号
|
|
178
|
+
if (/[\u2046-\u205F\u2070-\u209F\u2100-\u2102\u2104-\u214F\u21B0-\u21FF\u2270-\u22FF\u2400-\u243F\u249C-\u24FF\u2640-\u26FF\u27C0-\u27EF\u2900-\u297F\u2A00-\u2AFF\u2B00-\u2BFF\u2E00-\u2FFF\u3200-\u33FF]/u.test(compact))
|
|
179
|
+
return true;
|
|
165
180
|
if (readableRatio < 0.6)
|
|
166
181
|
return true;
|
|
167
182
|
if (symbolRatio > 0.35 && !hasDomainSignal)
|
|
@@ -170,6 +185,96 @@ export class ContentExtractor {
|
|
|
170
185
|
return true;
|
|
171
186
|
if (cjk >= 8 && digits === 0 && !hasDomainSignal && !hasCommonTextShape)
|
|
172
187
|
return true;
|
|
188
|
+
// 短行内汉字-Latin-汉字交叉混排(考堂f肀、渱潑喲W晀耀):二进制误读的典型形态;
|
|
189
|
+
// 正常标注的字母编号在汉字前或后(JD 电井、AB轴),不会夹在汉字中间
|
|
190
|
+
if (chars.length <= 8 && cjk >= 2 && latin >= 1 && digits === 0 && !hasDomainSignal && /[\p{Script=Han}][\p{Script=Latin}][\p{Script=Han}]/u.test(compact))
|
|
191
|
+
return true;
|
|
192
|
+
// 纯 Latin 行含 Latin-1 扩展字符(VdA«UdA«UdANÒg、dAç dA+ dA«):正常英文标注不用扩展字符
|
|
193
|
+
if (cjk === 0 && digits === 0 && latin >= 8 && /[\u00A0-\u02AF\u1E00-\u1EFF]/u.test(compact))
|
|
194
|
+
return true;
|
|
195
|
+
// 超长无句读行:二进制误读产生的长连续乱码(常混入个别汉字/数字触发域信号豁免,
|
|
196
|
+
// 因此只认中文句读,数字/单位不再豁免)
|
|
197
|
+
if (chars.length > 400 && !/[\u3002\uFF0C\uFF1B\uFF1A\u3001、,。;:]/.test(compact))
|
|
198
|
+
return true;
|
|
199
|
+
// 行内短片段周期性重复(Ml+Ml+Ml、AM~AM~BM~BM、M|¶M|¶、2dA+2dA+2dA):
|
|
200
|
+
// 正常标注不会让同一 2-4 字符片段在一行内重复出现,二进制误读则产生大量循环模式
|
|
201
|
+
const shortTokens = compact.split(/[^\p{L}\p{N}]+/u).filter(token => token.length >= 1 && token.length <= 4);
|
|
202
|
+
if (shortTokens.length >= 3 && chars.length >= 8 && !hasDomainSignal) {
|
|
203
|
+
const tokenFreq = new Map();
|
|
204
|
+
for (const token of shortTokens)
|
|
205
|
+
tokenFreq.set(token, (tokenFreq.get(token) ?? 0) + 1);
|
|
206
|
+
let repeatedChars = 0;
|
|
207
|
+
for (const [token, count] of tokenFreq) {
|
|
208
|
+
if (count >= 2)
|
|
209
|
+
repeatedChars += token.length * count;
|
|
210
|
+
}
|
|
211
|
+
if (repeatedChars / Math.max(1, shortTokens.join('').length) >= 0.4)
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
// 短行内同一汉字高频重复(摁䭚摁譚摁譚摁、耀U耀W耀Y耀):正常标注不会在
|
|
215
|
+
// 14 字符以内的行里让重复汉字占比超过 60%
|
|
216
|
+
const hanChars = chars.filter(char => /[\p{Script=Han}]/u.test(char));
|
|
217
|
+
if (hanChars.length >= 4 && chars.length <= 14) {
|
|
218
|
+
const hanFreq = new Map();
|
|
219
|
+
for (const char of hanChars)
|
|
220
|
+
hanFreq.set(char, (hanFreq.get(char) ?? 0) + 1);
|
|
221
|
+
const repeatedHan = hanChars.filter(char => (hanFreq.get(char) ?? 0) >= 2).length;
|
|
222
|
+
if (repeatedHan / hanChars.length >= 0.6)
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
// Latin-1 扩展字符(¡-ÿ 区)密集行:中文图纸标注几乎不用这些字符,
|
|
226
|
+
// 二进制误读(GBK/CP1252 混读)会批量产生
|
|
227
|
+
const latinExtended = chars.filter(char => /[\u00A0-\u02AF\u1E00-\u1EFF]/u.test(char)).length;
|
|
228
|
+
if (latinExtended >= 3 && (latinExtended / chars.length >= 0.3 || latin / Math.max(1, chars.length) >= 0.6))
|
|
229
|
+
return true;
|
|
230
|
+
// 纯 Latin-1 扩展字母短行(无 ASCII 字母/数字):GBK 中文标注被 Latin-1 误读的
|
|
231
|
+
// 典型产物("上"→ÉÏ、"柜"→¹ñ),正常标注的英文是 ASCII、中文是汉字,
|
|
232
|
+
// 不会出现整行全由扩展拉丁字母构成的短行
|
|
233
|
+
if (cjk === 0 && digits === 0 && latin >= 2 && latinExtended === latin && chars.length <= 12)
|
|
234
|
+
return true;
|
|
235
|
+
// GBK 误读标点/字母混入 ASCII 标注("1APz:P4~P6"→1APz£ºP4~P6、"消防控制"→Ïû·À¿ØÖÆ):
|
|
236
|
+
// 正常工程标注只使用 °±×÷·µ²³Ø 等少数 Latin-1 字符,
|
|
237
|
+
// 无汉字行中出现 2 个以上其他 Latin-1 字符即判定为 GBK 编码误读
|
|
238
|
+
const gbkMisreadChars = chars.filter(char => /[\u00A0-\u00FF]/u.test(char) && !'°±×÷·µ²³Ø'.includes(char)).length;
|
|
239
|
+
if (gbkMisreadChars >= 2 && cjk === 0)
|
|
240
|
+
return true;
|
|
241
|
+
// 分数符号/上标数字(¼½¾¹):中文工程图纸标注几乎不用(仅 ²³ 常见保留),
|
|
242
|
+
// 出现在标注中基本是 GBK 误读(门宽 FM×1524 误读为 FM¼×1524)
|
|
243
|
+
if (/[¹¼½¾]/u.test(compact) && cjk === 0)
|
|
244
|
+
return true;
|
|
245
|
+
// 无空格的超长 Latin 字母串(≥16 字符):正常标注是词/编号,不会出现连续长字母串
|
|
246
|
+
if (/[\p{Script=Latin}\u00C0-\u024F]{16,}/u.test(compact))
|
|
247
|
+
return true;
|
|
248
|
+
// GUID:DWG 内部结构标识,不是图纸字符数据
|
|
249
|
+
if (/[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}/u.test(compact))
|
|
250
|
+
return true;
|
|
251
|
+
// 单汉字高频重复:正常标注/文本不会让同一汉字占比超过 20%(如乱码行中
|
|
252
|
+
// 生僻字“罍”重复 400+ 次仍被 readableRatio 计为可读字符而漏网)
|
|
253
|
+
const charCounts = new Map();
|
|
254
|
+
for (const char of chars)
|
|
255
|
+
charCounts.set(char, (charCounts.get(char) ?? 0) + 1);
|
|
256
|
+
let mostCommon = '';
|
|
257
|
+
let mostCommonCount = 0;
|
|
258
|
+
for (const [char, count] of charCounts) {
|
|
259
|
+
if (count > mostCommonCount) {
|
|
260
|
+
mostCommon = char;
|
|
261
|
+
mostCommonCount = count;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (mostCommonCount >= 5 && mostCommonCount / chars.length > 0.2 && /[\p{Script=Han}]/u.test(mostCommon))
|
|
265
|
+
return true;
|
|
266
|
+
// CJK 扩展区生僻字(Ext A/B 等)混排:正常图纸标注几乎只用常用字(基本区
|
|
267
|
+
// U+4E00-U+9FFF),二进制误读会批量产生 U+3400-U+4DBF 等扩展区字符
|
|
268
|
+
const rareCjk = chars.filter(char => /[\u3400-\u4DBF\u{20000}-\u{2FA1F}]/u.test(char)).length;
|
|
269
|
+
if (rareCjk >= 2 && rareCjk / chars.length >= 0.1)
|
|
270
|
+
return true;
|
|
271
|
+
if (rareCjk >= 1 && rareCjk / chars.length >= 0.2)
|
|
272
|
+
return true;
|
|
273
|
+
// 非中/英/希字母脚本混排(韩文、藏文、彝文、泰文等):中文标注行内混入
|
|
274
|
+
// 其他文字系统字母是二进制误读的典型产物(希腊字母 ΦφΩ 在图纸中常见,豁免)
|
|
275
|
+
const foreignLetter = chars.filter(char => /\p{Letter}/u.test(char) && !/[\p{Script=Han}\p{Script=Latin}\p{Script=Greek}]/u.test(char)).length;
|
|
276
|
+
if (foreignLetter >= 1 && cjk > 0)
|
|
277
|
+
return true;
|
|
173
278
|
return false;
|
|
174
279
|
}
|
|
175
280
|
isReadableCadValue(value) {
|
|
@@ -180,7 +285,8 @@ export class ContentExtractor {
|
|
|
180
285
|
const normalized = [...value]
|
|
181
286
|
.filter(char => {
|
|
182
287
|
const code = char.charCodeAt(0);
|
|
183
|
-
|
|
288
|
+
// U+FFFF 非字符:PDF 表单空白下划线/占位符的提取产物,无检索意义,统一剔除
|
|
289
|
+
return code !== 0xFFFF && (code === 9 || code === 10 || code === 13 || code >= 32);
|
|
184
290
|
})
|
|
185
291
|
.join('');
|
|
186
292
|
if (file.category !== 'cad')
|
|
@@ -197,6 +303,10 @@ export class ContentExtractor {
|
|
|
197
303
|
const alnum = (value.match(/[\p{L}\p{N}]/gu) ?? []).length;
|
|
198
304
|
return cjk * 4 + alnum + Math.min(value.length, 200) / 20;
|
|
199
305
|
}
|
|
306
|
+
/** 统计图纸提取片段中的实际字符数据量(汉字/字母/数字),用于判断"无字符数据不入库" */
|
|
307
|
+
countCadCharacterData(fragments) {
|
|
308
|
+
return (fragments.join('').match(/[\p{Script=Han}\p{L}\p{N}]/gu) ?? []).length;
|
|
309
|
+
}
|
|
200
310
|
async extractCad(file) {
|
|
201
311
|
const metadata = { extractionMode: 'builtin_cad_structural', vectorizable: true, preferredExtractionMode: 'dwg_to_dxf_semantic' };
|
|
202
312
|
const warnings = [];
|
|
@@ -228,7 +338,16 @@ export class ContentExtractor {
|
|
|
228
338
|
metadata.blockNames = uniqueBlocks.slice(0, 80);
|
|
229
339
|
metadata.entityTypeCount = uniqueEntityTypes.length;
|
|
230
340
|
metadata.entityTypes = uniqueEntityTypes.slice(0, 80);
|
|
341
|
+
const characterDataCount = this.countCadCharacterData([...textEntities.map(annotation => annotation.text), ...uniqueLayers, ...uniqueBlocks]);
|
|
342
|
+
if (characterDataCount < MIN_CAD_CHARACTER_DATA) {
|
|
343
|
+
// 图纸无字符数据(无文字标注、图层/块名均为内部默认值),不入库
|
|
344
|
+
metadata.contentCoverage = 'cad_no_extractable_text';
|
|
345
|
+
metadata.characterDataCount = characterDataCount;
|
|
346
|
+
warnings.push(`${file.format} DXF 未提取到字符数据(仅 ${characterDataCount} 个可读字符),图纸内容未入库`);
|
|
347
|
+
return { text: this.metadataOnlyText(file), metadata, warnings };
|
|
348
|
+
}
|
|
231
349
|
metadata.contentCoverage = 'dxf_semantic_layer_block_annotations';
|
|
350
|
+
metadata.characterDataCount = characterDataCount;
|
|
232
351
|
const semanticNodes = this.buildCadSemanticNodes(file, uniqueLayers, uniqueBlocks, uniqueEntityTypes, textEntities);
|
|
233
352
|
return {
|
|
234
353
|
text: [
|
|
@@ -291,21 +410,59 @@ export class ContentExtractor {
|
|
|
291
410
|
return result;
|
|
292
411
|
}
|
|
293
412
|
const binaryFragments = this.extractBinaryReadableFragments(file.absolutePath);
|
|
294
|
-
|
|
413
|
+
// OLE 属性集(<prop_set ...>)是 DWG 文件内部的二进制属性结构,不含图纸标注字符,
|
|
414
|
+
// 属于解析噪声而非字符数据,直接排除(不锚定行首:误读字符可能混在片段开头)
|
|
415
|
+
const withoutPropSets = binaryFragments.filter(value => !/<prop_set\b/u.test(value));
|
|
416
|
+
let readable = withoutPropSets.filter(value => this.isReadableCadValue(value)).slice(0, 5000);
|
|
417
|
+
// 文档级字符词频过滤:真实标注文字在图纸中会重复出现(标题/图层/图名等),
|
|
418
|
+
// 随机二进制噪声片段中每个字符几乎只出现一次(孤立字符);短片段若全部由
|
|
419
|
+
// 孤立字符构成即为随机噪声,丢弃
|
|
420
|
+
const charFreq = new Map();
|
|
421
|
+
for (const value of readable) {
|
|
422
|
+
for (const char of value)
|
|
423
|
+
charFreq.set(char, (charFreq.get(char) ?? 0) + 1);
|
|
424
|
+
}
|
|
425
|
+
const isolatedNoise = (value) => {
|
|
426
|
+
if (CAD_DOMAIN_SIGNAL_RE.test(value))
|
|
427
|
+
return false;
|
|
428
|
+
const letters = [...value].filter(char => /[\p{L}\p{N}]/u.test(char));
|
|
429
|
+
if (letters.length > 8)
|
|
430
|
+
return false;
|
|
431
|
+
const freqs = letters.map(char => charFreq.get(char) ?? 1);
|
|
432
|
+
// 短片段中 60% 以上字符在全文只出现一次 → 随机噪声(真实标注文字会重复出现)
|
|
433
|
+
const isolatedCount = freqs.filter(freq => freq <= 1).length;
|
|
434
|
+
return isolatedCount / Math.max(1, letters.length) >= 0.6;
|
|
435
|
+
};
|
|
436
|
+
readable = readable.filter(value => !isolatedNoise(value));
|
|
437
|
+
// 可信片段过滤:真实图纸标注至少含 3 个汉字(图名/说明/材料文字)或含域信号词,
|
|
438
|
+
// 且全部字符落在图纸常用字符白名单内(汉字/ASCII 字母数字/常用标点/工程符号);
|
|
439
|
+
// 二进制误读碎片常含 Latin 扩展字母、罕见符号或纯字母数字串,白名单直接排除
|
|
440
|
+
const trustedCadFragment = (value) => {
|
|
441
|
+
const hanCount = (value.match(/\p{Script=Han}/gu) ?? []).length;
|
|
442
|
+
if (hanCount < 3 && !CAD_DOMAIN_SIGNAL_RE.test(value))
|
|
443
|
+
return false;
|
|
444
|
+
return !/[^\p{Script=Han}A-Za-z0-9\s\u3000-\u303F\uFF01-\uFF5E\u2460-\u2473\u2160-\u2179°Φφ×±≤≥∠√℃‰※′″〇●○◆■□▲△★☆◇→←↑↓ΩΔαβγθλμω]/u.test(value);
|
|
445
|
+
};
|
|
446
|
+
readable = readable.filter(trustedCadFragment);
|
|
295
447
|
const filteredCount = Math.max(0, binaryFragments.length - readable.length);
|
|
448
|
+
// 图纸"字符数据"= 提取到的标注/标题块中实际可读的文字(汉字/字母/数字)总量;
|
|
449
|
+
// 低于最低阈值视为无字符数据,不入库
|
|
450
|
+
const characterDataCount = this.countCadCharacterData(readable);
|
|
451
|
+
const hasCharacterData = characterDataCount >= MIN_CAD_CHARACTER_DATA;
|
|
296
452
|
metadata.extractionMode = 'builtin_cad_readable_fragments';
|
|
297
453
|
metadata.professionalConversionUsed = false;
|
|
298
|
-
metadata.contentCoverage =
|
|
299
|
-
metadata.contentConfidence =
|
|
454
|
+
metadata.contentCoverage = hasCharacterData ? 'cad_readable_text_fragments_filtered' : 'cad_no_extractable_text';
|
|
455
|
+
metadata.contentConfidence = hasCharacterData ? 'low_fallback_filtered' : 'metadata_only';
|
|
300
456
|
metadata.stringCandidateCount = binaryFragments.length;
|
|
301
457
|
metadata.stringCount = readable.length;
|
|
458
|
+
metadata.characterDataCount = characterDataCount;
|
|
302
459
|
metadata.filteredGarbledStringCount = filteredCount;
|
|
303
|
-
if (
|
|
304
|
-
warnings.push(`${file.format} 内置 CAD
|
|
460
|
+
if (!hasCharacterData)
|
|
461
|
+
warnings.push(`${file.format} 内置 CAD 解析器未提取到字符数据(仅 ${characterDataCount} 个可读字符),图纸内容未入库`);
|
|
305
462
|
else
|
|
306
463
|
warnings.push(`${file.format} 内置 DWG→DXF 转换未成功,已使用低置信度可读标注/标题块兜底抽取并过滤疑似乱码 ${filteredCount} 条;该结果仅作为兜底证据,不应等同于完整图层、块、标注和尺寸语义解析`);
|
|
307
464
|
return {
|
|
308
|
-
text:
|
|
465
|
+
text: hasCharacterData ? [this.metadataOnlyText(file), `CAD 图纸可读标注/标题块/属性:\n${readable.join('\n')}`].join('\n') : this.metadataOnlyText(file),
|
|
309
466
|
metadata,
|
|
310
467
|
warnings,
|
|
311
468
|
};
|
|
@@ -335,8 +492,17 @@ export class ContentExtractor {
|
|
|
335
492
|
metadata.blockNames = uniqueBlocks.slice(0, 80);
|
|
336
493
|
metadata.entityTypeCount = uniqueEntityTypes.length;
|
|
337
494
|
metadata.entityTypes = uniqueEntityTypes.slice(0, 80);
|
|
338
|
-
metadata.contentCoverage = 'dxf_semantic_layer_block_annotations';
|
|
339
495
|
metadata.parsedByDxfParser = Boolean(parsed);
|
|
496
|
+
const characterDataCount = this.countCadCharacterData([...textEntities.map(annotation => annotation.text), ...uniqueLayers, ...uniqueBlocks]);
|
|
497
|
+
if (characterDataCount < MIN_CAD_CHARACTER_DATA) {
|
|
498
|
+
// 图纸无字符数据(无文字标注、图层/块名均为内部默认值),不入库
|
|
499
|
+
metadata.contentCoverage = 'cad_no_extractable_text';
|
|
500
|
+
metadata.characterDataCount = characterDataCount;
|
|
501
|
+
warnings.push(`${file.format} DXF 未提取到字符数据(仅 ${characterDataCount} 个可读字符),图纸内容未入库`);
|
|
502
|
+
return { text: this.metadataOnlyText(file), metadata, warnings };
|
|
503
|
+
}
|
|
504
|
+
metadata.contentCoverage = 'dxf_semantic_layer_block_annotations';
|
|
505
|
+
metadata.characterDataCount = characterDataCount;
|
|
340
506
|
const semanticNodes = this.buildCadSemanticNodes(file, uniqueLayers, uniqueBlocks, uniqueEntityTypes, textEntities);
|
|
341
507
|
return {
|
|
342
508
|
text: [
|
|
@@ -373,10 +539,14 @@ export class ContentExtractor {
|
|
|
373
539
|
const text = this.cleanCadReadableText(/(?:^|\r?\n)\s*(?:1|3)\s*\r?\n([^\r\n]+)/u.exec(section)?.[1] ?? '');
|
|
374
540
|
if (!text || !this.isReadableCadValue(text))
|
|
375
541
|
return [];
|
|
542
|
+
// 图层/块名同样要过可读性过滤:DXF 里 GBK 误读(Ïä¹ñ)或纯数字/内部
|
|
543
|
+
// 标识(11、AcDb...)会直接混进节点文本,不合格时置空由语义节点回退
|
|
544
|
+
const layer = /(?:^|\r?\n)\s*8\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim() ?? '';
|
|
545
|
+
const block = /(?:^|\r?\n)\s*2\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim() ?? '';
|
|
376
546
|
return [{
|
|
377
547
|
text,
|
|
378
|
-
layer:
|
|
379
|
-
block:
|
|
548
|
+
layer: layer && this.isReadableCadValue(layer) ? layer : undefined,
|
|
549
|
+
block: block && this.isReadableCadValue(block) ? block : undefined,
|
|
380
550
|
entityType: section.trim().split(/\s+/u)[0],
|
|
381
551
|
x: Number(/(?:^|\r?\n)\s*10\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]),
|
|
382
552
|
y: Number(/(?:^|\r?\n)\s*20\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]),
|
|
@@ -406,25 +576,42 @@ export class ContentExtractor {
|
|
|
406
576
|
if (!mod.convertDwgToDxf)
|
|
407
577
|
return { tool: 'dwgdxf_wasm', warnings: ['内置 dwgdxf WASM 转换器未导出 convertDwgToDxf'] };
|
|
408
578
|
const dwgBytes = fs.readFileSync(filePath);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (dxfText.trim())
|
|
420
|
-
return { dxfText, tool: `dwgdxf_wasm:${attempt.label}`, warnings: [] };
|
|
421
|
-
failures.push(`${attempt.label}: 未输出 DXF 文本`);
|
|
579
|
+
// 注意:dwgdxf 内部对 WASM 基础 URL 有模块级缓存,只有首次调用传入的
|
|
580
|
+
// wasmBase 才会生效。若首次调用不带 wasmBase,会走到包内硬编码的构建机
|
|
581
|
+
// 路径(file:///home/runner/work/...)并永久缓存失败结果,后续重试全部
|
|
582
|
+
// 复用同一失败 Promise。因此第一次调用就必须传本地包内 wasm 目录的
|
|
583
|
+
// 正确 file:// URL,不允许再回退重试(重试无效且会污染缓存)。
|
|
584
|
+
let wasmBase;
|
|
585
|
+
try {
|
|
586
|
+
const localWasmDir = path.join(path.dirname(resolvePackage('dwgdxf')), 'wasm');
|
|
587
|
+
if (fs.existsSync(path.join(localWasmDir, 'dwgdxf_bg.wasm'))) {
|
|
588
|
+
wasmBase = pathToFileURL(localWasmDir).href;
|
|
422
589
|
}
|
|
423
|
-
|
|
424
|
-
|
|
590
|
+
}
|
|
591
|
+
catch { /* 包解析失败时回退到包内默认路径(大概率也会失败,但由下方 catch 统一记录) */ }
|
|
592
|
+
try {
|
|
593
|
+
const dxfBytes = await mod.convertDwgToDxf(dwgBytes, wasmBase ? { wasmBase } : undefined);
|
|
594
|
+
const dxfText = Buffer.from(dxfBytes).toString('utf8');
|
|
595
|
+
if (dxfText.trim())
|
|
596
|
+
return { dxfText, tool: wasmBase ? 'dwgdxf_wasm:local_wasm' : 'dwgdxf_wasm:package_default', warnings: [] };
|
|
597
|
+
return { tool: 'dwgdxf_wasm', warnings: ['内置 dwgdxf WASM 未输出 DXF 文本'] };
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
601
|
+
// unreachable 多为 WASM 内存压力下的瞬时失败(批量索引时主进程同时持有
|
|
602
|
+
// 大型 SQLite 缓存会挤压可用内存),换一个内存状态重试一次;格式类错误
|
|
603
|
+
// (Invalid file format 等)重试无意义,直接失败降级。
|
|
604
|
+
if (detail.includes('unreachable')) {
|
|
605
|
+
try {
|
|
606
|
+
const retryBytes = await mod.convertDwgToDxf(dwgBytes, wasmBase ? { wasmBase } : undefined);
|
|
607
|
+
const retryText = Buffer.from(retryBytes).toString('utf8');
|
|
608
|
+
if (retryText.trim())
|
|
609
|
+
return { dxfText: retryText, tool: wasmBase ? 'dwgdxf_wasm:local_wasm' : 'dwgdxf_wasm:package_default', warnings: ['内置 dwgdxf WASM 首次转换失败(unreachable),重试成功'] };
|
|
610
|
+
}
|
|
611
|
+
catch { /* 重试仍失败,按原错误降级 */ }
|
|
425
612
|
}
|
|
613
|
+
return { tool: 'dwgdxf_wasm', warnings: [`内置 dwgdxf WASM 转换失败${wasmBase ? '' : '(未找到本地 WASM 文件,使用了包内默认路径)'}: ${detail}`] };
|
|
426
614
|
}
|
|
427
|
-
return { tool: 'dwgdxf_wasm', warnings: [`内置 dwgdxf WASM 转换失败: ${failures.join(';')}`] };
|
|
428
615
|
}
|
|
429
616
|
catch (error) {
|
|
430
617
|
return { tool: 'dwgdxf_wasm', warnings: [`内置 dwgdxf WASM 加载失败: ${error instanceof Error ? error.message : String(error)}`] };
|
|
@@ -776,12 +963,20 @@ export class ContentExtractor {
|
|
|
776
963
|
return fallback;
|
|
777
964
|
}
|
|
778
965
|
extractLegacyOfficeBinary(file) {
|
|
779
|
-
const
|
|
966
|
+
const candidates = this.extractBinaryStrings(file.absolutePath).slice(0, 1_000);
|
|
967
|
+
// 二进制兜底提取出的字符串中混有大量误读乱码(如 OLE 复合文档中 UTF-16LE
|
|
968
|
+
// 中文被 latin1/utf8 误读产生的长串生僻字),复用 CAD 乱码判定规则过滤,
|
|
969
|
+
// 避免乱码直接入库污染检索与预览。
|
|
970
|
+
const strings = candidates.filter(value => !this.isLikelyGarbledCadText(value));
|
|
971
|
+
const filteredCount = candidates.length - strings.length;
|
|
780
972
|
const text = strings.join('\n').trim();
|
|
781
973
|
return {
|
|
782
974
|
text,
|
|
783
|
-
metadata: { extractionMode: 'builtin_legacy_office_binary_strings', vectorizable: true, contentCoverage: '
|
|
784
|
-
warnings:
|
|
975
|
+
metadata: { extractionMode: 'builtin_legacy_office_binary_strings', vectorizable: true, contentCoverage: 'legacy_office_binary_strings_filtered', stringCandidateCount: candidates.length, stringCount: strings.length, filteredGarbledStringCount: filteredCount },
|
|
976
|
+
warnings: [
|
|
977
|
+
...(filteredCount > 0 ? [`已过滤疑似乱码字符串 ${filteredCount} 条`] : []),
|
|
978
|
+
...(text ? [] : ['旧版 Office 二进制文件未提取到正文,未入库']),
|
|
979
|
+
],
|
|
785
980
|
};
|
|
786
981
|
}
|
|
787
982
|
extractLegacyOfficeBinaryWithWarnings(file, warnings) {
|