← back to Visual Factory
src/stages/vision_check.js
58 lines
// Stage 5 — vision_check. llava describes what's actually on the rendered PNG.
// This is the ground-truth visual signal that the critic uses to compare against the brief.
const { ollama } = require('../llm');
const SYSTEM = `You are a precise visual reviewer for a generated image.
Output ONLY a JSON object:
text_content array of every word or phrase you can read (best effort — leave [] if you cannot read text reliably)
layout one short sentence on the composition
mood one short phrase
issues array — ONLY include CONCRETE visible problems: text clipping/overflow, lorem ipsum, broken/squished layout, all-blank canvas. Do NOT include subjective concerns. Empty array if the image looks intentional.
overall_quality integer 0-100 — your honest read of polish/intentionality. A clean editorial layout with clear hierarchy is 85+. A broken/blank/cluttered layout is <50.
JSON only — no prose, no fences, no explanations.`;
async function runVisionCheck({ pngPath, signal = null }) {
// Vision is best-effort. llava sometimes degenerates (loops empty strings,
// fails JSON parse). If it crashes, return a neutral "vision unavailable"
// shape so the pipeline can still finish and the critic can decide based
// on the brief alone. The PNG already exists — vision is just a signal.
try {
const desc = await ollama({
model: process.env.VISION_MODEL || 'llava:latest',
system: SYSTEM,
prompt: 'Describe this image as JSON per the system instructions.',
images: [pngPath],
format: 'json',
temperature: 0.1,
signal
});
return {
text_content: Array.isArray(desc.text_content) ? desc.text_content.filter(s => s && s.trim()).slice(0, 30) : [],
layout: String(desc.layout || ''),
mood: String(desc.mood || ''),
issues: Array.isArray(desc.issues) ? desc.issues.filter(s => s && s.trim()) : [],
overall_quality: Number(desc.overall_quality) || 0,
_vision_ok: true
};
} catch (err) {
// Don't swallow stage cancellation as "vision unavailable" — propagate so
// the pipeline correctly reports the run as timed out instead of silently
// moving on with a neutral score.
if (signal?.aborted || /aborted/i.test(err.message)) throw err;
return {
text_content: [],
layout: '',
mood: '',
issues: [],
overall_quality: 75, // neutral default — assume rendered OK; critic can override on other signals
_vision_ok: false,
_vision_error: err.message.slice(0, 200)
};
}
}
module.exports = { runVisionCheck };