@moda-ai/cli 1.18.0 → 1.18.1
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/dist/cli.js +1 -0
- package/dist/prompts-ab.d.ts +53 -0
- package/dist/prompts-ab.js +80 -10
- package/dist/prompts-propose.d.ts +8 -3
- package/dist/prompts-propose.js +145 -9
- package/dist/schemas.d.ts +2 -2
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -177,6 +177,7 @@ Commands:
|
|
|
177
177
|
prompts sync --watch Upload again when prompt files change
|
|
178
178
|
prompts promote <key> Move a dev/staging/prod label
|
|
179
179
|
prompts ab Run a judged prompt A/B replay experiment
|
|
180
|
+
prompts propose Revise a prompt from a run's failures (--gate to auto-A/B, --promote-on-win)
|
|
180
181
|
|
|
181
182
|
Skills:
|
|
182
183
|
skills gen Kick off tenant-wide skill generation
|
package/dist/prompts-ab.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { CommandContext } from './command-context.js';
|
|
2
2
|
import type { ProfileIOOptions } from './profiles.js';
|
|
3
|
+
export declare const DEFAULT_POLL_INTERVAL_MS = 15000;
|
|
4
|
+
export declare const DEFAULT_WAIT_TIMEOUT_MS: number;
|
|
5
|
+
export declare const TERMINAL_RUN_STATUSES: Set<string>;
|
|
3
6
|
export interface PromptArmPayload {
|
|
4
7
|
content: string;
|
|
5
8
|
prompt_version_id?: string;
|
|
@@ -15,4 +18,54 @@ export interface PromptAbResult {
|
|
|
15
18
|
cases: Array<Record<string, unknown>>;
|
|
16
19
|
}
|
|
17
20
|
export declare function runPromptAb(flags: Record<string, string>, profileOptions: ProfileIOOptions, context: CommandContext): Promise<void>;
|
|
21
|
+
export interface ComparisonPayload {
|
|
22
|
+
run: {
|
|
23
|
+
runId: string;
|
|
24
|
+
status: string;
|
|
25
|
+
caseCount: number;
|
|
26
|
+
prodPassCount: number;
|
|
27
|
+
proposedPassCount: number;
|
|
28
|
+
prodPassRate: number;
|
|
29
|
+
proposedPassRate: number;
|
|
30
|
+
notes: string;
|
|
31
|
+
} | null;
|
|
32
|
+
cases: Array<{
|
|
33
|
+
caseId: string;
|
|
34
|
+
prodPass: boolean | null;
|
|
35
|
+
proposedPass: boolean | null;
|
|
36
|
+
prodScore: number | null;
|
|
37
|
+
proposedScore: number | null;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
export declare function resolvePromptArmSpec(source: string, label: string, opts?: {
|
|
41
|
+
preferKey?: boolean;
|
|
42
|
+
}): PromptArmPayload;
|
|
43
|
+
export declare function fetchLatestRun(tenantId: string, replaySetId: string, runId: string, profileOptions: ProfileIOOptions): Promise<ComparisonPayload>;
|
|
44
|
+
/**
|
|
45
|
+
* Strict outcome of a completed comparison for gating: 'candidate' only when the
|
|
46
|
+
* proposed arm passed strictly more cases than prod on a completed run. A tie,
|
|
47
|
+
* regression, skip, error, or missing run is never a candidate win.
|
|
48
|
+
*/
|
|
49
|
+
export declare function candidateOutcome(payload: ComparisonPayload): 'candidate' | 'baseline' | 'tie' | 'no-run';
|
|
50
|
+
export declare function formatVerdict(payload: ComparisonPayload): string;
|
|
51
|
+
/**
|
|
52
|
+
* Enqueue a prod-vs-proposed replay comparison and poll `/runs/latest` (pinned to
|
|
53
|
+
* OUR run id) until it reaches a terminal status or the deadline. Shared by
|
|
54
|
+
* `prompts ab` and the `prompts propose --gate` auto-gate. Returns the terminal
|
|
55
|
+
* comparison payload; throws on enqueue/poll timeout.
|
|
56
|
+
*/
|
|
57
|
+
export declare function enqueueAndPollComparison(args: {
|
|
58
|
+
tenantId: string;
|
|
59
|
+
replaySetId: string;
|
|
60
|
+
promptArms: {
|
|
61
|
+
prod: PromptArmPayload;
|
|
62
|
+
proposed: PromptArmPayload;
|
|
63
|
+
};
|
|
64
|
+
seedsPerCase?: number;
|
|
65
|
+
assistantModel?: string;
|
|
66
|
+
promotePrimary?: boolean;
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
pollIntervalMs?: number;
|
|
69
|
+
onWaiting?: (status: string) => void;
|
|
70
|
+
}, profileOptions: ProfileIOOptions): Promise<ComparisonPayload>;
|
|
18
71
|
//# sourceMappingURL=prompts-ab.d.ts.map
|
package/dist/prompts-ab.js
CHANGED
|
@@ -4,9 +4,9 @@ import { callControlAPI, callDataAPI } from './api-client.js';
|
|
|
4
4
|
import { resolveTenantId, validateConfig } from './config.js';
|
|
5
5
|
import { discoverPrompts, parsePromptFileForAb, readPromptLock, runPromptSync, } from './prompts.js';
|
|
6
6
|
const DEFAULT_SEEDS_PER_CASE = 3;
|
|
7
|
-
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
8
|
-
const DEFAULT_WAIT_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
9
|
-
const TERMINAL_RUN_STATUSES = new Set(['completed', 'skipped', 'error']);
|
|
7
|
+
export const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
8
|
+
export const DEFAULT_WAIT_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
9
|
+
export const TERMINAL_RUN_STATUSES = new Set(['completed', 'skipped', 'error']);
|
|
10
10
|
export async function runPromptAb(flags, profileOptions, context) {
|
|
11
11
|
validateConfig();
|
|
12
12
|
const tenantId = flags['tenant-id'] || resolveTenantId(profileOptions);
|
|
@@ -81,23 +81,40 @@ export async function runPromptAb(flags, profileOptions, context) {
|
|
|
81
81
|
}
|
|
82
82
|
context.output.writeData(result);
|
|
83
83
|
}
|
|
84
|
-
function resolvePromptArmSpec(source, label) {
|
|
84
|
+
export function resolvePromptArmSpec(source, label, opts = {}) {
|
|
85
85
|
const trimmed = source.trim();
|
|
86
86
|
if (!trimmed) {
|
|
87
87
|
throw new Error(`Empty prompt source for ${label}`);
|
|
88
88
|
}
|
|
89
|
+
const fromDiscovery = () => {
|
|
90
|
+
const discovered = discoverPrompts();
|
|
91
|
+
const match = discovered.find((prompt) => prompt.key === trimmed)
|
|
92
|
+
?? discovered.find((prompt) => prompt.sourcePath === trimmed);
|
|
93
|
+
return match ? { key: match.key, content: match.content } : null;
|
|
94
|
+
};
|
|
89
95
|
const absPath = isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed);
|
|
90
96
|
let key;
|
|
91
97
|
let content;
|
|
92
|
-
|
|
98
|
+
// When the caller knows `source` is a registry key (e.g. `propose --gate`'s
|
|
99
|
+
// `--prompt-key`), resolve by discovery first so a same-named file/dir in the
|
|
100
|
+
// cwd can't shadow the intended prompt; fall back to path resolution otherwise.
|
|
101
|
+
const preferred = opts.preferKey ? fromDiscovery() : null;
|
|
102
|
+
if (preferred) {
|
|
103
|
+
key = preferred.key;
|
|
104
|
+
content = preferred.content;
|
|
105
|
+
}
|
|
106
|
+
else if (opts.preferKey) {
|
|
107
|
+
// Key-first resolution must NOT fall through to path resolution — otherwise a
|
|
108
|
+
// cwd file named like the key would still shadow an unresolved registry key.
|
|
109
|
+
throw new Error(`Could not resolve prompt key '${trimmed}' — no discovered prompt matched this key`);
|
|
110
|
+
}
|
|
111
|
+
else if (existsSync(absPath)) {
|
|
93
112
|
const parsed = parsePromptFileForAb(absPath);
|
|
94
113
|
key = parsed.key;
|
|
95
114
|
content = parsed.content;
|
|
96
115
|
}
|
|
97
116
|
else {
|
|
98
|
-
const
|
|
99
|
-
const match = discovered.find((prompt) => prompt.key === trimmed)
|
|
100
|
-
?? discovered.find((prompt) => prompt.sourcePath === trimmed);
|
|
117
|
+
const match = fromDiscovery();
|
|
101
118
|
if (!match) {
|
|
102
119
|
throw new Error(`Could not resolve prompt '${trimmed}' — pass a .prompt.md path or a discovered prompt key`);
|
|
103
120
|
}
|
|
@@ -176,13 +193,29 @@ async function loadScenarioFromConversation(conversationId) {
|
|
|
176
193
|
}
|
|
177
194
|
return `Replay conversation ${conversationId}: help the user accomplish their original goal using the available tools.`;
|
|
178
195
|
}
|
|
179
|
-
async function fetchLatestRun(tenantId, replaySetId, runId, profileOptions) {
|
|
196
|
+
export async function fetchLatestRun(tenantId, replaySetId, runId, profileOptions) {
|
|
180
197
|
// Pin the poll to OUR run id — otherwise a concurrent comparison on the same
|
|
181
198
|
// set would make `/runs/latest` return the newer run forever and this command
|
|
182
199
|
// would stall until timeout even after its own run finished.
|
|
183
200
|
return callControlAPI(`/tenants/${encodeURIComponent(tenantId)}/replay-sets/${encodeURIComponent(replaySetId)}/runs/latest?runId=${encodeURIComponent(runId)}`, { method: 'GET' }, profileOptions, { timeoutMs: 60_000, retries: 2 });
|
|
184
201
|
}
|
|
185
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Strict outcome of a completed comparison for gating: 'candidate' only when the
|
|
204
|
+
* proposed arm passed strictly more cases than prod on a completed run. A tie,
|
|
205
|
+
* regression, skip, error, or missing run is never a candidate win.
|
|
206
|
+
*/
|
|
207
|
+
export function candidateOutcome(payload) {
|
|
208
|
+
const run = payload.run;
|
|
209
|
+
if (!run || run.status !== 'completed')
|
|
210
|
+
return 'no-run';
|
|
211
|
+
const delta = run.proposedPassCount - run.prodPassCount;
|
|
212
|
+
if (delta > 0)
|
|
213
|
+
return 'candidate';
|
|
214
|
+
if (delta < 0)
|
|
215
|
+
return 'baseline';
|
|
216
|
+
return 'tie';
|
|
217
|
+
}
|
|
218
|
+
export function formatVerdict(payload) {
|
|
186
219
|
const run = payload.run;
|
|
187
220
|
if (!run)
|
|
188
221
|
return 'no run';
|
|
@@ -224,6 +257,43 @@ function formatPass(value) {
|
|
|
224
257
|
function pct(rate) {
|
|
225
258
|
return `${Math.round(Math.max(0, Math.min(1, rate)) * 100)}%`;
|
|
226
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Enqueue a prod-vs-proposed replay comparison and poll `/runs/latest` (pinned to
|
|
262
|
+
* OUR run id) until it reaches a terminal status or the deadline. Shared by
|
|
263
|
+
* `prompts ab` and the `prompts propose --gate` auto-gate. Returns the terminal
|
|
264
|
+
* comparison payload; throws on enqueue/poll timeout.
|
|
265
|
+
*/
|
|
266
|
+
export async function enqueueAndPollComparison(args, profileOptions) {
|
|
267
|
+
const enqueue = (await callControlAPI(`/tenants/${encodeURIComponent(args.tenantId)}/replay-sets/${encodeURIComponent(args.replaySetId)}/run`, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
promptArms: args.promptArms,
|
|
271
|
+
seedsPerCase: args.seedsPerCase ?? DEFAULT_SEEDS_PER_CASE,
|
|
272
|
+
assistantModel: args.assistantModel || undefined,
|
|
273
|
+
promotePrimary: args.promotePrimary ?? false,
|
|
274
|
+
}),
|
|
275
|
+
}, profileOptions, { timeoutMs: 120_000, retries: 1 }));
|
|
276
|
+
const timeoutMs = args.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
277
|
+
const pollIntervalMs = args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
278
|
+
const deadline = Date.now() + timeoutMs;
|
|
279
|
+
let latest = null;
|
|
280
|
+
while (Date.now() < deadline) {
|
|
281
|
+
latest = await fetchLatestRun(args.tenantId, args.replaySetId, enqueue.runId, profileOptions);
|
|
282
|
+
const status = latest.run?.status ?? '';
|
|
283
|
+
const runId = latest.run?.runId ?? '';
|
|
284
|
+
if (TERMINAL_RUN_STATUSES.has(status) && runId === enqueue.runId) {
|
|
285
|
+
return latest;
|
|
286
|
+
}
|
|
287
|
+
args.onWaiting?.(status || 'pending');
|
|
288
|
+
await sleep(pollIntervalMs);
|
|
289
|
+
}
|
|
290
|
+
if (!latest?.run
|
|
291
|
+
|| latest.run.runId !== enqueue.runId
|
|
292
|
+
|| !TERMINAL_RUN_STATUSES.has(latest.run.status)) {
|
|
293
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for replay run ${enqueue.runId} to finish`);
|
|
294
|
+
}
|
|
295
|
+
return latest;
|
|
296
|
+
}
|
|
227
297
|
function parsePositiveInt(raw, fallback, max) {
|
|
228
298
|
if (!raw)
|
|
229
299
|
return fallback;
|
|
@@ -2,9 +2,14 @@ import type { CommandContext } from './command-context.js';
|
|
|
2
2
|
import type { ProfileIOOptions } from './profiles.js';
|
|
3
3
|
/**
|
|
4
4
|
* `moda prompts propose` — turn a `prompts ab` run's failures into a revised
|
|
5
|
-
* candidate registered as a new (unlabeled) prompt version. Does NOT promote
|
|
6
|
-
* the operator gates the candidate with a fresh `prompts ab` (ideally on
|
|
7
|
-
* printed holdout cases the revision never saw) and promotes only if it wins.
|
|
5
|
+
* candidate registered as a new (unlabeled) prompt version. Does NOT promote by
|
|
6
|
+
* default: the operator gates the candidate with a fresh `prompts ab` (ideally on
|
|
7
|
+
* the printed holdout cases the revision never saw) and promotes only if it wins.
|
|
8
|
+
*
|
|
9
|
+
* `--gate` chains that gate automatically: it A/Bs the fresh candidate (proposed
|
|
10
|
+
* arm) vs the on-disk baseline (prod arm) over the same replay set and prints the
|
|
11
|
+
* verdict. `--promote-on-win` then promotes the candidate to `prod` ONLY on a
|
|
12
|
+
* strict candidate win. `--model=<slug>` overrides the revision model.
|
|
8
13
|
*/
|
|
9
14
|
export declare function runPromptPropose(positionals: string[], flags: Record<string, string>, profileOptions: ProfileIOOptions, context: CommandContext): Promise<void>;
|
|
10
15
|
//# sourceMappingURL=prompts-propose.d.ts.map
|
package/dist/prompts-propose.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path';
|
|
3
3
|
import { callControlAPI } from './api-client.js';
|
|
4
|
-
import { validateConfig } from './config.js';
|
|
4
|
+
import { resolveTenantId, validateConfig } from './config.js';
|
|
5
|
+
import { candidateOutcome, enqueueAndPollComparison, formatVerdict, resolvePromptArmSpec, } from './prompts-ab.js';
|
|
5
6
|
// Propose is a single OpenRouter revision behind the backend; give it room.
|
|
6
7
|
const PROPOSE_TIMEOUT_MS = 300_000;
|
|
7
8
|
/**
|
|
8
9
|
* `moda prompts propose` — turn a `prompts ab` run's failures into a revised
|
|
9
|
-
* candidate registered as a new (unlabeled) prompt version. Does NOT promote
|
|
10
|
-
* the operator gates the candidate with a fresh `prompts ab` (ideally on
|
|
11
|
-
* printed holdout cases the revision never saw) and promotes only if it wins.
|
|
10
|
+
* candidate registered as a new (unlabeled) prompt version. Does NOT promote by
|
|
11
|
+
* default: the operator gates the candidate with a fresh `prompts ab` (ideally on
|
|
12
|
+
* the printed holdout cases the revision never saw) and promotes only if it wins.
|
|
13
|
+
*
|
|
14
|
+
* `--gate` chains that gate automatically: it A/Bs the fresh candidate (proposed
|
|
15
|
+
* arm) vs the on-disk baseline (prod arm) over the same replay set and prints the
|
|
16
|
+
* verdict. `--promote-on-win` then promotes the candidate to `prod` ONLY on a
|
|
17
|
+
* strict candidate win. `--model=<slug>` overrides the revision model.
|
|
12
18
|
*/
|
|
13
19
|
export async function runPromptPropose(positionals, flags, profileOptions, context) {
|
|
14
20
|
validateConfig();
|
|
@@ -16,21 +22,124 @@ export async function runPromptPropose(positionals, flags, profileOptions, conte
|
|
|
16
22
|
const fromRunId = flags['from-run'] || flags['from-run-id'] || flags['run-id'];
|
|
17
23
|
const replaySetId = flags['set-id'] || flags['replay-set-id'];
|
|
18
24
|
if (!promptKey || !fromRunId || !replaySetId) {
|
|
19
|
-
throw new Error('Usage: moda prompts propose --prompt-key=<key> --from-run=<run_id> --set-id=<replay_set_id> [--max-dossiers=8] [--out=path.prompt.md]');
|
|
25
|
+
throw new Error('Usage: moda prompts propose --prompt-key=<key> --from-run=<run_id> --set-id=<replay_set_id> [--max-dossiers=8] [--model=<slug>] [--out=path.prompt.md] [--gate] [--promote-on-win]');
|
|
20
26
|
}
|
|
21
27
|
const maxDossiers = parsePositiveInt(flags['max-dossiers'], 8, 16);
|
|
22
|
-
const
|
|
28
|
+
const model = (flags.model ?? flags['revise-model'] ?? '').trim();
|
|
29
|
+
const result = (await callControlAPI(`/prompts/${encodeURIComponent(promptKey)}/propose`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
body: JSON.stringify({ fromRunId, replaySetId, maxDossiers, model: model || undefined }),
|
|
32
|
+
}, profileOptions, { timeoutMs: PROPOSE_TIMEOUT_MS, retries: 0 }));
|
|
23
33
|
let outPath;
|
|
24
34
|
if (result.status === 'proposed' && result.content && flags.out) {
|
|
25
35
|
outPath = isAbsolute(flags.out) ? flags.out : resolve(process.cwd(), flags.out);
|
|
26
36
|
writeFileSync(outPath, result.content, 'utf8');
|
|
27
37
|
}
|
|
38
|
+
// The gate needs the candidate content in-hand for the proposed arm.
|
|
39
|
+
const wantGate = flags.gate === 'true';
|
|
40
|
+
const promoteOnWin = flags['promote-on-win'] === 'true';
|
|
41
|
+
let gate;
|
|
42
|
+
if (wantGate) {
|
|
43
|
+
if (result.status !== 'proposed' || !result.content) {
|
|
44
|
+
gate = {
|
|
45
|
+
ran: false,
|
|
46
|
+
scope: 'full-set',
|
|
47
|
+
scopeNote: `no candidate to gate (${result.status})`,
|
|
48
|
+
verdict: 'skipped',
|
|
49
|
+
outcome: 'no-run',
|
|
50
|
+
runId: null,
|
|
51
|
+
promoted: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
gate = await runGate({
|
|
56
|
+
result,
|
|
57
|
+
promptKey,
|
|
58
|
+
replaySetId,
|
|
59
|
+
flags,
|
|
60
|
+
promoteOnWin,
|
|
61
|
+
profileOptions,
|
|
62
|
+
context,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (context.outputMode === 'human') {
|
|
67
|
+
printHuman(result, outPath, gate);
|
|
68
|
+
}
|
|
69
|
+
context.output.writeData({
|
|
70
|
+
...result,
|
|
71
|
+
...(outPath ? { outPath } : {}),
|
|
72
|
+
...(gate ? { gate } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function runGate(args) {
|
|
76
|
+
const { result, promptKey, replaySetId, flags, promoteOnWin, profileOptions, context } = args;
|
|
77
|
+
const tenantId = flags['tenant-id'] || resolveTenantId(profileOptions);
|
|
78
|
+
if (!tenantId) {
|
|
79
|
+
throw new Error('Missing tenant id for --gate. Run `moda init` or pass --tenant-id=<id>.');
|
|
80
|
+
}
|
|
81
|
+
// prod arm = the on-disk/registry baseline. `promptKey` is always a registry
|
|
82
|
+
// key here, so resolve key-first (`preferKey`) — otherwise a same-named file or
|
|
83
|
+
// dir in the cwd could shadow it and gate the candidate against wrong content.
|
|
84
|
+
// proposed arm = the freshly proposed candidate content returned above.
|
|
85
|
+
const prodArm = resolvePromptArmSpec(promptKey, 'baseline', { preferKey: true });
|
|
86
|
+
const proposedArm = {
|
|
87
|
+
content: result.content ?? '',
|
|
88
|
+
prompt_version_id: result.versionId ?? '',
|
|
89
|
+
prompt_id: '',
|
|
90
|
+
prompt_label: 'candidate',
|
|
91
|
+
};
|
|
92
|
+
const seedsPerCase = parsePositiveInt(flags.seeds ?? flags['seeds-per-case'], 3, 10);
|
|
93
|
+
const assistantModel = (flags['assistant-model'] ?? '').trim();
|
|
94
|
+
const timeoutMs = parsePositiveInt(flags['gate-timeout'] ?? flags.timeout, 2 * 60 * 60 * 1000, 24 * 60 * 60 * 1000);
|
|
95
|
+
const pollIntervalMs = parsePositiveInt(flags['poll-interval'], 15_000, 120_000);
|
|
96
|
+
// The /run endpoint scores the WHOLE replay set — there is no per-case subset
|
|
97
|
+
// knob — so the gate cannot be scoped to just the holdout ids the revision
|
|
98
|
+
// never saw. Say so plainly so a win isn't read as overfitting-proof.
|
|
99
|
+
const scopeNote = result.holdoutCaseIds.length
|
|
100
|
+
? `gated over the FULL replay set (${result.holdoutCaseIds.length} holdout case(s) could not be isolated — the run endpoint scores every case)`
|
|
101
|
+
: 'gated over the FULL replay set';
|
|
28
102
|
if (context.outputMode === 'human') {
|
|
29
|
-
|
|
103
|
+
process.stderr.write(`\nGating candidate for '${promptKey}' — ${scopeNote}...\n`);
|
|
30
104
|
}
|
|
31
|
-
|
|
105
|
+
const payload = await enqueueAndPollComparison({
|
|
106
|
+
tenantId,
|
|
107
|
+
replaySetId,
|
|
108
|
+
promptArms: { prod: prodArm, proposed: proposedArm },
|
|
109
|
+
seedsPerCase,
|
|
110
|
+
assistantModel: assistantModel || undefined,
|
|
111
|
+
promotePrimary: false,
|
|
112
|
+
timeoutMs,
|
|
113
|
+
pollIntervalMs,
|
|
114
|
+
onWaiting: context.outputMode === 'human'
|
|
115
|
+
? (status) => process.stderr.write(`Waiting for gate run (${status})...\n`)
|
|
116
|
+
: undefined,
|
|
117
|
+
}, profileOptions);
|
|
118
|
+
const outcome = candidateOutcome(payload);
|
|
119
|
+
const gate = {
|
|
120
|
+
ran: true,
|
|
121
|
+
scope: 'full-set',
|
|
122
|
+
scopeNote,
|
|
123
|
+
verdict: formatVerdict(payload),
|
|
124
|
+
outcome,
|
|
125
|
+
runId: payload.run?.runId ?? null,
|
|
126
|
+
promoted: false,
|
|
127
|
+
};
|
|
128
|
+
if (promoteOnWin) {
|
|
129
|
+
if (outcome === 'candidate' && result.versionId) {
|
|
130
|
+
await callControlAPI(`/prompts/${encodeURIComponent(promptKey)}/labels`, { method: 'POST', body: JSON.stringify({ label: 'prod', versionId: result.versionId }) }, profileOptions);
|
|
131
|
+
gate.promoted = true;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
gate.promoteSkippedReason =
|
|
135
|
+
outcome === 'candidate'
|
|
136
|
+
? 'no candidate versionId to promote'
|
|
137
|
+
: `candidate did not strictly win (${outcome})`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return gate;
|
|
32
141
|
}
|
|
33
|
-
function printHuman(result, outPath) {
|
|
142
|
+
function printHuman(result, outPath, gate) {
|
|
34
143
|
const w = (s) => process.stderr.write(s + '\n');
|
|
35
144
|
if (result.status !== 'proposed') {
|
|
36
145
|
w(`No candidate produced (${result.status}): ${result.note}`);
|
|
@@ -46,7 +155,33 @@ function printHuman(result, outPath) {
|
|
|
46
155
|
w(` risks: ${result.risks.map((r) => `- ${r}`).join('\n ')}`);
|
|
47
156
|
if (outPath)
|
|
48
157
|
w(` written to: ${outPath}`);
|
|
158
|
+
if (result.lowN) {
|
|
159
|
+
w('');
|
|
160
|
+
w(` ⚠ low sample: ${result.lowN}`);
|
|
161
|
+
}
|
|
49
162
|
w('');
|
|
163
|
+
if (gate?.ran) {
|
|
164
|
+
w('Gate (automatic A/B of candidate vs baseline):');
|
|
165
|
+
w(` scope: ${gate.scopeNote}`);
|
|
166
|
+
w(` verdict: ${gate.verdict}`);
|
|
167
|
+
if (gate.runId)
|
|
168
|
+
w(` run id: ${gate.runId}`);
|
|
169
|
+
if (gate.promoted) {
|
|
170
|
+
w(` promoted: candidate won — promoted version ${result.versionId} to label 'prod'`);
|
|
171
|
+
}
|
|
172
|
+
else if (gate.promoteSkippedReason) {
|
|
173
|
+
w(` promote: skipped (${gate.promoteSkippedReason})`);
|
|
174
|
+
}
|
|
175
|
+
w('');
|
|
176
|
+
if (!gate.promoted) {
|
|
177
|
+
w(` moda prompts promote ${result.promptKey} --label=prod --version=${result.versionId}`);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (gate && !gate.ran) {
|
|
182
|
+
w(`Gate not run: ${gate.scopeNote}`);
|
|
183
|
+
w('');
|
|
184
|
+
}
|
|
50
185
|
w('Next — gate on holdout cases the revision never saw, then promote if it wins:');
|
|
51
186
|
if (result.holdoutCaseIds.length) {
|
|
52
187
|
w(` holdout cases (${result.holdoutCaseIds.length}): ${result.holdoutCaseIds.join(', ')}`);
|
|
@@ -65,6 +200,7 @@ function printHuman(result, outPath) {
|
|
|
65
200
|
w(` # re-run with --out=<path.prompt.md> to materialize the candidate, then:`);
|
|
66
201
|
w(` moda prompts ab --baseline-key=${result.promptKey} --candidate=<path.prompt.md> --auto-generate`);
|
|
67
202
|
}
|
|
203
|
+
w(` # or: moda prompts propose ... --gate --promote-on-win (auto-A/B, promote on a strict win)`);
|
|
68
204
|
w(` moda prompts promote ${result.promptKey} --label=prod --version=${result.versionId}`);
|
|
69
205
|
}
|
|
70
206
|
function parsePositiveInt(raw, fallback, max) {
|
package/dist/schemas.d.ts
CHANGED
|
@@ -43,24 +43,24 @@ export declare const ConversationsSchema: z.ZodObject<{
|
|
|
43
43
|
}, "strip", z.ZodTypeAny, {
|
|
44
44
|
search?: string | undefined;
|
|
45
45
|
environment?: "staging" | "all" | "development" | "production" | undefined;
|
|
46
|
+
outcome?: "any" | "positive" | "negative" | undefined;
|
|
46
47
|
time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
|
|
47
48
|
limit?: number | undefined;
|
|
48
49
|
offset?: number | undefined;
|
|
49
50
|
cluster_id?: string | undefined;
|
|
50
51
|
user_id?: string | undefined;
|
|
51
52
|
world_state?: string | undefined;
|
|
52
|
-
outcome?: "any" | "positive" | "negative" | undefined;
|
|
53
53
|
include_world_state?: boolean | undefined;
|
|
54
54
|
}, {
|
|
55
55
|
search?: string | undefined;
|
|
56
56
|
environment?: "staging" | "all" | "development" | "production" | undefined;
|
|
57
|
+
outcome?: "any" | "positive" | "negative" | undefined;
|
|
57
58
|
time_range?: "all" | "1h" | "3d" | "7d" | "24h" | "30d" | "90d" | undefined;
|
|
58
59
|
limit?: number | undefined;
|
|
59
60
|
offset?: number | undefined;
|
|
60
61
|
cluster_id?: string | undefined;
|
|
61
62
|
user_id?: string | undefined;
|
|
62
63
|
world_state?: string | undefined;
|
|
63
|
-
outcome?: "any" | "positive" | "negative" | undefined;
|
|
64
64
|
include_world_state?: unknown;
|
|
65
65
|
}>;
|
|
66
66
|
/** moda world-state <conversationId> — per-conversation world-state drill-in. */
|
package/package.json
CHANGED