56ee1580b0
- 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>
132 lines
4.8 KiB
JavaScript
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);
|
|
}
|