@juspay/neurolink 10.10.8 → 10.10.10
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 +14 -0
- package/dist/browser/neurolink.min.js +398 -398
- package/dist/cli/factories/commandFactory.js +6 -0
- package/dist/lib/processors/media/AudioProcessor.js +33 -15
- package/dist/lib/skills/skillsManager.js +24 -5
- package/dist/lib/types/file.d.ts +10 -0
- package/dist/lib/types/generate.d.ts +15 -0
- package/dist/lib/types/processor.d.ts +6 -0
- package/dist/lib/types/stream.d.ts +11 -0
- package/dist/lib/utils/csvProcessor.d.ts +20 -0
- package/dist/lib/utils/csvProcessor.js +162 -47
- package/dist/lib/utils/messageBuilder.js +40 -3
- package/dist/lib/utils/multimodalOptionsBuilder.d.ts +2 -0
- package/dist/lib/utils/pdfProcessor.js +10 -0
- package/dist/processors/media/AudioProcessor.js +33 -15
- package/dist/skills/skillsManager.js +24 -5
- package/dist/types/file.d.ts +10 -0
- package/dist/types/generate.d.ts +15 -0
- package/dist/types/processor.d.ts +6 -0
- package/dist/types/stream.d.ts +11 -0
- package/dist/utils/csvProcessor.d.ts +20 -0
- package/dist/utils/csvProcessor.js +162 -47
- package/dist/utils/messageBuilder.js +40 -3
- package/dist/utils/multimodalOptionsBuilder.d.ts +2 -0
- package/dist/utils/pdfProcessor.js +10 -0
- package/package.json +1 -1
|
@@ -87,7 +87,14 @@ export class SkillsManager {
|
|
|
87
87
|
// name-find would resolve non-deterministically to the stale deprecated one
|
|
88
88
|
// across store backends. Fall back to any match only when no active skill
|
|
89
89
|
// carries the name.
|
|
90
|
-
|
|
90
|
+
//
|
|
91
|
+
// Matched case-insensitively to agree with assertNameAvailable, which
|
|
92
|
+
// enforces uniqueness that way: names differing only in case cannot
|
|
93
|
+
// coexist, so a case-sensitive lookup could only fail to find a skill that
|
|
94
|
+
// is definitively there (`get("DEPLOY")` returned null for "deploy").
|
|
95
|
+
const wanted = idOrName.toLowerCase();
|
|
96
|
+
const entry = index.find((item) => item.name.toLowerCase() === wanted &&
|
|
97
|
+
(item.status ?? "active") === "active") ?? index.find((item) => item.name.toLowerCase() === wanted);
|
|
91
98
|
return entry ? this.store.get(entry.id) : null;
|
|
92
99
|
}
|
|
93
100
|
/**
|
|
@@ -237,11 +244,23 @@ export class SkillsManager {
|
|
|
237
244
|
}
|
|
238
245
|
async assertNameAvailable(name, excludeId) {
|
|
239
246
|
const index = await this.getIndex(true);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
247
|
+
// Deliberately NOT filtered to active (#1139). Soft-delete only flips
|
|
248
|
+
// status to "deprecated" — the entry stays in the index, so allowing a new
|
|
249
|
+
// skill to take the name left two entries sharing it. Every name-based
|
|
250
|
+
// lookup (CLI `skills show/delete <name>`, the skill_update/skill_delete
|
|
251
|
+
// tools, the `:id`-or-name REST routes) then had to guess which one the
|
|
252
|
+
// caller meant, and iteration order differs across store backends.
|
|
253
|
+
//
|
|
254
|
+
// A deprecated skill's name therefore stays reserved. Reusing it requires
|
|
255
|
+
// hard-deleting the old skill first, which is the explicit choice the
|
|
256
|
+
// ambiguity demands.
|
|
257
|
+
const clash = index.find((item) => item.id !== excludeId && item.name.toLowerCase() === name.toLowerCase());
|
|
243
258
|
if (clash) {
|
|
244
|
-
|
|
259
|
+
const suffix = (clash.status ?? "active") === "active"
|
|
260
|
+
? ""
|
|
261
|
+
: ` (that name belongs to a deprecated skill, id ${clash.id}; ` +
|
|
262
|
+
`remove it before reusing the name)`;
|
|
263
|
+
throw new Error(`A skill named "${name}" already exists${suffix}`);
|
|
245
264
|
}
|
|
246
265
|
}
|
|
247
266
|
}
|
package/dist/types/file.d.ts
CHANGED
|
@@ -200,6 +200,12 @@ export type CSVProcessorOptions = {
|
|
|
200
200
|
* rather than hanging forever. Defaults: 30s for strings, 5min for files.
|
|
201
201
|
*/
|
|
202
202
|
parseTimeoutMs?: number;
|
|
203
|
+
/**
|
|
204
|
+
* Skip blank / whitespace-only data rows (#373). Default `true`: blank lines
|
|
205
|
+
* are excluded from the returned content (including raw CSV text) and from
|
|
206
|
+
* `metadata.rowCount`. Set to `false` to preserve empty lines literally.
|
|
207
|
+
*/
|
|
208
|
+
skipEmptyLines?: boolean;
|
|
203
209
|
};
|
|
204
210
|
/**
|
|
205
211
|
* PDF API types for different providers
|
|
@@ -474,6 +480,10 @@ export type MultimodalPdfEntry = {
|
|
|
474
480
|
password?: string;
|
|
475
481
|
/** Per-page pixel ceiling for the image fallback (#260). */
|
|
476
482
|
maxCanvasPixels?: number;
|
|
483
|
+
/** Render scale for the image fallback (#297). */
|
|
484
|
+
scale?: number;
|
|
485
|
+
/** Max pages converted by the image fallback (#297). */
|
|
486
|
+
maxPages?: number;
|
|
477
487
|
};
|
|
478
488
|
/** Result of PDF to image conversion. */
|
|
479
489
|
export type PDFImageConversionResult = {
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -133,6 +133,17 @@ export type GenerateOptions = {
|
|
|
133
133
|
password?: string;
|
|
134
134
|
/** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
|
|
135
135
|
maxCanvasPixels?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Render scale for the image fallback used by providers without native PDF
|
|
138
|
+
* support (#297). Higher is sharper but costs roughly the square in memory
|
|
139
|
+
* and tokens. Range 0.1-10; defaults to PDF_LIMITS.DEFAULT_SCALE (1.5).
|
|
140
|
+
*/
|
|
141
|
+
scale?: number;
|
|
142
|
+
/**
|
|
143
|
+
* Max pages converted by the image fallback (#297). Pages beyond this are
|
|
144
|
+
* not sent to the model at all. Defaults to PDF_LIMITS.DEFAULT_MAX_PAGES (20).
|
|
145
|
+
*/
|
|
146
|
+
maxPages?: number;
|
|
136
147
|
};
|
|
137
148
|
videoOptions?: {
|
|
138
149
|
/** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
|
|
@@ -1265,6 +1276,10 @@ export type TextGenerationOptions = {
|
|
|
1265
1276
|
password?: string;
|
|
1266
1277
|
/** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
|
|
1267
1278
|
maxCanvasPixels?: number;
|
|
1279
|
+
/** Render scale for the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_SCALE. */
|
|
1280
|
+
scale?: number;
|
|
1281
|
+
/** Max pages converted by the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_MAX_PAGES. */
|
|
1282
|
+
maxPages?: number;
|
|
1268
1283
|
};
|
|
1269
1284
|
enableSummarization?: boolean;
|
|
1270
1285
|
/**
|
|
@@ -703,6 +703,12 @@ export type ProcessedAudio = ProcessedFileBase & {
|
|
|
703
703
|
transcript?: string;
|
|
704
704
|
hasTranscript: boolean;
|
|
705
705
|
transcriptionProvider?: string;
|
|
706
|
+
/**
|
|
707
|
+
* Why transcription produced nothing, when it did (#416). Absent on success.
|
|
708
|
+
* Lets a caller distinguish "this audio has no speech" from "the transcription
|
|
709
|
+
* backend was never reachable", which previously looked identical.
|
|
710
|
+
*/
|
|
711
|
+
transcriptionSkippedReason?: string;
|
|
706
712
|
coverArt?: Buffer;
|
|
707
713
|
};
|
|
708
714
|
/**
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -225,6 +225,17 @@ export type StreamOptions = {
|
|
|
225
225
|
password?: string;
|
|
226
226
|
/** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
|
|
227
227
|
maxCanvasPixels?: number;
|
|
228
|
+
/**
|
|
229
|
+
* Render scale for the image fallback used by providers without native PDF
|
|
230
|
+
* support (#297). Higher is sharper but costs roughly the square in memory
|
|
231
|
+
* and tokens. Range 0.1-10; defaults to PDF_LIMITS.DEFAULT_SCALE (1.5).
|
|
232
|
+
*/
|
|
233
|
+
scale?: number;
|
|
234
|
+
/**
|
|
235
|
+
* Max pages converted by the image fallback (#297). Pages beyond this are
|
|
236
|
+
* not sent to the model at all. Defaults to PDF_LIMITS.DEFAULT_MAX_PAGES (20).
|
|
237
|
+
*/
|
|
238
|
+
maxPages?: number;
|
|
228
239
|
};
|
|
229
240
|
videoOptions?: {
|
|
230
241
|
/** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
|
|
@@ -30,6 +30,18 @@ export declare function isValidCsvRow(row: unknown): row is CSVRow;
|
|
|
30
30
|
* match) when a row violates the invariant.
|
|
31
31
|
*/
|
|
32
32
|
export declare function assertValidCsvRow(row: unknown, rowNumber: number): asserts row is CSVRow;
|
|
33
|
+
/**
|
|
34
|
+
* True when a parsed CSV row is blank: no keys, or every value is empty /
|
|
35
|
+
* whitespace-only (#373). Shared by the structured post-filter and by
|
|
36
|
+
* `streamParse` so blank rows do not consume `maxRows`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isBlankCsvDataRow(row: CSVRow): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Raw-line counterpart of {@link isBlankCsvDataRow} (#373): a fully blank /
|
|
41
|
+
* whitespace line, or a delimiter-only line such as `,,` / ` , ` whose
|
|
42
|
+
* fields are all empty after a CSV-aware split.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isBlankCsvRawLine(line: string, delimiter: string): boolean;
|
|
33
45
|
/**
|
|
34
46
|
* Detect if first line is CSV metadata (not actual data/headers)
|
|
35
47
|
* Common patterns:
|
|
@@ -174,11 +186,17 @@ export declare class CSVProcessor {
|
|
|
174
186
|
/**
|
|
175
187
|
* Format parsed CSV data for LLM consumption
|
|
176
188
|
* Only used for JSON and Markdown formats (raw format handled separately)
|
|
189
|
+
*
|
|
190
|
+
* @param columnNames - Optional schema headers (#373). When preserved blank
|
|
191
|
+
* rows lead `rows`, Markdown must still use the real column names.
|
|
177
192
|
*/
|
|
178
193
|
private static formatForLLM;
|
|
179
194
|
/**
|
|
180
195
|
* Format as markdown table
|
|
181
196
|
* Best for small datasets (<100 rows)
|
|
197
|
+
*
|
|
198
|
+
* @param columnNames - Optional explicit headers (#373). Falls back to
|
|
199
|
+
* `Object.keys(rows[0])` when omitted.
|
|
182
200
|
*/
|
|
183
201
|
private static toMarkdownTable;
|
|
184
202
|
/**
|
|
@@ -187,6 +205,7 @@ export declare class CSVProcessor {
|
|
|
187
205
|
* @param sampleRows - Array of sample row objects
|
|
188
206
|
* @param format - Output format for sample data
|
|
189
207
|
* @param includeHeaders - Whether to include headers in CSV/markdown formats
|
|
208
|
+
* @param columnNames - Optional schema headers for csv/markdown (#373)
|
|
190
209
|
* @returns Formatted sample data as string or array
|
|
191
210
|
*/
|
|
192
211
|
private static formatSampleData;
|
|
@@ -195,6 +214,7 @@ export declare class CSVProcessor {
|
|
|
195
214
|
*
|
|
196
215
|
* @param rows - Array of row objects
|
|
197
216
|
* @param includeHeaders - Whether to include header row
|
|
217
|
+
* @param columnNames - Optional explicit headers (#373)
|
|
198
218
|
* @returns CSV formatted string
|
|
199
219
|
*/
|
|
200
220
|
private static toCSVString;
|
|
@@ -180,6 +180,37 @@ export function assertValidCsvRow(row, rowNumber) {
|
|
|
180
180
|
throw ErrorFactory.csvRowInvalid(`[CSVProcessor] Invalid CSV row ${rowNumber}: expected a string-keyed object with string values, got ${Array.isArray(row) ? "array" : typeof row}`, rowNumber);
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* True when a parsed CSV row is blank: no keys, or every value is empty /
|
|
185
|
+
* whitespace-only (#373). Shared by the structured post-filter and by
|
|
186
|
+
* `streamParse` so blank rows do not consume `maxRows`.
|
|
187
|
+
*/
|
|
188
|
+
export function isBlankCsvDataRow(row) {
|
|
189
|
+
const keys = Object.keys(row);
|
|
190
|
+
if (keys.length === 0) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
return Object.values(row).every((val) => val === "" || (typeof val === "string" && val.trim() === ""));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Raw-line counterpart of {@link isBlankCsvDataRow} (#373): a fully blank /
|
|
197
|
+
* whitespace line, or a delimiter-only line such as `,,` / ` , ` whose
|
|
198
|
+
* fields are all empty after a CSV-aware split.
|
|
199
|
+
*/
|
|
200
|
+
export function isBlankCsvRawLine(line, delimiter) {
|
|
201
|
+
if (line.trim() === "") {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
const fields = splitCSVFields(line, delimiter);
|
|
205
|
+
if (fields.length === 0) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
const row = Object.create(null);
|
|
209
|
+
for (let i = 0; i < fields.length; i++) {
|
|
210
|
+
row[`c${i}`] = fields[i];
|
|
211
|
+
}
|
|
212
|
+
return isBlankCsvDataRow(row);
|
|
213
|
+
}
|
|
183
214
|
// ============================================================================
|
|
184
215
|
// Parse Error Context (#375)
|
|
185
216
|
// ============================================================================
|
|
@@ -624,18 +655,41 @@ function isMetadataLine(lines) {
|
|
|
624
655
|
}
|
|
625
656
|
const firstLine = lines[0].trim();
|
|
626
657
|
const secondLine = lines[1].trim();
|
|
658
|
+
// Excel's explicit-delimiter preamble is metadata regardless of what follows.
|
|
627
659
|
if (firstLine.match(/^sep=/i)) {
|
|
628
660
|
return true;
|
|
629
661
|
}
|
|
662
|
+
// #373: this check has to come BEFORE the comma-count heuristics, not after.
|
|
663
|
+
// Both of them read a higher comma count on line 2 as "line 1 was a preamble"
|
|
664
|
+
// — but a delimiter-only row (`,,`) is a blank DATA row, and its commas are
|
|
665
|
+
// structure, not fields. Ordered the other way, a legitimate single-column
|
|
666
|
+
// header followed by a blank row (`name` / `,,`) tripped the zero-vs-nonzero
|
|
667
|
+
// rule and stripMetadataLine() deleted the real header, leaving unnamed
|
|
668
|
+
// columns. Verified against `name\n,,\nalice\nbob`.
|
|
669
|
+
if (isDelimiterOnlyOrBlankLine(secondLine)) {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
630
672
|
const firstCommaCount = countUnquotedCommas(firstLine);
|
|
631
673
|
const secondCommaCount = countUnquotedCommas(secondLine);
|
|
674
|
+
// A title/preamble line carries no delimiters while the header below it does.
|
|
632
675
|
if (firstCommaCount === 0 && secondCommaCount > 0) {
|
|
633
676
|
return true;
|
|
634
677
|
}
|
|
635
|
-
|
|
678
|
+
// Differing field counts mean line 1 is not part of the same table as line 2.
|
|
679
|
+
return secondCommaCount > 0 && firstCommaCount !== secondCommaCount;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* True when `line` is empty/whitespace or contains only common CSV
|
|
683
|
+
* delimiters (comma / tab / semicolon / pipe) and whitespace — i.e. a
|
|
684
|
+
* delimiter-only blank row like `,,` or ` ; ; `. Used before the
|
|
685
|
+
* delimiter is known (metadata detection).
|
|
686
|
+
*/
|
|
687
|
+
function isDelimiterOnlyOrBlankLine(line) {
|
|
688
|
+
const trimmed = line.trim();
|
|
689
|
+
if (trimmed === "") {
|
|
636
690
|
return true;
|
|
637
691
|
}
|
|
638
|
-
return
|
|
692
|
+
return /^[\s,;\t|]+$/.test(trimmed) && /[,;\t|]/.test(trimmed);
|
|
639
693
|
}
|
|
640
694
|
/**
|
|
641
695
|
* Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
|
|
@@ -737,13 +791,14 @@ export class CSVProcessor {
|
|
|
737
791
|
* @returns Formatted CSV data ready for LLM (JSON or Markdown)
|
|
738
792
|
*/
|
|
739
793
|
static async process(content, options) {
|
|
740
|
-
const { maxRows: rawMaxRows = 1000, formatStyle = "raw", includeHeaders = true, sampleDataFormat = "json", extension = null, encoding, sanitizeColumnNames = false, columnNameCase = "snake_case", parseTimeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS, } = options || {};
|
|
794
|
+
const { maxRows: rawMaxRows = 1000, formatStyle = "raw", includeHeaders = true, sampleDataFormat = "json", extension = null, encoding, sanitizeColumnNames = false, columnNameCase = "snake_case", parseTimeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS, skipEmptyLines = true, } = options || {};
|
|
741
795
|
const maxRows = Math.max(1, Math.min(10000, rawMaxRows));
|
|
742
796
|
logger.debug("[CSVProcessor] Starting CSV processing", {
|
|
743
797
|
contentSize: content.length,
|
|
744
798
|
formatStyle,
|
|
745
799
|
maxRows,
|
|
746
800
|
includeHeaders,
|
|
801
|
+
skipEmptyLines,
|
|
747
802
|
});
|
|
748
803
|
// #362: detect the encoding (BOM → chardet → UTF-8 fallback) or honor the
|
|
749
804
|
// caller's override, instead of the previous hard-coded UTF-8 decode.
|
|
@@ -764,7 +819,15 @@ export class CSVProcessor {
|
|
|
764
819
|
}
|
|
765
820
|
// dataLines already has the metadata line (if any) stripped.
|
|
766
821
|
const csvLines = dataLines;
|
|
767
|
-
const
|
|
822
|
+
const headerLine = csvLines[0] ?? "";
|
|
823
|
+
const rawDataLines = csvLines.slice(1);
|
|
824
|
+
// #373: drop blank AND delimiter-only data lines (e.g. `,,`) so raw
|
|
825
|
+
// content matches structured `isBlankCsvDataRow` semantics.
|
|
826
|
+
const effectiveDataLines = skipEmptyLines
|
|
827
|
+
? rawDataLines.filter((line) => !isBlankCsvRawLine(line, delimiter))
|
|
828
|
+
: rawDataLines;
|
|
829
|
+
const limitedDataLines = effectiveDataLines.slice(0, maxRows);
|
|
830
|
+
const limitedLines = csvLines.length === 0 ? [] : [headerLine, ...limitedDataLines];
|
|
768
831
|
// #378: opt-in — rewrite the literal header line with sanitized,
|
|
769
832
|
// deduped identifiers so the raw CSV text shown to the LLM has clean
|
|
770
833
|
// column names too. (Splits on commas to match the existing raw-branch
|
|
@@ -780,12 +843,10 @@ export class CSVProcessor {
|
|
|
780
843
|
// #1199: quote-aware, delimiter-aware split (matches the
|
|
781
844
|
// sanitizeColumnNames branch above and #1192's detected delimiter).
|
|
782
845
|
const headerFields = splitCSVFields(limitedLines[0] || "", delimiter);
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
const originalRowCount =
|
|
787
|
-
.slice(1)
|
|
788
|
-
.filter((line) => line.trim() !== "").length;
|
|
846
|
+
// When preserving empties, blank lines count as rows; when skipping,
|
|
847
|
+
// only non-empty data lines do.
|
|
848
|
+
const rowCount = limitedDataLines.length;
|
|
849
|
+
const originalRowCount = effectiveDataLines.length;
|
|
789
850
|
const wasTruncated = rowCount < originalRowCount;
|
|
790
851
|
if (wasTruncated) {
|
|
791
852
|
logger.warn(`[CSVProcessor] CSV data truncated: showing ${rowCount} of ${originalRowCount} rows (limit: ${maxRows})`);
|
|
@@ -802,17 +863,16 @@ export class CSVProcessor {
|
|
|
802
863
|
truncated: wasTruncated,
|
|
803
864
|
});
|
|
804
865
|
// Parse a sample for enhanced metadata analysis (raw format still
|
|
805
|
-
// benefits from column analysis).
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
// back through parseCSVStringWithMeta(), which would re-split/re-join
|
|
809
|
-
// it and re-run a redundant BOM/metadata/delimiter-detection pass.
|
|
866
|
+
// benefits from column analysis). Feed the already-filtered limited
|
|
867
|
+
// lines and honor skipEmptyRows so blank/delimiter-only rows do not
|
|
868
|
+
// consume the sample limit (#373).
|
|
810
869
|
const sampleRows = Math.min(rowCount, 500);
|
|
811
|
-
const { rows: sampleForAnalysis, timedOut: rawTimedOut } = await this.streamParse(Readable.from([
|
|
870
|
+
const { rows: sampleForAnalysis, timedOut: rawTimedOut } = await this.streamParse(Readable.from([limitedLines.join("\n")]), undefined, {
|
|
812
871
|
maxRows: sampleRows,
|
|
813
872
|
skipLines: 0,
|
|
814
873
|
timeoutMs: parseTimeoutMs,
|
|
815
874
|
delimiter,
|
|
875
|
+
skipEmptyRows: skipEmptyLines,
|
|
816
876
|
});
|
|
817
877
|
const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(sampleForAnalysis);
|
|
818
878
|
// Log data quality summary
|
|
@@ -856,51 +916,83 @@ export class CSVProcessor {
|
|
|
856
916
|
// `delimiter`) — reuse both via the shared streamParse() core instead of
|
|
857
917
|
// routing csvString back through parseCSVStringWithMeta(), which would
|
|
858
918
|
// re-split it and re-run metadata/delimiter detection a second time.
|
|
859
|
-
const { rows, timedOut: structuredTimedOut } = await this.streamParse(Readable.from([dataLines.join("\n")]), undefined, {
|
|
860
|
-
|
|
919
|
+
const { rows, timedOut: structuredTimedOut, headers: parsedHeaders, } = await this.streamParse(Readable.from([dataLines.join("\n")]), undefined, {
|
|
920
|
+
maxRows,
|
|
921
|
+
skipLines: 0,
|
|
922
|
+
timeoutMs: parseTimeoutMs,
|
|
923
|
+
delimiter,
|
|
924
|
+
// Count only non-blank rows toward maxRows when skipping empties
|
|
925
|
+
// (#373), so maxRows: 2 still yields two real data rows if blanks
|
|
926
|
+
// appear first.
|
|
927
|
+
skipEmptyRows: skipEmptyLines,
|
|
928
|
+
});
|
|
929
|
+
// Filter out empty rows (empty objects or rows with only whitespace values
|
|
930
|
+
// from blank lines). #373: `skipEmptyLines` (default true) controls this;
|
|
931
|
+
// set false to preserve blank-line rows in structured output.
|
|
861
932
|
const filteredRows = rows.filter((row) => {
|
|
862
933
|
if (!row || typeof row !== "object") {
|
|
863
934
|
return false;
|
|
864
935
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
return false;
|
|
936
|
+
if (!skipEmptyLines) {
|
|
937
|
+
return true;
|
|
868
938
|
}
|
|
869
|
-
|
|
870
|
-
return !Object.values(row).every((val) => val === "" || (typeof val === "string" && val.trim() === ""));
|
|
939
|
+
return !isBlankCsvDataRow(row);
|
|
871
940
|
});
|
|
941
|
+
// Schema must come from parser headers or the first non-blank data row,
|
|
942
|
+
// never from a preserved leading blank row (#373 review).
|
|
943
|
+
const schemaRow = filteredRows.find((row) => row && typeof row === "object" && !isBlankCsvDataRow(row));
|
|
944
|
+
const schemaHeaders = (parsedHeaders && parsedHeaders.length > 0
|
|
945
|
+
? [...parsedHeaders]
|
|
946
|
+
: undefined) ??
|
|
947
|
+
(schemaRow ? Object.keys(schemaRow) : undefined) ??
|
|
948
|
+
(filteredRows[0] ? Object.keys(filteredRows[0]) : []) ??
|
|
949
|
+
[];
|
|
872
950
|
// #378: opt-in — remap each row's keys from original → sanitized identifiers.
|
|
951
|
+
// Always project onto schemaHeaders first so preserved blank rows still
|
|
952
|
+
// carry the real column keys for Markdown/JSON (#373 review).
|
|
873
953
|
let structuredColumnNameMapping;
|
|
874
954
|
const sanitizedToOriginal = new Map();
|
|
875
|
-
let
|
|
876
|
-
|
|
877
|
-
|
|
955
|
+
let columnNames = schemaHeaders;
|
|
956
|
+
let nonEmptyRows = schemaHeaders.length === 0
|
|
957
|
+
? filteredRows
|
|
958
|
+
: filteredRows.map((row) => {
|
|
959
|
+
const out = Object.create(null);
|
|
960
|
+
for (const h of schemaHeaders) {
|
|
961
|
+
out[h] = typeof row[h] === "string" ? row[h] : "";
|
|
962
|
+
}
|
|
963
|
+
return out;
|
|
964
|
+
});
|
|
965
|
+
if (sanitizeColumnNames && schemaHeaders.length > 0) {
|
|
966
|
+
const origHeaders = schemaHeaders;
|
|
878
967
|
const { sanitized, mapping } = buildColumnNameMapping(origHeaders, columnNameCase);
|
|
879
968
|
origHeaders.forEach((original, i) => {
|
|
880
969
|
if (original !== sanitized[i]) {
|
|
881
970
|
sanitizedToOriginal.set(sanitized[i], original);
|
|
882
971
|
}
|
|
883
972
|
});
|
|
884
|
-
nonEmptyRows =
|
|
973
|
+
nonEmptyRows = nonEmptyRows.map((row) => {
|
|
885
974
|
// Defense-in-depth: a null-prototype target means an attacker-chosen
|
|
886
975
|
// header literally named "__proto__"/"constructor"/"prototype" can
|
|
887
976
|
// only ever create a harmless own property, never touch
|
|
888
977
|
// Object.prototype (#1199).
|
|
889
978
|
const out = Object.create(null);
|
|
890
979
|
origHeaders.forEach((h, i) => {
|
|
891
|
-
out[sanitized[i]] = row[h];
|
|
980
|
+
out[sanitized[i]] = row[h] ?? "";
|
|
892
981
|
});
|
|
893
982
|
return out;
|
|
894
983
|
});
|
|
895
984
|
structuredColumnNameMapping = mapping.length > 0 ? mapping : undefined;
|
|
985
|
+
columnNames = sanitized;
|
|
896
986
|
}
|
|
897
987
|
// Extract metadata from parsed results
|
|
898
988
|
const rowCount = nonEmptyRows.length;
|
|
899
|
-
const columnNames = nonEmptyRows.length > 0 ? Object.keys(nonEmptyRows[0]) : [];
|
|
900
989
|
const columnCount = columnNames.length;
|
|
901
990
|
const hasEmptyColumns = columnNames.some((col) => !col || col.trim() === "");
|
|
902
|
-
|
|
903
|
-
|
|
991
|
+
// Sample / analysis should prefer non-blank rows so a preserved leading
|
|
992
|
+
// blank does not poison type detection or formatSampleData.
|
|
993
|
+
const rowsForAnalysis = nonEmptyRows.filter((row) => !isBlankCsvDataRow(row));
|
|
994
|
+
const sampleRows = (rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows).slice(0, 3);
|
|
995
|
+
const sampleData = this.formatSampleData(sampleRows, sampleDataFormat, includeHeaders, columnNames);
|
|
904
996
|
if (hasEmptyColumns) {
|
|
905
997
|
logger.warn("[CSVProcessor] CSV contains empty or blank column headers", {
|
|
906
998
|
columnNames,
|
|
@@ -909,8 +1001,9 @@ export class CSVProcessor {
|
|
|
909
1001
|
if (rowCount === 0) {
|
|
910
1002
|
logger.warn("[CSVProcessor] CSV file contains no data rows");
|
|
911
1003
|
}
|
|
912
|
-
// Perform enhanced column analysis
|
|
913
|
-
|
|
1004
|
+
// Perform enhanced column analysis on non-blank rows so preserved
|
|
1005
|
+
// leading blanks do not yield an empty schema (#373 review).
|
|
1006
|
+
const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows);
|
|
914
1007
|
// #378: carry the pre-sanitization header on each renamed column.
|
|
915
1008
|
if (sanitizedToOriginal.size > 0) {
|
|
916
1009
|
for (const col of columnMetadata) {
|
|
@@ -929,7 +1022,7 @@ export class CSVProcessor {
|
|
|
929
1022
|
}
|
|
930
1023
|
// Format parsed data
|
|
931
1024
|
logger.debug(`[CSVProcessor] Converting ${rowCount} rows to ${formatStyle} format`);
|
|
932
|
-
const formatted = this.formatForLLM(nonEmptyRows, formatStyle, includeHeaders);
|
|
1025
|
+
const formatted = this.formatForLLM(nonEmptyRows, formatStyle, includeHeaders, columnNames);
|
|
933
1026
|
logger.info("[CSVProcessor] ✅ Processed CSV file", {
|
|
934
1027
|
formatStyle,
|
|
935
1028
|
rowCount,
|
|
@@ -954,7 +1047,7 @@ export class CSVProcessor {
|
|
|
954
1047
|
columnMetadata,
|
|
955
1048
|
dataQualityWarnings,
|
|
956
1049
|
dataQualityScore,
|
|
957
|
-
hasHeaders: detectHasHeaders(columnNames, nonEmptyRows),
|
|
1050
|
+
hasHeaders: detectHasHeaders(columnNames, rowsForAnalysis.length > 0 ? rowsForAnalysis : nonEmptyRows),
|
|
958
1051
|
detectedDelimiter: delimiter,
|
|
959
1052
|
detectedEncoding,
|
|
960
1053
|
encodingConfidence,
|
|
@@ -1240,7 +1333,7 @@ export class CSVProcessor {
|
|
|
1240
1333
|
finish(() => {
|
|
1241
1334
|
logger.warn(`[CSVProcessor] Parse timed out after ${Date.now() - startTime}ms with ${rows.length} partial row(s)`);
|
|
1242
1335
|
abort();
|
|
1243
|
-
resolve({ rows, timedOut: true });
|
|
1336
|
+
resolve({ rows, timedOut: true, headers: capturedHeaders });
|
|
1244
1337
|
});
|
|
1245
1338
|
}, opts.timeoutMs);
|
|
1246
1339
|
if (typeof timer.unref === "function") {
|
|
@@ -1277,6 +1370,10 @@ export class CSVProcessor {
|
|
|
1277
1370
|
});
|
|
1278
1371
|
return;
|
|
1279
1372
|
}
|
|
1373
|
+
// #373: blank rows must not consume maxRows when skipping empties.
|
|
1374
|
+
if (opts.skipEmptyRows && isBlankCsvDataRow(row)) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1280
1377
|
lastRowColumnCount = Object.keys(row).length;
|
|
1281
1378
|
rows.push(row);
|
|
1282
1379
|
count++;
|
|
@@ -1284,14 +1381,14 @@ export class CSVProcessor {
|
|
|
1284
1381
|
logger.debug(`[CSVProcessor] Reached row limit ${opts.maxRows}, stopping parse`);
|
|
1285
1382
|
finish(() => {
|
|
1286
1383
|
abort();
|
|
1287
|
-
resolve({ rows, timedOut: false });
|
|
1384
|
+
resolve({ rows, timedOut: false, headers: capturedHeaders });
|
|
1288
1385
|
});
|
|
1289
1386
|
}
|
|
1290
1387
|
})
|
|
1291
1388
|
.on("end", () => {
|
|
1292
1389
|
finish(() => {
|
|
1293
1390
|
logger.debug(`[CSVProcessor] Parsing complete: ${rows.length} rows parsed`);
|
|
1294
|
-
resolve({ rows, timedOut: false });
|
|
1391
|
+
resolve({ rows, timedOut: false, headers: capturedHeaders });
|
|
1295
1392
|
});
|
|
1296
1393
|
})
|
|
1297
1394
|
.on("error", (error) => {
|
|
@@ -1315,25 +1412,36 @@ export class CSVProcessor {
|
|
|
1315
1412
|
/**
|
|
1316
1413
|
* Format parsed CSV data for LLM consumption
|
|
1317
1414
|
* Only used for JSON and Markdown formats (raw format handled separately)
|
|
1415
|
+
*
|
|
1416
|
+
* @param columnNames - Optional schema headers (#373). When preserved blank
|
|
1417
|
+
* rows lead `rows`, Markdown must still use the real column names.
|
|
1318
1418
|
*/
|
|
1319
|
-
static formatForLLM(rows, formatStyle, includeHeaders) {
|
|
1419
|
+
static formatForLLM(rows, formatStyle, includeHeaders, columnNames) {
|
|
1320
1420
|
if (rows.length === 0) {
|
|
1321
1421
|
return "CSV file is empty or contains no data.";
|
|
1322
1422
|
}
|
|
1323
1423
|
if (formatStyle === "json") {
|
|
1324
1424
|
return JSON.stringify(rows, null, 2);
|
|
1325
1425
|
}
|
|
1326
|
-
return this.toMarkdownTable(rows, includeHeaders);
|
|
1426
|
+
return this.toMarkdownTable(rows, includeHeaders, columnNames);
|
|
1327
1427
|
}
|
|
1328
1428
|
/**
|
|
1329
1429
|
* Format as markdown table
|
|
1330
1430
|
* Best for small datasets (<100 rows)
|
|
1431
|
+
*
|
|
1432
|
+
* @param columnNames - Optional explicit headers (#373). Falls back to
|
|
1433
|
+
* `Object.keys(rows[0])` when omitted.
|
|
1331
1434
|
*/
|
|
1332
|
-
static toMarkdownTable(rows, includeHeaders) {
|
|
1435
|
+
static toMarkdownTable(rows, includeHeaders, columnNames) {
|
|
1333
1436
|
if (rows.length === 0) {
|
|
1334
1437
|
return "CSV file is empty or contains no data.";
|
|
1335
1438
|
}
|
|
1336
|
-
const headers =
|
|
1439
|
+
const headers = columnNames && columnNames.length > 0
|
|
1440
|
+
? columnNames
|
|
1441
|
+
: Object.keys(rows[0] ?? {});
|
|
1442
|
+
if (headers.length === 0) {
|
|
1443
|
+
return "CSV file is empty or contains no data.";
|
|
1444
|
+
}
|
|
1337
1445
|
// Escape backslashes, pipes, and sanitize newlines to keep rows intact
|
|
1338
1446
|
const escapePipe = (str) => str
|
|
1339
1447
|
.replace(/\\/g, "\\\\")
|
|
@@ -1358,9 +1466,10 @@ export class CSVProcessor {
|
|
|
1358
1466
|
* @param sampleRows - Array of sample row objects
|
|
1359
1467
|
* @param format - Output format for sample data
|
|
1360
1468
|
* @param includeHeaders - Whether to include headers in CSV/markdown formats
|
|
1469
|
+
* @param columnNames - Optional schema headers for csv/markdown (#373)
|
|
1361
1470
|
* @returns Formatted sample data as string or array
|
|
1362
1471
|
*/
|
|
1363
|
-
static formatSampleData(sampleRows, format, includeHeaders) {
|
|
1472
|
+
static formatSampleData(sampleRows, format, includeHeaders, columnNames) {
|
|
1364
1473
|
if (sampleRows.length === 0) {
|
|
1365
1474
|
return format === "object" ? [] : "No data rows";
|
|
1366
1475
|
}
|
|
@@ -1370,9 +1479,9 @@ export class CSVProcessor {
|
|
|
1370
1479
|
case "json":
|
|
1371
1480
|
return JSON.stringify(sampleRows, null, 2);
|
|
1372
1481
|
case "csv":
|
|
1373
|
-
return this.toCSVString(sampleRows, includeHeaders);
|
|
1482
|
+
return this.toCSVString(sampleRows, includeHeaders, columnNames);
|
|
1374
1483
|
case "markdown":
|
|
1375
|
-
return this.toMarkdownTable(sampleRows, includeHeaders);
|
|
1484
|
+
return this.toMarkdownTable(sampleRows, includeHeaders, columnNames);
|
|
1376
1485
|
default:
|
|
1377
1486
|
return sampleRows;
|
|
1378
1487
|
}
|
|
@@ -1382,13 +1491,19 @@ export class CSVProcessor {
|
|
|
1382
1491
|
*
|
|
1383
1492
|
* @param rows - Array of row objects
|
|
1384
1493
|
* @param includeHeaders - Whether to include header row
|
|
1494
|
+
* @param columnNames - Optional explicit headers (#373)
|
|
1385
1495
|
* @returns CSV formatted string
|
|
1386
1496
|
*/
|
|
1387
|
-
static toCSVString(rows, includeHeaders) {
|
|
1497
|
+
static toCSVString(rows, includeHeaders, columnNames) {
|
|
1388
1498
|
if (rows.length === 0) {
|
|
1389
1499
|
return "";
|
|
1390
1500
|
}
|
|
1391
|
-
const headers =
|
|
1501
|
+
const headers = columnNames && columnNames.length > 0
|
|
1502
|
+
? columnNames
|
|
1503
|
+
: Object.keys(rows[0] ?? {});
|
|
1504
|
+
if (headers.length === 0) {
|
|
1505
|
+
return "";
|
|
1506
|
+
}
|
|
1392
1507
|
// Escape CSV values (wrap in quotes if contains comma, quote, or newline)
|
|
1393
1508
|
const escapeCSV = (value) => {
|
|
1394
1509
|
if (value.includes(",") ||
|