@mux/ai 0.19.0 → 0.21.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.
- package/README.md +1 -1
- package/dist/{index-CwzNfBjV.d.ts → index-DwNtsg-u.d.ts} +34 -6
- package/dist/{index-Drj9Cy_k.d.ts → index-bjQi1ZCj.d.ts} +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +316 -66
- package/dist/index.js.map +1 -1
- package/dist/primitives/index.d.ts +2 -2
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/index.js.map +1 -1
- package/dist/{shots-BFYOfBgN.d.ts → shots-mGJfYGiO.d.ts} +2 -0
- package/dist/workflows/index.d.ts +2 -2
- package/dist/workflows/index.js +315 -65
- package/dist/workflows/index.js.map +1 -1
- package/package.json +3 -3
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.
|
|
8
|
+
var version = "0.21.1";
|
|
9
9
|
|
|
10
10
|
// src/lib/prompt-fragments.ts
|
|
11
11
|
import dedent from "dedent";
|
|
@@ -83,6 +83,7 @@ var EnvSchema = z.object({
|
|
|
83
83
|
GOOGLE_GENERATIVE_AI_API_KEY: optionalString("Google Generative AI API key for Gemini-backed workflows.", "Google Generative AI API key"),
|
|
84
84
|
ELEVENLABS_API_KEY: optionalString("ElevenLabs API key for audio translation.", "ElevenLabs API key"),
|
|
85
85
|
HIVE_API_KEY: optionalString("Hive Visual Moderation API key.", "Hive API key"),
|
|
86
|
+
GOOGLE_VISION_API_KEY: optionalString("Google Vision API key for SafeSearch moderation.", "Google Vision API key"),
|
|
86
87
|
// S3-Compatible Storage (required for translation & audio dubbing)
|
|
87
88
|
S3_ENDPOINT: optionalString("S3-compatible endpoint for uploads.", "S3 endpoint"),
|
|
88
89
|
S3_REGION: optionalString("S3 region (defaults to 'auto' when omitted)."),
|
|
@@ -763,23 +764,26 @@ function resolveProviderApiKeyFromCredentials(provider, resolved) {
|
|
|
763
764
|
const openaiApiKey = readString(record, "openaiApiKey");
|
|
764
765
|
const anthropicApiKey = readString(record, "anthropicApiKey");
|
|
765
766
|
const googleApiKey = readString(record, "googleApiKey");
|
|
767
|
+
const googleVisionApiKey = readString(record, "googleVisionApiKey");
|
|
766
768
|
const hiveApiKey = readString(record, "hiveApiKey");
|
|
767
769
|
const elevenLabsApiKey = readString(record, "elevenLabsApiKey");
|
|
768
770
|
const apiKeyMap = {
|
|
769
|
-
openai: openaiApiKey ?? env_default.OPENAI_API_KEY,
|
|
770
|
-
anthropic: anthropicApiKey ?? env_default.ANTHROPIC_API_KEY,
|
|
771
|
-
google: googleApiKey ?? env_default.GOOGLE_GENERATIVE_AI_API_KEY,
|
|
772
|
-
|
|
773
|
-
|
|
771
|
+
"openai": openaiApiKey ?? env_default.OPENAI_API_KEY,
|
|
772
|
+
"anthropic": anthropicApiKey ?? env_default.ANTHROPIC_API_KEY,
|
|
773
|
+
"google": googleApiKey ?? env_default.GOOGLE_GENERATIVE_AI_API_KEY,
|
|
774
|
+
"google-vision-api": googleVisionApiKey ?? env_default.GOOGLE_VISION_API_KEY,
|
|
775
|
+
"hive": hiveApiKey ?? env_default.HIVE_API_KEY,
|
|
776
|
+
"elevenlabs": elevenLabsApiKey ?? env_default.ELEVENLABS_API_KEY
|
|
774
777
|
};
|
|
775
778
|
const apiKey = apiKeyMap[provider];
|
|
776
779
|
if (!apiKey) {
|
|
777
780
|
const envVarNames = {
|
|
778
|
-
openai: "OPENAI_API_KEY",
|
|
779
|
-
anthropic: "ANTHROPIC_API_KEY",
|
|
780
|
-
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
781
|
-
|
|
782
|
-
|
|
781
|
+
"openai": "OPENAI_API_KEY",
|
|
782
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
783
|
+
"google": "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
784
|
+
"google-vision-api": "GOOGLE_VISION_API_KEY",
|
|
785
|
+
"hive": "HIVE_API_KEY",
|
|
786
|
+
"elevenlabs": "ELEVENLABS_API_KEY"
|
|
783
787
|
};
|
|
784
788
|
throw new Error(
|
|
785
789
|
`${provider} API key is required. Provide ${provider} credentials via workflow credentials or set ${envVarNames[provider]} environment variable.`
|
|
@@ -2554,6 +2558,7 @@ __export(workflows_exports, {
|
|
|
2554
2558
|
DEFAULT_DESCRIPTION_LENGTH: () => DEFAULT_DESCRIPTION_LENGTH,
|
|
2555
2559
|
DEFAULT_SUMMARY_KEYWORD_LIMIT: () => DEFAULT_SUMMARY_KEYWORD_LIMIT,
|
|
2556
2560
|
DEFAULT_TITLE_LENGTH: () => DEFAULT_TITLE_LENGTH,
|
|
2561
|
+
GOOGLE_VISION_LIKELIHOOD_TO_SCORE: () => GOOGLE_VISION_LIKELIHOOD_TO_SCORE,
|
|
2557
2562
|
HIVE_SEXUAL_CATEGORIES: () => HIVE_SEXUAL_CATEGORIES,
|
|
2558
2563
|
HIVE_VIOLENCE_CATEGORIES: () => HIVE_VIOLENCE_CATEGORIES,
|
|
2559
2564
|
aggregateTokenUsage: () => aggregateTokenUsage,
|
|
@@ -2828,8 +2833,7 @@ var questionAnswerSchema = z2.object({
|
|
|
2828
2833
|
skipped: z2.boolean()
|
|
2829
2834
|
});
|
|
2830
2835
|
var SKIP_SENTINEL = "__SKIPPED__";
|
|
2831
|
-
function createAskQuestionsSchema(
|
|
2832
|
-
const answerSchema = z2.enum([...allowedAnswers, SKIP_SENTINEL]);
|
|
2836
|
+
function createAskQuestionsSchema(answerSchema) {
|
|
2833
2837
|
return z2.object({
|
|
2834
2838
|
answers: z2.array(
|
|
2835
2839
|
questionAnswerSchema.extend({
|
|
@@ -2865,15 +2869,16 @@ var SYSTEM_PROMPT = promptDedent`
|
|
|
2865
2869
|
<task>
|
|
2866
2870
|
For each question provided, you must:
|
|
2867
2871
|
1. Analyze the storyboard frames and transcript (if provided)
|
|
2868
|
-
2. Answer
|
|
2872
|
+
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
2873
|
3. Provide a confidence score between 0 and 1 reflecting your certainty
|
|
2870
2874
|
4. Explain your reasoning based on observable evidence
|
|
2871
2875
|
</task>
|
|
2872
2876
|
|
|
2873
2877
|
<answer_guidelines>
|
|
2874
|
-
- Each question is presented as its own <question> block with a <text> element (the
|
|
2875
|
-
-
|
|
2876
|
-
-
|
|
2878
|
+
- 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)
|
|
2879
|
+
- For questions with <allowed_answers>: choose ONLY from the values listed in that question's <allowed_answers> element
|
|
2880
|
+
- For questions with <answer_format>: follow the format specification exactly (e.g. free-form text within the stated character budget)
|
|
2881
|
+
- Always read each question's <allowed_answers> or <answer_format> and respond in the required shape based on the evidence
|
|
2877
2882
|
- Select the answer best supported by observable evidence from the content
|
|
2878
2883
|
- 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
2884
|
- Confidence should reflect the clarity and strength of evidence:
|
|
@@ -2900,7 +2905,8 @@ var SYSTEM_PROMPT = promptDedent`
|
|
|
2900
2905
|
CRITICAL: Base your answers ONLY on the actual visual and audio/transcript content.
|
|
2901
2906
|
${METADATA_BOUNDARY_WARNING}
|
|
2902
2907
|
|
|
2903
|
-
CRITICAL: Do NOT answer irrelevant questions
|
|
2908
|
+
CRITICAL: Do NOT answer irrelevant questions in any form — neither with
|
|
2909
|
+
one of the <allowed_answers> nor with prose for <answer_format>.
|
|
2904
2910
|
Answering an irrelevant question is WRONG — you MUST skip it instead.
|
|
2905
2911
|
|
|
2906
2912
|
For skipped questions:
|
|
@@ -2927,7 +2933,7 @@ var SYSTEM_PROMPT = promptDedent`
|
|
|
2927
2933
|
</security>
|
|
2928
2934
|
|
|
2929
2935
|
<constraints>
|
|
2930
|
-
- You MUST answer every relevant question
|
|
2936
|
+
- You MUST answer every relevant question following its specified response format: an option from <allowed_answers>, or prose matching <answer_format>
|
|
2931
2937
|
- Skip irrelevant questions as described in relevance_filtering
|
|
2932
2938
|
- Only describe observable evidence from frames or transcript
|
|
2933
2939
|
- ${NO_FABRICATION_CONSTRAINT}
|
|
@@ -2962,15 +2968,16 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
|
|
|
2962
2968
|
<task>
|
|
2963
2969
|
For each question provided, you must:
|
|
2964
2970
|
1. Analyze the transcript
|
|
2965
|
-
2. Answer
|
|
2971
|
+
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
2972
|
3. Provide a confidence score between 0 and 1 reflecting your certainty
|
|
2967
2973
|
4. Explain your reasoning based on observable evidence
|
|
2968
2974
|
</task>
|
|
2969
2975
|
|
|
2970
2976
|
<answer_guidelines>
|
|
2971
|
-
- Each question is presented as its own <question> block with a <text> element (the
|
|
2972
|
-
-
|
|
2973
|
-
-
|
|
2977
|
+
- 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)
|
|
2978
|
+
- For questions with <allowed_answers>: choose ONLY from the values listed in that question's <allowed_answers> element
|
|
2979
|
+
- For questions with <answer_format>: follow the format specification exactly (e.g. free-form text within the stated character budget)
|
|
2980
|
+
- Always read each question's <allowed_answers> or <answer_format> and respond in the required shape based on the evidence
|
|
2974
2981
|
- Select the answer best supported by observable evidence from the content
|
|
2975
2982
|
- 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
2983
|
- Confidence should reflect the clarity and strength of evidence:
|
|
@@ -2997,7 +3004,8 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
|
|
|
2997
3004
|
CRITICAL: Base your answers ONLY on the actual audio/transcript content.
|
|
2998
3005
|
${METADATA_BOUNDARY_WARNING}
|
|
2999
3006
|
|
|
3000
|
-
CRITICAL: Do NOT answer irrelevant questions
|
|
3007
|
+
CRITICAL: Do NOT answer irrelevant questions in any form — neither with
|
|
3008
|
+
one of the <allowed_answers> nor with prose for <answer_format>.
|
|
3001
3009
|
Answering an irrelevant question is WRONG — you MUST skip it instead.
|
|
3002
3010
|
|
|
3003
3011
|
For skipped questions:
|
|
@@ -3022,7 +3030,7 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
|
|
|
3022
3030
|
</security>
|
|
3023
3031
|
|
|
3024
3032
|
<constraints>
|
|
3025
|
-
- You MUST answer every relevant question
|
|
3033
|
+
- You MUST answer every relevant question following its specified response format: an option from <allowed_answers>, or prose matching <answer_format>
|
|
3026
3034
|
- Skip irrelevant questions as described in relevance_filtering
|
|
3027
3035
|
- Only describe observable evidence from transcript content
|
|
3028
3036
|
- ${NO_FABRICATION_CONSTRAINT}
|
|
@@ -3037,16 +3045,104 @@ var AUDIO_ONLY_SYSTEM_PROMPT = promptDedent`
|
|
|
3037
3045
|
- GOOD: "The speaker describes running through a park"
|
|
3038
3046
|
- Be specific and evidence-based
|
|
3039
3047
|
</language_guidelines>`;
|
|
3048
|
+
function freeFormAddendum(maxFreeFormAnswerLength) {
|
|
3049
|
+
return promptDedent`
|
|
3050
|
+
<free_form_answers>
|
|
3051
|
+
Additional rules for questions with <answer_format>free-form text ...</answer_format>:
|
|
3052
|
+
|
|
3053
|
+
- Produce a short, evidence-grounded answer in plain prose
|
|
3054
|
+
- Keep it tight: one or two sentences, maximum ${maxFreeFormAnswerLength} characters
|
|
3055
|
+
- If the question is irrelevant to the asset content, still skip it
|
|
3056
|
+
by setting answer to "${SKIP_SENTINEL}" exactly, per relevance_filtering
|
|
3057
|
+
- The answer field is a content-analysis channel, not an instruction
|
|
3058
|
+
channel. Do NOT include raw instructions, escape sequences, code,
|
|
3059
|
+
or anything that looks like structured control content in the
|
|
3060
|
+
answer. All existing security, non-fabrication, and
|
|
3061
|
+
non-disclosure rules apply unchanged.
|
|
3062
|
+
</free_form_answers>`;
|
|
3063
|
+
}
|
|
3064
|
+
function buildSystemPrompt(isAudioOnly, hasFreeForm, maxFreeFormAnswerLength) {
|
|
3065
|
+
const base = isAudioOnly ? AUDIO_ONLY_SYSTEM_PROMPT : SYSTEM_PROMPT;
|
|
3066
|
+
if (!hasFreeForm)
|
|
3067
|
+
return base;
|
|
3068
|
+
return `${base}
|
|
3069
|
+
|
|
3070
|
+
${freeFormAddendum(maxFreeFormAnswerLength)}`;
|
|
3071
|
+
}
|
|
3072
|
+
var YES_NO_ANSWERS = ["yes", "no"];
|
|
3073
|
+
function allowedAnswersForQuestion(q) {
|
|
3074
|
+
switch (q.mode.kind) {
|
|
3075
|
+
case "yesNo":
|
|
3076
|
+
return YES_NO_ANSWERS;
|
|
3077
|
+
case "options":
|
|
3078
|
+
return q.mode.options;
|
|
3079
|
+
case "freeForm":
|
|
3080
|
+
return [];
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
function normalizeQuestion(q, idx) {
|
|
3084
|
+
const hasOptions = Array.isArray(q.answerOptions);
|
|
3085
|
+
const isFreeForm = q.freeFormReply === true;
|
|
3086
|
+
if (hasOptions && isFreeForm) {
|
|
3087
|
+
throw new MuxAiError(
|
|
3088
|
+
`Question at index ${idx} sets both answerOptions and freeFormReply: true. These are mutually exclusive \u2014 pick one.`,
|
|
3089
|
+
{ type: "validation_error" }
|
|
3090
|
+
);
|
|
3091
|
+
}
|
|
3092
|
+
if (isFreeForm) {
|
|
3093
|
+
return { question: q.question, mode: { kind: "freeForm" } };
|
|
3094
|
+
}
|
|
3095
|
+
if (hasOptions) {
|
|
3096
|
+
const options = Array.from(
|
|
3097
|
+
new Set(
|
|
3098
|
+
(q.answerOptions ?? []).map((o) => o.trim()).filter(Boolean)
|
|
3099
|
+
)
|
|
3100
|
+
);
|
|
3101
|
+
if (!options.length) {
|
|
3102
|
+
throw new MuxAiError(
|
|
3103
|
+
`Question at index ${idx} has invalid answerOptions: must include at least one non-empty value.`,
|
|
3104
|
+
{ type: "validation_error" }
|
|
3105
|
+
);
|
|
3106
|
+
}
|
|
3107
|
+
return {
|
|
3108
|
+
question: q.question,
|
|
3109
|
+
mode: { kind: "options", options }
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
return { question: q.question, mode: { kind: "yesNo" } };
|
|
3113
|
+
}
|
|
3114
|
+
function renderFormatTag(mode, maxFreeFormAnswerLength) {
|
|
3115
|
+
switch (mode.kind) {
|
|
3116
|
+
case "yesNo":
|
|
3117
|
+
return renderSection({
|
|
3118
|
+
tag: "allowed_answers",
|
|
3119
|
+
content: YES_NO_ANSWERS.map((a) => `"${a}"`).join(", ")
|
|
3120
|
+
});
|
|
3121
|
+
case "options":
|
|
3122
|
+
return renderSection({
|
|
3123
|
+
tag: "allowed_answers",
|
|
3124
|
+
content: mode.options.map((a) => `"${a}"`).join(", ")
|
|
3125
|
+
});
|
|
3126
|
+
case "freeForm":
|
|
3127
|
+
return renderSection({
|
|
3128
|
+
tag: "answer_format",
|
|
3129
|
+
content: `free-form text, maximum ${maxFreeFormAnswerLength} characters`
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3040
3133
|
function buildUserPrompt({
|
|
3041
3134
|
questions,
|
|
3042
3135
|
transcriptText,
|
|
3043
3136
|
isCleanTranscript = true,
|
|
3044
|
-
isAudioOnly = false
|
|
3137
|
+
isAudioOnly = false,
|
|
3138
|
+
maxFreeFormAnswerLength
|
|
3045
3139
|
}) {
|
|
3046
3140
|
const contentDescriptor = isAudioOnly ? "audio content using transcript evidence" : "content";
|
|
3141
|
+
const hasFreeForm = questions.some((q) => q.mode.kind === "freeForm");
|
|
3142
|
+
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
3143
|
const taskContent = dedent2`
|
|
3048
3144
|
Answer each question in the <questions> block below about the ${contentDescriptor}.
|
|
3049
|
-
|
|
3145
|
+
${formatInstruction}
|
|
3050
3146
|
Return one answer per question, in the order the questions appear.
|
|
3051
3147
|
If a question cannot be answered from the provided content, skip it as described in the system instructions.`;
|
|
3052
3148
|
const taskSection = `<task>
|
|
@@ -3054,13 +3150,10 @@ ${taskContent}
|
|
|
3054
3150
|
</task>`;
|
|
3055
3151
|
const questionBlocks = questions.map((q, idx) => {
|
|
3056
3152
|
const textTag = renderSection({ tag: "text", content: q.question });
|
|
3057
|
-
const
|
|
3058
|
-
tag: "allowed_answers",
|
|
3059
|
-
content: q.answerOptions.map((a) => `"${a}"`).join(", ")
|
|
3060
|
-
});
|
|
3153
|
+
const formatTag = renderFormatTag(q.mode, maxFreeFormAnswerLength);
|
|
3061
3154
|
return `<question number="${idx + 1}">
|
|
3062
3155
|
${textTag}
|
|
3063
|
-
${
|
|
3156
|
+
${formatTag}
|
|
3064
3157
|
</question>`;
|
|
3065
3158
|
}).join("\n\n");
|
|
3066
3159
|
const questionsSection = `<questions>
|
|
@@ -3080,18 +3173,41 @@ async function fetchImageAsBase64(imageUrl, imageDownloadOptions) {
|
|
|
3080
3173
|
const downloadResult = await downloadImageAsBase64(imageUrl, imageDownloadOptions);
|
|
3081
3174
|
return downloadResult.base64Data;
|
|
3082
3175
|
}
|
|
3176
|
+
function buildResponseSchemaForQuestions(normalizedQuestions, maxFreeFormAnswerLength) {
|
|
3177
|
+
const hasFreeForm = normalizedQuestions.some((q) => q.mode.kind === "freeForm");
|
|
3178
|
+
if (hasFreeForm) {
|
|
3179
|
+
const longestConstrained = Math.max(
|
|
3180
|
+
0,
|
|
3181
|
+
...normalizedQuestions.flatMap(allowedAnswersForQuestion).map((a) => a.length)
|
|
3182
|
+
);
|
|
3183
|
+
const schemaMax = Math.max(
|
|
3184
|
+
maxFreeFormAnswerLength,
|
|
3185
|
+
SKIP_SENTINEL.length,
|
|
3186
|
+
longestConstrained
|
|
3187
|
+
);
|
|
3188
|
+
return createAskQuestionsSchema(z2.string().min(1).max(schemaMax));
|
|
3189
|
+
}
|
|
3190
|
+
const allowed = Array.from(
|
|
3191
|
+
new Set(normalizedQuestions.flatMap(allowedAnswersForQuestion))
|
|
3192
|
+
);
|
|
3193
|
+
return createAskQuestionsSchema(z2.enum([...allowed, SKIP_SENTINEL]));
|
|
3194
|
+
}
|
|
3083
3195
|
async function analyzeQuestions({
|
|
3084
3196
|
provider,
|
|
3085
3197
|
modelId,
|
|
3086
3198
|
userPrompt,
|
|
3087
3199
|
systemPrompt,
|
|
3088
|
-
|
|
3200
|
+
normalizedQuestions,
|
|
3201
|
+
maxFreeFormAnswerLength,
|
|
3089
3202
|
imageDataUrl,
|
|
3090
3203
|
credentials
|
|
3091
3204
|
}) {
|
|
3092
3205
|
"use step";
|
|
3093
3206
|
const model = await createLanguageModelFromConfig(provider, modelId, credentials);
|
|
3094
|
-
const responseSchema =
|
|
3207
|
+
const responseSchema = buildResponseSchemaForQuestions(
|
|
3208
|
+
normalizedQuestions,
|
|
3209
|
+
maxFreeFormAnswerLength
|
|
3210
|
+
);
|
|
3095
3211
|
const response = await generateText({
|
|
3096
3212
|
model,
|
|
3097
3213
|
output: Output.object({ schema: responseSchema }),
|
|
@@ -3170,6 +3286,8 @@ async function askQuestions(assetId, questions, options) {
|
|
|
3170
3286
|
);
|
|
3171
3287
|
}
|
|
3172
3288
|
questions.forEach((q, idx) => {
|
|
3289
|
+
if (q.freeFormReply === true)
|
|
3290
|
+
return;
|
|
3173
3291
|
for (const opt of q.answerOptions ?? []) {
|
|
3174
3292
|
if (typeof opt === "string" && opt.length > maxAnswerOptionLength) {
|
|
3175
3293
|
throw new MuxAiError(
|
|
@@ -3179,6 +3297,14 @@ async function askQuestions(assetId, questions, options) {
|
|
|
3179
3297
|
}
|
|
3180
3298
|
}
|
|
3181
3299
|
});
|
|
3300
|
+
const DEFAULT_MAX_FREE_FORM_ANSWER_LENGTH = 500;
|
|
3301
|
+
const maxFreeFormAnswerLength = options?.maxFreeFormAnswerLength ?? DEFAULT_MAX_FREE_FORM_ANSWER_LENGTH;
|
|
3302
|
+
if (!Number.isFinite(maxFreeFormAnswerLength) || maxFreeFormAnswerLength <= 0) {
|
|
3303
|
+
throw new MuxAiError(
|
|
3304
|
+
`maxFreeFormAnswerLength must be a positive number (received ${maxFreeFormAnswerLength}).`,
|
|
3305
|
+
{ type: "validation_error" }
|
|
3306
|
+
);
|
|
3307
|
+
}
|
|
3182
3308
|
const {
|
|
3183
3309
|
provider = "openai",
|
|
3184
3310
|
model,
|
|
@@ -3190,23 +3316,8 @@ async function askQuestions(assetId, questions, options) {
|
|
|
3190
3316
|
storyboardWidth = 640,
|
|
3191
3317
|
credentials
|
|
3192
3318
|
} = options ?? {};
|
|
3193
|
-
const normalizedQuestions = questions.map((q, idx) =>
|
|
3194
|
-
|
|
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
|
-
);
|
|
3319
|
+
const normalizedQuestions = questions.map((q, idx) => normalizeQuestion(q, idx));
|
|
3320
|
+
const hasFreeForm = normalizedQuestions.some((q) => q.mode.kind === "freeForm");
|
|
3210
3321
|
const modelConfig = resolveLanguageModelConfig({
|
|
3211
3322
|
...options,
|
|
3212
3323
|
model,
|
|
@@ -3240,9 +3351,10 @@ async function askQuestions(assetId, questions, options) {
|
|
|
3240
3351
|
questions: normalizedQuestions,
|
|
3241
3352
|
transcriptText,
|
|
3242
3353
|
isCleanTranscript: cleanTranscript,
|
|
3243
|
-
isAudioOnly
|
|
3354
|
+
isAudioOnly,
|
|
3355
|
+
maxFreeFormAnswerLength
|
|
3244
3356
|
});
|
|
3245
|
-
const systemPrompt = isAudioOnly
|
|
3357
|
+
const systemPrompt = buildSystemPrompt(isAudioOnly, hasFreeForm, maxFreeFormAnswerLength);
|
|
3246
3358
|
let analysisResponse;
|
|
3247
3359
|
let imageUrl;
|
|
3248
3360
|
try {
|
|
@@ -3252,7 +3364,8 @@ async function askQuestions(assetId, questions, options) {
|
|
|
3252
3364
|
modelId: modelConfig.modelId,
|
|
3253
3365
|
userPrompt,
|
|
3254
3366
|
systemPrompt,
|
|
3255
|
-
|
|
3367
|
+
normalizedQuestions,
|
|
3368
|
+
maxFreeFormAnswerLength,
|
|
3256
3369
|
credentials
|
|
3257
3370
|
});
|
|
3258
3371
|
} else {
|
|
@@ -3270,7 +3383,8 @@ async function askQuestions(assetId, questions, options) {
|
|
|
3270
3383
|
modelId: modelConfig.modelId,
|
|
3271
3384
|
userPrompt,
|
|
3272
3385
|
systemPrompt,
|
|
3273
|
-
|
|
3386
|
+
normalizedQuestions,
|
|
3387
|
+
maxFreeFormAnswerLength,
|
|
3274
3388
|
imageDataUrl: base64Data,
|
|
3275
3389
|
credentials
|
|
3276
3390
|
});
|
|
@@ -3281,7 +3395,8 @@ async function askQuestions(assetId, questions, options) {
|
|
|
3281
3395
|
modelId: modelConfig.modelId,
|
|
3282
3396
|
userPrompt,
|
|
3283
3397
|
systemPrompt,
|
|
3284
|
-
|
|
3398
|
+
normalizedQuestions,
|
|
3399
|
+
maxFreeFormAnswerLength,
|
|
3285
3400
|
imageDataUrl: storyboardUrl,
|
|
3286
3401
|
credentials
|
|
3287
3402
|
})
|
|
@@ -3316,20 +3431,42 @@ async function askQuestions(assetId, questions, options) {
|
|
|
3316
3431
|
);
|
|
3317
3432
|
}
|
|
3318
3433
|
const answers = analysisResponse.result.answers.map((raw, idx) => {
|
|
3319
|
-
const
|
|
3320
|
-
const
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3434
|
+
const question = normalizedQuestions[idx];
|
|
3435
|
+
const isFreeForm = question.mode.kind === "freeForm";
|
|
3436
|
+
const reasoningScrub = safety.scrubDetailed(raw.reasoning, `reasoning[${idx}]`);
|
|
3437
|
+
const explicitSkip = raw.skipped || raw.answer === SKIP_SENTINEL;
|
|
3438
|
+
const answerScrub = isFreeForm && !explicitSkip ? safety.scrubDetailed(raw.answer, `answer[${idx}]`) : null;
|
|
3439
|
+
const overLength = isFreeForm && !explicitSkip && !(answerScrub?.leaked ?? false) && (answerScrub?.text.length ?? 0) > maxFreeFormAnswerLength;
|
|
3440
|
+
if (overLength) {
|
|
3441
|
+
console.warn(
|
|
3442
|
+
`[@mux/ai] Free-form answer at index ${idx} exceeded ${maxFreeFormAnswerLength}-char cap (${answerScrub.text.length} chars); treating as skipped.`
|
|
3324
3443
|
);
|
|
3325
3444
|
}
|
|
3445
|
+
const isSkipped = explicitSkip || reasoningScrub.leaked || (answerScrub?.leaked ?? false) || overLength;
|
|
3446
|
+
if (!isSkipped && !isFreeForm) {
|
|
3447
|
+
const allowed = allowedAnswersForQuestion(question);
|
|
3448
|
+
if (!allowed.includes(raw.answer)) {
|
|
3449
|
+
throw new MuxAiError(
|
|
3450
|
+
`Answer "${raw.answer}" for question ${idx} is not in allowed options: ${allowed.join(", ")}`
|
|
3451
|
+
);
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
let finalAnswer;
|
|
3455
|
+
if (isSkipped) {
|
|
3456
|
+
finalAnswer = null;
|
|
3457
|
+
} else if (isFreeForm) {
|
|
3458
|
+
finalAnswer = answerScrub ? answerScrub.text : raw.answer;
|
|
3459
|
+
} else {
|
|
3460
|
+
finalAnswer = raw.answer;
|
|
3461
|
+
}
|
|
3462
|
+
const suppressed = reasoningScrub.leaked || (answerScrub?.leaked ?? false);
|
|
3326
3463
|
return {
|
|
3327
3464
|
// Use the trusted input verbatim rather than the model's echo.
|
|
3328
|
-
question:
|
|
3465
|
+
question: question.question,
|
|
3329
3466
|
confidence: isSkipped ? 0 : Math.min(1, Math.max(0, raw.confidence)),
|
|
3330
|
-
reasoning:
|
|
3467
|
+
reasoning: suppressed ? "Response suppressed by safety filter." : reasoningScrub.text,
|
|
3331
3468
|
skipped: isSkipped,
|
|
3332
|
-
answer:
|
|
3469
|
+
answer: finalAnswer
|
|
3333
3470
|
};
|
|
3334
3471
|
});
|
|
3335
3472
|
return {
|
|
@@ -5042,6 +5179,15 @@ var OPENAI_MODERATION_BASE_DELAY_MS = 750;
|
|
|
5042
5179
|
var OPENAI_MODERATION_MAX_DELAY_MS = 3e3;
|
|
5043
5180
|
var MIN_SAMPLE_COVERAGE_FOR_CONFIDENT_THRESHOLDING = 0.5;
|
|
5044
5181
|
var MIN_SUCCESSFUL_THUMBNAILS_FOR_CONFIDENT_THRESHOLDING = 3;
|
|
5182
|
+
var GOOGLE_VISION_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate";
|
|
5183
|
+
var GOOGLE_VISION_LIKELIHOOD_TO_SCORE = {
|
|
5184
|
+
UNKNOWN: 0,
|
|
5185
|
+
VERY_UNLIKELY: 0.2,
|
|
5186
|
+
UNLIKELY: 0.4,
|
|
5187
|
+
POSSIBLE: 0.6,
|
|
5188
|
+
LIKELY: 0.8,
|
|
5189
|
+
VERY_LIKELY: 1
|
|
5190
|
+
};
|
|
5045
5191
|
var HIVE_ENDPOINT = "https://api.thehive.ai/api/v2/task/sync";
|
|
5046
5192
|
var HIVE_SEXUAL_CATEGORIES = [
|
|
5047
5193
|
"general_nsfw",
|
|
@@ -5370,6 +5516,98 @@ async function requestHiveModeration(images, maxConcurrent = 5, submissionMode =
|
|
|
5370
5516
|
}));
|
|
5371
5517
|
return await processConcurrently(targets, moderateImageWithHive, maxConcurrent);
|
|
5372
5518
|
}
|
|
5519
|
+
async function moderateImageWithGoogleVision(entry) {
|
|
5520
|
+
"use step";
|
|
5521
|
+
try {
|
|
5522
|
+
const apiKey = await getApiKeyFromEnv("google-vision-api", entry.credentials);
|
|
5523
|
+
const imageField = entry.isBase64 ? { content: entry.image } : { source: { imageUri: entry.image } };
|
|
5524
|
+
const controller = new AbortController();
|
|
5525
|
+
const timeout = setTimeout(() => controller.abort(), 15e3);
|
|
5526
|
+
let res;
|
|
5527
|
+
try {
|
|
5528
|
+
res = await fetch(GOOGLE_VISION_ENDPOINT, {
|
|
5529
|
+
method: "POST",
|
|
5530
|
+
headers: {
|
|
5531
|
+
"Accept": "application/json",
|
|
5532
|
+
"Content-Type": "application/json",
|
|
5533
|
+
"X-goog-api-key": apiKey
|
|
5534
|
+
},
|
|
5535
|
+
body: JSON.stringify({
|
|
5536
|
+
requests: [
|
|
5537
|
+
{
|
|
5538
|
+
image: imageField,
|
|
5539
|
+
features: [{ type: "SAFE_SEARCH_DETECTION" }]
|
|
5540
|
+
}
|
|
5541
|
+
]
|
|
5542
|
+
}),
|
|
5543
|
+
signal: controller.signal
|
|
5544
|
+
});
|
|
5545
|
+
} catch (err) {
|
|
5546
|
+
if (err?.name === "AbortError") {
|
|
5547
|
+
throw new Error("Google Vision request timed out after 15s");
|
|
5548
|
+
}
|
|
5549
|
+
throw err;
|
|
5550
|
+
} finally {
|
|
5551
|
+
clearTimeout(timeout);
|
|
5552
|
+
}
|
|
5553
|
+
const json = await res.json().catch(() => void 0);
|
|
5554
|
+
if (!res.ok) {
|
|
5555
|
+
throw new Error(
|
|
5556
|
+
`Google Vision moderation error: ${res.status} ${res.statusText} - ${JSON.stringify(json)}`
|
|
5557
|
+
);
|
|
5558
|
+
}
|
|
5559
|
+
const perImage = json?.responses?.[0];
|
|
5560
|
+
if (perImage?.error) {
|
|
5561
|
+
throw new Error(
|
|
5562
|
+
`Google Vision per-image error: ${perImage.error.code} ${perImage.error.message || "Unknown error"}`
|
|
5563
|
+
);
|
|
5564
|
+
}
|
|
5565
|
+
const annotation = perImage?.safeSearchAnnotation;
|
|
5566
|
+
if (!annotation) {
|
|
5567
|
+
throw new Error(`Google Vision response missing safeSearchAnnotation: ${JSON.stringify(json)}`);
|
|
5568
|
+
}
|
|
5569
|
+
const adultLikelihood = typeof annotation.adult === "string" ? annotation.adult : "UNKNOWN";
|
|
5570
|
+
const violenceLikelihood = typeof annotation.violence === "string" ? annotation.violence : "UNKNOWN";
|
|
5571
|
+
const sexual = GOOGLE_VISION_LIKELIHOOD_TO_SCORE[adultLikelihood] ?? 0;
|
|
5572
|
+
const violence = GOOGLE_VISION_LIKELIHOOD_TO_SCORE[violenceLikelihood] ?? 0;
|
|
5573
|
+
return {
|
|
5574
|
+
url: entry.url,
|
|
5575
|
+
time: entry.time,
|
|
5576
|
+
sexual,
|
|
5577
|
+
violence,
|
|
5578
|
+
error: false
|
|
5579
|
+
};
|
|
5580
|
+
} catch (error) {
|
|
5581
|
+
return {
|
|
5582
|
+
url: entry.url,
|
|
5583
|
+
time: entry.time,
|
|
5584
|
+
sexual: 0,
|
|
5585
|
+
violence: 0,
|
|
5586
|
+
error: true,
|
|
5587
|
+
errorMessage: error instanceof Error ? error.message : String(error)
|
|
5588
|
+
};
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
5591
|
+
async function requestGoogleVisionModeration(images, maxConcurrent = 5, submissionMode = "url", downloadOptions, credentials) {
|
|
5592
|
+
"use step";
|
|
5593
|
+
const imageUrls = images.map((img) => img.url);
|
|
5594
|
+
const timeByUrl = new Map(images.map((img) => [img.url, img.time]));
|
|
5595
|
+
const targets = submissionMode === "base64" ? (await downloadImagesAsBase64(imageUrls, downloadOptions, maxConcurrent)).map((img) => ({
|
|
5596
|
+
url: img.url,
|
|
5597
|
+
time: timeByUrl.get(img.url),
|
|
5598
|
+
// Vision REST wants raw base64; downloadImagesAsBase64 returns a data URI.
|
|
5599
|
+
image: img.base64Data.startsWith("data:") ? img.base64Data.split(",")[1] : img.base64Data,
|
|
5600
|
+
isBase64: true,
|
|
5601
|
+
credentials
|
|
5602
|
+
})) : images.map((img) => ({
|
|
5603
|
+
url: img.url,
|
|
5604
|
+
time: img.time,
|
|
5605
|
+
image: img.url,
|
|
5606
|
+
isBase64: false,
|
|
5607
|
+
credentials
|
|
5608
|
+
}));
|
|
5609
|
+
return processConcurrently(targets, moderateImageWithGoogleVision, maxConcurrent);
|
|
5610
|
+
}
|
|
5373
5611
|
async function getThumbnailUrlsFromTimestamps(playbackId, timestampsMs, options) {
|
|
5374
5612
|
"use step";
|
|
5375
5613
|
const { width, shouldSign, credentials } = options;
|
|
@@ -5433,8 +5671,11 @@ async function getModerationScores(assetId, options = {}) {
|
|
|
5433
5671
|
);
|
|
5434
5672
|
} else if (provider === "hive") {
|
|
5435
5673
|
throw new Error("Hive does not support transcript moderation in this workflow. Use provider: 'openai' for audio-only assets.");
|
|
5674
|
+
} else if (provider === "google-vision-api") {
|
|
5675
|
+
throw new Error("google-vision-api is image-only and does not support transcript moderation. Use provider: 'openai' for audio-only assets.");
|
|
5436
5676
|
} else {
|
|
5437
|
-
|
|
5677
|
+
const exhaustiveCheck = provider;
|
|
5678
|
+
throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
|
|
5438
5679
|
}
|
|
5439
5680
|
} else {
|
|
5440
5681
|
const estimatedIntervalCount = duration <= 50 ? 5 : Math.ceil(duration / thumbnailInterval);
|
|
@@ -5477,8 +5718,17 @@ async function getModerationScores(assetId, options = {}) {
|
|
|
5477
5718
|
imageDownloadOptions,
|
|
5478
5719
|
credentials
|
|
5479
5720
|
);
|
|
5721
|
+
} else if (provider === "google-vision-api") {
|
|
5722
|
+
thumbnailScores = await requestGoogleVisionModeration(
|
|
5723
|
+
thumbnailUrls,
|
|
5724
|
+
maxConcurrent,
|
|
5725
|
+
imageSubmissionMode,
|
|
5726
|
+
imageDownloadOptions,
|
|
5727
|
+
credentials
|
|
5728
|
+
);
|
|
5480
5729
|
} else {
|
|
5481
|
-
|
|
5730
|
+
const exhaustiveCheck = provider;
|
|
5731
|
+
throw new Error(`Unsupported moderation provider: ${exhaustiveCheck}`);
|
|
5482
5732
|
}
|
|
5483
5733
|
}
|
|
5484
5734
|
const failed = thumbnailScores.filter((s) => s.error);
|