mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-28 16:35:27 +08:00
fix(generation): parse data URLs without regex and cap vision image size (#1902)
## Summary Large or unsupported images attached to vision requests can crash prompt building or be rejected by the model provider, and a single bad image fails the whole generation step (retries fail the same way). - **`@openmaic/generation` 0.3.19**: `buildVisionUserContent` no longer runs a regex over the whole data URL. A new exported `parseBase64DataUrl` splits header and payload with plain string ops, so multi-megabyte payloads are never scanned by the regex engine. - **Vision image cap (app)**: new `prepareVisionImages` (`lib/server/generation/vision-images.ts`) runs right before every vision call — outline, scene content, scene actions, and the PBL evaluator (which receives uploaded submissions). - Pass-through only for single-frame, 8-bit sRGB/grayscale png/jpeg/webp/gif whose **base64 payload** is within `MAX_VISION_IMAGE_BASE64_BYTES` (1 MiB — providers measure the encoded payload) and whose longest side is within `MAX_VISION_IMAGE_DIMENSION` (2048 px). Pixels are strictly decoded first so truncated data is caught. http(s) URLs are untouched. - Everything else (oversized, animated, CMYK, 16-bit, unsupported formats) is decoded once with sharp (first frame, EXIF-rotated, resized inside 2048, flattened on white, sRGB) and encoded to JPEG, stepping quality down until under the cap. - Images that still don't fit or can't be decoded are dropped with a warning; the step continues. - Preparation runs where vision images are resolved (outline and scene-content pre-resolution), so the prompt's "[see attached]" placeholders and asset-id mappings only describe images that are actually attached. The PBL evaluator logs when an image submission is evaluated without its image. - Processing is sequential to bound peak memory. Non-goals: no change to material upload/extraction, prompts, or generation logic. ## Test plan - [x] `pnpm check`, `pnpm lint`, `npx tsc --noEmit` - [x] `@openmaic/generation` build + typecheck + tests (incl. an 8 MiB payload, malformed and non-base64 data URLs, http passthrough) - [x] New `tests/generation/vision-images.test.ts` (small untouched, large downscaled under caps, unsupported format converted, undecodable dropped while others kept, http untouched); updated scene-content / retry-boundary / evaluator tests - [x] Full `pnpm test` (Node 22) - [x] `node scripts/check-package-version-bumps.mjs origin/main` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
17 files changed
+838
-129
No files matched your search
@@ -9,6 +9,10 @@ export const MAX_PDF_CONTENT_CHARS = 50000;
|
||||
// Maximum number of images to send as vision content parts
|
||||
export const MAX_VISION_IMAGES = 20;
|
||||
|
||||
// Providers cap the base64 payload, which is about 4/3 the decoded byte length.
|
||||
export const MAX_VISION_IMAGE_BASE64_BYTES = 1024 * 1024;
|
||||
export const MAX_VISION_IMAGE_DIMENSION = 2048;
|
||||
|
||||
// Size cap for one course material document (bytes), enforced by the extract
|
||||
// route on both the multipart and asset-id forms and by the vision-image
|
||||
// resolution (`resolveVisionImagesForPrompt`) so an oversized asset is
|
||||
|
||||
@@ -46,6 +46,7 @@ import type { LanguageModel } from 'ai';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import { streamLLM } from '@/lib/ai/llm';
|
||||
import { buildVisionUserContent } from '@openmaic/generation';
|
||||
import { prepareVisionImages } from '@/lib/server/generation/vision-images';
|
||||
import type { ThinkingConfig } from '@/lib/types/provider';
|
||||
|
||||
import type {
|
||||
@@ -152,6 +153,16 @@ async function* runShared(args: RunSharedArgs): AsyncGenerator<PBLSSEEvent, void
|
||||
const isMilestone = kind === 'milestone';
|
||||
let lastSanitizedLength = 0;
|
||||
try {
|
||||
const safeImages = visionImageSrc
|
||||
? await prepareVisionImages([{ id: 'submission', src: visionImageSrc }], log)
|
||||
: [];
|
||||
if (visionImageSrc && safeImages.length === 0) {
|
||||
log.warn('Image submission evaluated without the image', {
|
||||
kind,
|
||||
milestoneId,
|
||||
microtaskId,
|
||||
});
|
||||
}
|
||||
const result = streamLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
@@ -159,14 +170,12 @@ async function* runShared(args: RunSharedArgs): AsyncGenerator<PBLSSEEvent, void
|
||||
// Image submission on a vision-capable model → send the picture as a
|
||||
// multimodal user turn (reusing OpenMAIC's buildVisionUserContent).
|
||||
// Everything else keeps the plain text prompt unchanged.
|
||||
...(visionImageSrc
|
||||
...(safeImages.length
|
||||
? {
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: buildVisionUserContent(userPrompt, [
|
||||
{ id: 'submission', src: visionImageSrc },
|
||||
]),
|
||||
content: buildVisionUserContent(userPrompt, safeImages),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* package's outline prompt. The stream is retried on the same model, then
|
||||
* once on the stage's fallback model, and every retry is reported as an event.
|
||||
*/
|
||||
import { prepareVisionImages } from '../vision-images';
|
||||
import { attachedModelFallback } from '@/lib/ai/model-fallbacks';
|
||||
import { streamLLM } from '@/lib/ai/llm';
|
||||
import {
|
||||
@@ -398,13 +399,16 @@ async function prepareOutline(
|
||||
// vision prompt byte-identical in both modes (RFC #1153 part 2 B). An
|
||||
// id the server cannot resolve is dropped here, so the placeholder
|
||||
// text below never promises an image that will not be attached.
|
||||
const resolvedVisionImages = await ctx.resolveVisionImages(
|
||||
visionSlice.map((img) => ({
|
||||
id: img.id,
|
||||
src: imageMapping[img.id],
|
||||
...(img.width !== undefined ? { width: img.width } : {}),
|
||||
...(img.height !== undefined ? { height: img.height } : {}),
|
||||
})),
|
||||
const resolvedVisionImages = await prepareVisionImages(
|
||||
await ctx.resolveVisionImages(
|
||||
visionSlice.map((img) => ({
|
||||
id: img.id,
|
||||
src: imageMapping[img.id],
|
||||
...(img.width !== undefined ? { width: img.width } : {}),
|
||||
...(img.height !== undefined ? { height: img.height } : {}),
|
||||
})),
|
||||
),
|
||||
ctx.log,
|
||||
);
|
||||
const resolvedIds = new Set(resolvedVisionImages.map((img) => img.id));
|
||||
const visionImageById = new Map(sortedImages.map((img) => [img.id, img] as const));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* and assemble the complete scene. The logic of 1.1.x's
|
||||
* POST /api/generate/scene-actions.
|
||||
*/
|
||||
import { prepareVisionImages } from '../vision-images';
|
||||
import { callLLM } from '@/lib/ai/llm';
|
||||
import {
|
||||
generateSceneActions as generateActions,
|
||||
@@ -80,6 +81,7 @@ export async function generateSceneActions(
|
||||
images?: Array<{ id: string; src: string }>,
|
||||
): Promise<string> => {
|
||||
if (images?.length && hasVision) {
|
||||
const safeImages = await prepareVisionImages(images, log);
|
||||
const result = await callLLM(
|
||||
{
|
||||
model: languageModel,
|
||||
@@ -89,7 +91,7 @@ export async function generateSceneActions(
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: buildVisionUserContent(userPrompt, images),
|
||||
content: buildVisionUserContent(userPrompt, safeImages),
|
||||
},
|
||||
],
|
||||
maxOutputTokens: modelInfo?.outputWindow,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* actions step. The logic of 1.1.x's POST /api/generate/scene-content; the
|
||||
* caller resolves the model for the scene type's stage.
|
||||
*/
|
||||
import { prepareVisionImages } from '../vision-images';
|
||||
import { callLLM } from '@/lib/ai/llm';
|
||||
import {
|
||||
applyOutlineFallbacks,
|
||||
@@ -90,25 +91,17 @@ export async function generateSceneContent(
|
||||
// Detect vision capability
|
||||
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||
|
||||
// Vision-aware AI call function. On a server-backed transport the
|
||||
// `imageMapping` values are allocated asset ids; the N3 pre-resolution
|
||||
// below has already resolved the vision slice's ids to bytes and stripped
|
||||
// every unresolvable id from the mapping, so the srcs this closure sees
|
||||
// are concrete data URLs and this resolution is a DEFENSIVE NO-OP — it
|
||||
// passes concrete srcs through untouched (RFC #1153 part 2 B) and would
|
||||
// only drop an id that still carried an allocated id, which the
|
||||
// pre-resolution makes impossible. Kept so a package consumer that
|
||||
// generates without pre-resolving still degrades cleanly.
|
||||
// Images are resolved and prepared before the generator builds its prompt,
|
||||
// so its placeholders and attachments describe the same set. The
|
||||
// resolution below is a DEFENSIVE NO-OP for those concrete data URLs
|
||||
// (RFC #1153 part 2 B); it is kept so a package consumer that generates
|
||||
// without pre-resolving never sends an allocated asset id as an image.
|
||||
const aiCall = async (
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
images?: Array<{ id: string; src: string }>,
|
||||
): Promise<string> => {
|
||||
if (images?.length && hasVision) {
|
||||
// Server-backed transport: `imageMapping` values are allocated asset
|
||||
// ids, so the image srcs reach here as ids. Resolve them to the same
|
||||
// bytes the base64 path would send BEFORE prompt assembly, keeping the
|
||||
// vision prompt byte-identical in both modes (RFC #1153 part 2 B).
|
||||
const resolvedImages = await ctx.resolveVisionImages(images);
|
||||
const result = await callLLM(
|
||||
{
|
||||
@@ -195,21 +188,16 @@ export async function generateSceneContent(
|
||||
// all-fail phase must not churn every candidate (and emit a warn per
|
||||
// candidate) until the route's 300 s platform cap. Two stops, both
|
||||
// degrading to "whatever resolved so far" — never failing the step:
|
||||
// (a) an aggregate budget for the WHOLE phase (the shared 15 s ingest
|
||||
// (a) an aggregate budget of resolver time across the phase (the shared 15 s ingest
|
||||
// constant, raced against each probe so even ONE hanging probe cannot
|
||||
// outlive it) and (b) a consecutive-failure fuse (3 unresolvable/errored
|
||||
// candidates in a row → stop). When a stop fires, every candidate that
|
||||
// did not resolve — unresolvable OR unprobed — is STRIPPED from the
|
||||
// mapping, so the generator can never hand an unresolved allocated id to
|
||||
// the defensive aiCall resolution (which would re-open the unbounded
|
||||
// probe the stop just closed); ONE summary warn names the stop.
|
||||
let phaseTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const phaseBudget = new Promise<'__vision-resolution-budget-expired__'>((resolve) => {
|
||||
phaseTimer = setTimeout(
|
||||
() => resolve('__vision-resolution-budget-expired__'),
|
||||
VISION_RESOLUTION_BUDGET_MS,
|
||||
);
|
||||
});
|
||||
// aiCall; ONE summary warn names the stop.
|
||||
// The budget meters resolver time only: image preparation (decode and
|
||||
// re-encode) runs outside it so slow transformations cannot starve probes.
|
||||
let resolutionMsRemaining = VISION_RESOLUTION_BUDGET_MS;
|
||||
const resolvedById = new Map<string, VisionPromptImage>();
|
||||
let consecutiveUnresolvable = 0;
|
||||
let stopReason: 'fuse' | 'budget' | null = null;
|
||||
@@ -219,7 +207,13 @@ export async function generateSceneContent(
|
||||
stopReason = 'fuse';
|
||||
break;
|
||||
}
|
||||
if (resolutionMsRemaining <= 0) {
|
||||
stopReason = 'budget';
|
||||
break;
|
||||
}
|
||||
let attempted: VisionPromptImage[] | '__vision-resolution-budget-expired__';
|
||||
let probeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const probeStartedAt = Date.now();
|
||||
try {
|
||||
attempted = await Promise.race([
|
||||
ctx.resolveVisionImages([
|
||||
@@ -230,7 +224,12 @@ export async function generateSceneContent(
|
||||
...(candidate.height !== undefined ? { height: candidate.height } : {}),
|
||||
},
|
||||
]),
|
||||
phaseBudget,
|
||||
new Promise<'__vision-resolution-budget-expired__'>((resolve) => {
|
||||
probeTimer = setTimeout(
|
||||
() => resolve('__vision-resolution-budget-expired__'),
|
||||
resolutionMsRemaining,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
// A throwing probe counts as an unresolvable candidate (an errored
|
||||
@@ -240,19 +239,24 @@ export async function generateSceneContent(
|
||||
error,
|
||||
);
|
||||
attempted = [];
|
||||
} finally {
|
||||
if (probeTimer !== undefined) clearTimeout(probeTimer);
|
||||
resolutionMsRemaining -= Date.now() - probeStartedAt;
|
||||
}
|
||||
if (attempted === '__vision-resolution-budget-expired__') {
|
||||
stopReason = 'budget';
|
||||
break;
|
||||
}
|
||||
if (attempted.length === 1) {
|
||||
resolvedById.set(attempted[0]!.id, attempted[0]!);
|
||||
consecutiveUnresolvable = 0;
|
||||
} else {
|
||||
if (attempted.length !== 1) {
|
||||
consecutiveUnresolvable += 1;
|
||||
continue;
|
||||
}
|
||||
// The store answered, so the fuse resets even when the image itself is
|
||||
// unusable; a dropped image is skipped and the next candidate refills.
|
||||
consecutiveUnresolvable = 0;
|
||||
const [prepared] = await prepareVisionImages(attempted, log);
|
||||
if (prepared) resolvedById.set(prepared.id, prepared);
|
||||
}
|
||||
if (phaseTimer !== undefined) clearTimeout(phaseTimer);
|
||||
resolvedVisionImages = [...resolvedById.values()];
|
||||
// Every candidate that did not resolve — an unresolvable probe, OR an
|
||||
// unprobed one when the fuse/budget stopped the phase — is stripped from
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import sharp from 'sharp';
|
||||
import { parseBase64DataUrl, type GenerationLogger } from '@openmaic/generation';
|
||||
import {
|
||||
MAX_VISION_IMAGE_BASE64_BYTES,
|
||||
MAX_VISION_IMAGE_DIMENSION,
|
||||
} from '@/lib/constants/generation';
|
||||
|
||||
interface VisionImage {
|
||||
id: string;
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const VISION_FORMATS = new Map([
|
||||
['image/png', 'png'],
|
||||
['image/jpeg', 'jpeg'],
|
||||
['image/webp', 'webp'],
|
||||
['image/gif', 'gif'],
|
||||
]);
|
||||
const JPEG_QUALITIES = [82, 65, 45, 30, 20];
|
||||
|
||||
/** Bound only inline vision attachments, leaving source assets and their ids intact. */
|
||||
export async function prepareVisionImages<T extends VisionImage>(
|
||||
images: readonly T[],
|
||||
log: Pick<GenerationLogger, 'info' | 'warn'>,
|
||||
): Promise<Array<T & VisionImage>> {
|
||||
const safeImages: Array<T & VisionImage> = [];
|
||||
// Sequential decoding bounds peak memory to one source image and its output.
|
||||
for (const image of images) {
|
||||
const parsed = parseBase64DataUrl(image.src);
|
||||
if (!parsed) {
|
||||
safeImages.push(image);
|
||||
continue;
|
||||
}
|
||||
let originalBytes = 0;
|
||||
try {
|
||||
const buffer = Buffer.from(parsed.base64, 'base64');
|
||||
originalBytes = buffer.byteLength;
|
||||
const metadata = await sharp(buffer, { failOn: 'none' }).metadata();
|
||||
const width = metadata.width;
|
||||
const height = metadata.height;
|
||||
if (!width || !height) throw new Error('Missing image dimensions');
|
||||
const declaredMime = parsed.mimeType.toLowerCase();
|
||||
const mime = declaredMime === 'image/jpg' ? 'image/jpeg' : declaredMime;
|
||||
if (
|
||||
VISION_FORMATS.get(mime) === metadata.format &&
|
||||
parsed.base64.length <= MAX_VISION_IMAGE_BASE64_BYTES &&
|
||||
(metadata.pages ?? 1) === 1 &&
|
||||
(metadata.space === 'srgb' || metadata.space === 'b-w') &&
|
||||
metadata.depth === 'uchar' &&
|
||||
Math.max(width, height, image.width ?? 0, image.height ?? 0) <= MAX_VISION_IMAGE_DIMENSION
|
||||
) {
|
||||
// Metadata alone cannot detect truncated pixel data, even for small images.
|
||||
await sharp(buffer, { failOn: 'error' }).raw().toBuffer();
|
||||
safeImages.push({
|
||||
...image,
|
||||
src: `data:${mime};base64,${parsed.base64}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Decode and transform the first frame once; quality retries only encode pixels.
|
||||
const pixels = await sharp(buffer, { failOn: 'error' })
|
||||
.rotate()
|
||||
.resize({
|
||||
width: MAX_VISION_IMAGE_DIMENSION,
|
||||
height: MAX_VISION_IMAGE_DIMENSION,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.flatten({ background: '#ffffff' })
|
||||
.toColourspace('srgb')
|
||||
.raw({ depth: 'uchar' })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let transformed = false;
|
||||
for (const quality of JPEG_QUALITIES) {
|
||||
const output = await sharp(pixels.data, {
|
||||
raw: {
|
||||
width: pixels.info.width,
|
||||
height: pixels.info.height,
|
||||
channels: pixels.info.channels,
|
||||
},
|
||||
})
|
||||
.jpeg({ quality })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (Math.ceil(output.data.byteLength / 3) * 4 > MAX_VISION_IMAGE_BASE64_BYTES) continue;
|
||||
safeImages.push({
|
||||
...image,
|
||||
src: `data:image/jpeg;base64,${output.data.toString('base64')}`,
|
||||
width: output.info.width,
|
||||
height: output.info.height,
|
||||
});
|
||||
log.info('Transformed vision image', {
|
||||
id: image.id,
|
||||
from: { mime: parsed.mimeType, bytes: originalBytes, width, height },
|
||||
to: {
|
||||
mime: 'image/jpeg',
|
||||
bytes: output.data.byteLength,
|
||||
width: output.info.width,
|
||||
height: output.info.height,
|
||||
},
|
||||
});
|
||||
transformed = true;
|
||||
break;
|
||||
}
|
||||
if (!transformed) throw new Error('Encoded image exceeds the vision base64 payload cap');
|
||||
} catch (error) {
|
||||
log.warn('Dropped vision image', {
|
||||
id: image.id,
|
||||
mime: parsed.mimeType,
|
||||
bytes: originalBytes,
|
||||
reason: error instanceof Error ? error.message : 'Image preparation failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
return safeImages;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openmaic/generation",
|
||||
"version": "0.3.18",
|
||||
"version": "0.3.19",
|
||||
"description": "Pure generation pipeline contracts and packaged prompt assets for MAIC consumers.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -96,6 +96,7 @@ export {
|
||||
buildCourseContext,
|
||||
buildLanguageText,
|
||||
buildVisionUserContent,
|
||||
parseBase64DataUrl,
|
||||
formatAgentsForPrompt,
|
||||
formatImageDescription,
|
||||
formatImagePlaceholder,
|
||||
|
||||
@@ -100,6 +100,21 @@ export function formatImagePlaceholder(img: PdfImage): string {
|
||||
return `- **${img.id}**: image${sourceInfo} page ${img.pageNumber}${dimInfo} [see attached]`;
|
||||
}
|
||||
|
||||
/** Parse a base64 data URL without scanning its payload with a regular expression. */
|
||||
export function parseBase64DataUrl(src: string): { mimeType: string; base64: string } | null {
|
||||
if (!src.startsWith('data:')) return null;
|
||||
const comma = src.indexOf(',');
|
||||
if (comma === -1) return null;
|
||||
const header = src.slice(5, comma);
|
||||
if (!header.endsWith(';base64')) return null;
|
||||
const mediaType = header.slice(0, -';base64'.length);
|
||||
const semicolon = mediaType.indexOf(';');
|
||||
const mimeType = semicolon === -1 ? mediaType : mediaType.slice(0, semicolon);
|
||||
const base64 = src.slice(comma + 1);
|
||||
if (!mimeType || !base64) return null;
|
||||
return { mimeType, base64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multimodal user content array for the AI SDK.
|
||||
* Interleaves text and images so the model can associate img_id with actual image.
|
||||
@@ -123,12 +138,12 @@ export function buildVisionUserContent(
|
||||
}
|
||||
parts.push({ type: 'text', text: `\n**${img.id}**${dimInfo}:` });
|
||||
// Strip data URI prefix — AI SDK only accepts http(s) URLs or raw base64
|
||||
const dataUriMatch = img.src.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (dataUriMatch) {
|
||||
const dataUrl = parseBase64DataUrl(img.src);
|
||||
if (dataUrl) {
|
||||
parts.push({
|
||||
type: 'image',
|
||||
image: dataUriMatch[2],
|
||||
mimeType: dataUriMatch[1],
|
||||
image: dataUrl.base64,
|
||||
mimeType: dataUrl.mimeType,
|
||||
});
|
||||
} else {
|
||||
parts.push({ type: 'image', image: img.src });
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildVisionUserContent, parseBase64DataUrl } from '@openmaic/generation';
|
||||
|
||||
describe('base64 data URLs', () => {
|
||||
it.each(['image/png', 'image/jpeg'])('parses %s and builds an image part', (mimeType) => {
|
||||
const src = `data:${mimeType};base64,aGVsbG8=`;
|
||||
expect(parseBase64DataUrl(src)).toEqual({ mimeType, base64: 'aGVsbG8=' });
|
||||
expect(buildVisionUserContent('prompt', [{ id: 'image', src }]).at(-1)).toEqual({
|
||||
type: 'image',
|
||||
image: 'aGVsbG8=',
|
||||
mimeType,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the MIME type before optional header parameters', () => {
|
||||
expect(parseBase64DataUrl('data:image/png;charset=utf-8;base64,YQ==')).toEqual({
|
||||
mimeType: 'image/png',
|
||||
base64: 'YQ==',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses and builds a prompt with an 8 MiB payload without throwing', () => {
|
||||
const base64 = 'A'.repeat(8 * 1024 * 1024);
|
||||
const src = `data:image/png;base64,${base64}`;
|
||||
expect(parseBase64DataUrl(src)).toEqual({ mimeType: 'image/png', base64 });
|
||||
expect(buildVisionUserContent('prompt', [{ id: 'large', src }]).at(-1)).toEqual({
|
||||
type: 'image',
|
||||
image: base64,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'https://example.org/image.png',
|
||||
'http://example.org/image.jpg',
|
||||
'data:image/png,plain',
|
||||
'data:image/png;base64',
|
||||
'data:;base64,YQ==',
|
||||
'data:image/png;base64,',
|
||||
'data:image/png;base64;extra,YQ==',
|
||||
'image/png;base64,YQ==',
|
||||
])('passes through a non-base64 or malformed URL: %s', (src) => {
|
||||
expect(parseBase64DataUrl(src)).toBeNull();
|
||||
expect(buildVisionUserContent('prompt', [{ id: 'image', src }]).at(-1)).toEqual({
|
||||
type: 'image',
|
||||
image: src,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import sharp from 'sharp';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createLogger } from '@/lib/logger';
|
||||
@@ -34,64 +35,88 @@ describe('outline step — standard branch prompt parity on a dropped image (N3)
|
||||
});
|
||||
});
|
||||
|
||||
test('drops an unresolvable image from the standard prompt text and the attachments', async () => {
|
||||
vi.resetModules();
|
||||
const dataUrlFor = (bytes: string) =>
|
||||
`data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
|
||||
// The pre-resolution drops img_2 (its id does not resolve server-side);
|
||||
// img_1 resolves to bytes.
|
||||
resolveVisionImagesMock.mockImplementation(async (images: Array<{ id: string; src: string }>) =>
|
||||
images
|
||||
.filter((image) => image.src !== 'ast_gone')
|
||||
.map((image) => ({ ...image, src: dataUrlFor(`outline-bytes-${image.id}`) })),
|
||||
);
|
||||
streamLLMMock.mockReturnValue({
|
||||
textStream: (async function* () {
|
||||
yield JSON.stringify({
|
||||
languageDirective: 'Teach in English.',
|
||||
outlines: [
|
||||
{
|
||||
id: 'scene_1',
|
||||
type: 'slide',
|
||||
title: 'Safety Checklist',
|
||||
description: 'Inspect the device.',
|
||||
keyPoints: ['Inspect', 'Calibrate'],
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
})(),
|
||||
});
|
||||
test.each([
|
||||
{ failure: 'unresolvable', interactiveMode: false },
|
||||
{ failure: 'undecodable', interactiveMode: false },
|
||||
{ failure: 'unresolvable', interactiveMode: true },
|
||||
{ failure: 'undecodable', interactiveMode: true },
|
||||
])(
|
||||
'drops a $failure image with interactiveMode=$interactiveMode from text and attachments',
|
||||
async ({ failure, interactiveMode }) => {
|
||||
vi.resetModules();
|
||||
const png = await sharp({
|
||||
create: { width: 32, height: 24, channels: 3, background: 'white' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const dataUrlFor = (bytes: string) =>
|
||||
`data:image/png;base64,${Buffer.concat([png, Buffer.from(bytes)]).toString('base64')}`;
|
||||
// The pre-resolution drops img_2 (its id does not resolve server-side);
|
||||
// img_1 resolves to bytes.
|
||||
resolveVisionImagesMock.mockImplementation(
|
||||
async (images: Array<{ id: string; src: string }>) =>
|
||||
images
|
||||
.filter((image) => failure !== 'unresolvable' || image.src !== 'ast_gone')
|
||||
.map((image) => ({
|
||||
...image,
|
||||
src:
|
||||
image.id === 'img_2'
|
||||
? `data:image/png;base64,${Buffer.from('undecodable').toString('base64')}`
|
||||
: dataUrlFor(`outline-bytes-${image.id}`),
|
||||
})),
|
||||
);
|
||||
streamLLMMock.mockReturnValue({
|
||||
textStream: (async function* () {
|
||||
yield JSON.stringify({
|
||||
languageDirective: 'Teach in English.',
|
||||
outlines: [
|
||||
{
|
||||
id: 'scene_1',
|
||||
type: 'slide',
|
||||
title: 'Safety Checklist',
|
||||
description: 'Inspect the device.',
|
||||
keyPoints: ['Inspect', 'Calibrate'],
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
})(),
|
||||
});
|
||||
|
||||
await generate({
|
||||
pdfImages: [
|
||||
{ id: 'img_1', src: '', pageNumber: 1, width: 100, height: 100 },
|
||||
{ id: 'img_2', src: '', pageNumber: 2, width: 200, height: 100 },
|
||||
],
|
||||
imageMapping: { img_1: 'ast_ok', img_2: 'ast_gone' },
|
||||
});
|
||||
await generate({
|
||||
pdfImages: [
|
||||
{ id: 'img_1', src: '', pageNumber: 1, width: 100, height: 100 },
|
||||
{ id: 'img_2', src: '', pageNumber: 2, width: 200, height: 100 },
|
||||
],
|
||||
imageMapping: { img_1: 'ast_ok', img_2: 'ast_gone' },
|
||||
interactiveMode,
|
||||
});
|
||||
|
||||
// The standard branch ran with a vision-enabled model, so the LLM call is
|
||||
// multimodal and its text half is the standard prompt's user text.
|
||||
const streamParams = streamLLMMock.mock.calls[0][0] as {
|
||||
system: string;
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: Array<{ type: string; text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
expect(streamParams.messages).toBeDefined();
|
||||
const content = streamParams.messages[0].content;
|
||||
const textPart = content.find((part) => part.type === 'text');
|
||||
// img_1 is promised `[see attached]` and IS attached; img_2's text
|
||||
// mention is gone together with its attachment.
|
||||
expect(textPart?.text).toContain('img_1');
|
||||
expect(textPart?.text).toContain('[see attached]');
|
||||
expect(textPart?.text).not.toContain('img_2');
|
||||
const imageParts = content.filter((part) => part.type === 'image');
|
||||
expect(imageParts).toHaveLength(1);
|
||||
expect(imageParts[0]?.image).toBe(Buffer.from('outline-bytes-img_1').toString('base64'));
|
||||
});
|
||||
// The standard branch ran with a vision-enabled model, so the LLM call is
|
||||
// multimodal and its text half is the standard prompt's user text.
|
||||
const streamParams = streamLLMMock.mock.calls[0][0] as {
|
||||
system: string;
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: Array<{ type: string; text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
expect(streamParams.messages).toBeDefined();
|
||||
const content = streamParams.messages[0].content;
|
||||
const textPart = content.find((part) => part.type === 'text');
|
||||
// img_1 is promised `[see attached]` and IS attached; img_2's text
|
||||
// mention is gone together with its attachment.
|
||||
expect(textPart?.text).toContain('img_1');
|
||||
expect(textPart?.text).toContain('[see attached]');
|
||||
expect(textPart?.text).not.toContain('img_2');
|
||||
const imageParts = content.filter((part) => part.type === 'image');
|
||||
expect(imageParts).toHaveLength(1);
|
||||
expect(resolveVisionImagesMock).toHaveBeenCalledTimes(1);
|
||||
expect(imageParts[0]?.image).toBe(
|
||||
Buffer.concat([png, Buffer.from('outline-bytes-img_1')]).toString('base64'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -102,11 +127,15 @@ describe('outline step — standard branch prompt parity on a dropped image (N3)
|
||||
async function generate(body: {
|
||||
pdfImages: Array<Pick<PdfImage, 'id' | 'src' | 'pageNumber' | 'width' | 'height'>>;
|
||||
imageMapping: ImageMapping;
|
||||
interactiveMode?: boolean;
|
||||
}) {
|
||||
const { generateOutlines } = await import('@/lib/server/generation/steps/outline');
|
||||
return generateOutlines(
|
||||
{
|
||||
requirements: { requirement: 'Teach a safety checklist course.' },
|
||||
requirements: {
|
||||
requirement: 'Teach a safety checklist course.',
|
||||
interactiveMode: body.interactiveMode,
|
||||
},
|
||||
pdfText: 'Inspect the device before calibration.',
|
||||
pdfImages: body.pdfImages as PdfImage[],
|
||||
imageMapping: body.imageMapping,
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import type { SceneOutline } from '@/lib/types/generation';
|
||||
|
||||
let png: Buffer;
|
||||
beforeAll(async () => {
|
||||
png = await sharp({
|
||||
create: { width: 32, height: 24, channels: 3, background: 'white' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
});
|
||||
function imageBase64(label: string): string {
|
||||
return Buffer.concat([png, Buffer.from(label)]).toString('base64');
|
||||
}
|
||||
|
||||
const callLLMMock = vi.hoisted(() => vi.fn());
|
||||
const resolveModelMock = vi.hoisted(() => vi.fn());
|
||||
const resolveVisionImagesMock = vi.hoisted(() => vi.fn());
|
||||
@@ -37,7 +50,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
resolveVisionImagesMock.mockImplementation(async (images: Array<{ id: string; src: string }>) =>
|
||||
images.map((image) => ({
|
||||
...image,
|
||||
src: `data:image/png;base64,${Buffer.from('resolved-bytes').toString('base64')}`,
|
||||
src: `data:image/png;base64,${imageBase64('resolved-bytes')}`,
|
||||
})),
|
||||
);
|
||||
callLLMMock.mockResolvedValueOnce({
|
||||
@@ -81,7 +94,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
const imagePart = messages[0].content.find((part: { type: string }) => part.type === 'image');
|
||||
expect(imagePart).toMatchObject({
|
||||
type: 'image',
|
||||
image: Buffer.from('resolved-bytes').toString('base64'),
|
||||
image: imageBase64('resolved-bytes'),
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
// The generated element's src is the ALLOCATED ID, resolved by the
|
||||
@@ -91,7 +104,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
|
||||
test('passes data URLs through untouched on a browser-backed request', async () => {
|
||||
vi.resetModules();
|
||||
const dataUrl = `data:image/png;base64,${Buffer.from('browser-bytes').toString('base64')}`;
|
||||
const dataUrl = `data:image/png;base64,${imageBase64('browser-bytes')}`;
|
||||
resolveVisionImagesMock.mockImplementation(
|
||||
async (images: Array<{ id: string; src: string }>) => images,
|
||||
);
|
||||
@@ -127,6 +140,42 @@ describe('scene content step — asset-id image transport', () => {
|
||||
expect(body.content.elements[0].src).toBe(dataUrl);
|
||||
});
|
||||
|
||||
test('drops an undecodable image from the prompt, attachments, and asset-id mapping', async () => {
|
||||
vi.resetModules();
|
||||
resolveVisionImagesMock.mockImplementation(async (images: Array<{ id: string; src: string }>) =>
|
||||
images.map((image) => ({
|
||||
...image,
|
||||
src:
|
||||
image.id === 'img_1'
|
||||
? `data:image/png;base64,${imageBase64('good')}`
|
||||
: `data:image/bmp;base64,${Buffer.from('undecodable').toString('base64')}`,
|
||||
})),
|
||||
);
|
||||
callLLMMock.mockResolvedValueOnce({
|
||||
text: JSON.stringify({
|
||||
elements: [
|
||||
{ type: 'image', src: 'img_2', left: 100, top: 100, width: 400, height: 300, rotate: 0 },
|
||||
],
|
||||
remark: '',
|
||||
}),
|
||||
});
|
||||
const body = await generate({
|
||||
outline: slideOutline(['img_1', 'img_2']),
|
||||
pdfImages: [
|
||||
{ id: 'img_1', src: '', pageNumber: 1 },
|
||||
{ id: 'img_2', src: '', pageNumber: 2 },
|
||||
],
|
||||
imageMapping: { img_1: 'ast_good', img_2: 'ast_bad' },
|
||||
});
|
||||
const content = callLLMMock.mock.calls[0][0].messages[0].content;
|
||||
expect(content[0].text).toContain('img_1');
|
||||
expect(content[0].text).toContain('[see attached]');
|
||||
expect(content[0].text).not.toContain('img_2');
|
||||
expect(resolveVisionImagesMock).toHaveBeenCalledTimes(3);
|
||||
expect(content.filter((part: { type: string }) => part.type === 'image')).toHaveLength(1);
|
||||
expect(body.content.elements).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('drops an unresolvable image from BOTH the prompt text and the attachments (N3)', async () => {
|
||||
vi.resetModules();
|
||||
// The pre-resolution drops img_2 (the server cannot resolve its id);
|
||||
@@ -138,7 +187,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
...image,
|
||||
src: image.src.startsWith('data:')
|
||||
? image.src
|
||||
: `data:image/png;base64,${Buffer.from(`bytes-for-${image.id}`).toString('base64')}`,
|
||||
: `data:image/png;base64,${imageBase64(`bytes-for-${image.id}`)}`,
|
||||
})),
|
||||
);
|
||||
callLLMMock.mockResolvedValueOnce({
|
||||
@@ -177,7 +226,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
// Only img_1's bytes were attached.
|
||||
const imageParts = content.filter((part: { type: string }) => part.type === 'image');
|
||||
expect(imageParts).toHaveLength(1);
|
||||
expect(imageParts[0].image).toBe(Buffer.from('bytes-for-img_1').toString('base64'));
|
||||
expect(imageParts[0].image).toBe(imageBase64('bytes-for-img_1'));
|
||||
// The surviving image still resolves to the ALLOCATED ID in the element
|
||||
// src (part 2 B transport preserved).
|
||||
expect(body.content.elements[0].src).toBe('ast_ok');
|
||||
@@ -185,13 +234,13 @@ describe('scene content step — asset-id image transport', () => {
|
||||
|
||||
test('handles a MIXED imageMapping (allocated ids AND data URLs) in one request (N4)', async () => {
|
||||
vi.resetModules();
|
||||
const dataUrl = `data:image/png;base64,${Buffer.from('browser-bytes').toString('base64')}`;
|
||||
const dataUrl = `data:image/png;base64,${imageBase64('browser-bytes')}`;
|
||||
resolveVisionImagesMock.mockImplementation(async (images: Array<{ id: string; src: string }>) =>
|
||||
images.map((image) => ({
|
||||
...image,
|
||||
src: image.src.startsWith('data:')
|
||||
? image.src
|
||||
: `data:image/png;base64,${Buffer.from('resolved-bytes').toString('base64')}`,
|
||||
: `data:image/png;base64,${imageBase64('resolved-bytes')}`,
|
||||
})),
|
||||
);
|
||||
callLLMMock.mockResolvedValueOnce({
|
||||
@@ -241,8 +290,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
|
||||
test('multi-drop: refills the vision slice with RESOLVED candidates so no promise dangles (P2)', async () => {
|
||||
vi.resetModules();
|
||||
const dataUrlFor = (bytes: string) =>
|
||||
`data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
|
||||
const dataUrlFor = (bytes: string) => `data:image/png;base64,${imageBase64(bytes)}`;
|
||||
// 25 candidates (> MAX_VISION_IMAGES = 20); img_2, img_7 and img_15 are
|
||||
// unresolvable. The old N3 filter dropped them and let the generator
|
||||
// re-slice img_21..img_23 in UNRESOLVED; the refill must admit them WITH
|
||||
@@ -295,9 +343,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
const imageParts = content.filter((part: { type: string }) => part.type === 'image');
|
||||
expect(imageParts).toHaveLength(20);
|
||||
imageParts.forEach((part: { image: string }, index: number) => {
|
||||
expect(part.image).toBe(
|
||||
Buffer.from(`bytes-for-${expectedResolved[index]}`).toString('base64'),
|
||||
);
|
||||
expect(part.image).toBe(imageBase64(`bytes-for-${expectedResolved[index]}`));
|
||||
});
|
||||
for (const refilled of ['img_21', 'img_22', 'img_23']) {
|
||||
expect(text).toContain(`**${refilled}**`);
|
||||
@@ -338,6 +384,36 @@ describe('scene content step — asset-id image transport', () => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('undecodable but resolvable images do not trip the fuse; later valid images still refill the slice', async () => {
|
||||
vi.resetModules();
|
||||
// The store answers every probe, but the first four images are corrupt.
|
||||
// Preparation drops them; that is a content problem, not a store outage,
|
||||
// so the consecutive-failure fuse must not stop the phase before img_5.
|
||||
const { ids, pdfImages, imageMapping } = manyCandidateImages(5);
|
||||
resolveVisionImagesMock.mockImplementation(async (images: Array<{ id: string; src: string }>) =>
|
||||
images.map((image) => ({
|
||||
...image,
|
||||
src:
|
||||
image.src === 'ast_img_5' || image.id === 'img_5'
|
||||
? `data:image/png;base64,${imageBase64('good')}`
|
||||
: `data:image/bmp;base64,${Buffer.from('undecodable').toString('base64')}`,
|
||||
})),
|
||||
);
|
||||
callLLMMock.mockResolvedValueOnce({
|
||||
text: JSON.stringify({ elements: [], remark: '' }),
|
||||
});
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await generate({ outline: slideOutline(ids), pdfImages, imageMapping });
|
||||
|
||||
const content = callLLMMock.mock.calls[0][0].messages[0].content;
|
||||
expect(content[0].text).toContain('img_5');
|
||||
expect(content[0].text).not.toContain('**img_1**');
|
||||
expect(content.filter((part: { type: string }) => part.type === 'image')).toHaveLength(1);
|
||||
expect(warn.mock.calls.some((call) => String(call[0]).includes('fuse'))).toBe(false);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('hanging store: the aggregate resolution budget stops the phase within the bound, no hang (P2-r3)', async () => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
@@ -372,8 +448,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
|
||||
test('hallucinated reference to a DROPPED id removes the element (no dangling src, P3)', async () => {
|
||||
vi.resetModules();
|
||||
const dataUrlFor = (bytes: string) =>
|
||||
`data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
|
||||
const dataUrlFor = (bytes: string) => `data:image/png;base64,${imageBase64(bytes)}`;
|
||||
resolveVisionImagesMock.mockImplementation(async (images: Array<{ id: string; src: string }>) =>
|
||||
images
|
||||
.filter((image) => image.src !== 'ast_gone')
|
||||
@@ -409,8 +484,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
|
||||
test('browser-backed mode is unaffected: >cap data-URL images all keep their promises', async () => {
|
||||
vi.resetModules();
|
||||
const dataUrlFor = (bytes: string) =>
|
||||
`data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
|
||||
const dataUrlFor = (bytes: string) => `data:image/png;base64,${imageBase64(bytes)}`;
|
||||
// Data URLs pass through the resolver untouched — nothing can drop.
|
||||
resolveVisionImagesMock.mockImplementation(
|
||||
async (images: Array<{ id: string; src: string }>) => images,
|
||||
@@ -444,7 +518,7 @@ describe('scene content step — asset-id image transport', () => {
|
||||
const imageParts = content.filter((part: { type: string }) => part.type === 'image');
|
||||
expect(imageParts).toHaveLength(20);
|
||||
imageParts.forEach((part: { image: string }, index: number) => {
|
||||
expect(part.image).toBe(Buffer.from(ids[index]).toString('base64'));
|
||||
expect(part.image).toBe(imageBase64(ids[index]));
|
||||
});
|
||||
// The element src is the data URL verbatim, exactly as before.
|
||||
expect(body.content.elements[0].src).toBe(dataUrlFor('img_1'));
|
||||
|
||||
@@ -105,6 +105,71 @@ describe('scene step retry boundary', () => {
|
||||
expect(mocks.callLLM.mock.calls[0][0].maxRetries).toBe(0);
|
||||
});
|
||||
|
||||
it.each(['content', 'actions'])(
|
||||
'continues the scene-%s call when all vision attachments are undecodable',
|
||||
async (step) => {
|
||||
vi.resetModules();
|
||||
const image = {
|
||||
id: 'bad',
|
||||
src: `data:image/png;base64,${Buffer.from('undecodable').toString('base64')}`,
|
||||
};
|
||||
const visionModel = {
|
||||
model: { id: 'language-model' },
|
||||
modelInfo: { outputWindow: 4096, capabilities: { vision: true } },
|
||||
modelString: 'test:model',
|
||||
} as never;
|
||||
if (step === 'content') {
|
||||
mocks.generateSceneContent.mockImplementation(async (_outline, aiCall, options) => {
|
||||
expect(options.resolvedVisionImages).toEqual([]);
|
||||
expect(options.assignedImages).toEqual([]);
|
||||
expect(options.imageMapping).toEqual({});
|
||||
await aiCall('system', 'user', options.resolvedVisionImages);
|
||||
return { elements: [], remark: 'ok' };
|
||||
});
|
||||
const { generateSceneContent } =
|
||||
await import('@/lib/server/generation/steps/scene-content');
|
||||
await generateSceneContent(
|
||||
{
|
||||
outline: { ...outline, suggestedImageIds: [image.id] },
|
||||
model: visionModel,
|
||||
pdfImages: [{ ...image, pageNumber: 1 }],
|
||||
imageMapping: { [image.id]: image.src },
|
||||
},
|
||||
{
|
||||
log: createLogger('Scene Content'),
|
||||
resolveVisionImages: async (images) => [...images],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
mocks.generateSceneActions.mockImplementation(async (_outline, _content, aiCall) => {
|
||||
await aiCall('system', 'user', [image]);
|
||||
return [];
|
||||
});
|
||||
mocks.buildCompleteScene.mockReturnValue({ id: 'scene-1' });
|
||||
const { generateSceneActions } =
|
||||
await import('@/lib/server/generation/steps/scene-actions');
|
||||
await generateSceneActions(
|
||||
{
|
||||
outline,
|
||||
allOutlines: [outline],
|
||||
content: { elements: [], remark: '' },
|
||||
stageId: 'stage-1',
|
||||
model: visionModel,
|
||||
},
|
||||
{ log: createLogger('Scene Actions') },
|
||||
);
|
||||
}
|
||||
if (step === 'content') {
|
||||
expect(mocks.buildVisionUserContent).not.toHaveBeenCalled();
|
||||
expect(mocks.callLLM.mock.calls[0][0].prompt).toBe('user');
|
||||
} else {
|
||||
expect(mocks.buildVisionUserContent).toHaveBeenCalledWith('user', []);
|
||||
}
|
||||
expect(mocks.callLLM).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.callLLM.mock.calls[0][0].maxRetries).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('normalizes stored legacy PBL content before generating actions and building the scene', async () => {
|
||||
vi.resetModules();
|
||||
mocks.generateSceneActions.mockImplementation(async (_outline, content, aiCall) => {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import sharp from 'sharp';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseBase64DataUrl } from '@openmaic/generation';
|
||||
import {
|
||||
MAX_VISION_IMAGE_BASE64_BYTES,
|
||||
MAX_VISION_IMAGE_DIMENSION,
|
||||
} from '@/lib/constants/generation';
|
||||
import { prepareVisionImages } from '@/lib/server/generation/vision-images';
|
||||
|
||||
const logger = () => ({ info: vi.fn(), warn: vi.fn() });
|
||||
const dataUrl = (buffer: Buffer, mime = 'image/png') =>
|
||||
`data:${mime};base64,${buffer.toString('base64')}`;
|
||||
const tinyImage = () =>
|
||||
sharp({ create: { width: 32, height: 24, channels: 4, background: '#00ff0080' } });
|
||||
|
||||
async function expectWithinCaps(image: { src: string; width?: number; height?: number }) {
|
||||
const parsed = parseBase64DataUrl(image.src)!;
|
||||
expect(parsed.mimeType).toBe('image/jpeg');
|
||||
const buffer = Buffer.from(parsed.base64, 'base64');
|
||||
expect(parsed.base64.length).toBeLessThanOrEqual(MAX_VISION_IMAGE_BASE64_BYTES);
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
expect(Math.max(metadata.width!, metadata.height!)).toBeLessThanOrEqual(
|
||||
MAX_VISION_IMAGE_DIMENSION,
|
||||
);
|
||||
expect(image.width).toBe(metadata.width);
|
||||
expect(image.height).toBe(metadata.height);
|
||||
}
|
||||
|
||||
describe('vision image preparation', () => {
|
||||
it.each(['png', 'jpeg', 'webp', 'gif'] as const)(
|
||||
'preserves a small supported %s image',
|
||||
async (format) => {
|
||||
const log = logger();
|
||||
const image = {
|
||||
id: 'small',
|
||||
src: dataUrl(await tinyImage().toFormat(format).toBuffer(), `image/${format}`),
|
||||
width: 32,
|
||||
height: 24,
|
||||
};
|
||||
expect(await prepareVisionImages([image], log)).toEqual([image]);
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['png', 'jpeg'] as const)('preserves an 8-bit grayscale %s image', async (format) => {
|
||||
const buffer = await tinyImage().toColourspace('b-w').toFormat(format).toBuffer();
|
||||
expect(await sharp(buffer).metadata()).toMatchObject({ space: 'b-w', depth: 'uchar' });
|
||||
const image = { id: 'grayscale', src: dataUrl(buffer, `image/${format}`) };
|
||||
expect(await prepareVisionImages([image], logger())).toEqual([image]);
|
||||
});
|
||||
|
||||
it.each(['IMAGE/JPEG', 'image/jpg', 'IMAGE/JPG'])(
|
||||
'normalizes %s without re-encoding',
|
||||
async (mime) => {
|
||||
const buffer = await tinyImage().jpeg().toBuffer();
|
||||
const log = logger();
|
||||
expect(await prepareVisionImages([{ id: 'alias', src: dataUrl(buffer, mime) }], log)).toEqual(
|
||||
[{ id: 'alias', src: dataUrl(buffer, 'image/jpeg') }],
|
||||
);
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['gif', 'webp'] as const)('converts a small animated %s to one frame', async (format) => {
|
||||
const buffer = await sharp(randomBytes(32 * 48 * 3), {
|
||||
raw: { width: 32, height: 48, channels: 3, pageHeight: 24 },
|
||||
})
|
||||
.toFormat(format, { loop: 0, delay: [100, 100] })
|
||||
.toBuffer();
|
||||
expect(await sharp(buffer).metadata()).toMatchObject({ pages: 2 });
|
||||
const [image] = await prepareVisionImages(
|
||||
[{ id: 'animated', src: dataUrl(buffer, `image/${format}`) }],
|
||||
logger(),
|
||||
);
|
||||
await expectWithinCaps(image);
|
||||
expect(image.height).toBe(24);
|
||||
});
|
||||
|
||||
it.each(['cmyk', 'rgb16'] as const)('converts a small %s image to 8-bit sRGB', async (space) => {
|
||||
const format = space === 'cmyk' ? 'jpeg' : 'png';
|
||||
const buffer = await tinyImage().toColourspace(space).toFormat(format).toBuffer();
|
||||
expect((await sharp(buffer).metadata()).space).toBe(space);
|
||||
const [image] = await prepareVisionImages(
|
||||
[{ id: 'color', src: dataUrl(buffer, `image/${format}`) }],
|
||||
logger(),
|
||||
);
|
||||
await expectWithinCaps(image);
|
||||
expect(
|
||||
await sharp(Buffer.from(parseBase64DataUrl(image.src)!.base64, 'base64')).metadata(),
|
||||
).toMatchObject({ space: 'srgb', depth: 'uchar' });
|
||||
});
|
||||
|
||||
it('re-encodes an image whose decoded bytes fit but base64 payload exceeds the cap', async () => {
|
||||
const buffer = await sharp(randomBytes(600 * 500 * 3), {
|
||||
raw: { width: 600, height: 500, channels: 3 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
expect(buffer.length).toBeLessThan(MAX_VISION_IMAGE_BASE64_BYTES);
|
||||
expect(buffer.toString('base64').length).toBeGreaterThan(MAX_VISION_IMAGE_BASE64_BYTES);
|
||||
const [image] = await prepareVisionImages([{ id: 'payload', src: dataUrl(buffer) }], logger());
|
||||
await expectWithinCaps(image);
|
||||
expect(image.width).toBe(600);
|
||||
});
|
||||
|
||||
it.each([32, 3000])('drops a truncated JPEG with width %i', async (width) => {
|
||||
const buffer = await sharp(randomBytes(width * 100 * 3), {
|
||||
raw: { width, height: 100, channels: 3 },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
const truncated = buffer.subarray(0, Math.floor(buffer.length * 0.7));
|
||||
expect(await sharp(truncated, { failOn: 'none' }).metadata()).toMatchObject({ width });
|
||||
const log = logger();
|
||||
expect(
|
||||
await prepareVisionImages([{ id: 'truncated', src: dataUrl(truncated, 'image/jpeg') }], log),
|
||||
).toEqual([]);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'Dropped vision image',
|
||||
expect.objectContaining({ id: 'truncated' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('downscales a large noisy PNG with missing dimensions and compresses it below the byte cap', async () => {
|
||||
const buffer = await sharp(randomBytes(4000 * 3000 * 3), {
|
||||
raw: { width: 4000, height: 3000, channels: 3 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
expect(buffer.byteLength).toBeGreaterThan(MAX_VISION_IMAGE_BASE64_BYTES);
|
||||
const log = logger();
|
||||
const [image] = await prepareVisionImages([{ id: 'large', src: dataUrl(buffer) }], log);
|
||||
await expectWithinCaps(image);
|
||||
expect(image.width).toBe(2048);
|
||||
expect(image.height).toBe(1536);
|
||||
expect(log.info).toHaveBeenCalledWith(
|
||||
'Transformed vision image',
|
||||
expect.objectContaining({
|
||||
id: 'large',
|
||||
from: { mime: 'image/png', bytes: buffer.byteLength, width: 4000, height: 3000 },
|
||||
}),
|
||||
);
|
||||
}, 20_000);
|
||||
|
||||
it('caps dimensions even when decoded bytes are small', async () => {
|
||||
const buffer = await sharp({
|
||||
create: { width: 3000, height: 1000, channels: 3, background: 'white' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
expect(buffer.byteLength).toBeLessThan(MAX_VISION_IMAGE_BASE64_BYTES);
|
||||
const [image] = await prepareVisionImages([{ id: 'wide', src: dataUrl(buffer) }], logger());
|
||||
await expectWithinCaps(image);
|
||||
expect(image.width).toBe(2048);
|
||||
});
|
||||
|
||||
it('compresses an image over the byte cap even when dimensions fit', async () => {
|
||||
const buffer = await sharp(randomBytes(1500 * 1000 * 3), {
|
||||
raw: { width: 1500, height: 1000, channels: 3 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const [image] = await prepareVisionImages(
|
||||
[{ id: 'noisy', src: dataUrl(buffer), width: 1500, height: 1000 }],
|
||||
logger(),
|
||||
);
|
||||
await expectWithinCaps(image);
|
||||
expect(image.width).toBe(1500);
|
||||
});
|
||||
|
||||
it('lowers JPEG quality when the first encoding still exceeds the byte cap', async () => {
|
||||
const buffer = await sharp(randomBytes(2048 * 2048 * 3), {
|
||||
raw: { width: 2048, height: 2048, channels: 3 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const firstEncoding = await sharp(buffer).jpeg({ quality: 82 }).toBuffer();
|
||||
expect(firstEncoding.toString('base64').length).toBeGreaterThan(MAX_VISION_IMAGE_BASE64_BYTES);
|
||||
const [image] = await prepareVisionImages([{ id: 'quality', src: dataUrl(buffer) }], logger());
|
||||
await expectWithinCaps(image);
|
||||
expect(image.width).toBe(2048);
|
||||
expect(image.height).toBe(2048);
|
||||
}, 20_000);
|
||||
|
||||
it('converts TIFF and flattens transparency onto white', async () => {
|
||||
const buffer = await tinyImage().tiff().toBuffer();
|
||||
const [image] = await prepareVisionImages(
|
||||
[{ id: 'tiff', src: dataUrl(buffer, 'image/tiff') }],
|
||||
logger(),
|
||||
);
|
||||
await expectWithinCaps(image);
|
||||
expect(
|
||||
(await sharp(Buffer.from(parseBase64DataUrl(image.src)!.base64, 'base64')).metadata())
|
||||
.hasAlpha,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops undecodable data while keeping other attachments in order', async () => {
|
||||
const log = logger();
|
||||
const good = { id: 'good', src: dataUrl(await tinyImage().png().toBuffer()) };
|
||||
const badBytes = Buffer.from('undecodable');
|
||||
expect(
|
||||
await prepareVisionImages([{ id: 'bad', src: dataUrl(badBytes, 'image/bmp') }, good], log),
|
||||
).toEqual([good]);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
'Dropped vision image',
|
||||
expect.objectContaining({
|
||||
id: 'bad',
|
||||
mime: 'image/bmp',
|
||||
bytes: badBytes.byteLength,
|
||||
reason: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://example.org/image.bmp',
|
||||
'https://example.org/image.tiff',
|
||||
'data:image/svg+xml,<svg/>',
|
||||
])('leaves non-base64 sources untouched: %s', async (src) => {
|
||||
const image = { id: 'external', src, width: 4000, height: 3000 };
|
||||
const log = logger();
|
||||
expect(await prepareVisionImages([image], log)).toEqual([image]);
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,8 @@
|
||||
* write a small MockLanguageModelV3 helper for both Instructor and
|
||||
* Evaluator together.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
||||
import {
|
||||
runFinalEvaluation,
|
||||
@@ -201,6 +202,68 @@ describe('Evaluator agent — task JSON-only output', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Evaluator agent — vision attachments', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it.each(['tiff', 'undecodable'])(
|
||||
'continues evaluation with an uploaded %s image',
|
||||
async (format) => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const project = mkProject();
|
||||
const buffer =
|
||||
format === 'tiff'
|
||||
? await sharp({ create: { width: 32, height: 24, channels: 3, background: 'white' } })
|
||||
.tiff()
|
||||
.toBuffer()
|
||||
: Buffer.from('undecodable');
|
||||
addSubmission(project, {
|
||||
microtaskId: 't1',
|
||||
milestoneId: 'ms1',
|
||||
kind: 'file',
|
||||
content: 'Review this diagram.',
|
||||
mimeType: 'image/tiff',
|
||||
fileUrl: `data:image/tiff;base64,${buffer.toString('base64')}`,
|
||||
});
|
||||
const model = scriptedModel(
|
||||
'{"feedback":"Diagram reviewed.","strengths":[],"improvements":[],"score":70}',
|
||||
);
|
||||
const events = await collectEvents(
|
||||
runTaskEvaluation({
|
||||
project,
|
||||
milestoneId: 'ms1',
|
||||
microtaskId: 't1',
|
||||
languageModel: model,
|
||||
hasVision: true,
|
||||
}),
|
||||
);
|
||||
expect(evaluationPatch(events)?.kind).toBe('evaluation');
|
||||
const imageParts = model.doStreamCalls[0].prompt.flatMap((message) =>
|
||||
message.role === 'user' ? message.content.filter((part) => part.type === 'file') : [],
|
||||
);
|
||||
expect(imageParts).toHaveLength(format === 'tiff' ? 1 : 0);
|
||||
if (format === 'tiff')
|
||||
expect(imageParts[0]).toMatchObject({ type: 'file', mediaType: 'image/jpeg' });
|
||||
else {
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[PBL v2 Evaluator] Image submission evaluated without the image',
|
||||
),
|
||||
);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('"microtaskId":"t1"'));
|
||||
expect(
|
||||
model.doStreamCalls[0].prompt.flatMap((message) =>
|
||||
message.role === 'user' ? message.content.filter((part) => part.type === 'text') : [],
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ text: expect.stringContaining('Review this diagram.') }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Evaluator agent — milestone/final JSON-only output', () => {
|
||||
it('persists milestone feedback from JSON and does not stream raw JSON tokens', async () => {
|
||||
const project = mkProject();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import sharp from 'sharp';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AICallFn } from '@openmaic/generation';
|
||||
|
||||
import { generateSceneActions } from '@/lib/server/generation/steps/scene-actions';
|
||||
import type { SceneOutline } from '@/lib/types/generation';
|
||||
@@ -41,6 +43,7 @@ describe('scene actions step', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mocks.generateSceneActions.mockReset();
|
||||
mocks.callLLM.mockReset();
|
||||
mocks.generateSceneActions.mockResolvedValue([
|
||||
{ id: 'a1', type: 'speech', text: 'Now the second part.' },
|
||||
{ id: 'a2', type: 'speech', text: 'Let us look closer.' },
|
||||
@@ -65,4 +68,40 @@ describe('scene actions step', () => {
|
||||
expect(result.scene).toMatchObject({ stageId: 'stage-1', type: 'slide', title: 'Second' });
|
||||
expect(result.previousSpeeches).toEqual(['Now the second part.', 'Let us look closer.']);
|
||||
});
|
||||
|
||||
it('includes attachment labels only for images that survive preparation', async () => {
|
||||
const png = await sharp({ create: { width: 32, height: 24, channels: 3, background: 'white' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
mocks.callLLM.mockResolvedValue({ text: 'Actions ready.' });
|
||||
mocks.generateSceneActions.mockImplementationOnce(
|
||||
async (_outline, _content, aiCall: AICallFn) => {
|
||||
await aiCall('Describe the slide.', 'Explain the diagram.', [
|
||||
{ id: 'good', src: `data:image/png;base64,${png.toString('base64')}` },
|
||||
{
|
||||
id: 'bad',
|
||||
src: `data:image/png;base64,${Buffer.from('undecodable').toString('base64')}`,
|
||||
},
|
||||
]);
|
||||
return [];
|
||||
},
|
||||
);
|
||||
await generateSceneActions(
|
||||
{
|
||||
...body,
|
||||
model: fakeModel({
|
||||
modelInfo: { id: 'test-model', name: 'Test model', capabilities: { vision: true } },
|
||||
}),
|
||||
},
|
||||
{ log: testLogger() },
|
||||
);
|
||||
const content = mocks.callLLM.mock.calls[0]![0].messages[0].content;
|
||||
expect(content.filter((part: { type: string }) => part.type === 'image')).toHaveLength(1);
|
||||
const text = content
|
||||
.filter((part: { type: string }) => part.type === 'text')
|
||||
.map((part: { text: string }) => part.text)
|
||||
.join('\n');
|
||||
expect(text).toContain('good');
|
||||
expect(text).not.toContain('bad');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { StepRefusal } from '@/lib/server/generation/steps/context';
|
||||
import {
|
||||
@@ -53,6 +54,13 @@ const body = {
|
||||
};
|
||||
|
||||
const content = { elements: [], remark: 'generated' };
|
||||
let imageSrc: string;
|
||||
beforeAll(async () => {
|
||||
const png = await sharp({ create: { width: 32, height: 24, channels: 3, background: 'white' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
imageSrc = `data:image/png;base64,${png.toString('base64')}`;
|
||||
});
|
||||
|
||||
describe('scene content step', () => {
|
||||
beforeEach(() => {
|
||||
@@ -62,9 +70,7 @@ describe('scene content step', () => {
|
||||
mocks.resolveVisionImagesStub.mockReset();
|
||||
mocks.resolveVisionImagesStub.mockImplementation(
|
||||
async (images: { id: string; src: string }[]) =>
|
||||
images
|
||||
.filter((img) => img.src !== 'asset-2')
|
||||
.map((img) => ({ ...img, src: `data:image/png;base64,${img.id}` })),
|
||||
images.filter((img) => img.src !== 'asset-2').map((img) => ({ ...img, src: imageSrc })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -90,9 +96,7 @@ describe('scene content step', () => {
|
||||
// Only the assigned images, in page order, minus the one that did not resolve.
|
||||
expect(options.assignedImages.map((img: PdfImage) => img.id)).toEqual(['img_1']);
|
||||
expect(options.imageMapping).toEqual({ img_1: 'asset-1', img_3: 'asset-3' });
|
||||
expect(options.resolvedVisionImages).toEqual([
|
||||
{ id: 'img_1', src: 'data:image/png;base64,img_1' },
|
||||
]);
|
||||
expect(options.resolvedVisionImages).toEqual([{ id: 'img_1', src: imageSrc }]);
|
||||
expect(options.targetLanguage).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user