@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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Shared parts of the `atlas critic` commands
|
|
3
|
+
*
|
|
4
|
+
* What every `atlas critic` subcommand reads the same way: flags, the server and token (a token is never sent over plain http beyond
|
|
5
|
+
* this machine), the scope that narrows the catalog, the way output is printed, and `judgeEntries`, the one place where entries are
|
|
6
|
+
* judged, read against their state kind and published, so `run` and `watch` cannot disagree about it.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/critics.md#the-atlas-critic-command
|
|
9
|
+
*/
|
|
10
|
+
import type { ObjectStore } from '../../reports/src/object-store.js';
|
|
11
|
+
import type { CatalogEntry, CatalogEntriesQuery } from '../../reports/src/ui-catalog-entries.js';
|
|
12
|
+
import type { CatalogClient } from './catalog-client.js';
|
|
13
|
+
import type { Budget, ProgressEvent, RunResult } from './runner.js';
|
|
14
|
+
import type { JudgeableEntry } from './contract.js';
|
|
15
|
+
import type { CritiqueOutcome } from './critique.js';
|
|
16
|
+
import type { BuiltCritic, LoadedConfig } from './registry.js';
|
|
17
|
+
/** The environment a command reads: a map of names to values. */
|
|
18
|
+
export type Env = Readonly<Record<string, string | undefined>>;
|
|
19
|
+
/** What a command is given besides arguments: where output goes, and, for tests and embedding, the environment, working directory, clock, `fetch`, module loading, a prompt and a signal that ends a long-running command. */
|
|
20
|
+
export type CriticIo = {
|
|
21
|
+
readonly out: (text: string) => void;
|
|
22
|
+
readonly env?: Env;
|
|
23
|
+
readonly cwd?: string;
|
|
24
|
+
readonly clock?: () => number;
|
|
25
|
+
readonly fetch?: typeof fetch;
|
|
26
|
+
readonly importModule?: (name: string) => Promise<unknown>;
|
|
27
|
+
/** Waits between retries of a request to the server. Default `setTimeout`. Tests pass a fake. */
|
|
28
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
29
|
+
/** Whether a person is at a terminal. Default: standard input and output are terminals. Nothing is asked when this is false. */
|
|
30
|
+
readonly interactive?: boolean;
|
|
31
|
+
/** Asks a question and returns the answer. Default: a line from standard input. */
|
|
32
|
+
readonly ask?: (question: string) => Promise<string>;
|
|
33
|
+
/** Ends `atlas critic watch` cleanly (what it finished is kept and published). The terminal wrapper aborts it on interrupt. */
|
|
34
|
+
readonly signal?: AbortSignal;
|
|
35
|
+
};
|
|
36
|
+
/** The flags that take a value, so a value is not mistaken for a positional argument. */
|
|
37
|
+
export declare const VALUE_FLAGS: string[];
|
|
38
|
+
/** Every value given for `--name`, in order. */
|
|
39
|
+
export declare const values: (argv: readonly string[], name: string) => string[];
|
|
40
|
+
/** The first value given for `--name`. */
|
|
41
|
+
export declare const value: (argv: readonly string[], name: string) => string | undefined;
|
|
42
|
+
/** Every value of `--name`, comma-separated values split and trimmed. */
|
|
43
|
+
export declare const list: (argv: readonly string[], name: string) => string[];
|
|
44
|
+
/** Whether `--format json` (or `--json`) was given. */
|
|
45
|
+
export declare const wantsJson: (argv: readonly string[]) => boolean;
|
|
46
|
+
/** The arguments that are not flags or their values. */
|
|
47
|
+
export declare function positionals(argv: readonly string[]): string[];
|
|
48
|
+
/** A positive number from `--name` (a whole number when `whole`), or undefined when it was not given. Throws for anything else. */
|
|
49
|
+
export declare function numberFlag(argv: readonly string[], name: string, whole?: boolean): number | undefined;
|
|
50
|
+
/** Where the server is and how to reach it. */
|
|
51
|
+
export type Connection = {
|
|
52
|
+
url: string;
|
|
53
|
+
token?: string;
|
|
54
|
+
project?: string;
|
|
55
|
+
};
|
|
56
|
+
/** Where the server is and how to reach it, from flags, configuration and the environment; `null` when no server is named. Throws for a token that would travel over plain http beyond this machine. */
|
|
57
|
+
export declare function connection(argv: readonly string[], loaded: LoadedConfig, env: Env): Promise<Connection | null>;
|
|
58
|
+
/** `text` with every secret of six characters or more replaced by `[redacted]`. */
|
|
59
|
+
export declare const redactIn: (text: string, secrets: readonly string[]) => string;
|
|
60
|
+
/** Asks a question on the terminal and returns the line typed. */
|
|
61
|
+
export declare function defaultAsk(question: string): Promise<string>;
|
|
62
|
+
/** Prints one JSON document, indented, with a final newline. */
|
|
63
|
+
export declare const print: (io: CriticIo, document: unknown) => void;
|
|
64
|
+
/** The catalog query and the readable summary of the choices that narrowed it (`--area`, `--profile`, `--state`, `--route`, `--q`, `--since`, `--all`, `--unjudged`). */
|
|
65
|
+
export declare function scopeOf(argv: readonly string[]): {
|
|
66
|
+
query: CatalogEntriesQuery;
|
|
67
|
+
chosen: Record<string, string>;
|
|
68
|
+
};
|
|
69
|
+
/** A catalog entry as a critic is told about it. */
|
|
70
|
+
export declare const toJudgeable: (entry: CatalogEntry) => JudgeableEntry;
|
|
71
|
+
/** The scores a critic's current critiques already give, by entry key with the entry's state kind: the population a new score is read against. */
|
|
72
|
+
export declare function populationOf(entries: readonly CatalogEntry[], critic: string): Map<string, {
|
|
73
|
+
stateKind: string;
|
|
74
|
+
score: number;
|
|
75
|
+
}>;
|
|
76
|
+
/** What judging a set of entries did: the run, the outcomes read against their state kind, and the layer they were published as. */
|
|
77
|
+
export type Judged = {
|
|
78
|
+
readonly result: RunResult;
|
|
79
|
+
readonly outcomes: readonly CritiqueOutcome[];
|
|
80
|
+
readonly layer: string | null;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Judges `entries` with one critic and publishes the outcomes as one layer. A raw score is read against the project's scores of the
|
|
84
|
+
* same state kind (`population`, which is updated with the new scores) and against the entry's previous image. Nothing is published
|
|
85
|
+
* on a dry run or when there is nothing to publish.
|
|
86
|
+
*
|
|
87
|
+
* @param args.population the critic's known scores by entry key (see {@link populationOf}); updated.
|
|
88
|
+
* @param args.store where critiques are published (usually a spooling store).
|
|
89
|
+
*/
|
|
90
|
+
export declare function judgeEntries(args: {
|
|
91
|
+
readonly built: BuiltCritic;
|
|
92
|
+
readonly entries: readonly CatalogEntry[];
|
|
93
|
+
readonly client: Pick<CatalogClient, 'image'>;
|
|
94
|
+
readonly store: ObjectStore;
|
|
95
|
+
readonly population: Map<string, {
|
|
96
|
+
stateKind: string;
|
|
97
|
+
score: number;
|
|
98
|
+
}>;
|
|
99
|
+
readonly dryRun: boolean;
|
|
100
|
+
readonly force?: boolean;
|
|
101
|
+
readonly budget?: Budget;
|
|
102
|
+
readonly secrets: readonly string[];
|
|
103
|
+
readonly concurrency?: number;
|
|
104
|
+
readonly timeoutMs?: number;
|
|
105
|
+
readonly signal?: AbortSignal;
|
|
106
|
+
readonly clock: () => number;
|
|
107
|
+
readonly onProgress?: (event: ProgressEvent) => void;
|
|
108
|
+
}): Promise<Judged>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { resolveCredential } from './credentials.js';
|
|
2
|
+
import { relativeVerdict } from './judging.js';
|
|
3
|
+
import { publishCritiques } from './publish.js';
|
|
4
|
+
import { runCritic } from './runner.js';
|
|
5
|
+
/** The flags that take a value, so a value is not mistaken for a positional argument. */
|
|
6
|
+
export const VALUE_FLAGS = [
|
|
7
|
+
'config', 'server', 'project', 'critic', 'area', 'profile', 'state', 'route', 'q', 'since', 'format', 'max-images', 'max-tokens', 'max-cost', 'concurrency', 'timeout-ms', 'mode', 'threshold', 'calibration', 'spool',
|
|
8
|
+
'max-batches', 'entry', 'finding', 'note', 'reviewer', 'limit', 'good', 'bad', 'heldout-good', 'heldout-bad', 'out', 'critic-version'
|
|
9
|
+
];
|
|
10
|
+
/** Every value given for `--name`, in order. */
|
|
11
|
+
export const values = (argv, name) => argv.flatMap((token, i) => (token === `--${name}` && argv[i + 1] !== undefined ? [argv[i + 1]] : []));
|
|
12
|
+
/** The first value given for `--name`. */
|
|
13
|
+
export const value = (argv, name) => values(argv, name)[0];
|
|
14
|
+
/** Every value of `--name`, comma-separated values split and trimmed. */
|
|
15
|
+
export const list = (argv, name) => values(argv, name).flatMap(item => item.split(',')).map(item => item.trim()).filter(Boolean);
|
|
16
|
+
/** Whether `--format json` (or `--json`) was given. */
|
|
17
|
+
export const wantsJson = (argv) => value(argv, 'format') === 'json' || argv.includes('--json');
|
|
18
|
+
/** The arguments that are not flags or their values. */
|
|
19
|
+
export function positionals(argv) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const token = argv[i];
|
|
23
|
+
if (token.startsWith('--')) {
|
|
24
|
+
if (VALUE_FLAGS.includes(token.slice(2)))
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
out.push(token);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** A positive number from `--name` (a whole number when `whole`), or undefined when it was not given. Throws for anything else. */
|
|
33
|
+
export function numberFlag(argv, name, whole = false) {
|
|
34
|
+
const text = value(argv, name);
|
|
35
|
+
if (text === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
const n = Number(text);
|
|
38
|
+
if (!Number.isFinite(n) || n <= 0 || (whole && !Number.isInteger(n)))
|
|
39
|
+
throw new Error(`--${name} takes a positive ${whole ? 'whole ' : ''}number (got "${text}")`);
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
const isLoopback = (url) => ['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname);
|
|
43
|
+
/** Where the server is and how to reach it, from flags, configuration and the environment; `null` when no server is named. Throws for a token that would travel over plain http beyond this machine. */
|
|
44
|
+
export async function connection(argv, loaded, env) {
|
|
45
|
+
const store = env.ATLAS_STORE && /^https?:\/\//.test(env.ATLAS_STORE) ? env.ATLAS_STORE : undefined;
|
|
46
|
+
const url = value(argv, 'server') ?? loaded.config.server?.url ?? env.ATLAS_SERVER_URL ?? store;
|
|
47
|
+
if (!url)
|
|
48
|
+
return null;
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = new URL(url);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error(`The server address "${url}" is not a URL`);
|
|
55
|
+
}
|
|
56
|
+
const reference = loaded.config.server?.token;
|
|
57
|
+
const token = reference ? await resolveCredential(reference, { env }) : env.ATLAS_CRITIC_TOKEN || env.ATLAS_TOKEN || undefined;
|
|
58
|
+
// A token is a credential: it goes over https, or to this machine.
|
|
59
|
+
if (token && parsed.protocol !== 'https:' && !isLoopback(parsed))
|
|
60
|
+
throw new Error(`Refusing to send a token to ${parsed.host} over plain ${parsed.protocol.replace(':', '')}: use an https address (plain http is allowed only for this machine)`);
|
|
61
|
+
const project = value(argv, 'project') ?? loaded.config.server?.project ?? env.ATLAS_PROJECT;
|
|
62
|
+
return { url: url.replace(/\/+$/, ''), ...(token ? { token } : {}), ...(project ? { project } : {}) };
|
|
63
|
+
}
|
|
64
|
+
/** `text` with every secret of six characters or more replaced by `[redacted]`. */
|
|
65
|
+
export const redactIn = (text, secrets) => secrets.reduce((acc, secret) => (secret.length >= 6 ? acc.split(secret).join('[redacted]') : acc), text);
|
|
66
|
+
/** Asks a question on the terminal and returns the line typed. */
|
|
67
|
+
export async function defaultAsk(question) {
|
|
68
|
+
const readline = await import('node:readline/promises');
|
|
69
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
70
|
+
try {
|
|
71
|
+
return await rl.question(question);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
rl.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Prints one JSON document, indented, with a final newline. */
|
|
78
|
+
export const print = (io, document) => io.out(`${JSON.stringify(document, null, 2)}\n`);
|
|
79
|
+
/** The catalog query and the readable summary of the choices that narrowed it (`--area`, `--profile`, `--state`, `--route`, `--q`, `--since`, `--all`, `--unjudged`). */
|
|
80
|
+
export function scopeOf(argv) {
|
|
81
|
+
const chosen = {};
|
|
82
|
+
const query = {};
|
|
83
|
+
for (const name of ['area', 'profile', 'state', 'route']) {
|
|
84
|
+
const found = list(argv, name);
|
|
85
|
+
if (found.length) {
|
|
86
|
+
query[name] = found;
|
|
87
|
+
chosen[name] = found.join(',');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const q = value(argv, 'q');
|
|
91
|
+
if (q) {
|
|
92
|
+
query.q = q;
|
|
93
|
+
chosen.q = q;
|
|
94
|
+
}
|
|
95
|
+
const since = value(argv, 'since');
|
|
96
|
+
if (since) {
|
|
97
|
+
query.since = since;
|
|
98
|
+
chosen.since = since;
|
|
99
|
+
}
|
|
100
|
+
if (argv.includes('--all'))
|
|
101
|
+
chosen.all = 'true';
|
|
102
|
+
if (argv.includes('--unjudged'))
|
|
103
|
+
chosen.unjudged = 'true';
|
|
104
|
+
return { query: query, chosen };
|
|
105
|
+
}
|
|
106
|
+
/** A catalog entry as a critic is told about it. */
|
|
107
|
+
export const toJudgeable = (entry) => ({
|
|
108
|
+
key: entry.key, title: entry.title, state: entry.state, imageSha256: entry.image.sha256,
|
|
109
|
+
...(entry.route ? { route: entry.route } : {}), profile: { key: entry.profile.key, environment: entry.profile.environment, parameters: entry.profile.parameters }
|
|
110
|
+
});
|
|
111
|
+
/** The scores a critic's current critiques already give, by entry key with the entry's state kind: the population a new score is read against. */
|
|
112
|
+
export function populationOf(entries, critic) {
|
|
113
|
+
const population = new Map();
|
|
114
|
+
for (const entry of entries)
|
|
115
|
+
for (const found of entry.critiques)
|
|
116
|
+
if (found.currency === 'current' && found.critique.critic.name === critic && found.critique.verdict?.score !== undefined)
|
|
117
|
+
population.set(entry.key, { stateKind: entry.state, score: found.critique.verdict.score });
|
|
118
|
+
return population;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Judges `entries` with one critic and publishes the outcomes as one layer. A raw score is read against the project's scores of the
|
|
122
|
+
* same state kind (`population`, which is updated with the new scores) and against the entry's previous image. Nothing is published
|
|
123
|
+
* on a dry run or when there is nothing to publish.
|
|
124
|
+
*
|
|
125
|
+
* @param args.population the critic's known scores by entry key (see {@link populationOf}); updated.
|
|
126
|
+
* @param args.store where critiques are published (usually a spooling store).
|
|
127
|
+
*/
|
|
128
|
+
export async function judgeEntries(args) {
|
|
129
|
+
const { built } = args;
|
|
130
|
+
const name = built.critic.name;
|
|
131
|
+
const existing = args.entries.flatMap(entry => entry.critiques.map(found => found.critique));
|
|
132
|
+
const result = await runCritic({
|
|
133
|
+
critic: built.critic, entries: args.entries.map(toJudgeable), existing, dryRun: args.dryRun, ...(args.force ? { force: true } : {}),
|
|
134
|
+
loadImage: judgeable => args.client.image(args.entries.find(entry => entry.key === judgeable.key)),
|
|
135
|
+
...(args.budget ? { budget: args.budget } : {}), secrets: args.secrets,
|
|
136
|
+
...(args.concurrency ? { concurrency: args.concurrency } : {}), ...(args.timeoutMs ? { timeoutMs: args.timeoutMs } : {}),
|
|
137
|
+
...(args.signal ? { signal: args.signal } : {}), ...(args.onProgress ? { onProgress: args.onProgress } : {})
|
|
138
|
+
});
|
|
139
|
+
for (const outcome of result.outcomes)
|
|
140
|
+
if (outcome.critique.verdict?.score !== undefined)
|
|
141
|
+
args.population.set(outcome.entry.key, { stateKind: outcome.entry.state, score: outcome.critique.verdict.score });
|
|
142
|
+
const outcomes = result.outcomes.map(outcome => {
|
|
143
|
+
const score = outcome.critique.verdict?.score;
|
|
144
|
+
if (score === undefined || outcome.critique.verdict?.relative)
|
|
145
|
+
return outcome;
|
|
146
|
+
const entry = args.entries.find(candidate => candidate.key === outcome.entry.key);
|
|
147
|
+
const previous = entry?.critiques.find(found => found.currency === 'earlier-image' && found.critique.critic.name === name && found.critique.verdict?.score !== undefined)?.critique.verdict?.score;
|
|
148
|
+
const relative = relativeVerdict({ score, stateKind: outcome.entry.state, population: [...args.population.values()], ...(previous === undefined ? {} : { previousScore: previous }) });
|
|
149
|
+
return { ...outcome, critique: { ...outcome.critique, verdict: { ...outcome.critique.verdict, relative } } };
|
|
150
|
+
});
|
|
151
|
+
let layer = null;
|
|
152
|
+
if (!args.dryRun && outcomes.length)
|
|
153
|
+
layer = (await publishCritiques(args.store, { critic: built.critic, outcomes, recordedAtMs: args.clock() }))?.id ?? null;
|
|
154
|
+
return { result, outcomes, layer };
|
|
155
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module `atlas critic review`
|
|
3
|
+
*
|
|
4
|
+
* Reads the ranked "needs polish" list from the server and lets a person mark what critics found: accept an opinion (it is right) or
|
|
5
|
+
* dismiss it (it is not), as a whole or finding by finding. Without `--entry` it lists the entries one critic judged, lowest first, with
|
|
6
|
+
* their findings and the marks already made; at a terminal it then walks through them and asks. With `--entry` and `--accept` or
|
|
7
|
+
* `--dismiss` it records one decision, with no terminal needed. A mark is published as a layer under `critic.review.<reviewer>` (see
|
|
8
|
+
* {@link ./marks}); it needs no more power than a critic's token has, is never counted, and never changes a status, a slot or the gate.
|
|
9
|
+
* Nothing is marked when a flag names something that is not there: the command says what is.
|
|
10
|
+
*
|
|
11
|
+
* @see docs/critics.md#reviewing-and-marking
|
|
12
|
+
*/
|
|
13
|
+
import { httpStore } from '../../reports/src/http-store.js';
|
|
14
|
+
import { markStanding, rankCritiques } from '../../reports/src/critique-review.js';
|
|
15
|
+
import { createCatalogClient } from './catalog-client.js';
|
|
16
|
+
import { connection, defaultAsk, list, numberFlag, print, scopeOf, value, values, wantsJson } from './cli-common.js';
|
|
17
|
+
import { publishMarks, reviewerName } from './marks.js';
|
|
18
|
+
const DEFAULT_LIMIT = 20;
|
|
19
|
+
const standingJson = (standing) => ({ state: standing.state, accepted: [...standing.accepted], dismissed: [...standing.dismissed], earlier: standing.earlier, notes: [...standing.notes] });
|
|
20
|
+
/** The critics that have a critique of any of the entries, most critiques first. */
|
|
21
|
+
function criticsOf(entries) {
|
|
22
|
+
const tally = new Map();
|
|
23
|
+
for (const entry of entries)
|
|
24
|
+
for (const found of entry.critiques)
|
|
25
|
+
tally.set(found.critique.critic.name, (tally.get(found.critique.critic.name) ?? 0) + 1);
|
|
26
|
+
return [...tally].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([name]) => name);
|
|
27
|
+
}
|
|
28
|
+
function describeEntry(entry, found, rank) {
|
|
29
|
+
const { critique } = found;
|
|
30
|
+
return {
|
|
31
|
+
rank, key: entry.key, title: entry.title, state: entry.state, imageSha256: entry.image.sha256,
|
|
32
|
+
score: critique.verdict?.score ?? null, percentile: critique.verdict?.relative?.percentile ?? null, label: critique.verdict?.label ?? null,
|
|
33
|
+
currency: found.currency,
|
|
34
|
+
...(critique.failure ? { failure: critique.failure.reason } : {}),
|
|
35
|
+
marks: standingJson(markStanding(entry.marks, { critic: critique.critic.name })),
|
|
36
|
+
findings: (critique.findings ?? []).map(finding => ({
|
|
37
|
+
id: finding.id ?? null, category: finding.category ?? null, severity: finding.severity ?? null,
|
|
38
|
+
...(finding.region ? { region: { unit: finding.region.unit, x: finding.region.x, y: finding.region.y, w: finding.region.w, h: finding.region.h } } : {}),
|
|
39
|
+
rationale: finding.rationale ?? null, suggestion: finding.suggestion ?? null,
|
|
40
|
+
marks: finding.id ? standingJson(markStanding(entry.marks, { critic: critique.critic.name, finding: finding.id })) : null
|
|
41
|
+
}))
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Looks up what a `--entry` flag names and which critique of it is being marked. Throws, saying what there is, when it is not there. */
|
|
45
|
+
function targetOf(entries, key, criticFlag) {
|
|
46
|
+
const entry = entries.find(candidate => candidate.key === key);
|
|
47
|
+
if (!entry)
|
|
48
|
+
throw new Error(`No entry "${key}" in the catalog. List them with \`atlas critic review\` (or \`atlas critic run --dry-run\`)`);
|
|
49
|
+
const named = [...new Set(entry.critiques.map(found => found.critique.critic.name))];
|
|
50
|
+
const criticName = criticFlag ?? (named.length === 1 ? named[0] : undefined);
|
|
51
|
+
if (!criticName)
|
|
52
|
+
throw new Error(named.length ? `${key} has critiques from ${named.join(', ')}: say whose opinion with --critic <name>` : `${key} has no critique to mark yet: run a critic on it first`);
|
|
53
|
+
const mine = entry.critiques.filter(found => found.critique.critic.name === criticName);
|
|
54
|
+
if (!mine.length)
|
|
55
|
+
throw new Error(`${criticName} has not judged ${key}${named.length ? ` (judged by ${named.join(', ')})` : ''}`);
|
|
56
|
+
const found = mine.find(candidate => candidate.currency === 'current');
|
|
57
|
+
if (!found)
|
|
58
|
+
throw new Error(`${criticName}'s critique of ${key} judged an earlier picture; the picture has changed. Run the critic again (\`atlas critic run --critic ${criticName} --all\`) and mark the new opinion`);
|
|
59
|
+
if (found.critique.failure)
|
|
60
|
+
throw new Error(`${criticName} could not judge ${key} (${found.critique.failure.reason}); there is no opinion to mark`);
|
|
61
|
+
return { entry, found };
|
|
62
|
+
}
|
|
63
|
+
/** `atlas critic review`. */
|
|
64
|
+
export async function reviewCommand(argv, io, loaded) {
|
|
65
|
+
const env = io.env ?? process.env;
|
|
66
|
+
const json = wantsJson(argv);
|
|
67
|
+
const interactive = !argv.includes('--non-interactive') && !argv.includes('--list') && !json && (io.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
68
|
+
const ask = io.ask ?? defaultAsk;
|
|
69
|
+
const dryRun = argv.includes('--dry-run');
|
|
70
|
+
const server = await connection(argv, loaded, env);
|
|
71
|
+
if (!server)
|
|
72
|
+
throw new Error('review needs --server <url> (or "server.url" in atlas.critics.json, or ATLAS_SERVER_URL)');
|
|
73
|
+
const accept = argv.includes('--accept'), dismiss = argv.includes('--dismiss');
|
|
74
|
+
if (accept && dismiss)
|
|
75
|
+
throw new Error('--accept and --dismiss are opposites: give one');
|
|
76
|
+
const entryKey = value(argv, 'entry');
|
|
77
|
+
if ((accept || dismiss) && !entryKey)
|
|
78
|
+
throw new Error(`--${accept ? 'accept' : 'dismiss'} needs --entry <key>, the entry whose opinion you are marking (list them with \`atlas critic review\`)`);
|
|
79
|
+
if (!accept && !dismiss && (values(argv, 'finding').length || value(argv, 'note')))
|
|
80
|
+
throw new Error('--finding and --note go with --accept or --dismiss');
|
|
81
|
+
const reviewer = reviewerName(value(argv, 'reviewer') ?? env.ATLAS_REVIEWER ?? 'reviewer');
|
|
82
|
+
const { query } = scopeOf(argv);
|
|
83
|
+
const client = createCatalogClient({ url: server.url, ...(server.token ? { token: server.token } : {}), ...(server.project ? { project: server.project } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), ...(io.sleep ? { sleep: io.sleep } : {}) });
|
|
84
|
+
const read = await client.read(query);
|
|
85
|
+
const clock = io.clock ?? Date.now;
|
|
86
|
+
const store = httpStore({ url: server.url, ...(server.token ? { token: server.token } : {}), ...(server.project ? { project: server.project } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), ...(io.sleep ? { sleep: io.sleep } : {}) });
|
|
87
|
+
const marked = [];
|
|
88
|
+
let layer = null;
|
|
89
|
+
const record = async (requests) => {
|
|
90
|
+
for (const request of requests)
|
|
91
|
+
marked.push({ entry: request.entry.key, critic: request.critic.name, finding: request.finding ?? null, decision: request.decision });
|
|
92
|
+
if (!dryRun && requests.length)
|
|
93
|
+
layer = (await publishMarks(store, { reviewer, marks: requests, recordedAtMs: clock() }))?.id ?? null;
|
|
94
|
+
};
|
|
95
|
+
const criticFlag = value(argv, 'critic');
|
|
96
|
+
let targetCritic;
|
|
97
|
+
if (criticFlag && !criticsOf(read.entries).includes(criticFlag) && !entryKey)
|
|
98
|
+
throw new Error(`No entry has a critique from "${criticFlag}". Critics with opinions: ${criticsOf(read.entries).join(', ') || 'none yet'}`);
|
|
99
|
+
// One decision, by flag: no terminal needed.
|
|
100
|
+
if (entryKey && (accept || dismiss)) {
|
|
101
|
+
const { entry, found } = targetOf(read.entries, entryKey, criticFlag);
|
|
102
|
+
targetCritic = found.critique.critic.name;
|
|
103
|
+
const decision = accept ? 'accept' : 'dismiss';
|
|
104
|
+
const ids = list(argv, 'finding');
|
|
105
|
+
const known = (found.critique.findings ?? []).map(finding => finding.id).filter((id) => Boolean(id));
|
|
106
|
+
for (const id of ids)
|
|
107
|
+
if (!known.includes(id))
|
|
108
|
+
throw new Error(`${found.critique.critic.name}'s critique of ${entryKey} has no finding "${id}". ${known.length ? `Its findings: ${known.join(', ')}` : 'It has no findings with an id: mark the whole opinion by leaving out --finding'}`);
|
|
109
|
+
const note = value(argv, 'note');
|
|
110
|
+
const base = { entry: { key: entry.key, title: entry.title, imageSha256: entry.image.sha256 }, critic: { name: found.critique.critic.name, version: found.critique.critic.version }, decision, layer: found.layer, ...(note ? { note } : {}) };
|
|
111
|
+
await record(ids.length ? ids.map(id => ({ ...base, finding: id })) : [base]);
|
|
112
|
+
}
|
|
113
|
+
const critics = criticsOf(read.entries);
|
|
114
|
+
const critic = criticFlag ?? targetCritic ?? (entryKey ? read.entries.find(entry => entry.key === entryKey)?.critiques[0]?.critique.critic.name : undefined) ?? critics[0] ?? null;
|
|
115
|
+
const ranking = rankCritiques(read.entries, critic, value(argv, 'state'));
|
|
116
|
+
const limit = numberFlag(argv, 'limit', true) ?? DEFAULT_LIMIT;
|
|
117
|
+
const everything = [...ranking.ranked.map((item, index) => describeEntry(item.entry, item.found, index + 1)), ...ranking.unranked.map(item => describeEntry(item.entry, item.found, null))];
|
|
118
|
+
// One entry when one was named (so its findings and marks can be read before or after marking), else the top of the list.
|
|
119
|
+
const shown = entryKey ? everything.filter(item => item.key === entryKey) : everything.slice(0, limit);
|
|
120
|
+
// Walk the list and ask, at a terminal.
|
|
121
|
+
if (!entryKey && interactive && critic) {
|
|
122
|
+
const requests = [];
|
|
123
|
+
walk: for (const item of ranking.ranked.slice(0, limit)) {
|
|
124
|
+
const { critique } = item.found;
|
|
125
|
+
io.out(`\n${item.entry.title} (${item.entry.key}) — ${item.entry.state}, score ${critique.verdict?.score ?? 'none'}${critique.verdict?.label ? `, ${critique.verdict.label}` : ''}\n`);
|
|
126
|
+
const base = { entry: { key: item.entry.key, title: item.entry.title, imageSha256: item.entry.image.sha256 }, critic: { name: critique.critic.name, version: critique.critic.version }, layer: item.found.layer };
|
|
127
|
+
const targets = (critique.findings ?? []).length
|
|
128
|
+
? (critique.findings ?? []).filter(finding => finding.id).map(finding => ({ finding: finding.id, text: `${finding.category ?? 'finding'} (${finding.severity ?? 'no severity'}): ${finding.rationale ?? ''}` }))
|
|
129
|
+
: [{ finding: undefined, text: 'the opinion as a whole' }];
|
|
130
|
+
for (const target of targets) {
|
|
131
|
+
const answer = (await ask(` ${target.text}\n [a]ccept, [d]ismiss, [s]kip, [q]uit? `)).trim().toLowerCase();
|
|
132
|
+
if (answer.startsWith('q'))
|
|
133
|
+
break walk;
|
|
134
|
+
if (answer.startsWith('a') || answer.startsWith('d'))
|
|
135
|
+
requests.push({ ...base, ...(target.finding ? { finding: target.finding } : {}), decision: answer.startsWith('a') ? 'accept' : 'dismiss' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await record(requests);
|
|
139
|
+
}
|
|
140
|
+
const document = {
|
|
141
|
+
ok: true, server: server.url, critic, critics, reviewer,
|
|
142
|
+
rankedBy: ranking.rankedBy,
|
|
143
|
+
summary: { ranked: ranking.ranked.length, unranked: ranking.unranked.length, unjudged: ranking.unjudged },
|
|
144
|
+
entries: shown,
|
|
145
|
+
...(marked.length || accept || dismiss ? { marked: { dryRun, reviewer, layer, marks: marked } } : {})
|
|
146
|
+
};
|
|
147
|
+
if (json) {
|
|
148
|
+
print(io, document);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
if (marked.length) {
|
|
152
|
+
io.out(`${dryRun ? 'Would mark' : 'Marked'} ${marked.length} ${marked.length === 1 ? 'opinion' : 'opinions'} as ${reviewer}:\n`);
|
|
153
|
+
for (const mark of marked)
|
|
154
|
+
io.out(` ${mark.decision === 'accept' ? 'accepted' : 'dismissed'} ${mark.critic} on ${mark.entry}${mark.finding ? ` (${mark.finding})` : ''}\n`);
|
|
155
|
+
if (layer)
|
|
156
|
+
io.out(`Published as layer ${layer.slice(0, 12)}\n`);
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
if (!critic) {
|
|
160
|
+
io.out('No critic has judged a captured state yet. Run `atlas critic run --critic <name> --all` first.\n');
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
io.out(`Needs polish, by ${critic} (${ranking.rankedBy === 'standing-within-kind' ? 'lowest standing within its state kind first' : 'lowest score first'}):\n`);
|
|
164
|
+
for (const entry of shown) {
|
|
165
|
+
const marksText = entry.marks.state === 'unmarked' ? '' : ` [${entry.marks.state}]`;
|
|
166
|
+
io.out(` ${entry.rank === null ? '-' : String(entry.rank).padStart(2)}. ${entry.title} (${entry.key}) ${entry.state} score ${entry.score ?? 'none'}${entry.percentile === null ? '' : `, ${Math.round(entry.percentile)}th percentile in kind`}${entry.currency === 'earlier-image' ? ' — judged an earlier picture' : ''}${entry.failure ? ` — could not judge (${entry.failure})` : ''}${marksText}\n`);
|
|
167
|
+
for (const finding of entry.findings)
|
|
168
|
+
io.out(` ${finding.id ?? '?'}: ${finding.category ?? 'finding'} (${finding.severity ?? 'no severity'}) ${finding.rationale ?? ''}${finding.marks && finding.marks.state !== 'unmarked' ? ` [${finding.marks.state}]` : ''}\n`);
|
|
169
|
+
}
|
|
170
|
+
if (ranking.unjudged)
|
|
171
|
+
io.out(`${ranking.unjudged} not judged yet by ${critic}.\n`);
|
|
172
|
+
io.out('Mark one with `atlas critic review --entry <key> --accept|--dismiss [--finding <id>] [--note <why>]`.\n');
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module `atlas critic watch`
|
|
3
|
+
*
|
|
4
|
+
* A worker: follows the catalog of an Atlas server and judges new or changed entries as they arrive, until it is stopped. It is
|
|
5
|
+
* `atlas critic run` in a loop over `createCatalogClient(…).follow`, so it catches up on connecting, reads again after every
|
|
6
|
+
* announcement and every reconnection, misses no entry and judges none twice (a picture a critic has judged is skipped by the cache
|
|
7
|
+
* without a call). What it finished is published as it goes, kept in the spool when the server goes away, and sent by the next
|
|
8
|
+
* invocation. Budgets are for the whole watch. It reports; it never gates.
|
|
9
|
+
*
|
|
10
|
+
* It ends when interrupted, when `--max-batches` batches have been handled, or when a budget is spent, and then prints what it did
|
|
11
|
+
* and the report version to resume from (`--since`). The server never runs a model: the critic runs here.
|
|
12
|
+
*
|
|
13
|
+
* @see docs/critics.md#watching-the-catalog
|
|
14
|
+
*/
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { httpStore } from '../../reports/src/http-store.js';
|
|
17
|
+
import { createCatalogClient } from './catalog-client.js';
|
|
18
|
+
import { connection, defaultAsk, judgeEntries, list, numberFlag, populationOf, print, redactIn, scopeOf, value, wantsJson } from './cli-common.js';
|
|
19
|
+
import { buildCritic } from './registry.js';
|
|
20
|
+
import { flushSpool, pendingInSpool, spoolingStore } from './spool.js';
|
|
21
|
+
/** `atlas critic watch`. */
|
|
22
|
+
export async function watchCommand(argv, io, loaded) {
|
|
23
|
+
const env = io.env ?? process.env;
|
|
24
|
+
const json = wantsJson(argv);
|
|
25
|
+
const interactive = !argv.includes('--non-interactive') && !json && (io.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
26
|
+
const ask = io.ask ?? defaultAsk;
|
|
27
|
+
const dryRun = argv.includes('--dry-run');
|
|
28
|
+
const clock = io.clock ?? Date.now;
|
|
29
|
+
let names = list(argv, 'critic');
|
|
30
|
+
let target = await connection(argv, loaded, env);
|
|
31
|
+
const missing = () => [...(names.length ? [] : ['--critic <name>']), ...(target ? [] : ['--server <url>'])];
|
|
32
|
+
if (missing().length && interactive) {
|
|
33
|
+
if (!names.length) {
|
|
34
|
+
const configured = Object.keys(loaded.config.critics);
|
|
35
|
+
names = (await ask(`Which critic${configured.length ? ` (${configured.join(', ')})` : ''}? `)).split(/[,\s]+/).filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
if (!target) {
|
|
38
|
+
const url = (await ask('Atlas server address? ')).trim();
|
|
39
|
+
if (url)
|
|
40
|
+
target = { url: url.replace(/\/+$/, ''), ...(env.ATLAS_CRITIC_TOKEN || env.ATLAS_TOKEN ? { token: (env.ATLAS_CRITIC_TOKEN || env.ATLAS_TOKEN) } : {}) };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (missing().length)
|
|
44
|
+
throw new Error(`watch needs ${missing().join(', ')}. Nothing was asked: ${interactive ? 'an answer was empty' : 'there is no terminal (or --non-interactive / --format json was given)'}`);
|
|
45
|
+
const server = target;
|
|
46
|
+
const { query: scope, chosen } = scopeOf(argv);
|
|
47
|
+
const { since: _since, ...filters } = scope;
|
|
48
|
+
const query = filters;
|
|
49
|
+
const since = value(argv, 'since');
|
|
50
|
+
const maxBatches = numberFlag(argv, 'max-batches', true);
|
|
51
|
+
const total = { images: numberFlag(argv, 'max-images', true), tokens: numberFlag(argv, 'max-tokens', true), costUsd: numberFlag(argv, 'max-cost') };
|
|
52
|
+
const concurrency = numberFlag(argv, 'concurrency', true), timeoutMs = numberFlag(argv, 'timeout-ms', true);
|
|
53
|
+
const builts = [];
|
|
54
|
+
for (const name of names)
|
|
55
|
+
builts.push(await buildCritic(loaded, name, io));
|
|
56
|
+
const secrets = builts.flatMap(built => built.secrets);
|
|
57
|
+
// Images that leave the machine need a yes, once, before the worker starts: it cannot ask later, and it never sends silently.
|
|
58
|
+
for (const built of builts) {
|
|
59
|
+
const destination = built.critic.describe().sendsImagesTo;
|
|
60
|
+
if (destination === null || dryRun || argv.includes('--yes'))
|
|
61
|
+
continue;
|
|
62
|
+
if (!interactive)
|
|
63
|
+
throw new Error(`${built.critic.name} sends images to ${destination}: pass --yes to allow it (nothing was sent)`);
|
|
64
|
+
const answer = await ask(`${built.critic.name} will send the picture of every new or changed entry to ${destination}, for as long as this runs. Continue? [y/N] `);
|
|
65
|
+
if (!/^y(es)?$/i.test(answer.trim()))
|
|
66
|
+
throw new Error(`Stopped: ${built.critic.name} would send images to ${destination} and that was not confirmed (nothing was sent)`);
|
|
67
|
+
}
|
|
68
|
+
if (!dryRun)
|
|
69
|
+
for (const built of builts)
|
|
70
|
+
await built.ready();
|
|
71
|
+
const transport = { url: server.url, ...(server.token ? { token: server.token } : {}), ...(server.project ? { project: server.project } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), ...(io.sleep ? { sleep: io.sleep } : {}) };
|
|
72
|
+
const client = createCatalogClient(transport);
|
|
73
|
+
const spoolDir = path.resolve(loaded.directory, value(argv, 'spool') ?? loaded.config.spool ?? '.atlas/critic-spool');
|
|
74
|
+
const base = httpStore(transport);
|
|
75
|
+
const warnings = [];
|
|
76
|
+
let flushed = 0;
|
|
77
|
+
if (!dryRun) {
|
|
78
|
+
const sent = await flushSpool(base, spoolDir);
|
|
79
|
+
flushed = sent.sent;
|
|
80
|
+
if (sent.error)
|
|
81
|
+
warnings.push(`${sent.remaining} spooled object${sent.remaining === 1 ? '' : 's'} could not be sent yet: ${redactIn(sent.error, secrets)}`);
|
|
82
|
+
}
|
|
83
|
+
const store = spoolingStore(base, spoolDir);
|
|
84
|
+
// A score is read against the project's scores of its state kind: the whole catalog is read once when the watch is narrowed or resumed.
|
|
85
|
+
const populations = new Map(builts.map(built => [built.critic.name, new Map()]));
|
|
86
|
+
const seed = async () => {
|
|
87
|
+
const whole = (await client.read({})).entries;
|
|
88
|
+
for (const built of builts)
|
|
89
|
+
for (const [key, item] of populationOf(whole, built.critic.name))
|
|
90
|
+
populations.get(built.critic.name).set(key, item);
|
|
91
|
+
};
|
|
92
|
+
if (Object.keys(filters).length > 0 || since)
|
|
93
|
+
await seed();
|
|
94
|
+
const tallies = new Map(builts.map(built => [built.critic.name, { entries: 0, judged: 0, skipped: 0, failed: 0, spent: { images: 0, tokens: 0, costUsd: 0 }, layers: 0, critiques: 0, failures: [] }]));
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
if (io.signal?.aborted)
|
|
97
|
+
controller.abort();
|
|
98
|
+
else
|
|
99
|
+
io.signal?.addEventListener('abort', () => controller.abort(), { once: true });
|
|
100
|
+
let stoppedBy = 'interrupted';
|
|
101
|
+
let version = since ?? null;
|
|
102
|
+
let batches = 0;
|
|
103
|
+
const spentTotal = () => [...tallies.values()].reduce((sum, tally) => ({ images: sum.images + tally.spent.images, tokens: sum.tokens + tally.spent.tokens, costUsd: sum.costUsd + tally.spent.costUsd }), { images: 0, tokens: 0, costUsd: 0 });
|
|
104
|
+
const exhausted = () => { const spent = spentTotal(); return (total.images !== undefined && spent.images >= total.images) || (total.tokens !== undefined && spent.tokens >= total.tokens) || (total.costUsd !== undefined && spent.costUsd >= total.costUsd); };
|
|
105
|
+
if (!json)
|
|
106
|
+
io.out(dryRun ? 'Reading the catalog once (dry run: nothing is sent or published)\n' : `Watching ${server.url} with ${builts.map(built => `${built.critic.name}@${built.critic.version}`).join(', ')}. Press Ctrl-C to stop.\n`);
|
|
107
|
+
for await (const batch of client.follow({ ...(since ? { since } : {}), query, signal: controller.signal })) {
|
|
108
|
+
version = batch.version;
|
|
109
|
+
batches += 1;
|
|
110
|
+
const lines = [];
|
|
111
|
+
for (const built of builts) {
|
|
112
|
+
const name = built.critic.name;
|
|
113
|
+
const tally = tallies.get(name);
|
|
114
|
+
const population = populations.get(name);
|
|
115
|
+
for (const [key, item] of populationOf(batch.entries, name))
|
|
116
|
+
population.set(key, item);
|
|
117
|
+
const spent = spentTotal();
|
|
118
|
+
const budget = {
|
|
119
|
+
...(total.images === undefined ? {} : { maxImages: Math.max(0, total.images - spent.images) }),
|
|
120
|
+
...(total.tokens === undefined ? {} : { maxTokens: Math.max(0, total.tokens - spent.tokens) }),
|
|
121
|
+
...(total.costUsd === undefined ? {} : { maxCostUsd: Math.max(0, total.costUsd - spent.costUsd) })
|
|
122
|
+
};
|
|
123
|
+
if (!batch.entries.length || (Object.keys(budget).length && Object.values(budget).some(left => left <= 0)))
|
|
124
|
+
continue;
|
|
125
|
+
const { result, outcomes, layer } = await judgeEntries({
|
|
126
|
+
built, entries: batch.entries, client, store, population, dryRun, budget, secrets, clock, signal: controller.signal,
|
|
127
|
+
...(concurrency ? { concurrency } : {}), ...(timeoutMs ? { timeoutMs } : {})
|
|
128
|
+
});
|
|
129
|
+
const run = result;
|
|
130
|
+
tally.entries += batch.entries.length;
|
|
131
|
+
tally.judged += run.judged;
|
|
132
|
+
tally.skipped += run.skipped;
|
|
133
|
+
tally.failed += run.failed;
|
|
134
|
+
tally.spent.images += run.spent.images;
|
|
135
|
+
tally.spent.tokens += run.spent.tokens;
|
|
136
|
+
tally.spent.costUsd += run.spent.costUsd;
|
|
137
|
+
if (layer) {
|
|
138
|
+
tally.layers += 1;
|
|
139
|
+
tally.critiques += outcomes.length;
|
|
140
|
+
}
|
|
141
|
+
for (const outcome of outcomes)
|
|
142
|
+
if (outcome.critique.failure)
|
|
143
|
+
tally.failures.push({ entry: outcome.entry.key, reason: outcome.critique.failure.reason });
|
|
144
|
+
lines.push(`${name}: ${batch.entries.length} ${batch.entries.length === 1 ? 'entry' : 'entries'}, ${run.judged} judged, ${run.skipped} skipped, ${run.failed} failed`);
|
|
145
|
+
}
|
|
146
|
+
if (!json && lines.length)
|
|
147
|
+
io.out(`${dryRun ? 'Would handle' : 'Handled'} ${batch.initial ? 'the catalog' : 'what arrived'} at version ${batch.version.slice(0, 12)}: ${lines.join('; ')}\n`);
|
|
148
|
+
if (dryRun) {
|
|
149
|
+
stoppedBy = 'dry-run';
|
|
150
|
+
controller.abort();
|
|
151
|
+
}
|
|
152
|
+
else if (exhausted()) {
|
|
153
|
+
stoppedBy = 'budget';
|
|
154
|
+
controller.abort();
|
|
155
|
+
}
|
|
156
|
+
else if (maxBatches !== undefined && batches >= maxBatches) {
|
|
157
|
+
stoppedBy = 'max-batches';
|
|
158
|
+
controller.abort();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const pending = await pendingInSpool(spoolDir);
|
|
162
|
+
if (store.spooled() > 0)
|
|
163
|
+
warnings.push(`The server could not be reached: ${store.spooled()} object${store.spooled() === 1 ? '' : 's'} of critiques were kept in ${path.relative(io.cwd ?? process.cwd(), spoolDir) || spoolDir} and will be sent by the next invocation`);
|
|
164
|
+
const failed = [...tallies.values()].some(tally => tally.failed > 0);
|
|
165
|
+
const ok = !failed;
|
|
166
|
+
const document = {
|
|
167
|
+
ok, dryRun, server: server.url, scope: chosen, stoppedBy, batches, version,
|
|
168
|
+
critics: builts.map(built => {
|
|
169
|
+
const tally = tallies.get(built.critic.name);
|
|
170
|
+
return {
|
|
171
|
+
critic: built.critic.name, version: built.critic.version, sendsImagesTo: built.critic.describe().sendsImagesTo,
|
|
172
|
+
entries: tally.entries, judged: tally.judged, skipped: tally.skipped, failed: tally.failed, failures: tally.failures, spent: tally.spent,
|
|
173
|
+
published: { layers: tally.layers, critiques: tally.critiques }
|
|
174
|
+
};
|
|
175
|
+
}),
|
|
176
|
+
spool: { flushed, spooled: store.spooled(), pending },
|
|
177
|
+
warnings: warnings.map(warning => redactIn(warning, secrets))
|
|
178
|
+
};
|
|
179
|
+
if (json) {
|
|
180
|
+
print(io, document);
|
|
181
|
+
return ok ? 0 : 1;
|
|
182
|
+
}
|
|
183
|
+
io.out(`Stopped (${stoppedBy === 'interrupted' ? 'interrupted' : stoppedBy === 'budget' ? 'the budget was reached' : stoppedBy === 'max-batches' ? `${batches} ${batches === 1 ? 'batch' : 'batches'} handled` : 'dry run'}) after ${batches} ${batches === 1 ? 'batch' : 'batches'}.\n`);
|
|
184
|
+
for (const item of document.critics)
|
|
185
|
+
io.out(` ${item.critic}@${item.version}: ${item.judged} judged, ${item.skipped} skipped, ${item.failed} failed; published ${item.published.critiques} ${item.published.critiques === 1 ? 'critique' : 'critiques'} in ${item.published.layers} ${item.published.layers === 1 ? 'layer' : 'layers'}\n`);
|
|
186
|
+
if (version && !dryRun)
|
|
187
|
+
io.out(`Resume with --since ${version}\n`);
|
|
188
|
+
for (const warning of document.warnings)
|
|
189
|
+
io.out(`! ${warning}\n`);
|
|
190
|
+
return ok ? 0 : 1;
|
|
191
|
+
}
|