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:
parent
29938fcd7f
commit
19c0cbb232
4 changed files with 98 additions and 4 deletions
|
|
@ -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
71
api/cli/qa-autofix.php
Normal 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]);
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue