Skip to content

Commit f9c913b

Browse files
authored
feat(evals): serialize values accessed via nested selectors (#10317)
* feat(evals): serialize values accessed via nested selectors * add proxy
1 parent 2d47be0 commit f9c913b

File tree

7 files changed

+412
-5
lines changed

7 files changed

+412
-5
lines changed

js/packages/phoenix-evals/src/llm/ClassificationEvaluator.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
CreateClassificationEvaluatorArgs,
44
EvaluatorFn,
55
Template,
6+
WithPromptTemplate,
67
} from "../types";
78

89
import { createClassifierFn } from "./createClassifierFn";
@@ -11,9 +12,10 @@ import { LLMEvaluator } from "./LLMEvaluator";
1112
/**
1213
* An LLM evaluator that performs evaluation via classification
1314
*/
14-
export class ClassificationEvaluator<
15-
RecordType extends Record<string, unknown>,
16-
> extends LLMEvaluator<RecordType> {
15+
export class ClassificationEvaluator<RecordType extends Record<string, unknown>>
16+
extends LLMEvaluator<RecordType>
17+
implements WithPromptTemplate
18+
{
1719
readonly evaluatorFn: EvaluatorFn<RecordType>;
1820
readonly promptTemplate: Template;
1921
private _promptTemplateVariables: string[] | undefined;

js/packages/phoenix-evals/src/template/applyTemplate.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Template } from "../types/templating";
22

3+
import { createTemplateVariablesProxy } from "./createTemplateVariablesProxy";
4+
35
import Mustache from "mustache";
46

57
/**
@@ -11,6 +13,12 @@ export function formatTemplate(args: {
1113
variables: Record<string, unknown>;
1214
}) {
1315
const { template, variables } = args;
16+
const variablesProxy = createTemplateVariablesProxy(variables);
1417
// Disable HTML escaping by providing a custom escape function that returns text as-is
15-
return Mustache.render(template, variables, {}, { escape: (text) => text });
18+
return Mustache.render(
19+
template,
20+
variablesProxy,
21+
{},
22+
{ escape: (text) => text }
23+
);
1624
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Creates a Proxy that wraps an object to stringify nested object values when accessed directly.
3+
* This allows Mustache to access properties of objects (e.g., {{user.name}}) while
4+
* stringifying objects that are accessed as leaf values (e.g., {{user.profile}}).
5+
*/
6+
export function createTemplateVariablesProxy(obj: unknown): unknown {
7+
if (obj === null || obj === undefined) {
8+
return obj;
9+
}
10+
11+
if (Array.isArray(obj)) {
12+
return obj.map(createTemplateVariablesProxy);
13+
}
14+
15+
if (typeof obj === "object") {
16+
return new Proxy(obj as Record<string, unknown>, {
17+
get(target, prop: string | symbol) {
18+
// Handle toString and valueOf to stringify the object when accessed directly
19+
if (prop === "toString") {
20+
return () => JSON.stringify(target);
21+
}
22+
if (prop === "valueOf") {
23+
return () => JSON.stringify(target);
24+
}
25+
26+
if (typeof prop !== "string") {
27+
return Reflect.get(target, prop);
28+
}
29+
30+
const value = Reflect.get(target, prop);
31+
32+
// If the value is an object (not array, not null), wrap it in a proxy
33+
// so it can be stringified if accessed directly, or have its properties accessed
34+
if (
35+
value !== null &&
36+
typeof value === "object" &&
37+
!Array.isArray(value)
38+
) {
39+
return createTemplateVariablesProxy(value);
40+
}
41+
42+
return value;
43+
},
44+
// Override valueOf and toString to stringify the object when Mustache tries to render it directly
45+
// Mustache will call toString() when it needs to render an object as a string
46+
has(target, prop) {
47+
if (prop === "toString" || prop === "valueOf") {
48+
return true;
49+
}
50+
return Reflect.has(target, prop);
51+
},
52+
ownKeys(target) {
53+
return Reflect.ownKeys(target);
54+
},
55+
getOwnPropertyDescriptor(target, prop) {
56+
if (prop === "toString") {
57+
return {
58+
enumerable: false,
59+
configurable: true,
60+
value: () => JSON.stringify(target),
61+
};
62+
}
63+
return Reflect.getOwnPropertyDescriptor(target, prop);
64+
},
65+
});
66+
}
67+
68+
return obj;
69+
}

js/packages/phoenix-evals/src/types/evals.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { WithTelemetry } from "./otel";
2+
import { Template } from "./templating";
23

34
import { LanguageModel } from "ai";
45

@@ -77,7 +78,7 @@ export interface CreateClassifierArgs extends WithTelemetry {
7778
/**
7879
* The prompt template to use for classification
7980
*/
80-
promptTemplate: string;
81+
promptTemplate: Template;
8182
}
8283

8384
export interface CreateEvaluatorArgs {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
11
export type Template = string;
22
export type TemplateVariables = Record<string, unknown>;
3+
4+
/**
5+
* A class or object that has a prompt template
6+
*/
7+
export interface WithPromptTemplate {
8+
readonly promptTemplate: Template;
9+
get promptTemplateVariables(): string[];
10+
}

js/packages/phoenix-evals/test/template/applyTemplate.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,24 @@ describe("formatTemplate", () => {
131131
"[Query]: How do I check if x > 5?\n[Answer]: Use: if (x > 5) { }"
132132
);
133133
});
134+
it("should handle template with nested object variables", () => {
135+
const result = formatTemplate({
136+
template: "Hello {{user.name}}, your email is {{user.email}}.",
137+
variables: {
138+
user: { name: "Bob", email: "[email protected]" },
139+
},
140+
});
141+
142+
expect(result).toBe("Hello Bob, your email is [email protected].");
143+
});
144+
it("should stringify nested object variables when accessed directly", () => {
145+
const result = formatTemplate({
146+
template: "Hello {{user}}",
147+
variables: {
148+
user: { name: "Bob", email: "[email protected]" },
149+
},
150+
});
151+
152+
expect(result).toBe('Hello {"name":"Bob","email":"[email protected]"}');
153+
});
134154
});

0 commit comments

Comments
 (0)