@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
|
@@ -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(",") ||
|
|
@@ -4,6 +4,7 @@ import { getGlobalDispatcher, interceptors, request } from "undici";
|
|
|
4
4
|
import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
|
|
5
5
|
import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
|
|
6
6
|
import { getAvailableInputTokens } from "../constants/contextWindows.js";
|
|
7
|
+
import { PDF_LIMITS } from "../core/constants.js";
|
|
7
8
|
import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
|
|
8
9
|
import { isCSVContent, SIZE_TIER_THRESHOLDS } from "../types/index.js";
|
|
9
10
|
import { tracers, ATTR, withSpan } from "../telemetry/index.js";
|
|
@@ -1150,6 +1151,10 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
|
|
|
1150
1151
|
// #260: carry the per-page canvas-pixel ceiling so the caller can
|
|
1151
1152
|
// raise (or lower) the memory guard for the image-fallback render.
|
|
1152
1153
|
maxCanvasPixels: options.pdfOptions?.maxCanvasPixels,
|
|
1154
|
+
// #297: render scale / page ceiling, so the lowered default is
|
|
1155
|
+
// actually reachable and callers can trade sharpness for memory.
|
|
1156
|
+
scale: options.pdfOptions?.scale,
|
|
1157
|
+
maxPages: options.pdfOptions?.maxPages,
|
|
1153
1158
|
});
|
|
1154
1159
|
logger.info(`[PDF] ✅ Queued for multimodal: ${filename} (${result.metadata?.estimatedPages ?? "unknown"} pages)`);
|
|
1155
1160
|
}
|
|
@@ -1458,6 +1463,8 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
|
|
|
1458
1463
|
// guard as the `input.pdfFiles` path (see `processExplicitPdfFiles`).
|
|
1459
1464
|
password: pdfOptions?.password,
|
|
1460
1465
|
maxCanvasPixels: pdfOptions?.maxCanvasPixels,
|
|
1466
|
+
scale: pdfOptions?.scale,
|
|
1467
|
+
maxPages: pdfOptions?.maxPages,
|
|
1461
1468
|
}));
|
|
1462
1469
|
// #309: same aggregate ceiling as `input.pdfFiles`. Without this, moving an
|
|
1463
1470
|
// over-limit payload from `input.pdfFiles` to `input.content` skipped the
|
|
@@ -1809,7 +1816,10 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
|
|
|
1809
1816
|
/**
|
|
1810
1817
|
* Convert multimodal content (images + PDFs) to provider format
|
|
1811
1818
|
*/
|
|
1812
|
-
async function convertMultimodalToProviderFormat(text, images,
|
|
1819
|
+
async function convertMultimodalToProviderFormat(text, images,
|
|
1820
|
+
// The canonical entry shape (#309) rather than a fourth copy of it inline —
|
|
1821
|
+
// which is what let the render knobs stop short of this function.
|
|
1822
|
+
pdfFiles, provider, model) {
|
|
1813
1823
|
const content = [
|
|
1814
1824
|
{ type: "text", text },
|
|
1815
1825
|
];
|
|
@@ -1843,14 +1853,41 @@ async function convertMultimodalToProviderFormat(text, images, pdfFiles, provide
|
|
|
1843
1853
|
logger.info(`[PDF→Image] Provider ${provider} doesn't support native PDF. Converting ${pdfFiles.length} PDF(s) to images...`);
|
|
1844
1854
|
for (const pdf of pdfFiles) {
|
|
1845
1855
|
try {
|
|
1856
|
+
const effectiveMaxPages = pdf.maxPages ?? PDF_LIMITS.DEFAULT_MAX_PAGES;
|
|
1846
1857
|
const conversionResult = await PDFImageConverter.convertToImages(pdf.buffer, {
|
|
1847
|
-
|
|
1848
|
-
|
|
1858
|
+
// #297: this is the only PDF→image call the product actually makes,
|
|
1859
|
+
// and it used to hardcode scale 2.0 — silently overriding the
|
|
1860
|
+
// lowered PDF_LIMITS.DEFAULT_SCALE and keeping the memory cost the
|
|
1861
|
+
// issue reports (a 100-page render at 2.0 is ~776MB; 1.5 is ~44%
|
|
1862
|
+
// fewer pixels per page). Callers can raise it back per request.
|
|
1863
|
+
scale: pdf.scale ?? PDF_LIMITS.DEFAULT_SCALE,
|
|
1864
|
+
// Page ceiling guards token overflow; also now caller-adjustable
|
|
1865
|
+
// rather than a constant nothing could reach.
|
|
1866
|
+
maxPages: effectiveMaxPages,
|
|
1849
1867
|
...(pdf.password ? { password: pdf.password } : {}), // #258
|
|
1850
1868
|
...(pdf.maxCanvasPixels
|
|
1851
1869
|
? { maxCanvasPixels: pdf.maxCanvasPixels }
|
|
1852
1870
|
: {}), // #260
|
|
1853
1871
|
});
|
|
1872
|
+
// The renderer stops at maxPages, so a longer document is silently
|
|
1873
|
+
// truncated — say so rather than letting the model answer from a
|
|
1874
|
+
// partial document as though it had the whole thing.
|
|
1875
|
+
//
|
|
1876
|
+
// Keyed on the cap being reached, not on pdf.pageCount: that field is
|
|
1877
|
+
// null whenever `input.content` omits `metadata.pages`, which is the
|
|
1878
|
+
// common case, so a page-count comparison would simply never fire
|
|
1879
|
+
// there. Reaching the cap is also unambiguous — a short count caused by
|
|
1880
|
+
// per-page render failures (#294 isolates those into `errors`) would
|
|
1881
|
+
// otherwise be misreported as a maxPages truncation.
|
|
1882
|
+
if (conversionResult.pageCount >= effectiveMaxPages) {
|
|
1883
|
+
logger.warn(`[PDF→Image] ${safeBasename(pdf.filename)} hit the ${effectiveMaxPages}-page ` +
|
|
1884
|
+
`conversion limit. Any pages beyond that were not sent — the model may be ` +
|
|
1885
|
+
`answering from a partial document. Raise pdfOptions.maxPages or split the file.`);
|
|
1886
|
+
}
|
|
1887
|
+
if (conversionResult.errors && conversionResult.errors.length > 0) {
|
|
1888
|
+
logger.warn(`[PDF→Image] ${safeBasename(pdf.filename)}: ${conversionResult.errors.length} page(s) ` +
|
|
1889
|
+
`failed to render and were omitted (page ${conversionResult.errors.map((e) => e.page).join(", ")}).`);
|
|
1890
|
+
}
|
|
1854
1891
|
logger.info(`[PDF→Image] ✅ Converted ${pdf.filename}: ${conversionResult.pageCount} page(s) → images`);
|
|
1855
1892
|
// Add each page as an ImagePart (raw base64, not data: URI — see SSRF note above)
|
|
1856
1893
|
conversionResult.images.forEach((base64Image, pageIndex) => {
|
|
@@ -58,6 +58,8 @@ export declare function buildMultimodalOptions(options: StreamOptions, providerN
|
|
|
58
58
|
pdfOptions: {
|
|
59
59
|
password?: string;
|
|
60
60
|
maxCanvasPixels?: number;
|
|
61
|
+
scale?: number;
|
|
62
|
+
maxPages?: number;
|
|
61
63
|
} | undefined;
|
|
62
64
|
systemPrompt: string | undefined;
|
|
63
65
|
conversationHistory: import("../types/conversation.js").ChatMessage[] | undefined;
|
|
@@ -377,6 +377,7 @@ export class PDFProcessor {
|
|
|
377
377
|
format,
|
|
378
378
|
scale,
|
|
379
379
|
maxCanvasPixels,
|
|
380
|
+
maxPages,
|
|
380
381
|
});
|
|
381
382
|
logger.debug("[PDF→Image] ✅ PDF validation passed", {
|
|
382
383
|
bufferSize: pdfBuffer.length,
|
|
@@ -535,6 +536,14 @@ export class PDFProcessor {
|
|
|
535
536
|
if (!Number.isFinite(opts.maxCanvasPixels) || opts.maxCanvasPixels <= 0) {
|
|
536
537
|
throw new Error(`Invalid maxCanvasPixels: ${opts.maxCanvasPixels}. Must be a finite number greater than 0.`);
|
|
537
538
|
}
|
|
539
|
+
// #297: maxPages became caller-controlled, and an unvalidated 0/-1/NaN
|
|
540
|
+
// silently converts nothing, surfacing later as a misleading
|
|
541
|
+
// "PDF has 0 pages" from deep inside the renderer. Reject it here where
|
|
542
|
+
// the message can still name the offending option.
|
|
543
|
+
if (opts.maxPages !== undefined &&
|
|
544
|
+
(!Number.isInteger(opts.maxPages) || opts.maxPages < 1)) {
|
|
545
|
+
throw new Error(`Invalid maxPages: ${opts.maxPages}. Must be a whole number of at least 1.`);
|
|
546
|
+
}
|
|
538
547
|
if (!pdfBuffer || pdfBuffer.length < 5) {
|
|
539
548
|
throw new Error("Invalid PDF: Buffer is too small or empty. " +
|
|
540
549
|
"A valid PDF must be at least 5 bytes (PDF header).");
|
|
@@ -570,6 +579,7 @@ export class PDFProcessor {
|
|
|
570
579
|
format,
|
|
571
580
|
scale,
|
|
572
581
|
maxCanvasPixels,
|
|
582
|
+
maxPages,
|
|
573
583
|
});
|
|
574
584
|
const pdfToImgModule = await import("pdf-to-img");
|
|
575
585
|
const pdf = pdfToImgModule.pdf;
|
|
@@ -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
|
// ===========================================================================
|