Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3debac082a | ||
|
|
fb43e03171 | ||
|
|
dd04da64ba | ||
|
|
b94cd9880a | ||
|
|
0f2d2b8850 | ||
|
|
69db85cfe4 | ||
|
|
b445471f98 | ||
|
|
2ce014980b | ||
|
|
40ad6761cc | ||
|
|
bd36669c6a | ||
|
|
98cc1bb7a1 | ||
|
|
3dcdbd99b3 | ||
|
|
1a5c5f6383 | ||
|
|
867f6c7484 | ||
|
|
1097f1b8d5 | ||
|
|
a73156bbe5 | ||
|
|
7a9c62b106 | ||
|
|
70cc910ee9 | ||
|
|
64a71f059f | ||
|
|
f5a0c7cfed | ||
|
|
2472d2f6da | ||
|
|
dd0446f4dd | ||
|
|
7263a1554c | ||
|
|
2c25c27b51 | ||
|
|
f2b92e1f23 | ||
|
|
593c0a567c | ||
|
|
ac1f465634 | ||
|
|
083b1357a1 | ||
|
|
30e594e839 | ||
|
|
2b8f583c44 | ||
|
|
ef3656a8d3 | ||
|
|
5cf2411e74 | ||
|
|
6d9956ae6e | ||
|
|
37d1ac7ff8 | ||
|
|
339633e8de | ||
|
|
a31af76ba1 | ||
|
|
dfd527a740 | ||
|
|
d65bdaa761 | ||
|
|
ef08a514af | ||
|
|
3b3d9de206 | ||
|
|
f1ca851b0d | ||
|
|
c1c6035778 | ||
|
|
d2bf002ea2 | ||
|
|
88f9008558 | ||
|
|
586e852375 | ||
|
|
94e49dbc4e | ||
|
|
d37e676ea6 | ||
|
|
626b36e225 | ||
|
|
62cd67f431 | ||
|
|
09f1bb9f7f | ||
|
|
d6963297a7 | ||
|
|
06b9e88d3a | ||
|
|
39feb65d90 | ||
|
|
b85511a3fe | ||
|
|
88e47faec4 |
@@ -11,7 +11,7 @@ alwaysApply: true
|
||||
## File Structure
|
||||
|
||||
- We generally put all code related to a net-new feature into a folder within web/src/features.
|
||||
- Checkout other features to learn about the common structure.
|
||||
- Check out other features to learn about the common structure.
|
||||
|
||||
## API for frontend features
|
||||
|
||||
|
||||
+5
-1
@@ -75,7 +75,11 @@ REDIS_PORT=6379
|
||||
REDIS_AUTH="myredissecret"
|
||||
|
||||
# openssl rand -hex 32 used only here
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# speeds up local development by not executing init scripts on server startup
|
||||
NEXT_PUBLIC_LANGFUSE_RUN_NEXT_INIT="false"
|
||||
|
||||
# For SDK integration tests to pass, decrease the ingestion queue delay by uncommenting the env vars:
|
||||
# LANGFUSE_INGESTION_QUEUE_DELAY_MS=10
|
||||
# LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=10
|
||||
|
||||
@@ -178,3 +178,11 @@ When running locally with seed data:
|
||||
To get a project, use the `get_project` capability with the full project name as it is in the title.
|
||||
- bad: message-placeholder-in-chat-messages-2beb6f02ec48
|
||||
- good: Message placeholder in chat messages
|
||||
|
||||
## Front-end Tips
|
||||
|
||||
### Window Location Handling
|
||||
- Whenever you want to use or do use window.location..., ensure that you also add proper handling for a custom basePath
|
||||
|
||||
## TypeScript Best Practices
|
||||
- In TypeScript, if possible, don't use the `any` type
|
||||
|
||||
+80
-41
@@ -42,7 +42,7 @@ A good first step is to search for open [issues](https://github.com/langfuse/lan
|
||||
- NextAuth.js / Auth.js
|
||||
- tRPC: Frontend APIs
|
||||
- Prisma ORM
|
||||
- Zod
|
||||
- Zod v4
|
||||
- Tailwind CSS
|
||||
- shadcn/ui tailwind components (using Radix and tanstack)
|
||||
- Fern: generate OpenAPI spec and Pydantic models
|
||||
@@ -55,34 +55,34 @@ A good first step is to search for open [issues](https://github.com/langfuse/lan
|
||||
|
||||
See this [diagram](https://langfuse.com/self-hosting#architecture) for an overview of the architecture.
|
||||
|
||||
### Network Overview
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User["UI, API, SDKs"]
|
||||
subgraph vpc["VPC"]
|
||||
Web["Web Server<br/>(langfuse/langfuse)"]
|
||||
Worker["Async Worker<br/>(langfuse/worker)"]
|
||||
Postgres["Postgres - OLTP<br/>(Transactional Data)"]
|
||||
Cache["Redis/Valkey<br/>(Cache, Queue)"]
|
||||
Clickhouse["Clickhouse - OLAP<br/>(Observability Data)"]
|
||||
S3["S3 / Blob Storage<br/>(Raw events, multi-modal attachments)"]
|
||||
end
|
||||
LLM["LLM API/Gateway<br/>(optional)"]
|
||||
|
||||
User --> Web
|
||||
Web --> S3
|
||||
Web --> Postgres
|
||||
Web --> Cache
|
||||
Web --> Clickhouse
|
||||
Web -.->|"optional for playground"| LLM
|
||||
|
||||
Cache --> Worker
|
||||
Worker --> Clickhouse
|
||||
Worker --> Postgres
|
||||
Worker --> S3
|
||||
Worker -.->|"optional for evals"| LLM
|
||||
```
|
||||
### Network Overview
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User["UI, API, SDKs"]
|
||||
subgraph vpc["VPC"]
|
||||
Web["Web Server<br/>(langfuse/langfuse)"]
|
||||
Worker["Async Worker<br/>(langfuse/worker)"]
|
||||
Postgres["Postgres - OLTP<br/>(Transactional Data)"]
|
||||
Cache["Redis/Valkey<br/>(Cache, Queue)"]
|
||||
Clickhouse["Clickhouse - OLAP<br/>(Observability Data)"]
|
||||
S3["S3 / Blob Storage<br/>(Raw events, multi-modal attachments)"]
|
||||
end
|
||||
LLM["LLM API/Gateway<br/>(optional)"]
|
||||
|
||||
User --> Web
|
||||
Web --> S3
|
||||
Web --> Postgres
|
||||
Web --> Cache
|
||||
Web --> Clickhouse
|
||||
Web -.->|"optional for playground"| LLM
|
||||
|
||||
Cache --> Worker
|
||||
Worker --> Clickhouse
|
||||
Worker --> Postgres
|
||||
Worker --> S3
|
||||
Worker -.->|"optional for evals"| LLM
|
||||
```
|
||||
|
||||
### Database Overview
|
||||
|
||||
@@ -130,10 +130,11 @@ Requirements
|
||||
cp .env.dev.example .env
|
||||
```
|
||||
|
||||
4. Run the entire infrastructure in dev mode
|
||||
4. Run the entire infrastructure in dev mode. **Note**: if you have an existing database, this command wipes it.
|
||||
|
||||
```bash
|
||||
pnpm run dx
|
||||
pnpm run dx # first run only (resets db, node_modules, ...)
|
||||
pnpm run dev # any subsequent runs
|
||||
```
|
||||
|
||||
You will be asked whether you want to reset Postgres and ClickHouse. Confirm both with 'Y' and press enter.
|
||||
@@ -148,6 +149,12 @@ Requirements
|
||||
- Username: `demo@langfuse.com`
|
||||
- Password: `password`
|
||||
|
||||
|
||||
To get comprehensive example data, you can use the `seed` command:
|
||||
```sh
|
||||
pnpm run db:seed:examples
|
||||
```
|
||||
|
||||
## Monorepo quickstart
|
||||
|
||||
- Available packages and their dependencies
|
||||
@@ -197,23 +204,41 @@ Requirements
|
||||
|
||||
On the main branch, we adhere to the best practices of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). All pull requests and branches are squash-merged to maintain a clean and readable history. This approach ensures the addition of a conventional commit message when merging contributions.
|
||||
|
||||
## Test the public API
|
||||
## Running Unit Tests
|
||||
|
||||
The API is tested using Jest. With the development server running, you can run the tests with:
|
||||
All tests run in the CI and must pass before merging.
|
||||
All tests run against a running langfuse instance and **write/delete real data from the database**.
|
||||
|
||||
Run all
|
||||
### Tests in the `web` package (public API)
|
||||
We're using Jest with in the `web` package. Therefore, if you want to provide an argument to the test runner, do it directly without an intermittent ` -- `.
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
There are three types of unit tests:
|
||||
- `test-sync`
|
||||
- `test-async`
|
||||
- `test-client`
|
||||
|
||||
To run a specific test, for example the test: `"should handle special characters in prompt names"` in `prompts.v2.servertest.ts`, run:
|
||||
```sh
|
||||
cd web # or with --filter=web
|
||||
pnpm test-sync --testPathPattern="prompts\.v2\.servertest" --testNamePattern="should handle special characters in prompt names"
|
||||
```
|
||||
|
||||
Run interactively in watch mode
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
To run all tests:
|
||||
```sh
|
||||
pnpm run test
|
||||
```
|
||||
|
||||
These tests are also run in CI.
|
||||
Run interactively in watch mode (not recommended!)
|
||||
```sh
|
||||
pnpm run test:watch
|
||||
```
|
||||
|
||||
### Tests in the `worker` package
|
||||
For the `worker` package, we're using `vitest` to run unit tests.
|
||||
|
||||
```sh
|
||||
pnpm run test --filter=worker -- FILE_YOU_WANT_TO_TEST.ts -t "test name"
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -341,6 +366,20 @@ Please note that
|
||||
|
||||
Until the V3 release, both the JSON record must be updated **and** a migration must be created to continue supporting self-hosted users. Note that the migration must updated both the `models` as well as the `prices` table accordingly.
|
||||
|
||||
## Updating the OpenAPI Specs & fern SDKs
|
||||
|
||||
We maintain the API specifications manually to guarantee a high degree of understandability. If you made changes to the API, please update the respective `.yml` files in `fern/apis/...`.
|
||||
|
||||
To generate the respective `openapi.yml` files which power the online API reference & SDKs, run:
|
||||
|
||||
```sh
|
||||
npx fern-api generate --api server # for the server API
|
||||
npx fern-api generate --api client # for the client API
|
||||
npx fern-api generate --api organizations # for the organizations API
|
||||
```
|
||||
|
||||
**Note:** You need a signed in fern account to run those commands.
|
||||
|
||||
## License
|
||||
|
||||
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<a href="https://langfuse.com/roadmap"><strong>路线图</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> 作为支持和功能请求的平台。</span>
|
||||
<span>Langfuse 使用 <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> 作为支持和功能请求的平台。</span>
|
||||
<br/>
|
||||
<span><b>我们正在招聘。</b> <a href="https://langfuse.com/careers"><strong>加入我们</strong></a>,从事产品工程和技术市场职位。</span>
|
||||
<br/>
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<a href="https://langfuse.com/roadmap"><strong>ロードマップ</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> を利用しています。</span>
|
||||
<span>Langfuseは、サポートと機能リクエストのために <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> を利用しています。</span>
|
||||
<br/>
|
||||
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>チームに加わる</strong></a> (製品エンジニアリングおよびテクニカルGTMのポジション)への応募をお待ちしています。</span>
|
||||
<br/>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<a href="https://langfuse.com/roadmap"><strong>Roadmap</strong></a> ·
|
||||
</div>
|
||||
<br/>
|
||||
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>Github Discussions</strong></a> for Support and Feature Requests.</span>
|
||||
<span>Langfuse uses <a href="https://github.com/orgs/langfuse/discussions"><strong>GitHub Discussions</strong></a> for Support and Feature Requests.</span>
|
||||
<br/>
|
||||
<span><b>We're hiring.</b> <a href="https://langfuse.com/careers"><strong>Join us</strong></a> in product engineering and technical go-to-market roles.</span>
|
||||
<br/>
|
||||
|
||||
@@ -82,7 +82,7 @@ types:
|
||||
CreateChatPromptRequest:
|
||||
properties:
|
||||
name: string
|
||||
prompt: list<ChatMessage>
|
||||
prompt: list<ChatMessageWithPlaceholders>
|
||||
config: optional<unknown>
|
||||
labels:
|
||||
type: optional<list<string>>
|
||||
@@ -132,6 +132,11 @@ types:
|
||||
type: optional<map<string, unknown>>
|
||||
docs: The dependency resolution graph for the current prompt. Null if prompt has no dependencies.
|
||||
|
||||
ChatMessageWithPlaceholders:
|
||||
union:
|
||||
chatmessage: ChatMessage
|
||||
placeholder: PlaceholderMessage
|
||||
|
||||
ChatMessage:
|
||||
properties:
|
||||
role:
|
||||
@@ -139,6 +144,11 @@ types:
|
||||
content:
|
||||
type: string
|
||||
|
||||
PlaceholderMessage:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
|
||||
TextPrompt:
|
||||
extends: BasePrompt
|
||||
properties:
|
||||
@@ -147,4 +157,4 @@ types:
|
||||
ChatPrompt:
|
||||
extends: BasePrompt
|
||||
properties:
|
||||
prompt: list<ChatMessage>
|
||||
prompt: list<ChatMessageWithPlaceholders>
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.75.0",
|
||||
"version": "3.78.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -25,6 +25,7 @@
|
||||
"dev": "turbo run dev",
|
||||
"dev:worker": "turbo run dev --filter=worker",
|
||||
"dev:web": "turbo run dev --filter=web",
|
||||
"dev:web-turbo": "turbo run dev --filter=web -- --turbo",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test",
|
||||
"release": "dotenv -e ../.env -- release-it",
|
||||
@@ -37,7 +38,7 @@
|
||||
"husky": "^9.0.11",
|
||||
"prettier": "^3.3.3",
|
||||
"release-it": "^19.0.3",
|
||||
"turbo": "^1.13.4"
|
||||
"turbo": "^2.5.4"
|
||||
},
|
||||
"release-it": {
|
||||
"git": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@vercel/style-guide": "^6.0.0",
|
||||
"eslint-config-next": "^14.2.15",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-config-turbo": "^2.5.4",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"langchain": "^0.3.28",
|
||||
"langfuse-langchain": "3.37.4",
|
||||
"lodash": "^4.17.21",
|
||||
"lossless-json": "^4.0.2",
|
||||
"lossless-json": "^4.1.1",
|
||||
"next-auth": "^4.24.11",
|
||||
"nodemailer": "^6.9.15",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
|
||||
@@ -111,7 +111,8 @@ export const DashboardWidgetChartType = {
|
||||
VERTICAL_BAR: "VERTICAL_BAR",
|
||||
PIE: "PIE",
|
||||
NUMBER: "NUMBER",
|
||||
HISTOGRAM: "HISTOGRAM"
|
||||
HISTOGRAM: "HISTOGRAM",
|
||||
PIVOT_TABLE: "PIVOT_TABLE"
|
||||
} as const;
|
||||
export type DashboardWidgetChartType = (typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
|
||||
export type Account = {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Database migration to add PIVOT_TABLE chart type to DashboardWidgetChartType enum
|
||||
-- This enables the creation of pivot table widgets in the dashboard system
|
||||
--
|
||||
-- This migration adds support for tabular data visualization with configurable
|
||||
-- row dimensions and metrics, extending the existing widget types (line charts,
|
||||
-- bar charts, pie charts, etc.) to include pivot table functionality.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "DashboardWidgetChartType" ADD VALUE 'PIVOT_TABLE';
|
||||
@@ -871,7 +871,7 @@ model JobExecution {
|
||||
jobConfiguration JobConfiguration @relation(fields: [jobConfigurationId], references: [id], onDelete: Cascade)
|
||||
|
||||
jobTemplateId String? @map("job_template_id")
|
||||
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull)
|
||||
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
|
||||
status JobExecutionStatus
|
||||
startTime DateTime? @map("start_time")
|
||||
@@ -902,7 +902,7 @@ model DefaultLlmModel {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
projectId String @map("project_id")
|
||||
Project Project @relation(fields: [projectId], references: [id])
|
||||
Project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
llmApiKeyId String @map("llm_api_key_id")
|
||||
LlmApiKey LlmApiKeys @relation("LlmApiKeyId", fields: [llmApiKeyId], references: [id], onDelete: Cascade)
|
||||
@@ -1157,6 +1157,7 @@ enum DashboardWidgetChartType {
|
||||
PIE
|
||||
NUMBER
|
||||
HISTOGRAM
|
||||
PIVOT_TABLE
|
||||
}
|
||||
|
||||
model DashboardWidget {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {
|
||||
PrismaClient,
|
||||
type Project,
|
||||
ScoreDataType,
|
||||
JobConfiguration,
|
||||
JobExecutionStatus,
|
||||
} from "../../src/index";
|
||||
import { hash } from "bcryptjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parseArgs } from "node:util";
|
||||
import { hash } from "bcryptjs";
|
||||
import { v4 } from "uuid";
|
||||
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
|
||||
import { encrypt } from "../../src/encryption";
|
||||
import { redis } from "../../src/server/redis/redis";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
type JobConfiguration,
|
||||
JobExecutionStatus,
|
||||
PrismaClient,
|
||||
type Project,
|
||||
ScoreDataType,
|
||||
} from "../../src/index";
|
||||
import { getDisplaySecretKey, hashSecretKey, logger } from "../../src/server";
|
||||
import { redis } from "../../src/server/redis/redis";
|
||||
import {EVAL_TRACE_COUNT,
|
||||
FAILED_EVAL_TRACE_INTERVAL,
|
||||
SEED_CHAT_ML_PROMPTS,
|
||||
SEED_DATASETS,
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
generateEvalScoreId,
|
||||
generateEvalTraceId,
|
||||
} from "./utils/seed-helpers";
|
||||
import { EVAL_TRACE_COUNT } from "./utils/postgres-seed-constants";
|
||||
|
||||
type ConfigCategory = {
|
||||
label: string;
|
||||
|
||||
@@ -442,7 +442,7 @@ export class DataGenerator {
|
||||
string_value: stringValue,
|
||||
data_type: scoreType as any,
|
||||
source: "API",
|
||||
comment: "Generated score",
|
||||
comment: "Generated score\ntest",
|
||||
environment: trace.environment,
|
||||
});
|
||||
|
||||
|
||||
@@ -307,6 +307,34 @@ export const SEED_CHAT_ML_PROMPTS = [
|
||||
labels: ["production", "latest"],
|
||||
tags: ["tag1", "tag2"],
|
||||
},
|
||||
{
|
||||
id: `prompt-chat-placeholder`,
|
||||
createdBy: "user-1",
|
||||
prompt: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'You are a very enthusiastic Langfuse representative who loves to help people! Langfuse is an open-source observability tool for developers of applications that use Large Language Models (LLMs). Given the following sections from the Langfuse documentation, answer the question using only that information, outputted in markdown format.\n\nPlease follow these guidelines:\n- Refer to the respective links of the documentation and select quality examples\n- Be kind.\n- Include emojis where it makes sense.\n- If the users have problems using Langfuse, tell them to reach out to the founders directly via the chat widget or GitHub at the end of your answer.\n- Answer as markdown, use highlights and paragraphs to structure the text.\n- Do not mention that you are "enthusiastic", the user does not need to know, will feel it from the style of your answers.\n- Only use information that is available in the context, do not make up any code that is not in the context.\n- Always put an empji at the end of the message.',
|
||||
},
|
||||
{
|
||||
type: "placeholder",
|
||||
name: "message_history",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Answering in next message based on your instructions only. What is the question?",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "{{question}}",
|
||||
},
|
||||
],
|
||||
name: "prompt-chat-ml-with-placeholder",
|
||||
version: 1,
|
||||
labels: ["production", "latest"],
|
||||
tags: ["tag1", "tag2"],
|
||||
},
|
||||
];
|
||||
|
||||
export const SEED_PROMPT_VERSIONS = [
|
||||
|
||||
@@ -45,10 +45,7 @@ const EnvSchema = z.object({
|
||||
.number()
|
||||
.nonnegative()
|
||||
.default(15_000),
|
||||
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(1),
|
||||
LANGFUSE_INGESTION_QUEUE_SHARD_COUNT: z.coerce.number().positive().default(1),
|
||||
SALT: z.string().optional(), // used by components imported by web package
|
||||
LANGFUSE_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
@@ -91,6 +88,7 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_GOOGLE_CLOUD_STORAGE_CREDENTIALS: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
|
||||
LANGFUSE_S3_LIST_MAX_KEYS: z.coerce.number().positive().default(200),
|
||||
LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
@@ -107,6 +105,7 @@ const EnvSchema = z.object({
|
||||
.number()
|
||||
.default(80e6), // 80MB
|
||||
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
|
||||
LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS: z.string().optional(),
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
+11
-9
@@ -1,15 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
import { jsonSchema, PromptNameSchema } from "@langfuse/shared";
|
||||
import type { Prompt } from "@langfuse/shared";
|
||||
import { COMMIT_MESSAGE_MAX_LENGTH } from "@/src/features/prompts/constants";
|
||||
import type { Prompt } from "../../../prisma/generated/types";
|
||||
import { jsonSchema } from "../../utils/zod";
|
||||
import { COMMIT_MESSAGE_MAX_LENGTH } from "./constants";
|
||||
import { PromptChatMessageSchema } from "../../server/llm/types";
|
||||
import { PromptNameSchema } from "./validation";
|
||||
|
||||
export const ChatMessageSchema = z.object({
|
||||
role: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
export const SingleChatMessageSchema = PromptChatMessageSchema;
|
||||
export type SingleChatMessage = z.infer<typeof SingleChatMessageSchema>;
|
||||
|
||||
export enum PromptType {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Chat = "chat",
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Text = "text",
|
||||
}
|
||||
|
||||
@@ -41,7 +43,7 @@ const BaseCreateChatPromptSchema = z.object({
|
||||
name: PromptNameSchema,
|
||||
labels: z.array(PromptLabelSchema).default([]),
|
||||
type: z.literal(PromptType.Chat),
|
||||
prompt: z.array(ChatMessageSchema),
|
||||
prompt: z.array(PromptChatMessageSchema),
|
||||
config: jsonSchema.nullable().default({}),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
});
|
||||
@@ -134,7 +136,7 @@ export const BaseChatPromptSchema = z.object({
|
||||
tags: z.array(z.string()),
|
||||
labels: z.array(PromptLabelSchema),
|
||||
type: z.literal(PromptType.Chat),
|
||||
prompt: z.array(ChatMessageSchema),
|
||||
prompt: z.array(PromptChatMessageSchema),
|
||||
config: jsonSchema,
|
||||
});
|
||||
|
||||
@@ -43,6 +43,9 @@ export * from "./features/experiments/utils";
|
||||
// prompts
|
||||
export * from "./features/prompts/parsePromptDependencyTags";
|
||||
export * from "./features/prompts/validation";
|
||||
export * from "./features/prompts/types";
|
||||
export * from "./features/prompts/constants";
|
||||
export * from "./server/llm/compileChatMessages";
|
||||
|
||||
// export db types only
|
||||
export * from "@prisma/client";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Make sure to update the InMemoryFilterService if you add new filter types
|
||||
export const filterOperators = {
|
||||
datetime: [">", "<", ">=", "<="],
|
||||
string: ["=", "contains", "does not contain", "starts with", "ends with"],
|
||||
|
||||
@@ -30,6 +30,21 @@ export const contextWithLangfuseProps = (
|
||||
value: strValue,
|
||||
});
|
||||
});
|
||||
|
||||
// get x-langfuse-xxx headers and add them to the span
|
||||
Object.keys(props.headers).forEach((name) => {
|
||||
if (
|
||||
name.toLowerCase().startsWith("x-langfuse") ||
|
||||
name.toLowerCase().startsWith("x_langfuse")
|
||||
) {
|
||||
const value = props.headers![name];
|
||||
if (!value) return;
|
||||
const strValue = Array.isArray(value) ? JSON.stringify(value) : value;
|
||||
baggage = baggage.setEntry(`langfuse.header.${name}`, {
|
||||
value: strValue,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (props.userId) {
|
||||
baggage = baggage.setEntry("langfuse.user.id", { value: props.userId });
|
||||
|
||||
@@ -4,12 +4,14 @@ export * from "./services/email/batchExportSuccess/sendBatchExportSuccessEmail";
|
||||
export * from "./services/email/passwordReset/sendResetPasswordVerificationRequest";
|
||||
export * from "./services/PromptService";
|
||||
export * from "./services/traces-ui-table-service";
|
||||
export * from "./services/InMemoryFilterService";
|
||||
export * from "./auth/apiKeys";
|
||||
export * from "./auth/customSsoProvider";
|
||||
export * from "./auth/gitHubEnterpriseProvider";
|
||||
export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/utils";
|
||||
export * from "./llm/types";
|
||||
export * from "./llm/compileChatMessages";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
export * from "./clickhouse/client";
|
||||
|
||||
@@ -229,12 +229,21 @@ export const processEventBatch = async (
|
||||
throw new Error("Redis not initialized, aborting event processing");
|
||||
}
|
||||
|
||||
const projectIdsToSkipS3List =
|
||||
env.LANGFUSE_SKIP_S3_LIST_FOR_OBSERVATIONS_PROJECT_IDS?.split(",") ?? [];
|
||||
|
||||
await Promise.all(
|
||||
Object.keys(sortedBatchByEventBodyId).map(async (id) => {
|
||||
const eventData = sortedBatchByEventBodyId[id];
|
||||
const shardingKey = `${authCheck.scope.projectId}-${eventData.eventBodyId}`;
|
||||
const queue = IngestionQueue.getInstance({ shardingKey });
|
||||
|
||||
const shouldSkipS3List =
|
||||
getClickhouseEntityType(eventData.type) === "observation" &&
|
||||
authCheck.scope.projectId !== null &&
|
||||
(projectIdsToSkipS3List.includes(authCheck.scope.projectId) ||
|
||||
source === "otel");
|
||||
|
||||
return queue
|
||||
? queue.add(
|
||||
QueueJobs.IngestionJob,
|
||||
@@ -247,9 +256,7 @@ export const processEventBatch = async (
|
||||
type: eventData.type,
|
||||
eventBodyId: eventData.eventBodyId,
|
||||
fileKey: eventData.key,
|
||||
skipS3List:
|
||||
source === "otel" &&
|
||||
getClickhouseEntityType(eventData.type) === "observation",
|
||||
skipS3List: shouldSkipS3List,
|
||||
},
|
||||
authCheck: authCheck as {
|
||||
validKey: true;
|
||||
|
||||
@@ -157,6 +157,61 @@ export const traceException = (
|
||||
});
|
||||
};
|
||||
|
||||
export const addUserToSpan = (
|
||||
attributes: {
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
email?: string;
|
||||
orgId?: string;
|
||||
plan?: string;
|
||||
},
|
||||
span?: opentelemetry.Span,
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
if (!activeSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = opentelemetry.context.active();
|
||||
let baggage =
|
||||
opentelemetry.propagation.getBaggage(ctx) ??
|
||||
opentelemetry.propagation.createBaggage();
|
||||
|
||||
if (attributes.userId) {
|
||||
baggage = baggage.setEntry("user.id", {
|
||||
value: attributes.userId,
|
||||
});
|
||||
activeSpan.setAttribute("user.id", attributes.userId);
|
||||
}
|
||||
if (attributes.email) {
|
||||
baggage = baggage.setEntry("user.email", {
|
||||
value: attributes.email,
|
||||
});
|
||||
activeSpan.setAttribute("user.email", attributes.email);
|
||||
}
|
||||
if (attributes.projectId) {
|
||||
baggage = baggage.setEntry("langfuse.project.id", {
|
||||
value: attributes.projectId,
|
||||
});
|
||||
activeSpan.setAttribute("langfuse.project.id", attributes.projectId);
|
||||
}
|
||||
if (attributes.orgId) {
|
||||
baggage = baggage.setEntry("langfuse.org.id", {
|
||||
value: attributes.orgId,
|
||||
});
|
||||
activeSpan.setAttribute("langfuse.org.id", attributes.orgId);
|
||||
}
|
||||
if (attributes.plan) {
|
||||
baggage = baggage.setEntry("langfuse.org.plan", {
|
||||
value: attributes.plan,
|
||||
});
|
||||
activeSpan.setAttribute("langfuse.org.plan", attributes.plan);
|
||||
}
|
||||
|
||||
return opentelemetry.propagation.setBaggage(ctx, baggage);
|
||||
};
|
||||
|
||||
export const getTracer = (name: string) => opentelemetry.trace.getTracer(name);
|
||||
|
||||
const cloudWatchClient = new CloudWatchClient();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod/v4";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { type ChatMessage, type PlaceholderMessage, ChatMessageType, type PromptChatMessageSchema, type ChatMessageWithId, type ChatMessageWithIdNoPlaceholders, ChatMessageSchema } from "./types";
|
||||
|
||||
export type MessagePlaceholderValues = Record<string, ChatMessage[]>;
|
||||
export type PromptMessage = z.infer<typeof PromptChatMessageSchema>;
|
||||
|
||||
export function isPlaceholder(message: PromptMessage): message is PlaceholderMessage {
|
||||
return "type" in message && message.type === ChatMessageType.Placeholder;
|
||||
}
|
||||
|
||||
function validateMessage(message: unknown): message is ChatMessage {
|
||||
return ChatMessageSchema.safeParse(message).success;
|
||||
}
|
||||
|
||||
function replaceTextVariables(
|
||||
content: string,
|
||||
textVariables: Record<string, string>
|
||||
): string {
|
||||
let result = content;
|
||||
for (const [varName, varValue] of Object.entries(textVariables)) {
|
||||
// Create regex that handles optional whitespace around variable name
|
||||
const variablePattern = new RegExp(`{{\\s*${varName}\\s*}}`, "g");
|
||||
result = result.replace(variablePattern, varValue);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function expandPlaceholder(
|
||||
placeholder: PlaceholderMessage,
|
||||
placeholderValues: MessagePlaceholderValues
|
||||
): ChatMessage[] {
|
||||
const replacementMessages = placeholderValues[placeholder.name];
|
||||
|
||||
if (!replacementMessages) {
|
||||
throw new Error(`Missing value for message placeholder: ${placeholder.name}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(replacementMessages)) {
|
||||
throw new Error(`Placeholder value for '${placeholder.name}' must be an array of messages`);
|
||||
}
|
||||
|
||||
for (const replacementMsg of replacementMessages) {
|
||||
if (!validateMessage(replacementMsg)) {
|
||||
throw new Error(`Invalid message format in placeholder '${placeholder.name}': messages must have 'role' and 'content' properties`);
|
||||
}
|
||||
}
|
||||
return replacementMessages;
|
||||
}
|
||||
|
||||
export function compileChatMessages(
|
||||
messages: PromptMessage[],
|
||||
placeholderValues: MessagePlaceholderValues,
|
||||
textVariables?: Record<string, string>
|
||||
): ChatMessage[] {
|
||||
const expandedMessages = messages.flatMap((message) =>
|
||||
isPlaceholder(message)
|
||||
? expandPlaceholder(message, placeholderValues)
|
||||
: [message as ChatMessage]
|
||||
);
|
||||
|
||||
// substitute text variables
|
||||
if (!textVariables || Object.keys(textVariables).length === 0) {
|
||||
return expandedMessages;
|
||||
}
|
||||
|
||||
return expandedMessages.map((message) => {
|
||||
if (!message.content) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: replaceTextVariables(message.content, textVariables)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function compileChatMessagesWithIds(
|
||||
messages: ChatMessageWithId[],
|
||||
placeholderValues: Record<string, ChatMessage[]>,
|
||||
textVariables?: Record<string, string>
|
||||
): ChatMessageWithIdNoPlaceholders[] {
|
||||
// TODO: check, is it even important to retain the IDs?
|
||||
const expandedMessages = messages.flatMap((message) => {
|
||||
if (isPlaceholder(message)) {
|
||||
const expandedMsgs = expandPlaceholder(message, placeholderValues);
|
||||
return expandedMsgs.map(msg => ({ ...msg, id: uuidv4() }));
|
||||
} else {
|
||||
// Preserve message IDs for already non-placeholder messages
|
||||
return [message as ChatMessageWithIdNoPlaceholders];
|
||||
}
|
||||
});
|
||||
|
||||
// substitute text variables
|
||||
if (!textVariables || Object.keys(textVariables).length === 0) {
|
||||
return expandedMessages;
|
||||
}
|
||||
|
||||
return expandedMessages.map((message) => {
|
||||
if (!message.content) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: replaceTextVariables(message.content, textVariables)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function extractPlaceholderNames(messages: PromptMessage[]): string[] {
|
||||
return messages
|
||||
.filter((msg): msg is PlaceholderMessage => "type" in msg && msg.type === ChatMessageType.Placeholder)
|
||||
.map(msg => msg.name);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { type ZodSchema } from "zod/v4";
|
||||
// We need to use Zod3 for structured outputs due to a bug in
|
||||
// ChatVertexAI. See issue: https://github.com/langfuse/langfuse/issues/7429
|
||||
import { type ZodSchema } from "zod/v3";
|
||||
|
||||
import { ChatAnthropic } from "@langchain/anthropic";
|
||||
import { ChatVertexAI } from "@langchain/google-vertexai";
|
||||
|
||||
@@ -115,6 +115,8 @@ export enum ChatMessageRole {
|
||||
Tool = "tool",
|
||||
}
|
||||
|
||||
// Thought: should placeholder not semantically be part of this, because it can be
|
||||
// PublicAPICreated of type? Works for now though.
|
||||
export enum ChatMessageType {
|
||||
System = "system",
|
||||
Developer = "developer",
|
||||
@@ -123,6 +125,7 @@ export enum ChatMessageType {
|
||||
AssistantToolCall = "assistant-tool-call",
|
||||
ToolResult = "tool-result",
|
||||
PublicAPICreated = "public-api-created",
|
||||
Placeholder = "placeholder",
|
||||
}
|
||||
|
||||
export const SystemMessageSchema = z.object({
|
||||
@@ -171,6 +174,12 @@ export const ToolResultMessageSchema = z.object({
|
||||
});
|
||||
export type ToolResultMessage = z.infer<typeof ToolResultMessageSchema>;
|
||||
|
||||
export const PlaceholderMessageSchema = z.object({
|
||||
type: z.literal(ChatMessageType.Placeholder),
|
||||
name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "Placeholder name must start with a letter and contain only alphanumeric characters and underscores"),
|
||||
});
|
||||
export type PlaceholderMessage = z.infer<typeof PlaceholderMessageSchema>;
|
||||
|
||||
export const ChatMessageDefaultRoleSchema = z.enum(ChatMessageRole);
|
||||
export const ChatMessageSchema = z.union([
|
||||
SystemMessageSchema,
|
||||
@@ -193,12 +202,16 @@ export const ChatMessageSchema = z.union([
|
||||
]);
|
||||
|
||||
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
|
||||
export type ChatMessageWithId = ChatMessage & { id: string };
|
||||
export type ChatMessageWithId = (ChatMessage & { id: string }) | (PlaceholderMessage & { id: string });
|
||||
export type ChatMessageWithIdNoPlaceholders = (ChatMessage & { id: string });
|
||||
|
||||
export const PromptChatMessageSchema = z.object({
|
||||
role: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
export const PromptChatMessageSchema = z.union([
|
||||
z.object({
|
||||
role: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
PlaceholderMessageSchema,
|
||||
]);
|
||||
export const PromptChatMessageListSchema = z.array(PromptChatMessageSchema);
|
||||
|
||||
export type PromptVariable = { name: string; value: string; isUsed: boolean };
|
||||
@@ -218,11 +231,11 @@ export const SYSTEM_ROLES: string[] = [
|
||||
ChatMessageRole.Developer,
|
||||
];
|
||||
|
||||
export const TextPromptSchema = z.string().min(1, "Enter a prompt");
|
||||
export const TextPromptContentSchema = z.string().min(1, "Enter a prompt");
|
||||
|
||||
export const PromptContentSchema = z.union([
|
||||
PromptChatMessageListSchema,
|
||||
TextPromptSchema,
|
||||
TextPromptContentSchema,
|
||||
]);
|
||||
export type PromptContent = z.infer<typeof PromptContentSchema>;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const clickhouseSearchCondition = (
|
||||
|
||||
const conditions = [
|
||||
!searchType || searchType.includes("id")
|
||||
? `${prefix}id ILIKE {searchString: String} OR ${prefix}user_id ILIKE {searchString: String} OR ${prefix}name ILIKE {searchString: String}`
|
||||
? `${prefix}id ILIKE {searchString: String} OR user_id ILIKE {searchString: String} OR ${prefix}name ILIKE {searchString: String}`
|
||||
: null,
|
||||
searchType && searchType.includes("content")
|
||||
? `${prefix}input ILIKE {searchString: String} OR ${prefix}output ILIKE {searchString: String}`
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function upsertClickhouse<
|
||||
>(opts: {
|
||||
table: "scores" | "traces" | "observations";
|
||||
records: T[];
|
||||
eventBodyMapper: (body: T) => Record<string, unknown>;
|
||||
eventBodyMapper: (body: T) => Record<string, unknown>; // eslint-disable-line no-unused-vars
|
||||
tags?: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
return await instrumentAsync(
|
||||
|
||||
@@ -122,6 +122,43 @@ export const traceRecordInsertSchema = traceRecordBaseSchema.extend({
|
||||
});
|
||||
export type TraceRecordInsertType = z.infer<typeof traceRecordInsertSchema>;
|
||||
|
||||
export const traceMtRecordInsertSchema = z.object({
|
||||
// Identifiers
|
||||
project_id: z.string(),
|
||||
id: z.string(),
|
||||
start_time: z.number(),
|
||||
end_time: z.number().nullish(),
|
||||
name: z.string(),
|
||||
|
||||
// Metadata properties
|
||||
metadata: z.record(z.string(), z.string()),
|
||||
user_id: z.string(),
|
||||
session_id: z.string(),
|
||||
environment: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
version: z.string().nullish(),
|
||||
release: z.string().nullish(),
|
||||
|
||||
// UI properties - nullable to prevent absent values being interpreted as overwrites
|
||||
bookmarked: z.boolean().nullish(),
|
||||
public: z.boolean().nullish(),
|
||||
|
||||
// Aggregations
|
||||
observation_ids: z.array(z.string()),
|
||||
score_ids: z.array(z.string()),
|
||||
cost_details: z.record(z.string(), z.number()),
|
||||
usage_details: z.record(z.string(), z.number()),
|
||||
|
||||
// Input/Output
|
||||
input: z.string(),
|
||||
output: z.string(),
|
||||
|
||||
created_at: z.number(),
|
||||
updated_at: z.number(),
|
||||
event_ts: z.number(),
|
||||
});
|
||||
export type TraceMtRecordInsertType = z.infer<typeof traceMtRecordInsertSchema>;
|
||||
|
||||
export const scoreRecordBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
project_id: z.string(),
|
||||
|
||||
@@ -687,7 +687,11 @@ const getObservationsTableInternal = async <T>(
|
||||
const appliedScoresFilter = scoresFilter.apply();
|
||||
const appliedObservationsFilter = observationsFilter.apply();
|
||||
|
||||
const search = clickhouseSearchCondition(opts.searchQuery, opts.searchType, "o");
|
||||
const search = clickhouseSearchCondition(
|
||||
opts.searchQuery,
|
||||
opts.searchType,
|
||||
"o",
|
||||
);
|
||||
|
||||
const scoresCte = `WITH scores_agg AS (
|
||||
SELECT
|
||||
@@ -1494,6 +1498,7 @@ export const getGenerationsForPostHog = async function* (
|
||||
o.provided_model_name as model,
|
||||
o.level as level,
|
||||
o.version as version,
|
||||
o.environment as environment,
|
||||
t.id as trace_id,
|
||||
t.name as trace_name,
|
||||
t.session_id as trace_session_id,
|
||||
@@ -1556,6 +1561,7 @@ export const getGenerationsForPostHog = async function* (
|
||||
langfuse_model: record.model,
|
||||
langfuse_level: record.level,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_environment: record.environment,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { _handleGetScoreById, _handleGetScoresByIds } from "./scores-utils";
|
||||
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { recordDistribution } from "../instrumentation";
|
||||
import { prisma } from "../../db";
|
||||
|
||||
export const searchExistingAnnotationScore = async (
|
||||
projectId: string,
|
||||
@@ -622,7 +623,7 @@ export const getCategoricalScoresGroupedByName = async (
|
||||
: undefined;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
SELECT
|
||||
name AS label,
|
||||
groupArray(DISTINCT string_value) AS values
|
||||
FROM scores s
|
||||
@@ -651,7 +652,58 @@ export const getCategoricalScoresGroupedByName = async (
|
||||
},
|
||||
});
|
||||
|
||||
return rows;
|
||||
// Get score names from ClickHouse results to query score configs
|
||||
const scoreNames = rows.map((row) => row.label);
|
||||
|
||||
// Query score_configs table for categorical configurations
|
||||
const scoreConfigs =
|
||||
scoreNames.length > 0
|
||||
? await prisma.scoreConfig.findMany({
|
||||
where: {
|
||||
projectId: projectId,
|
||||
name: {
|
||||
in: scoreNames,
|
||||
},
|
||||
dataType: "CATEGORICAL",
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
categories: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
// Create a map of score configs for easy lookup
|
||||
const configMap = new Map(
|
||||
scoreConfigs.map((config) => [config.name, config.categories]),
|
||||
);
|
||||
|
||||
// Enhance the results with all possible category values from score configs
|
||||
return rows.map((row) => {
|
||||
const configCategories = configMap.get(row.label);
|
||||
|
||||
if (configCategories && Array.isArray(configCategories)) {
|
||||
// Extract all possible category labels from the score config
|
||||
const allPossibleValues = (
|
||||
configCategories as Array<{ label: string; value: number }>
|
||||
).map((category) => category.label);
|
||||
|
||||
// Merge actual values from ClickHouse with all possible values from config
|
||||
// Use Set to ensure uniqueness
|
||||
const mergedValues = Array.from(
|
||||
new Set([...row.values, ...allPossibleValues]),
|
||||
);
|
||||
|
||||
return {
|
||||
...row,
|
||||
values: mergedValues,
|
||||
};
|
||||
}
|
||||
|
||||
// If no config found, return original values
|
||||
return row;
|
||||
});
|
||||
};
|
||||
|
||||
export const getScoresUiCount = async (props: {
|
||||
@@ -1284,6 +1336,7 @@ export const getScoresForPostHog = async function* (
|
||||
s.name as name,
|
||||
s.value as value,
|
||||
s.comment as comment,
|
||||
s.environment as environment,
|
||||
t.name as trace_name,
|
||||
t.session_id as trace_session_id,
|
||||
t.user_id as trace_user_id,
|
||||
@@ -1336,6 +1389,7 @@ export const getScoresForPostHog = async function* (
|
||||
langfuse_user_id: record.trace_user_id || "langfuse_unknown_user",
|
||||
langfuse_release: record.trace_release,
|
||||
langfuse_tags: record.trace_tags,
|
||||
langfuse_environment: record.environment,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
|
||||
@@ -957,6 +957,7 @@ export const getTracesForPostHog = async function* (
|
||||
t.release as release,
|
||||
t.version as version,
|
||||
t.tags as tags,
|
||||
t.environment as environment,
|
||||
t.metadata['$posthog_session_id'] as posthog_session_id,
|
||||
o.total_cost as total_cost,
|
||||
o.latency_milliseconds / 1000 as latency,
|
||||
@@ -1006,6 +1007,7 @@ export const getTracesForPostHog = async function* (
|
||||
langfuse_release: record.release,
|
||||
langfuse_version: record.version,
|
||||
langfuse_tags: record.tags,
|
||||
langfuse_environment: record.environment,
|
||||
langfuse_event_version: "1.0.0",
|
||||
$session_id: record.posthog_session_id ?? null,
|
||||
$set: {
|
||||
|
||||
@@ -33,6 +33,10 @@ export const HistogramChartConfig = BaseTotalValueChartConfig.extend({
|
||||
bins: z.number().int().min(1).max(100).optional().default(10),
|
||||
});
|
||||
|
||||
export const PivotTableChartConfig = BaseTotalValueChartConfig.extend({
|
||||
type: z.literal("PIVOT_TABLE"),
|
||||
});
|
||||
|
||||
// Define dimension schema
|
||||
export const DimensionSchema = z.object({
|
||||
field: z.string(),
|
||||
@@ -53,6 +57,7 @@ export const ChartConfigSchema = z.discriminatedUnion("type", [
|
||||
PieChartConfig,
|
||||
BigNumberChartConfig,
|
||||
HistogramChartConfig,
|
||||
PivotTableChartConfig,
|
||||
]);
|
||||
|
||||
export const DashboardDefinitionWidgetWidgetSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import { FilterCondition, FilterState } from "../../types";
|
||||
import { logger } from "../logger";
|
||||
|
||||
export class InMemoryFilterService {
|
||||
/**
|
||||
* Evaluates whether a data object matches the given filter conditions.
|
||||
*
|
||||
* @param data - The data object to evaluate
|
||||
* @param filter - The filter conditions to apply
|
||||
* @param fieldMapper - Function to map filter column names to data object values
|
||||
* @returns true if the data matches all filter conditions, false otherwise
|
||||
*/
|
||||
static evaluateFilter<T>(
|
||||
data: T,
|
||||
filter: FilterState,
|
||||
fieldMapper: (data: T, column: string) => unknown,
|
||||
): boolean {
|
||||
try {
|
||||
// If no filters, data matches
|
||||
if (!filter || filter.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Evaluate each filter condition
|
||||
for (const condition of filter) {
|
||||
if (!this.evaluateFilterCondition(data, condition, fieldMapper)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Error evaluating filter in memory", {
|
||||
error,
|
||||
filterCount: filter?.length || 0,
|
||||
});
|
||||
// On error, return false to be safe (filter doesn't match)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a single filter condition against a data object.
|
||||
*/
|
||||
private static evaluateFilterCondition<T>(
|
||||
data: T,
|
||||
condition: FilterCondition,
|
||||
fieldMapper: (data: T, column: string) => unknown,
|
||||
): boolean {
|
||||
const { column, type, operator } = condition;
|
||||
|
||||
// Get the data field value based on the column
|
||||
const fieldValue = fieldMapper(data, column);
|
||||
|
||||
switch (type) {
|
||||
case "string":
|
||||
return this.evaluateStringFilter(fieldValue, condition.value, operator);
|
||||
case "datetime":
|
||||
return this.evaluateDateTimeFilter(
|
||||
fieldValue,
|
||||
condition.value,
|
||||
operator,
|
||||
);
|
||||
case "stringOptions":
|
||||
return this.evaluateStringOptionsFilter(
|
||||
fieldValue,
|
||||
condition.value,
|
||||
operator,
|
||||
);
|
||||
case "arrayOptions":
|
||||
return this.evaluateArrayOptionsFilter(
|
||||
fieldValue,
|
||||
condition.value,
|
||||
operator,
|
||||
);
|
||||
case "number":
|
||||
return this.evaluateNumberFilter(fieldValue, condition.value, operator);
|
||||
case "boolean":
|
||||
return this.evaluateBooleanFilter(
|
||||
fieldValue,
|
||||
condition.value,
|
||||
operator,
|
||||
);
|
||||
case "categoryOptions":
|
||||
return this.evaluateCategoryOptionsFilter(
|
||||
fieldValue,
|
||||
condition.key,
|
||||
condition.value,
|
||||
operator,
|
||||
);
|
||||
case "stringObject":
|
||||
return this.evaluateStringObjectFilter(
|
||||
fieldValue,
|
||||
condition.key,
|
||||
condition.value,
|
||||
operator,
|
||||
);
|
||||
case "numberObject":
|
||||
return this.evaluateNumberObjectFilter(
|
||||
fieldValue,
|
||||
condition.key,
|
||||
condition.value,
|
||||
operator,
|
||||
);
|
||||
case "null":
|
||||
return this.evaluateNullFilter(fieldValue, operator);
|
||||
default:
|
||||
logger.error("Unsupported filter type for in-memory evaluation", {
|
||||
type,
|
||||
column,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateStringFilter(
|
||||
fieldValue: unknown,
|
||||
filterValue: string,
|
||||
operator: string,
|
||||
): boolean {
|
||||
const strValue = fieldValue != null ? String(fieldValue) : "";
|
||||
|
||||
switch (operator) {
|
||||
case "=":
|
||||
return strValue === filterValue;
|
||||
case "contains":
|
||||
return strValue.includes(filterValue);
|
||||
case "does not contain":
|
||||
return !strValue.includes(filterValue);
|
||||
case "starts with":
|
||||
return strValue.startsWith(filterValue);
|
||||
case "ends with":
|
||||
return strValue.endsWith(filterValue);
|
||||
default:
|
||||
logger.error("Unsupported string filter operator", {
|
||||
operator,
|
||||
filterValue,
|
||||
fieldValue: strValue,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateDateTimeFilter(
|
||||
fieldValue: unknown,
|
||||
filterValue: Date,
|
||||
operator: string,
|
||||
): boolean {
|
||||
if (!(fieldValue instanceof Date)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldTime = fieldValue.getTime();
|
||||
const filterTime = filterValue.getTime();
|
||||
|
||||
switch (operator) {
|
||||
case ">":
|
||||
return fieldTime > filterTime;
|
||||
case "<":
|
||||
return fieldTime < filterTime;
|
||||
case ">=":
|
||||
return fieldTime >= filterTime;
|
||||
case "<=":
|
||||
return fieldTime <= filterTime;
|
||||
default:
|
||||
logger.error("Unsupported datetime filter operator", {
|
||||
operator,
|
||||
filterValue,
|
||||
fieldValue,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateNumberFilter(
|
||||
fieldValue: unknown,
|
||||
filterValue: number,
|
||||
operator: string,
|
||||
): boolean {
|
||||
if (typeof fieldValue !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "=":
|
||||
return fieldValue === filterValue;
|
||||
case ">":
|
||||
return fieldValue > filterValue;
|
||||
case "<":
|
||||
return fieldValue < filterValue;
|
||||
case ">=":
|
||||
return fieldValue >= filterValue;
|
||||
case "<=":
|
||||
return fieldValue <= filterValue;
|
||||
default:
|
||||
logger.error("Unsupported number filter operator", {
|
||||
operator,
|
||||
filterValue,
|
||||
fieldValue,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateCategoryOptionsFilter(
|
||||
fieldValue: unknown,
|
||||
key: string,
|
||||
filterValues: string[],
|
||||
operator: string,
|
||||
): boolean {
|
||||
if (!fieldValue || typeof fieldValue !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type assertion is safe here since we've checked typeof fieldValue === "object" above
|
||||
const objectValue = (fieldValue as Record<string, unknown>)[key];
|
||||
const stringValue = objectValue?.toString() || "";
|
||||
|
||||
switch (operator) {
|
||||
case "any of":
|
||||
return filterValues.includes(stringValue);
|
||||
case "none of":
|
||||
return !filterValues.includes(stringValue);
|
||||
default:
|
||||
logger.error("Unsupported categoryOptions filter operator", {
|
||||
operator,
|
||||
filterValues,
|
||||
fieldValue: stringValue,
|
||||
key,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateStringOptionsFilter(
|
||||
fieldValue: unknown,
|
||||
filterValues: string[],
|
||||
operator: string,
|
||||
): boolean {
|
||||
const strValue = fieldValue ? String(fieldValue) : "";
|
||||
|
||||
switch (operator) {
|
||||
case "any of":
|
||||
return filterValues.includes(strValue);
|
||||
case "none of":
|
||||
return !filterValues.includes(strValue);
|
||||
default:
|
||||
logger.error("Unsupported stringOptions filter operator", {
|
||||
operator,
|
||||
filterValues,
|
||||
fieldValue: strValue,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateArrayOptionsFilter(
|
||||
fieldValue: unknown,
|
||||
filterValues: string[],
|
||||
operator: string,
|
||||
): boolean {
|
||||
if (!Array.isArray(fieldValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type assertion is safe here since we've checked Array.isArray above
|
||||
const arrayValue = fieldValue as unknown[];
|
||||
|
||||
switch (operator) {
|
||||
case "any of":
|
||||
return arrayValue.some((val) => filterValues.includes(String(val)));
|
||||
case "none of":
|
||||
return !arrayValue.some((val) => filterValues.includes(String(val)));
|
||||
case "all of":
|
||||
return filterValues.every((val) =>
|
||||
arrayValue.map(String).includes(val),
|
||||
);
|
||||
default:
|
||||
logger.error("Unsupported arrayOptions filter operator", {
|
||||
operator,
|
||||
filterValues,
|
||||
fieldValue,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateBooleanFilter(
|
||||
fieldValue: unknown,
|
||||
filterValue: boolean,
|
||||
operator: string,
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case "=":
|
||||
return fieldValue === filterValue;
|
||||
case "<>":
|
||||
return fieldValue !== filterValue;
|
||||
default:
|
||||
logger.error("Unsupported boolean filter operator", {
|
||||
operator,
|
||||
filterValue,
|
||||
fieldValue,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateStringObjectFilter(
|
||||
fieldValue: unknown,
|
||||
key: string,
|
||||
filterValue: string,
|
||||
operator: string,
|
||||
): boolean {
|
||||
if (!fieldValue || typeof fieldValue !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type assertion is safe here since we've checked typeof fieldValue === "object" above
|
||||
const objectValue = (fieldValue as Record<string, unknown>)[key];
|
||||
const stringValue = objectValue?.toString() || "";
|
||||
return this.evaluateStringFilter(stringValue, filterValue, operator);
|
||||
}
|
||||
|
||||
private static evaluateNumberObjectFilter(
|
||||
fieldValue: unknown,
|
||||
key: string,
|
||||
filterValue: number,
|
||||
operator: string,
|
||||
): boolean {
|
||||
if (!fieldValue || typeof fieldValue !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type assertion is safe here since we've checked typeof fieldValue === "object" above
|
||||
const objectValue = (fieldValue as Record<string, unknown>)[key];
|
||||
const numValue =
|
||||
typeof objectValue === "number"
|
||||
? objectValue
|
||||
: parseFloat(String(objectValue));
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "=":
|
||||
return numValue === filterValue;
|
||||
case ">":
|
||||
return numValue > filterValue;
|
||||
case "<":
|
||||
return numValue < filterValue;
|
||||
case ">=":
|
||||
return numValue >= filterValue;
|
||||
case "<=":
|
||||
return numValue <= filterValue;
|
||||
default:
|
||||
logger.error("Unsupported numberObject filter operator", {
|
||||
operator,
|
||||
filterValue,
|
||||
fieldValue: numValue,
|
||||
key,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static evaluateNullFilter(
|
||||
fieldValue: unknown,
|
||||
operator: string,
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case "is null":
|
||||
return fieldValue === null || fieldValue === undefined;
|
||||
case "is not null":
|
||||
return fieldValue !== null && fieldValue !== undefined;
|
||||
default:
|
||||
logger.error("Unsupported null filter operator", {
|
||||
operator,
|
||||
fieldValue,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,12 @@ export class PromptService {
|
||||
private ttlSeconds: number;
|
||||
|
||||
constructor(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis | Cluster | null,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
private metricIncrementer?: // used for otel metrics
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(name: string, value?: number) => void,
|
||||
cacheEnabled?: boolean, // used for testing
|
||||
) {
|
||||
|
||||
@@ -503,6 +503,7 @@ class S3StorageService implements StorageService {
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: this.bucketName,
|
||||
Prefix: prefix,
|
||||
MaxKeys: env.LANGFUSE_S3_LIST_MAX_KEYS,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
Generated
+73
-50
@@ -38,8 +38,8 @@ importers:
|
||||
specifier: ^19.0.3
|
||||
version: 19.0.3(@types/node@20.14.8)
|
||||
turbo:
|
||||
specifier: ^1.13.4
|
||||
version: 1.13.4
|
||||
specifier: ^2.5.4
|
||||
version: 2.5.4
|
||||
|
||||
ee:
|
||||
dependencies:
|
||||
@@ -120,8 +120,8 @@ importers:
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.0(eslint@8.57.0)
|
||||
eslint-config-turbo:
|
||||
specifier: ^1.13.4
|
||||
version: 1.13.4(eslint@8.57.0)
|
||||
specifier: ^2.5.4
|
||||
version: 2.5.4(eslint@8.57.0)(turbo@2.5.4)
|
||||
eslint-plugin-only-warn:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
@@ -230,8 +230,8 @@ importers:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
lossless-json:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
next-auth:
|
||||
specifier: ^4.24.11
|
||||
version: 4.24.11(patch_hash=cczsf6i6qe2m2htirvgxjtoclu)(next@14.2.30(@babel/core@7.24.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nodemailer@6.9.15)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -7207,8 +7207,8 @@ packages:
|
||||
resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
enhanced-resolve@5.18.1:
|
||||
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
|
||||
enhanced-resolve@5.18.2:
|
||||
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
@@ -7332,10 +7332,11 @@ packages:
|
||||
eslint-plugin-n: '^15.0.0 || ^16.0.0 '
|
||||
eslint-plugin-promise: ^6.0.0
|
||||
|
||||
eslint-config-turbo@1.13.4:
|
||||
resolution: {integrity: sha512-+we4eWdZlmlEn7LnhXHCIPX/wtujbHCS7XjQM/TN09BHNEl2fZ8id4rHfdfUKIYTSKyy8U/nNyJ0DNoZj5Q8bw==}
|
||||
eslint-config-turbo@2.5.4:
|
||||
resolution: {integrity: sha512-OpjpDLXIaus0N/Y+pMj17K430xjpd6WTo0xPUESqYZ9BkMngv2n0ZdjktgJTbJVnDmK7gHrXgJAljtdIMcYBIg==}
|
||||
peerDependencies:
|
||||
eslint: '>6.6.0'
|
||||
turbo: '>2.0.0'
|
||||
|
||||
eslint-import-resolver-alias@1.1.2:
|
||||
resolution: {integrity: sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==}
|
||||
@@ -7476,10 +7477,11 @@ packages:
|
||||
eslint-plugin-tsdoc@0.2.17:
|
||||
resolution: {integrity: sha512-xRmVi7Zx44lOBuYqG8vzTXuL6IdGOeF9nHX17bjJ8+VE6fsxpdGem0/SBTmAwgYMKYB1WBkqRJVQ+n8GK041pA==}
|
||||
|
||||
eslint-plugin-turbo@1.13.4:
|
||||
resolution: {integrity: sha512-82GfMzrewI/DJB92Bbch239GWbGx4j1zvjk1lqb06lxIlMPnVwUHVwPbAnLfyLG3JuhLv9whxGkO/q1CL18JTg==}
|
||||
eslint-plugin-turbo@2.5.4:
|
||||
resolution: {integrity: sha512-IZsW61DFj5mLMMaCJxhh1VE4HvNhfdnHnAaXajgne+LUzdyHk2NvYT0ECSa/1SssArcqgTvV74MrLL68hWLLFw==}
|
||||
peerDependencies:
|
||||
eslint: '>6.6.0'
|
||||
turbo: '>2.0.0'
|
||||
|
||||
eslint-plugin-unicorn@51.0.1:
|
||||
resolution: {integrity: sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==}
|
||||
@@ -9092,8 +9094,8 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lossless-json@4.0.2:
|
||||
resolution: {integrity: sha512-+z0EaLi2UcWi8MZRxA5iTb6m4Ys4E80uftGY+yG5KNFJb5EceQXOhdW/pWJZ8m97s26u7yZZAYMcKWNztSZssA==}
|
||||
lossless-json@4.1.1:
|
||||
resolution: {integrity: sha512-HusN80C0ohtT9kOHQH7EuUaqzRQsnekpa+2ot8OzvW0iC08dq/YtM/7uKwwajldQsCrHyC8q9fz3t3L+TmDltA==}
|
||||
|
||||
loupe@3.1.1:
|
||||
resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==}
|
||||
@@ -11313,6 +11315,11 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
terser@5.43.1:
|
||||
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
test-exclude@6.0.0:
|
||||
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -11485,38 +11492,38 @@ packages:
|
||||
ttl-set@1.0.0:
|
||||
resolution: {integrity: sha512-2fuHn/UR+8Z9HK49r97+p2Ru1b5Eewg2QqPrU14BVCQ9QoyU3+vLLZk2WEiyZ9sgJh6W8G1cZr9I2NBLywAHrA==}
|
||||
|
||||
turbo-darwin-64@1.13.4:
|
||||
resolution: {integrity: sha512-A0eKd73R7CGnRinTiS7txkMElg+R5rKFp9HV7baDiEL4xTG1FIg/56Vm7A5RVgg8UNgG2qNnrfatJtb+dRmNdw==}
|
||||
turbo-darwin-64@2.5.4:
|
||||
resolution: {integrity: sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
turbo-darwin-arm64@1.13.4:
|
||||
resolution: {integrity: sha512-eG769Q0NF6/Vyjsr3mKCnkG/eW6dKMBZk6dxWOdrHfrg6QgfkBUk0WUUujzdtVPiUIvsh4l46vQrNVd9EOtbyA==}
|
||||
turbo-darwin-arm64@2.5.4:
|
||||
resolution: {integrity: sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
turbo-linux-64@1.13.4:
|
||||
resolution: {integrity: sha512-Bq0JphDeNw3XEi+Xb/e4xoKhs1DHN7OoLVUbTIQz+gazYjigVZvtwCvgrZI7eW9Xo1eOXM2zw2u1DGLLUfmGkQ==}
|
||||
turbo-linux-64@2.5.4:
|
||||
resolution: {integrity: sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
turbo-linux-arm64@1.13.4:
|
||||
resolution: {integrity: sha512-BJcXw1DDiHO/okYbaNdcWN6szjXyHWx9d460v6fCHY65G8CyqGU3y2uUTPK89o8lq/b2C8NK0yZD+Vp0f9VoIg==}
|
||||
turbo-linux-arm64@2.5.4:
|
||||
resolution: {integrity: sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
turbo-windows-64@1.13.4:
|
||||
resolution: {integrity: sha512-OFFhXHOFLN7A78vD/dlVuuSSVEB3s9ZBj18Tm1hk3aW1HTWTuAw0ReN6ZNlVObZUHvGy8d57OAGGxf2bT3etQw==}
|
||||
turbo-windows-64@2.5.4:
|
||||
resolution: {integrity: sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
turbo-windows-arm64@1.13.4:
|
||||
resolution: {integrity: sha512-u5A+VOKHswJJmJ8o8rcilBfU5U3Y1TTAfP9wX8bFh8teYF1ghP0EhtMRLjhtp6RPa+XCxHHVA2CiC3gbh5eg5g==}
|
||||
turbo-windows-arm64@2.5.4:
|
||||
resolution: {integrity: sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
turbo@1.13.4:
|
||||
resolution: {integrity: sha512-1q7+9UJABuBAHrcC4Sxp5lOqYS5mvxRrwa33wpIyM18hlOCpRD/fTJNxZ0vhbMcJmz15o9kkVm743mPn7p6jpQ==}
|
||||
turbo@2.5.4:
|
||||
resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==}
|
||||
hasBin: true
|
||||
|
||||
type-check@0.4.0:
|
||||
@@ -11922,6 +11929,10 @@ packages:
|
||||
resolution: {integrity: sha512-ykKKus8lqlgXX/1WjudpIEjqsafjOTcOJqxnAbMLAu/KCsDCJ6GBtvscewvTkrn24HsnvFwrSCbenFrhtcCsAA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
webpack-sources@3.3.3:
|
||||
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
webpack-virtual-modules@0.5.0:
|
||||
resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==}
|
||||
|
||||
@@ -20107,7 +20118,7 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.2.1
|
||||
|
||||
enhanced-resolve@5.18.1:
|
||||
enhanced-resolve@5.18.2:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.2.2
|
||||
@@ -20329,10 +20340,11 @@ snapshots:
|
||||
eslint-plugin-n: 16.6.2(eslint@8.57.0)
|
||||
eslint-plugin-promise: 6.4.0(eslint@8.57.0)
|
||||
|
||||
eslint-config-turbo@1.13.4(eslint@8.57.0):
|
||||
eslint-config-turbo@2.5.4(eslint@8.57.0)(turbo@2.5.4):
|
||||
dependencies:
|
||||
eslint: 8.57.0
|
||||
eslint-plugin-turbo: 1.13.4(eslint@8.57.0)
|
||||
eslint-plugin-turbo: 2.5.4(eslint@8.57.0)(turbo@2.5.4)
|
||||
turbo: 2.5.4
|
||||
|
||||
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)):
|
||||
dependencies:
|
||||
@@ -20577,10 +20589,11 @@ snapshots:
|
||||
'@microsoft/tsdoc': 0.14.2
|
||||
'@microsoft/tsdoc-config': 0.16.2
|
||||
|
||||
eslint-plugin-turbo@1.13.4(eslint@8.57.0):
|
||||
eslint-plugin-turbo@2.5.4(eslint@8.57.0)(turbo@2.5.4):
|
||||
dependencies:
|
||||
dotenv: 16.0.3
|
||||
eslint: 8.57.0
|
||||
turbo: 2.5.4
|
||||
|
||||
eslint-plugin-unicorn@51.0.1(eslint@8.57.0):
|
||||
dependencies:
|
||||
@@ -22178,7 +22191,7 @@ snapshots:
|
||||
|
||||
jest-worker@27.5.1:
|
||||
dependencies:
|
||||
'@types/node': 20.14.8
|
||||
'@types/node': 20.10.5
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
||||
@@ -22542,7 +22555,7 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lossless-json@4.0.2: {}
|
||||
lossless-json@4.1.1: {}
|
||||
|
||||
loupe@3.1.1:
|
||||
dependencies:
|
||||
@@ -25164,7 +25177,7 @@ snapshots:
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.2
|
||||
serialize-javascript: 6.0.2
|
||||
terser: 5.43.0
|
||||
terser: 5.43.1
|
||||
webpack: 5.97.1
|
||||
|
||||
terser@5.43.0:
|
||||
@@ -25173,6 +25186,14 @@ snapshots:
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
|
||||
terser@5.43.1:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
test-exclude@6.0.0:
|
||||
dependencies:
|
||||
@@ -25349,32 +25370,32 @@ snapshots:
|
||||
dependencies:
|
||||
fast-fifo: 1.3.2
|
||||
|
||||
turbo-darwin-64@1.13.4:
|
||||
turbo-darwin-64@2.5.4:
|
||||
optional: true
|
||||
|
||||
turbo-darwin-arm64@1.13.4:
|
||||
turbo-darwin-arm64@2.5.4:
|
||||
optional: true
|
||||
|
||||
turbo-linux-64@1.13.4:
|
||||
turbo-linux-64@2.5.4:
|
||||
optional: true
|
||||
|
||||
turbo-linux-arm64@1.13.4:
|
||||
turbo-linux-arm64@2.5.4:
|
||||
optional: true
|
||||
|
||||
turbo-windows-64@1.13.4:
|
||||
turbo-windows-64@2.5.4:
|
||||
optional: true
|
||||
|
||||
turbo-windows-arm64@1.13.4:
|
||||
turbo-windows-arm64@2.5.4:
|
||||
optional: true
|
||||
|
||||
turbo@1.13.4:
|
||||
turbo@2.5.4:
|
||||
optionalDependencies:
|
||||
turbo-darwin-64: 1.13.4
|
||||
turbo-darwin-arm64: 1.13.4
|
||||
turbo-linux-64: 1.13.4
|
||||
turbo-linux-arm64: 1.13.4
|
||||
turbo-windows-64: 1.13.4
|
||||
turbo-windows-arm64: 1.13.4
|
||||
turbo-darwin-64: 2.5.4
|
||||
turbo-darwin-arm64: 2.5.4
|
||||
turbo-linux-64: 2.5.4
|
||||
turbo-linux-arm64: 2.5.4
|
||||
turbo-windows-64: 2.5.4
|
||||
turbo-windows-arm64: 2.5.4
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
@@ -25896,6 +25917,8 @@ snapshots:
|
||||
|
||||
webpack-sources@3.3.2: {}
|
||||
|
||||
webpack-sources@3.3.3: {}
|
||||
|
||||
webpack-virtual-modules@0.5.0: {}
|
||||
|
||||
webpack@5.97.1:
|
||||
@@ -25908,7 +25931,7 @@ snapshots:
|
||||
acorn: 8.15.0
|
||||
browserslist: 4.25.0
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.18.1
|
||||
enhanced-resolve: 5.18.2
|
||||
es-module-lexer: 1.7.0
|
||||
eslint-scope: 5.1.1
|
||||
events: 3.3.0
|
||||
@@ -25922,7 +25945,7 @@ snapshots:
|
||||
tapable: 2.2.2
|
||||
terser-webpack-plugin: 5.3.14(webpack@5.97.1)
|
||||
watchpack: 2.4.4
|
||||
webpack-sources: 3.3.2
|
||||
webpack-sources: 3.3.3
|
||||
transitivePeerDependencies:
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
|
||||
+36
-10
@@ -1,13 +1,25 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDotEnv": [".env"],
|
||||
"pipeline": {
|
||||
"globalDependencies": [
|
||||
".env"
|
||||
],
|
||||
"envMode": "loose",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["db:generate", "^build"],
|
||||
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||
"dependsOn": [
|
||||
"db:generate",
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
"dist/**",
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
},
|
||||
"start": {
|
||||
"dependsOn": ["^start"]
|
||||
"dependsOn": [
|
||||
"^start"
|
||||
]
|
||||
},
|
||||
"db:migrate": {
|
||||
"cache": false
|
||||
@@ -20,27 +32,41 @@
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["db:generate", "@langfuse/shared#build"]
|
||||
"dependsOn": [
|
||||
"db:generate",
|
||||
"@langfuse/shared#build"
|
||||
]
|
||||
},
|
||||
"dev:worker": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["db:generate", "@langfuse/shared#build"]
|
||||
"dependsOn": [
|
||||
"db:generate",
|
||||
"@langfuse/shared#build"
|
||||
]
|
||||
},
|
||||
"dev:web": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": ["db:generate", "@langfuse/shared#build"]
|
||||
"dependsOn": [
|
||||
"db:generate",
|
||||
"@langfuse/shared#build"
|
||||
]
|
||||
},
|
||||
"db:generate": {
|
||||
"cache": false,
|
||||
"dependsOn": ["^db:generate"]
|
||||
"dependsOn": [
|
||||
"^db:generate"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"cache": false
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^test", "db:generate"]
|
||||
"dependsOn": [
|
||||
"^test",
|
||||
"db:generate"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} node:20-alpine3.20 AS alpine
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox ssl_client
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS base
|
||||
RUN npm install turbo@^1.13.4 --global
|
||||
RUN npm install turbo@^2.5.4 --global
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.75.0",
|
||||
"version": "3.78.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 779 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -6740,7 +6740,7 @@ components:
|
||||
prompt:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ChatMessage'
|
||||
$ref: '#/components/schemas/ChatMessageWithPlaceholders'
|
||||
config:
|
||||
nullable: true
|
||||
labels:
|
||||
@@ -6854,6 +6854,31 @@ components:
|
||||
- config
|
||||
- labels
|
||||
- tags
|
||||
ChatMessageWithPlaceholders:
|
||||
title: ChatMessageWithPlaceholders
|
||||
oneOf:
|
||||
- type: object
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- chatmessage
|
||||
- $ref: '#/components/schemas/ChatMessage'
|
||||
required:
|
||||
- type
|
||||
- type: object
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- placeholder
|
||||
- $ref: '#/components/schemas/PlaceholderMessage'
|
||||
required:
|
||||
- type
|
||||
ChatMessage:
|
||||
title: ChatMessage
|
||||
type: object
|
||||
@@ -6865,6 +6890,14 @@ components:
|
||||
required:
|
||||
- role
|
||||
- content
|
||||
PlaceholderMessage:
|
||||
title: PlaceholderMessage
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
TextPrompt:
|
||||
title: TextPrompt
|
||||
type: object
|
||||
@@ -6882,7 +6915,7 @@ components:
|
||||
prompt:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ChatMessage'
|
||||
$ref: '#/components/schemas/ChatMessageWithPlaceholders'
|
||||
required:
|
||||
- prompt
|
||||
allOf:
|
||||
|
||||
@@ -2151,7 +2151,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 \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
|
||||
"raw": "{\n \"type\": \"chat\",\n \"name\": \"example\",\n \"prompt\": [\n {\n \"type\": \"chatmessage\",\n \"role\": \"example\",\n \"content\": \"example\"\n }\n ],\n \"config\": \"UNKNOWN\",\n \"labels\": [\n \"example\"\n ],\n \"tags\": [\n \"example\"\n ],\n \"commitMessage\": \"example\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isEuOrUsRegionNonHipaa = process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined ? ["EU", "US"].includes(process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) : false;
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
|
||||
@@ -7,7 +9,10 @@ Sentry.init({
|
||||
|
||||
// Replay may only be enabled for the client-side
|
||||
integrations: [
|
||||
Sentry.replayIntegration(),
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: !isEuOrUsRegionNonHipaa,
|
||||
blockAllMedia: !isEuOrUsRegionNonHipaa,
|
||||
}),
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.httpClientIntegration(),
|
||||
// Sentry.debugIntegration(),
|
||||
|
||||
@@ -6,7 +6,12 @@ import { pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { createTrace, createTracesCh } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
createTrace,
|
||||
createTracesCh,
|
||||
createTraceScore,
|
||||
createScoresCh,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
describe("traces trpc", () => {
|
||||
@@ -137,4 +142,56 @@ describe("traces trpc", () => {
|
||||
expect(traceRes?.timestamp).toEqual(new Date(trace.timestamp));
|
||||
});
|
||||
});
|
||||
|
||||
describe("traces.filterOptions", () => {
|
||||
it("should include all possible categorical score values from score configs", async () => {
|
||||
// Create a trace
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
});
|
||||
await createTracesCh([trace]);
|
||||
|
||||
// Create a categorical score config with multiple possible values
|
||||
const scoreConfig = await prisma.scoreConfig.create({
|
||||
data: {
|
||||
projectId: projectId,
|
||||
name: "sentiment",
|
||||
dataType: "CATEGORICAL",
|
||||
categories: [
|
||||
{ label: "positive", value: 1 },
|
||||
{ label: "neutral", value: 0 },
|
||||
{ label: "negative", value: -1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Create only one actual score (subset of possible values)
|
||||
const score = createTraceScore({
|
||||
project_id: projectId,
|
||||
trace_id: trace.id,
|
||||
name: "sentiment",
|
||||
string_value: "custom",
|
||||
data_type: "CATEGORICAL",
|
||||
config_id: scoreConfig.id,
|
||||
});
|
||||
await createScoresCh([score]);
|
||||
|
||||
// Get filter options
|
||||
const filterOptions = await caller.traces.filterOptions({
|
||||
projectId,
|
||||
});
|
||||
|
||||
// Find the sentiment score in categorical scores
|
||||
const sentimentScore = filterOptions.score_categories.find(
|
||||
(score) => score.label === "sentiment",
|
||||
);
|
||||
|
||||
expect(sentimentScore).toBeDefined();
|
||||
expect(sentimentScore?.values).toEqual(
|
||||
expect.arrayContaining(["custom", "positive", "neutral", "negative"]),
|
||||
);
|
||||
// Should include all possible values from config, not just the actual score value
|
||||
expect(sentimentScore?.values).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { ChatMessageType, compileChatMessages, extractPlaceholderNames } from "@langfuse/shared";
|
||||
|
||||
describe("compileChatMessages", () => {
|
||||
it("should compile message placeholders with provided values", () => {
|
||||
// Simulates how message placeholders would be compiled
|
||||
// during execution (e.g., in playground or experiments)
|
||||
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "conversation_history"
|
||||
},
|
||||
{ role: "user", content: "{{user_question}}" }
|
||||
];
|
||||
|
||||
const placeholderValues = {
|
||||
conversation_history: [
|
||||
{ role: "user", content: "Hello!" },
|
||||
{ role: "assistant", content: "Hi there! How can I help you?" },
|
||||
{ role: "user", content: "What's the weather like?" }
|
||||
]
|
||||
};
|
||||
|
||||
const textVariables = {
|
||||
user_question: "Can you continue our conversation?"
|
||||
};
|
||||
|
||||
// Simulate compilation logic that would happen in playground/experiments
|
||||
const compiledMessages = compileChatMessages(
|
||||
promptTemplate,
|
||||
placeholderValues,
|
||||
textVariables
|
||||
);
|
||||
|
||||
expect(compiledMessages).toEqual([
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello!" },
|
||||
{ role: "assistant", content: "Hi there! How can I help you?" },
|
||||
{ role: "user", content: "What's the weather like?" },
|
||||
{ role: "user", content: "Can you continue our conversation?" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw error when placeholder value is missing", () => {
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "missing_placeholder"
|
||||
},
|
||||
{ role: "user", content: "Hello" }
|
||||
];
|
||||
|
||||
const placeholderValues = {};
|
||||
|
||||
expect(() => {
|
||||
compileChatMessages(promptTemplate, placeholderValues);
|
||||
}).toThrow("Missing value for message placeholder: missing_placeholder");
|
||||
});
|
||||
|
||||
it("should throw error when placeholder messages lack required properties", () => {
|
||||
const promptTemplate = [
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "invalid_messages"
|
||||
}
|
||||
];
|
||||
|
||||
// Test missing role property
|
||||
const placeholderValuesNoRole = {
|
||||
invalid_messages: [
|
||||
{ content: "Hello" }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
compileChatMessages(promptTemplate, placeholderValuesNoRole);
|
||||
}).toThrow("Invalid message format in placeholder 'invalid_messages': messages must have 'role' and 'content' properties");
|
||||
|
||||
// Test missing content property
|
||||
const placeholderValuesNoContent = {
|
||||
invalid_messages: [
|
||||
{ role: "user" }
|
||||
]
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
compileChatMessages(promptTemplate, placeholderValuesNoContent);
|
||||
}).toThrow("Invalid message format in placeholder 'invalid_messages': messages must have 'role' and 'content' properties");
|
||||
});
|
||||
|
||||
it("should compile placeholders without applying text substitutions when no variables provided", () => {
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "You are a helpful assistant. {{system_var}}" },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "history"
|
||||
},
|
||||
{ role: "user", content: "{{user_var}}" }
|
||||
];
|
||||
|
||||
const placeholderValues = {
|
||||
history: [
|
||||
{ role: "user", content: "Previous message with {{var}}" },
|
||||
{ role: "assistant", content: "Response with {{another_var}}" }
|
||||
]
|
||||
};
|
||||
|
||||
// No text variables provided
|
||||
const compiledMessages = compileChatMessages(promptTemplate, placeholderValues);
|
||||
|
||||
expect(compiledMessages).toEqual([
|
||||
{ role: "system", content: "You are a helpful assistant. {{system_var}}" },
|
||||
{ role: "user", content: "Previous message with {{var}}" },
|
||||
{ role: "assistant", content: "Response with {{another_var}}" },
|
||||
{ role: "user", content: "{{user_var}}" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract all placeholder names from messages", () => {
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "System message" },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "history"
|
||||
},
|
||||
{ role: "user", content: "User message" },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "context"
|
||||
}
|
||||
];
|
||||
|
||||
const placeholderNames = extractPlaceholderNames(promptTemplate);
|
||||
|
||||
expect(placeholderNames).toEqual(["history", "context"]);
|
||||
});
|
||||
|
||||
it("should return empty array when no placeholders exist", () => {
|
||||
const promptTemplate = [
|
||||
{ role: "system", content: "System message" },
|
||||
{ role: "user", content: "User message" }
|
||||
];
|
||||
|
||||
const placeholderNames = extractPlaceholderNames(promptTemplate);
|
||||
|
||||
expect(placeholderNames).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for Pivot Table widget functionality in dashboard router
|
||||
*
|
||||
* This test suite validates the complete data pipeline for pivot table widgets:
|
||||
* - Query generation and execution through executeQuery function
|
||||
* - SQL generation by QueryBuilder for various pivot table configurations
|
||||
* - Data transformation from raw query results to pivot table structure
|
||||
* - Integration with ClickHouse database and error handling
|
||||
*
|
||||
* Test Coverage:
|
||||
* - Zero dimension pivot tables (grand total only)
|
||||
* - Single dimension pivot tables with subtotals
|
||||
* - Two dimension pivot tables with nested structure
|
||||
* - Row limiting functionality
|
||||
* - Error handling for malformed queries
|
||||
* - Integration with existing dashboard query infrastructure
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
createTrace,
|
||||
createTracesCh,
|
||||
createObservation,
|
||||
createObservationsCh,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
|
||||
import {
|
||||
transformToPivotTable,
|
||||
type DatabaseRow,
|
||||
} from "@/src/features/widgets/utils/pivot-table-utils";
|
||||
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
|
||||
|
||||
describe("Dashboard Router - Pivot Table Integration", () => {
|
||||
// Single project ID for all tests
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Time references for test data
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 3600000);
|
||||
const twoHoursAgo = new Date(now.getTime() - 7200000);
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
|
||||
|
||||
// Time ranges for queries - ISO format for query builder
|
||||
const defaultFromTime = threeDaysAgo.toISOString();
|
||||
const defaultToTime = new Date(now.getTime() + 3600000).toISOString(); // 1 hour in future
|
||||
|
||||
// Test data statistics for verification
|
||||
const testDataStats = {
|
||||
totalTraces: 0,
|
||||
environmentCounts: {} as Record<string, number>,
|
||||
modelCounts: {} as Record<string, number>,
|
||||
totalObservations: 0,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create diverse test data for pivot table testing
|
||||
const traces = [
|
||||
// Production environment traces
|
||||
...Array(6)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "chat-completion",
|
||||
environment: "production",
|
||||
timestamp: now.getTime() - i * 10000,
|
||||
user_id: `user-prod-${i}`,
|
||||
}),
|
||||
),
|
||||
|
||||
// Development environment traces
|
||||
...Array(4)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "embeddings",
|
||||
environment: "development",
|
||||
timestamp: oneHourAgo.getTime() - i * 15000,
|
||||
user_id: `user-dev-${i}`,
|
||||
}),
|
||||
),
|
||||
|
||||
// Staging environment traces
|
||||
...Array(3)
|
||||
.fill(0)
|
||||
.map((_, i) =>
|
||||
createTrace({
|
||||
project_id: projectId,
|
||||
name: "summarize",
|
||||
environment: "staging",
|
||||
timestamp: twoHoursAgo.getTime() - i * 20000,
|
||||
user_id: `user-staging-${i}`,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
// Insert traces into ClickHouse
|
||||
await createTracesCh(traces);
|
||||
|
||||
// Create observations with different models for each trace
|
||||
const observations = [];
|
||||
|
||||
// Production observations - GPT models
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const traceId = traces[i].id;
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "gpt-generation",
|
||||
type: "generation",
|
||||
environment: "production",
|
||||
start_time: now.getTime() - i * 10000,
|
||||
completion_start_time: now.getTime() - i * 10000 + 500,
|
||||
end_time: now.getTime() - i * 10000 + 2000,
|
||||
provided_model_name: i < 3 ? "gpt-4-turbo" : "gpt-3.5-turbo",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Development observations - Claude models
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const traceId = traces[6 + i].id;
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "claude-generation",
|
||||
type: "generation",
|
||||
environment: "development",
|
||||
start_time: oneHourAgo.getTime() - i * 15000,
|
||||
completion_start_time: oneHourAgo.getTime() - i * 15000 + 800,
|
||||
end_time: oneHourAgo.getTime() - i * 15000 + 3000,
|
||||
provided_model_name: i < 2 ? "claude-3-opus" : "claude-3-sonnet",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Staging observations - Mixed models
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const traceId = traces[10 + i].id;
|
||||
observations.push(
|
||||
createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
name: "mixed-generation",
|
||||
type: "generation",
|
||||
environment: "staging",
|
||||
start_time: twoHoursAgo.getTime() - i * 20000,
|
||||
completion_start_time: twoHoursAgo.getTime() - i * 20000 + 600,
|
||||
end_time: twoHoursAgo.getTime() - i * 20000 + 2500,
|
||||
provided_model_name: "gpt-4-turbo",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Insert observations into ClickHouse
|
||||
await createObservationsCh(observations);
|
||||
|
||||
// Calculate test data statistics for verification
|
||||
testDataStats.totalTraces = traces.length;
|
||||
testDataStats.totalObservations = observations.length;
|
||||
|
||||
// Count by environment
|
||||
traces.forEach((trace) => {
|
||||
testDataStats.environmentCounts[trace.environment] =
|
||||
(testDataStats.environmentCounts[trace.environment] || 0) + 1;
|
||||
});
|
||||
|
||||
// Count by model
|
||||
observations.forEach((obs) => {
|
||||
if (obs.provided_model_name) {
|
||||
testDataStats.modelCounts[obs.provided_model_name] =
|
||||
(testDataStats.modelCounts[obs.provided_model_name] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeQuery function with pivot table configurations", () => {
|
||||
it("should execute zero-dimension pivot table query (grand total only)", async () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(projectId, query);
|
||||
|
||||
// Verify basic query execution
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
|
||||
// Results should be grouped by time dimension when timeDimension is present
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Each row should have time_dimension and aggregated count
|
||||
result.forEach((row) => {
|
||||
expect(row).toHaveProperty("time_dimension");
|
||||
expect(row).toHaveProperty("count_count");
|
||||
expect(typeof row.count_count).toBe("string"); // ClickHouse returns numbers as strings
|
||||
});
|
||||
|
||||
// Sum all counts to verify total
|
||||
const totalCount = result.reduce(
|
||||
(sum, row) => sum + parseInt(row.count_count as string),
|
||||
0,
|
||||
);
|
||||
expect(totalCount).toBe(testDataStats.totalTraces);
|
||||
});
|
||||
|
||||
it("should execute single-dimension pivot table query with environment grouping", async () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "environment" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [{ field: "environment", direction: "asc" }],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(projectId, query);
|
||||
|
||||
// Verify query execution
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
|
||||
// Should have one row per environment per time dimension
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify structure of results
|
||||
result.forEach((row) => {
|
||||
expect(row).toHaveProperty("environment");
|
||||
expect(row).toHaveProperty("time_dimension");
|
||||
expect(row).toHaveProperty("count_count");
|
||||
expect(typeof row.environment).toBe("string");
|
||||
expect(typeof row.count_count).toBe("string"); // ClickHouse returns numbers as strings
|
||||
});
|
||||
|
||||
// Verify data accuracy by checking environment counts
|
||||
const environmentTotals = result.reduce(
|
||||
(acc, row) => {
|
||||
const env = row.environment as string;
|
||||
acc[env] =
|
||||
((acc[env] as number) || 0) + parseInt(row.count_count as string);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
expect(environmentTotals["production"]).toBe(
|
||||
testDataStats.environmentCounts["production"],
|
||||
);
|
||||
expect(environmentTotals["development"]).toBe(
|
||||
testDataStats.environmentCounts["development"],
|
||||
);
|
||||
expect(environmentTotals["staging"]).toBe(
|
||||
testDataStats.environmentCounts["staging"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should execute two-dimension pivot table query with environment and model grouping", async () => {
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
|
||||
metrics: [
|
||||
{ measure: "count", aggregation: "count" },
|
||||
{ measure: "totalTokens", aggregation: "sum" },
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{ field: "environment", direction: "asc" },
|
||||
{ field: "providedModelName", direction: "asc" },
|
||||
],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(projectId, query);
|
||||
|
||||
// Verify query execution
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have combinations of environment and model
|
||||
result.forEach((row) => {
|
||||
expect(row).toHaveProperty("environment");
|
||||
expect(row).toHaveProperty("providedModelName");
|
||||
expect(row).toHaveProperty("time_dimension");
|
||||
expect(row).toHaveProperty("count_count");
|
||||
expect(row).toHaveProperty("sum_totalTokens");
|
||||
|
||||
expect(typeof row.environment).toBe("string");
|
||||
expect(typeof row.providedModelName).toBe("string");
|
||||
expect(typeof row.count_count).toBe("string");
|
||||
expect(typeof row.sum_totalTokens).toBe("string");
|
||||
});
|
||||
|
||||
// Verify total observation count matches test data
|
||||
const totalObservations = result.reduce(
|
||||
(sum, row) => sum + parseInt(row.count_count as string),
|
||||
0,
|
||||
);
|
||||
expect(totalObservations).toBe(testDataStats.totalObservations);
|
||||
});
|
||||
|
||||
it("should handle empty results gracefully", async () => {
|
||||
const futureTime = new Date(now.getTime() + 86400000).toISOString(); // 1 day in future
|
||||
const farFutureTime = new Date(now.getTime() + 172800000).toISOString(); // 2 days in future
|
||||
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "environment" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: futureTime,
|
||||
toTimestamp: farFutureTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(projectId, query);
|
||||
|
||||
// Should handle empty results without errors
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
// Time dimension queries can return rows with 0 counts for time filling
|
||||
expect(result.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query Builder integration with pivot table configurations", () => {
|
||||
it("should generate correct SQL for zero-dimension pivot table", () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder(query.chartConfig);
|
||||
const { query: sql, parameters } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify SQL generation
|
||||
expect(sql).toBeDefined();
|
||||
expect(typeof sql).toBe("string");
|
||||
expect(parameters).toBeDefined();
|
||||
expect(typeof parameters).toBe("object");
|
||||
|
||||
// SQL should contain GROUP BY for time dimension when timeDimension is present
|
||||
expect(sql.toLowerCase()).toContain("group by");
|
||||
|
||||
// Should contain aggregation
|
||||
expect(sql.toLowerCase()).toContain("count(");
|
||||
});
|
||||
|
||||
it("should generate correct SQL for single-dimension pivot table", () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "environment" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [{ field: "environment", direction: "asc" }],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder(query.chartConfig);
|
||||
const { query: sql, parameters } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify SQL generation
|
||||
expect(sql).toBeDefined();
|
||||
expect(typeof sql).toBe("string");
|
||||
expect(parameters).toBeDefined();
|
||||
|
||||
// SQL should contain GROUP BY for dimension
|
||||
expect(sql.toLowerCase()).toContain("group by");
|
||||
expect(sql.toLowerCase()).toContain("environment");
|
||||
|
||||
// Should contain ORDER BY
|
||||
expect(sql.toLowerCase()).toContain("order by");
|
||||
});
|
||||
|
||||
it("should generate correct SQL for two-dimension pivot table", () => {
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
|
||||
metrics: [
|
||||
{ measure: "count", aggregation: "count" },
|
||||
{ measure: "totalTokens", aggregation: "sum" },
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{ field: "environment", direction: "asc" },
|
||||
{ field: "providedModelName", direction: "asc" },
|
||||
],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder(query.chartConfig);
|
||||
const { query: sql, parameters } = queryBuilder.build(query, projectId);
|
||||
|
||||
// Verify SQL generation
|
||||
expect(sql).toBeDefined();
|
||||
expect(typeof sql).toBe("string");
|
||||
expect(parameters).toBeDefined();
|
||||
|
||||
// SQL should contain GROUP BY for both dimensions
|
||||
expect(sql.toLowerCase()).toContain("group by");
|
||||
expect(sql.toLowerCase()).toContain("environment");
|
||||
expect(sql.toLowerCase()).toContain("providedmodelname");
|
||||
|
||||
// Should contain multiple aggregations
|
||||
expect(sql.toLowerCase()).toContain("count(");
|
||||
expect(sql.toLowerCase()).toContain("sum(");
|
||||
|
||||
// Should contain ORDER BY for both dimensions
|
||||
expect(sql.toLowerCase()).toContain("order by");
|
||||
});
|
||||
});
|
||||
|
||||
describe("End-to-end pivot table data transformation", () => {
|
||||
it("should transform query results to pivot table structure for zero dimensions", async () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null, // No time dimension for simpler test
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// Execute query to get raw data
|
||||
const rawData = await executeQuery(projectId, query);
|
||||
|
||||
// Transform to pivot table structure
|
||||
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
|
||||
dimensions: [],
|
||||
metrics: ["count_count"],
|
||||
rowLimit: 20,
|
||||
});
|
||||
|
||||
// Verify transformation
|
||||
expect(pivotTableData).toBeDefined();
|
||||
expect(Array.isArray(pivotTableData)).toBe(true);
|
||||
expect(pivotTableData.length).toBe(1); // Should have only grand total
|
||||
|
||||
const totalRow = pivotTableData[0];
|
||||
expect(totalRow.type).toBe("total");
|
||||
expect(totalRow.level).toBe(0);
|
||||
expect(totalRow.label).toBe("Total");
|
||||
expect(totalRow.isTotal).toBe(true);
|
||||
expect(totalRow.values).toHaveProperty("count_count");
|
||||
});
|
||||
|
||||
it("should transform query results to pivot table structure for single dimension", async () => {
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "environment" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null, // No time dimension for simpler test
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [{ field: "environment", direction: "asc" }],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// Execute query to get raw data
|
||||
const rawData = await executeQuery(projectId, query);
|
||||
|
||||
// Transform to pivot table structure
|
||||
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
|
||||
dimensions: ["environment"],
|
||||
metrics: ["count_count"],
|
||||
rowLimit: 20,
|
||||
});
|
||||
|
||||
// Verify transformation
|
||||
expect(pivotTableData).toBeDefined();
|
||||
expect(Array.isArray(pivotTableData)).toBe(true);
|
||||
expect(pivotTableData.length).toBeGreaterThan(1); // Should have data rows + total
|
||||
|
||||
// Should have data rows for each environment
|
||||
const dataRows = pivotTableData.filter((row) => row.type === "data");
|
||||
const totalRow = pivotTableData.find((row) => row.type === "total");
|
||||
|
||||
expect(dataRows.length).toBeGreaterThan(0);
|
||||
expect(totalRow).toBeDefined();
|
||||
expect(totalRow!.isTotal).toBe(true);
|
||||
|
||||
// Verify data row structure
|
||||
dataRows.forEach((row) => {
|
||||
expect(row.type).toBe("data");
|
||||
expect(row.level).toBe(0);
|
||||
expect(row.values).toHaveProperty("count_count");
|
||||
expect(typeof row.values.count_count).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform query results to pivot table structure for two dimensions", async () => {
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "environment" }, { field: "providedModelName" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: null, // No time dimension for simpler test
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: [
|
||||
{ field: "environment", direction: "asc" },
|
||||
{ field: "providedModelName", direction: "asc" },
|
||||
],
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// Execute query to get raw data
|
||||
const rawData = await executeQuery(projectId, query);
|
||||
|
||||
// Transform to pivot table structure
|
||||
const pivotTableData = transformToPivotTable(rawData as DatabaseRow[], {
|
||||
dimensions: ["environment", "providedModelName"],
|
||||
metrics: ["count_count"],
|
||||
rowLimit: 20,
|
||||
});
|
||||
|
||||
// Verify transformation
|
||||
expect(pivotTableData).toBeDefined();
|
||||
expect(Array.isArray(pivotTableData)).toBe(true);
|
||||
expect(pivotTableData.length).toBeGreaterThan(1);
|
||||
|
||||
// Should have nested structure with data rows, subtotals, and grand total
|
||||
const dataRows = pivotTableData.filter((row) => row.type === "data");
|
||||
const subtotalRows = pivotTableData.filter(
|
||||
(row) => row.type === "subtotal",
|
||||
);
|
||||
const totalRow = pivotTableData.find((row) => row.type === "total");
|
||||
|
||||
expect(dataRows.length).toBeGreaterThan(0);
|
||||
expect(totalRow).toBeDefined();
|
||||
|
||||
// Verify indentation levels
|
||||
dataRows.forEach((row) => {
|
||||
expect(row.level).toBe(1); // Second level for two dimensions
|
||||
expect(row.values).toHaveProperty("count_count");
|
||||
});
|
||||
|
||||
if (subtotalRows.length > 0) {
|
||||
subtotalRows.forEach((row) => {
|
||||
expect(row.level).toBe(0); // First level subtotals
|
||||
expect(row.isSubtotal).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
expect(totalRow!.level).toBe(0);
|
||||
expect(totalRow!.isTotal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling and edge cases", () => {
|
||||
it("should handle empty results gracefully in transformation", async () => {
|
||||
// Transform empty data to pivot table structure
|
||||
const pivotTableData = transformToPivotTable([], {
|
||||
dimensions: ["environment"],
|
||||
metrics: ["count_count"],
|
||||
rowLimit: 20,
|
||||
});
|
||||
|
||||
// Should handle empty data gracefully
|
||||
expect(pivotTableData).toBeDefined();
|
||||
expect(Array.isArray(pivotTableData)).toBe(true);
|
||||
|
||||
// Should still have total row with zero values
|
||||
expect(pivotTableData.length).toBe(1);
|
||||
const totalRow = pivotTableData[0];
|
||||
expect(totalRow.type).toBe("total");
|
||||
expect(totalRow.values.count_count).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle missing project data gracefully", async () => {
|
||||
const nonExistentProjectId = randomUUID();
|
||||
|
||||
const query: QueryType = {
|
||||
view: "traces",
|
||||
dimensions: [],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: defaultFromTime,
|
||||
toTimestamp: defaultToTime,
|
||||
orderBy: null,
|
||||
chartConfig: {
|
||||
type: "PIVOT_TABLE",
|
||||
row_limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeQuery(nonExistentProjectId, query);
|
||||
|
||||
// Should return results even for non-existent project (time fill creates rows)
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @fileoverview Unit Tests for PivotTable React Component
|
||||
*
|
||||
* Comprehensive test suite for the PivotTable component functionality including:
|
||||
* - Component rendering with various data scenarios
|
||||
* - Proper styling and CSS class application
|
||||
* - Indentation behavior for nested dimensions
|
||||
* - Empty data and error state handling
|
||||
* - Metric value formatting and display
|
||||
* - Column header formatting
|
||||
*
|
||||
* Uses Jest and React Testing Library for component testing.
|
||||
* This test focuses on component behavior rather than data transformation logic.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
import {
|
||||
PivotTable,
|
||||
type PivotTableProps,
|
||||
} from "@/src/features/widgets/chart-library/PivotTable";
|
||||
import { type DataPoint } from "@/src/features/widgets/chart-library/chart-props";
|
||||
|
||||
describe("PivotTable Component", () => {
|
||||
describe("Basic Rendering", () => {
|
||||
test("renders table with simple data", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "gpt-4",
|
||||
metric: 100,
|
||||
},
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "gpt-3.5",
|
||||
metric: 75,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Check table structure exists
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
// Should have dimension column and metric column
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("displays data when no configuration provided", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 50,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Should still render a table
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column Header Formatting", () => {
|
||||
test("formats single dimension header correctly", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model_name"],
|
||||
metrics: ["request_count"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Model Name" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Request Count" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("formats multiple dimension headers correctly", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model_name", "time_period"],
|
||||
metrics: ["request_count", "avg_duration"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Model Name / Time Period" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Request Count" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Avg Duration" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("defaults to 'Dimension' when no dimensions configured", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: [],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "Dimension" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty Data Handling", () => {
|
||||
test("displays 'No data available' for empty data array", () => {
|
||||
const props: PivotTableProps = {
|
||||
data: [],
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(screen.getByText("No data available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays 'No data available' for null data", () => {
|
||||
const props: PivotTableProps = {
|
||||
data: null as any,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(screen.getByText("No data available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays 'No data available' for undefined data", () => {
|
||||
const props: PivotTableProps = {
|
||||
data: undefined as any,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
expect(screen.getByText("No data available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Responsive Design", () => {
|
||||
test("includes overflow-auto class for responsive scrolling", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<PivotTable {...props} />);
|
||||
|
||||
const pivotTableContainer = container.firstChild as HTMLElement;
|
||||
expect(pivotTableContainer).toHaveClass("h-full", "overflow-auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
test("provides proper table structure for screen readers", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "gpt-4",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Check table has proper semantic structure
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
|
||||
// Should have at least header row
|
||||
expect(screen.getAllByRole("row").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("provides proper cell associations", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Header cells should be properly associated
|
||||
const headers = screen.getAllByRole("columnheader");
|
||||
expect(headers.length).toBeGreaterThan(0);
|
||||
|
||||
// Each header should be part of a row
|
||||
headers.forEach((header) => {
|
||||
expect(header.closest("tr")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Component Props", () => {
|
||||
test("handles missing config gracefully", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
// No config provided
|
||||
};
|
||||
|
||||
render(<PivotTable {...props} />);
|
||||
|
||||
// Should still render a table
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes accessibilityLayer prop correctly", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test",
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
accessibilityLayer: true,
|
||||
};
|
||||
|
||||
// Should render without error
|
||||
expect(() => render(<PivotTable {...props} />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Processing Integration", () => {
|
||||
test("handles various metric data types", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test1",
|
||||
metric: 100, // number
|
||||
},
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: "test2",
|
||||
metric: [
|
||||
[10, 20],
|
||||
[30, 40],
|
||||
], // nested array
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
// Should render without error
|
||||
expect(() => render(<PivotTable {...props} />)).not.toThrow();
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles missing dimension data", () => {
|
||||
const data: DataPoint[] = [
|
||||
{
|
||||
time_dimension: "2024-01-01",
|
||||
dimension: undefined, // missing dimension
|
||||
metric: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const props: PivotTableProps = {
|
||||
data,
|
||||
config: {
|
||||
dimensions: ["model"],
|
||||
metrics: ["metric"],
|
||||
},
|
||||
};
|
||||
|
||||
// Should render without error
|
||||
expect(() => render(<PivotTable {...props} />)).not.toThrow();
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
LegacyPromptSchema,
|
||||
PromptType,
|
||||
type LegacyValidatedPrompt,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
} from "@langfuse/shared";
|
||||
import { getObservationById } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("/api/public/prompts API Endpoint", () => {
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { makeAPICall, pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { v4 as uuidv4, v4 } from "uuid";
|
||||
import { type Prompt } from "@langfuse/shared";
|
||||
import {
|
||||
PromptSchema,
|
||||
PromptType,
|
||||
type ValidatedPrompt,
|
||||
} from "@/src/features/prompts/server/utils/validation";
|
||||
type ChatMessage,
|
||||
type Prompt,
|
||||
} from "@langfuse/shared";
|
||||
import { parsePromptDependencyTags } from "@langfuse/shared";
|
||||
import { nanoid } from "ai";
|
||||
import { generateId, nanoid } from "ai";
|
||||
|
||||
import { type PromptsMetaResponse } from "@/src/features/prompts/server/actions/getPromptsMeta";
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
getObservationById,
|
||||
MAX_PROMPT_NESTING_DEPTH,
|
||||
ChatMessageType,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -452,6 +454,52 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
expect(validatedPrompt.commitMessage).toBe("chore: setup initial prompt");
|
||||
});
|
||||
|
||||
it("should create and fetch a chat prompt with message placeholders", async () => {
|
||||
const promptName = `prompt-name-message-placeholders${generateId()}`;
|
||||
const commitMessage = "feat: add message placeholders support";
|
||||
const chatMessages = [
|
||||
{ role: "system", content: "You are a helpful assistant with conversation context." },
|
||||
{
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "conversation_history"
|
||||
},
|
||||
{ role: "user", content: "{{user_question}}" }
|
||||
];
|
||||
|
||||
const response = await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: chatMessages,
|
||||
type: "chat",
|
||||
labels: ["production"],
|
||||
commitMessage: commitMessage
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const { body: fetchedPrompt } = await makeAPICall(
|
||||
"GET",
|
||||
`${baseURI}/${promptName}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const validatedPrompt = validatePrompt(fetchedPrompt);
|
||||
|
||||
expect(validatedPrompt.name).toBe(promptName);
|
||||
expect(validatedPrompt.prompt).toEqual(chatMessages);
|
||||
expect(validatedPrompt.type).toBe("chat");
|
||||
expect(validatedPrompt.version).toBe(1);
|
||||
expect(validatedPrompt.labels).toEqual(["production", "latest"]);
|
||||
expect(validatedPrompt.createdBy).toBe("API");
|
||||
expect(validatedPrompt.config).toEqual({});
|
||||
expect(validatedPrompt.commitMessage).toBe(commitMessage);
|
||||
|
||||
// Verify the placeholder message structure is preserved
|
||||
const messages = validatedPrompt.prompt as ChatMessage[];
|
||||
const placeholderMessage = messages[1] as { type: ChatMessageType.Placeholder; name: string };
|
||||
expect(placeholderMessage.type).toBe(ChatMessageType.Placeholder);
|
||||
expect(placeholderMessage.name).toBe("conversation_history");
|
||||
});
|
||||
|
||||
it("should fail if chat prompt has string prompt", async () => {
|
||||
const promptName = "prompt-name";
|
||||
const response = await makeAPICall("POST", baseURI, {
|
||||
@@ -705,7 +753,9 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe("Invalid request data");
|
||||
const hasExpectedMessage = JSON.stringify(response.body.error).includes(`"message":"${expectedError}"`);
|
||||
const hasExpectedMessage = JSON.stringify(response.body.error).includes(
|
||||
`"message":"${expectedError}"`,
|
||||
);
|
||||
expect(hasExpectedMessage).toBe(true);
|
||||
};
|
||||
|
||||
@@ -723,10 +773,19 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
|
||||
it("should reject invalid prompt names", async () => {
|
||||
// Test invalid patterns
|
||||
await testInvalidName("/invalid-name", "Name cannot start with a slash");
|
||||
await testInvalidName(
|
||||
"/invalid-name",
|
||||
"Name cannot start with a slash",
|
||||
);
|
||||
await testInvalidName("invalid-name/", "Name cannot end with a slash");
|
||||
await testInvalidName("invalid//name", "Name cannot contain consecutive slashes");
|
||||
await testInvalidName("invalid|name", "Prompt name cannot contain '|' character");
|
||||
await testInvalidName(
|
||||
"invalid//name",
|
||||
"Name cannot contain consecutive slashes",
|
||||
);
|
||||
await testInvalidName(
|
||||
"invalid|name",
|
||||
"Prompt name cannot contain '|' character",
|
||||
);
|
||||
await testInvalidName("new", "Prompt name cannot be 'new'");
|
||||
await testInvalidName("", "Enter a name");
|
||||
});
|
||||
@@ -848,7 +907,68 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Verify the name with slashes is preserved
|
||||
expect(validatedPrompt.name).toBe(promptName);
|
||||
expect(validatedPrompt.name).toContain("/");
|
||||
expect(validatedPrompt.prompt).toBe("This is a prompt in a folder structure");
|
||||
expect(validatedPrompt.prompt).toBe(
|
||||
"This is a prompt in a folder structure",
|
||||
);
|
||||
});
|
||||
|
||||
it("should prevent creating a prompt with both a variable and placeholder with the same name", async () => {
|
||||
const promptName = "prompt-same-name-conflict-" + nanoid();
|
||||
|
||||
// Try to create a prompt where the same name is used as both a variable and a placeholder
|
||||
const response = await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: [
|
||||
{ role: "system", content: "Hello {{userName}}" },
|
||||
{ type: ChatMessageType.Placeholder, name: "userName" },
|
||||
{ role: "user", content: "How are you?" }
|
||||
],
|
||||
type: "chat",
|
||||
});
|
||||
|
||||
// This should fail with a 400 error
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty("error");
|
||||
expect(response.body).toHaveProperty("message");
|
||||
// @ts-expect-error
|
||||
expect(response.body.message).toContain("variables and placeholders must be unique");
|
||||
// @ts-expect-error
|
||||
expect(response.body.message).toContain("userName");
|
||||
});
|
||||
|
||||
it("should allow creating a new version of a prompt with placeholder names that conflict a variable name in a previous version", async () => {
|
||||
const promptName = "prompt-with-variable-conflict-" + nanoid();
|
||||
|
||||
// First, create a chat prompt with a message variable
|
||||
const v1Response = await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: [
|
||||
{ role: "system", content: "You are a helpful {{conversationHistory}}" },
|
||||
{ role: "user", content: "Continue our conversation" }
|
||||
],
|
||||
type: "chat",
|
||||
labels: ["production"],
|
||||
});
|
||||
|
||||
expect(v1Response.status).toBe(201);
|
||||
|
||||
// Try to create a new version with a text variable that has the same name as the placeholder
|
||||
const v2Response = await makeAPICall("POST", baseURI, {
|
||||
name: promptName,
|
||||
prompt: [
|
||||
{ role: "system", content: "You are a helpful assistant with context: {{newHistory}}" },
|
||||
{ type: "placeholder", name: "conversationHistory" },
|
||||
{ role: "user", content: "Continue our conversation" }
|
||||
],
|
||||
type: "chat"
|
||||
});
|
||||
|
||||
// This should succeed, we allow cross-version name reuse
|
||||
expect(v2Response.status).toBe(201);
|
||||
expect(v2Response.body).toHaveProperty("id");
|
||||
expect(v2Response.body).toHaveProperty("version");
|
||||
// @ts-expect-error - Response body type is flexible for testing
|
||||
expect(v2Response.body.version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3427,7 +3427,9 @@ describe("queryBuilder", () => {
|
||||
};
|
||||
|
||||
// Execute histogram query with custom bins
|
||||
const queryBuilder = new QueryBuilder(customBinHistogramQuery.chartConfig);
|
||||
const queryBuilder = new QueryBuilder(
|
||||
customBinHistogramQuery.chartConfig,
|
||||
);
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
customBinHistogramQuery,
|
||||
projectId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SYSTEM_ROLES,
|
||||
type ChatMessageWithId,
|
||||
type LLMToolCall,
|
||||
type PlaceholderMessage,
|
||||
} from "@langfuse/shared";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Card, CardContent } from "@/src/components/ui/card";
|
||||
@@ -45,15 +46,17 @@ const ROLES: ChatMessageRole[] = [
|
||||
const getRoleNamePlaceholder = (role: string) => {
|
||||
switch (role) {
|
||||
case ChatMessageRole.System:
|
||||
return "a system";
|
||||
return "a system message";
|
||||
case ChatMessageRole.Developer:
|
||||
return "a developer";
|
||||
return "a developer message";
|
||||
case ChatMessageRole.Assistant:
|
||||
return "an assistant";
|
||||
return "an assistant message";
|
||||
case ChatMessageRole.User:
|
||||
return "a user";
|
||||
return "a user message";
|
||||
case ChatMessageRole.Tool:
|
||||
return "a tool response";
|
||||
return "a tool response message";
|
||||
case "placeholder":
|
||||
return "placeholder name (e.g. msg_history)";
|
||||
default:
|
||||
return `a ${role}`;
|
||||
}
|
||||
@@ -92,6 +95,9 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
} = useSortable({ id: message.id });
|
||||
|
||||
const toggleRole = () => {
|
||||
// Only allow role toggling for messages that have a role property (not placeholder messages)
|
||||
if (!('role' in message)) return;
|
||||
|
||||
// if user has set custom roles, available roles will be non-empty and we toggle through custom and default roles (assistant, user)
|
||||
if (!!availableRoles && Boolean(availableRoles.length)) {
|
||||
let randomRole = availableRoles[roleIndex % availableRoles.length];
|
||||
@@ -113,7 +119,7 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
(toolCallIds && toolCallIds.length > 0),
|
||||
);
|
||||
const currentIndex = eligibleRoles.indexOf(
|
||||
message.role as ChatMessageRole,
|
||||
('role' in message ? message.role : ChatMessageRole.User) as ChatMessageRole,
|
||||
);
|
||||
const nextRole =
|
||||
eligibleRoles[(currentIndex + 1) % eligibleRoles.length];
|
||||
@@ -196,13 +202,28 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
};
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(value: string) =>
|
||||
updateMessage(message.type, message.id, "content", value),
|
||||
(value: string) => {
|
||||
if (message.type === ChatMessageType.Placeholder) {
|
||||
updateMessage(message.type, message.id, "name", value);
|
||||
} else {
|
||||
updateMessage(message.type, message.id, "content", value);
|
||||
}
|
||||
},
|
||||
[message.id, message.type, updateMessage],
|
||||
);
|
||||
|
||||
const showDragHandle = !SYSTEM_ROLES.includes(message.role);
|
||||
const onPlaceholderNameChange = useCallback(
|
||||
(value: string) => {
|
||||
if (message.type === ChatMessageType.Placeholder) {
|
||||
updateMessage(message.type, message.id, "name", value);
|
||||
}
|
||||
},
|
||||
[message.id, message.type, updateMessage],
|
||||
);
|
||||
|
||||
const showDragHandle = !('role' in message && SYSTEM_ROLES.includes(message.role));
|
||||
const showToolCallSelect = message.type === ChatMessageType.ToolResult;
|
||||
const isPlaceholder = message.type === ChatMessageType.Placeholder;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -233,14 +254,20 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className="flex w-[4rem] flex-shrink-0 flex-col gap-1">
|
||||
<Button
|
||||
onClick={toggleRole}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-6 w-full px-1 py-0 text-[10px] font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{capitalize(message.role)}
|
||||
</Button>
|
||||
{isPlaceholder ? (
|
||||
<span className="inline-flex items-center justify-center font-mono h-6 w-full text-[9px] rounded-md bg-accent px-4 text-muted-foreground">
|
||||
placeholder
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
onClick={toggleRole}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-6 w-full px-1 py-0 text-[10px] font-semibold text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{capitalize(message.role)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
@@ -271,11 +298,19 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<MemoizedEditor
|
||||
value={message.content}
|
||||
onChange={onValueChange}
|
||||
role={message.role}
|
||||
/>
|
||||
{isPlaceholder ? (
|
||||
<MemoizedEditor
|
||||
value={(message as PlaceholderMessage).name || ""}
|
||||
onChange={onPlaceholderNameChange}
|
||||
role={message.type}
|
||||
/>
|
||||
) : (
|
||||
<MemoizedEditor
|
||||
value={message.content}
|
||||
onChange={onValueChange}
|
||||
role={message.role}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{message.type === ChatMessageType.AssistantToolCall && (
|
||||
<ToolCalls toolCalls={message.toolCalls as LLMToolCall[]} />
|
||||
@@ -303,7 +338,7 @@ const MemoizedEditor = memo(function MemoizedEditor(props: {
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { value, role, onChange } = props;
|
||||
const placeholder = `Enter ${getRoleNamePlaceholder(role)} message here.`;
|
||||
const placeholder = `Enter ${getRoleNamePlaceholder(role)} here.`;
|
||||
|
||||
return (
|
||||
<CodeMirrorEditor
|
||||
|
||||
@@ -2,9 +2,16 @@ import { PlusCircleIcon } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/src/components/ui/tooltip";
|
||||
import {
|
||||
ChatMessageRole,
|
||||
ChatMessageType,
|
||||
type ChatMessageWithId,
|
||||
SYSTEM_ROLES,
|
||||
} from "@langfuse/shared";
|
||||
|
||||
@@ -60,8 +67,9 @@ export const ChatMessages: React.FC<ChatMessagesProps> = (props) => {
|
||||
if (newIndex < 0 || oldIndex < 0) {
|
||||
return;
|
||||
}
|
||||
// prevent reordering system messages
|
||||
if (SYSTEM_ROLES.includes(messages[newIndex].role)) {
|
||||
// prevent reordering system messages, placeholders can be reordered
|
||||
const targetMessage = messages[newIndex];
|
||||
if (targetMessage.type !== ChatMessageType.Placeholder && SYSTEM_ROLES.includes(targetMessage.role)) {
|
||||
return;
|
||||
}
|
||||
const newMessages = arrayMove(messages, oldIndex, newIndex);
|
||||
@@ -114,35 +122,66 @@ const AddMessageButton: React.FC<AddMessageButtonProps> = ({
|
||||
messages,
|
||||
addMessage,
|
||||
}) => {
|
||||
const lastMessageRole = messages[messages.length - 1]?.role;
|
||||
// Skip placeholder messages when determining last roles
|
||||
const lastMessageWithRole = messages.slice().reverse().find((msg): msg is ChatMessageWithId & { role: string } => msg.type !== ChatMessageType.Placeholder);
|
||||
const lastMessageRole = lastMessageWithRole?.role;
|
||||
const nextMessageRole =
|
||||
lastMessageRole === ChatMessageRole.User
|
||||
? ChatMessageRole.Assistant
|
||||
: ChatMessageRole.User;
|
||||
|
||||
const addRegularMessage = () => {
|
||||
if (nextMessageRole === ChatMessageRole.User) {
|
||||
addMessage({
|
||||
role: nextMessageRole,
|
||||
content: "",
|
||||
type: ChatMessageType.User,
|
||||
});
|
||||
} else {
|
||||
addMessage({
|
||||
role: nextMessageRole,
|
||||
content: "",
|
||||
type: ChatMessageType.AssistantText,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addPlaceholderMessage = () => {
|
||||
addMessage({
|
||||
type: ChatMessageType.Placeholder,
|
||||
name: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button" // prevents submitting a form if this button is inside a form
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (nextMessageRole === ChatMessageRole.User) {
|
||||
addMessage({
|
||||
role: nextMessageRole,
|
||||
content: "",
|
||||
type: ChatMessageType.User,
|
||||
});
|
||||
} else {
|
||||
addMessage({
|
||||
role: nextMessageRole,
|
||||
content: "",
|
||||
type: ChatMessageType.AssistantText,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message</p>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={addRegularMessage}
|
||||
>
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message</p>
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={addPlaceholderMessage}
|
||||
>
|
||||
<PlusCircleIcon size={14} className="mr-2" />
|
||||
<p>Add message placeholder</p>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">Adds a placeholder to inject message pairs, e.g. a message history (with "role", "content" pairs) when compiling the message in the SDK.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ChatMessage, ChatMessageWithId } from "@langfuse/shared";
|
||||
import type { ChatMessage, ChatMessageWithId, PlaceholderMessage } from "@langfuse/shared";
|
||||
|
||||
export type MessagesContext = {
|
||||
messages: ChatMessageWithId[];
|
||||
addMessage: (message: ChatMessage) => ChatMessageWithId;
|
||||
addMessage: (message: ChatMessage | PlaceholderMessage) => ChatMessageWithId;
|
||||
setMessages: (messages: ChatMessageWithId[]) => void;
|
||||
deleteMessage: (id: string) => void;
|
||||
updateMessage: <
|
||||
T extends ChatMessageWithId["type"],
|
||||
Key extends keyof Omit<
|
||||
Extract<ChatMessageWithId, { type: T }>,
|
||||
"id" | "type" | "role"
|
||||
"id" | "type"
|
||||
>,
|
||||
Value = Extract<ChatMessageWithId, { type: T }>[Key],
|
||||
>(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { type ChatMessage, type ChatMessageWithId } from "@langfuse/shared";
|
||||
import { type ChatMessage, type ChatMessageWithIdNoPlaceholders } from "@langfuse/shared";
|
||||
|
||||
export function createEmptyMessage(message: ChatMessage): ChatMessageWithId {
|
||||
export function createEmptyMessage(message: ChatMessage): ChatMessageWithIdNoPlaceholders {
|
||||
return {
|
||||
...message,
|
||||
content: message.content ?? "",
|
||||
|
||||
@@ -289,13 +289,13 @@ export default function Layout(props: PropsWithChildren) {
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32.png`}
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-32x32${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16.png`}
|
||||
href={`${env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon-16x16${env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION === "DEV" ? "-dev" : ""}.png`}
|
||||
/>
|
||||
</Head>
|
||||
<div>
|
||||
|
||||
@@ -29,6 +29,15 @@ type SidebarNotification = {
|
||||
};
|
||||
|
||||
const notifications: SidebarNotification[] = [
|
||||
{
|
||||
id: "python-sdk-v3",
|
||||
title: "New Python SDK v3",
|
||||
description:
|
||||
"Python SDK V3 offers significant improvements in developer experience, performance, and integrations.",
|
||||
link: "https://langfuse.com/docs/sdk/python/sdk-v3#upgrade-from-v2",
|
||||
linkTitle: "Upgrade to v3",
|
||||
createdAt: "2025-06-27",
|
||||
},
|
||||
{
|
||||
id: "lw3-5",
|
||||
title: "Launch Week #3: Day 5",
|
||||
@@ -102,7 +111,7 @@ const notifications: SidebarNotification[] = [
|
||||
linkContent: (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt="Langfuse Github stars"
|
||||
alt="Langfuse GitHub stars"
|
||||
src="https://img.shields.io/github/stars/langfuse/langfuse?label=langfuse&style=social"
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -58,7 +58,7 @@ export const SupportMenuDropdown = () => {
|
||||
menuNode: (
|
||||
<div className="flex items-center gap-2" onClick={() => openChat()}>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
<span>Open Chat</span>
|
||||
<span>Contact Support</span>
|
||||
</div>
|
||||
),
|
||||
icon: MessageCircle,
|
||||
|
||||
@@ -125,11 +125,12 @@ export const ChatMlMessageSchema = z
|
||||
...other,
|
||||
...additional_kwargs,
|
||||
}))
|
||||
.transform(({ role, name, content, audio, ...other }) => ({
|
||||
.transform(({ role, name, content, audio, type, ...other }) => ({
|
||||
role,
|
||||
name,
|
||||
content,
|
||||
audio,
|
||||
type,
|
||||
...(Object.keys(other).length === 0 ? {} : { json: other }),
|
||||
}));
|
||||
export type ChatMlMessageSchema = z.infer<typeof ChatMlMessageSchema>;
|
||||
|
||||
@@ -71,7 +71,7 @@ export const ScoresTableCell = ({
|
||||
<MessageCircleMore size={12} />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="overflow-hidden whitespace-normal break-normal">
|
||||
<p>{aggregate.comment}</p>
|
||||
<p className="whitespace-pre-wrap">{aggregate.comment}</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { type ListEntry } from "@/src/features/navigate-detail-pages/context";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const useDatasetComparePeekNavigation = () => {
|
||||
const getNavigationPath = (entry: ListEntry) => {
|
||||
const url = new URL(window.location.href);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
// Update the path part
|
||||
url.pathname = pathname;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const useDatasetComparePeekState = () => {
|
||||
const router = useRouter();
|
||||
@@ -15,7 +16,7 @@ export const useDatasetComparePeekState = () => {
|
||||
(open: boolean, itemId?: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
if (!open || !itemId) {
|
||||
// close peek view
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type EvalsTemplateRow } from "@/src/features/evals/components/eval-templates-table";
|
||||
import { type ListEntry } from "@/src/features/navigate-detail-pages/context";
|
||||
import { useRouter } from "next/router";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const useEvalTemplatesPeekNavigation = () => {
|
||||
const router = useRouter();
|
||||
@@ -8,7 +9,7 @@ export const useEvalTemplatesPeekNavigation = () => {
|
||||
|
||||
const getNavigationPath = (entry: ListEntry) => {
|
||||
const url = new URL(window.location.href);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
// Update the path part
|
||||
url.pathname = pathname;
|
||||
@@ -28,7 +29,8 @@ export const useEvalTemplatesPeekNavigation = () => {
|
||||
const pathname = `/project/${projectId}/evals/templates/${encodeURIComponent(peek as string)}`;
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(pathname, "_blank");
|
||||
const pathnameWithBasePath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${pathname}`;
|
||||
window.open(pathnameWithBasePath, "_blank");
|
||||
} else {
|
||||
router.push(pathname);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ListEntry } from "@/src/features/navigate-detail-pages/context";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ObservationsTableRow } from "@/src/components/table/use-cases/observations";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const useObservationPeekNavigation = () => {
|
||||
const router = useRouter();
|
||||
@@ -8,7 +9,7 @@ export const useObservationPeekNavigation = () => {
|
||||
|
||||
const getNavigationPath = (entry: ListEntry) => {
|
||||
const url = new URL(window.location.href);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
// Update the path part
|
||||
url.pathname = pathname;
|
||||
@@ -42,7 +43,8 @@ export const useObservationPeekNavigation = () => {
|
||||
const pathname = `/project/${projectId}/traces/${encodeURIComponent(row.traceId as string)}?timestamp=${timestamp}&display=${display}&observation=${peek as string}`;
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(pathname, "_blank");
|
||||
const pathnameWithBasePath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${pathname}`;
|
||||
window.open(pathnameWithBasePath, "_blank");
|
||||
} else {
|
||||
router.push(pathname);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback } from "react";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const useObservationPeekState = () => {
|
||||
const router = useRouter();
|
||||
@@ -9,7 +10,7 @@ export const useObservationPeekState = () => {
|
||||
(open: boolean, id?: string, time?: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
if (!open || !id) {
|
||||
// close peek view
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback } from "react";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const usePeekState = () => {
|
||||
const router = useRouter();
|
||||
@@ -9,7 +10,7 @@ export const usePeekState = () => {
|
||||
(open: boolean, id?: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
if (!open || !id) {
|
||||
// close peek view
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { type ListEntry } from "@/src/features/navigate-detail-pages/context";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const useRunningEvaluatorsPeekNavigation = () => {
|
||||
const getNavigationPath = (entry: ListEntry) => {
|
||||
const url = new URL(window.location.href);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
// Update the path part
|
||||
url.pathname = pathname;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ListEntry } from "@/src/features/navigate-detail-pages/context";
|
||||
import { useRouter } from "next/router";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const useTracePeekNavigation = () => {
|
||||
const router = useRouter();
|
||||
@@ -7,7 +8,7 @@ export const useTracePeekNavigation = () => {
|
||||
|
||||
const getNavigationPath = (entry: ListEntry) => {
|
||||
const url = new URL(window.location.href);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
// Update the path part
|
||||
url.pathname = pathname;
|
||||
@@ -38,7 +39,8 @@ export const useTracePeekNavigation = () => {
|
||||
const pathname = `/project/${projectId}/traces/${encodeURIComponent(peek as string)}?timestamp=${timestamp}&display=${display}`;
|
||||
|
||||
if (openInNewTab) {
|
||||
window.open(pathname, "_blank");
|
||||
const pathnameWithBasePath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${pathname}`;
|
||||
window.open(pathnameWithBasePath, "_blank");
|
||||
} else {
|
||||
router.push(pathname);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback } from "react";
|
||||
import { getPathnameWithoutBasePath } from "@/src/utils/api";
|
||||
|
||||
export const useTracePeekState = () => {
|
||||
const router = useRouter();
|
||||
@@ -9,7 +10,7 @@ export const useTracePeekState = () => {
|
||||
(open: boolean, id?: string, time?: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const pathname = window.location.pathname;
|
||||
const pathname = getPathnameWithoutBasePath();
|
||||
|
||||
if (!open || !id) {
|
||||
// close peek view
|
||||
|
||||
@@ -72,12 +72,11 @@ export const PeekDatasetCompareDetail = ({
|
||||
};
|
||||
|
||||
const handleSetCurrentObservationId = (id?: string) => {
|
||||
if (id && traceId)
|
||||
window.open(
|
||||
`/project/${projectId}/traces/${encodeURIComponent(traceId)}?observation=${encodeURIComponent(id)}`,
|
||||
"_blank",
|
||||
"noopener noreferrer",
|
||||
);
|
||||
if (id && traceId) {
|
||||
const pathname = `/project/${projectId}/traces/${encodeURIComponent(traceId)}?observation=${encodeURIComponent(id)}`;
|
||||
const pathnameWithBasePath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${pathname}`;
|
||||
window.open(pathnameWithBasePath, "_blank", "noopener noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
if (!row) return <Skeleton className="min-h-full w-full" />;
|
||||
@@ -183,15 +182,13 @@ export const PeekDatasetCompareDetail = ({
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="View full trace"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
run?.observationId
|
||||
? `/project/${projectId}/traces/${encodeURIComponent(run.traceId)}?observation=${encodeURIComponent(run.observationId)}`
|
||||
: `/project/${projectId}/traces/${encodeURIComponent(run.traceId)}`,
|
||||
"_blank",
|
||||
"noopener noreferrer",
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
const pathname = run?.observationId
|
||||
? `/project/${projectId}/traces/${encodeURIComponent(run.traceId)}?observation=${encodeURIComponent(run.observationId)}`
|
||||
: `/project/${projectId}/traces/${encodeURIComponent(run.traceId)}`;
|
||||
const pathnameWithBasePath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${pathname}`;
|
||||
window.open(pathnameWithBasePath, "_blank", "noopener noreferrer");
|
||||
}}
|
||||
>
|
||||
<ListTree className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -234,10 +234,14 @@ export const OpenAiMessageView: React.FC<{
|
||||
return !!message.json;
|
||||
};
|
||||
|
||||
const isPlaceholderMessage = (message: ChatMlMessageSchema) => {
|
||||
return message.type === 'placeholder';
|
||||
};
|
||||
|
||||
const messagesToRender = useMemo(
|
||||
() =>
|
||||
messages.filter(
|
||||
(message) => shouldRenderContent(message) || shouldRenderJson(message),
|
||||
(message) => shouldRenderContent(message) || shouldRenderJson(message) || isPlaceholderMessage(message),
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
@@ -257,39 +261,57 @@ export const OpenAiMessageView: React.FC<{
|
||||
)
|
||||
.map((message, index) => (
|
||||
<Fragment key={index}>
|
||||
{shouldRenderContent(message) &&
|
||||
(shouldRenderMarkdown ? (
|
||||
{isPlaceholderMessage(message) ? (
|
||||
shouldRenderMarkdown ? (
|
||||
<MarkdownJsonView
|
||||
title={message.name ?? message.role}
|
||||
content={message.content || '""'}
|
||||
className={cn(!!message.json && "rounded-b-none")}
|
||||
customCodeHeaderClassName={cn(
|
||||
message.role === "assistant" && "bg-secondary",
|
||||
message.role === "system" && "bg-primary-foreground",
|
||||
)}
|
||||
audio={message.audio}
|
||||
title="Placeholder"
|
||||
content={message.name || 'Unnamed placeholder'}
|
||||
customCodeHeaderClassName={cn("bg-primary-foreground")}
|
||||
/>
|
||||
) : (
|
||||
<JSONView
|
||||
title={message.name ?? message.role}
|
||||
json={message.content}
|
||||
title="Placeholder"
|
||||
json={message.name || 'Unnamed placeholder'}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
className={cn(!!message.json && "rounded-b-none")}
|
||||
/>
|
||||
))}
|
||||
{shouldRenderJson(message) && (
|
||||
<JSONView
|
||||
title={
|
||||
message.content
|
||||
? undefined
|
||||
: (message.name ?? message.role)
|
||||
}
|
||||
json={message.json}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
className={cn(
|
||||
!!message.content && "rounded-t-none border-t-0",
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{shouldRenderContent(message) &&
|
||||
(shouldRenderMarkdown ? (
|
||||
<MarkdownJsonView
|
||||
title={message.name ?? message.role}
|
||||
content={message.content || '""'}
|
||||
className={cn(!!message.json && !isPlaceholderMessage(message) && "rounded-b-none")}
|
||||
customCodeHeaderClassName={cn(
|
||||
message.role === "assistant" && "bg-secondary",
|
||||
message.role === "system" && "bg-primary-foreground",
|
||||
)}
|
||||
audio={message.audio}
|
||||
/>
|
||||
) : (
|
||||
<JSONView
|
||||
title={message.name ?? message.role}
|
||||
json={message.content}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
className={cn(!!message.json && !isPlaceholderMessage(message) && "rounded-b-none")}
|
||||
/>
|
||||
))}
|
||||
{shouldRenderJson(message) && !isPlaceholderMessage(message) && (
|
||||
<JSONView
|
||||
title={
|
||||
message.content
|
||||
? undefined
|
||||
: (message.name ?? message.role)
|
||||
}
|
||||
json={message.json}
|
||||
projectIdForPromptButtons={projectIdForPromptButtons}
|
||||
className={cn(
|
||||
!!message.content && "rounded-t-none border-t-0",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isCollapsed !== null && index === 0 ? (
|
||||
<Button
|
||||
|
||||
@@ -390,17 +390,18 @@ const ObservationTreeNodeCard = ({
|
||||
)}
|
||||
ref={currentObservationRef}
|
||||
>
|
||||
{/* Type badge */}
|
||||
<ItemBadge
|
||||
type={observation.type}
|
||||
isSmall
|
||||
className="flex-shrink-0 scale-75"
|
||||
/>
|
||||
{/* Type badge and name */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ItemBadge
|
||||
type={observation.type}
|
||||
isSmall
|
||||
className="flex-shrink-0 scale-75"
|
||||
/>
|
||||
<span className="text-sm font-medium whitespace-nowrap">{observation.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Name and duration */}
|
||||
{/* Duration and Comments */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{observation.name}</span>
|
||||
|
||||
{comments && showComments ? (
|
||||
<CommentCountIcon count={comments.get(observation.id)} />
|
||||
) : null}
|
||||
|
||||
@@ -257,7 +257,7 @@ export const IOTableCell = ({
|
||||
return <JsonSkeleton className="h-full w-full overflow-hidden px-2 py-1" />;
|
||||
}
|
||||
|
||||
const stringifiedJson = data ? stringifyJsonNode(data) : undefined;
|
||||
const stringifiedJson = data !== null && data !== undefined ? stringifyJsonNode(data) : undefined;
|
||||
|
||||
// perf: truncate to IO_TABLE_CHAR_LIMIT characters as table becomes unresponsive attempting to render large JSONs with high levels of nesting
|
||||
const shouldTruncate =
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.75.0";
|
||||
export const VERSION = "v3.78.0";
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
CardTitle,
|
||||
} from "@/src/components/ui/card";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useSidebar } from "@/src/components/ui/sidebar";
|
||||
|
||||
export const UsageTracker = () => {
|
||||
const { organization } = useQueryProjectOrOrganization();
|
||||
@@ -20,6 +23,7 @@ export const UsageTracker = () => {
|
||||
organizationId: organization?.id,
|
||||
scope: "langfuseCloudBilling:CRUD",
|
||||
});
|
||||
const { setOpen } = useSidebar();
|
||||
|
||||
const usageQuery = api.cloudBilling.getUsage.useQuery(
|
||||
{
|
||||
@@ -51,24 +55,46 @@ export const UsageTracker = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCritical = percentage > 200;
|
||||
|
||||
return (
|
||||
<Card className="relative max-h-48 overflow-hidden rounded-md bg-opacity-50 shadow-none group-data-[collapsible=icon]:hidden">
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<CardTitle className="text-sm">Hobby Plan Usage Limit</CardTitle>
|
||||
<CardDescription>
|
||||
{`${usage.toLocaleString()} / ${MAX_EVENTS_FREE_PLAN.toLocaleString()} (${percentage.toFixed(0)}%) ${usageType} in last 30 days. Please upgrade your plan to avoid interruptions.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={`/organization/${organization?.id}/settings/billing`}
|
||||
hasAccess={hasAccess}
|
||||
>
|
||||
Upgrade plan
|
||||
</ActionButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<>
|
||||
{/* icon when critical and sidebar is collapsed */}
|
||||
{isCritical && (
|
||||
<AlertTriangle
|
||||
className="hidden h-4 w-4 cursor-pointer self-center text-destructive group-data-[collapsible=icon]:inline-block"
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{/* card when sidebar is not collapsed */}
|
||||
<Card
|
||||
className={cn(
|
||||
"relative max-h-48 overflow-hidden rounded-md bg-opacity-50 shadow-none group-data-[collapsible=icon]:hidden",
|
||||
isCritical && "border-destructive",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
{isCritical && (
|
||||
<AlertTriangle className="inline-block h-4 w-4 text-destructive" />
|
||||
)}
|
||||
Plan Usage Limit
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{`${usage.toLocaleString()} / ${MAX_EVENTS_FREE_PLAN.toLocaleString()} (${percentage.toFixed(0)}%) ${usageType} in last 30 days. Please upgrade your plan to avoid interruptions.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<ActionButton
|
||||
variant={isCritical ? "default" : "secondary"}
|
||||
size="sm"
|
||||
href={`/organization/${organization?.id}/settings/billing`}
|
||||
hasAccess={hasAccess}
|
||||
>
|
||||
Upgrade plan
|
||||
</ActionButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -120,6 +120,7 @@ export const DatasetForm = (props: DatasetFormProps) => {
|
||||
allNames: allDatasetNames,
|
||||
form,
|
||||
errorMessage: "Dataset name already exists.",
|
||||
whitelistedName: props.mode === "update" ? props.datasetName : undefined,
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
@@ -267,27 +268,29 @@ export const DatasetForm = (props: DatasetFormProps) => {
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={props.mode === "delete" ? "destructive" : "default"}
|
||||
disabled={!!form.formState.errors.name}
|
||||
loading={
|
||||
(props.mode === "create" && createMutation.isLoading) ||
|
||||
(props.mode === "delete" && deleteMutation.isLoading)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{props.mode === "create"
|
||||
? "Create dataset"
|
||||
: props.mode === "delete"
|
||||
? "Delete Dataset"
|
||||
: "Update dataset"}
|
||||
</Button>
|
||||
{formError && (
|
||||
<p className="mt-4 text-center text-sm text-red-500">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant={props.mode === "delete" ? "destructive" : "default"}
|
||||
disabled={!!form.formState.errors.name}
|
||||
loading={
|
||||
(props.mode === "create" && createMutation.isLoading) ||
|
||||
(props.mode === "delete" && deleteMutation.isLoading)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{props.mode === "create"
|
||||
? "Create dataset"
|
||||
: props.mode === "delete"
|
||||
? "Delete Dataset"
|
||||
: "Update dataset"}
|
||||
</Button>
|
||||
{formError && (
|
||||
<p className="mt-4 text-center text-sm text-red-500">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -110,7 +110,11 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const description: RowData["description"] = row.getValue("description");
|
||||
return <div className="h-full overflow-y-auto flex items-center">{description}</div>;
|
||||
return (
|
||||
<div className="flex h-full items-center overflow-y-auto">
|
||||
{description}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -189,6 +193,7 @@ export function DatasetsTable(props: { projectId: string }) {
|
||||
datasetId={key.id}
|
||||
datasetName={key.name}
|
||||
datasetDescription={row.getValue("description") ?? undefined}
|
||||
datasetMetadata={row.getValue("metadata") ?? undefined}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@@ -325,19 +325,21 @@ export const NewDatasetItemForm = (props: {
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createManyDatasetItemsMutation.isLoading}
|
||||
className="w-full"
|
||||
disabled={form.watch("datasetIds").length === 0}
|
||||
>
|
||||
Add to dataset{form.watch("datasetIds").length > 1 ? "s" : ""}
|
||||
</Button>
|
||||
{formError ? (
|
||||
<p className="text-red mt-2 text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createManyDatasetItemsMutation.isLoading}
|
||||
className="w-full"
|
||||
disabled={form.watch("datasetIds").length === 0}
|
||||
>
|
||||
Add to dataset{form.watch("datasetIds").length > 1 ? "s" : ""}
|
||||
</Button>
|
||||
{formError ? (
|
||||
<p className="text-red mt-2 text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/src/utils/tailwind";
|
||||
import { type RouterOutput } from "@/src/utils/types";
|
||||
import { type EvalTemplate } from "@langfuse/shared";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
|
||||
const VARIABLE_COLORS = [
|
||||
"text-primary-accent",
|
||||
@@ -128,9 +128,14 @@ export const EvaluationPromptPreview = ({
|
||||
className?: string;
|
||||
controlButtons?: React.ReactNode;
|
||||
}) => {
|
||||
const memoizedVariables = useMemo(
|
||||
() => variableMapping.map(({ templateVariable }) => templateVariable),
|
||||
[variableMapping],
|
||||
);
|
||||
|
||||
const { extractedVariables, isExtracting } = useExtractVariables({
|
||||
variables: variableMapping.map(({ templateVariable }) => templateVariable),
|
||||
variableMapping: variableMapping,
|
||||
variables: memoizedVariables,
|
||||
variableMapping,
|
||||
trace: trace,
|
||||
isLoading,
|
||||
});
|
||||
|
||||
@@ -396,21 +396,27 @@ export const InnerEvaluatorForm = (props: {
|
||||
<div className="flex items-center gap-2">
|
||||
{form.watch("target") === "trace" && !props.disabled && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">Show preview</span>
|
||||
<span className="text-xs text-muted-foreground">Show Preview</span>
|
||||
<Switch
|
||||
checked={showPreview}
|
||||
onCheckedChange={setShowPreview}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
{traceWithObservations && showPreview && (
|
||||
<DetailPageNav
|
||||
currentId={traceWithObservations.id}
|
||||
listKey="traces"
|
||||
path={(entry) =>
|
||||
`/project/${props.projectId}/evals/new?evaluator=${props.evalTemplate.id}&traceId=${entry.id}`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showPreview &&
|
||||
(traceWithObservations ? (
|
||||
<DetailPageNav
|
||||
currentId={traceWithObservations.id}
|
||||
listKey="traces"
|
||||
path={(entry) =>
|
||||
`/project/${props.projectId}/evals/new?evaluator=${props.evalTemplate.id}&traceId=${entry.id}`
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-row gap-1">
|
||||
<Skeleton className="h-8 w-[54px]" />
|
||||
<Skeleton className="h-8 w-[54px]" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -723,11 +729,12 @@ export const InnerEvaluatorForm = (props: {
|
||||
controlButtons={mappingControlButtons}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex max-h-full min-h-[200px] w-full flex-col gap-1 lg:w-2/3">
|
||||
<div className="flex flex-row items-end justify-between">
|
||||
<span className="h-fit px-1 text-start text-sm font-medium">
|
||||
Evaluation Prompt
|
||||
</span>
|
||||
<div className="flex max-h-full min-h-48 w-full flex-col gap-1 lg:w-2/3">
|
||||
<div className="flex flex-row items-center justify-between py-0 text-sm font-medium capitalize">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
Evaluation Prompt Preview
|
||||
<Skeleton className="h-[25px] w-[63px]" />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
{mappingControlButtons}
|
||||
</div>
|
||||
@@ -1054,22 +1061,22 @@ export const InnerEvaluatorForm = (props: {
|
||||
);
|
||||
|
||||
const formFooter = (
|
||||
<>
|
||||
<div className="flex w-full flex-col items-end gap-4">
|
||||
{!props.disabled ? (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createJobMutation.isLoading || updateJobMutation.isLoading}
|
||||
className="mt-3"
|
||||
className="mt-3 max-w-fit"
|
||||
>
|
||||
Execute
|
||||
</Button>
|
||||
) : null}
|
||||
{formError ? (
|
||||
<p className="text-red text-center">
|
||||
<p className="text-red w-full text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -513,22 +513,22 @@ export const InnerEvalTemplateForm = (props: {
|
||||
);
|
||||
|
||||
const formFooter = (
|
||||
<>
|
||||
<div className="flex w-full flex-col items-end gap-4">
|
||||
{props.isEditing && (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={createEvalTemplateMutation.isLoading}
|
||||
className="w-full"
|
||||
className="max-w-fit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
{formError ? (
|
||||
<p className="text-red text-center">
|
||||
<p className="text-red w-full text-center">
|
||||
<span className="font-bold">Error:</span> {formError}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -44,7 +44,8 @@ export function useExtractVariables({
|
||||
const previousMappingRef = useRef<string>("");
|
||||
|
||||
// Create a stable string representation of the current mapping for comparison
|
||||
const currentMappingString = JSON.stringify(variableMapping);
|
||||
const currentMappingString =
|
||||
variables.length > 0 ? JSON.stringify(variableMapping) : "";
|
||||
|
||||
// Create a stable reference to the trace ID
|
||||
const traceId = trace?.id;
|
||||
@@ -59,10 +60,14 @@ export function useExtractVariables({
|
||||
|
||||
useEffect(() => {
|
||||
// Return early conditions
|
||||
if (isLoading || !variables.length) {
|
||||
setExtractedVariables(
|
||||
variables.map((variable) => ({ variable, value: "n/a" })),
|
||||
);
|
||||
if (isLoading) {
|
||||
setExtractedVariables([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no variables, only update if current state is not empty
|
||||
if (!Boolean(variables.length)) {
|
||||
setExtractedVariables((prev) => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import { z as zodV3 } from "zod/v3";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
@@ -832,9 +833,9 @@ export const evalRouter = createTRPCRouter({
|
||||
adapter: matchingLLMKey.adapter,
|
||||
...input.modelParams,
|
||||
},
|
||||
structuredOutputSchema: z.object({
|
||||
score: z.string(),
|
||||
reasoning: z.string(),
|
||||
structuredOutputSchema: zodV3.object({
|
||||
score: zodV3.string(),
|
||||
reasoning: zodV3.string(),
|
||||
}),
|
||||
config: matchingLLMKey.config,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user