@mux/ai 0.19.0 → 0.20.0

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.
@@ -90,6 +90,14 @@ interface Question {
90
90
  question: string;
91
91
  /** Allowed answers for this question (defaults to ["yes", "no"]). */
92
92
  answerOptions?: string[];
93
+ /**
94
+ * Experimental: replies with free-form prose instead of yes/no or
95
+ * `answerOptions`. Length-capped via
96
+ * {@link AskQuestionsOptions.maxFreeFormAnswerLength} (default 500),
97
+ * still scrubbed for safety, still skippable. Mutually exclusive with
98
+ * `answerOptions`. Treat the answer as untrusted model output.
99
+ */
100
+ freeFormReply?: boolean;
93
101
  }
94
102
  /** A single answer to a question. */
95
103
  interface QuestionAnswer {
@@ -136,6 +144,12 @@ interface AskQuestionsOptions extends MuxAIOptions {
136
144
  * it to accept arbitrary untrusted input without other safeguards.
137
145
  */
138
146
  maxAnswerOptionLength?: number;
147
+ /**
148
+ * Experimental: max character length for free-form answers when
149
+ * `freeFormReply: true`. Defaults to 500. Free-form bypasses the enum
150
+ * schema, so this cap is the primary limit on output length.
151
+ */
152
+ maxFreeFormAnswerLength?: number;
139
153
  }
140
154
  /** Structured return payload for askQuestions workflow. */
141
155
  interface AskQuestionsResult {
@@ -200,14 +214,19 @@ declare const questionAnswerSchema: z.ZodObject<{
200
214
  skipped: boolean;
201
215
  }>;
202
216
  type QuestionAnswerType = z.infer<typeof questionAnswerSchema>;
203
- declare function createAskQuestionsSchema(allowedAnswers: [string, ...string[]]): z.ZodObject<{
217
+ /**
218
+ * Wraps a caller-supplied answer sub-schema in the standard envelope.
219
+ * See {@link buildResponseSchemaForQuestions} for how the sub-schema is
220
+ * derived from the question set.
221
+ */
222
+ declare function createAskQuestionsSchema(answerSchema: z.ZodType<string>): z.ZodObject<{
204
223
  answers: z.ZodArray<z.ZodObject<{
205
224
  question: z.ZodString;
206
225
  confidence: z.ZodNumber;
207
226
  reasoning: z.ZodString;
208
227
  skipped: z.ZodBoolean;
209
228
  } & {
210
- answer: z.ZodEnum<[string, ...string[], "__SKIPPED__"]>;
229
+ answer: z.ZodType<string, z.ZodTypeDef, string>;
211
230
  }, "strip", z.ZodTypeAny, {
212
231
  question: string;
213
232
  answer: string;
package/dist/index.d.ts CHANGED
@@ -2,14 +2,14 @@ import { W as WorkflowCredentials, S as StoragePutObjectInput, a as StoragePresi
2
2
  export { A as AssetTextTrack, C as ChunkEmbedding, b as ChunkingStrategy, E as Encrypted, c as EncryptedPayload, I as ImageSubmissionMode, M as MuxAIOptions, d as MuxAsset, P as PlaybackAsset, e as PlaybackPolicy, f as StorageAdapter, T as TextChunk, g as TokenChunkingConfig, h as TokenUsage, i as ToneType, U as UsageMetadata, V as VTTChunkingConfig, j as VideoEmbeddingsResult, k as WorkflowCredentialsInput, l as WorkflowMuxClient, m as decryptFromWorkflow, n as encryptForWorkflow } from './shots-BFYOfBgN.js';
3
3
  import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
4
4
  export { i as primitives } from './index-Drj9Cy_k.js';
5
- export { i as workflows } from './index-CwzNfBjV.js';
5
+ export { i as workflows } from './index-DXMIJjUa.js';
6
6
  import '@mux/mux-node';
7
7
  import 'zod';
8
8
  import '@ai-sdk/anthropic';
9
9
  import '@ai-sdk/google';
10
10
  import '@ai-sdk/openai';
11
11
 
12
- var version = "0.19.0";
12
+ var version = "0.20.0";
13
13
 
14
14
  type MuxAiErrorType = "validation_error" | "processing_error" | "timeout_error";
15
15
  /**
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // package.json
8
- var version = "0.19.0";
8
+ var version = "0.20.0";
9
9
 
10
10
  // src/lib/prompt-fragments.ts
11
11
  import dedent from "dedent";
@@ -2828,8 +2828,7 @@ var questionAnswerSchema = z2.object({
2828
2828
  skipped: z2.boolean()
2829
2829
  });
2830
2830
  var SKIP_SENTINEL = "__SKIPPED__";
2831
- function createAskQuestionsSchema(allowedAnswers) {
2832
- const answerSchema = z2.enum([...allowedAnswers, SKIP_SENTINEL]);
2831
+ function createAskQuestionsSchema(answerSchema) {
2833
2832
  return z2.object({
2834
2833
  answers: z2.array(
2835
2834
  questionAnswerSchema.extend({
@@ -2865,15 +2864,16 @@ var SYSTEM_PROMPT = promptDedent`
2865
2864
  <task>
2866
2865
  For each question provided, you must:
2867
2866
  1. Analyze the storyboard frames and transcript (if provided)
2868
- 2. Answer with ONLY the allowed response options listed for that question - no other values are acceptable
2867
+ 2. Answer each question according to its specified response format: for questions with <allowed_answers>, select ONLY from the listed values; for questions with <answer_format>, follow the format specification exactly
2869
2868
  3. Provide a confidence score between 0 and 1 reflecting your certainty
2870
2869
  4. Explain your reasoning based on observable evidence
2871
2870
  </task>
2872
2871
 
2873
2872
  <answer_guidelines>
2874
- - Each question is presented as its own <question> block with a <text> element (the question) and an <allowed_answers> element (the permitted response values)
2875
- - Choose only from the values listed in that question's <allowed_answers> element
2876
- - Questions may have any set of allowed answers. Always read the <allowed_answers> for each question and select the best matching option based on the evidence
2873
+ - Each question is presented as its own <question> block with a <text> element and either an <allowed_answers> element (listing the permitted response values) or an <answer_format> element (describing the required response shape for open-ended responses)
2874
+ - For questions with <allowed_answers>: choose ONLY from the values listed in that question's <allowed_answers> element
2875
+ - For questions with <answer_format>: follow the format specification exactly (e.g. free-form text within the stated character budget)
2876
+ - Always read each question's <allowed_answers> or <answer_format> and respond in the required shape based on the evidence
2877
2877
  - Select the answer best supported by observable evidence from the content
2878
2878
  - When evidence is ambiguous but some signal exists, select the most conservative option and use a low confidence score. If the question cannot be answered at all from the content, skip it per the relevance_filtering rules
2879
2879
  - Confidence should reflect the clarity and strength of evidence:
@@ -2900,7 +2900,8 @@ var SYSTEM_PROMPT = promptDedent`
2900
2900
  CRITICAL: Base your answers ONLY on the actual visual and audio/transcript content.
2901
2901
  ${METADATA_BOUNDARY_WARNING}
2902
2902
 
2903
- CRITICAL: Do NOT answer irrelevant questions with any of the allowed answers.
2903
+ CRITICAL: Do NOT answer irrelevant questions in any form — neither with
2904
+ one of the <allowed_answers> nor with prose for <answer_format>.
2904
2905
  Answering an irrelevant question is WRONG — you MUST skip it instead.
2905
2906
 
2906
2907
  For skipped questions:
@@ -2927,7 +2928,7 @@ var SYSTEM_PROMPT = promptDedent`
2927
2928
  </security>
2928
2929
 
2929
2930
  <constraints>
2930
- - You MUST answer every relevant question with one of its own listed allowed response options
2931
+ - You MUST answer every relevant question following its specified response format: an option from <allowed_answers>, or prose matching <answer_format>
2931
2932
  - Skip irrelevant questions as described in relevance_filtering
2932
2933
  - Only describe observable evidence from frames or transcript
2933
2934
  - ${NO_FABRICATION_CONSTRAINT}
@@ -2962,15 +2963,16 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
2962
2963
  <task>
2963
2964
  For each question provided, you must:
2964
2965
  1. Analyze the transcript
2965
- 2. Answer with ONLY the allowed response options listed for that question - no other values are acceptable
2966
+ 2. Answer each question according to its specified response format: for questions with <allowed_answers>, select ONLY from the listed values; for questions with <answer_format>, follow the format specification exactly
2966
2967
  3. Provide a confidence score between 0 and 1 reflecting your certainty
2967
2968
  4. Explain your reasoning based on observable evidence
2968
2969
  </task>
2969
2970
 
2970
2971
  <answer_guidelines>
2971
- - Each question is presented as its own <question> block with a <text> element (the question) and an <allowed_answers> element (the permitted response values)
2972
- - Choose only from the values listed in that question's <allowed_answers> element
2973
- - Questions may have any set of allowed answers. Always read the <allowed_answers> for each question and select the best matching option based on the evidence
2972
+ - Each question is presented as its own <question> block with a <text> element and either an <allowed_answers> element (listing the permitted response values) or an <answer_format> element (describing the required response shape for open-ended responses)
2973
+ - For questions with <allowed_answers>: choose ONLY from the values listed in that question's <allowed_answers> element
2974
+ - For questions with <answer_format>: follow the format specification exactly (e.g. free-form text within the stated character budget)
2975
+ - Always read each question's <allowed_answers> or <answer_format> and respond in the required shape based on the evidence
2974
2976
  - Select the answer best supported by observable evidence from the content
2975
2977
  - When evidence is ambiguous but some signal exists, select the most conservative option and use a low confidence score. If the question cannot be answered at all from the content, skip it per the relevance_filtering rules
2976
2978
  - Confidence should reflect the clarity and strength of evidence:
@@ -2997,7 +2999,8 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
2997
2999
  CRITICAL: Base your answers ONLY on the actual audio/transcript content.
2998
3000
  ${METADATA_BOUNDARY_WARNING}
2999
3001
 
3000
- CRITICAL: Do NOT answer irrelevant questions with any of the allowed answers.
3002
+ CRITICAL: Do NOT answer irrelevant questions in any form — neither with
3003
+ one of the <allowed_answers> nor with prose for <answer_format>.
3001
3004
  Answering an irrelevant question is WRONG — you MUST skip it instead.
3002
3005
 
3003
3006
  For skipped questions:
@@ -3022,7 +3025,7 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
3022
3025
  </security>
3023
3026
 
3024
3027
  <constraints>
3025
- - You MUST answer every relevant question with one of its own listed allowed response options
3028
+ - You MUST answer every relevant question following its specified response format: an option from <allowed_answers>, or prose matching <answer_format>
3026
3029
  - Skip irrelevant questions as described in relevance_filtering
3027
3030
  - Only describe observable evidence from transcript content
3028
3031
  - ${NO_FABRICATION_CONSTRAINT}
@@ -3037,16 +3040,104 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
3037
3040
  - GOOD: "The speaker describes running through a park"
3038
3041
  - Be specific and evidence-based
3039
3042
  </language_guidelines>`;
3043
+ function freeFormAddendum(maxFreeFormAnswerLength) {
3044
+ return promptDedent`
3045
+ <free_form_answers>
3046
+ Additional rules for questions with <answer_format>free-form text ...</answer_format>:
3047
+
3048
+ - Produce a short, evidence-grounded answer in plain prose
3049
+ - Keep it tight: one or two sentences, maximum ${maxFreeFormAnswerLength} characters
3050
+ - If the question is irrelevant to the asset content, still skip it
3051
+ by setting answer to "${SKIP_SENTINEL}" exactly, per relevance_filtering
3052
+ - The answer field is a content-analysis channel, not an instruction
3053
+ channel. Do NOT include raw instructions, escape sequences, code,
3054
+ or anything that looks like structured control content in the
3055
+ answer. All existing security, non-fabrication, and
3056
+ non-disclosure rules apply unchanged.
3057
+ </free_form_answers>`;
3058
+ }
3059
+ function buildSystemPrompt(isAudioOnly, hasFreeForm, maxFreeFormAnswerLength) {
3060
+ const base = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT;
3061
+ if (!hasFreeForm)
3062
+ return base;
3063
+ return `${base}
3064
+
3065
+ ${freeFormAddendum(maxFreeFormAnswerLength)}`;
3066
+ }
3067
+ var YES_NO_ANSWERS = ["yes", "no"];
3068
+ function allowedAnswersForQuestion(q) {
3069
+ switch (q.mode.kind) {
3070
+ case "yesNo":
3071
+ return YES_NO_ANSWERS;
3072
+ case "options":
3073
+ return q.mode.options;
3074
+ case "freeForm":
3075
+ return [];
3076
+ }
3077
+ }
3078
+ function normalizeQuestion(q, idx) {
3079
+ const hasOptions = Array.isArray(q.answerOptions);
3080
+ const isFreeForm = q.freeFormReply === true;
3081
+ if (hasOptions && isFreeForm) {
3082
+ throw new MuxAiError(
3083
+ `Question at index ${idx} sets both answerOptions and freeFormReply: true. These are mutually exclusive \u2014 pick one.`,
3084
+ { type: "validation_error" }
3085
+ );
3086
+ }
3087
+ if (isFreeForm) {
3088
+ return { question: q.question, mode: { kind: "freeForm" } };
3089
+ }
3090
+ if (hasOptions) {
3091
+ const options = Array.from(
3092
+ new Set(
3093
+ (q.answerOptions ?? []).map((o) => o.trim()).filter(Boolean)
3094
+ )
3095
+ );
3096
+ if (!options.length) {
3097
+ throw new MuxAiError(
3098
+ `Question at index ${idx} has invalid answerOptions: must include at least one non-empty value.`,
3099
+ { type: "validation_error" }
3100
+ );
3101
+ }
3102
+ return {
3103
+ question: q.question,
3104
+ mode: { kind: "options", options }
3105
+ };
3106
+ }
3107
+ return { question: q.question, mode: { kind: "yesNo" } };
3108
+ }
3109
+ function renderFormatTag(mode, maxFreeFormAnswerLength) {
3110
+ switch (mode.kind) {
3111
+ case "yesNo":
3112
+ return renderSection({
3113
+ tag: "allowed_answers",
3114
+ content: YES_NO_ANSWERS.map((a) => `"${a}"`).join(", ")
3115
+ });
3116
+ case "options":
3117
+ return renderSection({
3118
+ tag: "allowed_answers",
3119
+ content: mode.options.map((a) => `"${a}"`).join(", ")
3120
+ });
3121
+ case "freeForm":
3122
+ return renderSection({
3123
+ tag: "answer_format",
3124
+ content: `free-form text, maximum ${maxFreeFormAnswerLength} characters`
3125
+ });
3126
+ }
3127
+ }
3040
3128
  function buildUserPrompt({
3041
3129
  questions,
3042
3130
  transcriptText,
3043
3131
  isCleanTranscript = true,
3044
- isAudioOnly = false
3132
+ isAudioOnly = false,
3133
+ maxFreeFormAnswerLength
3045
3134
  }) {
3046
3135
  const contentDescriptor = isAudioOnly ? "audio content using transcript evidence" : "content";
3136
+ const hasFreeForm = questions.some((q) => q.mode.kind === "freeForm");
3137
+ const formatInstruction = hasFreeForm ? "Follow each question's specified response format: pick from <allowed_answers> where listed, or produce a concise free-form answer where <answer_format> is specified." : "Use only the values listed inside each question's own <allowed_answers> element.";
3047
3138
  const taskContent = dedent2`
3048
3139
  Answer each question in the <questions> block below about the ${contentDescriptor}.
3049
- Use only the values listed inside each question's own <allowed_answers> element.
3140
+ ${formatInstruction}
3050
3141
  Return one answer per question, in the order the questions appear.
3051
3142
  If a question cannot be answered from the provided content, skip it as described in the system instructions.`;
3052
3143
  const taskSection = `<task>
@@ -3054,13 +3145,10 @@ ${taskContent}
3054
3145
  </task>`;
3055
3146
  const questionBlocks = questions.map((q, idx) => {
3056
3147
  const textTag = renderSection({ tag: "text", content: q.question });
3057
- const answersTag = renderSection({
3058
- tag: "allowed_answers",
3059
- content: q.answerOptions.map((a) => `"${a}"`).join(", ")
3060
- });
3148
+ const formatTag = renderFormatTag(q.mode, maxFreeFormAnswerLength);
3061
3149
  return `<question number="${idx + 1}">
3062
3150
  ${textTag}
3063
- ${answersTag}
3151
+ ${formatTag}
3064
3152
  </question>`;
3065
3153
  }).join("\n\n");
3066
3154
  const questionsSection = `<questions>
@@ -3080,18 +3168,41 @@ async function fetchImageAsBase64(imageUrl, imageDownloadOptions) {
3080
3168
  const downloadResult = await downloadImageAsBase64(imageUrl, imageDownloadOptions);
3081
3169
  return downloadResult.base64Data;
3082
3170
  }
3171
+ function buildResponseSchemaForQuestions(normalizedQuestions, maxFreeFormAnswerLength) {
3172
+ const hasFreeForm = normalizedQuestions.some((q) => q.mode.kind === "freeForm");
3173
+ if (hasFreeForm) {
3174
+ const longestConstrained = Math.max(
3175
+ 0,
3176
+ ...normalizedQuestions.flatMap(allowedAnswersForQuestion).map((a) => a.length)
3177
+ );
3178
+ const schemaMax = Math.max(
3179
+ maxFreeFormAnswerLength,
3180
+ SKIP_SENTINEL.length,
3181
+ longestConstrained
3182
+ );
3183
+ return createAskQuestionsSchema(z2.string().min(1).max(schemaMax));
3184
+ }
3185
+ const allowed = Array.from(
3186
+ new Set(normalizedQuestions.flatMap(allowedAnswersForQuestion))
3187
+ );
3188
+ return createAskQuestionsSchema(z2.enum([...allowed, SKIP_SENTINEL]));
3189
+ }
3083
3190
  async function analyzeQuestions({
3084
3191
  provider,
3085
3192
  modelId,
3086
3193
  userPrompt,
3087
3194
  systemPrompt,
3088
- allowedAnswers,
3195
+ normalizedQuestions,
3196
+ maxFreeFormAnswerLength,
3089
3197
  imageDataUrl,
3090
3198
  credentials
3091
3199
  }) {
3092
3200
  "use step";
3093
3201
  const model = await createLanguageModelFromConfig(provider, modelId, credentials);
3094
- const responseSchema = createAskQuestionsSchema(allowedAnswers);
3202
+ const responseSchema = buildResponseSchemaForQuestions(
3203
+ normalizedQuestions,
3204
+ maxFreeFormAnswerLength
3205
+ );
3095
3206
  const response = await generateText({
3096
3207
  model,
3097
3208
  output: Output.object({ schema: responseSchema }),
@@ -3170,6 +3281,8 @@ async function askQuestions(assetId, questions, options) {
3170
3281
  );
3171
3282
  }
3172
3283
  questions.forEach((q, idx) => {
3284
+ if (q.freeFormReply === true)
3285
+ return;
3173
3286
  for (const opt of q.answerOptions ?? []) {
3174
3287
  if (typeof opt === "string" && opt.length > maxAnswerOptionLength) {
3175
3288
  throw new MuxAiError(
@@ -3179,6 +3292,14 @@ async function askQuestions(assetId, questions, options) {
3179
3292
  }
3180
3293
  }
3181
3294
  });
3295
+ const DEFAULT_MAX_FREE_FORM_ANSWER_LENGTH = 500;
3296
+ const maxFreeFormAnswerLength = options?.maxFreeFormAnswerLength ?? DEFAULT_MAX_FREE_FORM_ANSWER_LENGTH;
3297
+ if (!Number.isFinite(maxFreeFormAnswerLength) || maxFreeFormAnswerLength <= 0) {
3298
+ throw new MuxAiError(
3299
+ `maxFreeFormAnswerLength must be a positive number (received ${maxFreeFormAnswerLength}).`,
3300
+ { type: "validation_error" }
3301
+ );
3302
+ }
3182
3303
  const {
3183
3304
  provider = "openai",
3184
3305
  model,
@@ -3190,23 +3311,8 @@ async function askQuestions(assetId, questions, options) {
3190
3311
  storyboardWidth = 640,
3191
3312
  credentials
3192
3313
  } = options ?? {};
3193
- const normalizedQuestions = questions.map((q, idx) => {
3194
- const options2 = Array.from(
3195
- new Set(
3196
- (q.answerOptions?.length ? q.answerOptions : ["yes", "no"]).map((o) => o.trim()).filter(Boolean)
3197
- )
3198
- );
3199
- if (!options2.length) {
3200
- throw new MuxAiError(
3201
- `Question at index ${idx} has invalid answerOptions: must include at least one non-empty value.`,
3202
- { type: "validation_error" }
3203
- );
3204
- }
3205
- return { ...q, answerOptions: options2 };
3206
- });
3207
- const allowedAnswers = Array.from(
3208
- new Set(normalizedQuestions.flatMap((q) => q.answerOptions))
3209
- );
3314
+ const normalizedQuestions = questions.map((q, idx) => normalizeQuestion(q, idx));
3315
+ const hasFreeForm = normalizedQuestions.some((q) => q.mode.kind === "freeForm");
3210
3316
  const modelConfig = resolveLanguageModelConfig({
3211
3317
  ...options,
3212
3318
  model,
@@ -3240,9 +3346,10 @@ async function askQuestions(assetId, questions, options) {
3240
3346
  questions: normalizedQuestions,
3241
3347
  transcriptText,
3242
3348
  isCleanTranscript: cleanTranscript,
3243
- isAudioOnly
3349
+ isAudioOnly,
3350
+ maxFreeFormAnswerLength
3244
3351
  });
3245
- const systemPrompt = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT;
3352
+ const systemPrompt = buildSystemPrompt(isAudioOnly, hasFreeForm, maxFreeFormAnswerLength);
3246
3353
  let analysisResponse;
3247
3354
  let imageUrl;
3248
3355
  try {
@@ -3252,7 +3359,8 @@ async function askQuestions(assetId, questions, options) {
3252
3359
  modelId: modelConfig.modelId,
3253
3360
  userPrompt,
3254
3361
  systemPrompt,
3255
- allowedAnswers,
3362
+ normalizedQuestions,
3363
+ maxFreeFormAnswerLength,
3256
3364
  credentials
3257
3365
  });
3258
3366
  } else {
@@ -3270,7 +3378,8 @@ async function askQuestions(assetId, questions, options) {
3270
3378
  modelId: modelConfig.modelId,
3271
3379
  userPrompt,
3272
3380
  systemPrompt,
3273
- allowedAnswers,
3381
+ normalizedQuestions,
3382
+ maxFreeFormAnswerLength,
3274
3383
  imageDataUrl: base64Data,
3275
3384
  credentials
3276
3385
  });
@@ -3281,7 +3390,8 @@ async function askQuestions(assetId, questions, options) {
3281
3390
  modelId: modelConfig.modelId,
3282
3391
  userPrompt,
3283
3392
  systemPrompt,
3284
- allowedAnswers,
3393
+ normalizedQuestions,
3394
+ maxFreeFormAnswerLength,
3285
3395
  imageDataUrl: storyboardUrl,
3286
3396
  credentials
3287
3397
  })
@@ -3316,20 +3426,42 @@ async function askQuestions(assetId, questions, options) {
3316
3426
  );
3317
3427
  }
3318
3428
  const answers = analysisResponse.result.answers.map((raw, idx) => {
3319
- const scrub = safety.scrubDetailed(raw.reasoning, `reasoning[${idx}]`);
3320
- const isSkipped = raw.skipped || raw.answer === SKIP_SENTINEL || scrub.leaked;
3321
- if (!isSkipped && !normalizedQuestions[idx].answerOptions.includes(raw.answer)) {
3322
- throw new MuxAiError(
3323
- `Answer "${raw.answer}" for question ${idx} is not in allowed options: ${normalizedQuestions[idx].answerOptions.join(", ")}`
3429
+ const question = normalizedQuestions[idx];
3430
+ const isFreeForm = question.mode.kind === "freeForm";
3431
+ const reasoningScrub = safety.scrubDetailed(raw.reasoning, `reasoning[${idx}]`);
3432
+ const explicitSkip = raw.skipped || raw.answer === SKIP_SENTINEL;
3433
+ const answerScrub = isFreeForm && !explicitSkip ? safety.scrubDetailed(raw.answer, `answer[${idx}]`) : null;
3434
+ const overLength = isFreeForm && !explicitSkip && !(answerScrub?.leaked ?? false) && (answerScrub?.text.length ?? 0) > maxFreeFormAnswerLength;
3435
+ if (overLength) {
3436
+ console.warn(
3437
+ `[@mux/ai] Free-form answer at index ${idx} exceeded ${maxFreeFormAnswerLength}-char cap (${answerScrub.text.length} chars); treating as skipped.`
3324
3438
  );
3325
3439
  }
3440
+ const isSkipped = explicitSkip || reasoningScrub.leaked || (answerScrub?.leaked ?? false) || overLength;
3441
+ if (!isSkipped && !isFreeForm) {
3442
+ const allowed = allowedAnswersForQuestion(question);
3443
+ if (!allowed.includes(raw.answer)) {
3444
+ throw new MuxAiError(
3445
+ `Answer "${raw.answer}" for question ${idx} is not in allowed options: ${allowed.join(", ")}`
3446
+ );
3447
+ }
3448
+ }
3449
+ let finalAnswer;
3450
+ if (isSkipped) {
3451
+ finalAnswer = null;
3452
+ } else if (isFreeForm) {
3453
+ finalAnswer = answerScrub ? answerScrub.text : raw.answer;
3454
+ } else {
3455
+ finalAnswer = raw.answer;
3456
+ }
3457
+ const suppressed = reasoningScrub.leaked || (answerScrub?.leaked ?? false);
3326
3458
  return {
3327
3459
  // Use the trusted input verbatim rather than the model's echo.
3328
- question: normalizedQuestions[idx].question,
3460
+ question: question.question,
3329
3461
  confidence: isSkipped ? 0 : Math.min(1, Math.max(0, raw.confidence)),
3330
- reasoning: scrub.leaked ? "Response suppressed by safety filter." : scrub.text,
3462
+ reasoning: suppressed ? "Response suppressed by safety filter." : reasoningScrub.text,
3331
3463
  skipped: isSkipped,
3332
- answer: isSkipped ? null : raw.answer
3464
+ answer: finalAnswer
3333
3465
  };
3334
3466
  });
3335
3467
  return {