Compare commits

...
5 Commits
Author SHA1 Message Date
Nimar ebe85f2a67 chore: release v3.78.1 2025-07-02 12:15:50 +02:00
NimarandGitHub c7b62adbab feat(experiments) support for placeholders as message histories (#7615)
* run experiments with placeholders as well

* add missing file

* fix experiements

* run outside of loop

* add tests

* fix tests

* make tests clean again

* also test that variables resovle first
2025-07-02 10:04:51 +00:00
66d4926fd0 feat: add tags to search on prompts table (#7619)
* Enhance prompt search to include tags with case-insensitive matching

Co-authored-by: marc <marc@langfuse.com>

* drop md

* prettier

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-01 20:37:57 +00:00
b26f5c512f refactor: pivot table dropdowns (#7611)
---------

Co-authored-by: Max Deichmann <m.deichmann@tum.de>
2025-07-01 21:21:46 +02:00
marliessophieandGitHub 9227eaab9c feat(deleteButton): add custom delete message for running evaluator (#7617) 2025-07-01 18:33:41 +00:00
16 changed files with 584 additions and 210 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.78.0",
"version": "3.78.1",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
+2 -1
View File
@@ -13,6 +13,7 @@ export * from "./utils/json";
export * from "./utils/stringChecks";
export * from "./utils/objects";
export * from "./utils/typeChecks";
export * from "./utils/prompts";
export * from "./features/entitlements/plans";
export * from "./interfaces/rate-limits";
export * from "./tableDefinitions/typeHelpers";
@@ -45,7 +46,7 @@ export * from "./features/prompts/parsePromptDependencyTags";
export * from "./features/prompts/validation";
export * from "./features/prompts/types";
export * from "./features/prompts/constants";
export * from "./server/llm/compileChatMessages";
export { compileChatMessages, compileChatMessagesWithIds, isPlaceholder, type MessagePlaceholderValues, type PromptMessage as ServerPromptMessage } from "./server/llm/compileChatMessages";
// export db types only
export * from "@prisma/client";
+24
View File
@@ -0,0 +1,24 @@
/**
* Client-safe utility functions for prompt handling
*/
export interface PromptMessage {
type?: string;
name?: string;
role?: string;
content?: string;
}
/**
* Extracts placeholder names from prompt messages.
* This is a client-safe version that doesn't depend on server-side types.
* @param messages Array of prompt messages
* @returns Array of placeholder names
*/
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
return messages
.filter((msg): msg is PromptMessage & { name: string } =>
msg.type === "placeholder" && typeof msg.name === "string"
)
.map(msg => msg.name);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.78.0",
"version": "3.78.1",
"private": true,
"license": "MIT",
"engines": {
+1
View File
@@ -338,6 +338,7 @@ export function DeleteEvalConfigButton(props: DeleteButtonProps) {
source: isTableAction ? "table-single-row" : "eval config detail",
})
}
customDeletePrompt="This action cannot be undone and removes all logs associated with this running evaluator. Scores produced by this evaluator will not be deleted."
entityToDeleteName="running evaluator"
executeDeleteMutation={executeDeleteMutation}
isDeleteMutationLoading={evaluatorMutation.isLoading}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.78.0";
export const VERSION = "v3.78.1";
@@ -638,7 +638,7 @@ export const CreateExperimentsForm = ({
<span>
Given current prompt, dataset item input must
contain at least one of these first-level JSON
keys, mapped to a string value:
keys:
</span>
<ul className="my-2 ml-2 list-inside list-disc">
{expectedColumns.map((col) => (
@@ -646,8 +646,9 @@ export const CreateExperimentsForm = ({
))}
</ul>
<span>
These will be used as the input to your
prompt.
Variables (like {"{{variable}}"}) should be mapped to string values.
Placeholders should be mapped to arrays of message objects.
These will be used as the input to your prompt.
</span>
</div>
) : (
@@ -721,7 +722,7 @@ export const CreateExperimentsForm = ({
<Loader2 className="h-3 w-3 animate-spin" />
</CardTitle>
<CardDescription className="text-foreground">
Checking dataset items against prompt variables
Checking dataset items against prompt variables and placeholders
</CardDescription>
</CardHeader>
</Card>
@@ -747,7 +748,7 @@ export const CreateExperimentsForm = ({
<CircleCheck className="h-4 w-4" />
</CardTitle>
<div className="text-sm">
Matches between dataset items and prompt variables
Matches between dataset items and prompt variables/placeholders
<ul className="my-2 ml-2 list-inside list-disc">
{Object.entries(
validationResult.data.variablesMap ?? {},
@@ -760,7 +761,7 @@ export const CreateExperimentsForm = ({
</li>
))}
</ul>
Items missing all prompt variables will be excluded from
Items missing all required variables and placeholders will be excluded from
the experiment.
</div>
</CardHeader>
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { api } from "@/src/utils/api";
import { type UseFormReturn } from "react-hook-form";
import { extractVariables, PromptType } from "@langfuse/shared";
import { extractVariables, PromptType, extractPlaceholderNames, type PromptMessage } from "@langfuse/shared";
type ExperimentPromptDataProps = {
projectId: string;
@@ -22,11 +22,18 @@ export function useExperimentPromptData({
const prompt = promptMeta.data?.find((p) => p.id === promptId);
if (!prompt) return [];
return extractVariables(
const extractedVariables = extractVariables(
prompt.type === PromptType.Text
? (prompt?.prompt?.toString() ?? "")
: JSON.stringify(prompt?.prompt),
);
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
return [...extractedVariables, ...placeholderNames];
}, [promptId, promptMeta.data]);
const promptsByName = useMemo(
+9 -15
View File
@@ -2,12 +2,11 @@ import { z } from "zod/v4";
import { randomUUID } from "crypto";
import {
type ExperimentMetadata,
ExperimentCreateQueue,
QueueJobs,
QueueName,
redis,
ZodModelConfig,
ExperimentCreateQueue,
type PlaceholderMessage,
} from "@langfuse/shared/src/server";
import {
createTRPCRouter,
@@ -20,6 +19,8 @@ import {
datasetItemMatchesVariable,
UnauthorizedError,
PromptType,
extractPlaceholderNames,
type PromptMessage,
} from "@langfuse/shared";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
@@ -98,24 +99,17 @@ export const experimentsRouter = createTRPCRouter({
: JSON.stringify(prompt.prompt),
);
if (!Boolean(extractedVariables.length)) {
return {
isValid: false,
message: "Selected prompt has no variables.",
};
}
const promptMessages = prompt?.type === PromptType.Chat && Array.isArray(prompt.prompt)
? prompt.prompt
: [];
const hasPlaceholders = promptMessages.some((msg): msg is PlaceholderMessage =>
(msg as PlaceholderMessage).type === "placeholder"
);
const placeholderNames = extractPlaceholderNames(promptMessages as PromptMessage[]);
if (hasPlaceholders) {
const allVariables = [...extractedVariables, ...placeholderNames];
if (!Boolean(allVariables.length)) {
return {
isValid: false,
message: "Selected prompt has placeholders, those are not yet supported for experiments.",
message: "Selected prompt has no variables or placeholders.",
};
}
@@ -136,7 +130,7 @@ export const experimentsRouter = createTRPCRouter({
const variablesMap = validateDatasetItems(
datasetItems,
extractedVariables,
allVariables,
);
if (!Boolean(Object.keys(variablesMap).length)) {
@@ -484,7 +484,7 @@ export function PromptTable() {
setFilterState={useDebounce(setFilterState)}
columnsWithCustomSelect={["labels", "tags"]}
searchConfig={{
metadataSearchFields: ["Name"],
metadataSearchFields: ["Name", "Tags"],
updateQuery: useDebounce(setSearchQuery, 300),
currentQuery: searchQuery ?? undefined,
tableAllowsFullTextSearch: false,
@@ -92,7 +92,7 @@ export const promptRouter = createTRPCRouter({
: Prisma.empty;
const searchFilter = input.searchQuery
? Prisma.sql` AND p.name ILIKE ${`%${input.searchQuery}%`}`
? Prisma.sql` AND (p.name ILIKE ${`%${input.searchQuery}%`} OR EXISTS (SELECT 1 FROM UNNEST(p.tags) AS tag WHERE tag ILIKE ${`%${input.searchQuery}%`}))`
: Prisma.empty;
const [prompts, promptCount] = await Promise.all([
@@ -119,19 +119,19 @@ export const promptRouter = createTRPCRouter({
searchFilter,
),
),
// promptCount
ctx.prisma.$queryRaw<Array<{ totalCount: bigint }>>(
generatePromptQuery(
Prisma.sql` count(*) AS "totalCount"`,
input.projectId,
filterCondition,
Prisma.empty,
1, // limit
0, // page,
pathFilter,
searchFilter,
),
// promptCount
ctx.prisma.$queryRaw<Array<{ totalCount: bigint }>>(
generatePromptQuery(
Prisma.sql` count(*) AS "totalCount"`,
input.projectId,
filterCondition,
Prisma.empty,
1, // limit
0, // page,
pathFilter,
searchFilter,
),
),
]);
return {
+316 -152
View File
@@ -48,15 +48,14 @@ import {
Hash,
BarChart3,
Table,
X,
Plus,
X,
} from "lucide-react";
import {
buildWidgetName,
buildWidgetDescription,
formatMetricName,
} from "@/src/features/widgets/utils";
import { Badge } from "@/src/components/ui/badge";
import {
MAX_PIVOT_TABLE_DIMENSIONS,
MAX_PIVOT_TABLE_METRICS,
@@ -284,40 +283,74 @@ export function WidgetForm({
setPivotDimensions(newDimensions);
};
// Helper functions for multiple metrics management
const addMetric = () => {
// Check if we've reached the maximum metrics limit
if (selectedMetrics.length >= MAX_PIVOT_TABLE_METRICS) {
showErrorToast(
"Error",
`Maximum ${MAX_PIVOT_TABLE_METRICS} metrics allowed for pivot tables`,
);
return;
// Helper function for updating pivot table metrics
const updatePivotMetric = (
index: number,
measure: string,
aggregation?: z.infer<typeof metricAggregations>,
) => {
const newMetrics = [...selectedMetrics];
if (measure && measure !== "none") {
let finalAggregation: z.infer<typeof metricAggregations>;
if (measure === "count") {
finalAggregation = "count";
} else {
// Get available aggregations for this measure at this index
const availableAggregations = getAvailableAggregations(index, measure);
if (aggregation && availableAggregations.includes(aggregation)) {
// Use provided aggregation if it's available
finalAggregation = aggregation as z.infer<typeof metricAggregations>;
} else {
// Use the first available aggregation as default
finalAggregation =
availableAggregations.length > 0
? availableAggregations[0]
: ("sum" as z.infer<typeof metricAggregations>);
}
}
const newMetric: SelectedMetric = {
id: `${finalAggregation}_${measure}`,
measure: measure,
aggregation: finalAggregation as z.infer<typeof metricAggregations>,
label: `${startCase(finalAggregation)} ${startCase(measure)}`,
};
// Set the metric at the specified index
newMetrics[index] = newMetric;
} else {
// Clear this metric and all subsequent ones
newMetrics.splice(index);
}
const newMetric: SelectedMetric = {
id: `${selectedAggregation}_${selectedMeasure}`,
measure: selectedMeasure,
aggregation: selectedAggregation,
label: `${startCase(selectedAggregation)} ${startCase(selectedMeasure)}`,
};
setSelectedMetrics(newMetrics);
};
// Check if this metric combination already exists
const existingMetric = selectedMetrics.find((m) => m.id === newMetric.id);
if (existingMetric) {
showErrorToast("Error", "This metric combination is already selected");
return;
// Add a new empty metric slot
const addNewMetricSlot = () => {
if (selectedMetrics.length < MAX_PIVOT_TABLE_METRICS) {
const newMetrics = [...selectedMetrics];
newMetrics.push({
id: `temp_${selectedMetrics.length}`,
measure: "",
aggregation: "sum" as z.infer<typeof metricAggregations>,
label: "",
});
setSelectedMetrics(newMetrics);
}
setSelectedMetrics([...selectedMetrics, newMetric]);
};
const removeMetric = (metricId: string) => {
setSelectedMetrics(selectedMetrics.filter((m) => m.id !== metricId));
};
const clearAllMetrics = () => {
setSelectedMetrics([]);
// Remove a metric slot and roll up subsequent ones
const removeMetricSlot = (index: number) => {
if (index > 0) {
// Can't remove the first metric (it's required)
const newMetrics = [...selectedMetrics];
newMetrics.splice(index, 1); // Remove only the metric at this index
setSelectedMetrics(newMetrics);
}
};
const traceFilterOptions = api.traces.filterOptions.useQuery(
@@ -542,6 +575,40 @@ export function WidgetForm({
// Get available metrics for the selected view
const availableMetrics = useMemo(() => {
const viewDeclaration = viewDeclarations[selectedView];
// For pivot tables, only show measures that still have available aggregations
if (selectedChartType === "PIVOT_TABLE") {
return Object.entries(viewDeclaration.measures)
.filter(([measureKey]) => {
// For count, there's only one aggregation option
if (measureKey === "count") {
return !selectedMetrics.some((m) => m.measure === "count");
}
// For other measures, check if there are any aggregations left
const selectedAggregationsForMeasure = selectedMetrics
.filter((m) => m.measure === measureKey)
.map((m) => m.aggregation);
const availableAggregationsForMeasure =
metricAggregations.options.filter(
(agg) =>
agg !== "histogram" &&
!selectedAggregationsForMeasure.includes(agg),
);
return availableAggregationsForMeasure.length > 0;
})
.map(([key]) => ({
value: key,
label: startCase(key),
}))
.sort((a, b) =>
a.label.localeCompare(b.label, "en", { sensitivity: "base" }),
);
}
// For regular charts, show all metrics
return Object.entries(viewDeclaration.measures)
.map(([key]) => ({
value: key,
@@ -550,7 +617,64 @@ export function WidgetForm({
.sort((a, b) =>
a.label.localeCompare(b.label, "en", { sensitivity: "base" }),
);
}, [selectedView]);
}, [selectedView, selectedChartType, selectedMetrics]);
// Get available aggregations for a specific metric index in pivot tables
const getAvailableAggregations = (
metricIndex: number,
measureKey: string,
): z.infer<typeof metricAggregations>[] => {
if (selectedChartType === "PIVOT_TABLE" && measureKey) {
return metricAggregations.options.filter(
(agg) =>
!selectedMetrics.some(
(m, idx) =>
idx !== metricIndex &&
m.measure === measureKey &&
m.aggregation === agg,
),
) as z.infer<typeof metricAggregations>[];
}
return metricAggregations.options as z.infer<typeof metricAggregations>[];
};
// Get available metrics for a specific metric index in pivot tables
const getAvailableMetrics = (metricIndex: number) => {
if (selectedChartType === "PIVOT_TABLE") {
const viewDeclaration = viewDeclarations[selectedView];
return Object.entries(viewDeclaration.measures)
.filter(([measureKey]) => {
// For count, there's only one aggregation option
if (measureKey === "count") {
return !selectedMetrics.some(
(m, idx) => idx !== metricIndex && m.measure === "count",
);
}
// For other measures, check if there are any aggregations left
const selectedAggregationsForMeasure = selectedMetrics
.filter((m, idx) => idx !== metricIndex && m.measure === measureKey)
.map((m) => m.aggregation);
const availableAggregationsForMeasure =
metricAggregations.options.filter(
(agg) =>
agg !== "histogram" &&
!selectedAggregationsForMeasure.includes(agg),
);
return availableAggregationsForMeasure.length > 0;
})
.map(([key]) => ({
value: key,
label: startCase(key),
}))
.sort((a, b) =>
a.label.localeCompare(b.label, "en", { sensitivity: "base" }),
);
}
return availableMetrics;
};
// Get available dimensions for the selected view
const availableDimensions = useMemo(() => {
@@ -584,10 +708,12 @@ export function WidgetForm({
// Determine metrics based on chart type
const queryMetrics =
selectedChartType === "PIVOT_TABLE"
? selectedMetrics.map((metric) => ({
measure: metric.measure,
aggregation: metric.aggregation,
}))
? selectedMetrics
.filter((metric) => metric.measure && metric.measure !== "")
.map((metric) => ({
measure: metric.measure,
aggregation: metric.aggregation,
}))
: [
{
measure: selectedMeasure,
@@ -701,7 +827,10 @@ export function WidgetForm({
}
// Validate pivot table requirements
if (selectedChartType === "PIVOT_TABLE" && selectedMetrics.length === 0) {
const validMetrics = selectedMetrics.filter(
(m) => m.measure && m.measure !== "",
);
if (selectedChartType === "PIVOT_TABLE" && validMetrics.length === 0) {
showErrorToast(
"Error",
"At least one metric is required for pivot tables",
@@ -721,7 +850,7 @@ export function WidgetForm({
: [],
metrics:
selectedChartType === "PIVOT_TABLE"
? selectedMetrics.map((metric) => ({
? validMetrics.map((metric) => ({
measure: metric.measure,
agg: metric.aggregation,
}))
@@ -766,9 +895,13 @@ export function WidgetForm({
// For pivot tables, extract actual metric names for the new formatting
const isPivotTable = selectedChartType === "PIVOT_TABLE";
const validMetricsForNaming = selectedMetrics.filter(
(m) => m.measure && m.measure !== "",
);
const metricNames =
isPivotTable && selectedMetrics.length > 0
? selectedMetrics.map((m) => m.id) // Use the ID which is "${aggregation}_${measure}"
isPivotTable && validMetricsForNaming.length > 0
? validMetricsForNaming.map((m) => m.id) // Use the ID which is "${aggregation}_${measure}"
: undefined;
const suggested = buildWidgetName({
@@ -777,7 +910,7 @@ export function WidgetForm({
dimension: dimensionForNaming,
view: selectedView,
metrics: metricNames,
isMultiMetric: isPivotTable && selectedMetrics.length > 0,
isMultiMetric: isPivotTable && validMetricsForNaming.length > 0,
});
setWidgetName(suggested);
@@ -804,9 +937,12 @@ export function WidgetForm({
// For pivot tables, extract actual metric names for the new formatting
const isPivotTable = selectedChartType === "PIVOT_TABLE";
const validMetricsForDescription = selectedMetrics.filter(
(m) => m.measure && m.measure !== "",
);
const metricNames =
isPivotTable && selectedMetrics.length > 0
? selectedMetrics.map((m) => m.id) // Use the ID which is "${aggregation}_${measure}"
isPivotTable && validMetricsForDescription.length > 0
? validMetricsForDescription.map((m) => m.id) // Use the ID which is "${aggregation}_${measure}"
: undefined;
const suggested = buildWidgetDescription({
@@ -816,7 +952,7 @@ export function WidgetForm({
view: selectedView,
filters: userFilterState,
metrics: metricNames,
isMultiMetric: isPivotTable && selectedMetrics.length > 0,
isMultiMetric: isPivotTable && validMetricsForDescription.length > 0,
});
setWidgetDescription(suggested);
@@ -887,118 +1023,146 @@ export function WidgetForm({
{/* For pivot tables: multiple metrics selection */}
{selectedChartType === "PIVOT_TABLE" ? (
<div className="space-y-3">
{/* Selected metrics display */}
{selectedMetrics.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
Selected Metrics ({selectedMetrics.length})
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearAllMetrics}
className="h-auto p-1 text-xs"
>
Clear All
</Button>
</div>
<div className="flex flex-wrap gap-1">
{selectedMetrics.map((metric) => (
<Badge
key={metric.id}
variant="secondary"
className="flex items-center gap-1"
>
<span className="text-xs">{metric.label}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeMetric(metric.id)}
className="h-auto p-0 hover:bg-transparent"
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
</div>
{/* Metric selection dropdowns */}
{Array.from(
{ length: Math.max(1, selectedMetrics.length) },
(_, index) => {
const isEnabled =
index === 0 ||
(selectedMetrics[index - 1] &&
selectedMetrics[index - 1].measure);
const currentMetric = selectedMetrics[index];
const currentMeasure = currentMetric?.measure || "";
const currentAggregation =
currentMetric?.aggregation || "sum";
const metricsForIndex = getAvailableMetrics(index);
const aggregationsForIndex = getAvailableAggregations(
index,
currentMeasure,
);
const canEdit = metricsForIndex.length > 0;
return (
<div key={index} className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={`pivot-metric-${index}`}>
Metric {index + 1}{" "}
{index === 0 ? "(Required)" : "(Optional)"}
</Label>
{index > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeMetricSlot(index)}
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Select
value={currentMeasure}
onValueChange={(value) =>
updatePivotMetric(
index,
value,
// Don't pass current aggregation when measure changes
// Let the function determine the best default
undefined,
)
}
disabled={!isEnabled || !canEdit}
>
<SelectTrigger id={`pivot-metric-${index}`}>
<SelectValue
placeholder={
!isEnabled
? "Select previous metric first"
: !canEdit
? "No more measures available"
: "Select measure"
}
/>
</SelectTrigger>
<SelectContent>
{metricsForIndex.map((metric) => {
const meta =
viewDeclarations[selectedView]
?.measures?.[metric.value];
return (
<WidgetPropertySelectItem
key={metric.value}
value={metric.value}
label={metric.label}
description={meta?.description}
unit={meta?.unit}
type={meta?.type}
/>
);
})}
</SelectContent>
</Select>
</div>
{currentMeasure &&
currentMeasure !== "count" && (
<div className="flex-1">
<Select
value={currentAggregation}
onValueChange={(value) =>
updatePivotMetric(
index,
currentMeasure,
value as z.infer<
typeof metricAggregations
>,
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select aggregation" />
</SelectTrigger>
<SelectContent>
{aggregationsForIndex.map(
(aggregation) => (
<SelectItem
key={aggregation}
value={aggregation}
>
{startCase(aggregation)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
);
},
)}
{/* Add new metric controls */}
<div className="space-y-2 rounded-md border p-3">
<span className="text-sm font-medium">Add Metric</span>
<div className="grid grid-cols-2 gap-2">
<Select
value={selectedMeasure}
onValueChange={(value) => setSelectedMeasure(value)}
{/* Add new metric button */}
{selectedMetrics.length < MAX_PIVOT_TABLE_METRICS &&
getAvailableMetrics(selectedMetrics.length).length >
0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={addNewMetricSlot}
className="w-full"
>
<SelectTrigger>
<SelectValue placeholder="Select measure" />
</SelectTrigger>
<SelectContent>
{availableMetrics.map((metric) => {
const meta =
viewDeclarations[selectedView]?.measures?.[
metric.value
];
return (
<WidgetPropertySelectItem
key={metric.value}
value={metric.value}
label={metric.label}
description={meta?.description}
unit={meta?.unit}
type={meta?.type}
/>
);
})}
</SelectContent>
</Select>
{selectedMeasure !== "count" && (
<Select
value={selectedAggregation}
onValueChange={(value) =>
setSelectedAggregation(
value as z.infer<typeof metricAggregations>,
)
}
>
<SelectTrigger>
<SelectValue placeholder="Aggregation" />
</SelectTrigger>
<SelectContent>
{metricAggregations.options.map((aggregation) => (
<SelectItem
key={aggregation}
value={aggregation}
>
{startCase(aggregation)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addMetric}
className="w-full"
disabled={
selectedMetrics.length >= MAX_PIVOT_TABLE_METRICS
}
>
<Plus className="mr-1 h-3 w-3" />
Add Metric
{selectedMetrics.length >= MAX_PIVOT_TABLE_METRICS &&
` (Max ${MAX_PIVOT_TABLE_METRICS})`}
</Button>
</div>
<Plus className="mr-1 h-3 w-3" />
Add Metric {selectedMetrics.length + 1}
</Button>
)}
</div>
) : (
/* For regular charts: single metric selection */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.78.0",
"version": "3.78.1",
"description": "",
"license": "MIT",
"private": true,
@@ -313,6 +313,187 @@ describe("create experiment jobs", () => {
}, 10_000);
});
describe("create experiment jobs with placeholders", () => {
const setupPlaceholderTest = async (promptConfig: any, datasetItemInput: any) => {
await pruneDatabase();
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const datasetId = randomUUID();
const runId = randomUUID();
const promptId = "03f834cc-c089-4bcb-9add-b14cadcdf47c";
// Create prompt
await prisma.prompt.create({
data: {
id: promptId,
projectId,
name: promptConfig.name,
prompt: promptConfig.prompt,
type: "chat",
version: 1,
createdBy: "test-user",
},
});
// Create dataset
await prisma.dataset.create({
data: {
id: datasetId,
projectId,
name: "Test Dataset",
},
});
// Create dataset run with metadata
await kyselyPrisma.$kysely
.insertInto("dataset_runs")
.values({
id: runId,
name: "Test Run",
project_id: projectId,
dataset_id: datasetId,
metadata: {
prompt_id: promptId,
provider: "openai",
model: "gpt-3.5-turbo",
model_params: { temperature: 0 },
},
})
.execute();
// Create dataset item
await prisma.datasetItem.create({
data: {
id: randomUUID(),
projectId,
datasetId,
input: datasetItemInput,
},
});
// Create API key
await prisma.llmApiKeys.create({
data: {
id: randomUUID(),
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
displaySecretKey: "test-key",
secretKey: encrypt("test-key"),
},
});
return { projectId, datasetId, runId };
};
test("creates experiment job with multiple placeholders containing variables", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Multiple Placeholders",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "conversation_history" },
{ type: "placeholder", name: "user_context" },
{ role: "user", content: "Please help me." }
]
},
{
conversation_history: [
{ role: "user", content: "Hello {{name}}!" },
{ role: "assistant", content: "Hi there!" }
],
user_context: [
{ role: "system", content: "User is a {{role}}" }
],
name: "John",
role: "developer"
}
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(1);
expect(runItems[0].project_id).toBe(projectId);
expect(runItems[0].dataset_run_id).toBe(runId);
expect(runItems[0].trace_id).toBeDefined();
}, 10_000);
test("handles empty placeholder arrays", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Empty Placeholder",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "empty_history" },
{ role: "user", content: "Start conversation." }
]
},
{ empty_history: [] }
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(1);
expect(runItems[0].project_id).toBe(projectId);
expect(runItems[0].dataset_run_id).toBe(runId);
expect(runItems[0].trace_id).toBeDefined();
}, 10_000);
test("fails when placeholder has invalid message format", async () => {
const { projectId, datasetId, runId } = await setupPlaceholderTest(
{
name: "Test Invalid Placeholder",
prompt: [
{ role: "system", content: "You are a helpful assistant." },
{ type: "placeholder", name: "invalid_messages" },
{ role: "user", content: "Help me." }
]
},
{ invalid_messages: "this should be an array or object" }
);
const payload = {
projectId,
datasetId,
runId,
};
await createExperimentJob({ event: payload });
// Should not create run items for invalid placeholder format
const runItems = await kyselyPrisma.$kysely
.selectFrom("dataset_run_items")
.selectAll()
.where("project_id", "=", projectId)
.execute();
expect(runItems.length).toBe(0);
}, 10_000);
});
describe("create experiment job calls with langfuse server side tracing", async () => {
await pruneDatabase();
const mockEvent = {
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.78.0";
export const VERSION = "v3.78.1";
@@ -18,11 +18,12 @@ import {
import { kyselyPrisma, prisma } from "@langfuse/shared/src/db";
import { type ExperimentCreateEventSchema } from "@langfuse/shared/src/server";
import {
datasetItemMatchesVariable,
extractVariables,
InvalidRequestError,
LangfuseNotFoundError,
type Prisma,
extractVariables,
datasetItemMatchesVariable,
PromptType,
stringifyValue,
} from "@langfuse/shared";
import { backOff } from "exponential-backoff";
@@ -45,11 +46,14 @@ const replaceVariablesInPrompt = (
prompt: PromptContent,
itemInput: Record<string, any>,
variables: string[],
placeholderNames: string[] = [],
): ChatMessage[] => {
const processContent = (content: string) => {
// Extract only relevant variables from itemInput
// Extract only Handlebars variables from itemInput (exclude message placeholders)
const filteredContext = Object.fromEntries(
Object.entries(itemInput).filter(([key]) => variables.includes(key)),
Object.entries(itemInput).filter(([key]) =>
variables.includes(key) && !placeholderNames.includes(key)
),
);
// Apply Handlebars ONLY if the content contains `{{variable}}` pattern
@@ -70,14 +74,12 @@ const replaceVariablesInPrompt = (
];
}
const placeholderNames = extractPlaceholderNames(prompt as PromptMessage[]);
const placeholderValues: MessagePlaceholderValues = {};
// itemInput to placeholderValues
for (const placeholderName of placeholderNames) {
if (!(placeholderName in itemInput)) {
// TODO: handle missing placeholder values
// throw new Error(`Missing placeholder value for '${placeholderName}'`);
continue;
// TODO: should we throw?
throw new Error(`Missing placeholder value for '${placeholderName}'`);
}
const value = itemInput[placeholderName];
@@ -117,8 +119,6 @@ const replaceVariablesInPrompt = (
{}
);
// TODO: validate correctness
// handlebars variable substitution to all messages
return compiledMessages.map((message) => ({
...message,
content: processContent(message.content),
@@ -251,13 +251,13 @@ export const createExperimentJob = async ({
// extract variables from prompt
const extractedVariables = extractVariables(
prompt?.type === "text"
prompt?.type === PromptType.Text
? (prompt.prompt?.toString() ?? "")
: JSON.stringify(prompt.prompt),
);
// also extract placeholder names if prompt is an array
const placeholderNames = prompt?.type !== "text" && Array.isArray(validatedPrompt.data)
// also extract placeholder names if prompt is a chat prompt
const placeholderNames = prompt?.type === PromptType.Chat && Array.isArray(validatedPrompt.data)
? extractPlaceholderNames(validatedPrompt.data as PromptMessage[])
: [];
const allVariables = [...extractedVariables, ...placeholderNames];
@@ -275,7 +275,7 @@ export const createExperimentJob = async ({
if (!validatedDatasetItems.length) {
throw new InvalidRequestError(
`No Dataset ${datasetId} item input matches expected prompt variable format`,
`No Dataset ${datasetId} item input matches expected prompt variables or placeholders format`,
);
}
@@ -305,7 +305,8 @@ export const createExperimentJob = async ({
messages = replaceVariablesInPrompt(
validatedPrompt.data,
datasetItem.input, // validated format
extractedVariables,
allVariables,
placeholderNames,
);
} catch (error) {
// skip this dataset item if there is an error replacing variables