@crvouga/atlas 1.20.0 → 1.22.0
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/CHANGELOG.md +15 -1
- package/dist/core/src/cli.d.ts +2 -2
- package/dist/core/src/cli.js +7 -1
- package/dist/critics/src/catalog-client.d.ts +91 -0
- package/dist/critics/src/catalog-client.js +194 -0
- package/dist/critics/src/cli-calibrate.d.ts +4 -0
- package/dist/critics/src/cli-calibrate.js +211 -0
- package/dist/critics/src/cli-common.d.ts +108 -0
- package/dist/critics/src/cli-common.js +155 -0
- package/dist/critics/src/cli-review.d.ts +4 -0
- package/dist/critics/src/cli-review.js +174 -0
- package/dist/critics/src/cli-watch.d.ts +4 -0
- package/dist/critics/src/cli-watch.js +191 -0
- package/dist/critics/src/cli.d.ts +13 -0
- package/dist/critics/src/cli.js +490 -0
- package/dist/critics/src/index.d.ts +3 -0
- package/dist/critics/src/index.js +3 -0
- package/dist/critics/src/judging.d.ts +8 -0
- package/dist/critics/src/llm.d.ts +120 -0
- package/dist/critics/src/llm.js +266 -0
- package/dist/critics/src/marks.d.ts +74 -0
- package/dist/critics/src/marks.js +76 -0
- package/dist/critics/src/registry.d.ts +125 -0
- package/dist/critics/src/registry.js +203 -0
- package/dist/critics/src/spool.d.ts +32 -0
- package/dist/critics/src/spool.js +129 -0
- package/dist/critics/src/uiclip.d.ts +82 -0
- package/dist/critics/src/uiclip.js +167 -0
- package/dist/reports/src/compose.d.ts +23 -1
- package/dist/reports/src/compose.js +29 -19
- package/dist/reports/src/critique-review.d.ts +111 -0
- package/dist/reports/src/critique-review.js +123 -0
- package/dist/reports/src/critique-row.d.ts +24 -0
- package/dist/reports/src/critique-row.js +39 -0
- package/dist/reports/src/evidence-types.d.ts +5 -0
- package/dist/reports/src/layer-merge.js +5 -0
- package/dist/reports/src/report-cli.d.ts +13 -0
- package/dist/reports/src/report-cli.js +6 -4
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +12 -0
- package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
- package/dist/reports/src/ui-catalog-cli.js +391 -0
- package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
- package/dist/reports/src/ui-catalog-diff.js +130 -0
- package/dist/reports/src/ui-catalog-entries.d.ts +147 -0
- package/dist/reports/src/ui-catalog-entries.js +190 -0
- package/dist/reports/src/ui-catalog-export.d.ts +82 -0
- package/dist/reports/src/ui-catalog-export.js +221 -0
- package/dist/reports/src/ui-catalog-publish.js +2 -2
- package/dist/reports/src/ui-catalog.d.ts +2 -0
- package/dist/reports/src/ui-catalog.js +1 -1
- package/dist/reports/src/ui-state.d.ts +2 -0
- package/dist/reports/src/ui-state.js +2 -2
- package/dist/schema/src/api.d.ts +653 -1
- package/dist/schema/src/api.js +44 -0
- package/dist/schema/src/cli-results.d.ts +991 -1
- package/dist/schema/src/cli-results.js +265 -1
- package/dist/schema/src/critique.d.ts +31 -0
- package/dist/schema/src/critique.js +26 -0
- package/dist/schema/src/server-config.d.ts +3 -3
- package/dist/server/src/app.js +28 -1
- package/dist/server/src/openapi.js +19 -2
- package/docs/agents.md +16 -2
- package/docs/critics.md +273 -9
- package/docs/extending.md +1 -1
- package/docs/reference/cli.md +126 -1
- package/docs/reference/core.md +1 -1
- package/docs/reference/critics.md +1131 -29
- package/docs/reference/http-api.md +40 -0
- package/docs/reference/reports.md +855 -0
- package/docs/reference/schema.md +197 -1
- package/docs/report-contract.md +2 -1
- package/docs/server.md +2 -0
- package/docs/ui-catalog.md +110 -13
- package/docs/ui.md +69 -2
- package/llms-full.txt +636 -26
- package/llms.txt +1 -1
- package/package.json +9 -1
- package/packages/critics/README.md +1 -0
- package/packages/schema/schemas/critic-calibrate-result.schema.json +274 -0
- package/packages/schema/schemas/critic-doctor-result.schema.json +74 -0
- package/packages/schema/schemas/critic-list-result.schema.json +90 -0
- package/packages/schema/schemas/critic-review-result.schema.json +397 -0
- package/packages/schema/schemas/critic-run-result.schema.json +249 -0
- package/packages/schema/schemas/critic-setup-result.schema.json +34 -0
- package/packages/schema/schemas/critic-watch-result.schema.json +211 -0
- package/packages/schema/schemas/critique-mark.schema.json +81 -0
- package/packages/schema/schemas/effective-delta.schema.json +337 -0
- package/packages/schema/schemas/effective-evidence.schema.json +337 -0
- package/packages/schema/schemas/layer-rows.schema.json +337 -0
- package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
- package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
- package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
- package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
- package/packages/server/openapi.json +6755 -4738
- package/packages/visualizer/src/app/AppShell.tsx +3 -1
- package/packages/visualizer/src/app/router.tsx +20 -7
- package/packages/visualizer/src/catalog/Picture.tsx +32 -0
- package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
- package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
- package/packages/visualizer/src/catalog/history.ts +61 -0
- package/packages/visualizer/src/catalog/search.ts +14 -3
- package/packages/visualizer/src/components/Header.tsx +16 -4
- package/packages/visualizer/src/critiques/CritiquesPage.tsx +402 -0
- package/packages/visualizer/src/critiques/FindingOverlay.tsx +50 -0
- package/packages/visualizer/src/critiques/ranking.ts +54 -0
- package/packages/visualizer/src/critiques/search.ts +41 -0
- package/packages/visualizer/src/evidence/applicability-view.tsx +11 -7
- package/packages/visualizer/src/evidence/behaviors-page.tsx +22 -15
- package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
- package/packages/visualizer/src/explorer/Explorer.tsx +5 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +4 -1
- package/packages/visualizer/src/explorer/parts.tsx +19 -1
- package/packages/visualizer/src/explorer/playlist.tsx +11 -7
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type Critic, type CriticAnswer } from './contract.js';
|
|
2
|
+
/** What a critic asks a provider. */
|
|
3
|
+
export type LlmRequest = {
|
|
4
|
+
readonly model: string;
|
|
5
|
+
readonly system: string;
|
|
6
|
+
readonly prompt: string;
|
|
7
|
+
readonly image: Uint8Array;
|
|
8
|
+
/** The image's media type, from its bytes (`image/png`, `image/jpeg`, `image/webp`, `image/gif`). */
|
|
9
|
+
readonly imageMediaType: string;
|
|
10
|
+
readonly temperature: number;
|
|
11
|
+
readonly maxTokens: number;
|
|
12
|
+
/** Aborted on timeout or cancellation: pass it to the network call. */
|
|
13
|
+
readonly signal: AbortSignal;
|
|
14
|
+
};
|
|
15
|
+
/** What a provider answers: the model's text, and what it cost when the provider says. */
|
|
16
|
+
export type LlmAnswer = {
|
|
17
|
+
readonly text: string;
|
|
18
|
+
readonly usage?: {
|
|
19
|
+
readonly inputTokens?: number;
|
|
20
|
+
readonly outputTokens?: number;
|
|
21
|
+
readonly latencyMs?: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** A model provider. `complete` should throw `RateLimitedError` (with `Retry-After`) when rate limited and any `CriticError` otherwise. */
|
|
25
|
+
export type LlmProvider = {
|
|
26
|
+
/** Short and stable: `openai-compatible`, `local`. Recorded as the model's provider. */
|
|
27
|
+
readonly name: string;
|
|
28
|
+
/** Where images are sent: a host name. A run that sends images to a third party needs explicit confirmation. */
|
|
29
|
+
readonly sendsImagesTo: string;
|
|
30
|
+
/** What it accepts. */
|
|
31
|
+
readonly capabilities: {
|
|
32
|
+
readonly images: true;
|
|
33
|
+
readonly maxImageBytes?: number;
|
|
34
|
+
};
|
|
35
|
+
complete(request: LlmRequest): Promise<LlmAnswer>;
|
|
36
|
+
};
|
|
37
|
+
/** The rubric used when a project gives none. */
|
|
38
|
+
export declare const DEFAULT_RUBRIC: string;
|
|
39
|
+
/** The instructions that make the model answer in the structure {@link parseLlmAnswer} reads. */
|
|
40
|
+
export declare const ANSWER_INSTRUCTIONS: string;
|
|
41
|
+
/**
|
|
42
|
+
* Reads a model's text into a critic's answer. Accepts the JSON object bare or inside a code fence. Throws `InvalidOutputError`,
|
|
43
|
+
* with the first problem and never the model's text, when it is not JSON or is not the asked-for structure, so no score is invented.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseLlmAnswer(text: string): CriticAnswer;
|
|
46
|
+
/** The media type of image bytes, from their first bytes. Throws for something that is not a PNG, JPEG, WebP or GIF. */
|
|
47
|
+
export declare function imageMediaType(image: Uint8Array): string;
|
|
48
|
+
/** Options of {@link createLlmCritic}. */
|
|
49
|
+
export type LlmCriticOptions = {
|
|
50
|
+
readonly provider: LlmProvider;
|
|
51
|
+
/** The model id the provider knows. Part of the critique's identity. */
|
|
52
|
+
readonly model: string;
|
|
53
|
+
/** The critic's name and the producer suffix. Default `vision-llm`. */
|
|
54
|
+
readonly name?: string;
|
|
55
|
+
/** Of the critic's own logic and prompt. Change it when the rubric's meaning changes. Default `1.0.0`. */
|
|
56
|
+
readonly version?: string;
|
|
57
|
+
/** What to look for. Recorded as a hash, never as text. Default {@link DEFAULT_RUBRIC}. */
|
|
58
|
+
readonly rubric?: string;
|
|
59
|
+
/** Default 0: as deterministic as the model allows. */
|
|
60
|
+
readonly temperature?: number;
|
|
61
|
+
/** The most the model may answer with. Default 1024. */
|
|
62
|
+
readonly maxTokens?: number;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* The LLM critic. It sends each image to the provider with the rubric and reads the structured answer into a verdict and findings.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* const critic = createLlmCritic({ model: 'my-vision-model', provider: openAiCompatibleProvider({ baseUrl: 'https://llm.example.com/v1', apiKey }) });
|
|
69
|
+
*/
|
|
70
|
+
export declare function createLlmCritic(options: LlmCriticOptions): Critic;
|
|
71
|
+
/** Reads a `Retry-After` header (seconds or an HTTP date) as milliseconds. */
|
|
72
|
+
export declare function retryAfterMs(header: string | null, now?: number): number | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* A provider for any server that implements the chat-completions protocol (`POST {baseUrl}/chat/completions` with image content),
|
|
75
|
+
* hosted or local. The key is sent as a Bearer token and nowhere else. A 429 becomes `RateLimitedError` with its `Retry-After`; other
|
|
76
|
+
* failures become `CriticError('error')` with the status and never the response body. Images go to `baseUrl`'s host, which is what
|
|
77
|
+
* `sendsImagesTo` says.
|
|
78
|
+
*
|
|
79
|
+
* @param options.baseUrl for example `https://llm.example.com/v1`. Plain `http` is allowed only for a loopback address.
|
|
80
|
+
* @param options.apiKey the credential, resolved by the caller from a reference. Optional for a local server.
|
|
81
|
+
* @param options.fetch for tests.
|
|
82
|
+
*/
|
|
83
|
+
export declare function openAiCompatibleProvider(options: {
|
|
84
|
+
readonly baseUrl: string;
|
|
85
|
+
readonly apiKey?: string;
|
|
86
|
+
readonly fetch?: typeof fetch;
|
|
87
|
+
readonly maxImageBytes?: number;
|
|
88
|
+
}): LlmProvider;
|
|
89
|
+
/** The version of Anthropic's Messages API that {@link anthropicProvider} speaks. */
|
|
90
|
+
export declare const ANTHROPIC_API_VERSION = "2023-06-01";
|
|
91
|
+
/**
|
|
92
|
+
* A provider for Anthropic's Messages API (`POST {baseUrl}/v1/messages`), with plain `fetch` and no SDK. The key is sent in the `x-api-key`
|
|
93
|
+
* header and nowhere else. A 429 or 529 (overloaded) becomes `RateLimitedError` with its `Retry-After`; other failures become `CriticError`
|
|
94
|
+
* with the status and never the response body. Images go to `baseUrl`'s host, which is what `sendsImagesTo` says.
|
|
95
|
+
*
|
|
96
|
+
* @param options.apiKey the credential, resolved by the caller from a reference.
|
|
97
|
+
* @param options.baseUrl default `https://api.anthropic.com`. Plain `http` only for a loopback address.
|
|
98
|
+
* @param options.fetch for tests.
|
|
99
|
+
*/
|
|
100
|
+
export declare function anthropicProvider(options: {
|
|
101
|
+
readonly apiKey: string;
|
|
102
|
+
readonly baseUrl?: string;
|
|
103
|
+
readonly fetch?: typeof fetch;
|
|
104
|
+
readonly maxImageBytes?: number;
|
|
105
|
+
}): LlmProvider;
|
|
106
|
+
/**
|
|
107
|
+
* A provider for Google's Gemini API (`POST {baseUrl}/v1beta/models/{model}:generateContent`), with plain `fetch` and no SDK. The key is sent
|
|
108
|
+
* in the `x-goog-api-key` header, never in the address, so it cannot end up in a URL, a log or an error. A 429 becomes `RateLimitedError`
|
|
109
|
+
* with its `Retry-After`; other failures become `CriticError` with the status and never the response body. Images go to `baseUrl`'s host.
|
|
110
|
+
*
|
|
111
|
+
* @param options.apiKey the credential, resolved by the caller from a reference.
|
|
112
|
+
* @param options.baseUrl default `https://generativelanguage.googleapis.com`. Plain `http` only for a loopback address.
|
|
113
|
+
* @param options.fetch for tests.
|
|
114
|
+
*/
|
|
115
|
+
export declare function geminiProvider(options: {
|
|
116
|
+
readonly apiKey: string;
|
|
117
|
+
readonly baseUrl?: string;
|
|
118
|
+
readonly fetch?: typeof fetch;
|
|
119
|
+
readonly maxImageBytes?: number;
|
|
120
|
+
}): LlmProvider;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module The LLM critic (`@crvouga/atlas/critics/llm`)
|
|
3
|
+
*
|
|
4
|
+
* A critic that asks a vision-capable language model to review a screenshot against a rubric and answer with structured
|
|
5
|
+
* findings. It is provider-agnostic: a provider is a small adapter (`LlmProvider.complete`) with declared capabilities, so
|
|
6
|
+
* a developer brings their own model and account. `openAiCompatibleProvider` speaks the widely implemented chat-completions
|
|
7
|
+
* protocol with plain `fetch` (hosted services and local servers alike), and `anthropicProvider` and `geminiProvider` speak those two
|
|
8
|
+
* APIs the same way, so there is no SDK to install; any other provider is a few lines.
|
|
9
|
+
*
|
|
10
|
+
* The rubric is recorded only as a hash. The credential is passed in by the caller (resolved from a reference at run time),
|
|
11
|
+
* sent only in a request header, and never written to a result, a log or an error: the runner redacts what it records.
|
|
12
|
+
* A provider's rate limit becomes a `RateLimitedError` carrying its `Retry-After`, an answer that is not the asked-for structure
|
|
13
|
+
* becomes `invalid-output` and no score is invented, and the images' destination is declared before a run.
|
|
14
|
+
*
|
|
15
|
+
* @see docs/critics.md#the-llm-critic-and-bringing-your-own-ai
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { z } from 'zod/v4';
|
|
19
|
+
import { CriticError, InvalidOutputError, RateLimitedError, defineCritic } from './contract.js';
|
|
20
|
+
/** The rubric used when a project gives none. */
|
|
21
|
+
export const DEFAULT_RUBRIC = [
|
|
22
|
+
'You are a meticulous UI reviewer. Judge how polished this screenshot of a product screen is.',
|
|
23
|
+
'Look for: inconsistent or cramped spacing, misalignment, text that is clipped, overlapping or hard to read, weak contrast, unclear visual hierarchy,',
|
|
24
|
+
'broken or placeholder content, controls that look inactive or inconsistent, and anything a careful designer would fix before release.',
|
|
25
|
+
'Judge only what is visible. Do not guess about behaviour. If the screenshot is a loading, empty or error state, judge it as that kind of state.'
|
|
26
|
+
].join(' ');
|
|
27
|
+
const FindingSchema = z.object({
|
|
28
|
+
id: z.string().optional(), category: z.string().min(1).optional(), severity: z.string().min(1).optional(),
|
|
29
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
30
|
+
region: z.object({ unit: z.string().min(1), x: z.number(), y: z.number(), w: z.number().nonnegative(), h: z.number().nonnegative() }).optional(),
|
|
31
|
+
rationale: z.string().optional(), suggestion: z.string().optional()
|
|
32
|
+
}).passthrough();
|
|
33
|
+
const ModelAnswerSchema = z.object({
|
|
34
|
+
score: z.number().min(0).max(1),
|
|
35
|
+
label: z.string().min(1).optional(),
|
|
36
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
37
|
+
findings: z.array(FindingSchema).max(50).optional()
|
|
38
|
+
}).passthrough();
|
|
39
|
+
/** The instructions that make the model answer in the structure {@link parseLlmAnswer} reads. */
|
|
40
|
+
export const ANSWER_INSTRUCTIONS = [
|
|
41
|
+
'Answer with one JSON object and nothing else, in this shape:',
|
|
42
|
+
'{"score": <number 0..1, higher is more polished>, "label": "ok" | "needs-polish", "confidence": <0..1>,',
|
|
43
|
+
' "findings": [{"category": "spacing|alignment|contrast|overflow|hierarchy|content|other", "severity": "info|minor|major", "confidence": <0..1>,',
|
|
44
|
+
' "region": {"unit": "normalized", "x": <0..1>, "y": <0..1>, "w": <0..1>, "h": <0..1>}, "rationale": "<why>", "suggestion": "<what to change>"}]}',
|
|
45
|
+
'List findings only for real problems; an empty list is fine.'
|
|
46
|
+
].join('\n');
|
|
47
|
+
/**
|
|
48
|
+
* Reads a model's text into a critic's answer. Accepts the JSON object bare or inside a code fence. Throws `InvalidOutputError`,
|
|
49
|
+
* with the first problem and never the model's text, when it is not JSON or is not the asked-for structure, so no score is invented.
|
|
50
|
+
*/
|
|
51
|
+
export function parseLlmAnswer(text) {
|
|
52
|
+
const body = /```(?:json)?\s*([\s\S]*?)```/.exec(text)?.[1] ?? text;
|
|
53
|
+
const start = body.indexOf('{'), end = body.lastIndexOf('}');
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = JSON.parse(start >= 0 && end > start ? body.slice(start, end + 1) : body);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new InvalidOutputError('The model did not answer with JSON');
|
|
60
|
+
}
|
|
61
|
+
const parsed = ModelAnswerSchema.safeParse(raw);
|
|
62
|
+
if (!parsed.success)
|
|
63
|
+
throw new InvalidOutputError(`The model's answer is not the asked-for structure: ${parsed.error.issues[0]?.path.join('.') || 'answer'}: ${parsed.error.issues[0]?.message ?? 'invalid'}`);
|
|
64
|
+
const { score, label, confidence, findings } = parsed.data;
|
|
65
|
+
return {
|
|
66
|
+
verdict: { score, scale: '0..1, higher is better', ...(label ? { label } : {}), ...(confidence === undefined ? {} : { confidence }) },
|
|
67
|
+
...(findings?.length ? { findings: findings.map((finding, index) => ({ id: finding.id ?? `f${index + 1}`, ...finding })) } : {})
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** The media type of image bytes, from their first bytes. Throws for something that is not a PNG, JPEG, WebP or GIF. */
|
|
71
|
+
export function imageMediaType(image) {
|
|
72
|
+
const starts = (...bytes) => bytes.every((byte, index) => image[index] === byte);
|
|
73
|
+
if (starts(0x89, 0x50, 0x4e, 0x47))
|
|
74
|
+
return 'image/png';
|
|
75
|
+
if (starts(0xff, 0xd8, 0xff))
|
|
76
|
+
return 'image/jpeg';
|
|
77
|
+
if (starts(0x47, 0x49, 0x46, 0x38))
|
|
78
|
+
return 'image/gif';
|
|
79
|
+
if (starts(0x52, 0x49, 0x46, 0x46) && image[8] === 0x57 && image[9] === 0x45 && image[10] === 0x42 && image[11] === 0x50)
|
|
80
|
+
return 'image/webp';
|
|
81
|
+
throw new CriticError('unsupported-image', 'The image is not a PNG, JPEG, WebP or GIF');
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The LLM critic. It sends each image to the provider with the rubric and reads the structured answer into a verdict and findings.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* const critic = createLlmCritic({ model: 'my-vision-model', provider: openAiCompatibleProvider({ baseUrl: 'https://llm.example.com/v1', apiKey }) });
|
|
88
|
+
*/
|
|
89
|
+
export function createLlmCritic(options) {
|
|
90
|
+
const rubric = options.rubric ?? DEFAULT_RUBRIC;
|
|
91
|
+
const temperature = options.temperature ?? 0;
|
|
92
|
+
const maxTokens = options.maxTokens ?? 1024;
|
|
93
|
+
const rubricHash = createHash('sha256').update(rubric).digest('hex');
|
|
94
|
+
return defineCritic({
|
|
95
|
+
name: options.name ?? 'vision-llm', version: options.version ?? '1.0.0', kind: 'llm',
|
|
96
|
+
describe: () => ({ sendsImagesTo: options.provider.sendsImagesTo, model: { id: options.model, provider: options.provider.name }, parameters: { temperature, maxTokens, rubric: rubricHash }, needsSetup: false }),
|
|
97
|
+
async evaluate({ image, entry, signal }) {
|
|
98
|
+
const max = options.provider.capabilities.maxImageBytes;
|
|
99
|
+
if (max !== undefined && image.byteLength > max)
|
|
100
|
+
throw new CriticError('image-too-large', `The image is ${image.byteLength} bytes; this provider accepts at most ${max}`);
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
const answer = await options.provider.complete({
|
|
103
|
+
model: options.model, system: `${rubric}\n\n${ANSWER_INSTRUCTIONS}`,
|
|
104
|
+
prompt: `Review this screenshot. It is a "${entry.state}" state${entry.route ? ` of ${entry.route}` : ''}, titled "${entry.title}".`,
|
|
105
|
+
image, imageMediaType: imageMediaType(image), temperature, maxTokens, signal
|
|
106
|
+
});
|
|
107
|
+
const parsed = parseLlmAnswer(answer.text);
|
|
108
|
+
return { ...parsed, usage: { ...(answer.usage ?? {}), latencyMs: answer.usage?.latencyMs ?? Date.now() - started } };
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Reads a `Retry-After` header (seconds or an HTTP date) as milliseconds. */
|
|
113
|
+
export function retryAfterMs(header, now = Date.now()) {
|
|
114
|
+
if (!header)
|
|
115
|
+
return undefined;
|
|
116
|
+
if (/^\d+(\.\d+)?$/.test(header.trim()))
|
|
117
|
+
return Math.round(Number(header) * 1000);
|
|
118
|
+
const at = Date.parse(header);
|
|
119
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : undefined;
|
|
120
|
+
}
|
|
121
|
+
/** Checks an address for a provider: https, or plain http only for a loopback address. Throws otherwise. Returns the parsed address. */
|
|
122
|
+
function providerUrl(baseUrl) {
|
|
123
|
+
const url = new URL(baseUrl);
|
|
124
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname);
|
|
125
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
|
|
126
|
+
throw new Error('A provider must be reached over https (plain http only for a loopback address)');
|
|
127
|
+
return url;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* POSTs a JSON body to a provider and returns the parsed answer, turning every failure into what the runner understands: a 429 (or a
|
|
131
|
+
* provider's own "overloaded" status) is a `RateLimitedError` with its `Retry-After`, 401 and 403 are `unauthorized`, any other failure is
|
|
132
|
+
* `error` with the status and never the response body, and an answer that is not JSON is `invalid-output`. The credential is in `headers`
|
|
133
|
+
* only; nothing here puts it in a message.
|
|
134
|
+
*/
|
|
135
|
+
async function postJson(args) {
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
response = await args.fetch(args.url, { method: 'POST', signal: args.signal, headers: { 'content-type': 'application/json', ...args.headers }, body: JSON.stringify(args.body) });
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (args.signal.aborted)
|
|
142
|
+
throw error;
|
|
143
|
+
throw new CriticError('error', `The provider at ${args.host} could not be reached (${error instanceof Error ? error.name : 'error'})`);
|
|
144
|
+
}
|
|
145
|
+
if (response.status === 429 || args.overloaded?.includes(response.status)) {
|
|
146
|
+
await response.body?.cancel();
|
|
147
|
+
throw new RateLimitedError(`The provider at ${args.host} is rate limiting`, retryAfterMs(response.headers.get('retry-after')));
|
|
148
|
+
}
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
await response.body?.cancel();
|
|
151
|
+
throw new CriticError(response.status === 401 || response.status === 403 ? 'unauthorized' : 'error', `The provider at ${args.host} answered ${response.status}`);
|
|
152
|
+
}
|
|
153
|
+
const body = await response.json().catch(() => null);
|
|
154
|
+
if (body === null || typeof body !== 'object')
|
|
155
|
+
throw new InvalidOutputError('The provider answered something that is not JSON');
|
|
156
|
+
return body;
|
|
157
|
+
}
|
|
158
|
+
const base64 = (bytes) => Buffer.from(bytes).toString('base64');
|
|
159
|
+
const maxImage = (value) => (value === undefined ? {} : { maxImageBytes: value });
|
|
160
|
+
const tokens = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined);
|
|
161
|
+
/**
|
|
162
|
+
* A provider for any server that implements the chat-completions protocol (`POST {baseUrl}/chat/completions` with image content),
|
|
163
|
+
* hosted or local. The key is sent as a Bearer token and nowhere else. A 429 becomes `RateLimitedError` with its `Retry-After`; other
|
|
164
|
+
* failures become `CriticError('error')` with the status and never the response body. Images go to `baseUrl`'s host, which is what
|
|
165
|
+
* `sendsImagesTo` says.
|
|
166
|
+
*
|
|
167
|
+
* @param options.baseUrl for example `https://llm.example.com/v1`. Plain `http` is allowed only for a loopback address.
|
|
168
|
+
* @param options.apiKey the credential, resolved by the caller from a reference. Optional for a local server.
|
|
169
|
+
* @param options.fetch for tests.
|
|
170
|
+
*/
|
|
171
|
+
export function openAiCompatibleProvider(options) {
|
|
172
|
+
const url = providerUrl(options.baseUrl);
|
|
173
|
+
const doFetch = options.fetch ?? fetch;
|
|
174
|
+
const endpoint = `${options.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
175
|
+
return {
|
|
176
|
+
name: 'openai-compatible', sendsImagesTo: url.host,
|
|
177
|
+
capabilities: { images: true, ...maxImage(options.maxImageBytes) },
|
|
178
|
+
async complete(request) {
|
|
179
|
+
const body = await postJson({
|
|
180
|
+
fetch: doFetch, url: endpoint, host: url.host, signal: request.signal,
|
|
181
|
+
headers: options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {},
|
|
182
|
+
body: {
|
|
183
|
+
model: request.model, temperature: request.temperature, max_tokens: request.maxTokens,
|
|
184
|
+
messages: [
|
|
185
|
+
{ role: 'system', content: request.system },
|
|
186
|
+
{ role: 'user', content: [{ type: 'text', text: request.prompt }, { type: 'image_url', image_url: { url: `data:${request.imageMediaType};base64,${base64(request.image)}` } }] }
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
const content = body.choices?.[0]?.message?.content;
|
|
191
|
+
if (typeof content !== 'string')
|
|
192
|
+
throw new InvalidOutputError('The provider answered without a message');
|
|
193
|
+
const input = tokens(body.usage?.prompt_tokens), output = tokens(body.usage?.completion_tokens);
|
|
194
|
+
return { text: content, usage: { ...(input === undefined ? {} : { inputTokens: input }), ...(output === undefined ? {} : { outputTokens: output }) } };
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** The version of Anthropic's Messages API that {@link anthropicProvider} speaks. */
|
|
199
|
+
export const ANTHROPIC_API_VERSION = '2023-06-01';
|
|
200
|
+
/**
|
|
201
|
+
* A provider for Anthropic's Messages API (`POST {baseUrl}/v1/messages`), with plain `fetch` and no SDK. The key is sent in the `x-api-key`
|
|
202
|
+
* header and nowhere else. A 429 or 529 (overloaded) becomes `RateLimitedError` with its `Retry-After`; other failures become `CriticError`
|
|
203
|
+
* with the status and never the response body. Images go to `baseUrl`'s host, which is what `sendsImagesTo` says.
|
|
204
|
+
*
|
|
205
|
+
* @param options.apiKey the credential, resolved by the caller from a reference.
|
|
206
|
+
* @param options.baseUrl default `https://api.anthropic.com`. Plain `http` only for a loopback address.
|
|
207
|
+
* @param options.fetch for tests.
|
|
208
|
+
*/
|
|
209
|
+
export function anthropicProvider(options) {
|
|
210
|
+
const baseUrl = options.baseUrl ?? 'https://api.anthropic.com';
|
|
211
|
+
const url = providerUrl(baseUrl);
|
|
212
|
+
const doFetch = options.fetch ?? fetch;
|
|
213
|
+
return {
|
|
214
|
+
name: 'anthropic', sendsImagesTo: url.host,
|
|
215
|
+
capabilities: { images: true, ...maxImage(options.maxImageBytes) },
|
|
216
|
+
async complete(request) {
|
|
217
|
+
const body = await postJson({
|
|
218
|
+
fetch: doFetch, url: `${baseUrl.replace(/\/+$/, '')}/v1/messages`, host: url.host, signal: request.signal, overloaded: [529],
|
|
219
|
+
headers: { 'x-api-key': options.apiKey, 'anthropic-version': ANTHROPIC_API_VERSION },
|
|
220
|
+
body: {
|
|
221
|
+
model: request.model, max_tokens: request.maxTokens, temperature: request.temperature, system: request.system,
|
|
222
|
+
messages: [{ role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: request.imageMediaType, data: base64(request.image) } }, { type: 'text', text: request.prompt }] }]
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
const text = (body.content ?? []).filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('');
|
|
226
|
+
if (!text)
|
|
227
|
+
throw new InvalidOutputError('The provider answered without a message');
|
|
228
|
+
const input = tokens(body.usage?.input_tokens), output = tokens(body.usage?.output_tokens);
|
|
229
|
+
return { text, usage: { ...(input === undefined ? {} : { inputTokens: input }), ...(output === undefined ? {} : { outputTokens: output }) } };
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* A provider for Google's Gemini API (`POST {baseUrl}/v1beta/models/{model}:generateContent`), with plain `fetch` and no SDK. The key is sent
|
|
235
|
+
* in the `x-goog-api-key` header, never in the address, so it cannot end up in a URL, a log or an error. A 429 becomes `RateLimitedError`
|
|
236
|
+
* with its `Retry-After`; other failures become `CriticError` with the status and never the response body. Images go to `baseUrl`'s host.
|
|
237
|
+
*
|
|
238
|
+
* @param options.apiKey the credential, resolved by the caller from a reference.
|
|
239
|
+
* @param options.baseUrl default `https://generativelanguage.googleapis.com`. Plain `http` only for a loopback address.
|
|
240
|
+
* @param options.fetch for tests.
|
|
241
|
+
*/
|
|
242
|
+
export function geminiProvider(options) {
|
|
243
|
+
const baseUrl = options.baseUrl ?? 'https://generativelanguage.googleapis.com';
|
|
244
|
+
const url = providerUrl(baseUrl);
|
|
245
|
+
const doFetch = options.fetch ?? fetch;
|
|
246
|
+
return {
|
|
247
|
+
name: 'gemini', sendsImagesTo: url.host,
|
|
248
|
+
capabilities: { images: true, ...maxImage(options.maxImageBytes) },
|
|
249
|
+
async complete(request) {
|
|
250
|
+
const body = await postJson({
|
|
251
|
+
fetch: doFetch, url: `${baseUrl.replace(/\/+$/, '')}/v1beta/models/${encodeURIComponent(request.model)}:generateContent`, host: url.host, signal: request.signal,
|
|
252
|
+
headers: { 'x-goog-api-key': options.apiKey },
|
|
253
|
+
body: {
|
|
254
|
+
systemInstruction: { parts: [{ text: request.system }] },
|
|
255
|
+
contents: [{ role: 'user', parts: [{ text: request.prompt }, { inline_data: { mime_type: request.imageMediaType, data: base64(request.image) } }] }],
|
|
256
|
+
generationConfig: { temperature: request.temperature, maxOutputTokens: request.maxTokens, responseMimeType: 'application/json' }
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
const text = (body.candidates?.[0]?.content?.parts ?? []).filter(part => typeof part?.text === 'string').map(part => part.text).join('');
|
|
260
|
+
if (!text)
|
|
261
|
+
throw new InvalidOutputError('The provider answered without a message');
|
|
262
|
+
const input = tokens(body.usageMetadata?.promptTokenCount), output = tokens(body.usageMetadata?.candidatesTokenCount);
|
|
263
|
+
return { text, usage: { ...(input === undefined ? {} : { inputTokens: input }), ...(output === undefined ? {} : { outputTokens: output }) } };
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Reviewers' marks of critiques
|
|
3
|
+
*
|
|
4
|
+
* A person who reads a critic's opinion can accept it (it is right: the finding is valid, the UI needs polish) or dismiss it (it is not).
|
|
5
|
+
* A mark is stored like the critique it is about: a check in a layer of parts of kind `critique`, with the decision in
|
|
6
|
+
* `extensions["atlas.critique-mark"]`, published under the producer `critic.review.<reviewer>`. That is within what a `critic`-role
|
|
7
|
+
* token may publish, so marking needs no more power than critiquing, and any `publish` token may do it too. Because a mark is evidence in
|
|
8
|
+
* an opinion part it is never counted, and it never changes a status, a slot or the gate: the server may refuse an upload, and it
|
|
9
|
+
* never alters one.
|
|
10
|
+
*
|
|
11
|
+
* Each reviewer holds one mark per critic and finding of an entry: a newer mark replaces their older one (the slot rule), and a mark made
|
|
12
|
+
* about a picture the entry no longer has is shown as that. Marks feed `atlas critic calibrate --from-marks`.
|
|
13
|
+
*
|
|
14
|
+
* @see docs/critics.md#reviewing-and-marking
|
|
15
|
+
*/
|
|
16
|
+
import type { ReportLayer } from '../../reports/src/layer-types.js';
|
|
17
|
+
import type { ObjectStore } from '../../reports/src/object-store.js';
|
|
18
|
+
import { type PublishChecksOptions } from '../../reports/src/publish-checks.js';
|
|
19
|
+
import { type CritiqueMark } from '../../schema/src/critique.js';
|
|
20
|
+
import type { PublishChecksInput } from '../../schema/src/layer.js';
|
|
21
|
+
/** The prefix of the id of every mark check: `critique-mark/…`. It keeps a mark from ever sharing a slot with a critique or a test. */
|
|
22
|
+
export declare const MARK_CHECK_PREFIX = "critique-mark/";
|
|
23
|
+
/** One decision about a critic's opinion, ready to be recorded. */
|
|
24
|
+
export type MarkRequest = {
|
|
25
|
+
/** The entry and the exact picture the marked critique judged. */
|
|
26
|
+
readonly entry: {
|
|
27
|
+
readonly key: string;
|
|
28
|
+
readonly title: string;
|
|
29
|
+
readonly imageSha256: string;
|
|
30
|
+
};
|
|
31
|
+
/** The critic whose opinion is marked. */
|
|
32
|
+
readonly critic: {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly version?: string;
|
|
35
|
+
};
|
|
36
|
+
/** The `id` of one finding; absent marks the critique as a whole. */
|
|
37
|
+
readonly finding?: string;
|
|
38
|
+
readonly decision: 'accept' | 'dismiss';
|
|
39
|
+
/** Why, in the reviewer's words. */
|
|
40
|
+
readonly note?: string;
|
|
41
|
+
/** The layer of the critique that was marked. */
|
|
42
|
+
readonly layer?: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The name a reviewer is recorded under, as a producer suffix: lowercase letters, digits, `-` and `.`. Anything else becomes `-`.
|
|
46
|
+
* Throws for a name with nothing left. It is a name the reviewer chooses, never an account, an address or a login.
|
|
47
|
+
*/
|
|
48
|
+
export declare function reviewerName(name: string): string;
|
|
49
|
+
/** `critic.review.<reviewer>`: the producer of a reviewer's marks. A critic-scoped token may publish it. */
|
|
50
|
+
export declare function reviewerProducer(reviewer: string): string;
|
|
51
|
+
/** The id of the check a mark is stored under; it names the entry, the critic and the finding (`*` for the whole critique), so a reviewer's newer mark of the same subject replaces the older. */
|
|
52
|
+
export declare function markCheckId(request: Pick<MarkRequest, 'entry' | 'critic' | 'finding'>): string;
|
|
53
|
+
/** The extension a mark is stored as. */
|
|
54
|
+
export declare function markExtension(request: MarkRequest, reviewer: string): CritiqueMark;
|
|
55
|
+
/**
|
|
56
|
+
* The input of `publishChecks` for a reviewer's marks: producer `critic.review.<reviewer>`, part kind `critique`. Each mark is a `passed`
|
|
57
|
+
* check (it was recorded) with an `info` annotation in words, so a viewer that has never heard of marks still shows it.
|
|
58
|
+
*
|
|
59
|
+
* @param args.recordedAtMs when, epoch milliseconds. Nothing here reads a clock, so the same input is the same layer.
|
|
60
|
+
*/
|
|
61
|
+
export declare function markLayer(args: {
|
|
62
|
+
readonly reviewer: string;
|
|
63
|
+
readonly marks: readonly MarkRequest[];
|
|
64
|
+
readonly recordedAtMs: number;
|
|
65
|
+
}): PublishChecksInput;
|
|
66
|
+
/**
|
|
67
|
+
* Publishes a reviewer's marks as one layer through any store, after checking the layer is within what a critic-scoped credential may
|
|
68
|
+
* publish. Returns the stored layer, or `null` when there is nothing to publish. The layer is content-addressed: the same marks at the same time are the same layer.
|
|
69
|
+
*
|
|
70
|
+
* @param store where to publish.
|
|
71
|
+
* @param args see {@link markLayer}.
|
|
72
|
+
* @param options attachment limits, as for `publishChecks`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function publishMarks(store: ObjectStore, args: Parameters<typeof markLayer>[0], options?: PublishChecksOptions): Promise<ReportLayer | null>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { publishChecks } from '../../reports/src/publish-checks.js';
|
|
2
|
+
import { CRITIQUE_MARK_EXTENSION, CRITIQUE_MARK_PRODUCER_PREFIX, CRITIQUE_MARK_SCHEMA_VERSION, CRITIQUE_PART_KIND } from '../../schema/src/critique.js';
|
|
3
|
+
import { criticScopeViolation } from './scope.js';
|
|
4
|
+
/** The prefix of the id of every mark check: `critique-mark/…`. It keeps a mark from ever sharing a slot with a critique or a test. */
|
|
5
|
+
export const MARK_CHECK_PREFIX = 'critique-mark/';
|
|
6
|
+
/**
|
|
7
|
+
* The name a reviewer is recorded under, as a producer suffix: lowercase letters, digits, `-` and `.`. Anything else becomes `-`.
|
|
8
|
+
* Throws for a name with nothing left. It is a name the reviewer chooses, never an account, an address or a login.
|
|
9
|
+
*/
|
|
10
|
+
export function reviewerName(name) {
|
|
11
|
+
const slug = name.trim().toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '');
|
|
12
|
+
if (!slug)
|
|
13
|
+
throw new Error(`"${name}" is not a usable reviewer name: use letters, digits, "-" and "."`);
|
|
14
|
+
return slug;
|
|
15
|
+
}
|
|
16
|
+
/** `critic.review.<reviewer>`: the producer of a reviewer's marks. A critic-scoped token may publish it. */
|
|
17
|
+
export function reviewerProducer(reviewer) {
|
|
18
|
+
return `${CRITIQUE_MARK_PRODUCER_PREFIX}${reviewerName(reviewer)}`;
|
|
19
|
+
}
|
|
20
|
+
/** The id of the check a mark is stored under; it names the entry, the critic and the finding (`*` for the whole critique), so a reviewer's newer mark of the same subject replaces the older. */
|
|
21
|
+
export function markCheckId(request) {
|
|
22
|
+
return `${MARK_CHECK_PREFIX}${[request.entry.key, request.critic.name, request.finding ?? '*'].map(encodeURIComponent).join('/')}`;
|
|
23
|
+
}
|
|
24
|
+
/** The extension a mark is stored as. */
|
|
25
|
+
export function markExtension(request, reviewer) {
|
|
26
|
+
return {
|
|
27
|
+
schema: CRITIQUE_MARK_SCHEMA_VERSION,
|
|
28
|
+
subject: { entryKey: request.entry.key, imageSha256: request.entry.imageSha256 },
|
|
29
|
+
critic: { name: request.critic.name, ...(request.critic.version ? { version: request.critic.version } : {}) },
|
|
30
|
+
...(request.finding ? { finding: request.finding } : {}),
|
|
31
|
+
decision: request.decision,
|
|
32
|
+
reviewer: reviewerName(reviewer),
|
|
33
|
+
...(request.note ? { note: request.note } : {}),
|
|
34
|
+
...(request.layer ? { layer: request.layer } : {})
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The input of `publishChecks` for a reviewer's marks: producer `critic.review.<reviewer>`, part kind `critique`. Each mark is a `passed`
|
|
39
|
+
* check (it was recorded) with an `info` annotation in words, so a viewer that has never heard of marks still shows it.
|
|
40
|
+
*
|
|
41
|
+
* @param args.recordedAtMs when, epoch milliseconds. Nothing here reads a clock, so the same input is the same layer.
|
|
42
|
+
*/
|
|
43
|
+
export function markLayer(args) {
|
|
44
|
+
const reviewer = reviewerName(args.reviewer);
|
|
45
|
+
return {
|
|
46
|
+
producer: reviewerProducer(reviewer), kind: CRITIQUE_PART_KIND, title: `Marks by ${reviewer}`,
|
|
47
|
+
recordedAtMs: args.recordedAtMs, sha: null, branch: null, runUrl: null, parameters: {}, labels: { reviewer },
|
|
48
|
+
checks: args.marks.map(request => ({
|
|
49
|
+
id: markCheckId(request),
|
|
50
|
+
title: `${request.decision === 'accept' ? 'Accepted' : 'Dismissed'} ${request.critic.name}${request.finding ? ` finding ${request.finding}` : ''} on ${request.entry.title}`,
|
|
51
|
+
status: 'passed',
|
|
52
|
+
detail: '',
|
|
53
|
+
measurements: [],
|
|
54
|
+
annotations: [{ type: 'critique-mark', level: 'info', message: `${reviewer} ${request.decision === 'accept' ? 'accepted' : 'dismissed'} ${request.critic.name}'s ${request.finding ? `finding ${request.finding}` : 'opinion'}${request.note ? `: ${request.note}` : ''}` }],
|
|
55
|
+
labels: { critic: request.critic.name, reviewer },
|
|
56
|
+
extensions: { [CRITIQUE_MARK_EXTENSION]: markExtension(request, reviewer) }
|
|
57
|
+
}))
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Publishes a reviewer's marks as one layer through any store, after checking the layer is within what a critic-scoped credential may
|
|
62
|
+
* publish. Returns the stored layer, or `null` when there is nothing to publish. The layer is content-addressed: the same marks at the same time are the same layer.
|
|
63
|
+
*
|
|
64
|
+
* @param store where to publish.
|
|
65
|
+
* @param args see {@link markLayer}.
|
|
66
|
+
* @param options attachment limits, as for `publishChecks`.
|
|
67
|
+
*/
|
|
68
|
+
export async function publishMarks(store, args, options = {}) {
|
|
69
|
+
if (!args.marks.length)
|
|
70
|
+
return null;
|
|
71
|
+
const input = markLayer(args);
|
|
72
|
+
const violation = criticScopeViolation({ producer: input.producer, parts: [{ kind: input.kind }] });
|
|
73
|
+
if (violation)
|
|
74
|
+
throw new Error(violation);
|
|
75
|
+
return publishChecks(store, input, options);
|
|
76
|
+
}
|