@orangekame3/pi-qcaleval 0.0.1 → 0.0.2-beta.10

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 CHANGED
@@ -13,23 +13,43 @@ works from the pi CLI and from the QDash Copilot runtime alike.
13
13
 
14
14
  ## Tool
15
15
 
16
- `qcal_evaluate(context, question?, images?, max_images?)` returns a JSON object with
16
+ `qcal_evaluate(context, question?, task_name?, knowledge?, images?, max_images?)` returns a JSON object with
17
17
  `technical_description`, `experimental_conclusion`, `experimental_significance`,
18
18
  `fit_quality`, `parameter_extraction`, and `success`. The parsed object is also
19
19
  stored in the tool result's `details.diagnosis`; when the model did not answer
20
- with JSON, `details.raw` holds its text instead.
20
+ with JSON, or not with all of it, `details.raw` holds its text and
21
+ `details.missing_keys` the keys it lacks.
22
+
23
+ On a chat-completions API (vLLM, SGLang, llama.cpp) the request carries a
24
+ `response_format` with the diagnosis schema, so decoding is constrained to the
25
+ six keys; a server that refuses it is asked plainly. An answer that still
26
+ lacks keys is asked for once more and the more complete one kept. The details
27
+ record `attempts` (1 or 2), `structured` (whether decoding was constrained),
28
+ and `stop_reason`.
21
29
 
22
30
  `images` are base64 image blocks. A host may fill them in from the most recent
23
31
  figures of the conversation, so the planner does not have to pass image bytes;
24
32
  `max_images` (default 1, up to 4) says how many of the newest figures to supply,
25
33
  for example 2 when an expected reference figure precedes the measured result.
26
34
 
35
+ `knowledge` is the host's reference for the experiment: what a good result
36
+ looks like, the questions a reviewer asks, and the common failure patterns. A
37
+ host that keeps such knowledge per task fills it in from `task_name`, so the
38
+ planner only names the task; the text is sent before the context. The exact
39
+ user turn the model received (reference, context, question) is stored in
40
+ `details.prompt`, so a host can show what the model saw.
41
+
27
42
  ## Configuration
28
43
 
29
- | Variable | Purpose |
30
- |----------|---------|
31
- | `QCALEVAL_MODEL` | `provider/modelId` of the evaluation model in pi's `models.json` (default: `vllm/nvidia/Ising-Calibration-1.5-31B-NVFP4`) |
32
- | `QCALEVAL_MAX_TOKENS` | Output budget for one evaluation (default: `2048`) |
44
+ No setting is required. The evaluation model is resolved in this order:
45
+
46
+ 1. `QCALEVAL_MODEL` (`provider/modelId`), an explicit override for pi CLI use;
47
+ 2. a configured calibration VLM, recognised by its id (`Ising-Calibration`);
48
+ 3. the conversation's own model, when it accepts images. The result is then
49
+ marked `fallback: true` in `details`, so a host without an Ising deployment
50
+ still gets a diagnosis from the vision model it already runs.
51
+
52
+ The output budget follows the model's `maxTokens` (at most 4096).
33
53
 
34
54
  The provider must be declared in pi's `models.json` with its base URL and
35
55
  credential, for example:
@@ -2,13 +2,14 @@
2
2
  * pi-qcaleval: evaluate calibration plots with a dedicated calibration VLM.
3
3
  *
4
4
  * The extension has no QDash dependency. It takes a plot (plus the experiment
5
- * context the planner already knows) and asks the evaluation model, which is
5
+ * context the planner already knows, and the host's reference knowledge for
6
+ * the task when the host supplies one) and asks the evaluation model, which is
6
7
  * configured separately from the conversation's model, for a diagnosis. The
7
8
  * planner decides what to do with it; whether a calibration loop continues is
8
9
  * the host's policy, not this tool's.
9
10
  */
10
11
 
11
- import type { Api, AssistantMessage, ImageContent, Model } from "@earendil-works/pi-ai";
12
+ import type { Api, AssistantMessage, ImageContent, Model, Usage } from "@earendil-works/pi-ai";
12
13
  import type {
13
14
  AgentToolResult,
14
15
  ExtensionAPI,
@@ -16,20 +17,29 @@ import type {
16
17
  } from "@earendil-works/pi-coding-agent";
17
18
  import { Type, type Static } from "typebox";
18
19
 
19
- /** `provider/modelId`; the model id itself may contain slashes (nvidia/...). */
20
- export const DEFAULT_MODEL = "vllm/nvidia/Ising-Calibration-1.5-31B-NVFP4";
21
-
22
20
  /** Figures per evaluation; a 16k-token deployment holds a few plots comfortably. */
23
21
  export const MAX_IMAGES = 4;
24
22
 
23
+ /** Labeled example plots shown before the query, and the figures they may hold in total. */
24
+ export const MAX_EXAMPLES = 4;
25
+ export const MAX_EXAMPLE_IMAGES = 4;
26
+
27
+ /** Model ids of models fine-tuned for calibration plots; preferred when configured. */
28
+ export const CALIBRATION_MODEL_PATTERN = /ising-calibration/i;
29
+
30
+ /** Output budget when the model declares none; a diagnosis is a few hundred tokens. */
31
+ const DEFAULT_MAX_TOKENS = 2048;
32
+ const MAX_OUTPUT_TOKENS = 4096;
33
+
25
34
  export interface ModelSpec {
26
35
  provider: string;
27
36
  modelId: string;
28
37
  }
29
38
 
30
- /** Parse `provider/modelId`, splitting at the first slash only. */
31
- export function parseModelSpec(value: string | undefined): ModelSpec {
32
- const spec = (value ?? "").trim() || DEFAULT_MODEL;
39
+ /** Parse an explicit `provider/modelId` override; the model id may contain slashes (nvidia/...). */
40
+ export function parseModelSpec(value: string | undefined): ModelSpec | undefined {
41
+ const spec = (value ?? "").trim();
42
+ if (!spec) return undefined;
33
43
  const slash = spec.indexOf("/");
34
44
  if (slash <= 0 || slash === spec.length - 1) {
35
45
  throw new Error(`QCALEVAL_MODEL must be provider/modelId, got "${spec}"`);
@@ -37,6 +47,54 @@ export function parseModelSpec(value: string | undefined): ModelSpec {
37
47
  return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
38
48
  }
39
49
 
50
+ /** The slice of pi's model registry the resolution needs. */
51
+ export interface ModelLookup {
52
+ find(provider: string, modelId: string): Model<Api> | undefined;
53
+ getAvailable(): Model<Api>[];
54
+ }
55
+
56
+ export type EvaluationModel =
57
+ | { model: Model<Api>; fallback: false }
58
+ | { model: Model<Api>; fallback: true }
59
+ | { model?: undefined; error: string };
60
+
61
+ /**
62
+ * Pick the model that judges the plot, without any host-specific setting:
63
+ *
64
+ * 1. `QCALEVAL_MODEL` (`provider/modelId`), an explicit override for the pi CLI;
65
+ * 2. a configured calibration VLM, recognised by its id (Ising-Calibration);
66
+ * 3. the conversation's own model, when it accepts images.
67
+ *
68
+ * A host that declares the calibration model in its models.json gets it
69
+ * everywhere; one that does not (no Ising deployment) still gets a diagnosis
70
+ * from the vision model it already runs, marked as a fallback.
71
+ */
72
+ export function resolveEvaluationModel(
73
+ registry: ModelLookup,
74
+ current: Model<Api> | undefined,
75
+ override: string | undefined,
76
+ ): EvaluationModel {
77
+ const spec = parseModelSpec(override);
78
+ if (spec) {
79
+ const model = registry.find(spec.provider, spec.modelId);
80
+ return model
81
+ ? { model, fallback: false }
82
+ : { error: `model ${spec.provider}/${spec.modelId} is not configured (QCALEVAL_MODEL)` };
83
+ }
84
+ const calibration = registry.getAvailable().find((model) => CALIBRATION_MODEL_PATTERN.test(model.id));
85
+ if (calibration) return { model: calibration, fallback: false };
86
+ if (current?.input.includes("image")) return { model: current, fallback: true };
87
+ return {
88
+ error:
89
+ "no evaluation model: declare a calibration VLM (Ising-Calibration) in models.json, or run with a vision model",
90
+ };
91
+ }
92
+
93
+ /** Output budget for one evaluation, within what the model allows. */
94
+ export function outputBudget(model: Pick<Model<Api>, "maxTokens">): number {
95
+ return model.maxTokens > 0 ? Math.min(model.maxTokens, MAX_OUTPUT_TOKENS) : DEFAULT_MAX_TOKENS;
96
+ }
97
+
40
98
  /** Categories the Ising-Calibration model card describes; asked for in this order. */
41
99
  export const DIAGNOSIS_SECTIONS = [
42
100
  "technical_description",
@@ -47,6 +105,50 @@ export const DIAGNOSIS_SECTIONS = [
47
105
  "success",
48
106
  ] as const;
49
107
 
108
+ /**
109
+ * The diagnosis as a JSON schema: the six keys, all required. Sent as the
110
+ * response format to OpenAI-compatible servers that constrain decoding to a
111
+ * schema (vLLM, SGLang, llama.cpp). Asked for JSON in the prompt alone, the
112
+ * calibration model now and then closes the object after the first key or
113
+ * nests quotes that break it; constrained decoding rules both out.
114
+ */
115
+ export const DIAGNOSIS_SCHEMA = {
116
+ type: "object",
117
+ properties: {
118
+ technical_description: { type: "string" },
119
+ experimental_conclusion: { type: "string" },
120
+ experimental_significance: { type: "string" },
121
+ fit_quality: { type: "string" },
122
+ parameter_extraction: {
123
+ anyOf: [{ type: "object", additionalProperties: true }, { type: "string" }, { type: "null" }],
124
+ },
125
+ success: { type: "boolean" },
126
+ },
127
+ required: [...DIAGNOSIS_SECTIONS],
128
+ additionalProperties: false,
129
+ } as const;
130
+
131
+ /** `response_format` of a chat completion constrained to the diagnosis schema. */
132
+ export const RESPONSE_FORMAT = {
133
+ type: "json_schema",
134
+ json_schema: { name: "calibration_diagnosis", schema: DIAGNOSIS_SCHEMA },
135
+ } as const;
136
+
137
+ /**
138
+ * Whether the model's API takes `response_format` in the request body. Pi
139
+ * merges `samplingParams` into the body of chat completions only; the
140
+ * responses API spells the format differently and other APIs ignore it.
141
+ */
142
+ export function canConstrain(model: Pick<Model<Api>, "api">): boolean {
143
+ return model.api === "openai-completions";
144
+ }
145
+
146
+ /** The diagnosis keys an answer lacks; all six when there is no diagnosis. */
147
+ export function missingKeys(diagnosis: Record<string, unknown> | undefined): string[] {
148
+ if (!diagnosis) return [...DIAGNOSIS_SECTIONS];
149
+ return DIAGNOSIS_SECTIONS.filter((key) => !(key in diagnosis));
150
+ }
151
+
50
152
  export const SYSTEM_PROMPT = [
51
153
  "You evaluate superconducting qubit calibration experiment plots.",
52
154
  "Answer with one JSON object and nothing else, with these keys:",
@@ -56,28 +158,114 @@ export const SYSTEM_PROMPT = [
56
158
  ' "fit_quality": "good" | "marginal" | "poor" and why;',
57
159
  ' "parameter_extraction": extracted values with units, or null;',
58
160
  ' "success": true | false, whether the experiment achieved its goal.',
59
- "Base every statement on the plot and the given context only.",
161
+ "Base every statement on the plot, the reference, and the given context only.",
60
162
  ].join("\n");
61
163
 
164
+ /** One labeled example: its figure(s) followed by the expert's diagnosis. */
165
+ export interface LabeledExample {
166
+ label: string;
167
+ images: ReadonlyArray<{ data: string; mimeType: string }>;
168
+ }
169
+
170
+ export type UserContentBlock =
171
+ | { type: "text"; text: string }
172
+ | { type: "image"; data: string; mimeType: string };
173
+
62
174
  /**
63
- * Build the single user turn sent to the evaluation model. With several
64
- * figures, the model is told how many follow so it reads them in the order
65
- * the context describes (expected reference first, measured result last).
175
+ * The single user turn sent to the evaluation model, as the QCalEval papers
176
+ * lay it out: the reference for the experiment, then each labeled example as
177
+ * its figure followed by its diagnosis, then the context and question, then
178
+ * the figure to judge. Everything is one ordered content array; images are
179
+ * native blocks, never placeholders in the text.
180
+ *
181
+ * `prompt` is the same turn as text, with `[figure]` where an image was, for
182
+ * the record of what the model saw.
66
183
  */
67
- export function buildUserPrompt(
184
+ export function buildUserContent(
68
185
  context: string,
69
186
  question: string | undefined,
70
- imageCount = 1,
71
- ): string {
72
- const lines = ["Experiment context:", context.trim() || "(none)"];
73
- if (imageCount > 1) {
74
- lines.push(
187
+ images: ReadonlyArray<{ data: string; mimeType: string }>,
188
+ knowledge?: string,
189
+ examples: ReadonlyArray<LabeledExample> = [],
190
+ ): { content: UserContentBlock[]; prompt: string; examplesUsed: number } {
191
+ const content: UserContentBlock[] = [];
192
+ const shown = fitExamples(examples);
193
+ const text = (lines: string[]) => {
194
+ content.push({ type: "text", text: lines.join("\n") });
195
+ };
196
+ const image = (figure: { data: string; mimeType: string }) => {
197
+ content.push({ type: "image", data: figure.data, mimeType: figure.mimeType });
198
+ };
199
+
200
+ const opening: string[] = [];
201
+ if (knowledge?.trim()) {
202
+ opening.push("Reference for this experiment (expected plot, outcomes, parameters):", knowledge.trim(), "");
203
+ }
204
+ if (shown.length > 0) {
205
+ opening.push(
206
+ `${shown.length} labeled example${shown.length === 1 ? "" : "s"} of this experiment follow, each figure followed by the diagnosis an expert validated. Use them as the standard for the figure to judge, which comes last.`,
207
+ );
208
+ }
209
+ if (opening.length > 0) text(opening);
210
+
211
+ shown.forEach((example, index) => {
212
+ text(["", `Example ${index + 1} of ${shown.length}:`]);
213
+ for (const figure of example.images) image(figure);
214
+ text([`Diagnosis of example ${index + 1}:`, example.label.trim()]);
215
+ });
216
+
217
+ const closing: string[] = [];
218
+ if (shown.length > 0) closing.push("", "Now the figure to judge, evaluated independently of the examples.");
219
+ closing.push("Experiment context:", context.trim() || "(none)");
220
+ if (images.length > 1) {
221
+ closing.push(
75
222
  "",
76
- `${imageCount} figures follow, in the order the context describes; the last one is the measured result to judge.`,
223
+ `${images.length} figures follow, in the order the context describes; the last one is the measured result to judge.`,
77
224
  );
78
225
  }
79
- if (question?.trim()) lines.push("", "Specific question:", question.trim());
80
- return lines.join("\n");
226
+ if (question?.trim()) closing.push("", "Specific question:", question.trim());
227
+ // Repeated here because a long reference ahead of the plot pulls the model
228
+ // towards prose; the last instruction it reads is the output format.
229
+ closing.push("", "Answer with the JSON object described in the system prompt, and nothing else.");
230
+ text(closing);
231
+ for (const figure of images) image(figure);
232
+
233
+ const prompt = content
234
+ .map((block) => (block.type === "text" ? block.text : "[figure]"))
235
+ .join("\n")
236
+ .replace(/\n{3,}/g, "\n\n")
237
+ .trim();
238
+ return { content, prompt, examplesUsed: shown.length };
239
+ }
240
+
241
+ /** Keep examples in order within the example and figure budgets; later ones are dropped. */
242
+ export function fitExamples(examples: ReadonlyArray<LabeledExample>): LabeledExample[] {
243
+ const kept: LabeledExample[] = [];
244
+ let figures = 0;
245
+ for (const example of examples) {
246
+ if (kept.length >= MAX_EXAMPLES) break;
247
+ if (!example.label?.trim() || !example.images?.length) continue;
248
+ if (figures + example.images.length > MAX_EXAMPLE_IMAGES) break;
249
+ figures += example.images.length;
250
+ kept.push(example);
251
+ }
252
+ return kept;
253
+ }
254
+
255
+ /** The user turn as text only (no examples), kept for callers that render the prompt. */
256
+ export function buildUserPrompt(
257
+ context: string,
258
+ question: string | undefined,
259
+ imageCount = 1,
260
+ knowledge?: string,
261
+ ): string {
262
+ const placeholder = { data: "", mimeType: "image/png" };
263
+ return buildUserContent(context, question, Array(imageCount).fill(placeholder), knowledge)
264
+ .content.filter((block) => block.type === "text")
265
+ .map((block) => (block as { text: string }).text)
266
+ .join("\n")
267
+ .replace(/\n{3,}/g, "\n\n")
268
+ .trim();
81
269
  }
82
270
 
83
271
  /** Pull the text blocks of the assistant answer, dropping thinking. */
@@ -133,11 +321,99 @@ function parseObject(json: string): Record<string, unknown> | undefined {
133
321
  return undefined;
134
322
  }
135
323
 
324
+ /** One answer of the evaluation model, read for the record. */
325
+ export interface Answer {
326
+ text: string;
327
+ diagnosis?: Record<string, unknown>;
328
+ /** Diagnosis keys the answer lacks; empty when it is complete. */
329
+ missing: string[];
330
+ /** Model calls made for this answer: 1, or 2 after an incomplete or refused first one. */
331
+ attempts: number;
332
+ /** Whether decoding was constrained to the diagnosis schema. */
333
+ structured: boolean;
334
+ stopReason: AssistantMessage["stopReason"];
335
+ usage?: Usage;
336
+ }
337
+
338
+ function readAnswer(message: AssistantMessage, structured: boolean): Answer {
339
+ const text = answerText(message);
340
+ const diagnosis = parseDiagnosis(text);
341
+ return {
342
+ text,
343
+ diagnosis,
344
+ missing: missingKeys(diagnosis),
345
+ attempts: 1,
346
+ structured,
347
+ stopReason: message.stopReason,
348
+ usage: message.usage,
349
+ };
350
+ }
351
+
352
+ /** Token usage of two calls together, so a retried answer is billed as both. */
353
+ export function addUsage(a: Usage | undefined, b: Usage | undefined): Usage | undefined {
354
+ if (!a || !b) return a ?? b;
355
+ const sum = (x: number | undefined, y: number | undefined) => (x ?? 0) + (y ?? 0);
356
+ return {
357
+ ...a,
358
+ input: sum(a.input, b.input),
359
+ output: sum(a.output, b.output),
360
+ cacheRead: sum(a.cacheRead, b.cacheRead),
361
+ cacheWrite: sum(a.cacheWrite, b.cacheWrite),
362
+ ...(a.reasoning === undefined && b.reasoning === undefined
363
+ ? {}
364
+ : { reasoning: sum(a.reasoning, b.reasoning) }),
365
+ totalTokens: sum(a.totalTokens, b.totalTokens),
366
+ cost: {
367
+ input: sum(a.cost?.input, b.cost?.input),
368
+ output: sum(a.cost?.output, b.cost?.output),
369
+ cacheRead: sum(a.cost?.cacheRead, b.cost?.cacheRead),
370
+ cacheWrite: sum(a.cost?.cacheWrite, b.cost?.cacheWrite),
371
+ total: sum(a.cost?.total, b.cost?.total),
372
+ },
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Ask the model for the diagnosis, in at most two calls. The first call is
378
+ * constrained to the schema when the API allows it; a server that refuses the
379
+ * response format (an older vLLM, a gateway) gets the plain request instead.
380
+ * An answer that lacks diagnosis keys is asked for once more, since the model
381
+ * usually completes the object on a second sample, and the more complete of
382
+ * the two answers is kept. Usage counts every call made.
383
+ */
384
+ export async function askForDiagnosis(
385
+ call: (structured: boolean) => Promise<AssistantMessage>,
386
+ constrain: boolean,
387
+ ): Promise<Answer | { error: string }> {
388
+ let structured = constrain;
389
+ let first = await call(structured);
390
+ let attempts = 1;
391
+ let usage: Usage | undefined = first.usage;
392
+ if (first.stopReason === "error" && structured) {
393
+ structured = false;
394
+ first = await call(structured);
395
+ attempts += 1;
396
+ usage = addUsage(usage, first.usage);
397
+ }
398
+ if (first.stopReason === "error") return { error: first.errorMessage ?? "model error" };
399
+ const answer = { ...readAnswer(first, structured), attempts, usage };
400
+ if (answer.missing.length === 0 || attempts === 2) return answer;
401
+ const second = await call(structured);
402
+ const retried = second.stopReason === "error" ? undefined : readAnswer(second, structured);
403
+ const kept = retried && retried.missing.length <= answer.missing.length ? retried : answer;
404
+ return { ...kept, attempts: 2, usage: addUsage(first.usage, second.usage) };
405
+ }
406
+
136
407
  const imageSchema = Type.Object({
137
408
  data: Type.String({ description: "Base64 image bytes (no data: prefix)" }),
138
409
  mimeType: Type.String({ description: "image/png or image/jpeg" }),
139
410
  });
140
411
 
412
+ const exampleSchema = Type.Object({
413
+ label: Type.String({ description: "The expert's diagnosis of the example figure(s)" }),
414
+ images: Type.Array(imageSchema, { minItems: 1 }),
415
+ });
416
+
141
417
  const parameters = Type.Object({
142
418
  context: Type.String({
143
419
  description:
@@ -146,6 +422,18 @@ const parameters = Type.Object({
146
422
  question: Type.Optional(
147
423
  Type.String({ description: "A specific question to answer about the plot" }),
148
424
  ),
425
+ task_name: Type.Optional(
426
+ Type.String({
427
+ description:
428
+ "The calibration task's name as the host knows it (e.g. RandomizedBenchmarking). Lets the host supply its reference knowledge for the experiment.",
429
+ }),
430
+ ),
431
+ knowledge: Type.Optional(
432
+ Type.String({
433
+ description:
434
+ "Normally omitted: the host supplies the reference for `task_name` (expected result, review questions, failure patterns). Fill only to override it with text you were given.",
435
+ }),
436
+ ),
149
437
  images: Type.Optional(
150
438
  Type.Array(imageSchema, {
151
439
  description:
@@ -160,18 +448,52 @@ const parameters = Type.Object({
160
448
  "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`.",
161
449
  }),
162
450
  ),
451
+ use_examples: Type.Optional(
452
+ Type.Boolean({
453
+ description:
454
+ "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.",
455
+ }),
456
+ ),
457
+ examples: Type.Optional(
458
+ Type.Array(exampleSchema, {
459
+ maxItems: MAX_EXAMPLES,
460
+ description:
461
+ "Normally omitted: the host supplies labeled examples when `use_examples` is true. Each is one or more real figures followed by the expert's diagnosis.",
462
+ }),
463
+ ),
163
464
  });
164
465
 
165
466
  /** Stored on the tool result; the host reads `diagnosis` without reparsing text. */
166
467
  export interface EvaluationDetails {
167
- model: ModelSpec;
468
+ /** The model that judged the plot; absent when none could be resolved. */
469
+ model?: ModelSpec;
470
+ /** True when no calibration VLM was configured and the conversation's model judged instead. */
471
+ fallback?: boolean;
168
472
  diagnosis?: Record<string, unknown> | null;
169
- /** The model's answer when it was not the requested JSON. */
473
+ /** The model's answer when it was not the requested JSON, or not all of it. */
170
474
  raw?: string;
475
+ /** Diagnosis keys the parsed answer lacks, when it is incomplete. */
476
+ missing_keys?: string[];
477
+ /** Model calls made: 1, or 2 after an incomplete or refused first answer. */
478
+ attempts?: number;
479
+ /** Whether decoding was constrained to the diagnosis schema. */
480
+ structured?: boolean;
481
+ /** Why the model stopped, as pi reports it. */
482
+ stop_reason?: string;
483
+ /** The user turn the model was sent (reference, examples, context, question), so the host can show what it saw. */
484
+ prompt?: string;
485
+ /** How many labeled examples were shown before the figure. */
486
+ examples_used?: number;
487
+ /** The system prompt the model was sent, so the host can show the whole request. */
488
+ system_prompt?: string;
171
489
  }
172
490
 
173
- function failure(spec: ModelSpec, text: string): AgentToolResult<EvaluationDetails> {
174
- return { content: [{ type: "text", text: `qcal_evaluate: ${text}` }], details: { model: spec }, isError: true };
491
+ function failure(spec: ModelSpec | undefined, text: string): AgentToolResult<EvaluationDetails> {
492
+ return {
493
+ content: [{ type: "text", text: `qcal_evaluate: ${text}` }],
494
+ details: spec ? { model: spec } : {},
495
+ isError: true,
496
+ };
175
497
  }
176
498
 
177
499
  export default function qcalevalExtension(pi: ExtensionAPI) {
@@ -184,6 +506,8 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
184
506
  promptGuidelines: [
185
507
  "Use qcal_evaluate for every calibration figure you need to judge, instead of judging it yourself.",
186
508
  "Pass the task name, target, and expected result as context so the diagnosis is specific.",
509
+ "Pass task_name when you know it: the host then supplies its reference knowledge for that task.",
510
+ "Set use_examples true only when the user asked for a judgement against labeled reference examples.",
187
511
  "Treat the returned diagnosis as the evaluation of record; add your own reasoning only on top of it.",
188
512
  ],
189
513
  parameters,
@@ -194,11 +518,10 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
194
518
  _onUpdate: unknown,
195
519
  ctx: ExtensionToolContext,
196
520
  ): Promise<AgentToolResult<EvaluationDetails>> {
197
- const spec = parseModelSpec(process.env.QCALEVAL_MODEL);
198
- const model: Model<Api> | undefined = ctx.modelRegistry.find(spec.provider, spec.modelId);
199
- if (!model) {
200
- return failure(spec, `model ${spec.provider}/${spec.modelId} is not configured (QCALEVAL_MODEL)`);
201
- }
521
+ const resolved = resolveEvaluationModel(ctx.modelRegistry, ctx.model, process.env.QCALEVAL_MODEL);
522
+ if (!resolved.model) return failure(undefined, resolved.error);
523
+ const { model, fallback } = resolved;
524
+ const spec: ModelSpec = { provider: model.provider, modelId: model.id };
202
525
  const images: ImageContent[] = (params.images ?? []).map((image) => ({
203
526
  type: "image",
204
527
  data: image.data,
@@ -208,39 +531,58 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
208
531
  return failure(spec, "no plot was given. Pass the figure in `images` or fetch it first.");
209
532
  }
210
533
 
211
- const message = await ctx.modelRegistry.complete(
212
- model,
213
- {
214
- systemPrompt: SYSTEM_PROMPT,
215
- messages: [
534
+ const { content, prompt, examplesUsed } = buildUserContent(
535
+ params.context,
536
+ params.question,
537
+ images.map((image) => ({ data: image.data, mimeType: image.mimeType })),
538
+ params.knowledge,
539
+ params.examples ?? [],
540
+ );
541
+ const answer = await askForDiagnosis(
542
+ (structured) =>
543
+ ctx.modelRegistry.complete(
544
+ model,
216
545
  {
217
- role: "user",
218
- content: [
546
+ systemPrompt: SYSTEM_PROMPT,
547
+ messages: [
219
548
  {
220
- type: "text",
221
- text: buildUserPrompt(params.context, params.question, images.length),
549
+ role: "user",
550
+ content: content.map((block) =>
551
+ block.type === "text"
552
+ ? { type: "text" as const, text: block.text }
553
+ : { type: "image" as const, data: block.data, mimeType: block.mimeType },
554
+ ),
555
+ timestamp: Date.now(),
222
556
  },
223
- ...images,
224
557
  ],
225
- timestamp: Date.now(),
226
558
  },
227
- ],
228
- },
229
- { signal, maxTokens: Number(process.env.QCALEVAL_MAX_TOKENS ?? 2048) },
559
+ {
560
+ signal,
561
+ maxTokens: outputBudget(model),
562
+ ...(structured ? { samplingParams: { response_format: RESPONSE_FORMAT } } : {}),
563
+ },
564
+ ),
565
+ canConstrain(model),
230
566
  );
231
- if (message.stopReason === "error") {
232
- return failure(spec, message.errorMessage ?? "model error");
233
- }
234
- const text = answerText(message);
235
- const diagnosis = parseDiagnosis(text);
567
+ if ("error" in answer) return failure(spec, answer.error);
568
+ const { text, diagnosis, missing } = answer;
569
+ const complete = diagnosis !== undefined && missing.length === 0;
236
570
  return {
237
571
  content: [{ type: "text", text: diagnosis ? JSON.stringify(diagnosis, null, 2) : text }],
238
572
  details: {
239
573
  model: spec,
574
+ ...(fallback ? { fallback: true } : {}),
240
575
  diagnosis: diagnosis ?? null,
241
- ...(diagnosis ? {} : { raw: text }),
576
+ ...(complete ? {} : { raw: text }),
577
+ ...(diagnosis && !complete ? { missing_keys: missing } : {}),
578
+ attempts: answer.attempts,
579
+ structured: answer.structured,
580
+ stop_reason: answer.stopReason,
581
+ prompt,
582
+ system_prompt: SYSTEM_PROMPT,
583
+ ...(examplesUsed ? { examples_used: examplesUsed } : {}),
242
584
  },
243
- ...(message.usage ? { usage: message.usage } : {}),
585
+ ...(answer.usage ? { usage: answer.usage } : {}),
244
586
  };
245
587
  },
246
588
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangekame3/pi-qcaleval",
3
- "version": "0.0.1",
3
+ "version": "0.0.2-beta.10",
4
4
  "description": "pi extension that sends calibration plots to a calibration-evaluation VLM such as NVIDIA Ising-Calibration and returns a structured diagnosis",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,11 +18,23 @@ Ising-Calibration by default) and returns a JSON diagnosis with
18
18
  the task name, the target (qubit or coupling), the swept parameter, and what a
19
19
  successful result looks like. Add `question` when a specific point matters
20
20
  (for example "is the chevron centred on the current frequency?").
21
+ - Pass `task_name` whenever you know the task (for example `CheckRabi`): the
22
+ host then supplies its own reference knowledge for that experiment, which is
23
+ more reliable than a summary you write yourself. Keep `context` for what the
24
+ reference cannot know: the target, the swept range, and previous values.
21
25
  - When the conversation holds an expected reference figure followed by the
22
26
  measured result (a task review opens this way), pass `max_images: 2` and say
23
27
  in `context` that the first figure is the reference and the last the
24
28
  measurement. The host then supplies both; the default is the newest figure only.
25
29
 
30
+ ## Labeled examples (in-context)
31
+
32
+ - When the user asks to judge a plot against the labeled reference examples
33
+ (in-context examples, few-shot), pass `use_examples: true` with `task_name`.
34
+ The host supplies its expert-labeled example plots for that task, and the
35
+ model sees each example figure followed by its diagnosis before the figure
36
+ to judge, as in the QCalEval papers. Leave it off otherwise.
37
+
26
38
  ## How to use the result
27
39
 
28
40
  - Treat the diagnosis as the evaluation of record; reason on top of it instead