@oqtopus-team/pi-qcaleval 0.0.0-stage → 0.0.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/LICENSE +201 -0
- package/README.md +141 -2
- package/extensions/qcaleval.ts +946 -0
- package/package.json +52 -4
- package/skills/qcaleval/SKILL.md +62 -0
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-qcaleval: evaluate calibration plots with a dedicated calibration VLM.
|
|
3
|
+
*
|
|
4
|
+
* The extension has no QDash dependency. It takes a plot (plus the experiment
|
|
5
|
+
* background and criteria the host supplies for the task, and the context the
|
|
6
|
+
* planner already knows) and asks the evaluation model the six QCalEval
|
|
7
|
+
* questions, each as its own single-turn request the way the benchmark and
|
|
8
|
+
* the Ising-Calibration training data lay them out. The planner decides what
|
|
9
|
+
* to do with the diagnosis; whether a calibration loop continues is the
|
|
10
|
+
* host's policy, not this tool's.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Api, AssistantMessage, ImageContent, Model, Usage } from "@earendil-works/pi-ai";
|
|
14
|
+
import type {
|
|
15
|
+
AgentToolResult,
|
|
16
|
+
ExtensionAPI,
|
|
17
|
+
ExtensionToolContext,
|
|
18
|
+
} from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { Type, type Static } from "typebox";
|
|
20
|
+
|
|
21
|
+
/** Figures per evaluation; a 16k-token deployment holds a few plots comfortably. */
|
|
22
|
+
export const MAX_IMAGES = 4;
|
|
23
|
+
|
|
24
|
+
/** Labeled example plots shown before the query, and the figures they may hold in total. */
|
|
25
|
+
export const MAX_EXAMPLES = 6;
|
|
26
|
+
export const MAX_EXAMPLE_IMAGES = 6;
|
|
27
|
+
|
|
28
|
+
/** Model ids of models fine-tuned for calibration plots; preferred when configured. */
|
|
29
|
+
export const CALIBRATION_MODEL_PATTERN = /ising-calibration/i;
|
|
30
|
+
|
|
31
|
+
/** Output budget when the model declares none; one answer is a few hundred tokens. */
|
|
32
|
+
const DEFAULT_MAX_TOKENS = 2048;
|
|
33
|
+
const MAX_OUTPUT_TOKENS = 4096;
|
|
34
|
+
|
|
35
|
+
export interface ModelSpec {
|
|
36
|
+
provider: string;
|
|
37
|
+
modelId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse an explicit `provider/modelId` override; the model id may contain slashes (nvidia/...). */
|
|
41
|
+
export function parseModelSpec(value: string | undefined): ModelSpec | undefined {
|
|
42
|
+
const spec = (value ?? "").trim();
|
|
43
|
+
if (!spec) return undefined;
|
|
44
|
+
const slash = spec.indexOf("/");
|
|
45
|
+
if (slash <= 0 || slash === spec.length - 1) {
|
|
46
|
+
throw new Error(`QCALEVAL_MODEL must be provider/modelId, got "${spec}"`);
|
|
47
|
+
}
|
|
48
|
+
return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The slice of pi's model registry the resolution needs. */
|
|
52
|
+
export interface ModelLookup {
|
|
53
|
+
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
54
|
+
getAvailable(): Model<Api>[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type EvaluationModel =
|
|
58
|
+
| { model: Model<Api>; fallback: false }
|
|
59
|
+
| { model: Model<Api>; fallback: true }
|
|
60
|
+
| { model?: undefined; error: string };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick the model that judges the plot, without any host-specific setting:
|
|
64
|
+
*
|
|
65
|
+
* 1. `QCALEVAL_MODEL` (`provider/modelId`), an explicit override for the pi CLI;
|
|
66
|
+
* 2. a configured calibration VLM, recognised by its id (Ising-Calibration);
|
|
67
|
+
* 3. the conversation's own model, when it accepts images.
|
|
68
|
+
*
|
|
69
|
+
* A host that declares the calibration model in its models.json gets it
|
|
70
|
+
* everywhere; one that does not (no Ising deployment) still gets a diagnosis
|
|
71
|
+
* from the vision model it already runs, marked as a fallback.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveEvaluationModel(
|
|
74
|
+
registry: ModelLookup,
|
|
75
|
+
current: Model<Api> | undefined,
|
|
76
|
+
override: string | undefined,
|
|
77
|
+
): EvaluationModel {
|
|
78
|
+
const spec = parseModelSpec(override);
|
|
79
|
+
if (spec) {
|
|
80
|
+
const model = registry.find(spec.provider, spec.modelId);
|
|
81
|
+
return model
|
|
82
|
+
? { model, fallback: false }
|
|
83
|
+
: { error: `model ${spec.provider}/${spec.modelId} is not configured (QCALEVAL_MODEL)` };
|
|
84
|
+
}
|
|
85
|
+
const calibration = registry.getAvailable().find((model) => CALIBRATION_MODEL_PATTERN.test(model.id));
|
|
86
|
+
if (calibration) return { model: calibration, fallback: false };
|
|
87
|
+
if (current?.input.includes("image")) return { model: current, fallback: true };
|
|
88
|
+
return {
|
|
89
|
+
error:
|
|
90
|
+
"no evaluation model: declare a calibration VLM (Ising-Calibration) in models.json, or run with a vision model",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Output budget for one answer, within what the model allows. */
|
|
95
|
+
export function outputBudget(model: Pick<Model<Api>, "maxTokens">): number {
|
|
96
|
+
return model.maxTokens > 0 ? Math.min(model.maxTokens, MAX_OUTPUT_TOKENS) : DEFAULT_MAX_TOKENS;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// The six questions
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The QCalEval questions, keyed as the benchmark keys its responses, in the
|
|
105
|
+
* order the paper numbers them (Q1–Q6). Each is asked in its own request.
|
|
106
|
+
*/
|
|
107
|
+
export const QUESTIONS = [
|
|
108
|
+
"technical_description",
|
|
109
|
+
"experimental_conclusion",
|
|
110
|
+
"experimental_significance",
|
|
111
|
+
"fit_reliability",
|
|
112
|
+
"parameter_extraction",
|
|
113
|
+
"calibration_diagnosis",
|
|
114
|
+
] as const;
|
|
115
|
+
export type Question = (typeof QUESTIONS)[number];
|
|
116
|
+
|
|
117
|
+
/** Questions that take labeled demonstrations (the benchmark's ICL mode). */
|
|
118
|
+
export const ICL_QUESTIONS: ReadonlySet<Question> = new Set<Question>([
|
|
119
|
+
"experimental_significance",
|
|
120
|
+
"parameter_extraction",
|
|
121
|
+
"calibration_diagnosis",
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
/** The four outcomes of Q2, as the benchmark words them, with the key the diagnosis uses. */
|
|
125
|
+
export const OUTCOMES = [
|
|
126
|
+
{ key: "expected", option: "Expected behavior", meaning: "Experiment produced usable calibration data" },
|
|
127
|
+
{
|
|
128
|
+
key: "suboptimal",
|
|
129
|
+
option: "Suboptimal parameters",
|
|
130
|
+
meaning: "Working but needs parameter adjustment within this experiment",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: "anomalous",
|
|
134
|
+
option: "Anomalous behavior",
|
|
135
|
+
meaning: "Requires upstream recalibration or shows uncontrollable quantum effects",
|
|
136
|
+
},
|
|
137
|
+
{ key: "apparatus", option: "Apparatus issue", meaning: "No meaningful signal — measurement system misconfigured" },
|
|
138
|
+
] as const;
|
|
139
|
+
export type Outcome = (typeof OUTCOMES)[number]["key"];
|
|
140
|
+
|
|
141
|
+
/** The three answers of Q4, with the key the diagnosis uses. */
|
|
142
|
+
export const FIT_ASSESSMENTS = [
|
|
143
|
+
{ key: "reliable", option: "Reliable" },
|
|
144
|
+
{ key: "unreliable", option: "Unreliable" },
|
|
145
|
+
{ key: "no_fit", option: "No fit" },
|
|
146
|
+
] as const;
|
|
147
|
+
export type FitAssessment = (typeof FIT_ASSESSMENTS)[number]["key"];
|
|
148
|
+
|
|
149
|
+
/** What Q3 asks the model to explain when the host gives no task-specific points. */
|
|
150
|
+
export const DEFAULT_GUIDANCE = [
|
|
151
|
+
"What the observed behavior indicates about the calibration",
|
|
152
|
+
"Whether the measurement is valid for its purpose (sweep range, resolution, signal quality)",
|
|
153
|
+
"What calibration step follows",
|
|
154
|
+
] as const;
|
|
155
|
+
|
|
156
|
+
/** Q6 statuses when the host gives none; a task's own criteria replace them. */
|
|
157
|
+
export const DEFAULT_STATUSES: Readonly<Record<string, string>> = {
|
|
158
|
+
SUCCESS: "The plot shows the expected behavior described above and the result is usable",
|
|
159
|
+
SUBOPTIMAL: "Usable data, but a parameter of this experiment (range, amplitude, resolution) needs adjustment",
|
|
160
|
+
NO_SIGNAL: "Flat or random data with no usable signal",
|
|
161
|
+
ANOMALOUS: "Behavior that calls for upstream recalibration or shows uncontrolled effects",
|
|
162
|
+
};
|
|
163
|
+
export const DEFAULT_RANGE_FORMAT = "(<min>, <max>) of the swept parameter";
|
|
164
|
+
|
|
165
|
+
/** Task-specific pieces of the question templates, supplied by the host. */
|
|
166
|
+
export interface Criteria {
|
|
167
|
+
/** Q3 "Explain:" bullets. */
|
|
168
|
+
guidance?: ReadonlyArray<string>;
|
|
169
|
+
/** Q5 schema: parameter name to its type or description. */
|
|
170
|
+
parameters?: Readonly<Record<string, string>>;
|
|
171
|
+
/** Q6 decision criteria: status code to the criterion; SUCCESS first. */
|
|
172
|
+
statuses?: Readonly<Record<string, string>>;
|
|
173
|
+
/** Q6 suggested-range format, e.g. "(<min 1/alpha>, <max 1/alpha>)". */
|
|
174
|
+
range_format?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface Figure {
|
|
178
|
+
data: string;
|
|
179
|
+
mimeType: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One labeled example plot: its figure(s) and the answers an expert validated. */
|
|
183
|
+
export interface LabeledExample {
|
|
184
|
+
images: ReadonlyArray<Figure>;
|
|
185
|
+
/** What the example shows, e.g. "drag_failure_no_signal" or a short title. */
|
|
186
|
+
scenario?: string;
|
|
187
|
+
/** Q3 answer for the example. */
|
|
188
|
+
analysis?: string;
|
|
189
|
+
/** Q5 answer for the example: JSON text or an object. */
|
|
190
|
+
parameters?: string | Readonly<Record<string, unknown>>;
|
|
191
|
+
/** Q6 status code of the example. */
|
|
192
|
+
status?: string;
|
|
193
|
+
/** Q6 suggested range of the example; "N/A" or empty for a success. */
|
|
194
|
+
suggested_range?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type UserContentBlock =
|
|
198
|
+
| { type: "text"; text: string }
|
|
199
|
+
| { type: "image"; data: string; mimeType: string };
|
|
200
|
+
|
|
201
|
+
export interface PromptInput {
|
|
202
|
+
/** The experiment background: what is swept and what a successful result looks like. */
|
|
203
|
+
background?: string;
|
|
204
|
+
/** The host's context: task, target, anything the background cannot know. */
|
|
205
|
+
context: string;
|
|
206
|
+
/** A specific point to address in the analysis (Q3). */
|
|
207
|
+
question?: string;
|
|
208
|
+
criteria?: Criteria;
|
|
209
|
+
/** The figures; the last one is the measured result to judge. */
|
|
210
|
+
images: ReadonlyArray<Figure>;
|
|
211
|
+
examples?: ReadonlyArray<LabeledExample>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The marker the templates use for the figure to judge, as the benchmark's prompts do. */
|
|
215
|
+
const IMAGE = "<image>";
|
|
216
|
+
|
|
217
|
+
function paragraphs(...parts: Array<string | undefined>): string {
|
|
218
|
+
return parts.filter((part): part is string => Boolean(part && part.trim())).join("\n\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function opening(input: PromptInput, withBackground = true): string {
|
|
222
|
+
const context = input.context.trim();
|
|
223
|
+
return paragraphs(
|
|
224
|
+
withBackground ? input.background?.trim() : undefined,
|
|
225
|
+
context ? `Experiment context: ${context}` : undefined,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Keep examples in order within the example and figure budgets; later ones are dropped. */
|
|
230
|
+
export function fitExamples(examples: ReadonlyArray<LabeledExample>): LabeledExample[] {
|
|
231
|
+
const kept: LabeledExample[] = [];
|
|
232
|
+
let figures = 0;
|
|
233
|
+
for (const example of examples) {
|
|
234
|
+
if (kept.length >= MAX_EXAMPLES) break;
|
|
235
|
+
const labeled = Boolean(example.analysis?.trim() || example.status?.trim() || example.parameters);
|
|
236
|
+
if (!labeled || !example.images?.length) continue;
|
|
237
|
+
if (figures + example.images.length > MAX_EXAMPLE_IMAGES) break;
|
|
238
|
+
figures += example.images.length;
|
|
239
|
+
kept.push(example);
|
|
240
|
+
}
|
|
241
|
+
return kept;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parametersText(value: LabeledExample["parameters"]): string | undefined {
|
|
245
|
+
if (value === undefined || value === null) return undefined;
|
|
246
|
+
if (typeof value === "string") return value.trim() || undefined;
|
|
247
|
+
return JSON.stringify(value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function statusList(statuses: Readonly<Record<string, string>>): string {
|
|
251
|
+
return Object.entries(statuses)
|
|
252
|
+
.map(([code, criterion]) => `- ${code}: ${criterion}`)
|
|
253
|
+
.join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The text of one question's prompt, with `<image>` where each figure goes:
|
|
258
|
+
* the example figures in order, then the figure to judge. Templates follow the
|
|
259
|
+
* QCalEval dataset's prompts; the host's criteria fill the family-specific parts.
|
|
260
|
+
*/
|
|
261
|
+
export function questionTemplate(question: Question, input: PromptInput): { text: string; images: Figure[] } {
|
|
262
|
+
const criteria = input.criteria ?? {};
|
|
263
|
+
const examples = fitExamples(input.examples ?? []);
|
|
264
|
+
const exampleImages: Figure[] = [];
|
|
265
|
+
const demo = (example: LabeledExample): string => {
|
|
266
|
+
exampleImages.push(...example.images);
|
|
267
|
+
return example.images.map(() => IMAGE).join(" ");
|
|
268
|
+
};
|
|
269
|
+
const specific = input.question?.trim();
|
|
270
|
+
let text: string;
|
|
271
|
+
|
|
272
|
+
switch (question) {
|
|
273
|
+
case "technical_description": {
|
|
274
|
+
text = [
|
|
275
|
+
`Describe the figure ${IMAGE} in JSON format.`,
|
|
276
|
+
"",
|
|
277
|
+
"Required fields:",
|
|
278
|
+
"{",
|
|
279
|
+
' "plot_type": "scatter" | "line" | "heatmap" | "histogram",',
|
|
280
|
+
' "x_axis": {"label": string, "scale": "linear" | "log", "range": [min, max]},',
|
|
281
|
+
' "y_axis": {"label": string, "scale": "linear" | "log", "range": [min, max]},',
|
|
282
|
+
' "main_features": string',
|
|
283
|
+
"}",
|
|
284
|
+
].join("\n");
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case "experimental_conclusion": {
|
|
288
|
+
text = paragraphs(
|
|
289
|
+
opening(input),
|
|
290
|
+
`Based on what you observe in the data ${IMAGE}, classify the experimental outcome.`,
|
|
291
|
+
["Options:", ...OUTCOMES.map((o) => `- ${o.option}: ${o.meaning}`)].join("\n"),
|
|
292
|
+
"Provide your answer as:\nClassification: <your choice>\nReason: <brief explanation>",
|
|
293
|
+
);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "experimental_significance": {
|
|
297
|
+
const shown = examples.filter((example) => example.analysis?.trim());
|
|
298
|
+
if (shown.length > 0) {
|
|
299
|
+
text = paragraphs(
|
|
300
|
+
opening(input),
|
|
301
|
+
"Here are example analyses for each scenario type in this experiment family:",
|
|
302
|
+
...shown.map(
|
|
303
|
+
(example, index) =>
|
|
304
|
+
`${demo(example)} Scenario "${example.scenario?.trim() || `example ${index + 1}`}":\n${example.analysis!.trim()}`,
|
|
305
|
+
),
|
|
306
|
+
specific ? `Also address: ${specific}` : undefined,
|
|
307
|
+
`Now analyze this chart:\n\n${IMAGE}`,
|
|
308
|
+
);
|
|
309
|
+
} else {
|
|
310
|
+
const guidance = criteria.guidance?.filter((line) => line.trim()).length
|
|
311
|
+
? criteria.guidance.filter((line) => line.trim())
|
|
312
|
+
: DEFAULT_GUIDANCE;
|
|
313
|
+
text = paragraphs(
|
|
314
|
+
opening(input),
|
|
315
|
+
`What does this result ${IMAGE} imply?`,
|
|
316
|
+
["Explain:", ...guidance.map((line) => `- ${line.trim()}`), ...(specific ? [`- ${specific}`] : [])].join(
|
|
317
|
+
"\n",
|
|
318
|
+
),
|
|
319
|
+
"Provide your assessment.",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case "fit_reliability": {
|
|
325
|
+
text = paragraphs(
|
|
326
|
+
opening(input),
|
|
327
|
+
`Assess whether the fit to the data in this plot ${IMAGE} is reliable for parameter extraction.`,
|
|
328
|
+
["Options:", ...FIT_ASSESSMENTS.map((f) => `- ${f.option}`)].join("\n"),
|
|
329
|
+
"Provide your answer as:\nAssessment: <your choice>\nReason: <brief explanation>",
|
|
330
|
+
);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "parameter_extraction": {
|
|
334
|
+
const shown = examples.find((example) => parametersText(example.parameters));
|
|
335
|
+
const schema = Object.entries(criteria.parameters ?? {});
|
|
336
|
+
if (shown) {
|
|
337
|
+
text = paragraphs(
|
|
338
|
+
opening(input),
|
|
339
|
+
"Here is an example of parameter extraction from a similar chart:",
|
|
340
|
+
`${demo(shown)} Extracted parameters: ${parametersText(shown.parameters)}`,
|
|
341
|
+
`Now extract parameters from this chart:\n\n${IMAGE}`,
|
|
342
|
+
);
|
|
343
|
+
} else if (schema.length > 0) {
|
|
344
|
+
text = paragraphs(
|
|
345
|
+
opening(input),
|
|
346
|
+
`Extract the following parameters from this plot ${IMAGE}.`,
|
|
347
|
+
`Report in JSON format:\n{${schema.map(([name, type]) => `"${name}": ${type}`).join(", ")}}\nUse "Unreliable" for a value the plot does not determine.`,
|
|
348
|
+
);
|
|
349
|
+
} else {
|
|
350
|
+
text = paragraphs(
|
|
351
|
+
opening(input),
|
|
352
|
+
`Extract the physical parameters this plot ${IMAGE} determines.`,
|
|
353
|
+
'Report in JSON format, one key per parameter with its value and unit, e.g. {"<parameter>": <value>, "<parameter>_unit": "<unit>"}. Use "Unreliable" for a value the plot does not determine.',
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "calibration_diagnosis": {
|
|
359
|
+
const statuses = criteria.statuses && Object.keys(criteria.statuses).length > 0 ? criteria.statuses : DEFAULT_STATUSES;
|
|
360
|
+
const range = criteria.range_format?.trim() || DEFAULT_RANGE_FORMAT;
|
|
361
|
+
const shown = examples.filter((example) => example.status?.trim());
|
|
362
|
+
if (shown.length > 0) {
|
|
363
|
+
text = paragraphs(
|
|
364
|
+
opening(input),
|
|
365
|
+
`Classify this chart given the following labeled examples.\n\nDECISION CRITERIA\n${statusList(statuses)}`,
|
|
366
|
+
...shown.map(
|
|
367
|
+
(example) =>
|
|
368
|
+
`${demo(example)} Status: ${example.status!.trim().toUpperCase()}\nSuggested range: ${example.suggested_range?.trim() || "N/A"}`,
|
|
369
|
+
),
|
|
370
|
+
`Now classify this chart:\n\n${IMAGE}`,
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
text = paragraphs(
|
|
374
|
+
opening(input),
|
|
375
|
+
`Evaluate the image ${IMAGE} and determine the experiment status.`,
|
|
376
|
+
`DECISION CRITERIA\n${statusList(statuses)}`,
|
|
377
|
+
`When the status is not SUCCESS, provide a SPECIFIC suggested ${range}.`,
|
|
378
|
+
`The response MUST follow this exact format:\n\nStatus: <one of the listed statuses>\nSuggested range: ${range} (or "N/A" if SUCCESS)\nNotes: <1-3 sentences explaining your reasoning>`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// A reference figure ahead of the measured one (a task review opens with
|
|
386
|
+
// the expected plot) is shown first, as the benchmark shows its examples.
|
|
387
|
+
const query = input.images[input.images.length - 1];
|
|
388
|
+
const references = input.images.slice(0, -1);
|
|
389
|
+
if (references.length > 0) {
|
|
390
|
+
text = paragraphs(
|
|
391
|
+
`Reference figure${references.length === 1 ? "" : "s"} for comparison (expected result); the last figure is the measured result to judge:\n${references.map(() => IMAGE).join(" ")}`,
|
|
392
|
+
text,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return { text, images: [...references, ...exampleImages, ...(query ? [query] : [])] };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* One question's user turn as content blocks: the template text with each
|
|
400
|
+
* `<image>` replaced by the next figure, images as native blocks. `prompt` is
|
|
401
|
+
* the same turn as text with `[figure]` where an image was, for the record.
|
|
402
|
+
*/
|
|
403
|
+
export function buildQuestionContent(
|
|
404
|
+
question: Question,
|
|
405
|
+
input: PromptInput,
|
|
406
|
+
): { content: UserContentBlock[]; prompt: string } {
|
|
407
|
+
const { text, images } = questionTemplate(question, input);
|
|
408
|
+
const content: UserContentBlock[] = [];
|
|
409
|
+
const parts = text.split(IMAGE);
|
|
410
|
+
parts.forEach((part, index) => {
|
|
411
|
+
if (part.trim()) content.push({ type: "text", text: part.trim() });
|
|
412
|
+
const figure = images[index];
|
|
413
|
+
if (index < parts.length - 1 && figure) content.push({ type: "image", data: figure.data, mimeType: figure.mimeType });
|
|
414
|
+
});
|
|
415
|
+
const prompt = text.replace(/<image>/g, "[figure]").replace(/\n{3,}/g, "\n\n").trim();
|
|
416
|
+
return { content, prompt };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The prompts of several questions as one text, headed by the question key, for the record. */
|
|
420
|
+
export function promptRecord(prompts: ReadonlyArray<{ question: Question; prompt: string }>): string {
|
|
421
|
+
return prompts.map(({ question, prompt }) => `## ${question}\n${prompt}`).join("\n\n");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Answers
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/** Pull the text blocks of the assistant answer, dropping thinking. */
|
|
429
|
+
export function answerText(message: AssistantMessage): string {
|
|
430
|
+
return message.content
|
|
431
|
+
.map((block) => (block.type === "text" ? block.text : ""))
|
|
432
|
+
.join("")
|
|
433
|
+
.trim();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Parse the first JSON object in an answer, tolerating a code fence, a
|
|
438
|
+
* "thought" preamble or trailing commentary around it: the first balanced
|
|
439
|
+
* `{...}` is taken.
|
|
440
|
+
*/
|
|
441
|
+
export function parseJsonObject(text: string): Record<string, unknown> | undefined {
|
|
442
|
+
const start = text.indexOf("{");
|
|
443
|
+
if (start < 0) return undefined;
|
|
444
|
+
let depth = 0;
|
|
445
|
+
let inString = false;
|
|
446
|
+
for (let i = start; i < text.length; i++) {
|
|
447
|
+
const ch = text[i];
|
|
448
|
+
if (inString) {
|
|
449
|
+
if (ch === "\\") i++;
|
|
450
|
+
else if (ch === '"') inString = false;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (ch === '"') inString = true;
|
|
454
|
+
else if (ch === "{") depth++;
|
|
455
|
+
else if (ch === "}" && --depth === 0) {
|
|
456
|
+
return parseObject(text.slice(start, i + 1));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Parse one JSON object. The model writes LaTeX such as `\pi` inside strings,
|
|
464
|
+
* which is an invalid JSON escape, so a failed strict parse is retried with
|
|
465
|
+
* every backslash that does not start a JSON escape doubled.
|
|
466
|
+
*/
|
|
467
|
+
function parseObject(json: string): Record<string, unknown> | undefined {
|
|
468
|
+
for (const candidate of [json, json.replace(/\\(?!["\\/bfnrtu])/g, "\\\\")]) {
|
|
469
|
+
try {
|
|
470
|
+
const parsed: unknown = JSON.parse(candidate);
|
|
471
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
472
|
+
return parsed as Record<string, unknown>;
|
|
473
|
+
}
|
|
474
|
+
return undefined;
|
|
475
|
+
} catch {
|
|
476
|
+
// Try the lenient candidate next.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The value after `Label:` on its own line (markdown emphasis tolerated), or undefined. */
|
|
483
|
+
export function labeledLine(text: string, label: string): string | undefined {
|
|
484
|
+
const match = text.match(new RegExp(`^[ \\t]*[*_#]*${label}[*_]*[ \\t]*:[*_ \\t]*(.+?)[ \\t]*$`, "im"));
|
|
485
|
+
return match?.[1]?.replace(/[*_]+$/, "").trim() || undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Everything after `Label:` to the end of the text, across lines. */
|
|
489
|
+
function labeledRest(text: string, label: string): string | undefined {
|
|
490
|
+
const match = text.match(new RegExp(`^[ \\t]*[*_#]*${label}[*_]*[ \\t]*:[*_ \\t]*([\\s\\S]*)$`, "im"));
|
|
491
|
+
return match?.[1]?.trim() || undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function parseOutcome(text: string): Outcome | null {
|
|
495
|
+
const line = (labeledLine(text, "Classification") ?? text).toLowerCase();
|
|
496
|
+
for (const outcome of OUTCOMES) {
|
|
497
|
+
if (line.includes(outcome.key) || line.includes(outcome.option.toLowerCase())) return outcome.key;
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function parseFitAssessment(text: string): FitAssessment | null {
|
|
503
|
+
const line = (labeledLine(text, "Assessment") ?? text).toLowerCase();
|
|
504
|
+
if (/\bno fit\b|\bno_fit\b/.test(line)) return "no_fit";
|
|
505
|
+
if (/\bunreliable\b/.test(line)) return "unreliable";
|
|
506
|
+
if (/\breliable\b/.test(line)) return "reliable";
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** A status code as the benchmark normalises it: trimmed, upper-cased, spaces as underscores. */
|
|
511
|
+
export function normalizeStatus(value: string | undefined): string | null {
|
|
512
|
+
const code = (value ?? "")
|
|
513
|
+
.replace(/[`*]+/g, "")
|
|
514
|
+
.trim()
|
|
515
|
+
.split(/\s*\(|\s+[—–-]\s+/)[0]
|
|
516
|
+
?.trim()
|
|
517
|
+
.toUpperCase()
|
|
518
|
+
.replace(/[\s-]+/g, "_");
|
|
519
|
+
return code && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The parsed value of one answer, in the shape the diagnosis stores for that question. */
|
|
523
|
+
export function parseAnswer(question: Question, text: string): unknown {
|
|
524
|
+
switch (question) {
|
|
525
|
+
case "technical_description":
|
|
526
|
+
return parseJsonObject(text) ?? text;
|
|
527
|
+
case "experimental_conclusion":
|
|
528
|
+
return { classification: parseOutcome(text), reason: labeledRest(text, "Reason") ?? "" };
|
|
529
|
+
case "experimental_significance":
|
|
530
|
+
return text;
|
|
531
|
+
case "fit_reliability":
|
|
532
|
+
return { assessment: parseFitAssessment(text), reason: labeledRest(text, "Reason") ?? "" };
|
|
533
|
+
case "parameter_extraction": {
|
|
534
|
+
const object = parseJsonObject(text);
|
|
535
|
+
if (object) return object;
|
|
536
|
+
return text.trim().toLowerCase() === "null" ? null : text;
|
|
537
|
+
}
|
|
538
|
+
case "calibration_diagnosis": {
|
|
539
|
+
const range = labeledLine(text, "Suggested range");
|
|
540
|
+
return {
|
|
541
|
+
status: normalizeStatus(labeledLine(text, "Status")),
|
|
542
|
+
suggested_range: range && !/^n\/?a$/i.test(range) ? range : null,
|
|
543
|
+
notes: labeledRest(text, "Notes") ?? "",
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The diagnosis from the parsed answers, plus `success`: the Q6 status is
|
|
551
|
+
* SUCCESS; without a Q6 answer, the Q2 outcome is expected; null when neither
|
|
552
|
+
* was answered.
|
|
553
|
+
*/
|
|
554
|
+
export function assembleDiagnosis(answers: Partial<Record<Question, unknown>>): Record<string, unknown> {
|
|
555
|
+
const diagnosis: Record<string, unknown> = {};
|
|
556
|
+
for (const question of QUESTIONS) {
|
|
557
|
+
if (question in answers) diagnosis[question] = answers[question];
|
|
558
|
+
}
|
|
559
|
+
const status = (answers.calibration_diagnosis as { status?: unknown } | undefined)?.status;
|
|
560
|
+
const outcome = (answers.experimental_conclusion as { classification?: unknown } | undefined)?.classification;
|
|
561
|
+
diagnosis.success =
|
|
562
|
+
typeof status === "string"
|
|
563
|
+
? status === "SUCCESS"
|
|
564
|
+
: answers.calibration_diagnosis === undefined && typeof outcome === "string"
|
|
565
|
+
? outcome === "expected"
|
|
566
|
+
: null;
|
|
567
|
+
return diagnosis;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** One model call made for an answer, for the record: how long it took and how it ended. */
|
|
571
|
+
export interface CallRecord {
|
|
572
|
+
question: Question;
|
|
573
|
+
duration_ms: number;
|
|
574
|
+
stop_reason: string;
|
|
575
|
+
/** Prompt tokens the model counted for the call, images included. */
|
|
576
|
+
input_tokens?: number;
|
|
577
|
+
output_tokens?: number;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The answers of one evaluation, read for the record. */
|
|
581
|
+
export interface Answers {
|
|
582
|
+
diagnosis: Record<string, unknown>;
|
|
583
|
+
/** Every answered question's text, as returned. */
|
|
584
|
+
answers: Partial<Record<Question, string>>;
|
|
585
|
+
questions: Question[];
|
|
586
|
+
/** Questions asked that got no answer (a model error, or an empty reply). */
|
|
587
|
+
missing: Question[];
|
|
588
|
+
/** Model calls made, retries included. */
|
|
589
|
+
attempts: number;
|
|
590
|
+
stopReason: string;
|
|
591
|
+
usage?: Usage;
|
|
592
|
+
calls: CallRecord[];
|
|
593
|
+
/** The error of the last failed call, when every call for some question failed. */
|
|
594
|
+
errors: Partial<Record<Question, string>>;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Token usage of two calls together, so a retried answer is billed as both. */
|
|
598
|
+
export function addUsage(a: Usage | undefined, b: Usage | undefined): Usage | undefined {
|
|
599
|
+
if (!a || !b) return a ?? b;
|
|
600
|
+
const sum = (x: number | undefined, y: number | undefined) => (x ?? 0) + (y ?? 0);
|
|
601
|
+
return {
|
|
602
|
+
...a,
|
|
603
|
+
input: sum(a.input, b.input),
|
|
604
|
+
output: sum(a.output, b.output),
|
|
605
|
+
cacheRead: sum(a.cacheRead, b.cacheRead),
|
|
606
|
+
cacheWrite: sum(a.cacheWrite, b.cacheWrite),
|
|
607
|
+
...(a.reasoning === undefined && b.reasoning === undefined
|
|
608
|
+
? {}
|
|
609
|
+
: { reasoning: sum(a.reasoning, b.reasoning) }),
|
|
610
|
+
totalTokens: sum(a.totalTokens, b.totalTokens),
|
|
611
|
+
cost: {
|
|
612
|
+
input: sum(a.cost?.input, b.cost?.input),
|
|
613
|
+
output: sum(a.cost?.output, b.cost?.output),
|
|
614
|
+
cacheRead: sum(a.cost?.cacheRead, b.cost?.cacheRead),
|
|
615
|
+
cacheWrite: sum(a.cost?.cacheWrite, b.cost?.cacheWrite),
|
|
616
|
+
total: sum(a.cost?.total, b.cost?.total),
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Ask every question, each in its own request, all at once. A call that
|
|
623
|
+
* fails (a transport or server error) is retried once, as the benchmark
|
|
624
|
+
* retries API failures; an answer is never re-asked for its content. Each
|
|
625
|
+
* call's duration and stop reason are kept, so a slow evaluation can be
|
|
626
|
+
* traced to the question that ran long.
|
|
627
|
+
*/
|
|
628
|
+
export async function askQuestions(
|
|
629
|
+
questions: ReadonlyArray<Question>,
|
|
630
|
+
call: (question: Question) => Promise<AssistantMessage>,
|
|
631
|
+
clock: () => number = Date.now,
|
|
632
|
+
): Promise<Answers> {
|
|
633
|
+
const calls: CallRecord[] = [];
|
|
634
|
+
const usages: Usage[] = [];
|
|
635
|
+
const ask = async (question: Question): Promise<AssistantMessage> => {
|
|
636
|
+
const started = clock();
|
|
637
|
+
const message = await call(question);
|
|
638
|
+
calls.push({
|
|
639
|
+
question,
|
|
640
|
+
duration_ms: Math.max(0, clock() - started),
|
|
641
|
+
stop_reason: message.stopReason,
|
|
642
|
+
...(message.usage ? { input_tokens: message.usage.input, output_tokens: message.usage.output } : {}),
|
|
643
|
+
});
|
|
644
|
+
if (message.usage) usages.push(message.usage);
|
|
645
|
+
return message;
|
|
646
|
+
};
|
|
647
|
+
const results = await Promise.all(
|
|
648
|
+
questions.map(async (question) => {
|
|
649
|
+
let message = await ask(question);
|
|
650
|
+
if (message.stopReason === "error") message = await ask(question);
|
|
651
|
+
return { question, message };
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
const answers: Partial<Record<Question, string>> = {};
|
|
655
|
+
const parsed: Partial<Record<Question, unknown>> = {};
|
|
656
|
+
const errors: Partial<Record<Question, string>> = {};
|
|
657
|
+
const missing: Question[] = [];
|
|
658
|
+
for (const { question, message } of results) {
|
|
659
|
+
const text = message.stopReason === "error" ? "" : answerText(message);
|
|
660
|
+
if (!text) {
|
|
661
|
+
missing.push(question);
|
|
662
|
+
if (message.stopReason === "error") errors[question] = message.errorMessage ?? "model error";
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
answers[question] = text;
|
|
666
|
+
parsed[question] = parseAnswer(question, text);
|
|
667
|
+
}
|
|
668
|
+
// Calls finish in any order; the record lists them by question.
|
|
669
|
+
calls.sort((a, b) => QUESTIONS.indexOf(a.question) - QUESTIONS.indexOf(b.question));
|
|
670
|
+
const stops = calls.map((c) => c.stop_reason);
|
|
671
|
+
const stopReason = stops.includes("length") ? "length" : missing.length === questions.length && stops.length ? stops[stops.length - 1]! : "stop";
|
|
672
|
+
return {
|
|
673
|
+
diagnosis: assembleDiagnosis(parsed),
|
|
674
|
+
answers,
|
|
675
|
+
questions: [...questions],
|
|
676
|
+
missing,
|
|
677
|
+
attempts: calls.length,
|
|
678
|
+
stopReason,
|
|
679
|
+
usage: usages.reduce<Usage | undefined>((sum, usage) => addUsage(sum, usage), undefined),
|
|
680
|
+
calls,
|
|
681
|
+
errors,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// The tool
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
const imageSchema = Type.Object({
|
|
690
|
+
data: Type.String({ description: "Base64 image bytes (no data: prefix)" }),
|
|
691
|
+
mimeType: Type.String({ description: "image/png or image/jpeg" }),
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const exampleSchema = Type.Object({
|
|
695
|
+
images: Type.Array(imageSchema, { minItems: 1 }),
|
|
696
|
+
scenario: Type.Optional(Type.String({ description: "What the example shows, in a few words" })),
|
|
697
|
+
analysis: Type.Optional(Type.String({ description: "The expert's analysis of the example (its experimental_significance answer)" })),
|
|
698
|
+
parameters: Type.Optional(
|
|
699
|
+
Type.Union([Type.String(), Type.Record(Type.String(), Type.Unknown())], {
|
|
700
|
+
description: "The parameters read from the example plot, as JSON",
|
|
701
|
+
}),
|
|
702
|
+
),
|
|
703
|
+
status: Type.Optional(Type.String({ description: "The example's status code (its calibration_diagnosis answer)" })),
|
|
704
|
+
suggested_range: Type.Optional(Type.String({ description: "The example's suggested range, or N/A" })),
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const criteriaSchema = Type.Object({
|
|
708
|
+
guidance: Type.Optional(Type.Array(Type.String(), { description: "Points the analysis should explain" })),
|
|
709
|
+
parameters: Type.Optional(
|
|
710
|
+
Type.Record(Type.String(), Type.String(), { description: "Parameters to extract: name to type, e.g. float" }),
|
|
711
|
+
),
|
|
712
|
+
statuses: Type.Optional(
|
|
713
|
+
Type.Record(Type.String(), Type.String(), { description: "Decision criteria: status code to criterion, SUCCESS first" }),
|
|
714
|
+
),
|
|
715
|
+
range_format: Type.Optional(Type.String({ description: "Format of the suggested range for a failed status" })),
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const questionSchema = Type.Union(QUESTIONS.map((question) => Type.Literal(question)));
|
|
719
|
+
|
|
720
|
+
const parameters = Type.Object({
|
|
721
|
+
context: Type.String({
|
|
722
|
+
description:
|
|
723
|
+
"Experiment context the background cannot know: task name, target qubit/coupling, swept range, previous values",
|
|
724
|
+
}),
|
|
725
|
+
question: Type.Optional(
|
|
726
|
+
Type.String({ description: "A specific point the analysis should address, on top of the standard questions" }),
|
|
727
|
+
),
|
|
728
|
+
task_name: Type.Optional(
|
|
729
|
+
Type.String({
|
|
730
|
+
description:
|
|
731
|
+
"The calibration task's name as the host knows it (e.g. CheckRabi). Lets the host supply the background and criteria for the experiment.",
|
|
732
|
+
}),
|
|
733
|
+
),
|
|
734
|
+
knowledge: Type.Optional(
|
|
735
|
+
Type.String({
|
|
736
|
+
description:
|
|
737
|
+
"Normally omitted: the host supplies the experiment background for `task_name` (what is swept, what a successful result looks like). Fill only to override it with text you were given.",
|
|
738
|
+
}),
|
|
739
|
+
),
|
|
740
|
+
criteria: Type.Optional(criteriaSchema),
|
|
741
|
+
images: Type.Optional(
|
|
742
|
+
Type.Array(imageSchema, {
|
|
743
|
+
description:
|
|
744
|
+
"Normally omitted: the host supplies the newest figures of the conversation. Fill only with real base64 image bytes you were given; never with placeholders or descriptions.",
|
|
745
|
+
}),
|
|
746
|
+
),
|
|
747
|
+
max_images: Type.Optional(
|
|
748
|
+
Type.Integer({
|
|
749
|
+
minimum: 1,
|
|
750
|
+
maximum: MAX_IMAGES,
|
|
751
|
+
description:
|
|
752
|
+
"How many of the newest figures the host should supply when `images` is omitted (default 1). Use 2 when an expected reference figure precedes the measured one, and describe their order in `context`.",
|
|
753
|
+
}),
|
|
754
|
+
),
|
|
755
|
+
use_examples: Type.Optional(
|
|
756
|
+
Type.Boolean({
|
|
757
|
+
description:
|
|
758
|
+
"Set true only when the user asked to judge against labeled reference examples: the host then supplies its expert-labeled example plots for `task_name` (in-context examples). Default false.",
|
|
759
|
+
}),
|
|
760
|
+
),
|
|
761
|
+
examples: Type.Optional(
|
|
762
|
+
Type.Array(exampleSchema, {
|
|
763
|
+
maxItems: MAX_EXAMPLES,
|
|
764
|
+
description:
|
|
765
|
+
"Normally omitted: the host supplies labeled examples when `use_examples` is true. Each is one or more real figures with the expert's answers.",
|
|
766
|
+
}),
|
|
767
|
+
),
|
|
768
|
+
questions: Type.Optional(
|
|
769
|
+
Type.Array(questionSchema, {
|
|
770
|
+
minItems: 1,
|
|
771
|
+
description: "The questions to ask (default: all six, in order).",
|
|
772
|
+
}),
|
|
773
|
+
),
|
|
774
|
+
dry_run: Type.Optional(
|
|
775
|
+
Type.Boolean({
|
|
776
|
+
description:
|
|
777
|
+
"Build the prompts and return them without asking the model, to show what it would be sent. The figure may be omitted.",
|
|
778
|
+
}),
|
|
779
|
+
),
|
|
780
|
+
model: Type.Optional(
|
|
781
|
+
Type.String({
|
|
782
|
+
description:
|
|
783
|
+
"Normally omitted: the evaluation model as `provider/modelId`, when the host offers several. It must be configured; otherwise the configured calibration VLM judges.",
|
|
784
|
+
}),
|
|
785
|
+
),
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
/** Stored on the tool result; the host reads `diagnosis` without reparsing text. */
|
|
789
|
+
export interface EvaluationDetails {
|
|
790
|
+
/** The model that judged the plot; absent when none could be resolved. */
|
|
791
|
+
model?: ModelSpec;
|
|
792
|
+
/** True when no calibration VLM was configured and the conversation's model judged instead. */
|
|
793
|
+
fallback?: boolean;
|
|
794
|
+
/** The parsed answers by question, plus `success`. */
|
|
795
|
+
diagnosis?: Record<string, unknown> | null;
|
|
796
|
+
/** Every answered question's text, as returned. */
|
|
797
|
+
answers?: Partial<Record<Question, string>>;
|
|
798
|
+
/** The questions asked, in order. */
|
|
799
|
+
questions?: Question[];
|
|
800
|
+
/** Questions asked that got no answer. */
|
|
801
|
+
missing_keys?: Question[];
|
|
802
|
+
/** Model calls made, retries included. */
|
|
803
|
+
attempts?: number;
|
|
804
|
+
/** "length" when a call hit the output budget; otherwise how the calls ended. */
|
|
805
|
+
stop_reason?: string;
|
|
806
|
+
/** Every model call made, by question: duration, stop reason, output tokens. */
|
|
807
|
+
calls?: CallRecord[];
|
|
808
|
+
/** The user turns the model was sent, one per question, so the host can show what it saw. */
|
|
809
|
+
prompt?: string;
|
|
810
|
+
/** The same turns by question. */
|
|
811
|
+
prompts?: Partial<Record<Question, string>>;
|
|
812
|
+
/** How many labeled examples were shown. */
|
|
813
|
+
examples_used?: number;
|
|
814
|
+
/** True when the prompts were built but the model was not asked. */
|
|
815
|
+
dry_run?: boolean;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function failure(spec: ModelSpec | undefined, text: string): AgentToolResult<EvaluationDetails> {
|
|
819
|
+
return {
|
|
820
|
+
content: [{ type: "text", text: `qcal_evaluate: ${text}` }],
|
|
821
|
+
details: spec ? { model: spec } : {},
|
|
822
|
+
isError: true,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export default function qcalevalExtension(pi: ExtensionAPI) {
|
|
827
|
+
pi.registerTool<typeof parameters, EvaluationDetails>({
|
|
828
|
+
name: "qcal_evaluate",
|
|
829
|
+
label: "Evaluate Calibration Plot",
|
|
830
|
+
description:
|
|
831
|
+
"Send a calibration experiment plot to the calibration-evaluation VLM and return its diagnosis: technical description, outcome classification, significance, fit reliability, extracted parameters, and calibration status.",
|
|
832
|
+
promptSnippet: "Diagnose a calibration plot with the dedicated calibration VLM",
|
|
833
|
+
promptGuidelines: [
|
|
834
|
+
"Use qcal_evaluate for every calibration figure you need to judge, instead of judging it yourself.",
|
|
835
|
+
"Pass the task name and target as context so the diagnosis is specific; do not pass fitted values, the model reads the plot.",
|
|
836
|
+
"Pass task_name when you know it: the host then supplies the background and criteria for that task.",
|
|
837
|
+
"Set use_examples true only when the user asked for a judgement against labeled reference examples.",
|
|
838
|
+
"Treat the returned diagnosis as the evaluation of record; add your own reasoning only on top of it.",
|
|
839
|
+
],
|
|
840
|
+
parameters,
|
|
841
|
+
async execute(
|
|
842
|
+
_toolCallId: string,
|
|
843
|
+
params: Static<typeof parameters>,
|
|
844
|
+
signal: AbortSignal | undefined,
|
|
845
|
+
_onUpdate: unknown,
|
|
846
|
+
ctx: ExtensionToolContext,
|
|
847
|
+
): Promise<AgentToolResult<EvaluationDetails>> {
|
|
848
|
+
let resolved: EvaluationModel;
|
|
849
|
+
try {
|
|
850
|
+
resolved = resolveEvaluationModel(ctx.modelRegistry, ctx.model, params.model?.trim() || process.env.QCALEVAL_MODEL);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
return failure(undefined, error instanceof Error ? error.message : String(error));
|
|
853
|
+
}
|
|
854
|
+
const dryRun = params.dry_run === true;
|
|
855
|
+
if (!resolved.model && !dryRun) return failure(undefined, resolved.error);
|
|
856
|
+
const spec: ModelSpec | undefined = resolved.model
|
|
857
|
+
? { provider: resolved.model.provider, modelId: resolved.model.id }
|
|
858
|
+
: undefined;
|
|
859
|
+
const images: ImageContent[] = (params.images ?? []).map((image) => ({
|
|
860
|
+
type: "image",
|
|
861
|
+
data: image.data,
|
|
862
|
+
mimeType: image.mimeType,
|
|
863
|
+
}));
|
|
864
|
+
if (images.length === 0) {
|
|
865
|
+
if (!dryRun) return failure(spec, "no plot was given. Pass the figure in `images` or fetch it first.");
|
|
866
|
+
// The prompts are the same whatever the figure; a placeholder stands in.
|
|
867
|
+
images.push({ type: "image", data: "", mimeType: "image/png" });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const requested = new Set<string>(params.questions ?? []);
|
|
871
|
+
const questions: Question[] = requested.size > 0 ? QUESTIONS.filter((question) => requested.has(question)) : [...QUESTIONS];
|
|
872
|
+
const input: PromptInput = {
|
|
873
|
+
background: params.knowledge,
|
|
874
|
+
context: params.context,
|
|
875
|
+
question: params.question,
|
|
876
|
+
criteria: params.criteria,
|
|
877
|
+
images: images.map((image) => ({ data: image.data, mimeType: image.mimeType })),
|
|
878
|
+
examples: params.examples ?? [],
|
|
879
|
+
};
|
|
880
|
+
const turns = new Map(questions.map((question) => [question, buildQuestionContent(question, input)]));
|
|
881
|
+
const examplesUsed = fitExamples(input.examples ?? []).length;
|
|
882
|
+
const prompts = Object.fromEntries(questions.map((question) => [question, turns.get(question)!.prompt])) as Partial<
|
|
883
|
+
Record<Question, string>
|
|
884
|
+
>;
|
|
885
|
+
const prompt = promptRecord(questions.map((question) => ({ question, prompt: turns.get(question)!.prompt })));
|
|
886
|
+
if (dryRun || !resolved.model) {
|
|
887
|
+
return {
|
|
888
|
+
content: [{ type: "text", text: prompt }],
|
|
889
|
+
details: {
|
|
890
|
+
...(spec ? { model: spec } : {}),
|
|
891
|
+
...(resolved.model && resolved.fallback ? { fallback: true } : {}),
|
|
892
|
+
dry_run: true,
|
|
893
|
+
questions,
|
|
894
|
+
prompt,
|
|
895
|
+
prompts,
|
|
896
|
+
...(examplesUsed ? { examples_used: examplesUsed } : {}),
|
|
897
|
+
},
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
const { model, fallback } = resolved;
|
|
901
|
+
const answers = await askQuestions(
|
|
902
|
+
questions,
|
|
903
|
+
(question) =>
|
|
904
|
+
ctx.modelRegistry.complete(
|
|
905
|
+
model,
|
|
906
|
+
{
|
|
907
|
+
messages: [
|
|
908
|
+
{
|
|
909
|
+
role: "user",
|
|
910
|
+
content: turns.get(question)!.content.map((block) =>
|
|
911
|
+
block.type === "text"
|
|
912
|
+
? { type: "text" as const, text: block.text }
|
|
913
|
+
: { type: "image" as const, data: block.data, mimeType: block.mimeType },
|
|
914
|
+
),
|
|
915
|
+
timestamp: Date.now(),
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
},
|
|
919
|
+
{ signal, maxTokens: outputBudget(model) },
|
|
920
|
+
),
|
|
921
|
+
);
|
|
922
|
+
if (answers.missing.length === questions.length) {
|
|
923
|
+
const error = Object.values(answers.errors)[0];
|
|
924
|
+
return failure(spec, error ?? "the model answered none of the questions");
|
|
925
|
+
}
|
|
926
|
+
return {
|
|
927
|
+
content: [{ type: "text", text: JSON.stringify(answers.diagnosis, null, 2) }],
|
|
928
|
+
details: {
|
|
929
|
+
model: spec,
|
|
930
|
+
...(fallback ? { fallback: true } : {}),
|
|
931
|
+
diagnosis: answers.diagnosis,
|
|
932
|
+
answers: answers.answers,
|
|
933
|
+
questions: answers.questions,
|
|
934
|
+
...(answers.missing.length > 0 ? { missing_keys: answers.missing } : {}),
|
|
935
|
+
attempts: answers.attempts,
|
|
936
|
+
stop_reason: answers.stopReason,
|
|
937
|
+
calls: answers.calls,
|
|
938
|
+
prompt,
|
|
939
|
+
prompts,
|
|
940
|
+
...(examplesUsed ? { examples_used: examplesUsed } : {}),
|
|
941
|
+
},
|
|
942
|
+
...(answers.usage ? { usage: answers.usage } : {}),
|
|
943
|
+
};
|
|
944
|
+
},
|
|
945
|
+
});
|
|
946
|
+
}
|