Skip to content

Commit 38cc35d

Browse files
comet-qa-botclaude
andcommitted
[OPIK-7791] [QA] Add e2e specs for JSON-looking judge prompts (OPIK-8250)
Two specs proposed by the 2.2.50 -> 2.2.51 release QA side flow, from flows a human verified on staging first. online-evaluation-json-looking-judge-prompts.spec.ts covers @cap:online-evaluation.list-rules: a project holding a prose control rule plus six judge prompts that open with '[' still lists every rule over the project-scoped API, keeps the workspace-wide (sampler-shared) listing at 200, and renders a row per rule with no 5xx from the evaluators endpoint. online-evaluation-judge-prompt-round-trip.spec.ts covers @cap:online-evaluation.edit-rule: an example-array-then-prose prompt reads back byte-exact over the API and in the edit dialog, and survives a no-op dialog save; a genuine content_array message still reads back structured, with its image_url url and detail intact, after a no-op re-save. The estate cannot reach these shapes through the create-rule dialog, which only emits the canned templates, so both specs seed over REST. Supporting additions: createLlmJudgeRule / findAutomationRuleEvaluatorsPage / getLlmJudgeMessages / resaveAutomationRuleFromReadBack on the backend client, and openEditDialogByName / readPromptMessageText / submitDialog / cancelDialog on OnlineEvaluationPage. Generated by the release QA side flow. Needs human review before merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d5ac079 commit 38cc35d

6 files changed

Lines changed: 690 additions & 6 deletions

File tree

tests_end_to_end/coverage/taxonomy.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -648,18 +648,24 @@ areas:
648648
- online-evaluation/online-evaluation-sampling-rate.spec.ts
649649
- online-evaluation/online-evaluation-python-metric-errors.spec.ts
650650
- online-evaluation/online-evaluation-non-object-sections.spec.ts
651+
- online-evaluation/online-evaluation-json-looking-judge-prompts.spec.ts
652+
- online-evaluation/online-evaluation-judge-prompt-round-trip.spec.ts
651653
capabilities:
652654
create-llm-judge-rule: { covered: true, tier: t1-smoke }
653655
llm-judge-scores: { covered: true, tier: t1-smoke, note: "bimodal safe/unsafe" }
654656
create-python-rule: { covered: true, tier: t1-smoke }
655657
python-rule-scores: { covered: true, tier: t1-smoke, note: "deterministic 3x1.0 / 2x0.0; t2 also covers sub-path mappings over non-object sections and the 400-class classification of a metric that exits 0 without a result line" }
656658
scores-in-trace-panel: { covered: true, tier: t1-smoke }
657-
list-rules: { covered: false }
659+
list-rules: { covered: true, tier: t2-cuj, note: "project-scoped and workspace-wide listing over a project holding six bracket-opening judge prompts, plus the rules page rendering a row for each" }
658660
rule-scope-thread-span: { covered: false, note: "span/thread scope flags" }
659661
rule-filters: { covered: false }
660662
sampling-rate: { covered: true, tier: t2-cuj, note: "50% rule vs 100% control over one 30-trace batch, binomial band 15-85%; plus a 0%-rate rule at trigger_scope=both, which must skip every SDK trace and still score experiment/playground/optimization ones" }
661663
clone-rule: { covered: false }
662-
edit-rule: { covered: false }
664+
# Scoped: the edit dialog is opened, its hydrated prompt asserted byte-exact,
665+
# and submitted unchanged so the save round trip is real. Editing a FIELD
666+
# through the dialog (renaming, changing the model, rewriting the prompt) is
667+
# still uncovered.
668+
edit-rule: { covered: true, tier: t2-cuj, note: "dialog hydration + no-op re-save preserve a judge prompt byte-for-byte, in both the plain-string and content_array shapes; editing a field through the dialog is not covered" }
663669
enable-disable-rule: { covered: true, tier: t2-cuj, note: "edit-dialog switch; control rule proves scoring stopped, then resumed" }
664670
delete-rule: { covered: true, tier: t2-cuj, note: "row kebab delete; control rule proves scoring stopped" }
665671
# Deliberately still false. online-evaluation-python-metric-errors.spec.ts

tests_end_to_end/e2e/core/backend/client.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,61 @@ export interface AutomationRuleLogRef {
187187
message: string;
188188
}
189189

190+
/**
191+
* One content part of a structured (multimodal) judge message.
192+
*
193+
* Only the fields the specs assert on are modelled; `video_url` / `audio_url`
194+
* exist on the wire too and are left off deliberately rather than typed and
195+
* ignored.
196+
*/
197+
export interface JudgeMessageContentPartRef {
198+
type: string;
199+
text: string | null;
200+
imageUrl: { url: string; detail: string | null } | null;
201+
}
202+
203+
/**
204+
* One judge message exactly as `GET /automations/evaluators/{id}` returns it.
205+
*
206+
* `content` and `contentArray` are the API's own two mutually-exclusive shapes
207+
* (`LlmAsJudgeMessage`), and telling them apart is the whole point of the specs
208+
* that use this: prose must come back as `content`, a genuine multimodal array
209+
* must come back as `contentArray`. They are therefore kept as the nullable
210+
* union the server sends rather than collapsed into one "text" field, which
211+
* would erase the distinction under test.
212+
*/
213+
export interface JudgeMessageRef {
214+
role: string;
215+
content: string | null;
216+
contentArray: JudgeMessageContentPartRef[] | null;
217+
}
218+
219+
/** One judge message as the create endpoint accepts it — set exactly one of the two. */
220+
export interface JudgeMessageWrite {
221+
role: 'SYSTEM' | 'USER';
222+
content?: string;
223+
contentArray?: Array<{
224+
type: string;
225+
text?: string;
226+
image_url?: { url: string; detail?: string };
227+
}>;
228+
}
229+
230+
/**
231+
* One page of `GET /automations/evaluators/`, status included rather than thrown.
232+
*
233+
* The listing answering 200 at all is the assertion in
234+
* `online-evaluation-json-looking-judge-prompts.spec.ts` — the regression it
235+
* guards turned the whole page into a 500 — so the status has to be a value the
236+
* spec can compare, not an exception the client raises on its way out.
237+
*/
238+
export interface AutomationRuleEvaluatorPageRef {
239+
status: number;
240+
/** Server-reported total for the query, not the length of this page. */
241+
total: number;
242+
names: string[];
243+
}
244+
190245
/**
191246
* A trace `input`/`output`/`metadata` payload as the REST API accepts it.
192247
*
@@ -1546,6 +1601,228 @@ export function makeBackendClient(apiKey: string | null = null, workspaceName: s
15461601
return id;
15471602
},
15481603

1604+
/**
1605+
* Create an LLM-as-judge rule and return its id.
1606+
*
1607+
* Separate from `createAutomationRule` (which only builds
1608+
* `user_defined_metric_python` rules) because the judge shape is what these
1609+
* specs are about: the message `content` / `content_array` split, which the
1610+
* python shape has no equivalent of.
1611+
*
1612+
* `rawFetch` again, for the same two reasons as the python creator: the
1613+
* pinned SDK has no `trigger_scope`, and creation answers 201 with an empty
1614+
* body so the id only exists in the `Location` header.
1615+
*
1616+
* No provider key is required — the backend validates neither the model name
1617+
* nor its availability at create time, so a rule can be seeded on a
1618+
* workspace with no LLM provider configured. It simply never scores, which
1619+
* is what the listing / read-back specs want.
1620+
*/
1621+
async createLlmJudgeRule(args: {
1622+
projectId: string;
1623+
name: string;
1624+
messages: JudgeMessageWrite[];
1625+
/** Fraction in [0, 1], the backend's own units — not the dialog's percentage. */
1626+
samplingRate?: number;
1627+
/** Provider model identifier, e.g. `gpt-4o`. */
1628+
model?: string;
1629+
/** `score()` variable name -> extraction path (e.g. `output.output`). */
1630+
variables?: Record<string, string>;
1631+
/**
1632+
* Name of the single output-schema entry. Deliberately NOT defaulted to
1633+
* the rule name: rule names carry the run namespace and can approach the
1634+
* 150-char column bound, while a score name is a short human label, and
1635+
* the edit dialog renders it as one.
1636+
*/
1637+
scoreName?: string;
1638+
enabled?: boolean;
1639+
}): Promise<string> {
1640+
const scoreName = args.scoreName ?? 'Accuracy';
1641+
const { status, message, location } = await rawFetch(
1642+
'POST',
1643+
'/v1/private/automations/evaluators/',
1644+
{
1645+
body: {
1646+
type: 'llm_as_judge',
1647+
action: 'evaluator',
1648+
name: args.name,
1649+
project_ids: [args.projectId],
1650+
sampling_rate: args.samplingRate ?? 1,
1651+
enabled: args.enabled ?? true,
1652+
code: {
1653+
model: { name: args.model ?? 'gpt-4o', temperature: 0 },
1654+
messages: args.messages.map((m) => ({
1655+
role: m.role,
1656+
...(m.content === undefined ? {} : { content: m.content }),
1657+
...(m.contentArray === undefined ? {} : { content_array: m.contentArray }),
1658+
})),
1659+
variables: args.variables ?? { output: 'output.output' },
1660+
schema: [
1661+
{
1662+
name: scoreName,
1663+
type: 'INTEGER',
1664+
description: 'Score assigned by the judge.',
1665+
},
1666+
],
1667+
},
1668+
},
1669+
},
1670+
);
1671+
if (status !== 201) {
1672+
throw new Error(
1673+
`createLlmJudgeRule: expected 201 for '${args.name}', got ${status}: ${message}`,
1674+
);
1675+
}
1676+
const id = location?.split('/').filter(Boolean).pop();
1677+
if (!id) {
1678+
throw new Error(
1679+
`createLlmJudgeRule: 201 for '${args.name}' carried no usable Location header ` +
1680+
`(got '${location}') — cannot address the rule.`,
1681+
);
1682+
}
1683+
return id;
1684+
},
1685+
1686+
/**
1687+
* One page of the evaluators listing, reporting the HTTP status rather than
1688+
* throwing on it.
1689+
*
1690+
* `projectId` omitted issues the workspace-wide listing — the same read the
1691+
* online-scoring sampler's `findAll()` performs, and the one that a single
1692+
* unreadable rule used to take down for every project at once.
1693+
*
1694+
* `size` defaults to 100 rather than the endpoint's own 10: a spec that
1695+
* seeds n rules and then reads a silently-truncated first page would assert
1696+
* against a subset without noticing.
1697+
*/
1698+
async findAutomationRuleEvaluatorsPage(
1699+
opts: { projectId?: string; page?: number; size?: number } = {},
1700+
): Promise<AutomationRuleEvaluatorPageRef> {
1701+
const query = new URLSearchParams();
1702+
if (opts.projectId) query.set('project_id', opts.projectId);
1703+
query.set('page', String(opts.page ?? 1));
1704+
query.set('size', String(opts.size ?? 100));
1705+
1706+
const { status, json } = await rawFetch(
1707+
'GET',
1708+
'/v1/private/automations/evaluators/',
1709+
{ query },
1710+
);
1711+
// A non-200 is a legitimate result here, not an error to translate: the
1712+
// caller asserts on it. Only the shape of a 200 is trusted.
1713+
if (status !== 200) return { status, total: 0, names: [] };
1714+
1715+
const page = json as { total?: number; content?: Array<{ name?: string }> };
1716+
const content = page.content ?? [];
1717+
if (typeof page.total !== 'number') {
1718+
throw new Error(
1719+
`findAutomationRuleEvaluatorsPage: 200 response carried no 'total' — ` +
1720+
`cannot assert the listing is complete.`,
1721+
);
1722+
}
1723+
return {
1724+
status,
1725+
total: page.total,
1726+
names: content.map((r) => String(r.name ?? '')),
1727+
};
1728+
},
1729+
1730+
/**
1731+
* The judge messages of one rule, as the read-back mapper produces them.
1732+
*
1733+
* This is the surface OPIK-8250 broke: the mapper infers the stored shape
1734+
* from the content string, so a prose prompt that happens to open with `[`
1735+
* has to come back as `content`, and a genuine multimodal array has to come
1736+
* back as `contentArray`. Both fields are surfaced verbatim so a spec can
1737+
* assert which one the server chose.
1738+
*/
1739+
async getLlmJudgeMessages(ruleId: string): Promise<JudgeMessageRef[]> {
1740+
const { status, message, json } = await rawFetch(
1741+
'GET',
1742+
`/v1/private/automations/evaluators/${ruleId}`,
1743+
);
1744+
if (status !== 200) {
1745+
throw new Error(`getLlmJudgeMessages: ${ruleId} answered ${status}: ${message}`);
1746+
}
1747+
const rule = json as { code?: { messages?: unknown } };
1748+
const messages = rule.code?.messages;
1749+
if (!Array.isArray(messages)) {
1750+
throw new Error(
1751+
`getLlmJudgeMessages: ${ruleId} returned no code.messages — not a judge rule?`,
1752+
);
1753+
}
1754+
return messages.map((raw) => {
1755+
const m = raw as {
1756+
role?: string;
1757+
content?: string | null;
1758+
content_array?: Array<{
1759+
type?: string;
1760+
text?: string | null;
1761+
image_url?: { url?: string; detail?: string | null } | null;
1762+
}> | null;
1763+
};
1764+
return {
1765+
role: String(m.role ?? ''),
1766+
content: m.content ?? null,
1767+
contentArray:
1768+
m.content_array?.map((part) => ({
1769+
type: String(part.type ?? ''),
1770+
text: part.text ?? null,
1771+
imageUrl: part.image_url
1772+
? { url: String(part.image_url.url ?? ''), detail: part.image_url.detail ?? null }
1773+
: null,
1774+
})) ?? null,
1775+
};
1776+
});
1777+
},
1778+
1779+
/**
1780+
* Read a rule and write back exactly what was read — the edit dialog's own
1781+
* save shape, with nothing edited.
1782+
*
1783+
* This is the round trip that turned OPIK-8250's read bug into permanent
1784+
* data loss: a truncated read fed straight back into a save persisted the
1785+
* truncation, so the prompt the user typed was gone even after the read was
1786+
* fixed. Echoing the server's own `code` verbatim is the point — building a
1787+
* fresh payload here would test this client's serializer instead.
1788+
*/
1789+
async resaveAutomationRuleFromReadBack(ruleId: string, projectId: string): Promise<void> {
1790+
const read = await rawFetch('GET', `/v1/private/automations/evaluators/${ruleId}`);
1791+
if (read.status !== 200) {
1792+
throw new Error(
1793+
`resaveAutomationRuleFromReadBack: GET ${ruleId} answered ${read.status}: ${read.message}`,
1794+
);
1795+
}
1796+
const rule = read.json as {
1797+
type?: string;
1798+
name?: string;
1799+
sampling_rate?: number;
1800+
enabled?: boolean;
1801+
trigger_scope?: string;
1802+
code?: unknown;
1803+
};
1804+
const { status, message } = await rawFetch(
1805+
'PATCH',
1806+
`/v1/private/automations/evaluators/${ruleId}`,
1807+
{
1808+
body: {
1809+
type: rule.type,
1810+
name: rule.name,
1811+
project_ids: [projectId],
1812+
sampling_rate: rule.sampling_rate,
1813+
enabled: rule.enabled,
1814+
trigger_scope: rule.trigger_scope,
1815+
code: rule.code,
1816+
},
1817+
},
1818+
);
1819+
if (status !== 204) {
1820+
throw new Error(
1821+
`resaveAutomationRuleFromReadBack: PATCH ${ruleId} expected 204, got ${status}: ${message}`,
1822+
);
1823+
}
1824+
},
1825+
15491826
/** One rule by id, including the `triggerScope` the pinned SDK cannot see. */
15501827
async getAutomationRule(ruleId: string): Promise<AutomationRuleDetail> {
15511828
const { status, message, json } = await rawFetch(

tests_end_to_end/e2e/core/backend/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export {
2626
type AutomationRuleRef,
2727
type AutomationRuleDetail,
2828
type AutomationRuleLogRef,
29+
type AutomationRuleEvaluatorPageRef,
30+
type JudgeMessageRef,
31+
type JudgeMessageContentPartRef,
32+
type JudgeMessageWrite,
2933
type TraceJsonSection,
3034
type AnnotationQueueDetail,
3135
type AnnotationQueueReviewerRef,

0 commit comments

Comments
 (0)