Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f44df5dc | ||
|
|
34da790d5e | ||
|
|
c16d86f7e5 | ||
|
|
1b2b8cc43a | ||
|
|
3903790086 | ||
|
|
6da1280c43 | ||
|
|
85343bf26c | ||
|
|
e3b4dc1928 | ||
|
|
a4939bacd8 | ||
|
|
4441942cf5 | ||
|
|
6597b02c09 | ||
|
|
0df3ad27dc | ||
|
|
696d458a27 | ||
|
|
fd18ab605d | ||
|
|
d47d14eecd | ||
|
|
24a81e1589 | ||
|
|
ac0c13f3e5 | ||
|
|
a7b90fa32c | ||
|
|
9eaf2a2e0f | ||
|
|
61e3cc33bf | ||
|
|
2be198400d | ||
|
|
830df682f9 | ||
|
|
2a420b2e90 | ||
|
|
53127c34ab | ||
|
|
9c57ff4853 | ||
|
|
a71176eda4 | ||
|
|
f59a85f412 |
@@ -48,12 +48,25 @@ types:
|
||||
date: date
|
||||
countTraces: integer
|
||||
countObservations: integer
|
||||
totalCost: double
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Total model cost in USD
|
||||
usage: list<UsageByModel>
|
||||
UsageByModel:
|
||||
docs: Daily usage of a given model. Usage corresponds to the unit set for the specific model (e.g. tokens).
|
||||
properties:
|
||||
model: optional<string>
|
||||
inputUsage: integer
|
||||
outputUsage: integer
|
||||
totalUsage: integer
|
||||
inputUsage:
|
||||
type: integer
|
||||
docs: Total number of generation input units (e.g. tokens)
|
||||
outputUsage:
|
||||
type: integer
|
||||
docs: Total number of generation output units (e.g. tokens)
|
||||
totalUsage:
|
||||
type: integer
|
||||
docs: Total number of generation total units (e.g. tokens)
|
||||
countTraces: integer
|
||||
countObservations: integer
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Total model cost in USD
|
||||
|
||||
@@ -70,14 +70,24 @@ types:
|
||||
name: string
|
||||
prompt: list<ChatMessage>
|
||||
config: optional<unknown>
|
||||
labels: optional<list<string>>
|
||||
labels:
|
||||
type: optional<list<string>>
|
||||
docs: List of deployment labels of this prompt version.
|
||||
tags:
|
||||
type: optional<list<string>>
|
||||
docs: List of tags to apply to all versions of this prompt.
|
||||
|
||||
CreateTextPromptRequest:
|
||||
properties:
|
||||
name: string
|
||||
prompt: string
|
||||
config: optional<unknown>
|
||||
labels: optional<list<string>>
|
||||
labels:
|
||||
type: optional<list<string>>
|
||||
docs: List of deployment labels of this prompt version.
|
||||
tags:
|
||||
type: optional<list<string>>
|
||||
docs: List of tags to apply to all versions of this prompt.
|
||||
|
||||
Prompt:
|
||||
union:
|
||||
@@ -89,7 +99,12 @@ types:
|
||||
name: string
|
||||
version: integer
|
||||
config: unknown
|
||||
labels: list<string>
|
||||
labels:
|
||||
type: list<string>
|
||||
docs: List of deployment labels of this prompt version.
|
||||
tags:
|
||||
type: list<string>
|
||||
docs: List of tags. Used to filter via UI and API. The same across versions of a prompt.
|
||||
|
||||
ChatMessage:
|
||||
properties:
|
||||
|
||||
@@ -7,7 +7,7 @@ service:
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
get:
|
||||
docs: Get a session
|
||||
docs: Get a session. Please note that `traces` on this endpoint are not paginated, if you plan to fetch large sessions, consider `GET /api/public/traces?sessionId=<sessionId>`
|
||||
method: GET
|
||||
path: /sessions/{sessionId}
|
||||
path-parameters:
|
||||
|
||||
@@ -30,6 +30,7 @@ service:
|
||||
docs: Limit of items per page. If you encounter api issues due to too large page sizes, try to reduce the limit.
|
||||
userId: optional<string>
|
||||
name: optional<string>
|
||||
sessionId: optional<string>
|
||||
fromTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only traces newer than this datetime (ISO 8601).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "2.47.2",
|
||||
"version": "2.48.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -32,7 +32,8 @@ export const ScoreSource = {
|
||||
export type ScoreSource = (typeof ScoreSource)[keyof typeof ScoreSource];
|
||||
export const ScoreDataType = {
|
||||
CATEGORICAL: "CATEGORICAL",
|
||||
NUMERIC: "NUMERIC"
|
||||
NUMERIC: "NUMERIC",
|
||||
BOOLEAN: "BOOLEAN"
|
||||
} as const;
|
||||
export type ScoreDataType = (typeof ScoreDataType)[keyof typeof ScoreDataType];
|
||||
export const PricingUnit = {
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY "dataset_items_source_trace_id_idx" ON "dataset_items" USING HASH ("source_trace_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scores_project_id_name_idx" ON "scores"("project_id", "name");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ScoreDataType" ADD VALUE 'BOOLEAN';
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Create manual-score config for any project with manual scores and link config_id to scores
|
||||
BEGIN;
|
||||
WITH project_configs AS (
|
||||
INSERT INTO score_configs (id,
|
||||
project_id,
|
||||
name,
|
||||
data_type,
|
||||
is_archived,
|
||||
min_value,
|
||||
max_value,
|
||||
description)
|
||||
SELECT
|
||||
gen_random_uuid () AS id,
|
||||
s.project_id,
|
||||
'manual-score',
|
||||
'NUMERIC',
|
||||
FALSE,
|
||||
- 1,
|
||||
1,
|
||||
'Langfuse legacy annotation score.'
|
||||
FROM ( SELECT DISTINCT
|
||||
project_id
|
||||
FROM
|
||||
scores
|
||||
WHERE
|
||||
name = 'manual-score'
|
||||
AND source = 'ANNOTATION') s
|
||||
WHERE
|
||||
NOT EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
score_configs sc
|
||||
WHERE
|
||||
sc.name = 'manual-score'
|
||||
AND sc.project_id = s.project_id)
|
||||
RETURNING
|
||||
id,
|
||||
project_id
|
||||
)
|
||||
UPDATE
|
||||
scores
|
||||
SET
|
||||
config_id = pc.id
|
||||
FROM
|
||||
project_configs pc
|
||||
WHERE
|
||||
scores.project_id = pc.project_id
|
||||
AND scores.name = 'manual-score';
|
||||
COMMIT;
|
||||
@@ -419,6 +419,7 @@ model Score {
|
||||
@@index(timestamp)
|
||||
@@index([value])
|
||||
@@index([projectId])
|
||||
@@index([projectId, name])
|
||||
@@index([authorUserId])
|
||||
@@index([configId])
|
||||
@@index([traceId], type: Hash)
|
||||
@@ -463,6 +464,7 @@ model ScoreConfig {
|
||||
enum ScoreDataType {
|
||||
CATEGORICAL
|
||||
NUMERIC
|
||||
BOOLEAN
|
||||
}
|
||||
|
||||
enum PricingUnit {
|
||||
@@ -532,6 +534,7 @@ model DatasetItem {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
datasetRunItems DatasetRunItems[]
|
||||
|
||||
@@index([sourceTraceId], type: Hash)
|
||||
@@index([sourceObservationId], type: Hash)
|
||||
@@index([datasetId], type: Hash)
|
||||
@@index([createdAt])
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type Prisma,
|
||||
ObservationType,
|
||||
ScoreSource,
|
||||
ScoreDataType,
|
||||
} from "../src/index";
|
||||
import { hash } from "bcryptjs";
|
||||
import { parseArgs } from "node:util";
|
||||
@@ -39,6 +40,7 @@ async function main() {
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
image: "https://static.langfuse.com/langfuse-dev%2Fexample-avatar.png",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -144,6 +146,11 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const configIdsAndNames = await generateConfigsForProject([
|
||||
project1,
|
||||
project2,
|
||||
]);
|
||||
|
||||
const promptIds = await generatePromptsForProject([project1, project2]);
|
||||
|
||||
const envTags = [null, "development", "staging", "production"];
|
||||
@@ -157,7 +164,8 @@ async function main() {
|
||||
colorTags,
|
||||
project1,
|
||||
project2,
|
||||
promptIds
|
||||
promptIds,
|
||||
configIdsAndNames
|
||||
);
|
||||
|
||||
console.log(
|
||||
@@ -453,13 +461,15 @@ function createObjects(
|
||||
colorTags: (string | null)[],
|
||||
project1: Project,
|
||||
project2: Project,
|
||||
promptIds: Map<string, string[]>
|
||||
promptIds: Map<string, string[]>,
|
||||
configIdsAndNames: Map<string, { name: string; id: string }[]>
|
||||
) {
|
||||
const traces: Prisma.TraceCreateManyInput[] = [];
|
||||
const observations: Prisma.ObservationCreateManyInput[] = [];
|
||||
const scores: Prisma.ScoreCreateManyInput[] = [];
|
||||
const sessions: Prisma.TraceSessionCreateManyInput[] = [];
|
||||
const events: Prisma.ObservationCreateManyInput[] = [];
|
||||
const configs: Prisma.ScoreConfigCreateManyInput[] = [];
|
||||
|
||||
for (let i = 0; i < traceVolume; i++) {
|
||||
// print progress to console with a progress bar that refreshes every 10 iterations
|
||||
@@ -511,17 +521,28 @@ function createObjects(
|
||||
|
||||
traces.push(trace);
|
||||
|
||||
const configArray = configIdsAndNames.get(projectId) ?? [];
|
||||
const randomIndex = Math.floor(Math.random() * 3);
|
||||
const config =
|
||||
configArray.length >= randomIndex - 1 && configArray[randomIndex];
|
||||
const { name: annotationScoreName, id: configId } = config || {
|
||||
name: "manual-score",
|
||||
id: undefined,
|
||||
};
|
||||
|
||||
const traceScores = [
|
||||
...(Math.random() > 0.5
|
||||
? [
|
||||
{
|
||||
traceId: trace.id,
|
||||
name: "manual-score",
|
||||
name: annotationScoreName,
|
||||
value: Math.floor(Math.random() * 3) - 1,
|
||||
timestamp: traceTs,
|
||||
source: ScoreSource.ANNOTATION,
|
||||
projectId,
|
||||
authorUserId: `user-${i}`,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
...(configId ? { configId } : {}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -534,6 +555,7 @@ function createObjects(
|
||||
timestamp: traceTs,
|
||||
source: ScoreSource.API,
|
||||
projectId,
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -772,6 +794,7 @@ function createObjects(
|
||||
traces,
|
||||
observations,
|
||||
scores,
|
||||
configs,
|
||||
sessions: uniqueSessions,
|
||||
events,
|
||||
};
|
||||
@@ -954,3 +977,80 @@ async function generatePrompts(project: Project) {
|
||||
}
|
||||
return promptIds;
|
||||
}
|
||||
|
||||
async function generateConfigsForProject(projects: Project[]) {
|
||||
const projectIdsToConfigs: Map<string, { name: string; id: string }[]> =
|
||||
new Map();
|
||||
|
||||
await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const configNameAndId = await generateConfigs(project);
|
||||
projectIdsToConfigs.set(project.id, configNameAndId);
|
||||
})
|
||||
);
|
||||
return projectIdsToConfigs;
|
||||
}
|
||||
|
||||
async function generateConfigs(project: Project) {
|
||||
const configNameAndId: { name: string; id: string }[] = [];
|
||||
|
||||
const configs = [
|
||||
{
|
||||
id: `config-${v4()}`,
|
||||
name: "manual-score",
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
projectId: project.id,
|
||||
isArchived: false,
|
||||
},
|
||||
{
|
||||
id: `config-${v4()}`,
|
||||
projectId: project.id,
|
||||
name: "Accuracy",
|
||||
dataType: ScoreDataType.CATEGORICAL,
|
||||
categories: [
|
||||
{ label: "Incorrect", value: 0 },
|
||||
{ label: "Partially Correct", value: 1 },
|
||||
{ label: "Correct", value: 2 },
|
||||
],
|
||||
isArchived: false,
|
||||
},
|
||||
{
|
||||
id: `config-${v4()}`,
|
||||
projectId: project.id,
|
||||
name: "Toxicity",
|
||||
dataType: ScoreDataType.BOOLEAN,
|
||||
categories: [
|
||||
{ label: "True", value: 1 },
|
||||
{ label: "False", value: 0 },
|
||||
],
|
||||
description:
|
||||
"Used to indicate if text was harmful or offensive in nature.",
|
||||
isArchived: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const config of configs) {
|
||||
await prisma.scoreConfig.upsert({
|
||||
where: {
|
||||
id_projectId: {
|
||||
projectId: config.projectId,
|
||||
id: config.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: config.id,
|
||||
projectId: config.projectId,
|
||||
name: config.name,
|
||||
dataType: config.dataType,
|
||||
categories: config.categories,
|
||||
isArchived: config.isArchived,
|
||||
},
|
||||
update: {
|
||||
id: config.id,
|
||||
},
|
||||
});
|
||||
configNameAndId.push({ name: config.name, id: config.id });
|
||||
}
|
||||
|
||||
return configNameAndId;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import {
|
||||
ColumnDefinition,
|
||||
type TableNames as TableName,
|
||||
type TableNames,
|
||||
} from "./interfaces/tableDefinition";
|
||||
import { FilterState } from "./types";
|
||||
import { filterOperators } from "./interfaces/filters";
|
||||
@@ -27,7 +27,7 @@ const arrayOperatorReplacements = {
|
||||
export function tableColumnsToSqlFilterAndPrefix(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableName
|
||||
table: TableNames
|
||||
): Prisma.Sql {
|
||||
const sql = tableColumnsToSqlFilter(filters, tableColumns, table);
|
||||
if (sql === Prisma.empty) {
|
||||
@@ -43,7 +43,7 @@ export function tableColumnsToSqlFilterAndPrefix(
|
||||
export function tableColumnsToSqlFilter(
|
||||
filters: FilterState,
|
||||
tableColumns: ColumnDefinition[],
|
||||
table: TableName
|
||||
table: TableNames
|
||||
): Prisma.Sql {
|
||||
const internalFilters = filters.map((filter) => {
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
@@ -153,7 +153,7 @@ export function tableColumnsToSqlFilter(
|
||||
|
||||
const castValueToPostgresTypes = (
|
||||
column: ColumnDefinition,
|
||||
table: TableName
|
||||
table: TableNames
|
||||
) => {
|
||||
return column.name === "type" &&
|
||||
(table === "observations" ||
|
||||
|
||||
@@ -42,6 +42,7 @@ export const tableNames = [
|
||||
"traces_parent_observation_scores",
|
||||
"sessions",
|
||||
"prompts",
|
||||
"users",
|
||||
] as const;
|
||||
|
||||
export type TableNames = (typeof tableNames)[number];
|
||||
|
||||
@@ -30,4 +30,5 @@ export type TableName =
|
||||
| "sessions"
|
||||
| "scores"
|
||||
| "prompts"
|
||||
| "dashboard";
|
||||
| "dashboard"
|
||||
| "users";
|
||||
|
||||
Generated
+49
-10
@@ -315,6 +315,9 @@ importers:
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -495,6 +498,9 @@ importers:
|
||||
uuid:
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1
|
||||
vaul:
|
||||
specifier: ^0.9.1
|
||||
version: 0.9.1(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0)
|
||||
zod:
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
@@ -2079,14 +2085,6 @@ packages:
|
||||
style-mod: 4.1.2
|
||||
dev: false
|
||||
|
||||
/@codemirror/lint@6.7.1:
|
||||
resolution: {integrity: sha512-rELba6QJD20/bNXWP/cKTGLrwVEcpa2ViwULCV03ONcY1Je85++7sczVRUlnE4TJMjatx3IJTz6HX4NXi+moXw==}
|
||||
dependencies:
|
||||
'@codemirror/state': 6.4.1
|
||||
'@codemirror/view': 6.26.3
|
||||
crelt: 1.0.6
|
||||
dev: false
|
||||
|
||||
/@codemirror/lint@6.8.0:
|
||||
resolution: {integrity: sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==}
|
||||
dependencies:
|
||||
@@ -4256,7 +4254,7 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.0
|
||||
'@babel/runtime': 7.24.5
|
||||
'@radix-ui/primitive': 1.0.1
|
||||
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.79)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.0.1(@types/react@18.2.79)(react@18.2.0)
|
||||
@@ -4841,6 +4839,33 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-toggle-group@1.0.4(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.5
|
||||
'@radix-ui/primitive': 1.0.1
|
||||
'@radix-ui/react-context': 1.0.1(@types/react@18.2.79)(react@18.2.0)
|
||||
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.79)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-toggle': 1.0.3(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.79)(react@18.2.0)
|
||||
'@types/react': 18.2.79
|
||||
'@types/react-dom': 18.2.25
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==}
|
||||
peerDependencies:
|
||||
@@ -8420,7 +8445,7 @@ packages:
|
||||
'@codemirror/autocomplete': 6.16.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.3)(@lezer/common@1.2.1)
|
||||
'@codemirror/commands': 6.5.0
|
||||
'@codemirror/language': 6.10.1
|
||||
'@codemirror/lint': 6.7.1
|
||||
'@codemirror/lint': 6.8.0
|
||||
'@codemirror/search': 6.5.6
|
||||
'@codemirror/state': 6.4.1
|
||||
'@codemirror/view': 6.26.3
|
||||
@@ -17050,6 +17075,20 @@ packages:
|
||||
engines: {node: '>= 0.8'}
|
||||
dev: false
|
||||
|
||||
/vaul@0.9.1(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
dev: false
|
||||
|
||||
/victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
dependencies:
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ FROM node:20-alpine AS alpine
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat
|
||||
|
||||
FROM alpine AS base
|
||||
RUN npm install turbo --global
|
||||
RUN npm install turbo@^1.13.3 --global
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "2.47.2",
|
||||
"version": "2.48.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -56,6 +56,7 @@
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@react-email/components": "^0.0.18",
|
||||
"@react-email/render": "^0.0.14",
|
||||
@@ -116,6 +117,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-query-params": "^2.2.1",
|
||||
"uuid": "^9.0.1",
|
||||
"vaul": "^0.9.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1137,7 +1137,10 @@ paths:
|
||||
security: *ref_0
|
||||
/api/public/sessions/{sessionId}:
|
||||
get:
|
||||
description: Get a session
|
||||
description: >-
|
||||
Get a session. Please note that `traces` on this endpoint are not
|
||||
paginated, if you plan to fetch large sessions, consider `GET
|
||||
/api/public/traces?sessionId=<sessionId>`
|
||||
operationId: sessions_get
|
||||
tags:
|
||||
- Sessions
|
||||
@@ -1262,6 +1265,12 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: sessionId
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: fromTimestamp
|
||||
in: query
|
||||
description: Retrieve only traces newer than this datetime (ISO 8601).
|
||||
@@ -2499,6 +2508,7 @@ components:
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
description: Total model cost in USD
|
||||
usage:
|
||||
type: array
|
||||
items:
|
||||
@@ -2521,14 +2531,28 @@ components:
|
||||
nullable: true
|
||||
inputUsage:
|
||||
type: integer
|
||||
description: Total number of generation input units (e.g. tokens)
|
||||
outputUsage:
|
||||
type: integer
|
||||
description: Total number of generation output units (e.g. tokens)
|
||||
totalUsage:
|
||||
type: integer
|
||||
description: Total number of generation total units (e.g. tokens)
|
||||
countTraces:
|
||||
type: integer
|
||||
countObservations:
|
||||
type: integer
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
description: Total model cost in USD
|
||||
required:
|
||||
- inputUsage
|
||||
- outputUsage
|
||||
- totalUsage
|
||||
- countTraces
|
||||
- countObservations
|
||||
- totalCost
|
||||
Observations:
|
||||
title: Observations
|
||||
type: object
|
||||
@@ -2654,6 +2678,13 @@ components:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: List of deployment labels of this prompt version.
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: List of tags to apply to all versions of this prompt.
|
||||
required:
|
||||
- name
|
||||
- prompt
|
||||
@@ -2672,6 +2703,13 @@ components:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: List of deployment labels of this prompt version.
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: List of tags to apply to all versions of this prompt.
|
||||
required:
|
||||
- name
|
||||
- prompt
|
||||
@@ -2713,11 +2751,20 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of deployment labels of this prompt version.
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >-
|
||||
List of tags. Used to filter via UI and API. The same across
|
||||
versions of a prompt.
|
||||
required:
|
||||
- name
|
||||
- version
|
||||
- config
|
||||
- labels
|
||||
- tags
|
||||
ChatMessage:
|
||||
title: ChatMessage
|
||||
type: object
|
||||
|
||||
@@ -699,7 +699,7 @@
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ]\n}",
|
||||
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ]\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
@@ -890,7 +890,7 @@
|
||||
"_type": "endpoint",
|
||||
"name": "Get",
|
||||
"request": {
|
||||
"description": "Get a session",
|
||||
"description": "Get a session. Please note that `traces` on this endpoint are not paginated, if you plan to fetch large sessions, consider `GET /api/public/traces?sessionId=<sessionId>`",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/sessions/:sessionId",
|
||||
"host": [
|
||||
@@ -963,7 +963,7 @@
|
||||
"request": {
|
||||
"description": "Get list of traces.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&fromTimestamp=&orderBy=&tags=",
|
||||
"raw": "{{baseUrl}}/api/public/traces?page=&limit=&userId=&name=&sessionId=&fromTimestamp=&orderBy=&tags=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
@@ -993,6 +993,11 @@
|
||||
"value": "",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"key": "sessionId",
|
||||
"value": "",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"key": "fromTimestamp",
|
||||
"value": "",
|
||||
|
||||
@@ -18,7 +18,7 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
samplingContext.request.url &&
|
||||
samplingContext.request.url.includes("api/trpc")
|
||||
) {
|
||||
return 0.3;
|
||||
return 0.1;
|
||||
}
|
||||
if (
|
||||
samplingContext.request &&
|
||||
@@ -27,12 +27,12 @@ if (process.env.NEXT_PUBLIC_SENTRY_DSN)
|
||||
samplingContext.transactionContext.status !== "ok" &&
|
||||
samplingContext.transactionContext.status !== "unauthenticated"
|
||||
) {
|
||||
return 1;
|
||||
return 0.1;
|
||||
}
|
||||
return 0.1;
|
||||
return 0.01;
|
||||
},
|
||||
|
||||
profilesSampleRate: 0.2, // Profiling sample rate is relative to tracesSampleRate
|
||||
profilesSampleRate: 0.1,
|
||||
integrations: [
|
||||
// Add profiling integration to list of integrations
|
||||
new ProfilingIntegration(),
|
||||
|
||||
@@ -68,12 +68,18 @@ describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
inputUsage: 333,
|
||||
outputUsage: 0,
|
||||
totalUsage: 333,
|
||||
countObservations: 1,
|
||||
countTraces: 1,
|
||||
totalCost: 0,
|
||||
},
|
||||
{
|
||||
model: "modelC",
|
||||
inputUsage: 666,
|
||||
outputUsage: 777,
|
||||
totalUsage: 1443,
|
||||
countObservations: 1,
|
||||
countTraces: 1,
|
||||
totalCost: 1024.22,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -88,6 +94,9 @@ describe("/api/public/metrics/daily API Endpoint", () => {
|
||||
inputUsage: 100,
|
||||
outputUsage: 200,
|
||||
totalUsage: 300,
|
||||
countObservations: 1,
|
||||
countTraces: 1,
|
||||
totalCost: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -680,6 +680,71 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(fetchedPrompt.body.createdBy).toBe("API");
|
||||
expect(fetchedPrompt.body.config).toEqual({});
|
||||
});
|
||||
|
||||
it("should update tags across versions", async () => {
|
||||
const promptName = "prompt-name" + nanoid();
|
||||
|
||||
const createPromptVersion = async (tags?: string[]) => {
|
||||
await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: "This is a test prompt",
|
||||
type: PromptType.Text,
|
||||
...(tags !== undefined && { tags: tags }),
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPromptVersion = async (version: number) => {
|
||||
const fetchedPrompt = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}/${promptName}?version=${version}`,
|
||||
undefined,
|
||||
);
|
||||
expect(fetchedPrompt.status).toBe(200);
|
||||
if (!isPrompt(fetchedPrompt.body)) {
|
||||
throw new Error("Expected body to be a prompt");
|
||||
}
|
||||
return fetchedPrompt.body;
|
||||
};
|
||||
|
||||
// Create version 1 with ["tag"]
|
||||
await createPromptVersion(["tag"]);
|
||||
let fetchedPrompt1 = await fetchPromptVersion(1);
|
||||
expect(fetchedPrompt1.tags).toEqual(["tag"]);
|
||||
expect(fetchedPrompt1.version).toBe(1);
|
||||
|
||||
// Create version 2 with no tags provided (should use tags from version 1)
|
||||
await createPromptVersion();
|
||||
let fetchedPrompt2 = await fetchPromptVersion(2);
|
||||
expect(fetchedPrompt2.tags).toEqual(["tag"]);
|
||||
expect(fetchedPrompt2.version).toBe(2);
|
||||
|
||||
// Create version 3 with ["tag1", "tag2", "tag3"] (should update tags across versions)
|
||||
await createPromptVersion(["tag1", "tag2", "tag3"]);
|
||||
fetchedPrompt1 = await fetchPromptVersion(1);
|
||||
fetchedPrompt2 = await fetchPromptVersion(2);
|
||||
let fetchedPrompt3 = await fetchPromptVersion(3);
|
||||
expect(fetchedPrompt1.tags).toEqual(["tag1", "tag2", "tag3"]);
|
||||
expect(fetchedPrompt1.version).toBe(1);
|
||||
expect(fetchedPrompt2.tags).toEqual(["tag1", "tag2", "tag3"]);
|
||||
expect(fetchedPrompt2.version).toBe(2);
|
||||
expect(fetchedPrompt3.tags).toEqual(["tag1", "tag2", "tag3"]);
|
||||
expect(fetchedPrompt3.version).toBe(3);
|
||||
|
||||
// remove tags
|
||||
await createPromptVersion([]);
|
||||
fetchedPrompt1 = await fetchPromptVersion(1);
|
||||
fetchedPrompt2 = await fetchPromptVersion(2);
|
||||
fetchedPrompt3 = await fetchPromptVersion(3);
|
||||
let fetchedPrompt4 = await fetchPromptVersion(4);
|
||||
expect(fetchedPrompt1.tags).toEqual([]);
|
||||
expect(fetchedPrompt1.version).toBe(1);
|
||||
expect(fetchedPrompt2.tags).toEqual([]);
|
||||
expect(fetchedPrompt2.version).toBe(2);
|
||||
expect(fetchedPrompt3.tags).toEqual([]);
|
||||
expect(fetchedPrompt3.version).toBe(3);
|
||||
expect(fetchedPrompt4.tags).toEqual([]);
|
||||
expect(fetchedPrompt4.version).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when fetching a prompt list", () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("Build valid SQL queries", () => {
|
||||
table: "traces_scores",
|
||||
values: ["project-id"],
|
||||
strings: [
|
||||
' FROM traces t JOIN scores s ON t.id = s.trace_id AND t.project_id = s.project_id WHERE t."project_id" = ',
|
||||
` FROM traces t JOIN scores s ON t.id = s.trace_id AND s.data_type != 'CATEGORICAL' AND t.project_id = s.project_id WHERE t."project_id" = `,
|
||||
";",
|
||||
],
|
||||
} as const,
|
||||
|
||||
@@ -238,4 +238,62 @@ describe("/api/public/traces API Endpoint", () => {
|
||||
expect(trace.body.htmlPath).toContain(`/traces/${traceId}`);
|
||||
expect(trace.body.htmlPath).toContain(`/project/`); // do not know the projectId
|
||||
});
|
||||
|
||||
it("should filter traces by session ID", async () => {
|
||||
const sessionId = "test-session-id";
|
||||
const anotherSessionId = "another-session-id";
|
||||
|
||||
// Create traces with different session IDs
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-1",
|
||||
name: "test-trace-1",
|
||||
sessionId,
|
||||
userId: "user-1",
|
||||
projectId: "project-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
await makeAPICall("POST", "/api/public/traces", {
|
||||
id: "trace-2",
|
||||
name: "test-trace-2",
|
||||
sessionId: anotherSessionId,
|
||||
userId: "user-2",
|
||||
projectId: "project-1",
|
||||
metadata: { key: "value" },
|
||||
release: "1.0.0",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Filter by session ID
|
||||
const tracesBySessionId = await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=${sessionId}`,
|
||||
);
|
||||
|
||||
expect(tracesBySessionId.status).toBe(200);
|
||||
expect(tracesBySessionId.body.data).toHaveLength(1);
|
||||
expect(tracesBySessionId.body.data[0].id).toBe("trace-1");
|
||||
|
||||
// Filter by another session ID
|
||||
const tracesByAnotherSessionId = await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=${anotherSessionId}`,
|
||||
);
|
||||
|
||||
expect(tracesByAnotherSessionId.status).toBe(200);
|
||||
expect(tracesByAnotherSessionId.body.data).toHaveLength(1);
|
||||
expect(tracesByAnotherSessionId.body.data[0].id).toBe("trace-2");
|
||||
|
||||
// Filter by non-existent session ID
|
||||
const tracesByNonExistentSessionId =
|
||||
await makeAPICall<GetTracesAPIResponse>(
|
||||
"GET",
|
||||
`/api/public/traces?sessionId=non-existent-session-id`,
|
||||
);
|
||||
|
||||
expect(tracesByNonExistentSessionId.status).toBe(200);
|
||||
expect(tracesByNonExistentSessionId.body.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,13 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/src/components/ui/hover-card";
|
||||
import {
|
||||
isBooleanDataType,
|
||||
isCategoricalDataType,
|
||||
} from "@/src/features/manual-scoring/lib/helpers";
|
||||
import { type ScoreSimplified } from "@/src/server/api/routers/generations/getAllQuery";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
|
||||
export const GroupedScoreBadges = ({
|
||||
scores,
|
||||
@@ -34,11 +38,13 @@ export const GroupedScoreBadges = ({
|
||||
<div className={cn("text-xs", props.className)}>
|
||||
{props.scores.map((s, i) => (
|
||||
<span key={i} className="group/score ml-1 first:ml-0">
|
||||
{s.value.toFixed(2)}
|
||||
{isCategoricalDataType(s.dataType) || isBooleanDataType(s.dataType)
|
||||
? s.stringValue
|
||||
: s.value.toFixed(2)}
|
||||
{s.comment && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="ml-1 inline-block cursor-pointer">
|
||||
<MessageCircle size={12} />
|
||||
<MessageCircleMore size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
|
||||
<p>{s.comment}</p>
|
||||
@@ -73,9 +79,12 @@ export const GroupedScoreBadges = ({
|
||||
<Badge
|
||||
variant="outline"
|
||||
key={name}
|
||||
className="break-all font-normal"
|
||||
className="grid grid-cols-[1fr,auto] gap-1 font-normal"
|
||||
>
|
||||
{name}: <ScoresOfGroup scores={scores} className="ml-2" />
|
||||
<p className="truncate" title={name}>
|
||||
{name}:
|
||||
</p>
|
||||
<ScoresOfGroup scores={scores} />
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { IOPreview } from "@/src/components/trace/IOPreview";
|
||||
import { JsonSkeleton } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { ManualScoreButton } from "@/src/features/manual-scoring/components/ManualScoreButton";
|
||||
import { DetailPageNav } from "@/src/features/navigate-detail-pages/DetailPageNav";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { api } from "@/src/utils/api";
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
useRowHeightLocalStorage,
|
||||
} from "@/src/components/table/data-table-row-height-switch";
|
||||
import { ScrollArea } from "@/src/components/ui/scroll-area";
|
||||
import { AnnotateButton } from "@/src/features/manual-scoring/components/AnnotateButton";
|
||||
|
||||
// do not use the usual table row heights here
|
||||
const rowHeightMapping: Record<RowHeight, number> = {
|
||||
@@ -202,7 +202,7 @@ const TraceCardList = ({
|
||||
<div className="flex flex-wrap content-start items-start gap-1">
|
||||
<GroupedScoreBadges scores={trace.scores} />
|
||||
</div>
|
||||
<ManualScoreButton
|
||||
<AnnotateButton
|
||||
projectId={projectId}
|
||||
traceId={trace.id}
|
||||
scores={trace.scores}
|
||||
|
||||
@@ -50,6 +50,8 @@ interface DataTableProps<TData, TValue> {
|
||||
help?: { description: string; href: string };
|
||||
rowHeight?: RowHeight;
|
||||
className?: string;
|
||||
paginationClassName?: string;
|
||||
isBorderless?: boolean;
|
||||
}
|
||||
|
||||
export interface AsyncTableData<T> {
|
||||
@@ -71,6 +73,9 @@ export function DataTable<TData extends object, TValue>({
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
rowHeight,
|
||||
className,
|
||||
paginationClassName,
|
||||
isBorderless = false,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight);
|
||||
@@ -105,8 +110,18 @@ export function DataTable<TData extends object, TValue>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full max-w-full flex-1 flex-col gap-1 overflow-auto">
|
||||
<div className="w-full overflow-auto rounded-md border">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full max-w-full flex-1 flex-col gap-1 overflow-auto",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-auto",
|
||||
isBorderless ? "" : "rounded-md border",
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -242,7 +257,12 @@ export function DataTable<TData extends object, TValue>({
|
||||
<div className="grow"></div>
|
||||
</div>
|
||||
{pagination !== undefined ? (
|
||||
<div className="bg:background sticky bottom-0 z-10 flex w-full justify-end font-medium">
|
||||
<div
|
||||
className={cn(
|
||||
"sticky bottom-0 z-10 flex w-full justify-end bg-background font-medium",
|
||||
paginationClassName,
|
||||
)}
|
||||
>
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
paginationOptions={pagination.options}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from "react";
|
||||
import { Card } from "@/src/components/ui/card";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { type ScoreDataType, type Prisma } from "@langfuse/shared";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import { isNumericDataType } from "@/src/features/manual-scoring/lib/helpers";
|
||||
|
||||
type ScoreConfigTableRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
dataType: ScoreDataType;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
range: {
|
||||
maxValue?: number | null;
|
||||
minValue?: number | null;
|
||||
categories?: Prisma.JsonValue | null;
|
||||
};
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
function getConfigRange(
|
||||
originalRow: ScoreConfigTableRow,
|
||||
): Prisma.JsonValue | undefined {
|
||||
const { range, dataType } = originalRow;
|
||||
if (isNumericDataType(dataType)) {
|
||||
return [
|
||||
{ minValue: range.minValue ?? "-∞", maxValue: range.maxValue ?? "∞" },
|
||||
];
|
||||
}
|
||||
return range.categories;
|
||||
}
|
||||
|
||||
export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
const [paginationState, setPaginationState] = useQueryParams({
|
||||
pageIndex: withDefault(NumberParam, 0),
|
||||
pageSize: withDefault(NumberParam, 50),
|
||||
});
|
||||
|
||||
const [rowHeight, setRowHeight] = useRowHeightLocalStorage(
|
||||
"scoreConfigs",
|
||||
"s",
|
||||
);
|
||||
|
||||
const configs = api.scoreConfigs.all.useQuery({
|
||||
projectId,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
});
|
||||
|
||||
const totalCount = configs.data?.totalCount ?? 0;
|
||||
|
||||
const columns: LangfuseColumnDef<ScoreConfigTableRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: "Name",
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "dataType",
|
||||
id: "dataType",
|
||||
header: "Data Type",
|
||||
enableHiding: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "range",
|
||||
id: "range",
|
||||
header: "Range",
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const range = getConfigRange(row.original);
|
||||
|
||||
return !!range ? (
|
||||
<IOTableCell data={range} singleLine={rowHeight === "s"} />
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
id: "description",
|
||||
header: "Description",
|
||||
enableHiding: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.description;
|
||||
|
||||
return !!value ? (
|
||||
<IOTableCell data={value} singleLine={rowHeight === "s"} />
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
id: "id",
|
||||
header: "Config ID",
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
id: "createdAt",
|
||||
header: "Created At",
|
||||
enableHiding: true,
|
||||
defaultHidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
useColumnVisibility<ScoreConfigTableRow>(
|
||||
"scoreConfigsColumnVisibility",
|
||||
columns,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
rowHeight={rowHeight}
|
||||
setRowHeight={setRowHeight}
|
||||
/>
|
||||
<Card className="mb-4 flex max-h-[calc(100dvh-40rem)] flex-col overflow-hidden">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
configs.isLoading
|
||||
? { isLoading: true, isError: false }
|
||||
: configs.isError
|
||||
? {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: configs.error.message,
|
||||
}
|
||||
: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: configs.data?.configs.map((config) => ({
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
dataType: config.dataType,
|
||||
description: config.description,
|
||||
createdAt: config.createdAt.toLocaleString(),
|
||||
updatedAt: config.updatedAt.toLocaleString(),
|
||||
range: {
|
||||
maxValue: config.maxValue,
|
||||
minValue: config.minValue,
|
||||
categories: config.categories,
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
pagination={{
|
||||
pageCount: Math.ceil(totalCount / paginationState.pageSize),
|
||||
onChange: setPaginationState,
|
||||
state: paginationState,
|
||||
}}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
rowHeight={rowHeight}
|
||||
className="gap-2"
|
||||
paginationClassName="-mx-2 mb-2"
|
||||
isBorderless
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { isNumericDataType } from "@/src/features/manual-scoring/lib/helpers";
|
||||
import { useOrderByState } from "@/src/features/orderBy/hooks/useOrderByState";
|
||||
import {
|
||||
type ScoreOptions,
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
import { api } from "@/src/utils/api";
|
||||
import { utcDateOffsetByDays } from "@/src/utils/dates";
|
||||
import type { RouterOutput, RouterInput } from "@/src/utils/types";
|
||||
import type { FilterState } from "@langfuse/shared";
|
||||
import type { FilterState, ScoreDataType } from "@langfuse/shared";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
|
||||
@@ -24,7 +26,12 @@ export type ScoresTableRow = {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
name: string;
|
||||
value: number;
|
||||
dataType: ScoreDataType;
|
||||
value: string;
|
||||
author: {
|
||||
image?: string;
|
||||
name?: string;
|
||||
};
|
||||
comment?: string;
|
||||
observationId?: string;
|
||||
traceName?: string;
|
||||
@@ -172,6 +179,29 @@ export default function ScoresTable({
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
header: "Trace User ID",
|
||||
id: "userId",
|
||||
headerTooltip: {
|
||||
description: "The user ID associated with the trace.",
|
||||
href: "https://langfuse.com/docs/tracing-features/users",
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("userId");
|
||||
return typeof value === "string" ? (
|
||||
<>
|
||||
<TableLink
|
||||
path={`/project/${projectId}/users/${value}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
</>
|
||||
) : undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: "Timestamp",
|
||||
@@ -193,38 +223,39 @@ export default function ScoresTable({
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "dataType",
|
||||
header: "Data Type",
|
||||
id: "dataType",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: "Value",
|
||||
id: "value",
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const value: number = row.getValue("value");
|
||||
return value % 1 === 0 ? value : value.toFixed(4);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
header: "User ID",
|
||||
id: "userId",
|
||||
headerTooltip: {
|
||||
description: "The user ID associated with the trace.",
|
||||
href: "https://langfuse.com/docs/tracing-features/users",
|
||||
},
|
||||
enableHiding: true,
|
||||
enableSorting: true,
|
||||
accessorKey: "author",
|
||||
id: "author",
|
||||
header: "Author",
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue("userId");
|
||||
return typeof value === "string" ? (
|
||||
<>
|
||||
<TableLink
|
||||
path={`/project/${projectId}/users/${value}`}
|
||||
value={value}
|
||||
truncateAt={40}
|
||||
/>
|
||||
</>
|
||||
) : undefined;
|
||||
const { name, image } = row.getValue(
|
||||
"author",
|
||||
) as ScoresTableRow["author"];
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage
|
||||
src={image ?? undefined}
|
||||
alt={name ?? "User Avatar"}
|
||||
/>
|
||||
</Avatar>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -279,12 +310,21 @@ export default function ScoresTable({
|
||||
timestamp: score.timestamp.toLocaleString(),
|
||||
source: score.source,
|
||||
name: score.name,
|
||||
value: score.value,
|
||||
dataType: score.dataType,
|
||||
value: isNumericDataType(score.dataType)
|
||||
? score.value % 1 === 0
|
||||
? String(score.value)
|
||||
: score.value.toFixed(4)
|
||||
: score.stringValue ?? "",
|
||||
author: {
|
||||
image: score.authorUserImage ?? undefined,
|
||||
name: score.authorUserName ?? undefined,
|
||||
},
|
||||
comment: score.comment ?? undefined,
|
||||
observationId: score.observationId ?? undefined,
|
||||
traceId: score.traceId,
|
||||
traceName: score.traceName ?? undefined,
|
||||
userId: score.userId ?? undefined,
|
||||
userId: score.traceUserId ?? undefined,
|
||||
jobConfigurationId: score.jobConfigurationId ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/src/components/ui/card";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { ManualScoreButton } from "@/src/features/manual-scoring/components/ManualScoreButton";
|
||||
import { NewDatasetItemFromTrace } from "@/src/features/datasets/components/NewDatasetItemFromObservationButton";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
import { api } from "@/src/utils/api";
|
||||
@@ -22,6 +21,7 @@ import { withDefault, StringParam, useQueryParam } from "use-query-params";
|
||||
import ScoresTable from "@/src/components/table/use-cases/scores";
|
||||
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
|
||||
import { JumpToPlaygroundButton } from "@/src/ee/features/playground/page/components/JumpToPlaygroundButton";
|
||||
import { AnnotateButton } from "@/src/features/manual-scoring/components/AnnotateButton";
|
||||
|
||||
export const ObservationPreview = (props: {
|
||||
observations: Array<ObservationReturnType>;
|
||||
@@ -154,7 +154,7 @@ export const ObservationPreview = (props: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ManualScoreButton
|
||||
<AnnotateButton
|
||||
projectId={props.projectId}
|
||||
traceId={preloadedObservation.traceId}
|
||||
observationId={preloadedObservation.id}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/src/components/ui/card";
|
||||
import { TraceAggUsageBadge } from "@/src/components/token-usage-badge";
|
||||
import { ManualScoreButton } from "@/src/features/manual-scoring/components/ManualScoreButton";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { type ObservationReturnType } from "@/src/server/api/routers/traces";
|
||||
import { IOPreview } from "@/src/components/trace/IOPreview";
|
||||
@@ -19,6 +18,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
|
||||
import { withDefault, StringParam, useQueryParam } from "use-query-params";
|
||||
import ScoresTable from "@/src/components/table/use-cases/scores";
|
||||
import { ScoresPreview } from "@/src/components/trace/ScoresPreview";
|
||||
import { AnnotateButton } from "@/src/features/manual-scoring/components/AnnotateButton";
|
||||
|
||||
export const TracePreview = ({
|
||||
trace,
|
||||
@@ -54,13 +54,13 @@ export const TracePreview = ({
|
||||
<TabsList className="bg-background py-0">
|
||||
<TabsTrigger
|
||||
value="preview"
|
||||
className="data-[state=active]:border-primary-accent h-full rounded-none border-b-4 border-transparent data-[state=active]:shadow-none"
|
||||
className="h-full rounded-none border-b-4 border-transparent data-[state=active]:border-primary-accent data-[state=active]:shadow-none"
|
||||
>
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="scores"
|
||||
className="data-[state=active]:border-primary-accent h-full rounded-none border-b-4 border-transparent data-[state=active]:shadow-none"
|
||||
className="h-full rounded-none border-b-4 border-transparent data-[state=active]:border-primary-accent data-[state=active]:shadow-none"
|
||||
>
|
||||
Scores
|
||||
</TabsTrigger>
|
||||
@@ -95,7 +95,7 @@ export const TracePreview = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ManualScoreButton
|
||||
<AnnotateButton
|
||||
projectId={trace.projectId}
|
||||
traceId={trace.id}
|
||||
scores={scores}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { useMediaQuery } from "react-responsive";
|
||||
|
||||
// https://tailwindcss.com/docs/responsive-design
|
||||
const TAILWIND_MD_MEDIA_QUERY = 768;
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => {
|
||||
const isMediumScreen = useMediaQuery({
|
||||
query: `(min-width: ${TAILWIND_MD_MEDIA_QUERY}px)`,
|
||||
});
|
||||
const direction = isMediumScreen ? "right" : "bottom";
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
direction={direction}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-primary/20", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background md:inset-x-auto md:inset-y-0 md:right-0 md:mt-0 md:h-full md:w-2/5 md:rounded-l-[10px] md:rounded-t-[0px] lg:w-1/3 xl:w-1/4 2xl:w-1/4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -28,4 +28,6 @@ const PopoverContent = React.forwardRef<
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
const PopoverClose = PopoverPrimitive.Close;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverClose };
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { toggleVariants } from "@/src/components/ui/toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v2.47.2";
|
||||
export const VERSION = "v2.48.0";
|
||||
|
||||
@@ -49,8 +49,7 @@ export const SaveToPromptButton: React.FC = () => {
|
||||
)
|
||||
.data?.prompts.filter((prompt) => prompt.type === PromptType.Chat)
|
||||
.map((prompt) => ({
|
||||
label:
|
||||
prompt.name.slice(0, 20) + (prompt.name.length > 25 ? "..." : ""),
|
||||
label: prompt.name,
|
||||
value: prompt.id,
|
||||
})) ?? [];
|
||||
|
||||
@@ -126,7 +125,9 @@ export const SaveToPromptButton: React.FC = () => {
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{promptName.label}
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{promptName.label}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useEffect } from "react";
|
||||
import { usdFormatter } from "../../../utils/numbers";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { type Prisma } from "@langfuse/shared";
|
||||
import { ScoreDataType, type Prisma } from "@langfuse/shared";
|
||||
import { useRowHeightLocalStorage } from "@/src/components/table/data-table-row-height-switch";
|
||||
import { IOTableCell } from "@/src/components/ui/CodeJsonViewer";
|
||||
|
||||
@@ -120,6 +120,7 @@ export function DatasetRunsTable(props: {
|
||||
scores={Object.entries(scores).map(([k, v]) => ({
|
||||
name: k,
|
||||
value: v,
|
||||
dataType: ScoreDataType.NUMERIC, // numeric and boolean values treated as numeric
|
||||
}))}
|
||||
variant="headings"
|
||||
/>
|
||||
|
||||
@@ -167,6 +167,7 @@ export const datasetRouter = createTRPCRouter({
|
||||
JOIN traces t ON t.id = s.trace_id
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
AND s.data_type != 'CATEGORICAL'
|
||||
AND ri.dataset_run_id = runs.id
|
||||
GROUP BY s.name
|
||||
) s
|
||||
|
||||
@@ -104,7 +104,6 @@ export function MultiSelect({
|
||||
selectedValues.add(option.value);
|
||||
}
|
||||
const filterValues = Array.from(selectedValues);
|
||||
|
||||
onValueChange(filterValues.length ? filterValues : []);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
withDefault,
|
||||
} from "use-query-params";
|
||||
import { promptsTableCols } from "@/src/server/api/definitions/promptsTable";
|
||||
import { usersTableCols } from "@/src/server/api/definitions/usersTable";
|
||||
|
||||
const DEBUG_QUERY_STATE = false;
|
||||
|
||||
@@ -107,6 +108,7 @@ const tableCols = {
|
||||
sessions: sessionsViewCols,
|
||||
scores: scoresTableCols,
|
||||
prompts: promptsTableCols,
|
||||
users: usersTableCols,
|
||||
dashboard: [
|
||||
{ id: "traceName", name: "Trace Name" },
|
||||
{ id: "tags", name: "Tags" },
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import React from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { LockIcon, MessageCircleMore, MessageCircle, X } from "lucide-react";
|
||||
import {
|
||||
type ControllerRenderProps,
|
||||
useFieldArray,
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTrigger,
|
||||
} from "@/src/components/ui/drawer";
|
||||
import { ScoreDataType, type Score, type ScoreConfig } from "@langfuse/shared";
|
||||
import { z } from "zod";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { api } from "@/src/utils/api";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import { HoverCardContent } from "@radix-ui/react-hover-card";
|
||||
import { HoverCard, HoverCardTrigger } from "@/src/components/ui/hover-card";
|
||||
import { ScoreConfigDetails } from "@/src/features/manual-scoring/components/ScoreConfigDetails";
|
||||
import { trpcErrorToast } from "@/src/utils/trpcErrorToast";
|
||||
import {
|
||||
isNumericDataType,
|
||||
isPresent,
|
||||
isScoreUnsaved,
|
||||
} from "@/src/features/manual-scoring/lib/helpers";
|
||||
import { getDefaultScoreData } from "@/src/features/manual-scoring/lib/getDefaultScoreData";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/src/components/ui/toggle-group";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { MultiSelectKeyValues } from "@/src/features/manual-scoring/components/multi-select-key-values";
|
||||
import { CommandItem } from "@/src/components/ui/command";
|
||||
import { useRouter } from "next/router";
|
||||
import useLocalStorage from "@/src/components/useLocalStorage";
|
||||
|
||||
const AnnotationScoreDataSchema = z.object({
|
||||
name: z.string(),
|
||||
scoreId: z.string().optional(),
|
||||
value: z.number().nullable().optional(),
|
||||
stringValue: z.string().optional(),
|
||||
dataType: z.nativeEnum(ScoreDataType),
|
||||
configId: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
});
|
||||
|
||||
const AnnotateFormSchema = z.object({
|
||||
scoreData: z.array(AnnotationScoreDataSchema),
|
||||
});
|
||||
|
||||
type AnnotateFormSchemaType = z.infer<typeof AnnotateFormSchema>;
|
||||
type AnnotationScoreSchemaType = z.infer<typeof AnnotationScoreDataSchema>;
|
||||
type ConfigCategory = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function AnnotateButton({
|
||||
traceId,
|
||||
scores,
|
||||
observationId,
|
||||
projectId,
|
||||
variant = "button",
|
||||
}: {
|
||||
traceId: string;
|
||||
scores: Score[];
|
||||
observationId?: string;
|
||||
projectId: string;
|
||||
variant?: "button" | "badge";
|
||||
}) {
|
||||
const hasAccess = useHasAccess({
|
||||
projectId,
|
||||
scope: "scores:CUD",
|
||||
});
|
||||
|
||||
const configsData = api.scoreConfigs.all.useQuery({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const configs = configsData.data?.configs ?? [];
|
||||
|
||||
const [emptySelectedConfigIds, setEmptySelectedConfigIds] = useLocalStorage<
|
||||
string[]
|
||||
>("emptySelectedConfigIds", []);
|
||||
|
||||
const form = useForm<z.infer<typeof AnnotateFormSchema>>({
|
||||
resolver: zodResolver(AnnotateFormSchema),
|
||||
defaultValues: {
|
||||
scoreData: getDefaultScoreData({
|
||||
scores,
|
||||
traceId,
|
||||
observationId,
|
||||
emptySelectedConfigIds,
|
||||
configs,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { fields, remove, update, replace } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "scoreData",
|
||||
});
|
||||
|
||||
const mutDeleteScore = api.scores.deleteAnnotationScore.useMutation({
|
||||
onError: (error) => {
|
||||
trpcErrorToast(error);
|
||||
},
|
||||
onSettled: async (data, error) => {
|
||||
if (!data || error) return;
|
||||
|
||||
const { id, name, dataType, configId } = data;
|
||||
const updatedScoreIndex = fields.findIndex(
|
||||
(field) => field.scoreId === id,
|
||||
);
|
||||
update(updatedScoreIndex, {
|
||||
name,
|
||||
dataType,
|
||||
configId: configId ?? undefined,
|
||||
value: null,
|
||||
scoreId: undefined,
|
||||
stringValue: undefined,
|
||||
comment: undefined,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
utils.scores.invalidate(),
|
||||
utils.traces.invalidate(),
|
||||
utils.sessions.invalidate(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const onSettledUpsert = async (data?: Score, error?: unknown) => {
|
||||
if (!data || error) return;
|
||||
|
||||
const { id, value, stringValue, name, dataType, configId, comment } = data;
|
||||
const updatedScoreIndex = fields.findIndex(
|
||||
(field) => field.configId === configId,
|
||||
);
|
||||
|
||||
update(updatedScoreIndex, {
|
||||
value,
|
||||
name,
|
||||
dataType,
|
||||
scoreId: id,
|
||||
stringValue: stringValue ?? undefined,
|
||||
configId: configId ?? undefined,
|
||||
comment: comment ?? undefined,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
utils.scores.invalidate(),
|
||||
utils.traces.invalidate(),
|
||||
utils.sessions.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
const mutCreateScores = api.scores.createAnnotationScore.useMutation({
|
||||
onError: (error) => {
|
||||
trpcErrorToast(error);
|
||||
},
|
||||
onSettled: onSettledUpsert,
|
||||
});
|
||||
|
||||
const mutUpdateScores = api.scores.updateAnnotationScore.useMutation({
|
||||
onError: (error) => {
|
||||
trpcErrorToast(error);
|
||||
},
|
||||
onSettled: onSettledUpsert,
|
||||
});
|
||||
|
||||
if (!hasAccess && variant === "badge") return null;
|
||||
|
||||
function handleOnCheckedChange(
|
||||
values: Record<string, string>[],
|
||||
changedValueId?: string,
|
||||
) {
|
||||
if (values.length === 0) {
|
||||
const populatedScoreFields = fields.filter(({ scoreId }) => !!scoreId);
|
||||
replace(populatedScoreFields);
|
||||
setEmptySelectedConfigIds(
|
||||
populatedScoreFields
|
||||
.filter(({ configId }) => !!configId)
|
||||
.map(({ configId }) => configId as string),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!changedValueId) return;
|
||||
|
||||
const configToChange = configs.find(({ id }) => id === changedValueId);
|
||||
if (!configToChange) return;
|
||||
const { id, name, dataType } = configToChange;
|
||||
|
||||
const index = fields.findIndex(({ configId }) => configId === id);
|
||||
|
||||
if (index === -1) {
|
||||
replace([
|
||||
...fields,
|
||||
{
|
||||
name,
|
||||
dataType,
|
||||
configId: id,
|
||||
},
|
||||
]);
|
||||
setEmptySelectedConfigIds([...emptySelectedConfigIds, changedValueId]);
|
||||
} else {
|
||||
remove(index);
|
||||
setEmptySelectedConfigIds(
|
||||
emptySelectedConfigIds.filter((id) => id !== changedValueId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnValueChange(
|
||||
score: AnnotationScoreSchemaType,
|
||||
index: number,
|
||||
configCategories: ConfigCategory[],
|
||||
): ((value: string) => void) | undefined {
|
||||
return async (stringValue) => {
|
||||
const selectedCategory = configCategories.find(
|
||||
({ label }) => label === stringValue,
|
||||
);
|
||||
if (selectedCategory) {
|
||||
const newValue = Number(selectedCategory.value);
|
||||
|
||||
update(index, {
|
||||
...score,
|
||||
value: newValue,
|
||||
});
|
||||
|
||||
if (!!stringValue) {
|
||||
if (!!score.scoreId)
|
||||
await mutUpdateScores.mutateAsync({
|
||||
projectId,
|
||||
...score,
|
||||
id: score.scoreId,
|
||||
value: newValue,
|
||||
stringValue,
|
||||
});
|
||||
else
|
||||
await mutCreateScores.mutateAsync({
|
||||
projectId,
|
||||
traceId,
|
||||
...score,
|
||||
observationId,
|
||||
value: newValue,
|
||||
stringValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleCommentUpdate({
|
||||
field,
|
||||
score,
|
||||
comment,
|
||||
}: {
|
||||
field: ControllerRenderProps<
|
||||
AnnotateFormSchemaType,
|
||||
`scoreData.${number}.comment`
|
||||
>;
|
||||
score: AnnotationScoreSchemaType;
|
||||
comment?: string | null;
|
||||
}): React.MouseEventHandler<HTMLButtonElement> | undefined {
|
||||
return async () => {
|
||||
const { value, scoreId } = score;
|
||||
if (!!field.value && !!scoreId && isPresent(value))
|
||||
await mutUpdateScores.mutateAsync({
|
||||
projectId,
|
||||
...score,
|
||||
value,
|
||||
id: scoreId,
|
||||
comment,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function handleOnBlur({
|
||||
config,
|
||||
field,
|
||||
index,
|
||||
score,
|
||||
}: {
|
||||
config: ScoreConfig;
|
||||
field: ControllerRenderProps<
|
||||
AnnotateFormSchemaType,
|
||||
`scoreData.${number}.value`
|
||||
>;
|
||||
index: number;
|
||||
score: AnnotationScoreSchemaType;
|
||||
}): React.FocusEventHandler<HTMLInputElement> | undefined {
|
||||
return async () => {
|
||||
const { maxValue, minValue, dataType } = config;
|
||||
|
||||
if (isNumericDataType(dataType)) {
|
||||
if (
|
||||
(isPresent(maxValue) && Number(field.value) > maxValue) ||
|
||||
(isPresent(minValue) && Number(field.value) < minValue)
|
||||
) {
|
||||
form.setError(`scoreData.${index}.value`, {
|
||||
type: "custom",
|
||||
message: `Not in range: [${minValue ?? "-∞"},${maxValue ?? "∞"}]`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
form.clearErrors(`scoreData.${index}.value`);
|
||||
|
||||
if (isPresent(field.value)) {
|
||||
if (!!score.scoreId)
|
||||
await mutUpdateScores.mutateAsync({
|
||||
projectId,
|
||||
...score,
|
||||
value: Number(field.value),
|
||||
id: score.scoreId,
|
||||
});
|
||||
else
|
||||
await mutCreateScores.mutateAsync({
|
||||
projectId,
|
||||
traceId,
|
||||
...score,
|
||||
observationId,
|
||||
value: Number(field.value),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
{variant === "button" ? (
|
||||
<Button variant="secondary" disabled={!hasAccess}>
|
||||
<span>Annotate</span>
|
||||
{!hasAccess ? <LockIcon className="ml-2 h-3 w-3" /> : null}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="h-6 rounded-full px-3 text-xs">Annotate</Button>
|
||||
)}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="h-1/3">
|
||||
<div className="mx-auto w-full overflow-y-auto md:max-h-full">
|
||||
<DrawerHeader className="sticky top-0 z-10 bg-background">
|
||||
<Header
|
||||
title="Annotate"
|
||||
level="h3"
|
||||
help={{
|
||||
description: `Annotate ${observationId ? "observation" : "trace"} with scores to capture human evaluation across different dimensions.`,
|
||||
href: "https://langfuse.com/docs/scores/manually",
|
||||
}}
|
||||
></Header>
|
||||
<div className="grid grid-flow-col items-center">
|
||||
<MultiSelectKeyValues
|
||||
title="Value"
|
||||
align="end"
|
||||
items="empty scores"
|
||||
className="grid grid-cols-[auto,1fr,auto,auto] gap-2"
|
||||
onValueChange={handleOnCheckedChange}
|
||||
options={configs.map((config) => ({
|
||||
key: config.id,
|
||||
value: config.name,
|
||||
disabled: fields.some(
|
||||
(field) => !!field.scoreId && field.configId === config.id,
|
||||
),
|
||||
}))}
|
||||
values={fields
|
||||
.filter((field) => !!field.configId)
|
||||
.map((field) => ({
|
||||
value: field.name,
|
||||
key: field.configId as string,
|
||||
}))}
|
||||
controlButtons={
|
||||
<CommandItem
|
||||
onSelect={() =>
|
||||
router.push(`/project/${projectId}/settings`)
|
||||
}
|
||||
>
|
||||
Manage score configs
|
||||
</CommandItem>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-4">
|
||||
<div className="grid grid-flow-row gap-2 px-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scoreData"
|
||||
render={() => (
|
||||
<>
|
||||
{fields.map((score, index) => {
|
||||
const config = configs.find(
|
||||
(config) => config.id === score.configId,
|
||||
);
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={score.id}
|
||||
className="grid w-full grid-cols-[1fr,2fr] items-center gap-8 text-left"
|
||||
>
|
||||
<div className="grid h-full grid-cols-[1fr,auto] items-center">
|
||||
{config.description ||
|
||||
isPresent(config.maxValue) ||
|
||||
isPresent(config.minValue) ? (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="line-clamp-2 break-words text-xs font-medium underline decoration-muted-gray decoration-dashed underline-offset-2">
|
||||
{score.name}
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<ScoreConfigDetails config={config} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<span
|
||||
className="line-clamp-2 break-words text-xs font-medium"
|
||||
title={score.name}
|
||||
>
|
||||
{score.name}
|
||||
</span>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
size="xs"
|
||||
title="Add or view score comment"
|
||||
className="h-full items-start px-0 pl-1 disabled:text-primary/50 disabled:opacity-100"
|
||||
disabled={isScoreUnsaved(score.scoreId)}
|
||||
>
|
||||
{score.comment ? (
|
||||
<MessageCircleMore className="h-4 w-4" />
|
||||
) : (
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`scoreData.${index}.comment`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-4">
|
||||
<FormLabel className="text-sm">
|
||||
Comment (optional)
|
||||
</FormLabel>
|
||||
{!!field.value &&
|
||||
field.value !== score.comment && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="ml-2 mr-2 rounded-sm bg-input p-1 text-xs">
|
||||
Draft
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top">
|
||||
{!!score.comment && (
|
||||
<div className="mb-4 max-w-48 rounded border bg-background p-2 shadow-sm">
|
||||
<p className="text-xs">
|
||||
Saved comment:{" "}
|
||||
{score.comment}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<FormControl>
|
||||
<>
|
||||
<Textarea
|
||||
{...field}
|
||||
className="text-xs"
|
||||
value={field.value || ""}
|
||||
/>
|
||||
{field.value !== score.comment && (
|
||||
<div className="grid w-full grid-cols-[1fr,1fr] gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={!field.value}
|
||||
loading={
|
||||
mutUpdateScores.isLoading
|
||||
}
|
||||
onClick={handleCommentUpdate({
|
||||
field,
|
||||
score,
|
||||
comment: field.value,
|
||||
})}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<PopoverClose asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={!field.value}
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
`scoreData.${index}.comment`,
|
||||
score.comment ?? "",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</div>
|
||||
)}
|
||||
{field.value === score.comment && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={
|
||||
!field.value ||
|
||||
!score.comment
|
||||
}
|
||||
loading={
|
||||
mutUpdateScores.isLoading
|
||||
}
|
||||
onClick={handleCommentUpdate({
|
||||
field,
|
||||
score,
|
||||
comment: null,
|
||||
})}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="grid grid-cols-[11fr,1fr]">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`scoreData.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
{isNumericDataType(score.dataType) ? (
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? undefined}
|
||||
type="number"
|
||||
className="text-xs"
|
||||
onBlur={handleOnBlur({
|
||||
config,
|
||||
field,
|
||||
index,
|
||||
score,
|
||||
})}
|
||||
/>
|
||||
) : config.categories &&
|
||||
(
|
||||
(config.categories as ConfigCategory[]) ??
|
||||
[]
|
||||
).length > 3 ? (
|
||||
<Select
|
||||
defaultValue={score.stringValue}
|
||||
onValueChange={handleOnValueChange(
|
||||
score,
|
||||
index,
|
||||
(config.categories as ConfigCategory[]) ??
|
||||
[],
|
||||
)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<div className="text-xs">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
(config.categories as ConfigCategory[]) ??
|
||||
[]
|
||||
).map(
|
||||
(category: ConfigCategory) => (
|
||||
<SelectItem
|
||||
key={category.value}
|
||||
value={category.label}
|
||||
className="text-xs"
|
||||
>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
defaultValue={score.stringValue}
|
||||
className={`grid grid-cols-${((config.categories as ConfigCategory[]) ?? [])?.length}`}
|
||||
onValueChange={handleOnValueChange(
|
||||
score,
|
||||
index,
|
||||
(config.categories as ConfigCategory[]) ??
|
||||
[],
|
||||
)}
|
||||
>
|
||||
{(
|
||||
(config.categories as ConfigCategory[]) ??
|
||||
[]
|
||||
).map((category: ConfigCategory) => (
|
||||
<ToggleGroupItem
|
||||
key={category.value}
|
||||
value={category.label}
|
||||
variant="outline"
|
||||
className="grid grid-flow-col gap-1 text-nowrap px-1 text-xs font-normal"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={category.label}
|
||||
>
|
||||
{category.label}
|
||||
</span>
|
||||
<span className="text-primary/60">{`(${category.value})`}</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="px-0 pl-1"
|
||||
title="Delete score from trace/observation"
|
||||
disabled={isScoreUnsaved(score.scoreId)}
|
||||
loading={mutDeleteScore.isLoading}
|
||||
onClick={async () => {
|
||||
if (score.scoreId) {
|
||||
await mutDeleteScore.mutateAsync({
|
||||
id: score.scoreId,
|
||||
projectId,
|
||||
});
|
||||
form.clearErrors(
|
||||
`scoreData.${index}.value`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import { PlusIcon, Trash2 } from "lucide-react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { ScoreDataType } from "@langfuse/shared";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/src/components/ui/select";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import {
|
||||
isBooleanDataType,
|
||||
isCategoricalDataType,
|
||||
isNumericDataType,
|
||||
} from "@/src/features/manual-scoring/lib/helpers";
|
||||
import DocPopup from "@/src/components/layouts/doc-popup";
|
||||
|
||||
const availableDataTypes = [
|
||||
ScoreDataType.NUMERIC,
|
||||
ScoreDataType.CATEGORICAL,
|
||||
ScoreDataType.BOOLEAN,
|
||||
] as const;
|
||||
|
||||
const category = z.object({
|
||||
label: z.string().min(1),
|
||||
value: z.coerce.number(),
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1).max(35),
|
||||
dataType: z.enum(availableDataTypes),
|
||||
minValue: z.coerce.number().optional(),
|
||||
maxValue: z.coerce.number().optional(),
|
||||
categories: z.array(category).optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export function CreateScoreConfigButton({ projectId }: { projectId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const hasAccess = useHasAccess({
|
||||
projectId: projectId,
|
||||
scope: "scoreConfigs:CUD",
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
const createScoreConfig = api.scoreConfigs.create.useMutation({
|
||||
onSuccess: () => utils.scoreConfigs.invalidate(),
|
||||
onError: (error) =>
|
||||
setFormError(error.message ?? "An error occurred while creating config."),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
dataType: ScoreDataType.NUMERIC,
|
||||
minValue: undefined,
|
||||
maxValue: undefined,
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove, replace } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "categories",
|
||||
});
|
||||
|
||||
if (!hasAccess) return null;
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const error = validateForm(values);
|
||||
setFormError(error);
|
||||
if (error) return;
|
||||
|
||||
return createScoreConfig
|
||||
.mutateAsync({
|
||||
projectId,
|
||||
...values,
|
||||
})
|
||||
.then(() => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" loading={createScoreConfig.isLoading}>
|
||||
<PlusIcon className="-ml-0.5 mr-1.5 h-5 w-5" aria-hidden="true" />
|
||||
Add new score config
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add new score config</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-6"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
onBlur={(e) => field.onChange(e.target.value.trimEnd())}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dataType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Data type</FormLabel>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(
|
||||
value as (typeof availableDataTypes)[number],
|
||||
);
|
||||
form.clearErrors();
|
||||
if (isNumericDataType(value as ScoreDataType)) {
|
||||
remove();
|
||||
} else {
|
||||
form.setValue("minValue", undefined);
|
||||
form.setValue("maxValue", undefined);
|
||||
if (isBooleanDataType(value as ScoreDataType)) {
|
||||
replace([
|
||||
{ label: "True", value: 1 },
|
||||
{ label: "False", value: 0 },
|
||||
]);
|
||||
} else {
|
||||
replace([{ label: "", value: 0 }]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a data type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{availableDataTypes.map((role) => (
|
||||
<SelectItem value={role} key={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isNumericDataType(form.getValues("dataType")) ? (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="minValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Minimum (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Maximum (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categories"
|
||||
render={() => (
|
||||
<>
|
||||
{fields.length > 0 && (
|
||||
<div className="mb-2 grid grid-cols-[1fr,3fr] items-center gap-2 text-left sm:grid-cols-[1fr,7fr]">
|
||||
<FormLabel className="grid grid-flow-col">
|
||||
Value
|
||||
<DocPopup
|
||||
description={`This is how the ${
|
||||
isCategoricalDataType(
|
||||
form.getValues("dataType"),
|
||||
)
|
||||
? "category"
|
||||
: "boolean"
|
||||
} label is mapped to an integer value internally.`}
|
||||
size="xs"
|
||||
></DocPopup>
|
||||
</FormLabel>
|
||||
<FormLabel>Label</FormLabel>
|
||||
</div>
|
||||
)}
|
||||
{fields.map((category, index) => (
|
||||
<div
|
||||
key={`${category.id}-langfuseObject`}
|
||||
className="items-top mb-2 grid grid-cols-[1fr,3fr] gap-2 text-left sm:grid-cols-[1fr,7fr]"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`categories.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
readOnly
|
||||
disabled
|
||||
inputMode="numeric"
|
||||
className="text-center"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-[1fr,auto] gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`categories.${index}.label`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
onBlur={(e) =>
|
||||
field.onChange(
|
||||
e.target.value.trimEnd(),
|
||||
)
|
||||
}
|
||||
readOnly={isBooleanDataType(
|
||||
form.getValues("dataType"),
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isCategoricalDataType(
|
||||
form.getValues("dataType"),
|
||||
) && (
|
||||
<Button
|
||||
onClick={() => remove(index)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={
|
||||
index === 0 || index !== fields.length - 1
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isCategoricalDataType(form.getValues("dataType")) && (
|
||||
<div className="grid-cols-auto grid">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
isBooleanDataType(form.getValues("dataType")) &&
|
||||
fields.length === 2
|
||||
}
|
||||
onClick={() =>
|
||||
append({ label: "", value: fields.length })
|
||||
}
|
||||
>
|
||||
Add category
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormItem>
|
||||
<FormLabel>Description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Provide an optional description of the score config..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
{formError ? (
|
||||
<p className="text-red text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function validateForm(values: z.infer<typeof formSchema>): string | null {
|
||||
if (isNumericDataType(values.dataType)) {
|
||||
if (
|
||||
!!values.maxValue &&
|
||||
!!values.minValue &&
|
||||
values.maxValue <= values.minValue
|
||||
) {
|
||||
return "Maximum value must be greater than Minimum value.";
|
||||
}
|
||||
} else if (isCategoricalDataType(values.dataType)) {
|
||||
if (!values.categories || values.categories.length === 0) {
|
||||
return "At least one category is required for categorical data types.";
|
||||
}
|
||||
} else if (isBooleanDataType(values.dataType)) {
|
||||
if (values.categories?.length !== 2)
|
||||
return "Boolean data type must have exactly 2 categories.";
|
||||
const isBooleanCategoryInvalid = values.categories?.some(
|
||||
(category) => category.value !== 0 && category.value !== 1,
|
||||
);
|
||||
if (isBooleanCategoryInvalid)
|
||||
return "Boolean data type must have categories with values 0 and 1.";
|
||||
}
|
||||
|
||||
const uniqueNames = new Set<string>();
|
||||
const uniqueValues = new Set<number>();
|
||||
|
||||
for (const category of values.categories || []) {
|
||||
if (uniqueNames.has(category.label)) {
|
||||
return "Category names must be unique.";
|
||||
}
|
||||
uniqueNames.add(category.label);
|
||||
|
||||
if (uniqueValues.has(category.value)) {
|
||||
return "Category values must be unique.";
|
||||
}
|
||||
uniqueValues.add(category.value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/src/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormDescription,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/src/components/ui/form";
|
||||
import { Textarea } from "@/src/components/ui/textarea";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { type Score } from "@langfuse/shared/src/db";
|
||||
import { useState } from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Slider } from "@/src/components/ui/slider";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
|
||||
const SCORE_NAME = "manual-score";
|
||||
|
||||
const formSchema = z.object({
|
||||
score: z.number(),
|
||||
comment: z.string().optional(),
|
||||
});
|
||||
|
||||
export function ManualScoreButton({
|
||||
traceId,
|
||||
scores,
|
||||
observationId,
|
||||
projectId,
|
||||
variant = "button",
|
||||
}: {
|
||||
traceId: string;
|
||||
scores: Score[];
|
||||
observationId?: string;
|
||||
projectId: string;
|
||||
variant?: "button" | "badge";
|
||||
}) {
|
||||
const capture = usePostHogClientCapture();
|
||||
const hasAccess = useHasAccess({
|
||||
projectId,
|
||||
scope: "scores:CUD",
|
||||
});
|
||||
const score = scores.find(
|
||||
(s) =>
|
||||
s.name === SCORE_NAME &&
|
||||
s.traceId === traceId &&
|
||||
(observationId !== undefined
|
||||
? s.observationId === observationId
|
||||
: s.observationId === null),
|
||||
);
|
||||
|
||||
// variant === "badge" type is session
|
||||
// if observationId is defined type is observation
|
||||
// else type is trace
|
||||
const type =
|
||||
variant === "badge" ? "session" : observationId ? "observation" : "trace";
|
||||
const source = type === "session" ? "SessionDetail" : "TraceDetail";
|
||||
|
||||
const utils = api.useUtils();
|
||||
const onSuccess = async () => {
|
||||
await Promise.all([
|
||||
utils.scores.invalidate(),
|
||||
utils.traces.invalidate(),
|
||||
utils.sessions.invalidate(),
|
||||
]);
|
||||
};
|
||||
const mutCreateScore = api.scores.createAnnotationScore.useMutation({
|
||||
onSuccess,
|
||||
});
|
||||
const mutUpdateScore = api.scores.updateAnnotationScore.useMutation({
|
||||
onSuccess,
|
||||
});
|
||||
const mutDeleteScore = api.scores.deleteAnnotationScore.useMutation({
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (score) {
|
||||
await mutDeleteScore.mutateAsync({ id: score.id, projectId });
|
||||
capture("score:delete", {
|
||||
type: type,
|
||||
source: source,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
score: 0,
|
||||
comment: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!hasAccess) return;
|
||||
if (!value) {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
} else {
|
||||
capture(score ? "score:update_form_open" : "score:create_form_open", {
|
||||
type: type,
|
||||
source: source,
|
||||
});
|
||||
form.setValue("score", score?.value ?? 0);
|
||||
form.setValue("comment", score?.comment ?? "");
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (score) {
|
||||
await mutUpdateScore.mutateAsync({
|
||||
id: score.id,
|
||||
value: values.score,
|
||||
comment: values.comment,
|
||||
projectId,
|
||||
});
|
||||
capture("score:update_form_submit", {
|
||||
type: type,
|
||||
source: source,
|
||||
});
|
||||
} else {
|
||||
await mutCreateScore.mutateAsync({
|
||||
name: SCORE_NAME,
|
||||
value: values.score,
|
||||
comment: values.comment,
|
||||
traceId,
|
||||
observationId,
|
||||
projectId,
|
||||
});
|
||||
capture("score:create_form_submit", {
|
||||
type: type,
|
||||
source: source,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!hasAccess && variant === "badge") return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{variant === "button" ? (
|
||||
<Button variant="secondary" disabled={!hasAccess}>
|
||||
<span>{score ? `Update score: ${score.value}` : "Add score"}</span>
|
||||
{!hasAccess ? <LockIcon className="ml-2 h-3 w-3" /> : null}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="h-6 rounded-full px-3 text-xs">
|
||||
{score ? "Update score" : "Add score"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-5">
|
||||
{score ? "Update Score" : "Create Score"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="score"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Score</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
{...field}
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onValueChange={(value) => {
|
||||
if (value[0] !== undefined) field.onChange(value[0]);
|
||||
}}
|
||||
value={[field.value]}
|
||||
onChange={undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="flex justify-between">
|
||||
<span>-1 (bad)</span>
|
||||
<span>1 (good)</span>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting
|
||||
? "Loading ..."
|
||||
: score
|
||||
? "Update"
|
||||
: "Create"}
|
||||
</Button>
|
||||
{score && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
loading={mutDeleteScore.isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
isNumericDataType,
|
||||
isPresent,
|
||||
} from "@/src/features/manual-scoring/lib/helpers";
|
||||
import { type ScoreConfig } from "@langfuse/shared";
|
||||
import React from "react";
|
||||
|
||||
export function ScoreConfigDetails({ config }: { config: ScoreConfig }) {
|
||||
const { name, description, minValue, maxValue, dataType } = config;
|
||||
if (!description && !minValue && !maxValue) return null;
|
||||
const isNameTruncated = name.length > 20;
|
||||
|
||||
return (
|
||||
<div className="max-w-48 overflow-hidden text-wrap rounded border bg-background p-2 text-xs font-light">
|
||||
{!!description && <p>{`Description: ${description}`}</p>}
|
||||
{isNumericDataType(dataType) &&
|
||||
(isPresent(minValue) || isPresent(maxValue)) ? (
|
||||
<p>{`Range: [${minValue ?? "-∞"}, ${maxValue ?? "∞"}]`}</p>
|
||||
) : null}
|
||||
{isNameTruncated && <p>{`Full name: ${name}`}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { CreateScoreConfigButton } from "@/src/features/manual-scoring/components/CreateScoreConfigButton";
|
||||
import { ScoreConfigsTable } from "@/src/components/table/use-cases/score-configs";
|
||||
|
||||
export function ScoreConfigSettings({ projectId }: { projectId: string }) {
|
||||
const hasReadAccess = useHasAccess({
|
||||
projectId: projectId,
|
||||
scope: "scoreConfigs:read",
|
||||
});
|
||||
|
||||
if (!hasReadAccess) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Score Configs" level="h3" />
|
||||
<p className="mb-4 text-sm">
|
||||
Score configs define which scores are available for{" "}
|
||||
<a
|
||||
href="https://langfuse.com/docs/scores/manually"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
annotation
|
||||
</a>{" "}
|
||||
in your project. Please note that all score configs are immutable.
|
||||
</p>
|
||||
<ScoreConfigsTable projectId={projectId} />
|
||||
<CreateScoreConfigButton projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import * as React from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { Badge } from "@/src/components/ui/badge";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/src/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
|
||||
type MultiSelectOptions = {
|
||||
value: string;
|
||||
key?: string;
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function MultiSelectKeyValues<
|
||||
T extends { key: string; value: string } | string,
|
||||
>({
|
||||
title,
|
||||
values,
|
||||
onValueChange,
|
||||
options,
|
||||
className,
|
||||
disabled,
|
||||
items = "items",
|
||||
align = "center",
|
||||
controlButtons,
|
||||
}: {
|
||||
title?: string;
|
||||
values: T[];
|
||||
onValueChange: (values: T[], changedValue?: string) => void;
|
||||
options: MultiSelectOptions[] | readonly MultiSelectOptions[];
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
items?: string;
|
||||
align?: "center" | "end" | "start";
|
||||
controlButtons?: React.ReactNode;
|
||||
}) {
|
||||
const selectedValueKeys = new Set(
|
||||
values.map((value) => (typeof value === "string" ? value : value.key)),
|
||||
);
|
||||
const showClearItems = selectedValueKeys.size > 0;
|
||||
|
||||
function formatFilterValues(): T[] {
|
||||
if (values.length > 0 && typeof values[0] === "string") {
|
||||
return Array.from(selectedValueKeys) as T[];
|
||||
}
|
||||
|
||||
return options
|
||||
.filter((option) => !!option.key && selectedValueKeys.has(option.key))
|
||||
.map((option) => ({
|
||||
key: option.key as string,
|
||||
value: option.value,
|
||||
})) as T[];
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Select
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
{selectedValueKeys.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValueKeys.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 overflow-x-auto lg:flex">
|
||||
{selectedValueKeys.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValueKeys.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) =>
|
||||
selectedValueKeys.has(option.key ?? option.value),
|
||||
)
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.key}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.value}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align={align}>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValueKeys.has(
|
||||
option.key ?? option.value,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.key ?? option.value}
|
||||
value={option.key ?? option.value}
|
||||
onSelect={(value) => {
|
||||
if (isSelected) {
|
||||
selectedValueKeys.delete(value);
|
||||
} else {
|
||||
selectedValueKeys.add(value);
|
||||
}
|
||||
const filterValues = formatFilterValues();
|
||||
|
||||
onValueChange(
|
||||
filterValues.length ? filterValues : [],
|
||||
value,
|
||||
);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
option.disabled ? "opacity-50" : null,
|
||||
)}
|
||||
>
|
||||
<Check className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
<span className="overflow-x-scroll">{option.value}</span>
|
||||
{option.count !== undefined ? (
|
||||
<span className="ml-auto flex h-4 w-4 items-center justify-center pl-1 font-mono text-xs">
|
||||
{option.count}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
{controlButtons || showClearItems ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Controls">
|
||||
{showClearItems && (
|
||||
<CommandItem onSelect={() => onValueChange([])}>
|
||||
Clear {items}
|
||||
</CommandItem>
|
||||
)}
|
||||
{controlButtons}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type Score, ScoreSource, type ScoreConfig } from "@langfuse/shared";
|
||||
|
||||
export const getDefaultScoreData = ({
|
||||
scores,
|
||||
emptySelectedConfigIds,
|
||||
configs,
|
||||
traceId,
|
||||
observationId,
|
||||
}: {
|
||||
scores: Score[];
|
||||
emptySelectedConfigIds: string[];
|
||||
configs: ScoreConfig[];
|
||||
traceId: string;
|
||||
observationId?: string;
|
||||
}) => {
|
||||
const populatedScores = scores
|
||||
.filter(
|
||||
(s) =>
|
||||
s.source === ScoreSource.ANNOTATION &&
|
||||
s.traceId === traceId &&
|
||||
(observationId !== undefined
|
||||
? s.observationId === observationId
|
||||
: s.observationId === null),
|
||||
)
|
||||
.map(({ id, name, value, dataType, stringValue, configId, comment }) => ({
|
||||
scoreId: id,
|
||||
name,
|
||||
value,
|
||||
dataType,
|
||||
stringValue: stringValue ?? undefined,
|
||||
configId: configId ?? undefined,
|
||||
comment: comment ?? undefined,
|
||||
}));
|
||||
|
||||
const populatedScoresConfigIds = new Set(
|
||||
populatedScores.map((s) => s.configId),
|
||||
);
|
||||
|
||||
const emptyScores = configs
|
||||
.filter(
|
||||
(c) =>
|
||||
!populatedScoresConfigIds.has(c.id) &&
|
||||
emptySelectedConfigIds.includes(c.id),
|
||||
)
|
||||
.map(({ name, dataType, id }) => ({
|
||||
scoreId: undefined,
|
||||
name,
|
||||
value: undefined,
|
||||
dataType,
|
||||
stringValue: undefined,
|
||||
configId: id,
|
||||
comment: undefined,
|
||||
}));
|
||||
|
||||
return [...populatedScores, ...emptyScores];
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ScoreDataType } from "@langfuse/shared";
|
||||
|
||||
export const isNumericDataType = (dataType: ScoreDataType) =>
|
||||
dataType === ScoreDataType.NUMERIC;
|
||||
|
||||
export const isCategoricalDataType = (dataType: ScoreDataType) =>
|
||||
dataType === ScoreDataType.CATEGORICAL;
|
||||
|
||||
export const isBooleanDataType = (dataType: ScoreDataType) =>
|
||||
dataType === ScoreDataType.BOOLEAN;
|
||||
|
||||
export const isPresent = <T>(value: T): value is NonNullable<T> =>
|
||||
value !== null && value !== undefined && value !== "";
|
||||
|
||||
export const isScoreUnsaved = (scoreId?: string): boolean => !scoreId;
|
||||
@@ -28,6 +28,7 @@ import { api } from "@/src/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { JsonEditor } from "@/src/components/json-editor";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import Link from "next/link";
|
||||
|
||||
const formSchema = z.object({
|
||||
modelName: z.string().min(1),
|
||||
@@ -255,11 +256,33 @@ export const NewModelForm = (props: {
|
||||
name="inputPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Input price (USD)</FormLabel>
|
||||
<FormLabel>
|
||||
Input price (USD per{" "}
|
||||
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormDescription>Cost per input unit.</FormDescription>
|
||||
{field.value !== null && field.value !== "" ? (
|
||||
<FormDescription>
|
||||
<ul className="font-mono text-xs">
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1000).toFixed(4)} USD
|
||||
/ 1k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 100_000).toFixed(4)}{" "}
|
||||
USD / 100k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
|
||||
4,
|
||||
)}{" "}
|
||||
USD / 1M {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
</ul>
|
||||
</FormDescription>
|
||||
) : null}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -269,11 +292,33 @@ export const NewModelForm = (props: {
|
||||
name="outputPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Output price (USD)</FormLabel>
|
||||
<FormLabel>
|
||||
Output price (USD per{" "}
|
||||
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormDescription>Cost per output unit.</FormDescription>
|
||||
{field.value !== null && field.value !== "" ? (
|
||||
<FormDescription>
|
||||
<ul className="font-mono text-xs">
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1000).toFixed(4)} USD
|
||||
/ 1k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 100_000).toFixed(4)}{" "}
|
||||
USD / 100k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
|
||||
4,
|
||||
)}{" "}
|
||||
USD / 1M {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
</ul>
|
||||
</FormDescription>
|
||||
) : null}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -283,12 +328,34 @@ export const NewModelForm = (props: {
|
||||
name="totalPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total price (USD)</FormLabel>
|
||||
<FormLabel>
|
||||
Total price (USD per{" "}
|
||||
{form.getValues("unit").toLowerCase().replace(/s$/, "")})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Cost per unit, if no separate input/output prices.
|
||||
{field.value !== null && field.value !== "" ? (
|
||||
<ul className="mt-2 font-mono text-xs">
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1000).toFixed(4)} USD
|
||||
/ 1k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 100_000).toFixed(4)}{" "}
|
||||
USD / 100k {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
<li>
|
||||
{(parseFloat(field.value ?? "0") * 1_000_000).toFixed(
|
||||
4,
|
||||
)}{" "}
|
||||
USD / 1M {form.getValues("unit").toLowerCase()}
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
"Enter total price only if no separate input and output prices are provided."
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -328,7 +395,15 @@ export const NewModelForm = (props: {
|
||||
Optionally, Langfuse can tokenize the input and output of a
|
||||
generation if no unit counts are ingested. This is useful for
|
||||
e.g. streamed OpenAI completions. For details on the supported
|
||||
tokenizers, see the docs.
|
||||
tokenizers, see the{" "}
|
||||
<Link
|
||||
href="https://langfuse.com/docs/model-usage-and-cost"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
>
|
||||
docs
|
||||
</Link>
|
||||
.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -346,8 +421,15 @@ export const NewModelForm = (props: {
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<FormDescription>
|
||||
The config for the tokenizer. Required for openai. See the
|
||||
docs for details.
|
||||
The config for the tokenizer. Required for openai. See the{" "}
|
||||
<Link
|
||||
href="https://langfuse.com/docs/model-usage-and-cost"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
>
|
||||
docs
|
||||
</Link>{" "}
|
||||
for details.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ValidationError } from "@langfuse/shared";
|
||||
import { jsonSchema } from "@/src/utils/zod";
|
||||
import { type PrismaClient } from "@langfuse/shared/src/db";
|
||||
import { LATEST_PROMPT_LABEL } from "@/src/features/prompts/constants";
|
||||
import { removeLabelsFromPreviousPromptVersions } from "@/src/features/prompts/server/utils/updatePromptLabels";
|
||||
import { updatePromptTagsOnAllVersions } from "@/src/features/prompts/server/utils/updatePromptTags";
|
||||
|
||||
export type CreatePromptParams = CreatePromptTRPCType & {
|
||||
createdBy: string;
|
||||
@@ -21,6 +23,7 @@ export const createPrompt = async ({
|
||||
config,
|
||||
createdBy,
|
||||
prisma,
|
||||
tags,
|
||||
}: CreatePromptParams) => {
|
||||
const latestPrompt = await prisma.prompt.findFirst({
|
||||
where: { projectId, name },
|
||||
@@ -35,14 +38,8 @@ export const createPrompt = async ({
|
||||
|
||||
const finalLabels = [...labels, LATEST_PROMPT_LABEL]; // Newly created prompts are always labeled as 'latest'
|
||||
|
||||
const previousLabeledPrompts = await prisma.prompt.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
name,
|
||||
labels: { hasSome: finalLabels },
|
||||
},
|
||||
orderBy: [{ version: "desc" }],
|
||||
});
|
||||
// If tags are undefined, use the tags from the latest prompt version
|
||||
const finalTags = [...new Set(tags ?? latestPrompt?.tags ?? [])];
|
||||
|
||||
const create = [
|
||||
prisma.prompt.create({
|
||||
@@ -52,7 +49,7 @@ export const createPrompt = async ({
|
||||
createdBy,
|
||||
labels: [...new Set(finalLabels)], // Ensure labels are unique
|
||||
type,
|
||||
tags: latestPrompt?.tags,
|
||||
tags: finalTags,
|
||||
version: latestPrompt?.version ? latestPrompt.version + 1 : 1,
|
||||
project: { connect: { id: projectId } },
|
||||
config: jsonSchema.parse(config),
|
||||
@@ -62,18 +59,28 @@ export const createPrompt = async ({
|
||||
|
||||
if (finalLabels.length > 0)
|
||||
// If we're creating a new labeled prompt, we must remove those labels on previous prompts since labels are unique
|
||||
previousLabeledPrompts.forEach((prevPrompt) => {
|
||||
create.push(
|
||||
prisma.prompt.update({
|
||||
where: { id: prevPrompt.id },
|
||||
data: {
|
||||
labels: prevPrompt.labels.filter(
|
||||
(prevLabel) => !finalLabels.includes(prevLabel),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
create.push(
|
||||
...(await removeLabelsFromPreviousPromptVersions({
|
||||
prisma,
|
||||
projectId,
|
||||
promptName: name,
|
||||
labelsToRemove: finalLabels,
|
||||
})),
|
||||
);
|
||||
|
||||
const haveTagsChanged =
|
||||
JSON.stringify([...new Set(finalTags)].sort()) !==
|
||||
JSON.stringify([...new Set(latestPrompt?.tags)].sort());
|
||||
if (haveTagsChanged)
|
||||
// If we're creating a new prompt with tags, we must update those tags on previous prompts since tags are consistent across versions
|
||||
create.push(
|
||||
...(await updatePromptTagsOnAllVersions({
|
||||
prisma,
|
||||
projectId,
|
||||
promptName: name,
|
||||
tags: finalTags,
|
||||
})),
|
||||
);
|
||||
|
||||
const [createdPrompt] = await prisma.$transaction(create);
|
||||
|
||||
|
||||
@@ -626,6 +626,7 @@ export const promptRouter = createTRPCRouter({
|
||||
LEFT JOIN scores s ON o.trace_id = s.trace_id AND s.observation_id = o.id AND s.project_id = ${input.projectId}
|
||||
WHERE
|
||||
o.type = 'GENERATION'
|
||||
AND s.data_type != 'CATEGORICAL'
|
||||
AND o.prompt_id IS NOT NULL
|
||||
AND o.project_id = ${input.projectId}
|
||||
AND p.id IN (${Prisma.join(input.promptIds)})
|
||||
@@ -681,6 +682,8 @@ export const promptRouter = createTRPCRouter({
|
||||
traces_by_prompt_id tp
|
||||
JOIN prompts AS p ON tp.prompt_id = p.id AND p.project_id = ${input.projectId}
|
||||
LEFT JOIN scores s ON tp.trace_id = s.trace_id AND s.observation_id IS NULL AND s.project_id = ${input.projectId}
|
||||
WHERE
|
||||
s.data_type != 'CATEGORICAL'
|
||||
), average_scores_by_prompt AS (
|
||||
SELECT
|
||||
prompt_id,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type PrismaClient } from "@langfuse/shared/src/db";
|
||||
|
||||
export const removeLabelsFromPreviousPromptVersions = async ({
|
||||
prisma,
|
||||
projectId,
|
||||
promptName,
|
||||
labelsToRemove,
|
||||
}: {
|
||||
prisma: PrismaClient;
|
||||
projectId: string;
|
||||
promptName: string;
|
||||
labelsToRemove: string[];
|
||||
}) => {
|
||||
const previouslyLabeledPrompts = await prisma.prompt.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
name: promptName,
|
||||
labels: { hasSome: labelsToRemove },
|
||||
},
|
||||
orderBy: [{ version: "desc" }],
|
||||
});
|
||||
|
||||
return previouslyLabeledPrompts.map((prevPrompt) =>
|
||||
prisma.prompt.update({
|
||||
where: { id: prevPrompt.id },
|
||||
data: {
|
||||
labels: prevPrompt.labels.filter(
|
||||
(prevLabel) => !labelsToRemove.includes(prevLabel),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type PrismaClient } from "@langfuse/shared/src/db";
|
||||
|
||||
export const updatePromptTagsOnAllVersions = async ({
|
||||
prisma,
|
||||
projectId,
|
||||
promptName,
|
||||
tags,
|
||||
}: {
|
||||
prisma: PrismaClient;
|
||||
projectId: string;
|
||||
promptName: string;
|
||||
tags: string[];
|
||||
}) => {
|
||||
const previousVersions = await prisma.prompt.findMany({
|
||||
where: { projectId, name: promptName },
|
||||
});
|
||||
|
||||
if (previousVersions.length === 0) return [];
|
||||
|
||||
return previousVersions.map((prevVersion) =>
|
||||
prisma.prompt.update({
|
||||
where: { id: prevVersion.id },
|
||||
data: {
|
||||
tags: [...new Set(tags)], // Ensure tags are unique
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,7 @@ export const CreateTextPromptSchema = z.object({
|
||||
type: z.literal(PromptType.Text).optional(),
|
||||
prompt: z.string(),
|
||||
config: jsonSchema.nullable().default({}),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
});
|
||||
|
||||
export const CreateChatPromptSchema = z.object({
|
||||
@@ -35,6 +36,7 @@ export const CreateChatPromptSchema = z.object({
|
||||
type: z.literal(PromptType.Chat),
|
||||
prompt: z.array(ChatMessageSchema),
|
||||
config: jsonSchema.nullable().default({}),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
});
|
||||
|
||||
export const CreatePromptSchema = z.union([
|
||||
|
||||
@@ -17,6 +17,9 @@ const scopes = [
|
||||
|
||||
"scores:CUD",
|
||||
|
||||
"scoreConfigs:CUD",
|
||||
"scoreConfigs:read",
|
||||
|
||||
"project:delete",
|
||||
"project:update",
|
||||
"project:transfer",
|
||||
@@ -57,6 +60,8 @@ export const roleAccessRights: Record<ProjectRole, Scope[]> = {
|
||||
"objects:tag",
|
||||
"traces:delete",
|
||||
"scores:CUD",
|
||||
"scoreConfigs:CUD",
|
||||
"scoreConfigs:read",
|
||||
"project:delete",
|
||||
"project:update",
|
||||
"project:transfer",
|
||||
@@ -87,6 +92,8 @@ export const roleAccessRights: Record<ProjectRole, Scope[]> = {
|
||||
"objects:tag",
|
||||
"traces:delete",
|
||||
"scores:CUD",
|
||||
"scoreConfigs:CUD",
|
||||
"scoreConfigs:read",
|
||||
"datasets:CUD",
|
||||
"prompts:CUD",
|
||||
"prompts:read",
|
||||
@@ -107,6 +114,7 @@ export const roleAccessRights: Record<ProjectRole, Scope[]> = {
|
||||
"objects:bookmark",
|
||||
"objects:tag",
|
||||
"scores:CUD",
|
||||
"scoreConfigs:read",
|
||||
"datasets:CUD",
|
||||
"prompts:CUD",
|
||||
"prompts:read",
|
||||
@@ -120,6 +128,7 @@ export const roleAccessRights: Record<ProjectRole, Scope[]> = {
|
||||
VIEWER: [
|
||||
"prompts:read",
|
||||
"evalTemplate:read",
|
||||
"scoreConfigs:read",
|
||||
"evalJob:read",
|
||||
"evalJobExecution:read",
|
||||
],
|
||||
|
||||
@@ -53,10 +53,12 @@ if (
|
||||
setProjectInPosthog();
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://eu.posthog.com",
|
||||
ui_host: "https://eu.posthog.com",
|
||||
// Enable debug mode in development
|
||||
loaded: (posthog) => {
|
||||
if (process.env.NODE_ENV === "development") posthog.debug();
|
||||
},
|
||||
autocapture: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,12 +68,15 @@ export default async function handler(
|
||||
DATE_TRUNC('DAY',
|
||||
o.start_time) "date",
|
||||
o.model,
|
||||
SUM(o.prompt_tokens) inputUsage,
|
||||
SUM(o.completion_tokens) outputUsage,
|
||||
SUM(o.total_tokens) totalUsage
|
||||
count(distinct o.id)::integer as "countObservations",
|
||||
count(distinct t.id)::integer as "countTraces",
|
||||
SUM(o.prompt_tokens) "inputUsage",
|
||||
SUM(o.completion_tokens) "outputUsage",
|
||||
SUM(o.total_tokens) "totalUsage",
|
||||
COALESCE(SUM(o.calculated_total_cost), 0)::DOUBLE PRECISION as "totalCost"
|
||||
FROM
|
||||
traces t
|
||||
LEFT JOIN observations o ON o.trace_id = t.id AND o.project_id = t.project_id
|
||||
LEFT JOIN observations_view o ON o.trace_id = t.id AND o.project_id = t.project_id
|
||||
WHERE o.start_time IS NOT NULL
|
||||
AND t.project_id = ${authCheck.scope.projectId}
|
||||
${traceNameCondition}
|
||||
@@ -94,11 +97,17 @@ export default async function handler(
|
||||
json_agg(json_build_object('model',
|
||||
model,
|
||||
'inputUsage',
|
||||
inputUsage,
|
||||
"inputUsage",
|
||||
'outputUsage',
|
||||
outputUsage,
|
||||
"outputUsage",
|
||||
'totalUsage',
|
||||
totalUsage)) daily_usage_json
|
||||
"totalUsage",
|
||||
'totalCost',
|
||||
"totalCost",
|
||||
'countObservations',
|
||||
"countObservations",
|
||||
'countTraces',
|
||||
"countTraces")) daily_usage_json
|
||||
FROM
|
||||
model_usage
|
||||
GROUP BY
|
||||
|
||||
@@ -25,6 +25,7 @@ const GetTracesSchema = z.object({
|
||||
userId: z.string().nullish(),
|
||||
name: z.string().nullish(),
|
||||
tags: z.union([z.array(z.string()), z.string()]).nullish(),
|
||||
sessionId: z.string().nullish(),
|
||||
fromTimestamp: stringDate,
|
||||
orderBy: z
|
||||
.string() // orderBy=timestamp.asc
|
||||
@@ -105,6 +106,9 @@ export default async function handler(
|
||||
", ",
|
||||
)}] <@ t."tags"`
|
||||
: Prisma.empty;
|
||||
const sessionCondition = obj.sessionId
|
||||
? Prisma.sql`AND t."session_id" = ${obj.sessionId}`
|
||||
: Prisma.empty;
|
||||
const fromTimestampCondition = obj.fromTimestamp
|
||||
? Prisma.sql`AND t."timestamp" >= ${obj.fromTimestamp}::timestamp with time zone at time zone 'UTC'`
|
||||
: Prisma.empty;
|
||||
@@ -145,6 +149,7 @@ export default async function handler(
|
||||
${userCondition}
|
||||
${nameCondition}
|
||||
${tagsCondition}
|
||||
${sessionCondition}
|
||||
${orderByCondition}
|
||||
LIMIT ${obj.limit} OFFSET ${skipValue}
|
||||
) AS t
|
||||
@@ -168,6 +173,7 @@ export default async function handler(
|
||||
projectId: authCheck.scope.projectId,
|
||||
name: obj.name ? obj.name : undefined,
|
||||
userId: obj.userId ? obj.userId : undefined,
|
||||
sessionId: obj.sessionId ? obj.sessionId : undefined,
|
||||
timestamp: obj.fromTimestamp
|
||||
? { gte: new Date(obj.fromTimestamp) }
|
||||
: undefined,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { formatIntervalSeconds } from "@/src/utils/dates";
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { ScoreDataType } from "@langfuse/shared";
|
||||
|
||||
type PromptVersionTableRow = {
|
||||
version: number;
|
||||
@@ -247,6 +248,7 @@ export default function PromptVersionTable() {
|
||||
scores={Object.entries(scores).map(([k, v]) => ({
|
||||
name: k,
|
||||
value: v,
|
||||
dataType: ScoreDataType.NUMERIC, // numeric and boolean values treated as numeric
|
||||
}))}
|
||||
variant="headings"
|
||||
/>
|
||||
@@ -273,6 +275,7 @@ export default function PromptVersionTable() {
|
||||
scores={Object.entries(scores).map(([k, v]) => ({
|
||||
name: k,
|
||||
value: v,
|
||||
dataType: ScoreDataType.NUMERIC, // numeric and boolean values treated as numeric
|
||||
}))}
|
||||
variant="headings"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Card } from "@tremor/react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { LlmApiKeyList } from "@/src/features/public-api/components/LLMApiKeyList";
|
||||
import { ScoreConfigSettings } from "@/src/features/manual-scoring/components/ScoreConfigSettings";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
@@ -29,6 +30,7 @@ export default function SettingsPage() {
|
||||
<LlmApiKeyList projectId={projectId} />
|
||||
<ProjectMembersTable projectId={projectId} />
|
||||
<ProjectUsageChart projectId={projectId} />
|
||||
<ScoreConfigSettings projectId={projectId} />
|
||||
<Integrations projectId={projectId} />
|
||||
<Instructions />
|
||||
<RenameProject projectId={projectId} />
|
||||
@@ -107,7 +109,7 @@ function Instructions() {
|
||||
<li key={itemIdx}>
|
||||
<div className="group relative flex items-start space-x-3 py-4">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="group-hover:border-primary-accent group-hover:text-primary-accent inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border text-muted-foreground">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border text-muted-foreground group-hover:border-primary-accent group-hover:text-primary-accent">
|
||||
<item.icon className="h-6 w-6" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Switch } from "@/src/components/ui/switch";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
import { posthogIntegrationFormSchema } from "@/src/features/posthog-integration/types";
|
||||
import { useHasAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -28,7 +29,13 @@ import { type z } from "zod";
|
||||
export default function PosthogIntegrationSettings() {
|
||||
const router = useRouter();
|
||||
const projectId = router.query.projectId as string;
|
||||
const state = api.posthogIntegration.get.useQuery({ projectId });
|
||||
const hasAccess = useHasAccess({ projectId, scope: "integrations:CRUD" });
|
||||
const state = api.posthogIntegration.get.useQuery(
|
||||
{ projectId },
|
||||
{
|
||||
enabled: hasAccess,
|
||||
},
|
||||
);
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === undefined) return null;
|
||||
|
||||
return (
|
||||
@@ -46,7 +53,7 @@ export default function PosthogIntegrationSettings() {
|
||||
</Button>
|
||||
}
|
||||
status={
|
||||
state.isInitialLoading
|
||||
state.isInitialLoading || !hasAccess
|
||||
? undefined
|
||||
: state.data?.enabled
|
||||
? "active"
|
||||
@@ -63,15 +70,20 @@ export default function PosthogIntegrationSettings() {
|
||||
on a daily schedule to PostHog. When first activated, it will sync all
|
||||
historical data from the beginning of your project.
|
||||
</p>
|
||||
<div className="flex flex-col gap-10"></div>
|
||||
|
||||
{!state.isInitialLoading && (
|
||||
{!hasAccess && (
|
||||
<p className="text-sm">
|
||||
You current role does not grant you access to these settings, please
|
||||
reach out to your project admin or owner.
|
||||
</p>
|
||||
)}
|
||||
{hasAccess && (
|
||||
<>
|
||||
<Header level="h3" title="Configuration" />
|
||||
<Card className="p-4">
|
||||
<PostHogIntegrationSettings
|
||||
state={state.data}
|
||||
projectId={projectId}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
@@ -97,9 +109,11 @@ export default function PosthogIntegrationSettings() {
|
||||
const PostHogIntegrationSettings = ({
|
||||
state,
|
||||
projectId,
|
||||
isLoading,
|
||||
}: {
|
||||
state?: RouterOutput["posthogIntegration"]["get"];
|
||||
projectId: string;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const capture = usePostHogClientCapture();
|
||||
const posthogForm = useForm<z.infer<typeof posthogIntegrationFormSchema>>({
|
||||
@@ -109,6 +123,7 @@ const PostHogIntegrationSettings = ({
|
||||
posthogProjectApiKey: state?.posthogApiKey ?? "",
|
||||
enabled: state?.enabled ?? false,
|
||||
},
|
||||
disabled: isLoading,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,12 +220,14 @@ const PostHogIntegrationSettings = ({
|
||||
<Button
|
||||
loading={mut.isLoading}
|
||||
onClick={posthogForm.handleSubmit(onSubmit)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
loading={mutDelete.isLoading}
|
||||
disabled={isLoading || !!!state}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import Header from "@/src/components/layouts/header";
|
||||
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type RouterOutput, type RouterInput } from "@/src/utils/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import { useRouter } from "next/router";
|
||||
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { useEffect } from "react";
|
||||
import { NumberParam, useQueryParams, withDefault } from "use-query-params";
|
||||
import { DataTableToolbar } from "@/src/components/table/data-table-toolbar";
|
||||
|
||||
import { GroupedScoreBadges } from "@/src/components/grouped-score-badge";
|
||||
import { type Score } from "@langfuse/shared";
|
||||
import { useQueryParams, withDefault, NumberParam } from "use-query-params";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import Header from "@/src/components/layouts/header";
|
||||
import { DataTable } from "@/src/components/table/data-table";
|
||||
import TableLink from "@/src/components/table/table-link";
|
||||
import { type LangfuseColumnDef } from "@/src/components/table/types";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { FullScreenPage } from "@/src/components/layouts/full-screen-page";
|
||||
import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState";
|
||||
import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers";
|
||||
import { type RouterInput, type RouterOutput } from "@/src/utils/types";
|
||||
import { type Score } from "@langfuse/shared";
|
||||
import { utcDateOffsetByDays } from "@/src/utils/dates";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usersTableCols } from "@/src/server/api/definitions/usersTable";
|
||||
|
||||
export type ScoreFilterInput = Omit<RouterInput["users"]["all"], "projectId">;
|
||||
|
||||
@@ -26,8 +31,22 @@ type RowData = {
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const projectId = router.query.projectId as string;
|
||||
const [queryOptions] = useState<ScoreFilterInput>({});
|
||||
|
||||
const [userFilterState, setUserFilterState] = useQueryFilterState(
|
||||
[
|
||||
{
|
||||
column: "timestamp",
|
||||
type: "datetime",
|
||||
operator: ">",
|
||||
value: utcDateOffsetByDays(
|
||||
session.data?.environment.defaultTableDateTimeOffset ?? -7,
|
||||
),
|
||||
},
|
||||
],
|
||||
"users",
|
||||
);
|
||||
|
||||
const { setDetailPageList } = useDetailPageLists();
|
||||
|
||||
@@ -37,7 +56,7 @@ export default function UsersPage() {
|
||||
});
|
||||
|
||||
const users = api.users.all.useQuery({
|
||||
...queryOptions,
|
||||
filter: userFilterState,
|
||||
page: paginationState.pageIndex,
|
||||
limit: paginationState.pageSize,
|
||||
projectId,
|
||||
@@ -212,7 +231,12 @@ export default function UsersPage() {
|
||||
href: "https://langfuse.com/docs/user-explorer",
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableToolbar
|
||||
filterColumnDefinition={usersTableCols}
|
||||
filterState={userFilterState}
|
||||
setFilterState={setUserFilterState}
|
||||
columns={columns}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type OptionsDefinition,
|
||||
type ColumnDefinition,
|
||||
ScoreSource,
|
||||
ScoreDataType,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
export const scoresTableCols: ColumnDefinition[] = [
|
||||
@@ -36,6 +37,13 @@ export const scoresTableCols: ColumnDefinition[] = [
|
||||
internal: 's."source"::text',
|
||||
options: Object.values(ScoreSource).map((value) => ({ value })),
|
||||
},
|
||||
{
|
||||
name: "Data Type",
|
||||
id: "dataType",
|
||||
type: "stringOptions",
|
||||
internal: 's."data_type"::text',
|
||||
options: Object.values(ScoreDataType).map((value) => ({ value })),
|
||||
},
|
||||
{
|
||||
name: "Name",
|
||||
id: "name",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ColumnDefinition } from "@langfuse/shared";
|
||||
|
||||
export const usersTableCols: ColumnDefinition[] = [
|
||||
{
|
||||
name: "Timestamp",
|
||||
id: "timestamp",
|
||||
type: "datetime",
|
||||
internal: 't."timestamp"',
|
||||
},
|
||||
];
|
||||
@@ -16,6 +16,7 @@ import { modelRouter } from "@/src/server/api/routers/models";
|
||||
import { evalRouter } from "@/src/ee/features/evals/server/router";
|
||||
import { posthogIntegrationRouter } from "@/src/features/posthog-integration/posthog-integration-router";
|
||||
import { llmApiKeyRouter } from "@/src/features/llm-api-key/server/router";
|
||||
import { scoreConfigsRouter } from "@/src/server/api/routers/scoreConfigs";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
sessions: sessionRouter,
|
||||
generations: generationsRouter,
|
||||
scores: scoresRouter,
|
||||
scoreConfigs: scoreConfigsRouter,
|
||||
dashboard: dashboardRouter,
|
||||
projects: projectsRouter,
|
||||
users: userRouter,
|
||||
|
||||
@@ -2,7 +2,11 @@ import { type z } from "zod";
|
||||
|
||||
import { protectedProjectProcedure } from "@/src/server/api/trpc";
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
import { type ObservationView, Prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
type ObservationView,
|
||||
Prisma,
|
||||
type ScoreDataType,
|
||||
} from "@langfuse/shared/src/db";
|
||||
|
||||
import { GenerationTableOptions } from "./utils/GenerationTableOptions";
|
||||
import { getAllGenerations } from "@/src/server/api/routers/generations/db/getAllGenerationsSqlQuery";
|
||||
@@ -14,6 +18,8 @@ const getAllGenerationsInput = GenerationTableOptions.extend({
|
||||
export type ScoreSimplified = {
|
||||
name: string;
|
||||
value: number;
|
||||
dataType: ScoreDataType;
|
||||
stringValue?: string | null;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { optionalPaginationZod } from "@/src/utils/zod";
|
||||
|
||||
import { ScoreDataType } from "@langfuse/shared/src/db";
|
||||
import { z } from "zod";
|
||||
|
||||
const ScoreConfigAllInput = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
});
|
||||
|
||||
const category = z.object({
|
||||
label: z.string().min(1),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
const ScoreConfigAllInputPaginated = ScoreConfigAllInput.extend({
|
||||
...optionalPaginationZod,
|
||||
});
|
||||
|
||||
export const scoreConfigsRouter = createTRPCRouter({
|
||||
all: protectedProjectProcedure
|
||||
.input(ScoreConfigAllInputPaginated)
|
||||
.query(async ({ input, ctx }) => {
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "scoreConfigs:read",
|
||||
});
|
||||
|
||||
try {
|
||||
const configs = await ctx.prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
...(input.limit !== undefined && input.page !== undefined
|
||||
? { take: input.limit, skip: input.page * input.limit }
|
||||
: undefined),
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const configsCount = await ctx.prisma.scoreConfig.count({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
configs,
|
||||
totalCount: configsCount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}),
|
||||
|
||||
create: protectedProjectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
name: z.string().min(1).max(35),
|
||||
dataType: z.nativeEnum(ScoreDataType),
|
||||
minValue: z.number().optional(),
|
||||
maxValue: z.number().optional(),
|
||||
categories: z.array(category).optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
throwIfNoAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "scoreConfigs:CUD",
|
||||
});
|
||||
|
||||
try {
|
||||
const existingConfig = await ctx.prisma.scoreConfig.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
name: input.name,
|
||||
dataType: input.dataType,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingConfig)
|
||||
throw new Error(
|
||||
"Score config with this name and data type already exists",
|
||||
);
|
||||
|
||||
const config = await ctx.prisma.scoreConfig.create({
|
||||
data: {
|
||||
...input,
|
||||
},
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { throwIfNoAccess } from "@/src/features/rbac/utils/checkAccess";
|
||||
import { type ProjectRole, Prisma, type Score } from "@langfuse/shared/src/db";
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
import { singleFilter } from "@langfuse/shared";
|
||||
import { ScoreDataType, singleFilter } from "@langfuse/shared";
|
||||
import { tableColumnsToSqlFilterAndPrefix } from "@langfuse/shared";
|
||||
import {
|
||||
type ScoreOptions,
|
||||
@@ -46,8 +46,10 @@ export const scoresRouter = createTRPCRouter({
|
||||
Array<
|
||||
Score & {
|
||||
traceName: string | null;
|
||||
userId: string | null;
|
||||
traceUserId: string | null;
|
||||
jobConfigurationId: string | null;
|
||||
authorUserImage: string | null;
|
||||
authorUserName: string | null;
|
||||
}
|
||||
>
|
||||
>(
|
||||
@@ -56,14 +58,19 @@ export const scoresRouter = createTRPCRouter({
|
||||
s.id,
|
||||
s.name,
|
||||
s.value,
|
||||
s.string_value AS "stringValue",
|
||||
s.timestamp,
|
||||
s.source,
|
||||
s.data_type AS "dataType",
|
||||
s.comment,
|
||||
s.trace_id as "traceId",
|
||||
s.observation_id as "observationId",
|
||||
t.user_id as "userId",
|
||||
t.name as "traceName",
|
||||
je.job_configuration_id as "jobConfigurationId"
|
||||
s.trace_id AS "traceId",
|
||||
s.observation_id AS "observationId",
|
||||
s.author_user_id AS "authorUserId",
|
||||
t.user_id AS "traceUserId",
|
||||
t.name AS "traceName",
|
||||
je.job_configuration_id AS "jobConfigurationId",
|
||||
u.image AS "authorUserImage",
|
||||
u.name AS "authorUserName"
|
||||
`,
|
||||
input.projectId,
|
||||
filterCondition,
|
||||
@@ -126,10 +133,13 @@ export const scoresRouter = createTRPCRouter({
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
traceId: z.string(),
|
||||
value: z.number(),
|
||||
name: z.string(),
|
||||
comment: z.string().optional(),
|
||||
observationId: z.string().optional(),
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
stringValue: z.string().optional(),
|
||||
comment: z.string().optional().nullable(),
|
||||
configId: z.string().optional(),
|
||||
dataType: z.nativeEnum(ScoreDataType),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -149,30 +159,63 @@ export const scoresRouter = createTRPCRouter({
|
||||
throw new Error("No trace with this id in this project.");
|
||||
}
|
||||
|
||||
const score = await ctx.prisma.score.create({
|
||||
data: {
|
||||
try {
|
||||
const existingScore = await ctx.prisma.score.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
traceId: input.traceId,
|
||||
observationId: input.observationId,
|
||||
source: "ANNOTATION",
|
||||
configId: input.configId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingScore) {
|
||||
return ctx.prisma.score.update({
|
||||
where: {
|
||||
id: existingScore.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
value: input.value,
|
||||
stringValue: input.stringValue,
|
||||
comment: input.comment,
|
||||
authorUserId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const score = await ctx.prisma.score.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
traceId: input.traceId,
|
||||
observationId: input.observationId,
|
||||
value: input.value,
|
||||
stringValue: input.stringValue,
|
||||
dataType: input.dataType,
|
||||
configId: input.configId,
|
||||
name: input.name,
|
||||
comment: input.comment,
|
||||
authorUserId: ctx.session.user.id,
|
||||
source: "ANNOTATION",
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
projectId: input.projectId,
|
||||
traceId: input.traceId,
|
||||
observationId: input.observationId,
|
||||
value: input.value,
|
||||
name: input.name,
|
||||
comment: input.comment,
|
||||
authorUserId: ctx.session.user.id,
|
||||
source: "ANNOTATION",
|
||||
},
|
||||
});
|
||||
await auditLog({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.user.id,
|
||||
userProjectRole: ctx.session.user.projects.find(
|
||||
(p) => p.id === input.projectId,
|
||||
)?.role as ProjectRole, // throwIfNoAccess ensures this is defined
|
||||
resourceType: "score",
|
||||
resourceId: score.id,
|
||||
action: "create",
|
||||
after: score,
|
||||
});
|
||||
return score;
|
||||
userId: ctx.session.user.id,
|
||||
userProjectRole: ctx.session.user.projects.find(
|
||||
(p) => p.id === input.projectId,
|
||||
)?.role as ProjectRole, // throwIfNoAccess ensures this is defined
|
||||
resourceType: "score",
|
||||
resourceId: score.id,
|
||||
action: "create",
|
||||
after: score,
|
||||
});
|
||||
return score;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
updateAnnotationScore: protectedProjectProcedure
|
||||
.input(
|
||||
@@ -180,7 +223,10 @@ export const scoresRouter = createTRPCRouter({
|
||||
projectId: z.string(),
|
||||
id: z.string(),
|
||||
value: z.number(),
|
||||
comment: z.string().optional(),
|
||||
stringValue: z.string().optional(),
|
||||
comment: z.string().optional().nullable(),
|
||||
configId: z.string().optional(),
|
||||
dataType: z.nativeEnum(ScoreDataType),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -200,29 +246,35 @@ export const scoresRouter = createTRPCRouter({
|
||||
throw new Error("No annotation score with this id in this project.");
|
||||
}
|
||||
|
||||
await auditLog({
|
||||
projectId: input.projectId,
|
||||
userId: ctx.session.user.id,
|
||||
userProjectRole: ctx.session.user.projects.find(
|
||||
(p) => p.id === input.projectId,
|
||||
)?.role as ProjectRole, // throwIfNoAccess ensures this is defined
|
||||
resourceType: "score",
|
||||
resourceId: score.id,
|
||||
action: "update",
|
||||
after: score,
|
||||
});
|
||||
|
||||
return ctx.prisma.score.update({
|
||||
where: {
|
||||
id: score.id,
|
||||
try {
|
||||
await auditLog({
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
value: input.value,
|
||||
comment: input.comment,
|
||||
authorUserId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
userId: ctx.session.user.id,
|
||||
userProjectRole: ctx.session.user.projects.find(
|
||||
(p) => p.id === input.projectId,
|
||||
)?.role as ProjectRole, // throwIfNoAccess ensures this is defined
|
||||
resourceType: "score",
|
||||
resourceId: score.id,
|
||||
action: "update",
|
||||
after: score,
|
||||
});
|
||||
|
||||
return ctx.prisma.score.update({
|
||||
where: {
|
||||
id: score.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
value: input.value,
|
||||
stringValue: input.stringValue,
|
||||
comment: input.comment,
|
||||
authorUserId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
deleteAnnotationScore: protectedProjectProcedure
|
||||
.input(z.object({ projectId: z.string(), id: z.string() }))
|
||||
@@ -279,6 +331,7 @@ const generateScoresQuery = (
|
||||
FROM scores s
|
||||
JOIN traces t ON t.id = s.trace_id AND t.project_id = ${projectId}
|
||||
LEFT JOIN job_executions je ON je.job_output_score_id = s.id AND je.project_id = ${projectId}
|
||||
LEFT JOIN users u ON u.id = s.author_user_id
|
||||
WHERE s.project_id = ${projectId}
|
||||
${filterCondition}
|
||||
${orderCondition}
|
||||
|
||||
@@ -62,56 +62,54 @@ export const sessionRouter = createTRPCRouter({
|
||||
totalTokens: number;
|
||||
}>
|
||||
>(Prisma.sql`
|
||||
WITH observation_metrics AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
EXTRACT(EPOCH FROM COALESCE(MAX(o."end_time"), MAX(o."start_time"), MAX(t.timestamp))) - EXTRACT(EPOCH FROM COALESCE(MIN(o."start_time"), MIN(t.timestamp)))::double precision AS "sessionDuration",
|
||||
SUM(COALESCE(o."calculated_input_cost", 0)) AS "inputCost",
|
||||
SUM(COALESCE(o."calculated_output_cost", 0)) AS "outputCost",
|
||||
SUM(COALESCE(o."calculated_total_cost", 0)) AS "totalCost",
|
||||
SUM(o.prompt_tokens) AS "promptTokens",
|
||||
SUM(o.completion_tokens) AS "completionTokens",
|
||||
SUM(o.total_tokens) AS "totalTokens"
|
||||
FROM traces t
|
||||
LEFT JOIN observations_view o ON o.trace_id = t.id
|
||||
WHERE
|
||||
t."project_id" = ${input.projectId}
|
||||
AND o."project_id" = ${input.projectId}
|
||||
AND t.session_id IS NOT NULL
|
||||
GROUP BY 1
|
||||
),
|
||||
trace_metrics AS (
|
||||
SELECT
|
||||
session_id,
|
||||
array_agg(distinct t.user_id) "userIds",
|
||||
count(t.id)::int "countTraces"
|
||||
FROM traces t
|
||||
WHERE
|
||||
t."project_id" = ${input.projectId}
|
||||
AND t.session_id IS NOT NULL
|
||||
GROUP BY 1
|
||||
)
|
||||
|
||||
SELECT
|
||||
s.id,
|
||||
s."created_at" "createdAt",
|
||||
s. "created_at" AS "createdAt",
|
||||
s.bookmarked,
|
||||
s.public,
|
||||
t."userIds",
|
||||
t."countTraces",
|
||||
o."sessionDuration",
|
||||
COALESCE(o."totalCost", 0) AS "totalCost",
|
||||
COALESCE(o."inputCost", 0) AS "inputCost",
|
||||
COALESCE(o."outputCost", 0) AS "outputCost",
|
||||
COALESCE(o."promptTokens", 0) AS "promptTokens",
|
||||
COALESCE(o."completionTokens", 0) AS "completionTokens",
|
||||
COALESCE(o."totalTokens", 0) AS "totalTokens",
|
||||
t. "userIds",
|
||||
t. "countTraces",
|
||||
o. "sessionDuration",
|
||||
o. "totalCost" AS "totalCost",
|
||||
o. "inputCost" AS "inputCost",
|
||||
o. "outputCost" AS "outputCost",
|
||||
o. "promptTokens" AS "promptTokens",
|
||||
o. "completionTokens" AS "completionTokens",
|
||||
o. "totalTokens" AS "totalTokens",
|
||||
(count(*) OVER ())::int AS "totalCount"
|
||||
FROM trace_sessions s
|
||||
LEFT JOIN trace_metrics t ON t.session_id = s.id
|
||||
LEFT JOIN observation_metrics o ON o.session_id = s.id
|
||||
FROM
|
||||
trace_sessions AS s
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t. "timestamp") AS "max_timestamp",
|
||||
MIN(t. "timestamp") AS "min_timestamp",
|
||||
array_agg(t.id) AS "traceIds",
|
||||
array_agg(DISTINCT t.user_id) AS "userIds",
|
||||
count(t.id)::int AS "countTraces"
|
||||
FROM
|
||||
traces t
|
||||
WHERE
|
||||
t.project_id = ${input.projectId}
|
||||
AND t.session_id = s.id
|
||||
GROUP BY
|
||||
t.session_id) AS t ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
EXTRACT(EPOCH FROM COALESCE(MAX(o. "end_time"), MAX(o. "start_time"), t. "max_timestamp")) - EXTRACT(EPOCH FROM COALESCE(MIN(o. "start_time"), t. "min_timestamp"))::double precision AS "sessionDuration",
|
||||
SUM(COALESCE(o. "calculated_input_cost", 0)) AS "inputCost",
|
||||
SUM(COALESCE(o. "calculated_output_cost", 0)) AS "outputCost",
|
||||
SUM(COALESCE(o. "calculated_total_cost", 0)) AS "totalCost",
|
||||
SUM(o.prompt_tokens) AS "promptTokens",
|
||||
SUM(o.completion_tokens) AS "completionTokens",
|
||||
SUM(o.total_tokens) AS "totalTokens"
|
||||
FROM
|
||||
observations_view o
|
||||
WHERE
|
||||
o.project_id = ${input.projectId}
|
||||
AND o.trace_id = ANY (t. "traceIds")) AS o ON TRUE
|
||||
WHERE
|
||||
s."project_id" = ${input.projectId}
|
||||
s. "project_id" = ${input.projectId}
|
||||
${filterCondition}
|
||||
${orderByCondition}
|
||||
LIMIT ${input.limit}
|
||||
|
||||
@@ -4,11 +4,17 @@ import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { Prisma, type Score } from "@langfuse/shared/src/db";
|
||||
import { paginationZod } from "@/src/utils/zod";
|
||||
import {
|
||||
singleFilter,
|
||||
tableColumnsToSqlFilterAndPrefix,
|
||||
} from "@langfuse/shared";
|
||||
import { Prisma, type Score } from "@langfuse/shared/src/db";
|
||||
import { usersTableCols } from "@/src/server/api/definitions/usersTable";
|
||||
|
||||
const UserFilterOptions = z.object({
|
||||
projectId: z.string(), // Required for protectedProjectProcedure
|
||||
filter: z.array(singleFilter).nullable(),
|
||||
});
|
||||
|
||||
const UserAllOptions = UserFilterOptions.extend({
|
||||
@@ -19,6 +25,12 @@ export const userRouter = createTRPCRouter({
|
||||
all: protectedProjectProcedure
|
||||
.input(UserAllOptions)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const filterCondition = tableColumnsToSqlFilterAndPrefix(
|
||||
input.filter ?? [],
|
||||
usersTableCols,
|
||||
"users",
|
||||
);
|
||||
|
||||
const totalUsers = (
|
||||
await ctx.prisma.$queryRaw<
|
||||
Array<{
|
||||
@@ -28,6 +40,7 @@ export const userRouter = createTRPCRouter({
|
||||
SELECT COUNT(DISTINCT t.user_id)::int AS "totalCount"
|
||||
FROM traces t
|
||||
WHERE t.project_id = ${input.projectId}
|
||||
${filterCondition}
|
||||
`
|
||||
)[0].totalCount;
|
||||
|
||||
@@ -46,6 +59,7 @@ export const userRouter = createTRPCRouter({
|
||||
t.user_id IS NOT NULL
|
||||
AND t.user_id != ''
|
||||
AND t.project_id = ${input.projectId}
|
||||
${filterCondition}
|
||||
GROUP BY
|
||||
t.user_id
|
||||
ORDER BY
|
||||
|
||||
@@ -200,7 +200,7 @@ export const tableDefinitions: TableDefinitions = {
|
||||
],
|
||||
},
|
||||
traces_scores: {
|
||||
table: ` traces t JOIN scores s ON t.id = s.trace_id AND t.project_id = s.project_id`,
|
||||
table: ` traces t JOIN scores s ON t.id = s.trace_id AND s.data_type != 'CATEGORICAL' AND t.project_id = s.project_id`,
|
||||
columns: [
|
||||
tracesProjectId,
|
||||
{ name: "value", id: "value", type: "number", internal: 's."value"' },
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/ingestion-metrics",
|
||||
"schedule": "*/15 * * * *"
|
||||
"schedule": "0 */6 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ FROM node:20-alpine AS alpine
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat
|
||||
|
||||
FROM alpine AS base
|
||||
RUN npm install turbo --global
|
||||
RUN npm install turbo@^1.13.3 --global
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ScoreSource = (typeof ScoreSource)[keyof typeof ScoreSource];
|
||||
export const ScoreDataType = {
|
||||
CATEGORICAL: "CATEGORICAL",
|
||||
NUMERIC: "NUMERIC",
|
||||
BOOLEAN: "BOOLEAN",
|
||||
} as const;
|
||||
export type ScoreDataType = (typeof ScoreDataType)[keyof typeof ScoreDataType];
|
||||
export const PricingUnit = {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "2.47.2",
|
||||
"version": "2.48.0",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
|
||||
+2
-2
@@ -31,9 +31,9 @@ if (isSentryEnabled) {
|
||||
Sentry.metrics.metricsAggregatorIntegration(),
|
||||
],
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 0.1, // Capture 100% of the transactions
|
||||
tracesSampleRate: 0.01, // Capture 100% of the transactions
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
profilesSampleRate: 0.1,
|
||||
profilesSampleRate: 0.01,
|
||||
sampleRate: 0.1,
|
||||
});
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v2.47.2";
|
||||
export const VERSION = "v2.48.0";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Job, Queue, Worker } from "bullmq";
|
||||
import { BaseError, QueueName, TQueueJobTypes } from "@langfuse/shared";
|
||||
import {
|
||||
ApiError,
|
||||
BaseError,
|
||||
QueueName,
|
||||
TQueueJobTypes,
|
||||
} from "@langfuse/shared";
|
||||
import { evaluate, createEvalJobs } from "../eval-service";
|
||||
import { kyselyPrisma } from "@langfuse/shared/src/db";
|
||||
import logger from "../logger";
|
||||
@@ -59,11 +64,6 @@ export const evalJobExecutor = redis
|
||||
await evaluate({ event: job.data.payload });
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
e,
|
||||
`Failed Evaluation_Execution job for id ${job.data.payload.jobExecutionId} ${e}`
|
||||
);
|
||||
|
||||
const displayError =
|
||||
e instanceof BaseError ? e.message : "An internal error occurred";
|
||||
|
||||
@@ -76,7 +76,20 @@ export const evalJobExecutor = redis
|
||||
.where("project_id", "=", job.data.payload.projectId)
|
||||
.execute();
|
||||
|
||||
Sentry.captureException(e);
|
||||
// do not log expected errors (api failures + missing api keys not provided by the user)
|
||||
if (
|
||||
!(e instanceof ApiError) &&
|
||||
!(
|
||||
e instanceof BaseError &&
|
||||
e.message.includes("API key for provider")
|
||||
)
|
||||
) {
|
||||
logger.error(
|
||||
e,
|
||||
`Failed Evaluation_Execution job for id ${job.data.payload.jobExecutionId} ${e}`
|
||||
);
|
||||
Sentry.captureException(e);
|
||||
}
|
||||
|
||||
throw e;
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user