Files
feadulta/tools/e2e/ask-vision.js
rafa 56ee1580b0 fix(content) + tools(e2e): rutas de imagen Joomla + suite E2E
- scripts/fix_image_paths.php: reescribe src/href "images/..." en wp_posts
  a "/fea/wp-content/uploads/..." cuando el fichero existe en uploads.
  Cubre comillas dobles/simples, urldecode antes de chequear filesystem.
  Resuelto contra issue #34 (458 posts, 465 refs) y completado con #36
  (124 posts, 128 refs tras recuperar 127 assets del backup producción).

- tools/e2e/: pipeline 3-tier para validar WP local con coste mínimo en
  tokens de Claude — Playwright (Tier 1, deterministas) + Gemma 4 vision
  en LM Studio (Tier 2, bajo demanda) + Claude solo lee report.md.
  Issue de diseño #37, suite inicial en sites/feadulta.json (13 URLs).

- .gitignore: excluir tools/e2e/node_modules y out/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:26:04 -04:00

132 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// Tier 2: pregunta a Gemma 4 vision (LM Studio) sobre una screenshot.
//
// Uso:
// node ask-vision.js <ruta/screenshot.png> "<prompt en español>"
// 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 <screenshot.png> "<prompt>"');
console.error(' node ask-vision.js --auto <report_dir>');
process.exit(2);
}