diff --git a/scripts/grade-alt-text-quality.ts b/scripts/grade-alt-text-quality.ts index bda1ad8..55c8f60 100644 --- a/scripts/grade-alt-text-quality.ts +++ b/scripts/grade-alt-text-quality.ts @@ -42,7 +42,7 @@ type GradeCase = { const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) -// Decodes an image's intrinsic pixel dimensions straight from its bytes, supporting PNG, GIF, WebP and JPEG. +// Decodes an image's intrinsic pixel dimensions from its bytes, supporting PNG, GIF, WebP and JPEG. // This lets the grader report size (and mirror the browser's naturalWidth/naturalHeight) without a browser or // image library. Returns {0, 0} for unrecognized or truncated data. function intrinsicSize(buf: Buffer): {width: number; height: number} { @@ -174,6 +174,7 @@ async function main(): Promise { console.log(` alt: ${JSON.stringify(c.alt)}`) if (c.expected !== undefined) console.log(` expected: ${c.expected}`) if (verdict.issue) console.log(` issue: ${verdict.issue}`) + if (verdict.suggestion) console.log(` suggest: ${JSON.stringify(verdict.suggestion)}`) console.log(` reason: ${verdict.reasoning}`) console.log('') } catch (err) { diff --git a/src/judges/prompt.ts b/src/judges/prompt.ts index a117d16..2c8c556 100644 --- a/src/judges/prompt.ts +++ b/src/judges/prompt.ts @@ -72,84 +72,91 @@ R6. **Keyword stuffing is a failure (SEO abuse).** Some authors pack the alt att # Output -Return a single JSON object with EXACTLY these fields, in this order: step, reasoning, verdict, issue, confidence. +Return a single JSON object with EXACTLY these fields, in this order: step, reasoning, verdict, issue, confidence, suggestion. + +# Remediation suggestion + +Populate "suggestion" with a concrete, improved alt text ONLY when verdict = "needs-fix"; otherwise set it to "" (empty). +- Ground the suggestion in what the image actually shows and in the surrounding context. Use relevant nearby text to enrich it (e.g., replacing a generic "player" with the specific player named in the caption), but do NOT invent names, numbers, or facts that are not visible in the image or stated in the context. +- Fix only the flagged problem: drop a redundant prefix, name the link or button target, collapse a keyword list into a real description, or add the missing context-relevant content. Keep register and length appropriate to the context (R1). +- The suggestion is the replacement alt value only — no "alt=" wrapper, no quotes, and no explanation; the explanation belongs in "reasoning". # Examples The image is a horizontal separator graphic placed between two sections. The structural separation is conveyed by the page's headings and layout. -{"step":1,"reasoning":"Step 1 matches: the image is a purely decorative separator. The current alt is empty, which is correct, so no action is needed.","verdict":"ok","issue":"","confidence":0.95} +{"step":1,"reasoning":"Step 1 matches: the image is a purely decorative separator. The current alt is empty, which is correct, so no action is needed.","verdict":"ok","issue":"","confidence":0.95,"suggestion":""} Adjacent caption immediately above the image: "Ellen Ochoa, Astronaut". Body text below names her achievements. -{"step":2,"reasoning":"Step 2 matches: the image's content is fully conveyed by the adjacent caption. The current alt is empty, which is correct per R4, so no action is needed.","verdict":"ok","issue":"","confidence":0.95} +{"step":2,"reasoning":"Step 2 matches: the image's content is fully conveyed by the adjacent caption. The current alt is empty, which is correct per R4, so no action is needed.","verdict":"ok","issue":"","confidence":0.95,"suggestion":""} Book about national parks. Photograph of the Grand Canyon during monsoon season, surrounding text describes a violent storm. A photograph of a blue sky peeking through grey storm clouds over the Grand Canyon in early September. -{"step":4,"reasoning":"Step 4 matches and all of A–F hold. Per R2, 'A photograph of' is a semantic prefix communicating medium and is not penalized.","verdict":"ok","issue":"","confidence":0.9} +{"step":4,"reasoning":"Step 4 matches and all of A–F hold. Per R2, 'A photograph of' is a semantic prefix communicating medium and is not penalized.","verdict":"ok","issue":"","confidence":0.9,"suggestion":""} Biology textbook chapter-summary question: which of the following is not a main stage of mitosis? The image is a hand-drawn diagram with each stage labeled. Mitosis stages: Prophase, Prometaphase, Metaphase, Anaphase, and Telophase -{"step":4,"reasoning":"Step 4 matches and all of A–F hold. Per R3, naming the diagram's labels is the correct alt content for a labeled diagram tied to a textbook question about those labels.","verdict":"ok","issue":"","confidence":0.9} +{"step":4,"reasoning":"Step 4 matches and all of A–F hold. Per R3, naming the diagram's labels is the correct alt content for a labeled diagram tied to a textbook question about those labels.","verdict":"ok","issue":"","confidence":0.9,"suggestion":""} Geology page about acid erosion of rock formations; bird droppings are cited as a contributor. A close-up of a puffin with bright orange feet and a colorful beak. -{"step":4,"reasoning":"Step 4 matches but criterion B fails: the alt omits the contextually relevant content (the bird defecating, which is what makes the image relevant to acid erosion).","verdict":"needs-fix","issue":"missing-context-relevant-content","confidence":0.9} +{"step":4,"reasoning":"Step 4 matches but criterion B fails: the alt omits the contextually relevant content (the bird defecating, which is what makes the image relevant to acid erosion).","verdict":"needs-fix","issue":"missing-context-relevant-content","confidence":0.9,"suggestion":"A puffin defecating onto the rocks below, illustrating how bird droppings contribute to acid erosion."} Image is the only content inside a link to an Ellen Ochoa Wikipedia page. Read More -{"step":3,"reasoning":"Step 3 matches: the image is the only content of a link, and 'Read More' does not describe the destination.","verdict":"needs-fix","issue":"missing-link-name","confidence":0.95} +{"step":3,"reasoning":"Step 3 matches: the image is the only content of a link, and 'Read More' does not describe the destination.","verdict":"needs-fix","issue":"missing-link-name","confidence":0.95,"suggestion":"Astronaut Ellen Ochoa"} Body text near the image: "As the first Hispanic woman to go to space, Ellen Ochoa is widely regarded as a role model." The image is not inside a link. The body text names her but does not state she is an astronaut; the uniform in the image conveys that. Image of Ellen Ochoa, Astronaut -{"step":4,"reasoning":"Step 2 does not apply because the surrounding text is body prose, not a caption or label. Step 4 applies and criterion E fails: 'Image of' is a redundant prefix per R2 and should be removed; the rest of the alt is otherwise fine.","verdict":"needs-fix","issue":"redundant-prefix","confidence":0.9} +{"step":4,"reasoning":"Step 2 does not apply because the surrounding text is body prose, not a caption or label. Step 4 applies and criterion E fails: 'Image of' is a redundant prefix per R2 and should be removed; the rest of the alt is otherwise fine.","verdict":"needs-fix","issue":"redundant-prefix","confidence":0.9,"suggestion":"Ellen Ochoa, Astronaut"} The image is the only content inside a link pointing to https://en.wikipedia.org/wiki/Ellen_Ochoa. The link's accessible name comes entirely from the image's alt text. Astronaut Ellen Ochoa -{"step":3,"reasoning":"Step 3 matches and all of F–I hold. The alt names the entity at the destination ('Astronaut Ellen Ochoa'), does not announce the medium, and is not generic. Per F, naming the entity is sufficient — the alt does not need to say 'link' or 'page'.","verdict":"ok","issue":"","confidence":0.9} +{"step":3,"reasoning":"Step 3 matches and all of F–I hold. The alt names the entity at the destination ('Astronaut Ellen Ochoa'), does not announce the medium, and is not generic. Per F, naming the entity is sufficient — the alt does not need to say 'link' or 'page'.","verdict":"ok","issue":"","confidence":0.9,"suggestion":""} The image is the only content inside a link pointing to https://en.wikipedia.org/wiki/Ellen_Ochoa. The link's accessible name comes entirely from the image's alt text. Wikipedia entry for Ellen Ochoa, Astronaut -{"step":3,"reasoning":"Step 3 matches but criterion G fails: 'Wikipedia entry for' announces the destination's medium, which screen readers already convey through the link role. Drop the medium announcement.","verdict":"needs-fix","issue":"medium-announcing","confidence":0.9} +{"step":3,"reasoning":"Step 3 matches but criterion G fails: 'Wikipedia entry for' announces the destination's medium, which screen readers already convey through the link role. Drop the medium announcement.","verdict":"needs-fix","issue":"medium-announcing","confidence":0.9,"suggestion":"Ellen Ochoa, Astronaut"} An art-history textbook chapter on artistic technique. The surrounding paragraph analyzes the painting's use of light, color, form, perspective, proportion, and motion to depict the iconic crossing. Painting of George Washington crossing the Delaware River -{"step":4,"reasoning":"Step 4 matches and all of A–F hold. Per R1a, the surrounding paragraph's analysis of light/color/form is not a requirement that the alt reproduce that analysis; the alt names what the image is, and the surrounding text supplies the artistic analysis. Per R2, 'Painting of' is a semantic prefix.","verdict":"ok","issue":"","confidence":0.9} +{"step":4,"reasoning":"Step 4 matches and all of A–F hold. Per R1a, the surrounding paragraph's analysis of light/color/form is not a requirement that the alt reproduce that analysis; the alt names what the image is, and the surrounding text supplies the artistic analysis. Per R2, 'Painting of' is a semantic prefix.","verdict":"ok","issue":"","confidence":0.9,"suggestion":""} Product page for a running shoe. The image shows a single blue running shoe. Surrounding body text describes the shoe's cushioning. The image is not inside a link. running shoes, blue running shoes, best running shoes 2026, cheap running shoes, buy shoes online, marathon shoes, nike adidas shoes, mens womens shoes, shoe sale -{"step":4,"reasoning":"Step 4 matches but criterion F fails per R6: the alt is a comma-separated list of search keywords — 'shoes' repeated, unrelated brands, and commercial terms like 'buy' and 'sale' — rather than a description of the single blue running shoe shown. This is keyword stuffing.","verdict":"needs-fix","issue":"keyword-stuffing","confidence":0.95} +{"step":4,"reasoning":"Step 4 matches but criterion F fails per R6: the alt is a comma-separated list of search keywords — 'shoes' repeated, unrelated brands, and commercial terms like 'buy' and 'sale' — rather than a description of the single blue running shoe shown. This is keyword stuffing.","verdict":"needs-fix","issue":"keyword-stuffing","confidence":0.95,"suggestion":"A single blue running shoe."} Blog post about a hiking trail. The image shows a wooden trail sign in a forest. The image is not inside a link. Wooden trail sign in a forest, hiking, camping, national parks, outdoor gear, best hiking boots, travel tips, adventure tourism -{"step":4,"reasoning":"Step 4 matches but criterion F fails per R6: the alt opens with an accurate description ('wooden trail sign in a forest') then appends unrelated SEO keywords (camping, outdoor gear, hiking boots, travel tips) the image does not depict. The trailing keyword pile-up is stuffing.","verdict":"needs-fix","issue":"keyword-stuffing","confidence":0.9} +{"step":4,"reasoning":"Step 4 matches but criterion F fails per R6: the alt opens with an accurate description ('wooden trail sign in a forest') then appends unrelated SEO keywords (camping, outdoor gear, hiking boots, travel tips) the image does not depict. The trailing keyword pile-up is stuffing.","verdict":"needs-fix","issue":"keyword-stuffing","confidence":0.9,"suggestion":"A wooden trail sign in a forest."} Biology textbook figure. The image is a labeled diagram showing the stages of mitosis in order. Mitosis stages: Prophase, Prometaphase, Metaphase, Anaphase, and Telophase -{"step":4,"reasoning":"Step 4 matches and all of A–F hold. Although the alt is comma-separated, per R6 it is not stuffing: it enumerates the diagram's actual labeled content (the mitosis stages) rather than listing search keywords. Per R3, naming the labels is the correct alt content.","verdict":"ok","issue":"","confidence":0.9} +{"step":4,"reasoning":"Step 4 matches and all of A–F hold. Although the alt is comma-separated, per R6 it is not stuffing: it enumerates the diagram's actual labeled content (the mitosis stages) rather than listing search keywords. Per R3, naming the labels is the correct alt content.","verdict":"ok","issue":"","confidence":0.9,"suggestion":""} ` diff --git a/src/judges/types.ts b/src/judges/types.ts index f58e523..addb213 100644 --- a/src/judges/types.ts +++ b/src/judges/types.ts @@ -9,6 +9,8 @@ export type JudgeVerdict = { verdict: Verdict issue: string confidence: number + // Suggested replacement alt text for needs-fix verdicts; "" otherwise. + suggestion: string } // What the rule hands to a judge diff --git a/src/judges/verdict-schema.ts b/src/judges/verdict-schema.ts index 433d7de..7d3a691 100644 --- a/src/judges/verdict-schema.ts +++ b/src/judges/verdict-schema.ts @@ -3,7 +3,8 @@ // response_format (Structured Outputs strict mode). // // Field order in `properties` is the generation order; reasoning is generated -// before verdict to force chain-of-thought before classification. +// before verdict to force chain-of-thought before classification, and +// suggestion is generated last so the rewrite is conditioned on the verdict. export const VERDICT_SCHEMA = { name: 'alt_text_verdict', strict: true, @@ -16,7 +17,8 @@ export const VERDICT_SCHEMA = { verdict: {type: 'string', enum: ['ok', 'needs-fix', 'decorative']}, issue: {type: 'string'}, confidence: {type: 'number', minimum: 0, maximum: 1}, + suggestion: {type: 'string'}, }, - required: ['step', 'reasoning', 'verdict', 'issue', 'confidence'], + required: ['step', 'reasoning', 'verdict', 'issue', 'confidence', 'suggestion'], }, } as const diff --git a/src/rules/alt-text-quality.ts b/src/rules/alt-text-quality.ts index 4efaade..c1c6f1e 100644 --- a/src/rules/alt-text-quality.ts +++ b/src/rules/alt-text-quality.ts @@ -72,13 +72,15 @@ function verdictToResult(image: ImageRecord, verdict: JudgeVerdict): RuleResult solutionLong: verdict.reasoning, } - case 'needs-fix': + case 'needs-fix': { + const suggestion = verdict.suggestion.trim() if (verdict.issue === 'keyword-stuffing') { return { image, problemShort: `Alt text appears keyword-stuffed for SEO rather than describing the image:\n"${image.alt ?? ''}"`, - solutionShort: - verdict.step === 3 + solutionShort: suggestion + ? `Consider this alt text: "${suggestion}"` + : verdict.step === 3 ? 'Replace the keyword list with a concise name for the link or button target or action.' : 'Replace the keyword list with a concise description of what the image actually shows.', solutionLong: verdict.reasoning, @@ -87,9 +89,12 @@ function verdictToResult(image: ImageRecord, verdict: JudgeVerdict): RuleResult return { image, problemShort: `Alt text quality issue${verdict.issue ? ` (${verdict.issue})` : ''}:\n"${image.alt ?? ''}"`, - solutionShort: 'Revise the alt text per the reviewer reasoning below.', + solutionShort: suggestion + ? `Consider this alt text: "${suggestion}"` + : 'Revise the alt text per the reviewer reasoning below.', solutionLong: verdict.reasoning, } + } default: return null diff --git a/tests/unit/alt-text-quality.test.ts b/tests/unit/alt-text-quality.test.ts index f361f6e..3d927f2 100644 --- a/tests/unit/alt-text-quality.test.ts +++ b/tests/unit/alt-text-quality.test.ts @@ -20,7 +20,7 @@ class FakeJudge implements JudgeAltText { } function verdict(overrides: Partial = {}): JudgeVerdict { - return {step: 4, reasoning: 'reasoning', verdict: 'ok', issue: '', confidence: 0.9, ...overrides} + return {step: 4, reasoning: 'reasoning', verdict: 'ok', issue: '', confidence: 0.9, suggestion: '', ...overrides} } async function run(images: ImageRecord[]): Promise { @@ -85,6 +85,25 @@ describe('alt-text-quality', () => { expect(results[0]!.solutionShort).toContain('link or button target') }) + it('surfaces a needs-fix suggestion in the finding solutionShort', async () => { + __setJudge( + new FakeJudge(() => + verdict({verdict: 'needs-fix', issue: 'redundant-prefix', suggestion: 'Ellen Ochoa, Astronaut'}), + ), + ) + const results = await run([makeImage({src: DATA_URL, alt: 'Image of Ellen Ochoa, Astronaut'})]) + expect(results).toHaveLength(1) + expect(results[0]!.solutionShort).toContain('Ellen Ochoa, Astronaut') + expect(results[0]!.solutionShort).toContain('Consider') + }) + + it('falls back to generic advice when a needs-fix verdict has no suggestion', async () => { + __setJudge(new FakeJudge(() => verdict({verdict: 'needs-fix', issue: 'vague', suggestion: ''}))) + const results = await run([makeImage({src: DATA_URL, alt: 'a thing'})]) + expect(results).toHaveLength(1) + expect(results[0]!.solutionShort).toContain('Revise the alt text') + }) + it('skips images with alt === null without calling the judge', async () => { const fake = new FakeJudge(() => verdict()) __setJudge(fake) diff --git a/tests/unit/judges-caching.test.ts b/tests/unit/judges-caching.test.ts index e4d7216..c49b42a 100644 --- a/tests/unit/judges-caching.test.ts +++ b/tests/unit/judges-caching.test.ts @@ -7,7 +7,7 @@ const IMG_A = 'data:image/png;base64,iVBORw0KGgoAAAA=' const IMG_B = 'data:image/png;base64,Zm9vYmFyYmF6Cg==' function verdict(overrides: Partial = {}): JudgeVerdict { - return {step: 4, reasoning: 'reasoning', verdict: 'ok', issue: '', confidence: 0.9, ...overrides} + return {step: 4, reasoning: 'reasoning', verdict: 'ok', issue: '', confidence: 0.9, suggestion: '', ...overrides} } function input(overrides: Partial = {}): JudgeInput {