@customize-agent/knowledge 4.0.16 → 4.0.18
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 +2 -0
- package/dist/extraction/content-extractor.js +76 -13
- package/dist/extraction/ocr-providers.d.ts +2 -0
- package/dist/extraction/ocr-providers.js +24 -0
- 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
|
+
}
|
|
@@ -60,6 +60,8 @@ export declare class ContentExtractor {
|
|
|
60
60
|
private cleanOcrText;
|
|
61
61
|
/** 加载图片像素数据(依赖 sharp) */
|
|
62
62
|
private loadImagePixels;
|
|
63
|
+
private readImageDimensions;
|
|
64
|
+
private isTooSmallForOcr;
|
|
63
65
|
private extractPdfText;
|
|
64
66
|
private normalizedTextLength;
|
|
65
67
|
private toPdfTextItem;
|
|
@@ -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
|
}
|
|
@@ -883,6 +918,12 @@ export class ContentExtractor {
|
|
|
883
918
|
let imageData;
|
|
884
919
|
try {
|
|
885
920
|
imageData = await this.loadImagePixels(file.absolutePath);
|
|
921
|
+
if (this.isTooSmallForOcr(imageData.width, imageData.height)) {
|
|
922
|
+
metadata.contentCoverage = 'image_too_small_for_ocr';
|
|
923
|
+
metadata.imageWidth = imageData.width;
|
|
924
|
+
metadata.imageHeight = imageData.height;
|
|
925
|
+
return { text: this.metadataOnlyText(file), metadata, warnings: [`图片尺寸过小(${imageData.width}x${imageData.height}),已跳过 OCR 并仅索引元数据`] };
|
|
926
|
+
}
|
|
886
927
|
}
|
|
887
928
|
catch (e) {
|
|
888
929
|
metadata.contentCoverage = 'image_decode_failed';
|
|
@@ -1059,10 +1100,16 @@ export class ContentExtractor {
|
|
|
1059
1100
|
for (let i = 0; i < pageImages.length; i++) {
|
|
1060
1101
|
const imgPath = pageImages[i];
|
|
1061
1102
|
try {
|
|
1103
|
+
const dimensions = await this.readImageDimensions(imgPath);
|
|
1104
|
+
if (dimensions && this.isTooSmallForOcr(dimensions.width, dimensions.height)) {
|
|
1105
|
+
failedPages.push({ page: i + 1, reason: `image_too_small_for_ocr_${dimensions.width}x${dimensions.height}` });
|
|
1106
|
+
warnings.push(`PDF 第 ${i + 1} 页渲染图片尺寸过小(${dimensions.width}x${dimensions.height}),已跳过 OCR`);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1062
1109
|
const provider = await getOcrProvider();
|
|
1063
1110
|
const ocrResult = await provider.recognize({
|
|
1064
1111
|
data: new Uint8Array(0),
|
|
1065
|
-
width: 0, height: 0, channels: 0,
|
|
1112
|
+
width: dimensions?.width ?? 0, height: dimensions?.height ?? 0, channels: 0,
|
|
1066
1113
|
filePath: imgPath,
|
|
1067
1114
|
});
|
|
1068
1115
|
const ocrText = this.cleanOcrText(ocrResult.text);
|
|
@@ -1184,6 +1231,22 @@ export class ContentExtractor {
|
|
|
1184
1231
|
const { data, info } = await sharpFn(filePath).raw().toBuffer({ resolveWithObject: true });
|
|
1185
1232
|
return { data: new Uint8Array(data), width: info.width, height: info.height };
|
|
1186
1233
|
}
|
|
1234
|
+
async readImageDimensions(filePath) {
|
|
1235
|
+
try {
|
|
1236
|
+
const sharpMod = await resolveAndImport('sharp');
|
|
1237
|
+
const sharpFn = sharpMod.default ?? sharpMod;
|
|
1238
|
+
const metadata = await sharpFn(filePath).metadata();
|
|
1239
|
+
const width = Number(metadata.width ?? 0);
|
|
1240
|
+
const height = Number(metadata.height ?? 0);
|
|
1241
|
+
return width > 0 && height > 0 ? { width, height } : undefined;
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
return undefined;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
isTooSmallForOcr(width, height) {
|
|
1248
|
+
return width < 8 || height < 8 || width * height < 128;
|
|
1249
|
+
}
|
|
1187
1250
|
async extractPdfText(buffer) {
|
|
1188
1251
|
let pdfjsText = '';
|
|
1189
1252
|
// 第一层:pdfjs-dist 文本提取(处理压缩内容流、CJK 字体、现代 PDF)
|
|
@@ -48,6 +48,8 @@ export declare class TesseractJsProvider implements OcrProvider {
|
|
|
48
48
|
filePath?: string;
|
|
49
49
|
}): Promise<OcrResult>;
|
|
50
50
|
getWarnings(): string[];
|
|
51
|
+
private readImageDimensions;
|
|
52
|
+
private isTooSmallForOcr;
|
|
51
53
|
private getWorker;
|
|
52
54
|
private createReusableWorker;
|
|
53
55
|
dispose(): Promise<void>;
|
|
@@ -44,9 +44,14 @@ export class TesseractJsProvider {
|
|
|
44
44
|
async recognize(input) {
|
|
45
45
|
let pngPath;
|
|
46
46
|
let tmpDir = null;
|
|
47
|
+
let width = input.width;
|
|
48
|
+
let height = input.height;
|
|
47
49
|
// 如果传了 filePath,直接使用;否则 raw pixels → PNG
|
|
48
50
|
if (input.filePath && fs.existsSync(input.filePath)) {
|
|
49
51
|
pngPath = input.filePath;
|
|
52
|
+
const dimensions = await this.readImageDimensions(pngPath);
|
|
53
|
+
width = dimensions?.width ?? width;
|
|
54
|
+
height = dimensions?.height ?? height;
|
|
50
55
|
}
|
|
51
56
|
else {
|
|
52
57
|
const sharpMod = await resolveAndImport('sharp');
|
|
@@ -61,6 +66,9 @@ export class TesseractJsProvider {
|
|
|
61
66
|
.withMetadata({ density: 288 })
|
|
62
67
|
.png().toFile(pngPath);
|
|
63
68
|
}
|
|
69
|
+
if (this.isTooSmallForOcr(width, height)) {
|
|
70
|
+
return { text: '', confidence: 0, regions: [], warnings: [`image too small for OCR: ${width}x${height}`] };
|
|
71
|
+
}
|
|
64
72
|
try {
|
|
65
73
|
const worker = await this.getWorker();
|
|
66
74
|
const result = await worker.recognize(pngPath);
|
|
@@ -88,6 +96,22 @@ export class TesseractJsProvider {
|
|
|
88
96
|
getWarnings() {
|
|
89
97
|
return [...new Set(this.warnings)].slice(-20);
|
|
90
98
|
}
|
|
99
|
+
async readImageDimensions(filePath) {
|
|
100
|
+
try {
|
|
101
|
+
const sharpMod = await resolveAndImport('sharp');
|
|
102
|
+
const sharpFn = sharpMod.default ?? sharpMod;
|
|
103
|
+
const metadata = await sharpFn(filePath).metadata();
|
|
104
|
+
const width = Number(metadata.width ?? 0);
|
|
105
|
+
const height = Number(metadata.height ?? 0);
|
|
106
|
+
return width > 0 && height > 0 ? { width, height } : undefined;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
isTooSmallForOcr(width, height) {
|
|
113
|
+
return width < 8 || height < 8 || width * height < 128;
|
|
114
|
+
}
|
|
91
115
|
async getWorker() {
|
|
92
116
|
if (this.worker)
|
|
93
117
|
return this.worker;
|