@juspay/neurolink 12.31.1 → 12.32.1

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.
@@ -714,6 +714,25 @@ export type ProcessedAudio = ProcessedFileBase & {
714
714
  transcript?: string;
715
715
  hasTranscript: boolean;
716
716
  transcriptionProvider?: string;
717
+ /**
718
+ * Language the transcription backend reported for the speech (#409), when
719
+ * it reported one.
720
+ *
721
+ * Not always a *detected* language: when the backend's response carries no
722
+ * language (Whisper's `verbose_json` usually does; Google/Azure responses
723
+ * vary), this falls back to the caller-requested `options.language` instead
724
+ * of going unset. A caller that must distinguish "the backend detected X"
725
+ * from "X is just what was asked for" cannot do so from this field alone.
726
+ */
727
+ transcriptionLanguage?: string;
728
+ /**
729
+ * Audio duration in seconds as measured by the transcription backend (#409).
730
+ *
731
+ * Kept apart from `metadata.duration`, which comes from the container
732
+ * header: the two disagree on a file with a broken or absent header, and the
733
+ * header is the one that is available without a transcription call.
734
+ */
735
+ transcriptionDuration?: number;
717
736
  /**
718
737
  * Why transcription produced nothing, when it did (#416). Absent on success.
719
738
  * Lets a caller distinguish "this audio has no speech" from "the transcription
@@ -401,6 +401,23 @@ export type StreamOptions = {
401
401
  */
402
402
  outputFormat?: VisionImageOutputFormat;
403
403
  };
404
+ /**
405
+ * Audio transcription options for attached audio files (#413/#440).
406
+ *
407
+ * Mirrors `GenerateOptions.audioOptions`. `stream()` spreads its options
408
+ * rather than rebuilding them field by field, so declaring it here is what
409
+ * lets it reach the message builder.
410
+ */
411
+ audioOptions?: {
412
+ /** Backend: "openai" (aliases "whisper"), "google" or "azure". */
413
+ provider?: string;
414
+ /** Transcription model, e.g. "whisper-1". Backend-specific. */
415
+ transcriptionModel?: string;
416
+ /** Language hint, e.g. "en". */
417
+ language?: string;
418
+ /** OpenAI/Whisper-only context prompt to bias transcription; ignored by Google and Azure. */
419
+ prompt?: string;
420
+ };
404
421
  /**
405
422
  * Text-to-Speech (TTS) configuration for streaming
406
423
  *
@@ -340,15 +340,13 @@ export class FileDetector {
340
340
  }
341
341
  logger.warn(`[FileDetector] All fallback parsing failed for type "${detection.type}". ` +
342
342
  `Attempted: ${options.allowedTypes.join(", ")}. Falling through to universal handler.`);
343
- const csvOptions = options?.csvOptions;
344
- const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions, options?.officeOptions);
343
+ const result = await FileDetector.processFile(content, detection, options);
345
344
  FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
346
345
  return result;
347
346
  }
348
347
  const content = loadedUrl?.content ??
349
348
  (await FileDetector.loadContent(input, detection, options));
350
- const csvOptions = options?.csvOptions;
351
- const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions, options?.officeOptions);
349
+ const result = await FileDetector.processFile(content, detection, options);
352
350
  FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
353
351
  return result;
354
352
  });
@@ -1063,31 +1061,33 @@ export class FileDetector {
1063
1061
  /**
1064
1062
  * Route to appropriate processor
1065
1063
  */
1066
- static async processFile(content, detection, options, provider, videoOptions, officeOptions) {
1064
+ static async processFile(content, detection, options) {
1067
1065
  switch (detection.type) {
1068
1066
  case "csv":
1069
1067
  // Pass original extension through to CSV processor; if detection has none,
1070
1068
  // fall back to any extension provided in csvOptions.
1071
1069
  return await CSVProcessor.process(content, {
1072
- ...options,
1073
- extension: detection.extension ?? options?.extension,
1070
+ ...options?.csvOptions,
1071
+ extension: detection.extension ?? options?.csvOptions?.extension,
1074
1072
  });
1075
1073
  case "image":
1076
1074
  return await ImageProcessor.process(content);
1077
1075
  case "pdf":
1078
- return await PDFProcessor.process(content, { provider });
1076
+ return await PDFProcessor.process(content, {
1077
+ provider: options?.provider,
1078
+ });
1079
1079
  case "svg":
1080
1080
  // SVG is processed as text content (sanitized XML markup)
1081
1081
  // AI providers don't support SVG as image format, so we extract text content
1082
1082
  return await FileDetector.processSvgAsText(content, detection);
1083
1083
  case "video":
1084
- return await FileDetector.processVideoFile(content, detection, videoOptions);
1084
+ return await FileDetector.processVideoFile(content, detection, options?.videoOptions);
1085
1085
  case "audio":
1086
- return await FileDetector.processAudioFile(content, detection);
1086
+ return await FileDetector.processAudioFile(content, detection, options?.audioOptions);
1087
1087
  case "archive":
1088
1088
  return await FileDetector.processArchiveFile(content, detection);
1089
1089
  case "xlsx":
1090
- return await FileDetector.processXlsxFile(content, detection, officeOptions);
1090
+ return await FileDetector.processXlsxFile(content, detection, options?.officeOptions);
1091
1091
  case "docx":
1092
1092
  return await FileDetector.processDocxFile(content, detection);
1093
1093
  case "pptx":
@@ -1197,7 +1197,7 @@ export class FileDetector {
1197
1197
  /**
1198
1198
  * Process audio file: extract metadata, tags, and cover art via AudioProcessor
1199
1199
  */
1200
- static async processAudioFile(content, detection) {
1200
+ static async processAudioFile(content, detection, audioOptions) {
1201
1201
  const audioFilename = detection.metadata.filename || "audio";
1202
1202
  try {
1203
1203
  const audioResult = await (await getAudioProcessor()).processFile({
@@ -1206,18 +1206,40 @@ export class FileDetector {
1206
1206
  mimetype: detection.mimeType || "audio/mpeg",
1207
1207
  size: content.length,
1208
1208
  buffer: content,
1209
- });
1209
+ },
1210
+ // #440: carry the caller's transcription provider/language/prompt
1211
+ // through to the processor; previously these stopped here.
1212
+ audioOptions);
1210
1213
  if (audioResult.success && audioResult.data) {
1214
+ const audio = audioResult.data;
1211
1215
  return {
1212
1216
  type: "audio",
1213
- content: audioResult.data.textContent ||
1217
+ content: audio.textContent ||
1214
1218
  FileDetector.formatInformativePlaceholder("Audio", audioFilename, content, detection),
1215
1219
  mimeType: detection.mimeType,
1216
1220
  // Surface embedded cover art as an image content block
1217
- images: audioResult.data.coverArt
1218
- ? [audioResult.data.coverArt]
1219
- : undefined,
1220
- metadata: detection.metadata,
1221
+ images: audio.coverArt ? [audio.coverArt] : undefined,
1222
+ metadata: {
1223
+ ...detection.metadata,
1224
+ // #409: the processor's audio metadata used to stop here —
1225
+ // `detection.metadata` alone carries only size/filename/confidence,
1226
+ // so duration and everything about the transcript were dropped by
1227
+ // the very call that produced them.
1228
+ ...(audio.metadata.duration > 0
1229
+ ? { duration: audio.metadata.duration }
1230
+ : {}),
1231
+ ...(audio.transcriptionLanguage
1232
+ ? { language: audio.transcriptionLanguage }
1233
+ : {}),
1234
+ // Written whenever a backend actually ran, so 0 (a real empty
1235
+ // transcript) stays distinguishable from "never attempted".
1236
+ ...(audio.transcriptionProvider
1237
+ ? {
1238
+ transcriptionLength: audio.transcript?.length ?? 0,
1239
+ transcriptionProvider: audio.transcriptionProvider,
1240
+ }
1241
+ : {}),
1242
+ },
1221
1243
  };
1222
1244
  }
1223
1245
  }
@@ -11,7 +11,7 @@ import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../contex
11
11
  import { isCSVContent, isTextContent, SIZE_TIER_THRESHOLDS, } from "../types/index.js";
12
12
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
13
13
  import { EXIF_PROBE_PREFIX_BYTES, mayCarryExifOrientation, needsVisionTranscode, normalizeImageOrientation, probeExifOrientation, toVisionCompatibleImage, } from "../adapters/imageFormatSupport.js";
14
- import { FILE_TYPE_REGISTRY, lookupByExtension, } from "../processors/config/fileTypeRegistry.js";
14
+ import { extensionsForModality, FILE_TYPE_REGISTRY, lookupByExtension, } from "../processors/config/fileTypeRegistry.js";
15
15
  import { ErrorFactory, NeuroLinkError, withTimeout } from "./errorHandling.js";
16
16
  import { FileDetector } from "./fileDetector.js";
17
17
  import { detectIsoBmffImageMimeType } from "./isoBmff.js";
@@ -911,11 +911,41 @@ async function detectFileForUnifiedArray(file, options, provider, genericFileMax
911
911
  transcribeAudio: options.videoOptions.transcribeAudio,
912
912
  }
913
913
  : undefined,
914
+ // #413/#440: same story for audio — the transcription backend, language
915
+ // and prompt a caller chose have to reach AudioProcessor, which is
916
+ // several hops down from here.
917
+ audioOptions: options.audioOptions
918
+ ? {
919
+ provider: options.audioOptions.provider,
920
+ transcriptionModel: options.audioOptions.transcriptionModel,
921
+ language: options.audioOptions.language,
922
+ prompt: options.audioOptions.prompt,
923
+ }
924
+ : undefined,
914
925
  provider: provider,
915
926
  mimetypeHint: fileMimetypeHint,
916
927
  filenameHint: fileFilenameHint,
917
928
  });
918
929
  }
930
+ /**
931
+ * Guidance appended to the system prompt when an audio file is attached (#471).
932
+ *
933
+ * An audio attachment does not reach the model as audio on most providers: the
934
+ * processor transcribes it and inlines the transcript as text. Without saying
935
+ * so, models reliably answer "I cannot listen to audio" while the transcript
936
+ * of that very recording sits in the prompt above — the audio equivalent of
937
+ * the "no files attached" failure the CSV/PDF guidance exists to prevent.
938
+ *
939
+ * It also states the negative case, because a file whose transcription was
940
+ * skipped (no backend configured, unsupported format) still arrives, carrying
941
+ * metadata and a stated reason. A model told only "audio is transcribed" will
942
+ * otherwise invent speech content for a file that has none.
943
+ */
944
+ const AUDIO_PROMPT_EXTENSIONS = extensionsForModality("audio");
945
+ const AUDIO_TRANSCRIPTION_INSTRUCTIONS = `
946
+ - Any audio file has ALREADY BEEN PROCESSED for you: its metadata, and its transcript when one could be produced, are inlined above under "## Audio File: ..." — you do not need to, and cannot, listen to the recording yourself.
947
+ - Treat the text under "--- Transcript ---" as the verbatim spoken content of that recording, and answer from it directly. Do NOT reply that you are unable to process audio.
948
+ - If an audio file shows no transcript, transcription was skipped and the stated reason is inlined with it. Say so rather than inventing what the audio might contain.`;
919
949
  export async function processUnifiedFilesArray(options, maxSize, provider) {
920
950
  options.input ??= {};
921
951
  if (!options.input.files || options.input.files.length === 0) {
@@ -962,6 +992,12 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
962
992
  inp2.text = inp2.text || "";
963
993
  let includedCount = 0;
964
994
  const fileRegistry = options.fileRegistry;
995
+ // #471: the system-prompt augmentation below has to know whether any
996
+ // audio actually made it in. Counted from the detection result rather
997
+ // than from `inp2.nativeAudioFiles`, which is only populated when the
998
+ // raw bytes could also be re-read — an audio file whose transcript was
999
+ // inlined but whose bytes were not would otherwise go unmentioned.
1000
+ let audioIncludedCount = 0;
965
1001
  // Only detection runs concurrently, and only for files that will not
966
1002
  // take the lazy-registration branch.
967
1003
  //
@@ -1022,6 +1058,9 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
1022
1058
  (await detectFileForUnifiedArray(file, options, provider, genericFileMaxSize));
1023
1059
  await appendDetectedFileResult(result, file, options);
1024
1060
  includedCount++;
1061
+ if (result.type === "audio") {
1062
+ audioIncludedCount++;
1063
+ }
1025
1064
  // Log what content type was added to the message
1026
1065
  const contentType = result.type === "image" ? "image" : "text";
1027
1066
  logger.info(`[NEUROLINK] File added to message: ${filename} as ${contentType} (type: ${result.type})`);
@@ -1065,7 +1104,7 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
1065
1104
  - The full content of the user's local file(s) is INLINED in this message under "## CSV Data from ..." / "## PDF Data from ..." / "## File: ..." headings — it is the actual file the user is asking about.
1066
1105
  - TREAT THE INLINED CONTENT AS IF IT WERE AN ATTACHMENT. Do NOT respond with "no files attached" or ask the user to re-upload — the data is already here.
1067
1106
  - DO NOT use GitHub tools (get_file_contents, search_code, etc.) for local files - they only work for remote repository files.
1068
- - Analyze the inlined file content directly without attempting to fetch or read files using tools.`;
1107
+ - Analyze the inlined file content directly without attempting to fetch or read files using tools.${audioIncludedCount > 0 ? AUDIO_TRANSCRIPTION_INSTRUCTIONS : ""}`;
1069
1108
  const existingSystem = (options.systemPrompt || "").trim();
1070
1109
  options.systemPrompt = existingSystem
1071
1110
  ? `${existingSystem}${filePromptAugmentation}`
@@ -1320,7 +1359,20 @@ function buildMultimodalSystemPrompt(options, hasPDFFiles) {
1320
1359
  const hasCSVFiles = (inp.csvFiles && inp.csvFiles.length > 0) ||
1321
1360
  (inp.files &&
1322
1361
  inp.files.some((f) => typeof f === "string" ? f.toLowerCase().endsWith(".csv") : false));
1323
- if (hasCSVFiles || hasPDFFiles) {
1362
+ // #471: audio attachments. `processUnifiedFilesArray` has already run by the
1363
+ // time this is called, so `nativeAudioFiles` is the authoritative signal —
1364
+ // it is populated from the detector's verdict, which reads magic bytes and
1365
+ // therefore recognises an audio file whose name says nothing. The remaining
1366
+ // checks cover the paths that never reach the detector: an explicit
1367
+ // `audioFiles` alias (folded away by `foldMediaAliasesIntoFiles`, but only
1368
+ // once that has run) and a plain path attached by name.
1369
+ const hasAudioFiles = (inp.nativeAudioFiles && inp.nativeAudioFiles.length > 0) ||
1370
+ (inp.audioFiles && inp.audioFiles.length > 0) ||
1371
+ (inp.files &&
1372
+ inp.files.some((f) => typeof f === "string"
1373
+ ? AUDIO_PROMPT_EXTENSIONS.some((ext) => f.toLowerCase().endsWith(ext))
1374
+ : false));
1375
+ if (hasCSVFiles || hasPDFFiles || hasAudioFiles) {
1324
1376
  const fileTypes = [];
1325
1377
  if (hasPDFFiles) {
1326
1378
  fileTypes.push("PDFs");
@@ -1328,13 +1380,22 @@ function buildMultimodalSystemPrompt(options, hasPDFFiles) {
1328
1380
  if (hasCSVFiles) {
1329
1381
  fileTypes.push("CSVs");
1330
1382
  }
1383
+ if (hasAudioFiles) {
1384
+ // Not "(transcribed)": `hasAudioFiles` only means an audio file was
1385
+ // attached, not that transcription actually produced anything — a file
1386
+ // with no backend configured, an unavailable pinned backend, or a
1387
+ // format/size the backend rejects still arrives here with metadata
1388
+ // only. AUDIO_TRANSCRIPTION_INSTRUCTIONS covers the per-file case; this
1389
+ // list must not assert the opposite of what it says.
1390
+ fileTypes.push("audio files (transcript may be unavailable)");
1391
+ }
1331
1392
  systemPrompt += `\n\nIMPORTANT FILE HANDLING INSTRUCTIONS:
1332
- - The full content of the user's local ${fileTypes.join(", ")} (and any images) is INLINED in this message under the "## CSV Data from ..." / "## PDF Data from ..." headings — it is the actual file the user is asking about.
1393
+ - The full content of the user's local ${fileTypes.join(", ")} (and any images) is INLINED in this message under the "## CSV Data from ..." / "## PDF Data from ..." / "## Audio File: ..." headings — it is the actual file the user is asking about.
1333
1394
  - TREAT THE INLINED CONTENT AS IF IT WERE AN ATTACHMENT. Do NOT respond with "no files attached" or ask the user to re-upload — the data is already here.
1334
1395
  - DO NOT use GitHub tools (get_file_contents, search_code, etc.) for local files - they only work for remote repository files
1335
1396
  - Analyze the provided file content directly without attempting to fetch or read files using tools
1336
1397
  - GitHub MCP tools are ONLY for remote repository operations, not local filesystem access
1337
- - Use the file content shown in this message for your analysis`;
1398
+ - Use the file content shown in this message for your analysis${hasAudioFiles ? AUDIO_TRANSCRIPTION_INSTRUCTIONS : ""}`;
1338
1399
  }
1339
1400
  return systemPrompt;
1340
1401
  }