kaloko 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ // Auto-detected unstable regions (#20). Two sources:
2
+ // - repeated screenshots of one capture (`kaloko walk --stability N`): what moves while nobody touches the page
3
+ // (clocks, carousels, animations) lands in the capture record as `unstable`;
4
+ // - earlier local runs of the same scenario and environment (`kaloko stability`): what differs on every load
5
+ // (ads, avatars, "3 minutes ago").
6
+ // `kaloko stability` turns both into ignore suggestions per step (`ignore: [{ selector } | { rect, viewport }]`),
7
+ // written next to the run and, with --apply, into the scenario. Pixel diffs on the canvas and the design pixel
8
+ // check skip ignored regions.
9
+ import { readFile, writeFile, readdir } from 'node:fs/promises'
10
+ import { join, resolve } from 'node:path'
11
+ import yaml from 'js-yaml'
12
+ import { readJson, writeJson, UserError } from './util.mjs'
13
+ import { readCaptures } from './run.mjs'
14
+ import { latestCaptures } from './evaluate.mjs'
15
+ import { decodePng } from './png.mjs'
16
+ import { unstableRegions, mergeRects, ignoreFor } from './stability-core.mjs'
17
+
18
+ const MAX_HEIGHT = 12000
19
+
20
+ /** Copies the top-left width × height of an RGBA image. */
21
+ function cropRgba (img, width, height) {
22
+ if (img.width === width) return img.data.subarray(0, width * height * 4)
23
+ const out = new Uint8ClampedArray(width * height * 4)
24
+ for (let y = 0; y < height; y++) out.set(img.data.subarray(y * img.width * 4, y * img.width * 4 + width * 4), y * width * 4)
25
+ return out
26
+ }
27
+
28
+ /** Regions that change while the page sits still: `n` screenshots `delay` ms apart (CSS pixels). */
29
+ export async function repeatRegions (page, { n = 3, delay = 800, fullPage = true } = {}) {
30
+ const shots = []
31
+ for (let i = 0; i < n; i++) {
32
+ if (i) await page.waitForTimeout(delay)
33
+ shots.push(decodePng(await page.screenshot({ fullPage, scale: 'css', animations: 'allow' })))
34
+ }
35
+ const width = Math.min(...shots.map((s) => s.width)); const height = Math.min(MAX_HEIGHT, ...shots.map((s) => s.height))
36
+ const r = unstableRegions({ images: shots.map((s) => cropRgba(s, width, height)), width, height })
37
+ return { shots: n, regions: r.regions, ...(r.too_much ? { too_much: Math.round(r.changed * 100) / 100 } : {}) }
38
+ }
39
+
40
+ /** Ignore regions of one capture as page rectangles (CSS px): selectors resolved in the live page, rects as given. */
41
+ export async function resolveIgnore (page, node, locale, viewport) {
42
+ const entries = ignoreFor(node, locale, viewport)
43
+ if (!entries.length) return []
44
+ const sels = entries.filter((e) => e.selector).map((e) => e.selector)
45
+ const found = sels.length ? await page.evaluate((list) => list.flatMap((sel) => {
46
+ let els = []
47
+ try { els = [...document.querySelectorAll(sel)] } catch { return [] }
48
+ return els.map((el) => { const r = el.getBoundingClientRect(); return { x: Math.round(r.left + scrollX), y: Math.round(r.top + scrollY), w: Math.round(r.width), h: Math.round(r.height), selector: sel } }).filter((r) => r.w > 0 && r.h > 0).slice(0, 20)
49
+ }), sels).catch(() => []) : []
50
+ return [...found, ...entries.filter((e) => e.rect).map((e) => ({ x: e.rect[0], y: e.rect[1], w: e.rect[2], h: e.rect[3] }))]
51
+ }
52
+
53
+ /** Local runs of the scenario and environment of `run`, newest first (meta and directory). */
54
+ export async function siblingRuns ({ root, config, run, sameCommit = false }) {
55
+ const base = resolve(root, config.output)
56
+ let names = []
57
+ try { names = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name) } catch { return [] }
58
+ const out = []
59
+ for (const name of names) {
60
+ const meta = await readJson(join(base, name, 'meta.json'), null)
61
+ if (!meta || meta.id === run.meta.id || meta.scenario?.id !== run.meta.scenario?.id || meta.environment?.name !== run.meta.environment?.name) continue
62
+ if (sameCommit && (meta.git?.commit || null) !== (run.meta.git?.commit || null)) continue
63
+ out.push({ dir: join(base, name), meta })
64
+ }
65
+ return out.sort((a, b) => String(b.meta.started_at).localeCompare(String(a.meta.started_at)))
66
+ }
67
+
68
+ const key = (e) => `${e.node}|${e.locale}|${e.viewport}`
69
+
70
+ // the self-contained detector, injected into a blank page to compare WebP captures there
71
+ const DETECTOR = `window.__mergeRects = ${mergeRects.toString()}; const mergeRects = window.__mergeRects; window.__unstableRegions = ${unstableRegions.toString()};`
72
+
73
+ /** Cross-run regions of one step, locale and viewport: images drawn at the page's CSS width. */
74
+ async function crossRegions (page, sources, cssWidth) {
75
+ return page.evaluate(async ({ sources, W, MAX }) => {
76
+ const load = (src) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = src })
77
+ const imgs = await Promise.all(sources.map(load))
78
+ const H = Math.min(MAX, ...imgs.map((i) => Math.round(i.naturalHeight * W / i.naturalWidth)))
79
+ const data = imgs.map((img) => { const c = document.createElement('canvas'); c.width = W; c.height = H; const g = c.getContext('2d', { willReadFrequently: true }); g.drawImage(img, 0, 0, W, Math.round(img.naturalHeight * W / img.naturalWidth)); return g.getImageData(0, 0, W, H).data })
80
+ return window.__unstableRegions({ images: data, width: W, height: H })
81
+ }, { sources, W: cssWidth, MAX: MAX_HEIGHT })
82
+ }
83
+
84
+ const dataUrl = async (file) => `data:image/${file.endsWith('.png') ? 'png' : 'webp'};base64,${(await readFile(file)).toString('base64')}`
85
+ const withBase = (html, url) => {
86
+ if (!url || /<base\s/i.test(html)) return html
87
+ const tag = `<base href="${String(url).replace(/"/g, '&quot;')}">`
88
+ return /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + tag) : tag + html
89
+ }
90
+
91
+ /** A stable CSS selector for the element that covers a region in the stored HTML, or null (runs in the page). */
92
+ function selectorAt (r) {
93
+ const cx = r.x + r.w / 2; const cy = r.y + r.h / 2
94
+ window.scrollTo(0, Math.max(0, cy - innerHeight / 2))
95
+ const hit = document.elementFromPoint(cx - scrollX, cy - scrollY)
96
+ const fit = (e) => {
97
+ const b = e.getBoundingClientRect(); const x = b.left + scrollX; const y = b.top + scrollY
98
+ const ix = Math.max(0, Math.min(x + b.width, r.x + r.w) - Math.max(x, r.x)); const iy = Math.max(0, Math.min(y + b.height, r.y + r.h) - Math.max(y, r.y))
99
+ return { covers: (ix * iy) / (r.w * r.h), ratio: (b.width * b.height) / (r.w * r.h) }
100
+ }
101
+ let best = null
102
+ for (let e = hit; e && e !== document.body && e !== document.documentElement; e = e.parentElement) {
103
+ const f = fit(e)
104
+ if (f.ratio > 6) break
105
+ if (f.covers >= 0.7) { best = e; break }
106
+ }
107
+ if (!best) return null
108
+ const esc = (s) => CSS.escape(s)
109
+ const unique = (s) => { try { return document.querySelectorAll(s).length === 1 } catch { return false } }
110
+ // generated ids and state classes change between builds; leave them out
111
+ const stableId = (e) => e.id && !/\d{4,}|[a-f0-9]{8,}|^:r/i.test(e.id)
112
+ const part = (e) => e.tagName.toLowerCase() + [...e.classList].filter((c) => !/\d{4,}|[a-f0-9]{6,}|^(is|has)-|active|current|visible|show|open|selected|swiper-slide-/i.test(c)).slice(0, 2).map((c) => '.' + esc(c)).join('')
113
+ if (stableId(best) && unique('#' + esc(best.id))) return '#' + esc(best.id)
114
+ let sel = part(best)
115
+ if (sel.includes('.') && unique(sel)) return sel
116
+ for (let e = best.parentElement, d = 0; e && e !== document.body && d < 4; e = e.parentElement, d++) {
117
+ if (stableId(e) && unique(`#${esc(e.id)} ${sel}`)) return `#${esc(e.id)} ${sel}`
118
+ sel = `${part(e)} > ${sel}`
119
+ if (unique(sel)) return sel
120
+ }
121
+ return null
122
+ }
123
+
124
+ /**
125
+ * Ignore suggestions for the steps of `run` from its own repeated shots and from earlier runs.
126
+ * @returns {{ runs: string[], steps: Array<{ node, ignore: object[], found: object[] }>, skipped: string[] }}
127
+ */
128
+ export async function detectUnstable ({ root, config, run, scenario, last = 4, runs = null, sameCommit = false, launch = null }) {
129
+ const others = runs ? (await Promise.all(runs.map(async (id) => {
130
+ const dir = id.includes('/') ? resolve(id) : join(resolve(root, config.output), id)
131
+ const meta = await readJson(join(dir, 'meta.json'), null)
132
+ if (!meta) throw new UserError(`Run ${id} not found in ${config.output}.`)
133
+ return { dir, meta }
134
+ }))) : (await siblingRuns({ root, config, run, sameCommit })).slice(0, Math.max(0, last - 1))
135
+ const all = [{ dir: run.dir, meta: run.meta }, ...others]
136
+ const maps = await Promise.all(all.map(async (r) => new Map(latestCaptures(await readCaptures(r.dir)).filter((e) => !e.item).map((e) => [key(e), { ...e, dir: r.dir }]))))
137
+ const nodes = new Map(scenario.nodes.map((n) => [n.id, n]))
138
+ const found = new Map() // node → [{ locale, viewport, x, y, w, h, share, source }]
139
+ const skipped = []
140
+ const browser = await (launch ? launch() : (await import('./browser.mjs')).launchEphemeral(config))
141
+ try {
142
+ const page = await browser.newPage()
143
+ await page.setContent('<!doctype html><title>stability</title>')
144
+ await page.addScriptTag({ content: DETECTOR })
145
+ for (const [k, entry] of maps[0]) {
146
+ if (!nodes.has(entry.node)) continue
147
+ const caps = []
148
+ for (const m of maps) { const e = m.get(k); if (e) caps.push({ e, cap: await readJson(join(e.dir, e.file), null) }) }
149
+ const mine = caps[0].cap
150
+ if (!mine?.files) continue
151
+ const regions = []
152
+ for (const { cap } of caps) for (const r of cap?.unstable?.regions || []) regions.push({ ...r, source: 'repeat' })
153
+ const usable = caps.filter(({ cap }) => cap?.files?.image || cap?.files?.png)
154
+ if (usable.length >= 2) {
155
+ try {
156
+ const cssWidth = mine.size?.width || mine.size?.viewport?.[0] || 1280
157
+ const r = await crossRegions(page, await Promise.all(usable.map(({ e, cap }) => dataUrl(join(e.dir, cap.files.image || cap.files.png)))), cssWidth)
158
+ if (r.too_much) skipped.push(`${entry.node} ${entry.locale}/${entry.viewport}: ${Math.round(r.changed * 100)} % of the screen differs between runs; that is a changed page, not a noisy region`)
159
+ for (const x of r.regions) regions.push({ ...x, source: 'runs' })
160
+ } catch (error) { skipped.push(`${entry.node} ${entry.locale}/${entry.viewport}: ${error.message}`) }
161
+ }
162
+ if (!regions.length) continue
163
+ const merged = mergeRects(regions.map(({ x, y, w, h, share }) => ({ x, y, w, h, share })), 8)
164
+ const list = found.get(entry.node) || []
165
+ for (const r of merged) list.push({ ...r, locale: entry.locale, viewport: entry.viewport, html: mine.files.html ? join(entry.dir, mine.files.html) : null, url: mine.url, sources: [...new Set(regions.filter((x) => x.x < r.x + r.w && r.x < x.x + x.w && x.y < r.y + r.h && r.y < x.y + x.h).map((x) => x.source))] })
166
+ found.set(entry.node, list)
167
+ }
168
+ // regions → selectors in the stored HTML (scripts off); rectangles when no element fits
169
+ const steps = []
170
+ for (const [node, list] of found) {
171
+ const ignore = []
172
+ for (const r of list) {
173
+ let selector = null
174
+ if (r.html) {
175
+ const vp = run.meta.scope.viewports?.[r.viewport] || [1280, 800]
176
+ const ctx = await browser.newContext({ viewport: { width: vp[0], height: vp[1] }, javaScriptEnabled: false })
177
+ try {
178
+ const p = await ctx.newPage()
179
+ await p.setContent(withBase(await readFile(r.html, 'utf8'), r.url), { waitUntil: 'load', timeout: 20000 }).catch(() => {})
180
+ selector = await p.evaluate(selectorAt, { x: r.x, y: r.y, w: r.w, h: r.h }).catch(() => null)
181
+ } finally { await ctx.close() }
182
+ }
183
+ const note = `${r.sources.includes('repeat') ? 'moves while the page is idle' : 'changes between runs'} (${r.locale}/${r.viewport})`
184
+ const known = selector && ignore.find((x) => x.selector === selector)
185
+ if (known) { known.viewports = [...new Set([...(known.viewports || []), r.viewport])]; continue }
186
+ ignore.push(selector ? { selector, viewports: [r.viewport], note } : { rect: [r.x, r.y, r.w, r.h], viewport: r.viewport, note })
187
+ }
188
+ // a selector seen in every viewport of the run needs no viewport list
189
+ const vps = Object.keys(run.meta.scope.viewports || {})
190
+ for (const x of ignore) if (x.viewports && vps.every((v) => x.viewports.includes(v))) delete x.viewports
191
+ steps.push({ node, ignore, found: list.map(({ html, url, ...r }) => r) })
192
+ }
193
+ return { runs: all.map((r) => r.meta.id), steps, skipped }
194
+ } finally { await browser.close() }
195
+ }
196
+
197
+ /** The suggestions as a YAML fragment per step, ready to paste under each node. */
198
+ export function suggestionsYaml (report) {
199
+ return report.steps.map((s) => `# ${s.node}\nignore:\n${s.ignore.map((x) => ` - ${yaml.dump(x, { flowLevel: 0, lineWidth: 200 }).trim()}`).join('\n')}`).join('\n\n')
200
+ }
201
+
202
+ /**
203
+ * Inserts the suggestions into the scenario file under each node that has no `ignore` yet (block-style nodes only;
204
+ * comments and formatting stay). Returns { applied: [node], skipped: [reason] }.
205
+ */
206
+ export async function applySuggestions (file, report) {
207
+ let text = await readFile(file, 'utf8')
208
+ const applied = []; const skipped = []
209
+ const parsed = yaml.load(text)
210
+ for (const s of report.steps) {
211
+ const node = (parsed?.nodes || []).find((n) => n.id === s.node)
212
+ if (!node) { skipped.push(`${s.node}: not in ${file}`); continue }
213
+ if (node.ignore) { skipped.push(`${s.node}: already has ignore; merge by hand`); continue }
214
+ const lines = text.split('\n')
215
+ const at = lines.findIndex((l) => new RegExp(`^(\\s*)- id: ['"]?${s.node.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?\\s*(#.*)?$`).test(l))
216
+ if (at < 0) { skipped.push(`${s.node}: node is not in block style; paste the suggestion by hand`); continue }
217
+ const indent = lines[at].match(/^(\s*)-/)[1] + ' '
218
+ const block = [`${indent}ignore:`, ...s.ignore.map((x) => `${indent} - ${yaml.dump(x, { flowLevel: 0, lineWidth: 400 }).trim()}`)]
219
+ lines.splice(at + 1, 0, ...block)
220
+ text = lines.join('\n')
221
+ applied.push(s.node)
222
+ }
223
+ if (applied.length) {
224
+ yaml.load(text) // must still parse
225
+ await writeFile(file, text)
226
+ }
227
+ return { applied, skipped }
228
+ }
229
+
230
+ /** `kaloko stability`: detect, write stability.json and stability.yml in the run, print, optionally apply. */
231
+ export async function stabilityCmd ({ root, config, run, scenario, args, say }) {
232
+ const report = await detectUnstable({ root, config, run, scenario, last: Number(args.last) || 4, runs: typeof args.runs === 'string' ? args.runs.split(',').map((s) => s.trim()).filter(Boolean) : null, sameCommit: !!args['same-commit'] })
233
+ await writeJson(join(run.dir, 'stability.json'), { schema: 'qawalk.stability.v1', generated_at: new Date().toISOString(), ...report })
234
+ if (args.json) { say(JSON.stringify(report, null, 2)); return report }
235
+ const repeats = report.steps.some((s) => s.found.some((r) => r.sources.includes('repeat')))
236
+ say(`Compared ${report.runs.length} run(s)${report.runs.length < 2 && !repeats ? ' (one run and no repeated shots: walk again, or walk with --stability 3, to find regions that move)' : ''}: ${report.steps.length ? `${report.steps.reduce((n, s) => n + s.ignore.length, 0)} unstable region(s) in ${report.steps.length} step(s).` : 'no unstable regions.'}`)
237
+ for (const s of report.skipped) say(` ! ${s}`)
238
+ if (!report.steps.length) return report
239
+ const text = suggestionsYaml(report)
240
+ await writeFile(join(run.dir, 'stability.yml'), text + '\n')
241
+ say(`\n${text}\n\nSuggestions: ${join(run.dir, 'stability.yml')}`)
242
+ if (args.apply) {
243
+ const file = resolve(root, run.meta.scenario_file)
244
+ const r = await applySuggestions(file, report)
245
+ if (r.applied.length) say(`Added ignore to ${r.applied.join(', ')} in ${run.meta.scenario_file}. Capture those steps again so the canvas and diffs use them.`)
246
+ for (const x of r.skipped) say(` skipped ${x}`)
247
+ } else say('Check them on the screenshots, then run kaloko stability --apply (or paste them into the scenario).')
248
+ return report
249
+ }
@@ -0,0 +1,132 @@
1
+ // Vision evaluator (#17), shared by the CLI and the service: which criteria go to it, the request for a
2
+ // vision-capable model (Anthropic Messages API with image input and a strict JSON answer), the parsed answer, and the
3
+ // one-sentence reason every semantic verdict carries. Pure: no Node APIs (the worker imports it).
4
+ //
5
+ // The default text evaluator (JEV) reads a reduced page state and returns a probability only; it takes no images
6
+ // and gives no reasons. This second provider sees the screenshot and explains itself. It is asked only for criteria
7
+ // marked `vision: true` and for text answers in the uncertain band, so the cost stays bounded.
8
+
9
+ export const VISION_PROMPT_VERSION = 'kaloko-vision-v1'
10
+ export const VISION_DEFAULTS = {
11
+ provider: 'anthropic', model: 'claude-opus-5-5', api_key_env: 'ANTHROPIC_API_KEY', base_url: 'https://api.anthropic.com',
12
+ when: 'uncertain', uncertain: [0.15, 0.85], max_calls: 40, max_width: 1000, tile_height: 1400, max_tiles: 2,
13
+ effort: 'low', reason_language: 'en', hosted: true, concurrency: 3
14
+ }
15
+ /** Models the service accepts for hosted vision questions. */
16
+ export const VISION_MODEL = /^claude-[a-z0-9-]{3,40}$/
17
+ /** One hosted vision question counts as this many questions of the monthly allowance. */
18
+ export const VISION_WEIGHT = 5
19
+ export const MAX_VISION_IMAGES = 5
20
+ // models that take the server-side refusal fallback ("default" routing)
21
+ const FALLBACK_MODELS = new Set(['claude-opus-5-5', 'claude-opus-5', 'claude-fable-5-1', 'claude-sonnet-5-5'])
22
+
23
+ /** Vision settings from the config (`evaluators.vision`; false or when: off turns it off). */
24
+ export function visionSettings (config) {
25
+ const v = config?.evaluators?.vision
26
+ if (v === false || v === null) return null
27
+ const s = { ...VISION_DEFAULTS, ...(v || {}) }
28
+ return s.when === 'off' ? null : s
29
+ }
30
+
31
+ /**
32
+ * The semantic criteria of one capture that go to the vision evaluator: flagged ones (check.vision) always,
33
+ * every one with when: always, and with when: uncertain those whose text score lies strictly inside the band.
34
+ * Console triage stays with the text evaluator.
35
+ */
36
+ export function visionCriteria ({ criteria, answers = {}, settings }) {
37
+ if (!settings) return []
38
+ const [lo, hi] = settings.uncertain || VISION_DEFAULTS.uncertain
39
+ return criteria.filter((c) => {
40
+ if (c.check?.type !== 'semantic' || c.check.about === 'console') return false
41
+ if (c.check.vision === true) return true
42
+ if (settings.when === 'always') return true
43
+ if (settings.when !== 'uncertain') return false
44
+ const s = answers?.[c.id]?.noul
45
+ return s != null && s > lo && s < hi
46
+ })
47
+ }
48
+
49
+ const SYSTEM = 'You check web pages captured during acceptance tests. You see screenshots of one page and answer yes/no questions about it. Judge only what is visible in the images and the stated facts; do not guess about content you cannot see. For each question give the probability (0 to 1) that the answer is yes, and one short sentence that names what in the screenshot decided it.'
50
+
51
+ /**
52
+ * The Messages API request body for one capture.
53
+ * questions: [{ id, question, true?, false?, image? }] (image: label of the crop meant for this question)
54
+ * images: [{ label, media_type, data }] (base64, at most MAX_VISION_IMAGES)
55
+ */
56
+ export function buildVisionRequest ({ model, effort = 'low', context = {}, questions, images, reasonLanguage = 'en' }) {
57
+ const content = []
58
+ for (const img of images.slice(0, MAX_VISION_IMAGES)) {
59
+ content.push({ type: 'text', text: `Image "${img.label}"` })
60
+ content.push({ type: 'image', source: { type: 'base64', media_type: img.media_type, data: img.data } })
61
+ }
62
+ const facts = [
63
+ context.purpose ? `What this screen must achieve: ${context.purpose}` : null,
64
+ context.url ? `URL: ${context.url}` : null,
65
+ context.locale ? `Expected language: ${context.locale}` : null,
66
+ context.viewport ? `Viewport: ${context.viewport}` : null
67
+ ].filter(Boolean)
68
+ const list = questions.map((q) => ({
69
+ id: q.id,
70
+ question: q.question,
71
+ ...(q.true ? { counts_as_yes: q.true } : {}),
72
+ ...(q.false ? { counts_as_no: q.false } : {}),
73
+ look_at: q.image ? `image "${q.image}" (the part of the page the question is about)` : 'the full-page images'
74
+ }))
75
+ content.push({ type: 'text', text: `${facts.join('\n')}\n\nQuestions (JSON):\n${JSON.stringify(list, null, 1)}\n\nAnswer every question once, by id. "reason": one sentence of at most 25 words${reasonLanguage && reasonLanguage !== 'en' ? ` in the language with code "${reasonLanguage}"` : ''}, saying what you saw.` })
76
+ const body = {
77
+ model,
78
+ max_tokens: 4096,
79
+ system: SYSTEM,
80
+ messages: [{ role: 'user', content }],
81
+ output_config: {
82
+ effort,
83
+ format: {
84
+ type: 'json_schema',
85
+ schema: {
86
+ type: 'object',
87
+ properties: { answers: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, probability: { type: 'number' }, reason: { type: 'string' } }, required: ['id', 'probability', 'reason'], additionalProperties: false } } },
88
+ required: ['answers'],
89
+ additionalProperties: false
90
+ }
91
+ }
92
+ }
93
+ }
94
+ // a declined request is re-run on a fallback model in the same call instead of ending without answers
95
+ if (FALLBACK_MODELS.has(model)) body.fallbacks = 'default'
96
+ return body
97
+ }
98
+
99
+ /** Headers of the Messages API call (beta header only with the refusal fallback). */
100
+ export function visionHeaders (apiKey, body) {
101
+ return { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', ...(body.fallbacks ? { 'anthropic-beta': 'server-side-fallback-2026-07-01' } : {}) }
102
+ }
103
+
104
+ const sentence = (s) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); const m = t.match(/^.{10,300}?[.!?](?=\s|$)/); return (m ? m[0] : t).slice(0, 300) }
105
+
106
+ /** { model, answers: { id: { probability, reason } }, usage } from a Messages API response; throws on refusal. */
107
+ export function parseVisionResponse (resp, ids = null) {
108
+ if (resp?.stop_reason === 'refusal') throw new Error(`the vision evaluator declined (${resp.stop_details?.category || 'refusal'})`)
109
+ const text = (resp?.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('')
110
+ let parsed
111
+ try { parsed = JSON.parse(text) } catch { throw new Error(`the vision evaluator did not answer in JSON${resp?.stop_reason === 'max_tokens' ? ' (cut off)' : ''}`) }
112
+ const answers = {}
113
+ for (const a of parsed?.answers || []) {
114
+ if (!a || typeof a.id !== 'string' || (ids && !ids.includes(a.id))) continue
115
+ const p = Number(a.probability)
116
+ if (!Number.isFinite(p)) continue
117
+ answers[a.id] = { probability: Math.round(Math.min(1, Math.max(0, p)) * 1000) / 1000, reason: sentence(a.reason) }
118
+ }
119
+ return { model: resp.model || null, answers, usage: resp.usage ? { input_tokens: resp.usage.input_tokens ?? null, output_tokens: resp.usage.output_tokens ?? null } : null }
120
+ }
121
+
122
+ /**
123
+ * The one sentence stored with a semantic verdict when no model explained it (the text evaluator returns a
124
+ * probability only): what was asked of what, and how sure the answer was.
125
+ */
126
+ export function templateReason ({ score, scope = null, about = null, verdict = null }) {
127
+ if (score == null) return null
128
+ const p = Math.round(score * 100)
129
+ const what = about === 'console' ? 'the console errors' : scope ? `the text of ${scope}` : 'the page text'
130
+ const sure = verdict === 'pass' || verdict === 'fail' ? 'clear enough for a verdict' : 'not sure either way'
131
+ return `Judged from ${what} without the screenshot: ${p} % yes, ${sure}.`
132
+ }
@@ -0,0 +1,127 @@
1
+ // Vision evaluator calls from the CLI (#17): screenshots scaled and cut in a browser page (WebP and PNG decode
2
+ // there, no native dependency), one request per capture with the criteria that need it, cached by content like
3
+ // the text evaluator. With ANTHROPIC_API_KEY (or the configured env) the call goes to the provider directly;
4
+ // without it, through the organization's service, which counts it against the monthly allowance.
5
+ import { join } from 'node:path'
6
+ import { readFile } from 'node:fs/promises'
7
+ import { readJson, writeJson, sha256 } from './util.mjs'
8
+ import { buildVisionRequest, parseVisionResponse, visionHeaders, VISION_PROMPT_VERSION, MAX_VISION_IMAGES } from './vision-core.mjs'
9
+
10
+ /**
11
+ * Images for one capture: tiles of the full page from the top (when a question needs the whole page) and a crop per
12
+ * scoped criterion (its highlight box, padded). Runs in `page` (a blank browser page).
13
+ * crops: [{ id, box: { x, y, w, h } }] in CSS px of the captured page; cssWidth: the page width in CSS px.
14
+ * @returns {Promise<{ images: Array<{ label, media_type, data, id? }>, failed: string[] }>} failed: crops that could not be cut (their
15
+ * questions look at the full page)
16
+ */
17
+ export async function prepareImages ({ page, file, cssWidth, crops = [], full = true, settings }) {
18
+ const src = `data:image/${file.endsWith('.png') ? 'png' : 'webp'};base64,${(await readFile(file)).toString('base64')}`
19
+ return page.evaluate(async ({ src, cssWidth, crops, full, maxW, tileH, maxTiles, maxImages }) => {
20
+ const img = await new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = () => no(new Error('the screenshot could not be decoded')); i.src = src })
21
+ const k = img.naturalWidth / (cssWidth || img.naturalWidth)
22
+ const out = []
23
+ const encode = (c) => c.toDataURL('image/webp', 0.82).split(',')[1]
24
+ const draw = (sx, sy, sw, sh, dw, dh) => { const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(dw)); c.height = Math.max(1, Math.round(dh)); const g = c.getContext('2d'); g.fillStyle = '#fff'; g.fillRect(0, 0, c.width, c.height); g.drawImage(img, sx, sy, sw, sh, 0, 0, c.width, c.height); return encode(c) }
25
+ const failed = []
26
+ for (const { id, box } of crops) {
27
+ if (out.length >= maxImages - (full ? maxTiles : 0)) { failed.push(id); continue }
28
+ const pad = 24
29
+ const sx = Math.max(0, (box.x - pad) * k); const sy = Math.max(0, (box.y - pad) * k)
30
+ const sw = Math.min(img.naturalWidth - sx, (box.w + pad * 2) * k); let sh = Math.min(img.naturalHeight - sy, (box.h + pad * 2) * k)
31
+ if (sw < 4 || sh < 4) { failed.push(id); continue }
32
+ const s = Math.min(1, maxW / sw)
33
+ if (sh * s > tileH * 1.2) sh = (tileH * 1.2) / s // a very tall region: its top part
34
+ out.push({ id, label: `crop ${id}`, media_type: 'image/webp', data: draw(sx, sy, sw, sh, sw * s, sh * s) })
35
+ }
36
+ if (full || failed.length) {
37
+ const s = Math.min(1, maxW / img.naturalWidth)
38
+ const H = img.naturalHeight * s
39
+ const parts = Math.ceil(H / tileH)
40
+ const tiles = Math.min(maxTiles, parts, maxImages - out.length)
41
+ const list = []
42
+ for (let t = 0; t < tiles; t++) {
43
+ const h = Math.min(tileH, H - t * tileH)
44
+ const label = parts === 1 ? 'page' : `page, part ${t + 1} of ${parts} from the top${t === tiles - 1 && tiles < parts ? ' (the rest of the page is not shown)' : ''}`
45
+ list.push({ label, media_type: 'image/webp', data: draw(0, (t * tileH) / s, img.naturalWidth, h / s, img.naturalWidth * s, h) })
46
+ }
47
+ out.unshift(...list)
48
+ }
49
+ return { images: out, failed }
50
+ }, { src, cssWidth, crops, full, maxW: settings.max_width, tileH: settings.tile_height, maxTiles: settings.max_tiles, maxImages: MAX_VISION_IMAGES })
51
+ }
52
+
53
+ /**
54
+ * The crops and questions for one capture: a scoped criterion (or one with a highlight) gets the crop of its first
55
+ * box unless that box is most of the page; the rest look at the full page.
56
+ */
57
+ export function visionPlan ({ criteria, capture }) {
58
+ const page = capture.size ? capture.size.width * capture.size.height : 0
59
+ const crops = []; const questions = []
60
+ for (const c of criteria) {
61
+ const box = (c.check.scope || c.highlight) ? capture.boxes?.[c.id]?.[0] : null
62
+ const useCrop = box && box.w > 0 && box.h > 0 && (!page || (box.w * box.h) / page < 0.6)
63
+ if (useCrop) crops.push({ id: c.id, box })
64
+ questions.push({ id: c.id, question: c.check.question, ...(c.check.true ? { true: c.check.true } : {}), ...(c.check.false ? { false: c.check.false } : {}), ...(useCrop ? { image: `crop ${c.id}` } : {}) })
65
+ }
66
+ return { crops, questions, full: questions.some((q) => !q.image) }
67
+ }
68
+
69
+ /**
70
+ * Asks the vision evaluator with a content-addressed cache. Returns { answers, model, usage, cached, via } or
71
+ * { skipped } / { error }. `hosted(payload)` sends through the service when no local key is set; `allowCall()` is asked
72
+ * before every uncached request (false: skipped).
73
+ */
74
+ export async function askVision ({ payload, settings, cacheDir, fetchImpl = fetch, hosted = null, allowCall = () => true }) {
75
+ const body = buildVisionRequest({ model: settings.model, effort: settings.effort, context: payload.context, questions: payload.questions, images: payload.images, reasonLanguage: settings.reason_language })
76
+ const key = sha256(JSON.stringify(body) + VISION_PROMPT_VERSION)
77
+ const cacheFile = join(cacheDir, `${key}.json`)
78
+ const cached = await readJson(cacheFile, null)
79
+ if (cached) return { ...cached, cached: true, key }
80
+ const apiKey = process.env[settings.api_key_env]
81
+ // uncached requests are bounded per evaluation (max_calls); the rest keep the text answer
82
+ if ((apiKey || (hosted && settings.hosted !== false)) && !allowCall()) return { skipped: `vision call limit reached (max_calls ${settings.max_calls})` }
83
+ if (!apiKey && hosted && settings.hosted !== false) {
84
+ const r = await hosted({ model: settings.model, effort: settings.effort, reason_language: settings.reason_language, ...payload })
85
+ if (r.skipped || r.error) return r
86
+ const out = { answers: r.response.answers || {}, model: r.response.model || settings.model, usage: r.response.usage || null, via: 'qawalk', hosted: r.response.qawalk || null }
87
+ await writeJson(cacheFile, out)
88
+ return { ...out, cached: !!r.response.qawalk?.cached, key }
89
+ }
90
+ if (!apiKey) return { skipped: `${settings.api_key_env} is not set${hosted ? '' : ' and the service is not configured (KALOKO_TOKEN)'}` }
91
+ const ids = payload.questions.map((q) => q.id)
92
+ for (let attempt = 0; attempt < 4; attempt++) {
93
+ let res
94
+ try {
95
+ res = await fetchImpl(`${settings.base_url.replace(/\/$/, '')}/v1/messages`, { method: 'POST', headers: visionHeaders(apiKey, body), body: JSON.stringify(body) })
96
+ } catch (error) { return { error: `vision evaluator unreachable: ${error.message}` } }
97
+ if (res.status === 429 || res.status === 529 || res.status === 503) {
98
+ const wait = Number(res.headers.get('retry-after')) * 1000 || 1000 * 2 ** attempt
99
+ await new Promise((r) => setTimeout(r, Math.min(wait, 20000)))
100
+ continue
101
+ }
102
+ if (!res.ok) return { error: `vision evaluator HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` }
103
+ let parsed
104
+ try { parsed = parseVisionResponse(await res.json(), ids) } catch (error) { return { error: error.message } }
105
+ const out = { ...parsed, model: parsed.model || settings.model, via: 'own-key' }
106
+ await writeJson(cacheFile, out)
107
+ return { ...out, cached: false, key }
108
+ }
109
+ return { error: 'vision evaluator rate limited after 4 attempts' }
110
+ }
111
+
112
+ /**
113
+ * Sends a vision payload through the organization's service. An allowance used up or a service without hosted
114
+ * vision stops further hosted vision calls for this evaluation with one message.
115
+ */
116
+ export function hostedVisionSender (config, report) {
117
+ let client = null
118
+ return async (payload) => {
119
+ if (report.visionStop) return { skipped: report.visionStop }
120
+ try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no vision key (ANTHROPIC_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
121
+ try { return { response: await client.request('POST', `/orgs/${config.org}/semantic/vision`, payload) } } catch (e) {
122
+ const m = String(e.message || e)
123
+ if (/HTTP (402|403|404|503)/.test(m)) { report.visionStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.visionStop } }
124
+ return { error: m }
125
+ }
126
+ }
127
+ }
@@ -69,6 +69,12 @@ evaluators:
69
69
  model: jev-1.13.0
70
70
  api_key_env: TYPESAFE_API_KEY
71
71
  thresholds: { pass: 0.85, fail: 0.15 }
72
+ # second look with the screenshot for criteria with vision: true and unsure text answers (false: off)
73
+ vision:
74
+ model: claude-opus-5-5
75
+ api_key_env: ANTHROPIC_API_KEY
76
+ when: uncertain
77
+ max_calls: 40
72
78
 
73
79
  share:
74
80
  url: https://kaloko.app