@customize-agent/knowledge 4.0.15 → 4.0.17
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/chunking/text-chunker.d.ts +1 -0
- package/dist/chunking/text-chunker.js +67 -21
- package/dist/core/index-state-store.js +7 -2
- package/dist/core/knowledge-base-manager.d.ts +1 -1
- package/dist/core/knowledge-base-manager.js +49 -60
- package/dist/embedding/local-reranker.d.ts +13 -0
- package/dist/embedding/local-reranker.js +73 -0
- package/dist/extraction/content-extractor.d.ts +1 -0
- package/dist/extraction/content-extractor.js +61 -13
- package/dist/extraction/ocr-providers.d.ts +8 -0
- package/dist/extraction/ocr-providers.js +57 -29
- package/package.json +1 -1
|
@@ -44,6 +44,7 @@ export declare class TextChunker {
|
|
|
44
44
|
private splitByWindow;
|
|
45
45
|
private splitBySentenceBoundary;
|
|
46
46
|
private isMarkdownTable;
|
|
47
|
+
private extractMarkdownTableBlocks;
|
|
47
48
|
private extractMarkdownTableRowRange;
|
|
48
49
|
private splitMarkdownTable;
|
|
49
50
|
private enforceCandidateLimit;
|
|
@@ -79,6 +79,7 @@ export class TextChunker {
|
|
|
79
79
|
parentId,
|
|
80
80
|
parentIndex,
|
|
81
81
|
childIndex,
|
|
82
|
+
parentText: section.text, // <=== 记录原始完整的 Section 文本
|
|
82
83
|
});
|
|
83
84
|
});
|
|
84
85
|
});
|
|
@@ -86,21 +87,29 @@ export class TextChunker {
|
|
|
86
87
|
}
|
|
87
88
|
createTableCandidates(text, config) {
|
|
88
89
|
if (this.isMarkdownTable(text)) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
text
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
90
|
+
const tableBlocks = this.extractMarkdownTableBlocks(text);
|
|
91
|
+
if (tableBlocks.length > 0) {
|
|
92
|
+
return tableBlocks.flatMap((block, parentIndex) => {
|
|
93
|
+
const sectionTitle = this.extractSectionTitle(block.text) ?? '表格数据';
|
|
94
|
+
return this.splitMarkdownTable(block.text, config.maxChunkSize).map((part, childIndex) => {
|
|
95
|
+
const localStart = Math.max(0, block.text.indexOf(part.slice(0, 40)));
|
|
96
|
+
const startChar = block.startChar + localStart;
|
|
97
|
+
return {
|
|
98
|
+
text: part,
|
|
99
|
+
startChar,
|
|
100
|
+
endChar: startChar + part.length,
|
|
101
|
+
sectionTitle,
|
|
102
|
+
titlePath: sectionTitle,
|
|
103
|
+
kind: 'table',
|
|
104
|
+
parentId: `table-${parentIndex}`,
|
|
105
|
+
parentIndex,
|
|
106
|
+
childIndex,
|
|
107
|
+
rowRange: this.extractMarkdownTableRowRange(part),
|
|
108
|
+
parentText: block.text,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
104
113
|
}
|
|
105
114
|
return this.createTextCandidates(text, 'spreadsheet', config);
|
|
106
115
|
}
|
|
@@ -114,9 +123,10 @@ export class TextChunker {
|
|
|
114
123
|
sectionTitle: this.extractSectionTitle(part),
|
|
115
124
|
titlePath: this.extractSectionTitle(part),
|
|
116
125
|
kind: 'data',
|
|
117
|
-
parentId: `data
|
|
118
|
-
parentIndex:
|
|
119
|
-
childIndex:
|
|
126
|
+
parentId: `data-0`,
|
|
127
|
+
parentIndex: 0,
|
|
128
|
+
childIndex: index,
|
|
129
|
+
parentText: text,
|
|
120
130
|
}));
|
|
121
131
|
}
|
|
122
132
|
createCodeCandidates(text, file, config) {
|
|
@@ -135,9 +145,10 @@ export class TextChunker {
|
|
|
135
145
|
sectionTitle: this.extractSectionTitle(part),
|
|
136
146
|
titlePath: this.extractSectionTitle(part),
|
|
137
147
|
kind: 'code',
|
|
138
|
-
parentId: `code-${language}
|
|
139
|
-
parentIndex:
|
|
140
|
-
childIndex:
|
|
148
|
+
parentId: `code-${language}-0`,
|
|
149
|
+
parentIndex: 0,
|
|
150
|
+
childIndex: index,
|
|
151
|
+
parentText: text,
|
|
141
152
|
};
|
|
142
153
|
});
|
|
143
154
|
}
|
|
@@ -308,6 +319,40 @@ export class TextChunker {
|
|
|
308
319
|
const lines = text.trim().split(/\r?\n/u);
|
|
309
320
|
return lines.length >= 3 && lines.some(line => /^\s*\|?\s*:?-{3,}:?\s*\|/u.test(line));
|
|
310
321
|
}
|
|
322
|
+
extractMarkdownTableBlocks(text) {
|
|
323
|
+
const lines = text.split(/\r?\n/u);
|
|
324
|
+
const lineStarts = [];
|
|
325
|
+
let cursor = 0;
|
|
326
|
+
for (const line of lines) {
|
|
327
|
+
lineStarts.push(cursor);
|
|
328
|
+
cursor += line.length + 1;
|
|
329
|
+
}
|
|
330
|
+
const blocks = [];
|
|
331
|
+
let index = 0;
|
|
332
|
+
while (index < lines.length) {
|
|
333
|
+
const separatorIndex = lines.findIndex((line, lineIndex) => lineIndex >= index && /^\s*\|?\s*:?-{3,}:?\s*\|/u.test(line));
|
|
334
|
+
if (separatorIndex <= index)
|
|
335
|
+
break;
|
|
336
|
+
const headerIndex = separatorIndex - 1;
|
|
337
|
+
let startLine = headerIndex;
|
|
338
|
+
const titleIndex = headerIndex - 1;
|
|
339
|
+
if (titleIndex >= 0 && lines[titleIndex]?.trim() === '' && titleIndex - 1 >= 0) {
|
|
340
|
+
const title = lines[titleIndex - 1]?.trim() ?? '';
|
|
341
|
+
if (title && title.length <= 120 && !/^\s*\|/u.test(title))
|
|
342
|
+
startLine = titleIndex - 1;
|
|
343
|
+
}
|
|
344
|
+
let endLine = separatorIndex + 1;
|
|
345
|
+
while (endLine < lines.length && /^\s*\|/u.test(lines[endLine] ?? ''))
|
|
346
|
+
endLine += 1;
|
|
347
|
+
const startChar = lineStarts[startLine] ?? 0;
|
|
348
|
+
const endChar = endLine < lineStarts.length ? (lineStarts[endLine] ?? text.length) : text.length;
|
|
349
|
+
const blockText = text.slice(startChar, endChar).trim();
|
|
350
|
+
if (blockText)
|
|
351
|
+
blocks.push({ text: blockText, startChar });
|
|
352
|
+
index = endLine;
|
|
353
|
+
}
|
|
354
|
+
return blocks;
|
|
355
|
+
}
|
|
311
356
|
extractMarkdownTableRowRange(text) {
|
|
312
357
|
const lines = text.trim().split(/\r?\n/u).filter(line => /^\s*\|/u.test(line));
|
|
313
358
|
const rowCount = Math.max(0, lines.length - 2);
|
|
@@ -395,6 +440,7 @@ export class TextChunker {
|
|
|
395
440
|
startChar: candidate.startChar,
|
|
396
441
|
endChar: candidate.endChar,
|
|
397
442
|
splitStrategy: 'recursive_parent_child_v2',
|
|
443
|
+
parentText: candidate.parentText, // <=== 原始父块内容
|
|
398
444
|
},
|
|
399
445
|
};
|
|
400
446
|
}
|
|
@@ -159,9 +159,14 @@ export class IndexStateStore {
|
|
|
159
159
|
}
|
|
160
160
|
const parentContents = [];
|
|
161
161
|
for (const [parentId, group] of parentGroups.entries()) {
|
|
162
|
-
const
|
|
162
|
+
const firstChunkMeta = group[0]?.metadata;
|
|
163
|
+
const parentContent = this.metadataString(firstChunkMeta?.parentText) || group.map(chunk => chunk.text).join('\n\n---\n\n');
|
|
163
164
|
parentContents.push(parentContent);
|
|
164
|
-
|
|
165
|
+
// 我们不想把一整个大文本冗余在每个切片的 metadata 里,所以存完 parent 之后清理一下
|
|
166
|
+
for (const chunk of group) {
|
|
167
|
+
delete chunk.metadata.parentText;
|
|
168
|
+
}
|
|
169
|
+
insertParent.run(parentId, relativePath, parentId, parentContent, file.category, file.format, file.collectionName, group.find(chunk => chunk.sectionTitle)?.sectionTitle ?? null, group.length, JSON.stringify({ parentId, splitStrategy: this.metadataString(firstChunkMeta?.splitStrategy), chunkKind: this.metadataString(firstChunkMeta?.chunkKind), titlePath: this.metadataString(firstChunkMeta?.titlePath) }), now);
|
|
165
170
|
}
|
|
166
171
|
insertDocument.run(`${relativePath}#document`, relativePath, parentContents.join('\n\n=== SECTION ===\n\n'), file.category, file.format, file.collectionName, parentGroups.size, groupedChunks.length, JSON.stringify({ parentType: 'document', splitStrategy: 'document_section_child_v1' }), now);
|
|
167
172
|
for (const chunk of groupedChunks) {
|
|
@@ -158,11 +158,11 @@ export declare class KnowledgeBaseManager {
|
|
|
158
158
|
private llmExpandQueries;
|
|
159
159
|
private retrievalWeights;
|
|
160
160
|
private heuristicRerank;
|
|
161
|
-
private llmRerank;
|
|
162
161
|
private hydrateVectorResultsFromSqlite;
|
|
163
162
|
private toFederatedItem;
|
|
164
163
|
private mergeHybridRankedLists;
|
|
165
164
|
private mergeContexts;
|
|
165
|
+
private mergeExpandedContexts;
|
|
166
166
|
private contextKey;
|
|
167
167
|
private parseChunkIndex;
|
|
168
168
|
private parseMetadataString;
|
|
@@ -7,6 +7,7 @@ import { ALL_CATEGORIES, DEFAULT_CATEGORY_DIRS, GLOBAL_KNOWLEDGE_DIR, USER_DATA_
|
|
|
7
7
|
import { DedupEngine } from '../dedup/dedup-engine.js';
|
|
8
8
|
import { RelationshipDetector } from '../dedup/relationship-detector.js';
|
|
9
9
|
import { createEmbeddingProviderFromEnvironment } from '../embedding/embedding-provider.js';
|
|
10
|
+
import { LocalReranker } from '../embedding/local-reranker.js';
|
|
10
11
|
import { ContentExtractor } from '../extraction/content-extractor.js';
|
|
11
12
|
import { FederationSearch } from '../search/federation-search.js';
|
|
12
13
|
import { CollectionManager } from '../vector/collection-manager.js';
|
|
@@ -350,20 +351,47 @@ export class KnowledgeBaseManager {
|
|
|
350
351
|
}
|
|
351
352
|
const keywordItems = rankedLists.filter(list => list.source === 'keyword').flatMap(list => list.items);
|
|
352
353
|
const vectorItems = rankedLists.filter(list => list.source === 'vector').flatMap(list => list.items);
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
354
|
+
// 1. 先进行初筛合并,合并相同的子块并计算混合初始分(不获取大片段,保留子块自身用于精确打分)
|
|
355
|
+
const mergedChildChunks = this.mergeContexts(this.mergeHybridRankedLists(rankedLists, limit * 4, weights), limit * 4);
|
|
356
|
+
// 2. 对这些子块进行交叉编码器重排(Cross-Encoder Rerank)
|
|
357
|
+
let reranked = mergedChildChunks;
|
|
358
|
+
let rerankerName = 'local-heuristic-fallback';
|
|
359
|
+
if (mergedChildChunks.length > 0) {
|
|
360
|
+
const candidates = mergedChildChunks.slice(0, Math.min(30, limit * 4));
|
|
361
|
+
// 这里使用的是子块自身内容,通常在 500 tokens 左右,不仅相关性判断最准,而且不会超出 Reranker 的 max_length
|
|
362
|
+
const textsToRerank = candidates.map(item => `${item.titlePath ?? item.sectionTitle ?? ''}\n${item.content}`);
|
|
363
|
+
try {
|
|
364
|
+
const scores = await LocalReranker.rerank(query, textsToRerank);
|
|
365
|
+
const usableScores = scores.length === candidates.length && scores.some(score => Number.isFinite(score) && score > 0);
|
|
366
|
+
if (usableScores) {
|
|
367
|
+
reranked = candidates.map((item, i) => ({
|
|
368
|
+
...item,
|
|
369
|
+
score: scores[i] ?? item.score,
|
|
370
|
+
scoreDetails: { ...item.scoreDetails, crossEncoderScore: scores[i] ?? 0 }
|
|
371
|
+
})).sort((a, b) => b.score - a.score);
|
|
372
|
+
rerankerName = 'bge-reranker-base';
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
reranked = this.heuristicRerank(query, mergedChildChunks);
|
|
376
|
+
rerankerName = 'local-heuristic-fallback-empty-rerank';
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
reranked = this.heuristicRerank(query, mergedChildChunks);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// 3. 拿到精确打分后的 Top 结果,此时再进行 expandContext 向上追溯到完整的父块大片段
|
|
384
|
+
const finalExpandedResults = this.mergeExpandedContexts(reranked.map(item => this.expandContext(item)), limit);
|
|
357
385
|
return {
|
|
358
|
-
results:
|
|
386
|
+
results: finalExpandedResults,
|
|
359
387
|
scopesSearched: this.scope === 'global' ? ['global'] : ['project'],
|
|
360
388
|
queryTimeMs: Date.now() - start,
|
|
361
389
|
debug: {
|
|
362
390
|
originalQuery: query,
|
|
363
391
|
rewrittenQueries,
|
|
364
392
|
weights,
|
|
365
|
-
recallCounts: { keyword: keywordItems.length, vector: vectorItems.length, merged:
|
|
366
|
-
reranker:
|
|
393
|
+
recallCounts: { keyword: keywordItems.length, vector: vectorItems.length, merged: mergedChildChunks.length },
|
|
394
|
+
reranker: rerankerName,
|
|
367
395
|
},
|
|
368
396
|
};
|
|
369
397
|
}
|
|
@@ -697,59 +725,6 @@ export class KnowledgeBaseManager {
|
|
|
697
725
|
};
|
|
698
726
|
}).sort((a, b) => b.score - a.score);
|
|
699
727
|
}
|
|
700
|
-
async llmRerank(query, items) {
|
|
701
|
-
if (!this.llmProvider || items.length === 0)
|
|
702
|
-
return this.heuristicRerank(query, items);
|
|
703
|
-
const candidates = items.slice(0, 20);
|
|
704
|
-
const resultsText = candidates.map((item, index) => {
|
|
705
|
-
const contentPreview = item.content.slice(0, 300).replace(/[\n\r]+/g, ' ');
|
|
706
|
-
return `[DOC_${index}] 路径: ${item.filePath} | 标题路径: ${item.titlePath ?? item.sectionTitle ?? ''} | 类型: ${item.chunkKind ?? 'text'}\n 内容: ${contentPreview}`;
|
|
707
|
-
}).join('\n\n');
|
|
708
|
-
const prompt = `你是一个文档相关性评估器。根据用户查询,为以下文档片段打分(1-10)。
|
|
709
|
-
1=完全不相关,10=高度相关。
|
|
710
|
-
输出格式:每行 "DOC_ID:分数",如 "DOC_0:8"
|
|
711
|
-
|
|
712
|
-
查询:${query}
|
|
713
|
-
|
|
714
|
-
${resultsText}
|
|
715
|
-
|
|
716
|
-
相关性评分:`;
|
|
717
|
-
try {
|
|
718
|
-
const response = await this.llmProvider.chat([
|
|
719
|
-
{ role: 'system', content: '你是一个精确的文档相关性评估器。只输出 DOC_ID:分数的列表。' },
|
|
720
|
-
{ role: 'user', content: prompt },
|
|
721
|
-
], { temperature: 0.1, maxTokens: 600 });
|
|
722
|
-
const scoreMap = new Map();
|
|
723
|
-
for (const line of response.content.split('\n')) {
|
|
724
|
-
const match = line.match(/DOC[_\s]*(\d+)[^\d]*(\d+)/i);
|
|
725
|
-
if (match)
|
|
726
|
-
scoreMap.set(Number(match[1]), Math.min(10, Math.max(1, Number(match[2]))));
|
|
727
|
-
}
|
|
728
|
-
if (scoreMap.size === 0)
|
|
729
|
-
return this.heuristicRerank(query, items);
|
|
730
|
-
return items.map((item, index) => {
|
|
731
|
-
const llmScore = scoreMap.get(index);
|
|
732
|
-
if (llmScore == null || llmScore === undefined)
|
|
733
|
-
return item;
|
|
734
|
-
// LLM 分数 (1-10) 映射为权重因子:10→2.0x, 5→1.0x, 1→0.2x
|
|
735
|
-
const llmFactor = 0.2 + (llmScore / 10) * 1.8;
|
|
736
|
-
const newScore = item.score * llmFactor;
|
|
737
|
-
return {
|
|
738
|
-
...item,
|
|
739
|
-
score: newScore,
|
|
740
|
-
scoreDetails: {
|
|
741
|
-
...item.scoreDetails,
|
|
742
|
-
rerankBoost: newScore - (item.scoreDetails?.hybridScore ?? item.score),
|
|
743
|
-
llmRelevanceScore: llmScore,
|
|
744
|
-
hybridScore: newScore,
|
|
745
|
-
},
|
|
746
|
-
};
|
|
747
|
-
}).sort((a, b) => b.score - a.score);
|
|
748
|
-
}
|
|
749
|
-
catch {
|
|
750
|
-
return this.heuristicRerank(query, items);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
728
|
hydrateVectorResultsFromSqlite(items) {
|
|
754
729
|
return items.map(item => {
|
|
755
730
|
if (!item.rowid)
|
|
@@ -846,6 +821,20 @@ ${resultsText}
|
|
|
846
821
|
}
|
|
847
822
|
return [...byKey.values()].sort((a, b) => b.score - a.score).slice(0, limit);
|
|
848
823
|
}
|
|
824
|
+
mergeExpandedContexts(items, limit) {
|
|
825
|
+
const byKey = new Map();
|
|
826
|
+
for (const item of items) {
|
|
827
|
+
const key = `${item.scope}:${item.filePath}:${item.parentId ?? item.chunkIndex ?? item.id}`;
|
|
828
|
+
const existing = byKey.get(key);
|
|
829
|
+
if (!existing || item.score > existing.score) {
|
|
830
|
+
byKey.set(key, item);
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
existing.source = existing.source === item.source ? existing.source : 'hybrid';
|
|
834
|
+
existing.scoreDetails = { ...existing.scoreDetails, ...item.scoreDetails, hybridScore: existing.score };
|
|
835
|
+
}
|
|
836
|
+
return [...byKey.values()].sort((a, b) => b.score - a.score).slice(0, limit);
|
|
837
|
+
}
|
|
849
838
|
contextKey(item) {
|
|
850
839
|
if (item.rowid)
|
|
851
840
|
return `${item.scope}:rowid:${item.rowid}`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class LocalReranker {
|
|
2
|
+
private static instance;
|
|
3
|
+
private static loadingPromise;
|
|
4
|
+
private static disabledUntil;
|
|
5
|
+
private static modelName;
|
|
6
|
+
static getInstance(): Promise<any>;
|
|
7
|
+
/**
|
|
8
|
+
* 对多条文本和查询进行相关性重排
|
|
9
|
+
*/
|
|
10
|
+
static rerank(query: string, texts: string[]): Promise<number[]>;
|
|
11
|
+
private static takeHeadTail;
|
|
12
|
+
private static extractScore;
|
|
13
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { pipeline } from '@huggingface/transformers';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export class LocalReranker {
|
|
6
|
+
static instance = null;
|
|
7
|
+
static loadingPromise = null;
|
|
8
|
+
static disabledUntil = 0;
|
|
9
|
+
static modelName = process.env.KB_RERANKER_MODEL || 'Xenova/bge-reranker-base';
|
|
10
|
+
static async getInstance() {
|
|
11
|
+
if (this.instance)
|
|
12
|
+
return this.instance;
|
|
13
|
+
if (Date.now() < this.disabledUntil)
|
|
14
|
+
throw new Error('Local reranker is temporarily disabled after load failure');
|
|
15
|
+
if (process.env.KB_ENABLE_LOCAL_RERANKER === 'false')
|
|
16
|
+
throw new Error('Local reranker is disabled');
|
|
17
|
+
// 使用 loadingPromise 防止高并发下的重复加载
|
|
18
|
+
if (this.loadingPromise)
|
|
19
|
+
return this.loadingPromise;
|
|
20
|
+
this.loadingPromise = (async () => {
|
|
21
|
+
const cacheDir = process.env.TRANSFORMERS_CACHE || path.join(os.homedir(), '.customize-agent', 'models');
|
|
22
|
+
if (!fs.existsSync(cacheDir)) {
|
|
23
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
process.env.TRANSFORMERS_CACHE = cacheDir;
|
|
26
|
+
const pipe = await pipeline('text-classification', this.modelName, {
|
|
27
|
+
dtype: 'q8',
|
|
28
|
+
});
|
|
29
|
+
this.instance = pipe;
|
|
30
|
+
return pipe;
|
|
31
|
+
})().catch(error => {
|
|
32
|
+
this.loadingPromise = null;
|
|
33
|
+
this.disabledUntil = Date.now() + 60_000;
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
36
|
+
return this.loadingPromise;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 对多条文本和查询进行相关性重排
|
|
40
|
+
*/
|
|
41
|
+
static async rerank(query, texts) {
|
|
42
|
+
if (!texts.length)
|
|
43
|
+
return [];
|
|
44
|
+
const ranker = await this.getInstance();
|
|
45
|
+
const scores = [];
|
|
46
|
+
const safeQuery = this.takeHeadTail(query, 240);
|
|
47
|
+
for (const text of texts) {
|
|
48
|
+
try {
|
|
49
|
+
const safeText = this.takeHeadTail(text, 1400);
|
|
50
|
+
const out = await ranker({ text: safeQuery, text_pair: safeText });
|
|
51
|
+
scores.push(this.extractScore(out));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
scores.push(0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return scores;
|
|
58
|
+
}
|
|
59
|
+
static takeHeadTail(text, maxLength) {
|
|
60
|
+
if (text.length <= maxLength)
|
|
61
|
+
return text;
|
|
62
|
+
const headLength = Math.ceil(maxLength * 0.65);
|
|
63
|
+
const tailLength = maxLength - headLength;
|
|
64
|
+
return `${text.slice(0, headLength)}\n...\n${text.slice(-tailLength)}`;
|
|
65
|
+
}
|
|
66
|
+
static extractScore(output) {
|
|
67
|
+
const first = Array.isArray(output) ? output[0] : output;
|
|
68
|
+
if (Array.isArray(first))
|
|
69
|
+
return this.extractScore(first);
|
|
70
|
+
const score = Number(first?.score ?? 0);
|
|
71
|
+
return Number.isFinite(score) ? score : 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -776,8 +776,13 @@ export class ContentExtractor {
|
|
|
776
776
|
}
|
|
777
777
|
if (matrix.length > 0) {
|
|
778
778
|
const header = matrix[0] ?? [];
|
|
779
|
-
|
|
780
|
-
|
|
779
|
+
// 表格分页:为了防止超大 Excel 导致单块过大,将其每 500 行分为一个独立的 Markdown Table。
|
|
780
|
+
const chunkSize = 500;
|
|
781
|
+
for (let i = 1; i < matrix.length; i += chunkSize) {
|
|
782
|
+
const rows = matrix.slice(i, i + chunkSize);
|
|
783
|
+
const sheetSuffix = matrix.length > chunkSize ? ` (第 ${Math.floor(i / chunkSize) + 1} 部分)` : '';
|
|
784
|
+
sheetTexts.push([`工作表:${name}${sheetSuffix}`, this.toMarkdownTable(header, rows)].join('\n\n'));
|
|
785
|
+
}
|
|
781
786
|
}
|
|
782
787
|
}
|
|
783
788
|
if (sheetTexts.length > 0) {
|
|
@@ -804,17 +809,47 @@ export class ContentExtractor {
|
|
|
804
809
|
const docXml = await zip.files['word/document.xml']?.async('string');
|
|
805
810
|
if (!docXml)
|
|
806
811
|
return '';
|
|
807
|
-
|
|
812
|
+
// 我们需要按出现顺序提取 paragraph 和 table
|
|
813
|
+
// 在 xml 中 w:body 的一级子节点通常是 w:p 和 w:tbl
|
|
814
|
+
const elements = Array.from(docXml.matchAll(/<(w:p|w:tbl)[\s>][\s\S]*?<\/\1>/gu), match => ({ tag: match[1], xml: match[0] }));
|
|
808
815
|
const lines = [];
|
|
809
|
-
for (const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
816
|
+
for (const { tag, xml } of elements) {
|
|
817
|
+
if (tag === 'w:p') {
|
|
818
|
+
const texts = Array.from(xml.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/gu), match => this.stripXml(match[1] ?? '')).join('');
|
|
819
|
+
if (!texts.trim())
|
|
820
|
+
continue;
|
|
821
|
+
const style = /<w:pStyle\s+w:val="([^"]+)"/u.exec(xml)?.[1] ?? '';
|
|
822
|
+
const bold = /<w:b\b/u.test(xml);
|
|
823
|
+
const size = Number(/<w:sz\s+w:val="(\d+)"/u.exec(xml)?.[1] ?? 0);
|
|
824
|
+
const level = this.docxHeadingLevel(style, bold, size, texts);
|
|
825
|
+
lines.push(`${level > 0 ? `${'#'.repeat(level)} ` : ''}${texts.trim()}`);
|
|
826
|
+
}
|
|
827
|
+
else if (tag === 'w:tbl') {
|
|
828
|
+
const trList = Array.from(xml.matchAll(/<w:tr[\s>][\s\S]*?<\/w:tr>/gu), match => match[0]);
|
|
829
|
+
const rows = [];
|
|
830
|
+
for (const tr of trList) {
|
|
831
|
+
const tcList = Array.from(tr.matchAll(/<w:tc[\s>][\s\S]*?<\/w:tc>/gu), match => match[0]);
|
|
832
|
+
const cols = [];
|
|
833
|
+
for (const tc of tcList) {
|
|
834
|
+
const tcText = Array.from(tc.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/gu), match => this.stripXml(match[1] ?? '')).join('');
|
|
835
|
+
cols.push(tcText.trim().replace(/\r?\n/gu, ' '));
|
|
836
|
+
}
|
|
837
|
+
rows.push(cols);
|
|
838
|
+
}
|
|
839
|
+
if (rows.length > 0) {
|
|
840
|
+
const maxCols = Math.max(...rows.map(r => r.length));
|
|
841
|
+
// 补齐列数
|
|
842
|
+
rows.forEach(r => { while (r.length < maxCols)
|
|
843
|
+
r.push(''); });
|
|
844
|
+
const header = rows[0] || Array(maxCols).fill('');
|
|
845
|
+
const mdTable = [
|
|
846
|
+
`| ${header.join(' | ')} |`,
|
|
847
|
+
`| ${header.map(() => '---').join(' | ')} |`,
|
|
848
|
+
...rows.slice(1).map(row => `| ${row.join(' | ')} |`)
|
|
849
|
+
].join('\n');
|
|
850
|
+
lines.push(mdTable);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
818
853
|
}
|
|
819
854
|
return lines.join('\n\n');
|
|
820
855
|
}
|
|
@@ -1065,7 +1100,9 @@ export class ContentExtractor {
|
|
|
1065
1100
|
width: 0, height: 0, channels: 0,
|
|
1066
1101
|
filePath: imgPath,
|
|
1067
1102
|
});
|
|
1068
|
-
const ocrText = ocrResult.text
|
|
1103
|
+
const ocrText = this.cleanOcrText(ocrResult.text);
|
|
1104
|
+
if (ocrResult.warnings?.length)
|
|
1105
|
+
warnings.push(...ocrResult.warnings.map(item => `OCR 警告: ${item}`));
|
|
1069
1106
|
if (ocrText) {
|
|
1070
1107
|
ocrPages.push(i + 1);
|
|
1071
1108
|
ocrStrategies.push({ page: i + 1, strategy: renderer, score: this.scoreOcrText(ocrText) });
|
|
@@ -1079,6 +1116,8 @@ export class ContentExtractor {
|
|
|
1079
1116
|
failedPages.push({ page: i + 1, reason: error instanceof Error ? error.message : String(error) });
|
|
1080
1117
|
}
|
|
1081
1118
|
}
|
|
1119
|
+
if (ocrProvider)
|
|
1120
|
+
await ocrProvider.dispose();
|
|
1082
1121
|
// 清理临时文件
|
|
1083
1122
|
try {
|
|
1084
1123
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
@@ -1164,6 +1203,15 @@ export class ContentExtractor {
|
|
|
1164
1203
|
const replacementCount = (text.match(/[�□]/gu) ?? []).length;
|
|
1165
1204
|
return cjkCount * 8 + normalizedLength - latinCount * 0.8 - replacementCount * 10;
|
|
1166
1205
|
}
|
|
1206
|
+
cleanOcrText(value) {
|
|
1207
|
+
return String(value ?? '')
|
|
1208
|
+
.replace(/[ \t]+/gu, ' ')
|
|
1209
|
+
.replace(/([\p{Script=Han}])\s+([\p{Script=Han}])/gu, '$1$2')
|
|
1210
|
+
.replace(/([\p{Script=Han}])\s+([,。;:!?、)】》])/gu, '$1$2')
|
|
1211
|
+
.replace(/([(【《])\s+([\p{Script=Han}])/gu, '$1$2')
|
|
1212
|
+
.replace(/\n{3,}/gu, '\n\n')
|
|
1213
|
+
.trim();
|
|
1214
|
+
}
|
|
1167
1215
|
/** 加载图片像素数据(依赖 sharp) */
|
|
1168
1216
|
async loadImagePixels(filePath) {
|
|
1169
1217
|
const sharpMod = await resolveAndImport('sharp');
|
|
@@ -18,6 +18,7 @@ export interface OcrResult {
|
|
|
18
18
|
text: string;
|
|
19
19
|
confidence: number;
|
|
20
20
|
regions: OcrRegion[];
|
|
21
|
+
warnings?: string[];
|
|
21
22
|
}
|
|
22
23
|
export interface OcrProvider {
|
|
23
24
|
readonly id: string;
|
|
@@ -29,11 +30,15 @@ export interface OcrProvider {
|
|
|
29
30
|
channels?: number;
|
|
30
31
|
filePath?: string;
|
|
31
32
|
}): Promise<OcrResult>;
|
|
33
|
+
getWarnings?(): string[];
|
|
32
34
|
dispose(): Promise<void>;
|
|
33
35
|
}
|
|
34
36
|
export declare class TesseractJsProvider implements OcrProvider {
|
|
35
37
|
readonly id = "tesseract.js";
|
|
36
38
|
private _available;
|
|
39
|
+
private worker;
|
|
40
|
+
private workerPromise;
|
|
41
|
+
private warnings;
|
|
37
42
|
get available(): boolean;
|
|
38
43
|
recognize(input: {
|
|
39
44
|
data: Uint8Array;
|
|
@@ -42,6 +47,9 @@ export declare class TesseractJsProvider implements OcrProvider {
|
|
|
42
47
|
channels?: number;
|
|
43
48
|
filePath?: string;
|
|
44
49
|
}): Promise<OcrResult>;
|
|
50
|
+
getWarnings(): string[];
|
|
51
|
+
private getWorker;
|
|
52
|
+
private createReusableWorker;
|
|
45
53
|
dispose(): Promise<void>;
|
|
46
54
|
}
|
|
47
55
|
export declare function createOcrProvider(): Promise<OcrProvider>;
|
|
@@ -23,6 +23,9 @@ function tessdataDir() {
|
|
|
23
23
|
export class TesseractJsProvider {
|
|
24
24
|
id = 'tesseract.js';
|
|
25
25
|
_available = null;
|
|
26
|
+
worker = null;
|
|
27
|
+
workerPromise = null;
|
|
28
|
+
warnings = [];
|
|
26
29
|
get available() {
|
|
27
30
|
if (this._available !== null)
|
|
28
31
|
return this._available;
|
|
@@ -39,8 +42,6 @@ export class TesseractJsProvider {
|
|
|
39
42
|
return this._available;
|
|
40
43
|
}
|
|
41
44
|
async recognize(input) {
|
|
42
|
-
const tessMod = await resolveAndImport('tesseract.js');
|
|
43
|
-
const { createWorker } = tessMod;
|
|
44
45
|
let pngPath;
|
|
45
46
|
let tmpDir = null;
|
|
46
47
|
// 如果传了 filePath,直接使用;否则 raw pixels → PNG
|
|
@@ -61,39 +62,66 @@ export class TesseractJsProvider {
|
|
|
61
62
|
.png().toFile(pngPath);
|
|
62
63
|
}
|
|
63
64
|
try {
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
width: (l.bbox?.x1 ?? 0) - (l.bbox?.x0 ?? 0),
|
|
82
|
-
height: (l.bbox?.y1 ?? 0) - (l.bbox?.y0 ?? 0),
|
|
83
|
-
},
|
|
84
|
-
}));
|
|
85
|
-
return { text, confidence: result.data.confidence ?? 0, regions };
|
|
86
|
-
}
|
|
87
|
-
finally {
|
|
88
|
-
await worker.terminate();
|
|
89
|
-
}
|
|
65
|
+
const worker = await this.getWorker();
|
|
66
|
+
const result = await worker.recognize(pngPath);
|
|
67
|
+
const text = (result.data.text ?? '').trim();
|
|
68
|
+
const lines = (result.data.lines ?? []);
|
|
69
|
+
const regions = lines
|
|
70
|
+
.filter((l) => l.text?.trim())
|
|
71
|
+
.map((l) => ({
|
|
72
|
+
text: l.text.trim(),
|
|
73
|
+
confidence: l.confidence ?? 0,
|
|
74
|
+
box: {
|
|
75
|
+
x: l.bbox?.x0 ?? 0,
|
|
76
|
+
y: l.bbox?.y0 ?? 0,
|
|
77
|
+
width: (l.bbox?.x1 ?? 0) - (l.bbox?.x0 ?? 0),
|
|
78
|
+
height: (l.bbox?.y1 ?? 0) - (l.bbox?.y0 ?? 0),
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
return { text, confidence: result.data.confidence ?? 0, regions, warnings: this.getWarnings() };
|
|
90
82
|
}
|
|
91
83
|
finally {
|
|
92
84
|
if (tmpDir)
|
|
93
85
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
94
86
|
}
|
|
95
87
|
}
|
|
96
|
-
|
|
88
|
+
getWarnings() {
|
|
89
|
+
return [...new Set(this.warnings)].slice(-20);
|
|
90
|
+
}
|
|
91
|
+
async getWorker() {
|
|
92
|
+
if (this.worker)
|
|
93
|
+
return this.worker;
|
|
94
|
+
if (!this.workerPromise)
|
|
95
|
+
this.workerPromise = this.createReusableWorker();
|
|
96
|
+
this.worker = await this.workerPromise;
|
|
97
|
+
return this.worker;
|
|
98
|
+
}
|
|
99
|
+
async createReusableWorker() {
|
|
100
|
+
const tessMod = await resolveAndImport('tesseract.js');
|
|
101
|
+
const { createWorker, OEM, setLogging } = tessMod;
|
|
102
|
+
if (typeof setLogging === 'function')
|
|
103
|
+
setLogging(false);
|
|
104
|
+
const worker = await createWorker('chi_sim', OEM?.LSTM_ONLY ?? 1, {
|
|
105
|
+
langPath: tessdataDir(),
|
|
106
|
+
gzip: false,
|
|
107
|
+
logger: () => undefined,
|
|
108
|
+
errorHandler: (error) => {
|
|
109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
110
|
+
if (message.trim())
|
|
111
|
+
this.warnings.push(message.trim());
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
if (typeof worker.setParameters === 'function') {
|
|
115
|
+
await worker.setParameters({ preserve_interword_spaces: '0', user_defined_dpi: '300' });
|
|
116
|
+
}
|
|
117
|
+
return worker;
|
|
118
|
+
}
|
|
119
|
+
async dispose() {
|
|
120
|
+
if (this.worker)
|
|
121
|
+
await this.worker.terminate();
|
|
122
|
+
this.worker = null;
|
|
123
|
+
this.workerPromise = null;
|
|
124
|
+
}
|
|
97
125
|
}
|
|
98
126
|
// ─── 工厂 ───────────────────────────────────────────────────────
|
|
99
127
|
export async function createOcrProvider() {
|