@customize-agent/knowledge 4.0.36 → 4.0.38

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.
@@ -28,7 +28,7 @@ export declare class TextChunker {
28
28
  chunk(text: string, file: ClassifiedFile, metadata?: Record<string, unknown>): TextChunk[];
29
29
  private createCandidates;
30
30
  private createTextCandidates;
31
- private createTableCandidates;
31
+ private createMixedTableCandidates;
32
32
  private createDataCandidates;
33
33
  private createCodeCandidates;
34
34
  private normalizeCodeLanguage;
@@ -44,6 +44,7 @@ export declare class TextChunker {
44
44
  private splitByWindow;
45
45
  private splitBySentenceBoundary;
46
46
  private isMarkdownTable;
47
+ private hasMarkdownTableBlock;
47
48
  private extractMarkdownTableBlocks;
48
49
  private extractMarkdownTableRowRange;
49
50
  private splitMarkdownTable;
@@ -50,8 +50,8 @@ export class TextChunker {
50
50
  return candidates.map((candidate, index) => this.createChunk(index, candidate, file, metadata));
51
51
  }
52
52
  createCandidates(text, file, config) {
53
- if (file.category === 'spreadsheet')
54
- return this.createTableCandidates(text, config);
53
+ if (file.category === 'spreadsheet' || this.hasMarkdownTableBlock(text))
54
+ return this.createMixedTableCandidates(text, file.category, config);
55
55
  if (file.category === 'data')
56
56
  return this.createDataCandidates(text, config);
57
57
  if (file.category === 'code')
@@ -85,33 +85,52 @@ export class TextChunker {
85
85
  });
86
86
  return candidates;
87
87
  }
88
- createTableCandidates(text, config) {
89
- if (this.isMarkdownTable(text)) {
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
- });
88
+ createMixedTableCandidates(text, category, config) {
89
+ const tableBlocks = this.extractMarkdownTableBlocks(text);
90
+ if (tableBlocks.length === 0)
91
+ return this.createTextCandidates(text, category, config);
92
+ const candidates = [];
93
+ let cursor = 0;
94
+ tableBlocks.forEach((block, parentIndex) => {
95
+ const beforeStart = cursor;
96
+ const before = text.slice(beforeStart, block.startChar).trim();
97
+ if (before) {
98
+ candidates.push(...this.createTextCandidates(before, category, config).map(candidate => ({
99
+ ...candidate,
100
+ startChar: candidate.startChar + beforeStart,
101
+ endChar: candidate.endChar + beforeStart,
102
+ })));
103
+ }
104
+ const sectionTitle = this.extractSectionTitle(block.text) ?? '表格数据';
105
+ for (const [childIndex, part] of this.splitMarkdownTable(block.text, config.maxChunkSize).entries()) {
106
+ const localStart = Math.max(0, block.text.indexOf(part.slice(0, 40)));
107
+ const startChar = block.startChar + localStart;
108
+ candidates.push({
109
+ text: part,
110
+ startChar,
111
+ endChar: startChar + part.length,
112
+ sectionTitle,
113
+ titlePath: sectionTitle,
114
+ kind: 'table',
115
+ parentId: `table-${parentIndex}`,
116
+ parentIndex,
117
+ childIndex,
118
+ rowRange: this.extractMarkdownTableRowRange(part),
119
+ parentText: block.text,
111
120
  });
112
121
  }
122
+ cursor = block.startChar + block.text.length;
123
+ });
124
+ const afterStart = cursor;
125
+ const after = text.slice(afterStart).trim();
126
+ if (after) {
127
+ candidates.push(...this.createTextCandidates(after, category, config).map(candidate => ({
128
+ ...candidate,
129
+ startChar: candidate.startChar + afterStart,
130
+ endChar: candidate.endChar + afterStart,
131
+ })));
113
132
  }
114
- return this.createTextCandidates(text, 'spreadsheet', config);
133
+ return candidates;
115
134
  }
116
135
  createDataCandidates(text, config) {
117
136
  const sections = text.split(/\n(?=[\w.[\]-]+[::]\s)|\n{2,}/u).map(part => part.trim()).filter(Boolean);
@@ -331,6 +350,9 @@ export class TextChunker {
331
350
  const lines = text.trim().split(/\r?\n/u);
332
351
  return lines.length >= 3 && lines.some(line => /^\s*\|?\s*:?-{3,}:?\s*\|/u.test(line));
333
352
  }
353
+ hasMarkdownTableBlock(text) {
354
+ return /^\s*\|?\s*:?-{3,}:?\s*\|/mu.test(text);
355
+ }
334
356
  extractMarkdownTableBlocks(text) {
335
357
  const lines = text.split(/\r?\n/u);
336
358
  const lineStarts = [];
@@ -41,9 +41,15 @@ export declare class ContentExtractor {
41
41
  private extractRtf;
42
42
  private extractLegacyWordDocument;
43
43
  private extractLegacyOfficeBinary;
44
+ private extractLegacyOfficeBinaryWithWarnings;
45
+ private isZipOpenXmlFile;
46
+ private isOleCompoundFile;
47
+ private extractOleOfficeDocument;
44
48
  private findMergedCellValue;
45
49
  private extractSpreadsheet;
46
50
  private extractDocxStyleTreeMarkdown;
51
+ private extractDocxTableRows;
52
+ private extractDocxCellText;
47
53
  private docxHeadingLevel;
48
54
  private extractOfficeZip;
49
55
  private extractRasterImage;
@@ -653,6 +653,10 @@ export class ContentExtractor {
653
653
  }
654
654
  async extractOfficeDocument(file) {
655
655
  const ext = path.extname(file.absolutePath).toLowerCase();
656
+ const isZip = this.isZipOpenXmlFile(file.absolutePath);
657
+ const isOle = this.isOleCompoundFile(file.absolutePath);
658
+ if (isOle)
659
+ return this.extractOleOfficeDocument(file, ext === '.docx' ? ['文件扩展名为 .docx,但真实格式为旧版 OLE/CFB Office 复合文档,已按旧版 Office 解析'] : []);
656
660
  if (ext === '.rtf')
657
661
  return this.extractRtf(file);
658
662
  if (ext === '.doc')
@@ -660,6 +664,8 @@ export class ContentExtractor {
660
664
  if (ext === '.ppt')
661
665
  return this.extractLegacyOfficeBinary(file);
662
666
  if (ext === '.docx') {
667
+ if (!isZip)
668
+ return this.extractLegacyOfficeBinaryWithWarnings(file, ['文件扩展名为 .docx,但未检测到 OpenXML ZIP 文件头,已降级为二进制可读文本抽取']);
663
669
  try {
664
670
  const styledMarkdown = await this.extractDocxStyleTreeMarkdown(file.absolutePath);
665
671
  if (styledMarkdown.trim()) {
@@ -732,6 +738,37 @@ export class ContentExtractor {
732
738
  warnings: text ? [] : ['旧版 Office 二进制文件未提取到正文,未入库'],
733
739
  };
734
740
  }
741
+ extractLegacyOfficeBinaryWithWarnings(file, warnings) {
742
+ const result = this.extractLegacyOfficeBinary(file);
743
+ return { ...result, warnings: [...warnings, ...result.warnings] };
744
+ }
745
+ isZipOpenXmlFile(filePath) {
746
+ const header = fs.readFileSync(filePath).subarray(0, 4);
747
+ return header.length >= 4 && header[0] === 0x50 && header[1] === 0x4b && [0x03, 0x05, 0x07].includes(header[2] ?? -1);
748
+ }
749
+ isOleCompoundFile(filePath) {
750
+ const signature = fs.readFileSync(filePath).subarray(0, 8);
751
+ return signature.equals(Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]));
752
+ }
753
+ async extractOleOfficeDocument(file, warnings = []) {
754
+ const word = await this.extractLegacyWordDocument(file);
755
+ if (word.text.trim() && word.metadata.extractionMode === 'builtin_word_extractor') {
756
+ return {
757
+ text: word.text,
758
+ metadata: { ...word.metadata, realOfficeContainer: 'ole_cfb' },
759
+ warnings: [...warnings, ...word.warnings],
760
+ };
761
+ }
762
+ const spreadsheet = await this.extractSpreadsheet(file);
763
+ if (spreadsheet.text.trim() && spreadsheet.metadata.extractionMode !== 'office_zip_failed') {
764
+ return {
765
+ text: spreadsheet.text,
766
+ metadata: { ...spreadsheet.metadata, realOfficeContainer: 'ole_cfb' },
767
+ warnings: [...warnings, ...spreadsheet.warnings],
768
+ };
769
+ }
770
+ return this.extractLegacyOfficeBinaryWithWarnings(file, warnings);
771
+ }
735
772
  findMergedCellValue(sheet, merges, row, col, XLSX) {
736
773
  const merge = merges.find(item => row >= item.s.r && row <= item.e.r && col >= item.s.c && col <= item.e.c);
737
774
  if (!merge)
@@ -814,6 +851,7 @@ export class ContentExtractor {
814
851
  // 在 xml 中 w:body 的一级子节点通常是 w:p 和 w:tbl
815
852
  const elements = Array.from(docXml.matchAll(/<(w:p|w:tbl)[\s>][\s\S]*?<\/\1>/gu), match => ({ tag: match[1], xml: match[0] }));
816
853
  const lines = [];
854
+ let tableIndex = 0;
817
855
  for (const { tag, xml } of elements) {
818
856
  if (tag === 'w:p') {
819
857
  const texts = Array.from(xml.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/gu), match => this.stripXml(match[1] ?? '')).join('');
@@ -826,34 +864,58 @@ export class ContentExtractor {
826
864
  lines.push(`${level > 0 ? `${'#'.repeat(level)} ` : ''}${texts.trim()}`);
827
865
  }
828
866
  else if (tag === 'w:tbl') {
829
- const trList = Array.from(xml.matchAll(/<w:tr[\s>][\s\S]*?<\/w:tr>/gu), match => match[0]);
830
- const rows = [];
831
- for (const tr of trList) {
832
- const tcList = Array.from(tr.matchAll(/<w:tc[\s>][\s\S]*?<\/w:tc>/gu), match => match[0]);
833
- const cols = [];
834
- for (const tc of tcList) {
835
- const tcText = Array.from(tc.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/gu), match => this.stripXml(match[1] ?? '')).join('');
836
- cols.push(tcText.trim().replace(/\r?\n/gu, ' '));
837
- }
838
- rows.push(cols);
839
- }
867
+ tableIndex += 1;
868
+ const rows = this.extractDocxTableRows(xml);
840
869
  if (rows.length > 0) {
841
- const maxCols = Math.max(...rows.map(r => r.length));
842
- // 补齐列数
843
- rows.forEach(r => { while (r.length < maxCols)
844
- r.push(''); });
870
+ const maxCols = Math.max(...rows.map(row => row.length), 1);
871
+ rows.forEach(row => { while (row.length < maxCols)
872
+ row.push(''); });
845
873
  const header = rows[0] || Array(maxCols).fill('');
846
- const mdTable = [
847
- `| ${header.join(' | ')} |`,
848
- `| ${header.map(() => '---').join(' | ')} |`,
849
- ...rows.slice(1).map(row => `| ${row.join(' | ')} |`)
850
- ].join('\n');
851
- lines.push(mdTable);
874
+ const mdTable = this.toMarkdownTable(header, rows.slice(1));
875
+ const declarations = rows.slice(1).flatMap((row, rowIndex) => row.map((value, colIndex) => {
876
+ const column = header[colIndex] || `COL${colIndex + 1}`;
877
+ return `表${tableIndex}.R${rowIndex + 2}C${colIndex + 1} ${column}: ${value}`;
878
+ })).filter(line => !line.endsWith(': '));
879
+ lines.push([`DOCX 表格 ${tableIndex}`, mdTable, '表格路径声明', ...declarations].join('\n'));
852
880
  }
853
881
  }
854
882
  }
855
883
  return lines.join('\n\n');
856
884
  }
885
+ extractDocxTableRows(tableXml) {
886
+ const activeVMerges = new Map();
887
+ return Array.from(tableXml.matchAll(/<w:tr[\s>][\s\S]*?<\/w:tr>/gu), match => match[0]).map(tr => {
888
+ const row = [];
889
+ for (const tc of Array.from(tr.matchAll(/<w:tc[\s>][\s\S]*?<\/w:tc>/gu), match => match[0])) {
890
+ const gridSpan = Math.max(1, Number(/<w:gridSpan\s+w:val="(\d+)"/u.exec(tc)?.[1] ?? 1));
891
+ const vMerge = /<w:vMerge(?:\s+w:val="([^"]+)")?\s*\/?/u.exec(tc)?.[1] ?? (/<w:vMerge\b/u.test(tc) ? 'continue' : undefined);
892
+ const cellText = this.extractDocxCellText(tc);
893
+ const colIndex = row.length;
894
+ const value = vMerge === 'continue' ? (activeVMerges.get(colIndex) ?? cellText) : cellText;
895
+ if (vMerge === 'restart' || (vMerge && cellText))
896
+ activeVMerges.set(colIndex, cellText);
897
+ if (!vMerge)
898
+ activeVMerges.delete(colIndex);
899
+ for (let index = 0; index < gridSpan; index++)
900
+ row.push(index === 0 ? value : '');
901
+ }
902
+ return row;
903
+ }).filter(row => row.some(Boolean));
904
+ }
905
+ extractDocxCellText(cellXml) {
906
+ return Array.from(cellXml.matchAll(/<w:p[\s>][\s\S]*?<\/w:p>/gu), match => match[0])
907
+ .map(paragraph => Array.from(paragraph.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>|<w:tab\s*\/>|<w:br\s*\/>/gu), match => {
908
+ if (match[1] != null)
909
+ return this.stripXml(match[1]);
910
+ if (match[0].startsWith('<w:tab'))
911
+ return ' ';
912
+ return '\n';
913
+ }).join('').trim())
914
+ .filter(Boolean)
915
+ .join(' / ')
916
+ .replace(/\s+/gu, ' ')
917
+ .trim();
918
+ }
857
919
  docxHeadingLevel(style, bold, size, text) {
858
920
  const normalized = style.toLowerCase();
859
921
  const heading = /heading(\d)|标题(\d)|h(\d)/iu.exec(normalized);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@customize-agent/knowledge",
3
- "version": "4.0.36",
3
+ "version": "4.0.38",
4
4
  "description": "Local knowledge base infrastructure for customize-agent",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",