@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.
@@ -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
- return this.splitMarkdownTable(text, config.maxChunkSize).map((part, index) => {
90
- const startChar = Math.max(0, text.indexOf(part.slice(0, 40)));
91
- return {
92
- text: part,
93
- startChar,
94
- endChar: startChar + part.length,
95
- sectionTitle: this.extractSectionTitle(part) ?? '表格数据',
96
- titlePath: this.extractSectionTitle(part) ?? '表格数据',
97
- kind: 'table',
98
- parentId: `table-${index}`,
99
- parentIndex: index,
100
- childIndex: 0,
101
- rowRange: this.extractMarkdownTableRowRange(part),
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-${index}`,
118
- parentIndex: index,
119
- childIndex: 0,
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}-${index}`,
139
- parentIndex: index,
140
- childIndex: 0,
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 parentContent = group.map(chunk => chunk.text).join('\n\n---\n\n');
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
- 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(group[0]?.metadata.splitStrategy), chunkKind: this.metadataString(group[0]?.metadata.chunkKind), titlePath: this.metadataString(group[0]?.metadata.titlePath) }), now);
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
- const merged = this.mergeContexts(this.mergeHybridRankedLists(rankedLists, limit * 4, weights).map(item => this.expandContext(item)), limit * 2);
354
- const useLLMRerank = !!this.llmProvider;
355
- const preReranked = useLLMRerank ? merged : this.heuristicRerank(query, merged);
356
- const reranked = useLLMRerank ? (await this.llmRerank(query, preReranked)) : preReranked;
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: reranked.slice(0, limit),
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: merged.length },
366
- reranker: useLLMRerank ? 'llm-semantic-reranker-v1' : 'local-statistical-reranker-v1',
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
- const rows = matrix.slice(1);
780
- sheetTexts.push([`工作表:${name}`, this.toMarkdownTable(header, rows)].join('\n\n'));
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
- const paragraphs = Array.from(docXml.matchAll(/<w:p[\s\S]*?<\/w:p>/gu), match => match[0]);
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 paragraph of paragraphs) {
810
- const texts = Array.from(paragraph.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/gu), match => this.stripXml(match[1] ?? '')).join('');
811
- if (!texts.trim())
812
- continue;
813
- const style = /<w:pStyle\s+w:val="([^"]+)"/u.exec(paragraph)?.[1] ?? '';
814
- const bold = /<w:b\b/u.test(paragraph);
815
- const size = Number(/<w:sz\s+w:val="(\d+)"/u.exec(paragraph)?.[1] ?? 0);
816
- const level = this.docxHeadingLevel(style, bold, size, texts);
817
- lines.push(`${level > 0 ? `${'#'.repeat(level)} ` : ''}${texts.trim()}`);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@customize-agent/knowledge",
3
- "version": "4.0.16",
3
+ "version": "4.0.18",
4
4
  "description": "Local knowledge base infrastructure for customize-agent",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",