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
This commit is contained in:
Carlos Arias 2026-07-24 10:39:15 +00:00
parent 29938fcd7f
commit 19c0cbb232
4 changed files with 98 additions and 4 deletions

View file

@ -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

71
api/cli/qa-autofix.php Normal file
View file

@ -0,0 +1,71 @@
<?php
/**
* Auto-queue Web Designer fix tasks for the SAFE QA findings of a run the ones
* with a clear, low-risk fix (missing/empty alt text, broken internal links).
* Judgment calls (external links, images, SEO opinions) are left for the human
* via the admin's per-finding instructions box.
*
* php api/cli/qa-autofix.php --run=ID -> {"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]);

View file

@ -82,9 +82,16 @@ class AdminQa extends PublicController
$target = (str_starts_with($url, '/') && !str_contains($url, ',') && !str_contains($url, '://'))
? explode('#', explode('?', $url)[0])[0]
: '/';
// 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');

View file

@ -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"
? `<span class="state">fix queued</span>`
: `<button class="fix" data-fix="${f.id}">Create fix task</button><button class="ignore" data-ignore="${f.id}">Ignore</button>`;
const inst = f.status === "fix_queued" ? ""
: `<input class="inst" data-inst="${f.id}" placeholder="Optional — tell the Web Designer how to fix this (e.g. “remove it”, “use https://…”)">`;
row.innerHTML = `
<span class="sev ${f.severity}"></span>
<div class="body">
<div class="top"><span class="ck">${esc(f.check)}</span><span class="url">${esc(f.url)}</span></div>
<p class="detail">${esc(f.detail)}</p>
${inst}
</div>
<div class="acts">${acts}</div>`;
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; }