Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion scripts/grade-alt-text-quality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type GradeCase = {

const sleep = (ms: number) => new Promise<void>(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} {
Expand Down Expand Up @@ -174,6 +174,7 @@ async function main(): Promise<void> {
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) {
Expand Down
35 changes: 21 additions & 14 deletions src/judges/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

<example id="empty-alt-on-decorative-image">
<context>The image is a horizontal separator graphic placed between two sections. The structural separation is conveyed by the page's headings and layout.</context>
<alt></alt>
<output>{"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}</output>
<output>{"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":""}</output>
</example>

<example id="empty-alt-redundant-with-caption">
<context>Adjacent caption immediately above the image: "Ellen Ochoa, Astronaut". Body text below names her achievements.</context>
<alt></alt>
<output>{"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}</output>
<output>{"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":""}</output>
</example>

<example id="semantic-prefix-not-redundant">
<context>Book about national parks. Photograph of the Grand Canyon during monsoon season, surrounding text describes a violent storm.</context>
<alt>A photograph of a blue sky peeking through grey storm clouds over the Grand Canyon in early September.</alt>
<output>{"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}</output>
<output>{"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":""}</output>
</example>

<example id="labeled-diagram-alt">
<context>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.</context>
<alt>Mitosis stages: Prophase, Prometaphase, Metaphase, Anaphase, and Telophase</alt>
<output>{"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}</output>
<output>{"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":""}</output>
</example>

<example id="vague-alt-on-informative-image">
<context>Geology page about acid erosion of rock formations; bird droppings are cited as a contributor.</context>
<alt>A close-up of a puffin with bright orange feet and a colorful beak.</alt>
<output>{"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}</output>
<output>{"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."}</output>
</example>

<example id="redundant-prefix-on-link">
<context>Image is the only content inside a link to an Ellen Ochoa Wikipedia page.</context>
<alt>Read More</alt>
<output>{"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}</output>
<output>{"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"}</output>
</example>

<example id="body-prose-not-caption">
<context>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.</context>
<alt>Image of Ellen Ochoa, Astronaut</alt>
<output>{"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}</output>
<output>{"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"}</output>
</example>

<example id="named-target-link-ok">
<context>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.</context>
<alt>Astronaut Ellen Ochoa</alt>
<output>{"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}</output>
<output>{"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":""}</output>
</example>

<example id="medium-announcing-link">
<context>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.</context>
<alt>Wikipedia entry for Ellen Ochoa, Astronaut</alt>
<output>{"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}</output>
<output>{"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"}</output>
</example>

<example id="surrounding-analysis-not-detail-requirement">
<context>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.</context>
<alt>Painting of George Washington crossing the Delaware River</alt>
<output>{"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}</output>
<output>{"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":""}</output>
</example>

<example id="keyword-stuffed-alt">
<context>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.</context>
<alt>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</alt>
<output>{"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}</output>
<output>{"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."}</output>
</example>

<example id="subtle-keyword-stuffed-alt">
<context>Blog post about a hiking trail. The image shows a wooden trail sign in a forest. The image is not inside a link.</context>
<alt>Wooden trail sign in a forest, hiking, camping, national parks, outdoor gear, best hiking boots, travel tips, adventure tourism</alt>
<output>{"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}</output>
<output>{"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."}</output>
</example>

<example id="comma-list-not-stuffing">
<context>Biology textbook figure. The image is a labeled diagram showing the stages of mitosis in order.</context>
<alt>Mitosis stages: Prophase, Prometaphase, Metaphase, Anaphase, and Telophase</alt>
<output>{"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}</output>
<output>{"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":""}</output>
</example>`
2 changes: 2 additions & 0 deletions src/judges/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/judges/verdict-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
13 changes: 9 additions & 4 deletions src/rules/alt-text-quality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
kzhou314 marked this conversation as resolved.
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,
Expand All @@ -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
Expand Down
21 changes: 20 additions & 1 deletion tests/unit/alt-text-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class FakeJudge implements JudgeAltText {
}

function verdict(overrides: Partial<JudgeVerdict> = {}): 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<RuleResult[]> {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/judges-caching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): 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> = {}): JudgeInput {
Expand Down