diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-18 19:01:37 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-18 19:01:37 +0300 |
| commit | 747b102e17e4567011e4c4fb7f72190e71c79b43 (patch) | |
| tree | 808574afc4dd23c670d50e78ed1b5b07d7e204fd /player-server | |
| parent | e89d039fa85cab00f34ee45e99b801f92c58ff3a (diff) | |
Add Haiku screenshot oracle for LLM e2e visual checks
Implements oracle.ts (Layer 5 of the assertion stack) which sends a PNG
screenshot to Claude Haiku with a yes/no question and returns true when the
answer starts with "yes". Gated behind LLM_E2E_SCREENSHOTS=true so CI runs
that don't set this variable skip visual checks silently without burning API
credits. Adds @anthropic-ai/sdk dependency to the runner package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-server')
| -rw-r--r-- | player-server/test/e2e-llm/runner/oracle.ts | 149 | ||||
| -rw-r--r-- | player-server/test/e2e-llm/runner/package-lock.json | 10 | ||||
| -rw-r--r-- | player-server/test/e2e-llm/runner/package.json | 1 |
3 files changed, 160 insertions, 0 deletions
diff --git a/player-server/test/e2e-llm/runner/oracle.ts b/player-server/test/e2e-llm/runner/oracle.ts new file mode 100644 index 0000000..1d95292 --- /dev/null +++ b/player-server/test/e2e-llm/runner/oracle.ts @@ -0,0 +1,149 @@ +/** + * oracle.ts — Screenshot oracle for LLM e2e visual checks (Layer 5). + * + * Exports checkScreenshot(pngPath, question) which sends a PNG to Claude + * Haiku with a yes/no question and returns true when the answer starts with + * "yes" (case-insensitive). + * + * The oracle is gated behind the LLM_E2E_SCREENSHOTS=true env var. When + * that variable is absent or set to any other value the function always + * returns true so CI runs that don't set the variable skip visual checks + * silently rather than failing or burning API credits. + * + * When screenshots are enabled, ANTHROPIC_API_KEY must be present in the + * environment or the function throws immediately. + * + * The system prompt is cache-controlled so that repeated calls within the + * same run benefit from prompt-caching (≥1024 tokens threshold on Haiku 4.5; + * the system block here is short, but the cache_control marker is cheap to + * add and costs nothing when the threshold isn't reached). + * + * Used only for S03 (upload-verify-web) and S04 (share-link-round-trip). + * Estimated cost: ~$0.003 per call at Haiku 4.5 pricing. + */ + +import * as fs from 'fs'; +import Anthropic from '@anthropic-ai/sdk'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// The model used for visual checks. Haiku is chosen for cost efficiency. +const HAIKU_MODEL = 'claude-haiku-4-5'; + +// Maximum tokens for the yes/no answer (plus a one-sentence reason). +const MAX_TOKENS = 64; + +// System prompt shared across all oracle calls within a run. +// Marked as ephemeral so repeated calls can read it from the cache. +const SYSTEM_PROMPT = 'You are a visual test oracle. Answer every question with a single word: yes or no. Optionally add one short sentence of reasoning after the answer.'; + +// --------------------------------------------------------------------------- +// Singleton Anthropic client (created lazily when screenshots are enabled) +// --------------------------------------------------------------------------- + +let _client: Anthropic | null = null; + +/** + * getClient returns the Anthropic SDK client, constructing it on first use. + * Throws if ANTHROPIC_API_KEY is not set, so callers learn immediately + * rather than receiving a cryptic 401 later. + */ +function getClient(): Anthropic { + if (_client) return _client; + + const apiKey = process.env['ANTHROPIC_API_KEY']; + if (!apiKey) { + throw new Error( + '[oracle] ANTHROPIC_API_KEY is not set. ' + + 'Set it before enabling LLM_E2E_SCREENSHOTS=true.', + ); + } + + _client = new Anthropic({ apiKey }); + return _client; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * checkScreenshot sends a PNG file to Claude Haiku with a yes/no question + * and returns true when the model answers "yes" (case-insensitive prefix + * match). + * + * Returns true immediately (without calling the API) when LLM_E2E_SCREENSHOTS + * is not "true", so the oracle is effectively a no-op in environments that + * don't opt in. + * + * @param pngPath Absolute or relative path to the PNG screenshot file. + * @param question A yes/no question about the screenshot content, e.g. + * "Is there a media card visible in the grid?" + * @returns true when Haiku answers yes or when screenshots are disabled. + */ +export async function checkScreenshot( + pngPath: string, + question: string, +): Promise<boolean> { + // Gate: skip visual check when the feature flag is not enabled. + if (process.env['LLM_E2E_SCREENSHOTS'] !== 'true') { + console.log(`[oracle] screenshots disabled — skipping visual check: "${question}"`); + return true; + } + + const client = getClient(); + + // Read the PNG and base64-encode it for the API. + const imageBytes = fs.readFileSync(pngPath); + const imageData = imageBytes.toString('base64'); + + console.log(`[oracle] checking screenshot "${pngPath}": "${question}"`); + + const response = await client.messages.create({ + model: HAIKU_MODEL, + max_tokens: MAX_TOKENS, + // Cache the system prompt so repeated calls within the same run + // benefit from prompt-caching (no cost penalty when threshold not met). + system: [ + { + type: 'text', + text: SYSTEM_PROMPT, + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: imageData, + }, + }, + { + type: 'text', + text: `Does this screenshot show ${question}? Answer yes or no.`, + }, + ], + }, + ], + }); + + // Extract the text from the first content block. + const firstBlock = response.content[0]; + if (!firstBlock || firstBlock.type !== 'text') { + console.warn('[oracle] unexpected response structure — treating as failure'); + return false; + } + + const answer = firstBlock.text.trim().toLowerCase(); + const passed = answer.startsWith('yes'); + + console.log(`[oracle] answer: "${firstBlock.text.trim()}" → ${passed ? 'PASS' : 'FAIL'}`); + return passed; +} diff --git a/player-server/test/e2e-llm/runner/package-lock.json b/player-server/test/e2e-llm/runner/package-lock.json index df7de58..9df6d1d 100644 --- a/player-server/test/e2e-llm/runner/package-lock.json +++ b/player-server/test/e2e-llm/runner/package-lock.json @@ -8,6 +8,7 @@ "name": "player-e2e-llm-runner", "version": "1.0.0", "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", "js-yaml": "^4.1.0" }, "devDependencies": { @@ -16,6 +17,15 @@ "typescript": "^5.8.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", + "integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", diff --git a/player-server/test/e2e-llm/runner/package.json b/player-server/test/e2e-llm/runner/package.json index de1ae9d..a3b2423 100644 --- a/player-server/test/e2e-llm/runner/package.json +++ b/player-server/test/e2e-llm/runner/package.json @@ -10,6 +10,7 @@ "dev": "tsc && node dist/index.js" }, "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", "js-yaml": "^4.1.0" }, "devDependencies": { |
