@juspay/neurolink 9.94.1 → 9.94.2
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/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +369 -371
- package/dist/lib/rag/document/loaders.d.ts +12 -1
- package/dist/lib/rag/document/loaders.js +46 -32
- package/dist/lib/utils/csvProcessor.d.ts +61 -3
- package/dist/lib/utils/csvProcessor.js +125 -40
- package/dist/lib/utils/csvUtils.d.ts +26 -0
- package/dist/lib/utils/csvUtils.js +85 -0
- package/dist/rag/document/loaders.d.ts +12 -1
- package/dist/rag/document/loaders.js +46 -32
- package/dist/utils/csvProcessor.d.ts +61 -3
- package/dist/utils/csvProcessor.js +125 -40
- package/dist/utils/csvUtils.d.ts +26 -0
- package/dist/utils/csvUtils.js +84 -0
- package/package.json +1 -1
|
@@ -61,9 +61,20 @@ export declare class JSONLoader extends TextLoader {
|
|
|
61
61
|
export declare class CSVLoader extends TextLoader {
|
|
62
62
|
load(source: string, options?: CSVLoaderOptions): Promise<MDocument>;
|
|
63
63
|
canHandle(source: string): boolean;
|
|
64
|
-
private parseCSVLine;
|
|
65
64
|
private toMarkdownTable;
|
|
66
65
|
private toTextTable;
|
|
66
|
+
/**
|
|
67
|
+
* Collapse embedded newlines to a single space so one logical CSV row
|
|
68
|
+
* renders as exactly one line in the text/markdown table output.
|
|
69
|
+
*
|
|
70
|
+
* The shared quote-aware parser (`splitCsvLines` + `splitCSVFields`)
|
|
71
|
+
* preserves newlines embedded inside a quoted field verbatim — correct
|
|
72
|
+
* for the `json` output format (where `JSON.stringify` escapes them), but
|
|
73
|
+
* left as-is here it would split a single record across multiple
|
|
74
|
+
* physical table rows. Mirrors the same normalization `CSVProcessor`
|
|
75
|
+
* already applies in its own markdown formatter.
|
|
76
|
+
*/
|
|
77
|
+
private collapseEmbeddedNewlines;
|
|
67
78
|
}
|
|
68
79
|
/**
|
|
69
80
|
* PDF file loader
|
|
@@ -27,6 +27,8 @@ import { existsSync } from "fs";
|
|
|
27
27
|
import { readFile } from "fs/promises";
|
|
28
28
|
import { basename, extname } from "path";
|
|
29
29
|
import { logger } from "../../utils/logger.js";
|
|
30
|
+
import { CSVProcessor, stripBom, stripMetadataLine, splitCsvLines, } from "../../utils/csvProcessor.js";
|
|
31
|
+
import { splitCSVFields } from "../../utils/csvUtils.js";
|
|
30
32
|
import { MDocument } from "./MDocument.js";
|
|
31
33
|
/**
|
|
32
34
|
* Text file loader
|
|
@@ -115,13 +117,28 @@ export class JSONLoader extends TextLoader {
|
|
|
115
117
|
export class CSVLoader extends TextLoader {
|
|
116
118
|
async load(source, options) {
|
|
117
119
|
const content = await this.loadContent(source, options?.encoding);
|
|
118
|
-
const { delimiter
|
|
119
|
-
|
|
120
|
+
const { delimiter, hasHeader = true, columns, outputFormat = "text", } = options || {};
|
|
121
|
+
// #361: an explicit delimiter always wins; otherwise detect it from the
|
|
122
|
+
// content + extension so a .tsv fed through RAG isn't collapsed into one
|
|
123
|
+
// comma-column (CSVLoader.canHandle already accepts .tsv).
|
|
124
|
+
const effectiveDelimiter = delimiter ?? CSVProcessor.detectDelimiter(content, extname(source));
|
|
125
|
+
// Route header/row parsing through the same shared, quote-aware path
|
|
126
|
+
// detectDelimiter() above already uses internally: a quote-aware logical
|
|
127
|
+
// row split (splitCsvLines — a newline inside a quoted field stays part
|
|
128
|
+
// of that field instead of breaking the row) with a leading Excel `sep=`
|
|
129
|
+
// preamble line stripped (stripMetadataLine), so it can't be parsed as
|
|
130
|
+
// the header. Field-level splitting also goes through the shared
|
|
131
|
+
// RFC-4180-aware splitCSVFields() instead of the loader's own naive
|
|
132
|
+
// backslash-escape parser, which didn't handle doubled-quote (`""`)
|
|
133
|
+
// escaping or quoted delimiters in the headerless-columns branch.
|
|
134
|
+
const { dataLines: strippedLines } = stripMetadataLine(splitCsvLines(stripBom(content)));
|
|
135
|
+
const lines = strippedLines.filter((line) => line.trim());
|
|
120
136
|
const headers = hasHeader
|
|
121
|
-
?
|
|
122
|
-
: columns ||
|
|
137
|
+
? splitCSVFields(lines[0] ?? "", effectiveDelimiter).map((h) => h.trim())
|
|
138
|
+
: columns ||
|
|
139
|
+
splitCSVFields(lines[0] ?? "", effectiveDelimiter).map((_, i) => `col${i + 1}`);
|
|
123
140
|
const dataLines = hasHeader ? lines.slice(1) : lines;
|
|
124
|
-
const rows = dataLines.map((line) =>
|
|
141
|
+
const rows = dataLines.map((line) => splitCSVFields(line, effectiveDelimiter).map((field) => field.trim()));
|
|
125
142
|
let formattedContent;
|
|
126
143
|
switch (outputFormat) {
|
|
127
144
|
case "json":
|
|
@@ -145,42 +162,39 @@ export class CSVLoader extends TextLoader {
|
|
|
145
162
|
const ext = extname(source).toLowerCase();
|
|
146
163
|
return ext === ".csv" || ext === ".tsv";
|
|
147
164
|
}
|
|
148
|
-
parseCSVLine(line, delimiter) {
|
|
149
|
-
const result = [];
|
|
150
|
-
let current = "";
|
|
151
|
-
let inQuotes = false;
|
|
152
|
-
for (let i = 0; i < line.length; i++) {
|
|
153
|
-
const char = line[i];
|
|
154
|
-
if (char === '"' && (i === 0 || line[i - 1] !== "\\")) {
|
|
155
|
-
inQuotes = !inQuotes;
|
|
156
|
-
}
|
|
157
|
-
else if (char === delimiter && !inQuotes) {
|
|
158
|
-
result.push(current.trim());
|
|
159
|
-
current = "";
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
current += char;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
result.push(current.trim());
|
|
166
|
-
return result;
|
|
167
|
-
}
|
|
168
165
|
toMarkdownTable(headers, rows) {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
const
|
|
166
|
+
const safeHeaders = headers.map((h) => this.collapseEmbeddedNewlines(h));
|
|
167
|
+
const headerRow = `| ${safeHeaders.join(" | ")} |`;
|
|
168
|
+
const separator = `| ${safeHeaders.map(() => "---").join(" | ")} |`;
|
|
169
|
+
const dataRows = rows.map((row) => `| ${row.map((cell) => this.collapseEmbeddedNewlines(cell)).join(" | ")} |`);
|
|
172
170
|
return [headerRow, separator, ...dataRows].join("\n");
|
|
173
171
|
}
|
|
174
172
|
toTextTable(headers, rows) {
|
|
175
|
-
const
|
|
176
|
-
const
|
|
173
|
+
const safeHeaders = headers.map((h) => this.collapseEmbeddedNewlines(h));
|
|
174
|
+
const safeRows = rows.map((row) => row.map((cell) => this.collapseEmbeddedNewlines(cell)));
|
|
175
|
+
const allRows = [safeHeaders, ...safeRows];
|
|
176
|
+
const colWidths = safeHeaders.map((_, i) => Math.max(...allRows.map((row) => (row[i] || "").length)));
|
|
177
177
|
const formatRow = (row) => row.map((cell, i) => (cell || "").padEnd(colWidths[i])).join(" | ");
|
|
178
178
|
return [
|
|
179
|
-
formatRow(
|
|
179
|
+
formatRow(safeHeaders),
|
|
180
180
|
colWidths.map((w) => "-".repeat(w)).join("-+-"),
|
|
181
|
-
...
|
|
181
|
+
...safeRows.map(formatRow),
|
|
182
182
|
].join("\n");
|
|
183
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Collapse embedded newlines to a single space so one logical CSV row
|
|
186
|
+
* renders as exactly one line in the text/markdown table output.
|
|
187
|
+
*
|
|
188
|
+
* The shared quote-aware parser (`splitCsvLines` + `splitCSVFields`)
|
|
189
|
+
* preserves newlines embedded inside a quoted field verbatim — correct
|
|
190
|
+
* for the `json` output format (where `JSON.stringify` escapes them), but
|
|
191
|
+
* left as-is here it would split a single record across multiple
|
|
192
|
+
* physical table rows. Mirrors the same normalization `CSVProcessor`
|
|
193
|
+
* already applies in its own markdown formatter.
|
|
194
|
+
*/
|
|
195
|
+
collapseEmbeddedNewlines(cell) {
|
|
196
|
+
return cell.replace(/\r\n|\n|\r/g, " ");
|
|
197
|
+
}
|
|
184
198
|
}
|
|
185
199
|
/**
|
|
186
200
|
* PDF file loader
|
|
@@ -4,6 +4,50 @@
|
|
|
4
4
|
* Uses streaming for memory efficiency with large files
|
|
5
5
|
*/
|
|
6
6
|
import type { FileProcessingResult, CSVProcessorOptions } from "../types/index.js";
|
|
7
|
+
/**
|
|
8
|
+
* Detect if first line is CSV metadata (not actual data/headers)
|
|
9
|
+
* Common patterns:
|
|
10
|
+
* - Excel separator line: "SEP=,"
|
|
11
|
+
* - Lines with significantly different delimiter count than line 2
|
|
12
|
+
* - Lines that don't match CSV structure of subsequent lines
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Strip a leading UTF-8 BOM (U+FEFF) if present. Exported so every entry
|
|
16
|
+
* point that sniffs raw text (including the RAG CSVLoader) normalizes BOMs
|
|
17
|
+
* the same way before metadata/delimiter detection.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stripBom(text: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
|
|
22
|
+
* already-split lines, and read the delimiter it explicitly declares.
|
|
23
|
+
* Excel semantics: an explicit `sep=<char>` always wins over frequency-based
|
|
24
|
+
* detection; a metadata line without a recognized delimiter character is
|
|
25
|
+
* simply dropped from the sample without forcing a delimiter. This is the
|
|
26
|
+
* single place process(), detectDelimiter(), parseCSVFile(), and
|
|
27
|
+
* parseCSVString() all go through, so none of them detect a delimiter over
|
|
28
|
+
* the raw, un-stripped lines (which lets the preamble skew detection).
|
|
29
|
+
* Exported so non-CSVProcessor callers (the RAG CSVLoader) share it too.
|
|
30
|
+
*/
|
|
31
|
+
export declare function stripMetadataLine(lines: string[]): {
|
|
32
|
+
dataLines: string[];
|
|
33
|
+
hasMetadataLine: boolean;
|
|
34
|
+
explicitDelimiter?: string;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Split CSV text into logical rows for metadata detection and raw row limiting.
|
|
38
|
+
*
|
|
39
|
+
* Supports Unix (LF), Windows (CRLF), and classic Mac (CR) line endings, and is
|
|
40
|
+
* quote-aware: a line ending that appears *inside* a quoted field (RFC 4180) is
|
|
41
|
+
* kept as part of that field rather than treated as a row boundary. A doubled
|
|
42
|
+
* `""` inside quotes is the RFC-4180 escaped quote and does not toggle the quote
|
|
43
|
+
* state. For unquoted input this yields exactly the same result as a plain
|
|
44
|
+
* `split(/\r\n|\n|\r/)`.
|
|
45
|
+
*
|
|
46
|
+
* Exported so non-CSVProcessor callers (the RAG CSVLoader) get the same
|
|
47
|
+
* quote-aware row boundaries instead of a physical `content.split("\n")`,
|
|
48
|
+
* which would break rows on a newline embedded in a quoted field.
|
|
49
|
+
*/
|
|
50
|
+
export declare function splitCsvLines(csvString: string): string[];
|
|
7
51
|
/**
|
|
8
52
|
* CSV processor for converting CSV data to LLM-optimized formats
|
|
9
53
|
*
|
|
@@ -35,9 +79,14 @@ export declare class CSVProcessor {
|
|
|
35
79
|
*/
|
|
36
80
|
static process(content: Buffer, options?: CSVProcessorOptions): Promise<FileProcessingResult>;
|
|
37
81
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
82
|
+
* Detect the delimiter of DSV content (comma/tab/semicolon/pipe), respecting
|
|
83
|
+
* RFC-4180 quoting. An optional extension hint (`tsv` → tab, `psv` → pipe)
|
|
84
|
+
* breaks ambiguity. Used by callers (e.g. the RAG CSV loader) that parse
|
|
85
|
+
* DSV content themselves and need the same detection (#361). A leading
|
|
86
|
+
* Excel `sep=` metadata line is stripped before sampling so it can't skew
|
|
87
|
+
* detection, and an explicit `sep=<char>` value wins outright.
|
|
40
88
|
*/
|
|
89
|
+
static detectDelimiter(csvString: string, extensionHint?: string): string;
|
|
41
90
|
/**
|
|
42
91
|
* Parse CSV file from disk using streaming (memory efficient)
|
|
43
92
|
*
|
|
@@ -54,7 +103,16 @@ export declare class CSVProcessor {
|
|
|
54
103
|
* @param maxRows - Maximum rows to parse (default: 1000)
|
|
55
104
|
* @returns Array of row objects
|
|
56
105
|
*/
|
|
57
|
-
static parseCSVString(csvString: string, maxRows?: number): Promise<unknown[]>;
|
|
106
|
+
static parseCSVString(csvString: string, maxRows?: number, delimiter?: string): Promise<unknown[]>;
|
|
107
|
+
/**
|
|
108
|
+
* Stream-parse already-split, metadata-stripped data lines into row
|
|
109
|
+
* objects with a known delimiter. Shared core for parseCSVString() and
|
|
110
|
+
* process(), both of which need this after already splitting the content
|
|
111
|
+
* and resolving the delimiter once — calling parseCSVString() directly
|
|
112
|
+
* from process() would re-split and re-run metadata/delimiter detection
|
|
113
|
+
* over the same content a second time.
|
|
114
|
+
*/
|
|
115
|
+
private static parseCSVLines;
|
|
58
116
|
/**
|
|
59
117
|
* Format parsed CSV data for LLM consumption
|
|
60
118
|
* Only used for JSON and Markdown formats (raw format handled separately)
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import csvParser from "csv-parser";
|
|
7
7
|
import { Readable } from "stream";
|
|
8
|
+
import { extname } from "path";
|
|
8
9
|
import { logger } from "./logger.js";
|
|
10
|
+
import { countCSVColumns, splitCSVFields, detectDelimiter as detectDelimiterUtil, CANDIDATE_DELIMITERS, } from "./csvUtils.js";
|
|
9
11
|
// ============================================================================
|
|
10
12
|
// Data Type Detection Patterns
|
|
11
13
|
// ============================================================================
|
|
@@ -439,8 +441,12 @@ function detectHasHeaders(headerValues, dataRows) {
|
|
|
439
441
|
* - Lines with significantly different delimiter count than line 2
|
|
440
442
|
* - Lines that don't match CSV structure of subsequent lines
|
|
441
443
|
*/
|
|
442
|
-
/**
|
|
443
|
-
|
|
444
|
+
/**
|
|
445
|
+
* Strip a leading UTF-8 BOM (U+FEFF) if present. Exported so every entry
|
|
446
|
+
* point that sniffs raw text (including the RAG CSVLoader) normalizes BOMs
|
|
447
|
+
* the same way before metadata/delimiter detection.
|
|
448
|
+
*/
|
|
449
|
+
export function stripBom(text) {
|
|
444
450
|
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
445
451
|
}
|
|
446
452
|
/**
|
|
@@ -485,6 +491,31 @@ function isMetadataLine(lines) {
|
|
|
485
491
|
}
|
|
486
492
|
return false;
|
|
487
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
|
|
496
|
+
* already-split lines, and read the delimiter it explicitly declares.
|
|
497
|
+
* Excel semantics: an explicit `sep=<char>` always wins over frequency-based
|
|
498
|
+
* detection; a metadata line without a recognized delimiter character is
|
|
499
|
+
* simply dropped from the sample without forcing a delimiter. This is the
|
|
500
|
+
* single place process(), detectDelimiter(), parseCSVFile(), and
|
|
501
|
+
* parseCSVString() all go through, so none of them detect a delimiter over
|
|
502
|
+
* the raw, un-stripped lines (which lets the preamble skew detection).
|
|
503
|
+
* Exported so non-CSVProcessor callers (the RAG CSVLoader) share it too.
|
|
504
|
+
*/
|
|
505
|
+
export function stripMetadataLine(lines) {
|
|
506
|
+
if (!isMetadataLine(lines)) {
|
|
507
|
+
return { dataLines: lines, hasMetadataLine: false };
|
|
508
|
+
}
|
|
509
|
+
const sepChar = lines[0]?.trim().match(/^sep=(.)/i)?.[1];
|
|
510
|
+
const explicitDelimiter = sepChar && CANDIDATE_DELIMITERS.includes(sepChar)
|
|
511
|
+
? sepChar
|
|
512
|
+
: undefined;
|
|
513
|
+
return {
|
|
514
|
+
dataLines: lines.slice(1),
|
|
515
|
+
hasMetadataLine: true,
|
|
516
|
+
explicitDelimiter,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
488
519
|
/**
|
|
489
520
|
* Split CSV text into logical rows for metadata detection and raw row limiting.
|
|
490
521
|
*
|
|
@@ -494,8 +525,12 @@ function isMetadataLine(lines) {
|
|
|
494
525
|
* `""` inside quotes is the RFC-4180 escaped quote and does not toggle the quote
|
|
495
526
|
* state. For unquoted input this yields exactly the same result as a plain
|
|
496
527
|
* `split(/\r\n|\n|\r/)`.
|
|
528
|
+
*
|
|
529
|
+
* Exported so non-CSVProcessor callers (the RAG CSVLoader) get the same
|
|
530
|
+
* quote-aware row boundaries instead of a physical `content.split("\n")`,
|
|
531
|
+
* which would break rows on a newline embedded in a quoted field.
|
|
497
532
|
*/
|
|
498
|
-
function splitCsvLines(csvString) {
|
|
533
|
+
export function splitCsvLines(csvString) {
|
|
499
534
|
const rows = [];
|
|
500
535
|
let current = "";
|
|
501
536
|
let inQuotes = false;
|
|
@@ -565,18 +600,22 @@ export class CSVProcessor {
|
|
|
565
600
|
includeHeaders,
|
|
566
601
|
});
|
|
567
602
|
const csvString = content.toString("utf-8");
|
|
603
|
+
// #361: split once and strip a leading Excel `sep=` metadata line before
|
|
604
|
+
// detecting the delimiter (comma/tab/semicolon/pipe), so the preamble
|
|
605
|
+
// can't skew detection — then reuse dataLines below instead of
|
|
606
|
+
// re-splitting csvString for the raw-format row handling.
|
|
607
|
+
const lines = splitCsvLines(csvString);
|
|
608
|
+
const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
|
|
609
|
+
const delimiter = explicitDelimiter ??
|
|
610
|
+
detectDelimiterUtil(dataLines, extension ?? undefined);
|
|
568
611
|
// For raw format, return CSV text with row limit (no parsing needed)
|
|
569
612
|
// This preserves the CSV shape while normalizing line endings for row handling.
|
|
570
613
|
if (formatStyle === "raw") {
|
|
571
|
-
const lines = splitCsvLines(csvString);
|
|
572
|
-
const hasMetadataLine = isMetadataLine(lines);
|
|
573
614
|
if (hasMetadataLine) {
|
|
574
615
|
logger.debug("[CSVProcessor] Detected metadata line, skipping first line");
|
|
575
616
|
}
|
|
576
|
-
//
|
|
577
|
-
const csvLines =
|
|
578
|
-
? lines.slice(1) // Skip metadata line
|
|
579
|
-
: lines;
|
|
617
|
+
// dataLines already has the metadata line (if any) stripped.
|
|
618
|
+
const csvLines = dataLines;
|
|
580
619
|
const limitedLines = csvLines.slice(0, 1 + maxRows); // header + data rows
|
|
581
620
|
const limitedCSV = limitedLines.join("\n");
|
|
582
621
|
const rowCount = limitedLines
|
|
@@ -597,11 +636,16 @@ export class CSVProcessor {
|
|
|
597
636
|
logger.info("[CSVProcessor] ✅ Processed CSV file", {
|
|
598
637
|
formatStyle: "raw",
|
|
599
638
|
rowCount,
|
|
600
|
-
columnCount: (limitedLines[0] || ""
|
|
639
|
+
columnCount: countCSVColumns(limitedLines[0] || "", delimiter),
|
|
601
640
|
truncated: wasTruncated,
|
|
602
641
|
});
|
|
603
|
-
// Parse a sample for enhanced metadata analysis (raw format still
|
|
604
|
-
|
|
642
|
+
// Parse a sample for enhanced metadata analysis (raw format still
|
|
643
|
+
// benefits from column analysis). Reuse the already-split, already
|
|
644
|
+
// metadata-stripped `dataLines` via the shared parseCSVLines() core
|
|
645
|
+
// instead of routing `limitedCSV` back through parseCSVString(), which
|
|
646
|
+
// would re-split/re-join it and re-run a redundant BOM/metadata pass.
|
|
647
|
+
const sampleRows = Math.min(rowCount, 500);
|
|
648
|
+
const sampleForAnalysis = await this.parseCSVLines(dataLines.slice(0, 1 + sampleRows), sampleRows, delimiter);
|
|
605
649
|
const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(sampleForAnalysis);
|
|
606
650
|
// Log data quality summary
|
|
607
651
|
if (dataQualityWarnings.length > 0) {
|
|
@@ -619,13 +663,13 @@ export class CSVProcessor {
|
|
|
619
663
|
size: content.length,
|
|
620
664
|
rowCount,
|
|
621
665
|
totalLines: limitedLines.length,
|
|
622
|
-
columnCount: (limitedLines[0] || ""
|
|
666
|
+
columnCount: countCSVColumns(limitedLines[0] || "", delimiter),
|
|
623
667
|
extension,
|
|
624
668
|
columnMetadata,
|
|
625
669
|
dataQualityWarnings,
|
|
626
670
|
dataQualityScore,
|
|
627
|
-
hasHeaders: detectHasHeaders((limitedLines[0] || ""
|
|
628
|
-
detectedDelimiter:
|
|
671
|
+
hasHeaders: detectHasHeaders(splitCSVFields(limitedLines[0] || "", delimiter), undefined),
|
|
672
|
+
detectedDelimiter: delimiter,
|
|
629
673
|
},
|
|
630
674
|
};
|
|
631
675
|
}
|
|
@@ -634,7 +678,10 @@ export class CSVProcessor {
|
|
|
634
678
|
formatStyle,
|
|
635
679
|
maxRows,
|
|
636
680
|
});
|
|
637
|
-
|
|
681
|
+
// dataLines was already split and metadata-stripped above (alongside
|
|
682
|
+
// `delimiter`) — reuse it instead of routing through parseCSVString(),
|
|
683
|
+
// which would re-split csvString and re-run metadata detection.
|
|
684
|
+
const rows = await this.parseCSVLines(dataLines, maxRows, delimiter);
|
|
638
685
|
// Filter out empty rows (empty objects or rows with only whitespace values from blank lines)
|
|
639
686
|
const nonEmptyRows = rows.filter((row) => {
|
|
640
687
|
if (!row || typeof row !== "object") {
|
|
@@ -701,14 +748,22 @@ export class CSVProcessor {
|
|
|
701
748
|
dataQualityWarnings,
|
|
702
749
|
dataQualityScore,
|
|
703
750
|
hasHeaders: detectHasHeaders(columnNames, nonEmptyRows),
|
|
704
|
-
detectedDelimiter:
|
|
751
|
+
detectedDelimiter: delimiter,
|
|
705
752
|
},
|
|
706
753
|
};
|
|
707
754
|
}
|
|
708
755
|
/**
|
|
709
|
-
*
|
|
710
|
-
*
|
|
756
|
+
* Detect the delimiter of DSV content (comma/tab/semicolon/pipe), respecting
|
|
757
|
+
* RFC-4180 quoting. An optional extension hint (`tsv` → tab, `psv` → pipe)
|
|
758
|
+
* breaks ambiguity. Used by callers (e.g. the RAG CSV loader) that parse
|
|
759
|
+
* DSV content themselves and need the same detection (#361). A leading
|
|
760
|
+
* Excel `sep=` metadata line is stripped before sampling so it can't skew
|
|
761
|
+
* detection, and an explicit `sep=<char>` value wins outright.
|
|
711
762
|
*/
|
|
763
|
+
static detectDelimiter(csvString, extensionHint) {
|
|
764
|
+
const { dataLines, explicitDelimiter } = stripMetadataLine(splitCsvLines(stripBom(csvString)));
|
|
765
|
+
return explicitDelimiter ?? detectDelimiterUtil(dataLines, extensionHint);
|
|
766
|
+
}
|
|
712
767
|
/**
|
|
713
768
|
* Parse CSV file from disk using streaming (memory efficient)
|
|
714
769
|
*
|
|
@@ -734,9 +789,13 @@ export class CSVProcessor {
|
|
|
734
789
|
let buffer = "";
|
|
735
790
|
lineReader.on("data", (chunk) => {
|
|
736
791
|
buffer += chunk.toString();
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
792
|
+
// Strip a leading UTF-8 BOM before the `sep=` check below, same as
|
|
793
|
+
// process(), parseCSVString(), and detectDelimiter() — otherwise an
|
|
794
|
+
// Excel export starting with a BOM + "sep=;" fails the /^sep=/i match.
|
|
795
|
+
const normalizedBuffer = stripBom(buffer);
|
|
796
|
+
const splitBuffer = normalizedBuffer.endsWith("\r")
|
|
797
|
+
? normalizedBuffer.slice(0, -1)
|
|
798
|
+
: normalizedBuffer;
|
|
740
799
|
const lines = splitCsvLines(splitBuffer);
|
|
741
800
|
if (lines.length >= 2) {
|
|
742
801
|
firstLines.push(lines[0], lines[1]);
|
|
@@ -745,21 +804,32 @@ export class CSVProcessor {
|
|
|
745
804
|
}
|
|
746
805
|
});
|
|
747
806
|
lineReader.on("end", () => resolve());
|
|
748
|
-
// Metadata sniffing is best-effort — a read error here must not crash
|
|
749
|
-
|
|
807
|
+
// Metadata sniffing is best-effort — a read error here must not crash
|
|
808
|
+
// (the main read stream below will surface a real failure properly),
|
|
809
|
+
// but silently dropping it loses the diagnostic entirely, so log it.
|
|
810
|
+
lineReader.on("error", (error) => {
|
|
811
|
+
logger.warn(`[CSVProcessor] Metadata sniffing read failed for ${filePath}, proceeding without it: ${error.message}`, { cause: error });
|
|
812
|
+
resolve();
|
|
813
|
+
});
|
|
750
814
|
});
|
|
751
815
|
await fileHandle.close();
|
|
752
|
-
const hasMetadataLine =
|
|
816
|
+
const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(firstLines);
|
|
753
817
|
const skipLines = hasMetadataLine ? 1 : 0;
|
|
818
|
+
// #361: pick the delimiter from the sniffed lines (metadata line
|
|
819
|
+
// stripped) + file extension so .tsv / semicolon / pipe files aren't
|
|
820
|
+
// collapsed into one column, and a `sep=` preamble can't skew detection.
|
|
821
|
+
const fileDelimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines, extname(filePath));
|
|
754
822
|
if (hasMetadataLine) {
|
|
755
823
|
logger.debug("[CSVProcessor] Detected metadata line in file, will skip first line");
|
|
756
824
|
}
|
|
757
825
|
return new Promise((resolve, reject) => {
|
|
758
826
|
const rows = [];
|
|
759
827
|
let count = 0;
|
|
760
|
-
let lineCount = 0;
|
|
761
828
|
const source = fs.createReadStream(filePath, { encoding: "utf-8" });
|
|
762
|
-
|
|
829
|
+
// Pass skipLines through to csv-parser itself so it skips the `sep=`
|
|
830
|
+
// metadata line BEFORE picking headers — otherwise csv-parser treats
|
|
831
|
+
// that line as the header row and the real header becomes a data row.
|
|
832
|
+
const parser = csvParser({ separator: fileDelimiter, skipLines });
|
|
763
833
|
const abort = () => {
|
|
764
834
|
source.destroy();
|
|
765
835
|
parser.destroy();
|
|
@@ -773,10 +843,6 @@ export class CSVProcessor {
|
|
|
773
843
|
source
|
|
774
844
|
.pipe(parser)
|
|
775
845
|
.on("data", (row) => {
|
|
776
|
-
lineCount++;
|
|
777
|
-
if (lineCount <= skipLines) {
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
846
|
rows.push(row);
|
|
781
847
|
count++;
|
|
782
848
|
if (count >= clampedMaxRows) {
|
|
@@ -803,32 +869,51 @@ export class CSVProcessor {
|
|
|
803
869
|
* @param maxRows - Maximum rows to parse (default: 1000)
|
|
804
870
|
* @returns Array of row objects
|
|
805
871
|
*/
|
|
806
|
-
static async parseCSVString(csvString, maxRows = 1000) {
|
|
872
|
+
static async parseCSVString(csvString, maxRows = 1000, delimiter) {
|
|
807
873
|
if (typeof csvString !== "string" || csvString.trim().length === 0) {
|
|
808
874
|
throw new Error("CSVProcessor.parseCSVString: csvString must be a non-empty string");
|
|
809
875
|
}
|
|
810
876
|
// Strip a leading UTF-8 BOM (common in Excel exports) so it does not glue
|
|
811
877
|
// onto the first column name and break downstream key lookups.
|
|
812
878
|
const normalized = stripBom(csvString);
|
|
813
|
-
const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
|
|
814
879
|
logger.debug("[CSVProcessor] Starting string parsing", {
|
|
815
880
|
inputLength: normalized.length,
|
|
816
|
-
maxRows
|
|
881
|
+
maxRows,
|
|
817
882
|
});
|
|
818
|
-
// Detect and skip metadata line
|
|
883
|
+
// Detect and skip a leading metadata line before detecting the
|
|
884
|
+
// delimiter, so a `sep=` preamble can't skew detection (#361).
|
|
819
885
|
const lines = splitCsvLines(normalized);
|
|
820
|
-
const hasMetadataLine =
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
886
|
+
const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
|
|
887
|
+
// Caller-provided delimiter wins, then an explicit `sep=` declaration,
|
|
888
|
+
// then frequency-based detection over the post-metadata lines.
|
|
889
|
+
const effectiveDelimiter = delimiter ?? explicitDelimiter ?? detectDelimiterUtil(dataLines);
|
|
824
890
|
if (hasMetadataLine) {
|
|
825
891
|
logger.debug("[CSVProcessor] Detected metadata line in string, skipping");
|
|
826
892
|
}
|
|
893
|
+
return this.parseCSVLines(dataLines, maxRows, effectiveDelimiter);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Stream-parse already-split, metadata-stripped data lines into row
|
|
897
|
+
* objects with a known delimiter. Shared core for parseCSVString() and
|
|
898
|
+
* process(), both of which need this after already splitting the content
|
|
899
|
+
* and resolving the delimiter once — calling parseCSVString() directly
|
|
900
|
+
* from process() would re-split and re-run metadata/delimiter detection
|
|
901
|
+
* over the same content a second time.
|
|
902
|
+
*/
|
|
903
|
+
static parseCSVLines(dataLines, maxRows, delimiter) {
|
|
904
|
+
const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
|
|
905
|
+
const csvData = dataLines.join("\n");
|
|
906
|
+
if (csvData.trim().length === 0) {
|
|
907
|
+
// Shared core for both process() and parseCSVString() — use a generic
|
|
908
|
+
// message rather than naming one specific entry point, since either
|
|
909
|
+
// caller can land here with empty post-metadata data lines.
|
|
910
|
+
return Promise.reject(new Error("CSVProcessor: CSV data must be a non-empty string"));
|
|
911
|
+
}
|
|
827
912
|
return new Promise((resolve, reject) => {
|
|
828
913
|
const rows = [];
|
|
829
914
|
let count = 0;
|
|
830
915
|
const source = Readable.from([csvData]);
|
|
831
|
-
const parser = csvParser();
|
|
916
|
+
const parser = csvParser({ separator: delimiter });
|
|
832
917
|
const abort = () => {
|
|
833
918
|
source.destroy();
|
|
834
919
|
parser.destroy();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, RFC-4180-aware delimiter-separated-value helpers.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates quote-aware field counting/splitting that was previously
|
|
5
|
+
* duplicated across csvProcessor.ts and fileDetector.ts (#359), and adds
|
|
6
|
+
* delimiter detection so TSV / semicolon / pipe files parse correctly (#361).
|
|
7
|
+
* Keeping these in one module prevents the same quoting edge case from having
|
|
8
|
+
* to be fixed in three places and drifting out of sync.
|
|
9
|
+
*/
|
|
10
|
+
/** Delimiter candidates, in tie-break preference order (comma first). */
|
|
11
|
+
export declare const CANDIDATE_DELIMITERS: readonly [",", "\t", ";", "|"];
|
|
12
|
+
/**
|
|
13
|
+
* Split a single line into fields, respecting RFC-4180 quoting: a delimiter
|
|
14
|
+
* inside a `"…"` quoted field does not split, and `""` is an escaped quote.
|
|
15
|
+
*/
|
|
16
|
+
export declare function splitCSVFields(line: string, delimiter?: string): string[];
|
|
17
|
+
/** Count fields in a single line, respecting RFC-4180 quoting. */
|
|
18
|
+
export declare function countCSVColumns(line: string, delimiter?: string): number;
|
|
19
|
+
/**
|
|
20
|
+
* Detect the delimiter of DSV content by testing comma/tab/semicolon/pipe
|
|
21
|
+
* against up to 5 sampled non-empty lines and keeping the candidate with the
|
|
22
|
+
* most consistent (>1) column count. Comma wins ties and the "nothing splits"
|
|
23
|
+
* case, so plain CSVs are never reclassified. An optional extension hint
|
|
24
|
+
* (`tsv` → tab, `psv` → pipe) breaks ambiguity in the file's favor.
|
|
25
|
+
*/
|
|
26
|
+
export declare function detectDelimiter(lines: string[], extensionHint?: string): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, RFC-4180-aware delimiter-separated-value helpers.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates quote-aware field counting/splitting that was previously
|
|
5
|
+
* duplicated across csvProcessor.ts and fileDetector.ts (#359), and adds
|
|
6
|
+
* delimiter detection so TSV / semicolon / pipe files parse correctly (#361).
|
|
7
|
+
* Keeping these in one module prevents the same quoting edge case from having
|
|
8
|
+
* to be fixed in three places and drifting out of sync.
|
|
9
|
+
*/
|
|
10
|
+
/** Delimiter candidates, in tie-break preference order (comma first). */
|
|
11
|
+
export const CANDIDATE_DELIMITERS = [",", "\t", ";", "|"];
|
|
12
|
+
/**
|
|
13
|
+
* Split a single line into fields, respecting RFC-4180 quoting: a delimiter
|
|
14
|
+
* inside a `"…"` quoted field does not split, and `""` is an escaped quote.
|
|
15
|
+
*/
|
|
16
|
+
export function splitCSVFields(line, delimiter = ",") {
|
|
17
|
+
const fields = [];
|
|
18
|
+
let field = "";
|
|
19
|
+
let inQuotes = false;
|
|
20
|
+
for (let i = 0; i < line.length; i++) {
|
|
21
|
+
const ch = line[i];
|
|
22
|
+
if (ch === '"') {
|
|
23
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
24
|
+
field += '"';
|
|
25
|
+
i++; // consume the escaped quote
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
inQuotes = !inQuotes;
|
|
29
|
+
}
|
|
30
|
+
else if (ch === delimiter && !inQuotes) {
|
|
31
|
+
fields.push(field);
|
|
32
|
+
field = "";
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
field += ch;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
fields.push(field);
|
|
39
|
+
return fields;
|
|
40
|
+
}
|
|
41
|
+
/** Count fields in a single line, respecting RFC-4180 quoting. */
|
|
42
|
+
export function countCSVColumns(line, delimiter = ",") {
|
|
43
|
+
return splitCSVFields(line, delimiter).length;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Detect the delimiter of DSV content by testing comma/tab/semicolon/pipe
|
|
47
|
+
* against up to 5 sampled non-empty lines and keeping the candidate with the
|
|
48
|
+
* most consistent (>1) column count. Comma wins ties and the "nothing splits"
|
|
49
|
+
* case, so plain CSVs are never reclassified. An optional extension hint
|
|
50
|
+
* (`tsv` → tab, `psv` → pipe) breaks ambiguity in the file's favor.
|
|
51
|
+
*/
|
|
52
|
+
export function detectDelimiter(lines, extensionHint) {
|
|
53
|
+
const sample = lines.filter((l) => l.trim() !== "").slice(0, 5);
|
|
54
|
+
if (sample.length === 0) {
|
|
55
|
+
return ",";
|
|
56
|
+
}
|
|
57
|
+
const hint = extensionHint?.toLowerCase().replace(/^\./, "");
|
|
58
|
+
const hinted = hint === "tsv" ? "\t" : hint === "psv" ? "|" : undefined;
|
|
59
|
+
let best = ",";
|
|
60
|
+
let bestScore = -1;
|
|
61
|
+
for (const delim of CANDIDATE_DELIMITERS) {
|
|
62
|
+
const counts = sample.map((l) => countCSVColumns(l, delim));
|
|
63
|
+
const headerCols = counts[0];
|
|
64
|
+
if (headerCols <= 1) {
|
|
65
|
+
continue; // this delimiter doesn't split the header — not the delimiter
|
|
66
|
+
}
|
|
67
|
+
const consistent = counts.every((c) => c === headerCols);
|
|
68
|
+
// Prefer: a matching extension hint, then consistency, then more columns,
|
|
69
|
+
// with a small comma bias so comma stays the default on ties.
|
|
70
|
+
const score = (delim === hinted ? 1000 : 0) +
|
|
71
|
+
(consistent ? 100 : 0) +
|
|
72
|
+
headerCols +
|
|
73
|
+
(delim === "," ? 0.5 : 0);
|
|
74
|
+
if (score > bestScore) {
|
|
75
|
+
bestScore = score;
|
|
76
|
+
best = delim;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Nothing split multi-column, but the extension says otherwise — honor it.
|
|
80
|
+
if (bestScore < 0 && hinted) {
|
|
81
|
+
return hinted;
|
|
82
|
+
}
|
|
83
|
+
return best;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=csvUtils.js.map
|
|
@@ -61,9 +61,20 @@ export declare class JSONLoader extends TextLoader {
|
|
|
61
61
|
export declare class CSVLoader extends TextLoader {
|
|
62
62
|
load(source: string, options?: CSVLoaderOptions): Promise<MDocument>;
|
|
63
63
|
canHandle(source: string): boolean;
|
|
64
|
-
private parseCSVLine;
|
|
65
64
|
private toMarkdownTable;
|
|
66
65
|
private toTextTable;
|
|
66
|
+
/**
|
|
67
|
+
* Collapse embedded newlines to a single space so one logical CSV row
|
|
68
|
+
* renders as exactly one line in the text/markdown table output.
|
|
69
|
+
*
|
|
70
|
+
* The shared quote-aware parser (`splitCsvLines` + `splitCSVFields`)
|
|
71
|
+
* preserves newlines embedded inside a quoted field verbatim — correct
|
|
72
|
+
* for the `json` output format (where `JSON.stringify` escapes them), but
|
|
73
|
+
* left as-is here it would split a single record across multiple
|
|
74
|
+
* physical table rows. Mirrors the same normalization `CSVProcessor`
|
|
75
|
+
* already applies in its own markdown formatter.
|
|
76
|
+
*/
|
|
77
|
+
private collapseEmbeddedNewlines;
|
|
67
78
|
}
|
|
68
79
|
/**
|
|
69
80
|
* PDF file loader
|