@letterstory/cli 0.47.0 → 0.49.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/README.md +4 -0
- package/lib/cli.mjs +29 -1
- package/lib/commands/admin.mjs +195 -3
- package/lib/commands/evals.mjs +198 -0
- package/lib/commands.mjs +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -388,6 +388,10 @@ letterstory admin crm slack-channels [--all] # Watchdog channels + the
|
|
|
388
388
|
letterstory admin crm link-slack <account-id> C0123ABC # the CRM's watch-only Slack link (`none` unlinks)
|
|
389
389
|
letterstory admin crm import leads.csv --dry-run # bulk-import leads: what would be created/matched
|
|
390
390
|
letterstory admin crm import leads.csv --tag "YC W26" # …and for real (dedupes by domain, then company name)
|
|
391
|
+
letterstory admin crm capture thread.png --context "this is for tryordinal.com" --dry-run
|
|
392
|
+
# a screenshot → a lead: what it read, which account
|
|
393
|
+
letterstory admin crm capture thread.png --context "this is for tryordinal.com" # …and for real
|
|
394
|
+
letterstory admin crm capture --text-file notes.txt # pasted text or notes work the same way
|
|
391
395
|
|
|
392
396
|
# One-click onboarding of a won client (key needs crm:write + fleet:onboard + org:create)
|
|
393
397
|
letterstory admin crm onboard <account-id> --domain acme.com \
|
package/lib/cli.mjs
CHANGED
|
@@ -48,12 +48,13 @@ import {
|
|
|
48
48
|
cmdMailers,
|
|
49
49
|
cmdAdmin,
|
|
50
50
|
cmdFeedback,
|
|
51
|
+
cmdEvals,
|
|
51
52
|
cmdOfferings,
|
|
52
53
|
cmdTargets,
|
|
53
54
|
} from "./commands.mjs";
|
|
54
55
|
|
|
55
56
|
// Keep in sync with cli/package.json.
|
|
56
|
-
export const VERSION = "0.
|
|
57
|
+
export const VERSION = "0.49.0";
|
|
57
58
|
|
|
58
59
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
59
60
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -94,6 +95,8 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
94
95
|
"no-visuals",
|
|
95
96
|
"no-enhance",
|
|
96
97
|
"no-key",
|
|
98
|
+
// `admin evals triage --overdue`
|
|
99
|
+
"overdue",
|
|
97
100
|
// `autopen start --article <id> --from-scratch`
|
|
98
101
|
"from-scratch",
|
|
99
102
|
// `collections activate <id> --no-topic-map` / `--no-headlines`
|
|
@@ -560,6 +563,8 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
560
563
|
ledger entry. Written by \`npm run lab --
|
|
561
564
|
publish\` in the kernels repo.
|
|
562
565
|
admin crm import <file.csv> [--source <name>] [--tag "…"] [--dry-run]
|
|
566
|
+
| capture [shot.png …] [--text "…" | --text-file f] [--context "this is for acme.com"]
|
|
567
|
+
[--account <id>] [--dry-run] screenshots / a thread / a note → a lead
|
|
563
568
|
| signals [--status new] [--intent qualified] [--source rb2b] [--account <id>]
|
|
564
569
|
| record --title "…" [--source bookface] [--kind other] [--intent qualified]
|
|
565
570
|
[--name …] [--email …] [--company …] [--domain …] [--linkedin …] [--detail …]
|
|
@@ -594,6 +599,16 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
594
599
|
org:create. onboard-plan reads the calls
|
|
595
600
|
(sites, who to invite, paid or not);
|
|
596
601
|
payment-link needs billing:write.
|
|
602
|
+
admin evals scoreboard [--scope fleet|org|collection --id <uuid>] [--days N]
|
|
603
|
+
| cases [--org <uuid>] [--status s] [--stage s] | runs [--limit N] [--mode record|full]
|
|
604
|
+
| run [--mode record|full] [--keys a,b] [--org <uuid>] [--baseline latest|<id>]
|
|
605
|
+
| triage [--owner research|kernel|links|mentions|accuracy|pipeline] [--overdue]
|
|
606
|
+
| guard <case-key> [--sha <commit>] [--fix-pr #N] | retire <case-key> --reason "…"
|
|
607
|
+
The fleet-wide eval surface: scoreboard,
|
|
608
|
+
every org's cases and suite runs, the
|
|
609
|
+
triage queue, and the two lifecycle calls
|
|
610
|
+
an org key cannot make. Reads need
|
|
611
|
+
fleet:read; writes need tuning:experiment.
|
|
597
612
|
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
598
613
|
the pipeline as deployed, levers, measures,
|
|
599
614
|
goals, related history.
|
|
@@ -833,6 +848,18 @@ Feedback (tell us what's broken, missing, unclear, or wanted):
|
|
|
833
848
|
[--message <text>|--body <text>|--file <path>|--file -] [--rating 1-5]
|
|
834
849
|
[--context '<json>']
|
|
835
850
|
|
|
851
|
+
Evals (how the pipeline is doing on your posts, and the regression cases captured from them):
|
|
852
|
+
evals scoreboard [--collection <id>] [--days N] Every measure by stage: posts, failing, rate
|
|
853
|
+
evals cases [--status open|guarding|proposed|retired|rejected] [--stage research|brief|links|mentions|accuracy|pipeline]
|
|
854
|
+
evals capture (--article <id> | --research-run <id> | --kernel-job <id>) --title "…"
|
|
855
|
+
(--description "…" | --body … | --file <path>) (--measure <key> | --assertion "…")
|
|
856
|
+
[--stage <s>] [--replay <stage>] [--auto-pen <id>] [--published-version N]
|
|
857
|
+
[--incident <ref>] [--link <url>] [--status open|proposed]
|
|
858
|
+
Freeze the post's record as a case; exits 1 when
|
|
859
|
+
it does NOT detect the issue on current code
|
|
860
|
+
evals run [--keys a,b] [--baseline latest|<run-id>] Record-only suite; exits 1 on a regression
|
|
861
|
+
evals run-status <run-id>
|
|
862
|
+
|
|
836
863
|
Anything else:
|
|
837
864
|
tools list List every tool this server exposes, by name
|
|
838
865
|
tools show <name> Show one tool's capability, argument schema, and return shape
|
|
@@ -907,6 +934,7 @@ const CLIENT_COMMANDS = {
|
|
|
907
934
|
mail: cmdMail,
|
|
908
935
|
mailers: cmdMailers,
|
|
909
936
|
feedback: cmdFeedback,
|
|
937
|
+
evals: cmdEvals,
|
|
910
938
|
// Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
|
|
911
939
|
admin: cmdAdmin,
|
|
912
940
|
};
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
8
8
|
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
9
9
|
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
10
|
-
// admin
|
|
10
|
+
// admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
|
|
11
|
+
// admin crm import|capture|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
|
|
11
12
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
12
13
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
13
14
|
// [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
|
|
@@ -626,6 +627,75 @@ function onboardBody(flags) {
|
|
|
626
627
|
});
|
|
627
628
|
}
|
|
628
629
|
|
|
630
|
+
const CRM_CAPTURE_TYPES = {
|
|
631
|
+
png: "image/png",
|
|
632
|
+
jpg: "image/jpeg",
|
|
633
|
+
jpeg: "image/jpeg",
|
|
634
|
+
gif: "image/gif",
|
|
635
|
+
webp: "image/webp",
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* `admin crm capture [image…] [--text "…" | --text-file f] [--context "this is for acme.com"]
|
|
640
|
+
* [--account id] [--dry-run]` — screenshots, a pasted thread or a note → a lead. The same
|
|
641
|
+
* captureLead the CRM's paste box and Slack's "Send to CRM" call (POST /api/superadmin/crm/capture).
|
|
642
|
+
*/
|
|
643
|
+
async function cmdAdminCrmCapture(ctx, paths) {
|
|
644
|
+
const { flags, io } = ctx;
|
|
645
|
+
const usage =
|
|
646
|
+
'Usage: admin crm capture [image.png …] [--text "…" | --text-file notes.txt] [--context "this is for acme.com"] [--account <id>] [--dry-run]';
|
|
647
|
+
const images = paths.map((path) => {
|
|
648
|
+
const media_type = CRM_CAPTURE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""];
|
|
649
|
+
if (!media_type) throw new CliError(`${path}: only png, jpg, gif and webp screenshots can be read`);
|
|
650
|
+
try {
|
|
651
|
+
return { media_type, data: readFileSync(path).toString("base64") };
|
|
652
|
+
} catch (err) {
|
|
653
|
+
throw new CliError(`Could not read ${path}: ${err.message}`);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
let text = flagStr(flags.text);
|
|
657
|
+
const textFile = flagStr(flags["text-file"]);
|
|
658
|
+
if (textFile) {
|
|
659
|
+
try {
|
|
660
|
+
text = readFileSync(textFile, "utf8");
|
|
661
|
+
} catch (err) {
|
|
662
|
+
throw new CliError(`Could not read ${textFile}: ${err.message}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (images.length === 0 && !text) throw new CliError(usage);
|
|
666
|
+
const r = await staffPost(
|
|
667
|
+
ctx,
|
|
668
|
+
`${CRM}/capture`,
|
|
669
|
+
compact({
|
|
670
|
+
images: images.length ? images : undefined,
|
|
671
|
+
text,
|
|
672
|
+
context: flagStr(flags.context),
|
|
673
|
+
account_id: flagStr(flags.account),
|
|
674
|
+
source: flagStr(flags.source) ?? "agent",
|
|
675
|
+
dry_run: flagBool(flags["dry-run"]) || undefined,
|
|
676
|
+
})
|
|
677
|
+
);
|
|
678
|
+
if (flagBool(flags.json)) return printResult(io, flags, r);
|
|
679
|
+
const verb = r.dryRun
|
|
680
|
+
? r.accountId
|
|
681
|
+
? "would add to"
|
|
682
|
+
: "would create"
|
|
683
|
+
: r.duplicate
|
|
684
|
+
? "already captured →"
|
|
685
|
+
: r.accountCreated
|
|
686
|
+
? "created"
|
|
687
|
+
: "added to";
|
|
688
|
+
io.log(
|
|
689
|
+
`${verb} ${r.accountName ?? "a new account"}${r.accountUrl ? ` ${r.accountUrl}` : ""}${r.matchedVia ? ` (via ${r.matchedVia})` : ""}`
|
|
690
|
+
);
|
|
691
|
+
if (r.read) {
|
|
692
|
+
io.log(` ${r.read.title}`);
|
|
693
|
+
if (r.contacts.length)
|
|
694
|
+
io.log(` contacts: ${r.contacts.map((p) => p.name + (p.email ? ` <${p.email}>` : "")).join(", ")}`);
|
|
695
|
+
if (r.read.notes) io.log(` notes: ${r.read.notes}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
629
699
|
/**
|
|
630
700
|
* `admin crm …` — the headless half of the staff CRM, so a background agent can watch the
|
|
631
701
|
* pipeline and record the signals that have no webhook (Bookface, tweet likes, DMs).
|
|
@@ -640,6 +710,8 @@ async function cmdAdminCrm(ctx) {
|
|
|
640
710
|
switch (sub) {
|
|
641
711
|
case "import":
|
|
642
712
|
return cmdAdminCrmImport(ctx, id);
|
|
713
|
+
case "capture":
|
|
714
|
+
return cmdAdminCrmCapture(ctx, ctx.positionals.slice(1));
|
|
643
715
|
case "signals": {
|
|
644
716
|
const params = new URLSearchParams(
|
|
645
717
|
compact({
|
|
@@ -874,7 +946,126 @@ async function cmdAdminCrm(ctx) {
|
|
|
874
946
|
}
|
|
875
947
|
default:
|
|
876
948
|
throw new CliError(
|
|
877
|
-
"Usage: admin crm import|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
|
|
949
|
+
"Usage: admin crm import|capture|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/* ------------------------------------------------------------ content evals */
|
|
955
|
+
|
|
956
|
+
const EVALS = "/api/superadmin/evals";
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* `admin evals …` — the staff half of the eval surface, against /api/superadmin/evals: the
|
|
960
|
+
* fleet-wide scoreboard, every org's cases and runs, the triage queue, and the two lifecycle
|
|
961
|
+
* decisions an org key cannot make (guard, retire). Reads need fleet:read; writes need
|
|
962
|
+
* tuning:experiment. The org-scoped half is `letterstory evals …` on an org key.
|
|
963
|
+
*/
|
|
964
|
+
async function cmdAdminEvals(ctx) {
|
|
965
|
+
const [sub, id] = ctx.positionals;
|
|
966
|
+
const { flags, io } = ctx;
|
|
967
|
+
const json = flagBool(flags.json);
|
|
968
|
+
const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
|
|
969
|
+
const query = (obj) => {
|
|
970
|
+
const params = new URLSearchParams(compact(obj));
|
|
971
|
+
const q = params.toString();
|
|
972
|
+
return q ? `?${q}` : "";
|
|
973
|
+
};
|
|
974
|
+
switch (sub) {
|
|
975
|
+
case "scoreboard": {
|
|
976
|
+
const q = query({ scope: flagStr(flags.scope), id: flagStr(flags.id), days: flagStr(flags.days) });
|
|
977
|
+
return out(
|
|
978
|
+
await staffRequest(ctx, "GET", `${EVALS}/scoreboard${q}`),
|
|
979
|
+
(r) =>
|
|
980
|
+
(r.stages ?? [])
|
|
981
|
+
.flatMap((st) => st.measures ?? [])
|
|
982
|
+
.map(
|
|
983
|
+
(m) => `${m.stage.padEnd(9)} ${m.measure.padEnd(44)} ${m.failing}/${m.posts} ${m.severity}`
|
|
984
|
+
)
|
|
985
|
+
.join("\n") || "(no measurements in the window)"
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
case "cases": {
|
|
989
|
+
const q = query({ org_id: flagStr(flags.org), status: flagStr(flags.status), stage: flagStr(flags.stage) });
|
|
990
|
+
return out(
|
|
991
|
+
await staffRequest(ctx, "GET", `${EVALS}/cases${q}`),
|
|
992
|
+
(r) =>
|
|
993
|
+
(r.cases ?? [])
|
|
994
|
+
.map(
|
|
995
|
+
(c) =>
|
|
996
|
+
`${c.key.padEnd(48)} ${c.status.padEnd(9)} ${c.stage.padEnd(9)} ${c.latestResult ? c.latestResult.verdict : "never run"}`
|
|
997
|
+
)
|
|
998
|
+
.join("\n") || "(no cases)"
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
case "runs": {
|
|
1002
|
+
const q = query({ limit: flagStr(flags.limit), mode: flagStr(flags.mode) });
|
|
1003
|
+
return out(
|
|
1004
|
+
await staffRequest(ctx, "GET", `${EVALS}/suite/runs${q}`),
|
|
1005
|
+
(r) =>
|
|
1006
|
+
(r.runs ?? [])
|
|
1007
|
+
.map(
|
|
1008
|
+
(run) =>
|
|
1009
|
+
`${run.id} ${run.startedAt} ${run.mode.padEnd(6)} ${run.verdict.padEnd(4)} ${run.isBaseline ? "baseline " : ""}${run.totals?.regressed ?? 0} regressed`
|
|
1010
|
+
)
|
|
1011
|
+
.join("\n") || "(no runs)"
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
case "run": {
|
|
1015
|
+
const keys = flagStr(flags.keys);
|
|
1016
|
+
const body = compact({
|
|
1017
|
+
mode: flagStr(flags.mode),
|
|
1018
|
+
case_keys: keys
|
|
1019
|
+
? keys
|
|
1020
|
+
.split(",")
|
|
1021
|
+
.map((k) => k.trim())
|
|
1022
|
+
.filter(Boolean)
|
|
1023
|
+
: undefined,
|
|
1024
|
+
org_id: flagStr(flags.org),
|
|
1025
|
+
baseline: flagStr(flags.baseline),
|
|
1026
|
+
});
|
|
1027
|
+
const r = await staffRequest(ctx, "POST", `${EVALS}/suite/runs`, body);
|
|
1028
|
+
out(
|
|
1029
|
+
r,
|
|
1030
|
+
(x) =>
|
|
1031
|
+
`Run ${x.run?.id ?? "(queued)"} — ${String(x.run?.verdict ?? "").toUpperCase()} ${x.run?.totals?.regressed ?? 0} regressed`
|
|
1032
|
+
);
|
|
1033
|
+
return r.run?.verdict === "fail" ? 1 : 0;
|
|
1034
|
+
}
|
|
1035
|
+
case "triage": {
|
|
1036
|
+
const q = query({ owner: flagStr(flags.owner), overdue: flagBool(flags.overdue) ? "true" : undefined });
|
|
1037
|
+
return out(await staffRequest(ctx, "GET", `${EVALS}/triage${q}`), (r) =>
|
|
1038
|
+
[
|
|
1039
|
+
...(r.findings ?? []).map(
|
|
1040
|
+
(f) =>
|
|
1041
|
+
`${f.id} ${(f.owner ?? "unassigned").padEnd(10)} ${f.evalKey.padEnd(36)} ${f.overdue ? "OVERDUE " : ""}${f.title ?? f.summary ?? ""}`
|
|
1042
|
+
),
|
|
1043
|
+
`${r.total ?? 0} open, ${r.overdue ?? 0} overdue`,
|
|
1044
|
+
].join("\n")
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
case "guard": {
|
|
1048
|
+
if (!id) throw new CliError("Usage: admin evals guard <case-key> [--sha <commit>] [--fix-pr #123]");
|
|
1049
|
+
const body = compact({ action: "guard", sha: flagStr(flags.sha), fix_pr: flagStr(flags["fix-pr"]) });
|
|
1050
|
+
return out(await staffRequest(ctx, "PATCH", `${EVALS}/cases/${encodeURIComponent(id)}`, body), (r) =>
|
|
1051
|
+
r.ok ? `${r.case.key} is now guarding (first passed ${r.case.firstPassedSha})` : `refused: ${r.reason}`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
case "retire": {
|
|
1055
|
+
if (!id) throw new CliError('Usage: admin evals retire <case-key> --reason "…"');
|
|
1056
|
+
const reason = flagStr(flags.reason);
|
|
1057
|
+
if (!reason) throw new CliError("Retiring a case needs --reason.");
|
|
1058
|
+
return out(
|
|
1059
|
+
await staffRequest(ctx, "PATCH", `${EVALS}/cases/${encodeURIComponent(id)}`, {
|
|
1060
|
+
action: "retire",
|
|
1061
|
+
reason,
|
|
1062
|
+
}),
|
|
1063
|
+
(r) => (r.ok ? `${r.case.key} retired` : `refused: ${r.reason}`)
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
default:
|
|
1067
|
+
throw new CliError(
|
|
1068
|
+
`Unknown admin evals subcommand: ${sub ?? "(none)"}. Try: scoreboard, cases, runs, run, triage, guard, retire`
|
|
878
1069
|
);
|
|
879
1070
|
}
|
|
880
1071
|
}
|
|
@@ -887,7 +1078,8 @@ export async function cmdAdmin(ctx) {
|
|
|
887
1078
|
if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
888
1079
|
if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
889
1080
|
if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
1081
|
+
if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
890
1082
|
throw new CliError(
|
|
891
|
-
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin tuning-context (see --help)'
|
|
1083
|
+
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning-context (see --help)'
|
|
892
1084
|
);
|
|
893
1085
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// `evals` — the content-eval surface on an org key: how the pipeline is doing on YOUR
|
|
2
|
+
// posts (the scoreboard), the regression cases captured from them, capturing a new one from
|
|
3
|
+
// a bad post, and running the record-only suite.
|
|
4
|
+
//
|
|
5
|
+
// evals scoreboard [--collection <id>] [--days N]
|
|
6
|
+
// evals cases [--status open|guarding|…] [--stage research|brief|…]
|
|
7
|
+
// evals capture (--article <id> | --research-run <id> | --kernel-job <id>) --title "…"
|
|
8
|
+
// --description "…" (--measure <key> | --assertion "…") [--stage s] [--replay <stage>]
|
|
9
|
+
// [--auto-pen <id>] [--published-version N] [--incident …] [--link …]
|
|
10
|
+
// evals run [--keys a,b] [--baseline latest|<run-id>] exits 1 when a guarding case regressed
|
|
11
|
+
// evals run-status <run-id>
|
|
12
|
+
//
|
|
13
|
+
// Five commands, five tools; every input key is declared in ARGS so parity-check compares
|
|
14
|
+
// them against the tools' published schemas (see shared.mjs `argsFromFlags`).
|
|
15
|
+
|
|
16
|
+
import { CliError } from "../client.mjs";
|
|
17
|
+
import { flagStr, readBodyInput, printResult, ok, argsFromFlags } from "./shared.mjs";
|
|
18
|
+
|
|
19
|
+
const STAGES = ["research", "brief", "links", "mentions", "accuracy", "pipeline"];
|
|
20
|
+
const STATUSES = ["proposed", "open", "guarding", "retired", "rejected"];
|
|
21
|
+
|
|
22
|
+
/** `--keys a,b,c` (or repeated `--keys`) → ["a", "b", "c"]; undefined when unset. */
|
|
23
|
+
function keysFrom(flags) {
|
|
24
|
+
const raw = flags.keys;
|
|
25
|
+
if (raw === undefined) return undefined;
|
|
26
|
+
const items = (Array.isArray(raw) ? raw : [raw])
|
|
27
|
+
.flatMap((v) => String(v).split(","))
|
|
28
|
+
.map((k) => k.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
return items.length ? items : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const ARGS = {
|
|
34
|
+
get_eval_scoreboard: {
|
|
35
|
+
// One collection narrows the scope; otherwise the whole org (the tool's default).
|
|
36
|
+
scope: { value: (flags) => (flagStr(flags.collection) ? "collection" : undefined) },
|
|
37
|
+
collection_id: { flag: "collection" },
|
|
38
|
+
days: { flag: "days", type: "num" },
|
|
39
|
+
},
|
|
40
|
+
list_eval_cases: {
|
|
41
|
+
status: { flag: "status", enum: STATUSES },
|
|
42
|
+
stage: { flag: "stage", enum: STAGES },
|
|
43
|
+
},
|
|
44
|
+
capture_eval_case: {
|
|
45
|
+
article_id: { flag: "article" },
|
|
46
|
+
research_run_id: { flag: "research-run" },
|
|
47
|
+
kernel_job_id: { flag: "kernel-job" },
|
|
48
|
+
title: { flag: "title", required: true },
|
|
49
|
+
// --description, or the larger-blob trio --body/--file/stdin.
|
|
50
|
+
description: { value: (flags) => flagStr(flags.description) ?? readBodyInput(flags) },
|
|
51
|
+
expected: { object: { measure: { flag: "measure" }, assertion: { flag: "assertion" } } },
|
|
52
|
+
stage: { flag: "stage", enum: STAGES },
|
|
53
|
+
// Pin the incident's auto-pen run and live version when the post has been re-run since.
|
|
54
|
+
auto_pen_id: { flag: "auto-pen" },
|
|
55
|
+
published_version: { flag: "published-version", type: "num" },
|
|
56
|
+
// A record-only replay stage to re-run over the fixture before grading (evals catalog).
|
|
57
|
+
replay: "replay",
|
|
58
|
+
link: "link",
|
|
59
|
+
incident: "incident",
|
|
60
|
+
status: { flag: "status", enum: ["open", "proposed"] },
|
|
61
|
+
},
|
|
62
|
+
run_eval_suite: {
|
|
63
|
+
case_keys: { value: (flags) => keysFrom(flags) },
|
|
64
|
+
baseline: { flag: "baseline" },
|
|
65
|
+
},
|
|
66
|
+
get_eval_suite_run: {
|
|
67
|
+
run_id: { positional: 0, label: "run-id", required: true },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export async function cmdEvals(ctx) {
|
|
72
|
+
const sub = ctx.positionals[0];
|
|
73
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
74
|
+
switch (sub) {
|
|
75
|
+
case "scoreboard":
|
|
76
|
+
return evalsScoreboard(rest);
|
|
77
|
+
case "cases":
|
|
78
|
+
return evalsCases(rest);
|
|
79
|
+
case "capture":
|
|
80
|
+
return evalsCapture(rest);
|
|
81
|
+
case "run":
|
|
82
|
+
return evalsRun(rest);
|
|
83
|
+
case "run-status":
|
|
84
|
+
return evalsRunStatus(rest);
|
|
85
|
+
default:
|
|
86
|
+
throw new CliError(
|
|
87
|
+
`Unknown evals subcommand: ${sub ?? "(none)"}. Try: scoreboard, cases, capture, run, run-status`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pct(rate) {
|
|
93
|
+
return `${Math.round((rate ?? 0) * 100)}%`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatScoreboard(r) {
|
|
97
|
+
const lines = [`Scoreboard — ${r.scope}${r.id ? ` ${r.id}` : ""}, last ${r.days} days`];
|
|
98
|
+
for (const stage of r.stages ?? []) {
|
|
99
|
+
if (!stage.measures?.length) continue;
|
|
100
|
+
lines.push(`\n${stage.stage}`);
|
|
101
|
+
for (const m of stage.measures) {
|
|
102
|
+
lines.push(
|
|
103
|
+
` ${m.measure.padEnd(44)} ${String(m.failing).padStart(4)}/${String(m.posts).padEnd(4)} ${pct(m.rate).padStart(4)} ${m.severity}${m.kind === "judge" ? " (judge)" : ""}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (r.judgeReliance) {
|
|
108
|
+
lines.push(`\njudge reliance: ${r.judgeReliance.measures} measure(s), ${r.judgeReliance.cases} case(s)`);
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function evalsScoreboard(ctx) {
|
|
114
|
+
const { client, flags, io } = ctx;
|
|
115
|
+
const args = argsFromFlags(ARGS.get_eval_scoreboard, flags, ctx.positionals);
|
|
116
|
+
const result = await client.callTool("get_eval_scoreboard", args);
|
|
117
|
+
printResult(io, flags, result, formatScoreboard);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function caseLine(c) {
|
|
122
|
+
const latest = c.latestResult ? `${c.latestResult.verdict} (${c.latestResult.gate})` : "never run";
|
|
123
|
+
const expected = c.expected?.measure ? c.expected.measure : `assertion: ${c.expected?.assertion ?? ""}`;
|
|
124
|
+
return `${c.key.padEnd(48)} ${c.status.padEnd(9)} ${c.stage.padEnd(9)} ${latest.padEnd(26)} ${expected}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function evalsCases(ctx) {
|
|
128
|
+
const { client, flags, io } = ctx;
|
|
129
|
+
const args = argsFromFlags(ARGS.list_eval_cases, flags, ctx.positionals);
|
|
130
|
+
const result = await client.callTool("list_eval_cases", args);
|
|
131
|
+
printResult(io, flags, result, (r) =>
|
|
132
|
+
r.cases?.length ? r.cases.map(caseLine).join("\n") : "(no cases captured from this org's posts)"
|
|
133
|
+
);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function evalsCapture(ctx) {
|
|
138
|
+
const { client, flags, io } = ctx;
|
|
139
|
+
const args = argsFromFlags(ARGS.capture_eval_case, flags, ctx.positionals);
|
|
140
|
+
if (!args.article_id && !args.research_run_id && !args.kernel_job_id) {
|
|
141
|
+
throw new CliError("Name the post: --article <id>, --research-run <id> or --kernel-job <id>.");
|
|
142
|
+
}
|
|
143
|
+
if (!args.description) {
|
|
144
|
+
throw new CliError(
|
|
145
|
+
"Missing --description (or --body / --file <path> / stdin): what went wrong, in a sentence or two."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const expected = args.expected ?? {};
|
|
149
|
+
if ((expected.measure ? 1 : 0) + (expected.assertion ? 1 : 0) !== 1) {
|
|
150
|
+
throw new CliError('Say what must hold once fixed: exactly one of --measure <key> or --assertion "…".');
|
|
151
|
+
}
|
|
152
|
+
const result = await client.callTool("capture_eval_case", args);
|
|
153
|
+
const detects = result.detectsIssue;
|
|
154
|
+
ok(
|
|
155
|
+
ctx,
|
|
156
|
+
detects
|
|
157
|
+
? `Captured ${result.case?.key}: it FAILS on current code, so it detects the issue.`
|
|
158
|
+
: `Captured ${result.case?.key}, but it PASSES on current code — it does not detect what it claims. Tighten the assertion or pick the right measure.`
|
|
159
|
+
);
|
|
160
|
+
printResult(
|
|
161
|
+
io,
|
|
162
|
+
flags,
|
|
163
|
+
result,
|
|
164
|
+
(r) => `${r.case?.key}\n ${r.firstResult?.verdict}: ${r.firstResult?.summary ?? ""}`
|
|
165
|
+
);
|
|
166
|
+
return detects === false ? 1 : 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resultLine(r) {
|
|
170
|
+
const delta = r.delta && r.delta !== "no_baseline" ? ` ${r.delta}` : "";
|
|
171
|
+
return ` ${r.caseKey.padEnd(48)} ${r.verdict.padEnd(6)} ${r.gate.padEnd(16)}${delta} ${r.summary ?? ""}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatRun(run) {
|
|
175
|
+
const t = run.totals ?? {};
|
|
176
|
+
const head = `Run ${run.id ?? "(not persisted)"} — ${run.verdict.toUpperCase()} (${t.cases ?? 0} cases: ${t.pass ?? 0} pass, ${t.fail ?? 0} fail, ${t.skip ?? 0} skip, ${t.error ?? 0} error; ${t.regressed ?? 0} regressed, ${t.readyToGuard ?? 0} ready to guard)${run.gitSha ? ` @ ${run.gitSha.slice(0, 10)}` : ""}`;
|
|
177
|
+
const lines = [head, ...(run.results ?? []).map(resultLine)];
|
|
178
|
+
if (run.unresolved?.length) {
|
|
179
|
+
lines.push("", ...run.unresolved.map((u) => ` ! ${u.key}: ${u.reason}`));
|
|
180
|
+
}
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function evalsRun(ctx) {
|
|
185
|
+
const { client, flags, io } = ctx;
|
|
186
|
+
const args = argsFromFlags(ARGS.run_eval_suite, flags, ctx.positionals);
|
|
187
|
+
const result = await client.callTool("run_eval_suite", args);
|
|
188
|
+
printResult(io, flags, result, (r) => formatRun(r.run));
|
|
189
|
+
return result.run?.verdict === "fail" ? 1 : 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function evalsRunStatus(ctx) {
|
|
193
|
+
const { client, flags, io } = ctx;
|
|
194
|
+
const args = argsFromFlags(ARGS.get_eval_suite_run, flags, ctx.positionals);
|
|
195
|
+
const result = await client.callTool("get_eval_suite_run", args);
|
|
196
|
+
printResult(io, flags, result, (r) => formatRun(r.run));
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
package/lib/commands.mjs
CHANGED