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.
- package/README.md +41 -2
- package/package.json +1 -1
- package/schemas/config.v1.json +26 -0
- package/schemas/scenario.v1.json +32 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +24 -7
- package/skills/kaloko/reference/acceptance.md +34 -2
- package/skills/kaloko/reference/collaboration.md +45 -2
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/evaluation.md +51 -3
- package/skills/kaloko/reference/execution.md +18 -0
- package/skills/kaloko/reference/first-run.md +51 -0
- package/skills/kaloko/reference/mcp.md +8 -1
- package/src/cli.mjs +93 -9
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- package/src/lib/design-cli.mjs +7 -3
- package/src/lib/devspec.mjs +821 -0
- package/src/lib/doctor.mjs +7 -0
- package/src/lib/drift.mjs +16 -8
- package/src/lib/evaluate.mjs +94 -12
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- package/src/lib/onboarding.mjs +33 -0
- package/src/lib/pack-help.mjs +7 -0
- package/src/lib/packs.mjs +9 -2
- package/src/lib/png.mjs +61 -0
- package/src/lib/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -1
- package/src/lib/review.mjs +69 -0
- package/src/lib/spec-cli.mjs +90 -0
- package/src/lib/stability-core.mjs +114 -0
- package/src/lib/stability.mjs +249 -0
- package/src/lib/vision-core.mjs +132 -0
- package/src/lib/vision.mjs +127 -0
- package/templates/kaloko.config.yml +6 -0
- package/viewer/index.html +889 -46
|
@@ -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, '"')}">`
|
|
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
|