@juspay/neurolink 10.10.9 → 10.10.11
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 +13 -0
- package/dist/browser/neurolink.min.js +392 -392
- package/dist/cli/factories/commandFactory.js +6 -0
- package/dist/lib/processors/media/AudioProcessor.js +33 -15
- package/dist/lib/types/file.d.ts +6 -0
- package/dist/lib/types/processor.d.ts +6 -0
- package/dist/lib/utils/csvProcessor.d.ts +20 -0
- package/dist/lib/utils/csvProcessor.js +162 -47
- package/dist/lib/voice/providers/OpenAITTS.d.ts +8 -2
- package/dist/lib/voice/providers/OpenAITTS.js +17 -3
- package/dist/processors/media/AudioProcessor.js +33 -15
- package/dist/types/file.d.ts +6 -0
- package/dist/types/processor.d.ts +6 -0
- package/dist/utils/csvProcessor.d.ts +20 -0
- package/dist/utils/csvProcessor.js +162 -47
- package/dist/voice/providers/OpenAITTS.d.ts +8 -2
- package/dist/voice/providers/OpenAITTS.js +17 -3
- package/package.json +1 -1
|
@@ -228,6 +228,11 @@ export class CLICommandFactory {
|
|
|
228
228
|
type: "number",
|
|
229
229
|
description: "Wall-clock cap (ms) for CSV parsing; returns partial rows on timeout (#379).",
|
|
230
230
|
},
|
|
231
|
+
"csv-skip-empty-lines": {
|
|
232
|
+
type: "boolean",
|
|
233
|
+
default: true,
|
|
234
|
+
description: "Skip blank/whitespace-only CSV data rows in content and rowCount (default true). Use --no-csv-skip-empty-lines to preserve them (#373).",
|
|
235
|
+
},
|
|
231
236
|
model: {
|
|
232
237
|
type: "string",
|
|
233
238
|
description: "Specific model to use (e.g. gemini-2.5-pro, gemini-2.5-flash)",
|
|
@@ -2441,6 +2446,7 @@ export class CLICommandFactory {
|
|
|
2441
2446
|
sanitizeColumnNames: argv.csvSanitizeNames,
|
|
2442
2447
|
columnNameCase: argv.csvNameCase,
|
|
2443
2448
|
parseTimeoutMs: argv.csvParseTimeoutMs,
|
|
2449
|
+
skipEmptyLines: argv.csvSkipEmptyLines,
|
|
2444
2450
|
};
|
|
2445
2451
|
}
|
|
2446
2452
|
/**
|
|
@@ -41,6 +41,7 @@ import { SIZE_LIMITS_MB } from "../config/index.js";
|
|
|
41
41
|
import { FileErrorCode } from "../errors/index.js";
|
|
42
42
|
import { withTimeout } from "../../utils/timeout.js";
|
|
43
43
|
import { formatMediaDuration } from "../../utils/mediaDuration.js";
|
|
44
|
+
import { logger } from "../../utils/logger.js";
|
|
44
45
|
import { tryImport } from "../../utils/tryImport.js";
|
|
45
46
|
let _musicMetadata = null;
|
|
46
47
|
async function loadMusicMetadata() {
|
|
@@ -264,6 +265,11 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
264
265
|
transcript: transcriptionResult.transcript,
|
|
265
266
|
hasTranscript: transcriptionResult.hasTranscript,
|
|
266
267
|
transcriptionProvider: transcriptionResult.transcriptionProvider,
|
|
268
|
+
...(transcriptionResult.transcriptionSkippedReason
|
|
269
|
+
? {
|
|
270
|
+
transcriptionSkippedReason: transcriptionResult.transcriptionSkippedReason,
|
|
271
|
+
}
|
|
272
|
+
: {}),
|
|
267
273
|
coverArt: coverArt ?? undefined,
|
|
268
274
|
buffer,
|
|
269
275
|
mimetype: fileInfo.mimetype || "audio/mpeg",
|
|
@@ -316,20 +322,25 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
316
322
|
* @returns Transcription result with transcript text, or empty result
|
|
317
323
|
*/
|
|
318
324
|
async attemptTranscription(buffer, filename, mimetype) {
|
|
319
|
-
const
|
|
320
|
-
transcript:
|
|
321
|
-
|
|
322
|
-
|
|
325
|
+
const skipped = (reason) => {
|
|
326
|
+
logger.warn(`[AudioProcessor] No transcript for ${filename}: ${reason}. ` +
|
|
327
|
+
`The model will receive metadata only.`);
|
|
328
|
+
return {
|
|
329
|
+
transcript: undefined,
|
|
330
|
+
hasTranscript: false,
|
|
331
|
+
transcriptionProvider: undefined,
|
|
332
|
+
transcriptionSkippedReason: reason,
|
|
333
|
+
};
|
|
323
334
|
};
|
|
324
335
|
// Check if OPENAI_API_KEY is available
|
|
325
336
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
326
337
|
if (!apiKey) {
|
|
327
|
-
return
|
|
338
|
+
return skipped("OPENAI_API_KEY is not set, and Whisper is the only transcription backend wired up");
|
|
328
339
|
}
|
|
329
340
|
// Check file size (Whisper limit is 25MB)
|
|
330
341
|
const fileSizeMB = buffer.length / (1024 * 1024);
|
|
331
342
|
if (fileSizeMB > AUDIO_CONFIG.WHISPER_MAX_SIZE_MB) {
|
|
332
|
-
return
|
|
343
|
+
return skipped(`file is ${fileSizeMB.toFixed(1)}MB, over Whisper's ${AUDIO_CONFIG.WHISPER_MAX_SIZE_MB}MB limit — split or compress it`);
|
|
333
344
|
}
|
|
334
345
|
// Check if file format is supported by Whisper
|
|
335
346
|
const ext = filename.split(".").pop()?.toLowerCase();
|
|
@@ -343,7 +354,7 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
343
354
|
mimetype.startsWith("audio/ogg") ||
|
|
344
355
|
mimetype.startsWith("audio/x-m4a"));
|
|
345
356
|
if (!isFormatSupported && !isMimeSupported) {
|
|
346
|
-
return
|
|
357
|
+
return skipped(`format is not one Whisper accepts (extension "${ext ?? "none"}", mimetype "${mimetype ?? "none"}"); supported: ${AUDIO_CONFIG.WHISPER_SUPPORTED_FORMATS.join(", ")}`);
|
|
347
358
|
}
|
|
348
359
|
try {
|
|
349
360
|
// Dynamic imports to avoid loading these modules when transcription is not needed
|
|
@@ -351,26 +362,33 @@ export class AudioProcessor extends BaseFileProcessor {
|
|
|
351
362
|
const openai = createOpenAI({ apiKey });
|
|
352
363
|
const model = openai.transcription("whisper-1");
|
|
353
364
|
// Wrap in withTimeout — large audio files can take a while, but a
|
|
354
|
-
// stalled request shouldn't block the processor forever.
|
|
355
|
-
//
|
|
356
|
-
//
|
|
365
|
+
// stalled request shouldn't block the processor forever. A TimeoutError
|
|
366
|
+
// lands in the same handler as other failures below, which reports it as
|
|
367
|
+
// the reason rather than discarding it.
|
|
357
368
|
const result = await withTimeout(experimental_transcribe({
|
|
358
369
|
model,
|
|
359
370
|
audio: buffer,
|
|
360
371
|
}), AUDIO_CONFIG.TRANSCRIPTION_TIMEOUT_MS, "openai-whisper", "generate");
|
|
361
372
|
if (result.text && result.text.trim().length > 0) {
|
|
373
|
+
logger.debug(`[AudioProcessor] Transcribed ${filename} via openai-whisper (${result.text.trim().length} chars)`);
|
|
362
374
|
return {
|
|
363
375
|
transcript: result.text.trim(),
|
|
364
376
|
hasTranscript: true,
|
|
365
377
|
transcriptionProvider: "openai-whisper",
|
|
378
|
+
transcriptionSkippedReason: undefined,
|
|
366
379
|
};
|
|
367
380
|
}
|
|
368
|
-
|
|
381
|
+
// A successful call that returned nothing is a legitimate outcome
|
|
382
|
+
// (silence, music, no speech) — distinct from a failure.
|
|
383
|
+
return skipped("Whisper returned an empty transcript for this audio");
|
|
369
384
|
}
|
|
370
|
-
catch {
|
|
371
|
-
// Transcription
|
|
372
|
-
//
|
|
373
|
-
|
|
385
|
+
catch (error) {
|
|
386
|
+
// Transcription stays best-effort — a failure must never kill the whole
|
|
387
|
+
// processing pipeline. But discarding the error outright, as this block
|
|
388
|
+
// used to, made a bad API key, a rate limit and a network blip all look
|
|
389
|
+
// identical to "this file has no speech in it".
|
|
390
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
391
|
+
return skipped(`transcription request failed — ${message}`);
|
|
374
392
|
}
|
|
375
393
|
}
|
|
376
394
|
// ===========================================================================
|
package/dist/lib/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
|
|
@@ -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
|
/**
|
|
@@ -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(",") ||
|
|
@@ -30,8 +30,14 @@ export declare class OpenAITTS implements TTSHandler {
|
|
|
30
30
|
synthesize(text: string, options?: TTSOptions): Promise<TTSResult>;
|
|
31
31
|
/**
|
|
32
32
|
* Map TTSAudioFormat to OpenAI response_format.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
*
|
|
34
|
+
* OpenAI's /audio/speech accepts mp3, opus, aac, flac, wav and pcm. This map
|
|
35
|
+
* previously stopped at mp3/wav/ogg/opus/pcm16, so `flac` — a valid
|
|
36
|
+
* TTSAudioFormat *and* a real OpenAI response_format — was treated as
|
|
37
|
+
* unsupported and silently downgraded to mp3 (#479). A caller who asked for
|
|
38
|
+
* lossless got lossy, and the only signal was a warn-level log.
|
|
39
|
+
*
|
|
40
|
+
* Formats OpenAI genuinely cannot produce still coerce to mp3 with a warning.
|
|
35
41
|
*/
|
|
36
42
|
private mapFormat;
|
|
37
43
|
/**
|