#!/usr/bin/env node // Tier 2: pregunta a Gemma 4 vision (LM Studio) sobre una screenshot. // // Uso: // node ask-vision.js "" // cat report.json | node ask-vision.js --auto out/feadulta/latest // → para cada result con severity != OK, pregunta a la vision y añade // el resultado a out/feadulta/latest/vision.json + vision.md // // Env vars opcionales: // LMSTUDIO_URL default http://172.19.128.1:1234/v1 // LMSTUDIO_MODEL default google/gemma-4-e4b // MAX_TOKENS default 800 // TEMPERATURE default 0.2 import fs from 'node:fs'; import path from 'node:path'; const LMSTUDIO_URL = process.env.LMSTUDIO_URL || 'http://172.19.128.1:1234/v1'; const LMSTUDIO_MODEL = process.env.LMSTUDIO_MODEL || 'google/gemma-4-e4b'; const MAX_TOKENS = Number(process.env.MAX_TOKENS || 800); const TEMPERATURE = Number(process.env.TEMPERATURE || 0.2); const DEFAULT_PROMPT = 'Eres un revisor visual de páginas web. Describe brevemente en español qué ves en la captura: ' + 'estructura general (cabecera, contenido, pie), si hay imágenes rotas (placeholders, iconos de imagen rota, espacios vacíos donde debería haber contenido), ' + 'si el texto se ve bien (no solapado, no recortado), y cualquier elemento claramente fuera de sitio. ' + 'Sé conciso (máximo 6 frases). Empieza la respuesta con una de estas etiquetas: [OK], [WARN] o [FAIL].'; async function ask(screenshotPath, prompt = DEFAULT_PROMPT) { if (!fs.existsSync(screenshotPath)) throw new Error(`No existe ${screenshotPath}`); const buf = fs.readFileSync(screenshotPath); const b64 = buf.toString('base64'); const body = { model: LMSTUDIO_MODEL, messages: [{ role: 'user', content: [ { type: 'text', text: prompt }, { type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } }, ], }], max_tokens: MAX_TOKENS, temperature: TEMPERATURE, }; const t0 = Date.now(); const resp = await fetch(`${LMSTUDIO_URL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!resp.ok) throw new Error(`LM Studio ${resp.status}: ${await resp.text()}`); const out = await resp.json(); const dt = Date.now() - t0; const raw = out.choices?.[0]?.message?.content ?? ''; const text = extractFinal(raw); return { screenshot: screenshotPath, text, raw, model: LMSTUDIO_MODEL, elapsedMs: dt, usage: out.usage, }; } function extractFinal(s) { // Gemma 4 a veces escribe razonamiento antes de la respuesta. Buscamos marcadores. const m = s.match(/Final Answer[^:]*:\s*([\s\S]*)$/i); if (m) return m[1].trim(); // O coge desde la primera etiqueta de severidad const sev = s.match(/\[(?:OK|WARN|FAIL)\][\s\S]*$/); if (sev) return sev[0].trim(); return s.trim(); } async function modeAuto(reportDir) { const jsonPath = path.join(reportDir, 'report.json'); const report = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); const sevs = report.results.filter(r => sevTag(r) !== 'OK'); console.log(`[vision] ${sevs.length}/${report.results.length} candidatos`); const results = []; for (const r of sevs) { if (!r.screenshot) continue; const shot = path.join(reportDir, r.screenshot); console.log(` → ${r.slug}`); try { const v = await ask(shot); results.push({ slug: r.slug, severity: sevTag(r), ...v }); } catch (e) { results.push({ slug: r.slug, severity: sevTag(r), error: String(e?.message ?? e) }); } } fs.writeFileSync(path.join(reportDir, 'vision.json'), JSON.stringify(results, null, 2)); const md = ['# Vision check', '']; for (const v of results) { md.push(`## ${v.slug} — ${v.severity}`); md.push(''); md.push(v.text ?? `_error: ${v.error}_`); md.push(''); } fs.writeFileSync(path.join(reportDir, 'vision.md'), md.join('\n')); console.log(`[vision] vision.md escrito en ${reportDir}`); } function sevTag(r) { if (r.error) return 'FAIL'; if (r.httpStatus && r.httpStatus >= 400) return 'FAIL'; if (r.brokenImages?.length > 0) return 'WARN'; if (r.failedRequests?.some(f => f.resourceType === 'image' || f.resourceType === 'media' || f.resourceType === 'document')) return 'WARN'; if (r.consoleErrors?.length > 0) return 'WARN'; return 'OK'; } const args = process.argv.slice(2); if (args[0] === '--auto') { await modeAuto(args[1]); } else if (args.length >= 1) { const [shot, prompt] = args; const v = await ask(shot, prompt); console.log(`[${v.elapsedMs} ms · ${v.usage?.total_tokens ?? '?'} tk]\n`); console.log(v.text); } else { console.error('Uso:'); console.error(' node ask-vision.js ""'); console.error(' node ask-vision.js --auto '); process.exit(2); }