From 19c0cbb23205de8be4fce0e55712cfe2af64d709 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Fri, 24 Jul 2026 10:39:15 +0000 Subject: [PATCH] qa: auto-fix safe findings + per-finding instructions box - qa-autofix.php queues Web Designer tasks for safe findings (alt, internal links) with 6h dedup; runQaFlow runs it after each pass and kicks the queue (QA_AUTOFIX env, on by default) - adminqa fix() accepts optional human instructions; /admin/qa adds an instructions input per finding for the judgment calls --- agents/console/server.mjs | 9 ++++ api/cli/qa-autofix.php | 71 ++++++++++++++++++++++++++++++ api/public/controllers/adminqa.php | 13 ++++-- app/src/pages/admin/qa.astro | 9 +++- 4 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 api/cli/qa-autofix.php diff --git a/agents/console/server.mjs b/agents/console/server.mjs index d2792f9..f1b72d6 100644 --- a/agents/console/server.mjs +++ b/agents/console/server.mjs @@ -822,6 +822,14 @@ async function runQaFlow(trigger) { writeFileSync(tmp, JSON.stringify(findings)); await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]); rmSync(tmp, { force: true }); + + // Auto-fix the safe findings (alt text, broken internal links) — queue Web + // Designer tasks and kick the queue. Judgment calls are left for the admin. + if (QA_AUTOFIX) { + const af = await phpCli(["api/cli/qa-autofix.php", `--run=${runId}`]); + let queued = 0; try { queued = JSON.parse(af.out || "{}").queued || 0; } catch {} + if (queued > 0) drainTasks(); // fire-and-forget; the Web Designer fixes + self-logs + } } catch (e) { if (runId) await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=failed", `--summary=QA run failed: ${String((e && e.message) || e).slice(0, 180)}`]); } finally { @@ -1020,6 +1028,7 @@ server.listen(PORT, "127.0.0.1", () => { // no cron, no extra units. Runs a scheduled QA pass every QA_HEARTBEAT_MIN // minutes (0 disables), plus one pulse shortly after boot. runQaFlow self-guards // against overlap and is read-only against the live site. +const QA_AUTOFIX = (process.env.QA_AUTOFIX ?? "1") !== "0"; // auto-fix safe findings const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60); if (QA_HEARTBEAT_MIN > 0) { setTimeout(() => runQaFlow("scheduled"), 90_000); // first pulse ~90s after boot diff --git a/api/cli/qa-autofix.php b/api/cli/qa-autofix.php new file mode 100644 index 0000000..7da0e8d --- /dev/null +++ b/api/cli/qa-autofix.php @@ -0,0 +1,71 @@ + {"queued": N} + * + * Dedup: skips a finding if a matching fix task is already queued/running, or was + * created in the last 6 hours — so hourly heartbeats never spam or loop on the + * same issue. + */ + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../config.php'; + +const SAFE = ['a11y', 'link']; + +$opts = getopt('', ['run:']); +$runId = (int) ($opts['run'] ?? 0); +if ($runId <= 0) { echo json_encode(['queued' => 0]); exit; } + +$pdo = new PDO( + sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME), + DB_USER, + DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +$in = implode(',', array_fill(0, count(SAFE), '?')); +$sel = $pdo->prepare( + "SELECT finding_id, check_type, url, detail, fix_hint + FROM cja_qa_findings + WHERE run_id = ? AND status = 'open' AND check_type IN ($in)" +); +$sel->execute([$runId, ...SAFE]); + +$dupe = $pdo->prepare( + "SELECT COUNT(*) FROM cja_tasks + WHERE title = ? AND (status IN ('queued','running') OR created_at > (NOW() - INTERVAL 6 HOUR))" +); +$order = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM cja_tasks')->fetchColumn(); +$ins = $pdo->prepare( + 'INSERT INTO cja_tasks (title, target_page, prompt, draft, assets, status, sort_order) + VALUES (?, ?, ?, "", ?, "queued", ?)' +); +$mark = $pdo->prepare("UPDATE cja_qa_findings SET status = 'fix_queued' WHERE finding_id = ?"); + +$queued = 0; +foreach ($sel as $f) { + $url = (string) $f['url']; + $title = 'QA fix: ' . $f['check_type'] . ' — ' . mb_substr($url, 0, 60); + + $dupe->execute([$title]); + if ((int) $dupe->fetchColumn() > 0) continue; + + $target = (str_starts_with($url, '/') && !str_contains($url, ',') && !str_contains($url, '://')) + ? explode('#', explode('?', $url)[0])[0] + : '/'; + $prompt = ($f['fix_hint'] !== null && $f['fix_hint'] !== '') + ? (string) $f['fix_hint'] + : "QA found a {$f['check_type']} issue on {$url}: {$f['detail']}. Fix it."; + + $order += 10; + $ins->execute([mb_substr($title, 0, 200), mb_substr($target, 0, 200), $prompt, json_encode(['images' => [], 'videos' => []]), $order]); + $mark->execute([$f['finding_id']]); + $queued++; +} + +echo json_encode(['queued' => $queued]); diff --git a/api/public/controllers/adminqa.php b/api/public/controllers/adminqa.php index 7a22091..6dbec1e 100644 --- a/api/public/controllers/adminqa.php +++ b/api/public/controllers/adminqa.php @@ -82,9 +82,16 @@ class AdminQa extends PublicController $target = (str_starts_with($url, '/') && !str_contains($url, ',') && !str_contains($url, '://')) ? explode('#', explode('?', $url)[0])[0] : '/'; - $prompt = $f['fix_hint'] !== null && $f['fix_hint'] !== '' - ? (string) $f['fix_hint'] - : "QA found a {$f['check_type']} issue on {$url}: {$f['detail']}. Investigate and fix it."; + // Optional human instructions override the templated fix hint — this is + // the admin's "tell the Web Designer how to handle it" path. + $instructions = trim((string) ($in['instructions'] ?? '')); + if ($instructions !== '') { + $prompt = $instructions . "\n\n(QA context: a {$f['check_type']} issue on {$url} — {$f['detail']})"; + } else { + $prompt = $f['fix_hint'] !== null && $f['fix_hint'] !== '' + ? (string) $f['fix_hint'] + : "QA found a {$f['check_type']} issue on {$url}: {$f['detail']}. Investigate and fix it."; + } $title = 'QA fix: ' . $f['check_type'] . ' — ' . mb_substr($url, 0, 60); $order = (int) \Db::getValue('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM cja_tasks'); diff --git a/app/src/pages/admin/qa.astro b/app/src/pages/admin/qa.astro index 0a46272..c297185 100644 --- a/app/src/pages/admin/qa.astro +++ b/app/src/pages/admin/qa.astro @@ -48,6 +48,9 @@ import AdminLayout from "../../layouts/AdminLayout.astro"; .fnd .ck { font-family:var(--mono); font-size:.6rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink4); } .fnd .url { font-family:var(--mono); font-size:.78rem; color:var(--ink2); word-break:break-all; } .fnd .detail { margin:.25rem 0 0; font-size:.88rem; color:var(--ink3); } + .fnd .inst { margin-top:.5rem; width:100%; border:0; border-bottom:1px solid var(--rule); background:transparent; + padding:.35rem 0; font-size:.82rem; color:var(--ink); font-family:var(--sans); } + .fnd .inst:focus { outline:none; border-bottom-color:var(--seal); } .fnd .acts { flex:0 0 auto; display:flex; gap:.5rem; align-items:center; } .fnd .acts button { font-family:var(--mono); font-size:.58rem; letter-spacing:.06em; text-transform:uppercase; background:none; border:1px solid var(--rule); border-radius:2px; padding:.35rem .6rem; color:var(--ink3); cursor:pointer; } @@ -139,11 +142,14 @@ import AdminLayout from "../../layouts/AdminLayout.astro"; const acts = f.status === "fix_queued" ? `fix queued` : ``; + const inst = f.status === "fix_queued" ? "" + : ``; row.innerHTML = `
${esc(f.check)}${esc(f.url)}

${esc(f.detail)}

+ ${inst}
${acts}
`; const fixBtn = row.querySelector("[data-fix]"); @@ -158,9 +164,10 @@ import AdminLayout from "../../layouts/AdminLayout.astro"; async function createFix(id, runId) { try { + const instructions = document.querySelector(`[data-inst="${id}"]`)?.value.trim() || ""; const r = await fetch("/api/adminqa/fix", { method: "POST", credentials: "same-origin", - headers: { "content-type": "application/json" }, body: JSON.stringify({ finding_id: id }), + headers: { "content-type": "application/json" }, body: JSON.stringify({ finding_id: id, instructions }), }); const d = await r.json(); if (!r.ok || !d?.data?.ok) { setMsg(d?.error?.message || "Couldn't queue the fix.", "error"); return; }