feat(llm-evals): redesign the quality page on the new quality apis (#15244)

Co-authored-by: udev <uddhav@openobserve.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Sai Nikhil
2026-10-27 17:47:24 +00:00
committed by GitHub
co-authored by udev github-actions[bot]
parent acdb6042b6
commit 5fd94111c2
94 changed files with 8961 additions and 8440 deletions
+106 -130
View File
@@ -8037,11 +8037,10 @@
"paused": "e159b06187d369a0"
},
"qualityStatus": {
"attention": "c1ebc7817870e5be",
"healthy": "7f1e323b6272c620",
"nodata": "af75117dfbb0edb7",
"nothreshold": "e343e1b48e909484",
"unhealthy": "317b1fbc1e13beeb",
"warn": "8448814dc5caff5c"
"nodata": "dcb61373fe73eca0",
"unset": "237cddc737b07bf1"
},
"queryStatus": {
"canceled": "13ca2ee2499310d9",
@@ -18324,7 +18323,6 @@
"testConnection": "c02977b07ec93816"
},
"catalogScoreConfigNotFound": "f40f5ef1f9371929",
"count": "37bac4952becf21a",
"deleteConfirmMessage": "db7e1f9dabc0cbc7",
"deleteError": "4e1252dd62c17a4b",
"deleteTitle": "0f81c0d86606f518",
@@ -18335,7 +18333,6 @@
"failedToImportScorer": "657edceb184dad48",
"failedToLoadCatalog": "81454f428d2eca65",
"failedToRunScorerTest": "8efda6002ab8a147",
"healthyRate": "cfbdb2419025378c",
"import": {
"alreadyExistsSkipped": "7138074e5680ecc5",
"createdSuccessfully": "a669346209687818",
@@ -18727,143 +18724,122 @@
"typeRequired": "2d973f48294309ac"
},
"quality": {
"agentLabel": "11b39c93777e8f1f",
"agentPlaceholder": "54c32d3e2cfa1cc8",
"allAgents": "2ab73ea5b9f48140",
"averageWithHealthyRate": "457b958204b8415f",
"dataTypes": {
"booleanShort": "49f411f0a1a7f719",
"categoricalShort": "48735c4fae42d150",
"numericShort": "03732d19e64b727a"
},
"detail": {
"categoryDistributionTitle": "4a0f40cef45fc3b8",
"distributionTitle": "204e97ff526e88bc",
"empty": {
"description": "33a0c0ddb152c545",
"descriptionScoped": "040bea517fb3dd81",
"title": "c28eabc50c6172de",
"titleScoped": "0f6ba020af4d8734"
},
"health": {
"noThreshold": "757555170371d25d",
"summary": "e4ef8942e7382c26",
"thresholdOnly": "f7e60f4f5bf81dcd",
"title": "bb2dc38ee25dd468"
},
"healthyRateTitle": "fb401c8635c7b907",
"healthyThreshold": "870d9a17c5faf64e",
"kpis": {
"average": "b38ae2c5b84529e8",
"coverage": "523487a5de21adb5",
"falseRate": "9274b4e0211a61dd",
"healthy": "7f1e323b6272c620",
"p50": "fa4cedf31b643396",
"p95": "aebf8ec133e867b7",
"targetsScored": "5e1eea4fa8c36200",
"trueRate": "d5c5a6de7f1f2849",
"unhealthy": "317b1fbc1e13beeb"
},
"averageValue": "b0b2248bc9e5f488",
"back": "d1da712ff756faed",
"backToAll": "66a0c37b52f215ed",
"barButton": "02b069f89d756025",
"chartTooltip": "5a613891de20bb60",
"chartTooltipCount": "06b2297283d5afa0",
"clearFilter": "ba59e2d4a5e39a42",
"distributionNumeric": "204e97ff526e88bc",
"distributionValues": "22197a16c5a80914",
"editConfig": "3f140981c2a04443",
"emptyDescription": "828c3fc50f4b18ef",
"emptyTitle": "c28eabc50c6172de",
"emptyTitleScoped": "0f6ba020af4d8734",
"filterAny": "004f62d2d5b5c5be",
"filterAtLeast": "9bb325debb8a823f",
"filterBelow": "e574e3d7f92935e9",
"filterByBar": "90d0c8c1d694680e",
"filterRange": "ba318cc195ceff48",
"legendAvg": "41835ff14ac6b8c7",
"legendFalseCount": "20ee2de2332893c8",
"legendHealthy": "87695fdac81728b9",
"legendHealthyRate": "cfbdb2419025378c",
"legendThreshold": "e5920260ed27825f",
"legendTrueCount": "dad221927b66d136",
"legendTrueRate": "d5c5a6de7f1f2849",
"legendUnhealthy": "85826072c3d4c3a4",
"loading": "f3051ab55cc051a4",
"legendHealthy": "7f1e323b6272c620",
"legendScores": "c5f09715dba8604d",
"legendThreshold": "0bfbdd46a5674de7",
"legendUnhealthy": "317b1fbc1e13beeb",
"markLineHealthy": "bcd6d4cd56e53ff0",
"noData": "2887c63c9003316c",
"ofScoreResults": "6979fb5bfdf392ec",
"range": "9c6a5df138917ac8",
"trendTitle": "9e217716c4e03eee",
"trueFalseTitle": "427cbdc3dde45771",
"trueRateTitle": "7fd6f91f0c0ad748"
},
"kpis": {
"evaluationCost": {
"title": "dd5b14b252fc637d"
"notFound": "034503d3e44e3bb1",
"notFoundDescription": "67392d34bc6a3ea6",
"trendLegend": {
"count": "c5f09715dba8604d",
"healthyRate": "cfbdb2419025378c",
"trueRate": "d5c5a6de7f1f2849"
},
"latencyP95": {
"title": "dddc206eaf9f7ec2"
},
"noData": "bda050585a00f0f6",
"noPriorData": "e80c62b92be4c15a",
"scoreResults": {
"title": "db1a23cbdc30e12d"
},
"scorerFailures": {
"title": "4997bcc2bd10ef67"
},
"scorerSuccess": {
"title": "ff716aa8a234d071"
},
"vsPrev": "1c2453c2978f9985"
},
"kpisAriaLabel": "49409b7c87cbb436",
"overview": {
"columns": {
"health": "55898449eb74fb2e",
"quality": "1b2c08a8733d7ff1",
"scopeMix": "55479bfa00b783b8",
"scoreConfig": "84039a14d5ad9a94",
"totalScores": "fe5f9ade2071a6eb",
"type": "baaddf70fb5d432b",
"updated": "3a5ecca188c0579c",
"volumeTrend": "180920ae5ef94340"
},
"health": {
"noDataHint": "78737bac13daa445",
"noThresholdHint": "a708e7ac6bddb316",
"thresholdSummary": "5154cd5d6e1c2a4a"
},
"relativeTime": {
"daysAgo": "33a682af5904321a",
"monthsAgo": "7289f8e6f0ed365e"
},
"searchPlaceholder": "8bd11a03fc493153",
"status": {
"healthy": "7f1e323b6272c620",
"noData": "dcb61373fe73eca0",
"noThreshold": "237cddc737b07bf1",
"unhealthy": "c1ebc7817870e5be"
"trendTitle": {
"count": "43169ca648570cd2",
"healthyRate": "fb401c8635c7b907",
"score": "9e217716c4e03eee",
"trueRate": "7fd6f91f0c0ad748"
}
},
"runs": {
"columns": {
"agent": "11b39c93777e8f1f",
"reasoning": "d8211e24e83d1600",
"result": "38e5a46cbc5ad328",
"target": "978354db0c00fc78",
"time": "ca533a85286317b4"
},
"empty": "20c547d2d24ce30e",
"emptyUnhealthy": "cc13d51eb9e8b753",
"filterLabel": "8659d92fb947e840",
"filters": {
"all": "7d87c534d08f768a",
"unhealthy": "317b1fbc1e13beeb"
},
"health": {
"healthy": "7f1e323b6272c620",
"unhealthy": "317b1fbc1e13beeb"
},
"hint": "814307c2d705e314",
"healthyIf": "61314bb9a51b80bf",
"noHealthyValue": "140bedbf9c3f6d56",
"noScoresInWindow": "78737bac13daa445",
"outOf": "61dd1ffbd6954439",
"overview": {
"relativeTime": {
"daysAgo": "33a682af5904321a"
}
},
"pane": {
"addToDataset": "159d93ec1948b77b",
"datasetNoStream": "bb078f15b2d4f118",
"datasetSession": "37fd1c0485ec875b",
"evaluatorTrace": "fe5c37f83b4c578d",
"fullTextUnavailable": "ba96812e4442db16",
"healthy": "7f1e323b6272c620",
"next": "1ddf974477c58115",
"noReasoning": "b20ff56cb508dc0c",
"noThresholdHint": "e40d1f44676c2c45",
"queryError": "8ab4ae56eee26833",
"noThreshold": "2079685ba275f6cf",
"noneSelected": "80974ccb9819eda0",
"open": {
"session": "b205bb47f81a3096",
"span": "968c142311163240",
"trace": "c331a5916f7f017b"
},
"previous": "5fc351495fd548d4",
"reasoning": "d8211e24e83d1600",
"unhealthy": "317b1fbc1e13beeb"
},
"review": {
"all": "a52ace420f2175d0",
"inputNotCaptured": "6dfe0059fc5db462",
"noMatch": "6601ab7c6987bf11",
"outputNotCaptured": "45106b5442854c38",
"title": "66055b66d4d610b4",
"unknownAgent": "342b4ab21e2eb31a",
"unknownScope": "6246e12e56bc3aa2"
"unhealthy": "317b1fbc1e13beeb",
"unhealthyDisabled": "64c27eff26d9a562"
},
"scopeCount": "5411544954ed8f59",
"scopeNouns": {
"session": "3f3af1ecebbd1410",
"span": "37a0025aa1f2e29f",
"trace": "eafe895eb8119e6e"
},
"scopes": {
"all": "a52ace420f2175d0",
"filterHint": "395e2b0c8bb6b19e",
"filterLabel": "503647ae568c573b",
"session": "6959b4159575d8dd",
"span": "5b0e83c19eb41845",
"trace": "7e14c3d7dd12bbd0"
},
"setThreshold": "a708e7ac6bddb316",
"table": {
"averageRange": "2318f0d9be0999c5",
"columns": {
"health": "55898449eb74fb2e",
"scoreConfig": "84039a14d5ad9a94",
"scores": "c5f09715dba8604d",
"typicalScore": "21242c16652268a4",
"unhealthy": "317b1fbc1e13beeb",
"updated": "3a5ecca188c0579c"
},
"configLink": "87e89abb4c1c551f",
"mostCommon": "e1f0b823d6de14a9",
"openUnhealthy": "47f8f7ac3b996ddb",
"searchPlaceholder": "8bd11a03fc493153"
},
"tiles": {
"allGiveResult": "b43db3198ee5d6f8",
"attention": "c705641cc5b4fd62",
"attentionNote": "cab88e281cc40392",
"attentionTooltip": "12adcafc7502d45d",
"noScores": "c48b40434b3bfa84",
"noThreshold": "259270af3152ab51",
"ofTotal": "43db84087c320671",
"withoutResult": "496e670f4873446a",
"withoutResultTooltip": "00cec2e30b91d0bd"
}
},
"saved": "1e37a2e5e2cd6f23",
@@ -19267,7 +19243,6 @@
"scorers": "d8598427499abb65"
},
"title": "b25cc6989b4f2bc5",
"topCategory": "24ddf469eeb218d4",
"traceScoreChip": {
"allScores": "1e99f88511c40151",
"moreCount": "ecccea94c62457a7",
@@ -19275,7 +19250,6 @@
"reasoning": "d3ae14a50bfe463f",
"scoredAt": "49f475a142832d91"
},
"trueRate": "d5c5a6de7f1f2849",
"unknownError": "27c2ccd962c2b8dc",
"validation": {
"mustBeNumberBetweenZeroAndOne": "3afc12fc4cc6dca0",
@@ -23052,6 +23026,7 @@
"pipelinesAdd": "f08dbc4665fafa23",
"pipelinesImport": "d065d3e3aba4359c",
"pipelinesRefresh": "3cf8d66227384dc2",
"qualityBack": "66a0c37b52f215ed",
"regexPatternsRefresh": "3cf8d66227384dc2",
"reportsAdd": "529b9d4aa1cf7301",
"reportsRefresh": "3cf8d66227384dc2",
@@ -23153,6 +23128,7 @@
"panelEditor": "2f8a354915ba91ad",
"pipelineDestinations": "c973c318806d3c92",
"pipelines": "d1a8fffeb935847e",
"quality": "3d1c3c527d4e499a",
"regexPatterns": "83a58bf362610444",
"reports": "dacca3cba3f346a4",
"rumErrors": "52dd701cb2cf1fd2",
+5 -2
View File
@@ -63,8 +63,8 @@ use {
openobserve_api_management::request::{
ai, alert_calibration, annotation_queues, annotations, anomaly_detection, datasets,
discovery, domain_management, eval_jobs, experiments, fleet, gen_ai, keys, license, oncall,
playground, prompts, providers, remote_tasks, score_configs, scorers, service_streams,
workflows,
playground, prompts, providers, quality, remote_tasks, score_configs, scorers,
service_streams, workflows,
},
openobserve_api_pipelines::request::re_pattern,
openobserve_api_search::search::patterns,
@@ -1763,6 +1763,9 @@ pub fn service_routes() -> Router {
.route("/{org_id}/tasks/{entity_id}", get(remote_tasks::get_remote_task).put(remote_tasks::save_remote_task_draft).delete(remote_tasks::delete_remote_task))
.route("/{org_id}/score_configs", get(score_configs::list_score_configs).post(score_configs::create_score_config).put(score_configs::ensure_score_config))
.route("/{org_id}/score_configs/{entity_id}/versions", get(score_configs::list_score_config_versions))
.route("/{org_id}/score_configs/quality", get(quality::list_quality_summaries))
.route("/{org_id}/score_configs/{entity_id}/quality", get(quality::list_quality_scores))
.route("/{org_id}/score_configs/{entity_id}/quality/timeline", get(quality::get_quality_timeline))
.route("/{org_id}/score_configs/{entity_id}", get(score_configs::get_score_config).put(score_configs::update_score_config).delete(score_configs::delete_score_config))
// Scorers (Online Eval Phase 2)
+2
View File
@@ -42,6 +42,8 @@ pub mod prompts;
#[cfg(feature = "enterprise")]
pub mod providers;
#[cfg(feature = "enterprise")]
pub mod quality;
#[cfg(feature = "enterprise")]
pub mod remote_tasks;
pub mod reports;
#[cfg(feature = "enterprise")]
+110
View File
@@ -0,0 +1,110 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use config::meta::self_reporting::llm_scores::LlmScoreTargetScope;
use openobserve_core::llm_evaluations::quality::{
DEFAULT_PAGE_SIZE, ListQualityScores, QualityFilter,
};
use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Clone, Debug, Deserialize, IntoParams)]
#[serde(deny_unknown_fields)]
#[into_params(parameter_in = Query)]
pub struct ListQualityQuery {
/// Inclusive Score write-time lower bound, in microseconds.
pub start_time: i64,
/// Exclusive Score write-time upper bound, in microseconds.
pub end_time: i64,
/// `span`, `trace` or `session`. Omit for all scopes.
#[param(value_type = Option<String>, example = "trace")]
pub scope: Option<LlmScoreTargetScope>,
/// Only Scores of this agent ID.
pub agent_id: Option<String>,
/// Only Scores of this agent name.
pub agent_name: Option<String>,
/// Only Scores of this agent environment.
pub agent_env: Option<String>,
/// Only Scores of this agent version.
pub agent_version: Option<String>,
}
impl From<ListQualityQuery> for QualityFilter {
fn from(value: ListQualityQuery) -> Self {
Self {
start_time: value.start_time,
end_time: value.end_time,
scope: value.scope,
agent_id: value.agent_id,
agent_name: value.agent_name,
agent_env: value.agent_env,
agent_version: value.agent_version,
}
}
}
#[derive(Clone, Debug, Deserialize, IntoParams)]
#[serde(deny_unknown_fields)]
#[into_params(parameter_in = Query)]
pub struct ListQualityScoresQuery {
/// Inclusive Score write-time lower bound, in microseconds.
pub start_time: i64,
/// Exclusive Score write-time upper bound, in microseconds.
pub end_time: i64,
/// `span`, `trace` or `session`. Omit for all scopes.
#[param(value_type = Option<String>, example = "trace")]
pub scope: Option<LlmScoreTargetScope>,
pub agent_id: Option<String>,
pub agent_name: Option<String>,
pub agent_env: Option<String>,
pub agent_version: Option<String>,
/// Only Scores that fail the config's healthy threshold.
pub unhealthy_only: Option<bool>,
/// Numeric configs: first distribution bucket index to include.
pub bucket_from: Option<i64>,
/// Numeric configs: last distribution bucket index to include, `bucket_from` when omitted.
pub bucket_to: Option<i64>,
/// Boolean and categorical configs: exact value to include.
pub value: Option<String>,
/// Zero-based result offset. Defaults to 0.
pub from: Option<usize>,
/// Page size from 1 through 100. Defaults to 20.
pub size: Option<usize>,
}
impl From<ListQualityScoresQuery> for ListQualityScores {
fn from(value: ListQualityScoresQuery) -> Self {
Self {
filter: QualityFilter {
start_time: value.start_time,
end_time: value.end_time,
scope: value.scope,
agent_id: value.agent_id,
agent_name: value.agent_name,
agent_env: value.agent_env,
agent_version: value.agent_version,
},
unhealthy_only: value.unhealthy_only.unwrap_or(false),
buckets: match (value.bucket_from, value.bucket_to) {
(Some(from), to) => Some((from, to.unwrap_or(from))),
(None, Some(to)) => Some((to, to)),
(None, None) => None,
},
value: value.value,
from: value.from.unwrap_or(0),
size: value.size.unwrap_or(DEFAULT_PAGE_SIZE),
}
}
}
+2
View File
@@ -66,6 +66,8 @@ pub mod prompts;
pub mod providers;
pub mod public_dashboards;
pub mod public_rate_limit;
#[cfg(feature = "enterprise")]
pub mod quality;
pub mod query_history;
#[cfg(feature = "enterprise")]
pub mod remote_tasks;
+270
View File
@@ -0,0 +1,270 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use axum::{
extract::{Path, Query},
response::Response,
};
use infra::table::score_configs::ScoreConfig;
use openobserve_api_common::extractors::Headers;
use openobserve_core::{
auth::{UserEmail, check_permissions, is_ofga_object_visible},
http::map_error_to_http_response,
llm_evaluations::quality::{
self, QualityConfigList, QualityError, QualityScorePage, QualityTimeline,
},
};
use crate::{
common::meta::http::HttpResponse as MetaHttpResponse,
models::quality::{ListQualityQuery, ListQualityScoresQuery},
};
/// Same visibility as ListScoreConfigs; the routes only check the org-level trace grant.
async fn visible_configs(org_id: &str, user_id: &str) -> Result<impl Fn(&str) -> bool, Response> {
let permitted_objects = openobserve_api_common::auth::validator::list_objects_for_user(
org_id,
user_id,
"GET",
"score_config",
)
.await
.map_err(|error| MetaHttpResponse::forbidden(error.to_string()))?;
let org_id = org_id.to_string();
Ok(move |entity_id: &str| config_visible(&org_id, entity_id, permitted_objects.as_deref()))
}
fn config_visible(org_id: &str, entity_id: &str, permitted_objects: Option<&[String]>) -> bool {
is_ofga_object_visible(org_id, "score_config", entity_id, permitted_objects)
}
/// The per-config grant GetScoreConfig gets; the route itself only checks the org trace grant.
async fn check_config_access(org_id: &str, entity_id: &str, user_id: &str) -> Result<(), Response> {
let allowed = check_permissions(
entity_id,
org_id,
user_id,
"score_configs",
"GET",
None,
false,
false,
true,
)
.await;
require_visible(allowed, entity_id).map_err(quality_error_response)
}
/// A hidden config answers like a missing one, so the 404 does not reveal that it exists.
fn require_visible(allowed: bool, entity_id: &str) -> Result<(), QualityError> {
if allowed {
Ok(())
} else {
Err(QualityError::NotFound(entity_id.to_string()))
}
}
fn quality_error_response(error: QualityError) -> Response {
match error {
QualityError::NotFound(_) => MetaHttpResponse::not_found(error),
error @ (QualityError::InvalidTimeRange
| QualityError::InvalidPageSize
| QualityError::UnsupportedScope
| QualityError::InvalidValueFilter(_)) => MetaHttpResponse::bad_request(error),
QualityError::Infra(error) => {
log::error!("[Quality] score-config database error: {error}");
MetaHttpResponse::internal_error("Internal server error")
}
QualityError::Search(error) => {
log::error!("[Quality] {error}");
match error.downcast_ref::<infra::errors::Error>() {
Some(error) => map_error_to_http_response(error, None),
None => MetaHttpResponse::internal_error("Quality query failed"),
}
}
error @ QualityError::MalformedSearchResponse(_) => {
log::error!("[Quality] {error}");
MetaHttpResponse::internal_error("Quality query failed")
}
}
}
/// ListScoreConfigQuality
#[utoipa::path(
get,
path = "/{org_id}/score_configs/quality",
context_path = "/api",
tag = "ScoreConfigs",
operation_id = "ListScoreConfigQuality",
summary = "Quality summary for every Score Config",
description = "Reads the latest Score per evaluation for each active Score Config the caller can see, in the time window, and returns its health status, totals, average, per-scope counts and most frequent value. The scope filter applies to everything except the per-scope counts.",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
ListQualityQuery,
),
responses(
(status = 200, body = inline(QualityConfigList)),
(status = 400, description = "Invalid request or search query", content_type = "application/json", body = MetaHttpResponse),
(status = 403, description = "Forbidden", content_type = "application/json", body = MetaHttpResponse),
(status = 500, description = "Quality query failed", content_type = "application/json", body = MetaHttpResponse),
),
extensions(("x-o2-ratelimit" = json!({"module": "ScoreConfigs", "operation": "list"}))),
)]
pub async fn list_quality_summaries(
Path(org_id): Path<String>,
Query(query): Query<ListQualityQuery>,
Headers(user_email): Headers<UserEmail>,
) -> Response {
let is_visible = match visible_configs(&org_id, &user_email.user_id).await {
Ok(is_visible) => is_visible,
Err(response) => return response,
};
match quality::list_summaries(&org_id, query.into(), |config: &ScoreConfig| {
is_visible(&config.entity_id)
})
.await
{
Ok(list) => MetaHttpResponse::json(list),
Err(error) => quality_error_response(error),
}
}
/// ListScoreConfigQualityScores
#[utoipa::path(
get,
path = "/{org_id}/score_configs/{entity_id}/quality",
context_path = "/api",
tag = "ScoreConfigs",
operation_id = "ListScoreConfigQualityScores",
summary = "Value distribution and latest Scores for one Score Config",
description = "Pages the latest Score per evaluation for the config, newest first. Filters by scope, agent, health, numeric distribution bucket or exact value. The total counts every Score that matches the filters. The value distribution follows only the scope and agent filters, so it stays the same while the page drills into one bucket or value.",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("entity_id" = String, Path, description = "Score Config entity ID"),
ListQualityScoresQuery,
),
responses(
(status = 200, body = inline(QualityScorePage)),
(status = 400, description = "Invalid request or search query", content_type = "application/json", body = MetaHttpResponse),
(status = 403, description = "Forbidden", content_type = "application/json", body = MetaHttpResponse),
(status = 404, description = "Score Config not found", content_type = "application/json", body = MetaHttpResponse),
(status = 500, description = "Quality query failed", content_type = "application/json", body = MetaHttpResponse),
),
extensions(("x-o2-ratelimit" = json!({"module": "ScoreConfigs", "operation": "list"}))),
)]
pub async fn list_quality_scores(
Path((org_id, entity_id)): Path<(String, String)>,
Query(query): Query<ListQualityScoresQuery>,
Headers(user_email): Headers<UserEmail>,
) -> Response {
if let Err(response) = check_config_access(&org_id, &entity_id, &user_email.user_id).await {
return response;
}
match quality::list_scores(&org_id, &entity_id, query.into()).await {
Ok(page) => MetaHttpResponse::json(page),
Err(error) => quality_error_response(error),
}
}
/// GetScoreConfigQualityTimeline
#[utoipa::path(
get,
path = "/{org_id}/score_configs/{entity_id}/quality/timeline",
context_path = "/api",
tag = "ScoreConfigs",
operation_id = "GetScoreConfigQualityTimeline",
summary = "Quality trend for one Score Config",
description = "Splits the time window into buckets and returns, per bucket, the count, unhealthy count, true count, average and p95 of the latest Score per evaluation for the config. Follows only the scope and agent filters, so paging or drilling into the Scores does not change it.",
security(("Authorization" = [])),
params(
("org_id" = String, Path, description = "Organization name"),
("entity_id" = String, Path, description = "Score Config entity ID"),
ListQualityQuery,
),
responses(
(status = 200, body = inline(QualityTimeline)),
(status = 400, description = "Invalid request or search query", content_type = "application/json", body = MetaHttpResponse),
(status = 403, description = "Forbidden", content_type = "application/json", body = MetaHttpResponse),
(status = 404, description = "Score Config not found", content_type = "application/json", body = MetaHttpResponse),
(status = 500, description = "Quality query failed", content_type = "application/json", body = MetaHttpResponse),
),
extensions(("x-o2-ratelimit" = json!({"module": "ScoreConfigs", "operation": "list"}))),
)]
pub async fn get_quality_timeline(
Path((org_id, entity_id)): Path<(String, String)>,
Query(query): Query<ListQualityQuery>,
Headers(user_email): Headers<UserEmail>,
) -> Response {
if let Err(response) = check_config_access(&org_id, &entity_id, &user_email.user_id).await {
return response;
}
match quality::timeline(&org_id, &entity_id, query.into()).await {
Ok(timeline) => MetaHttpResponse::json(timeline),
Err(error) => quality_error_response(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_client_errors() {
let status = |error| quality_error_response(error).status().as_u16();
assert_eq!(status(QualityError::NotFound("cfg".to_string())), 404);
assert_eq!(status(QualityError::InvalidTimeRange), 400);
assert_eq!(
status(QualityError::InvalidValueFilter("x".to_string())),
400
);
assert_eq!(
status(QualityError::Search(anyhow::anyhow!("private details"))),
500
);
}
#[test]
fn hidden_config_answers_404_like_a_missing_one() {
assert!(require_visible(true, "cfg").is_ok());
let error = require_visible(false, "cfg").unwrap_err();
assert!(matches!(&error, QualityError::NotFound(id) if id == "cfg"));
assert_eq!(quality_error_response(error).status().as_u16(), 404);
}
#[test]
fn list_shows_only_permitted_configs() {
let grants = |ids: &[&str]| ids.iter().map(|id| id.to_string()).collect::<Vec<_>>();
assert!(config_visible("org-1", "cfg-1", None));
assert!(config_visible(
"org-1",
"cfg-1",
Some(grants(&["score_config:cfg-1"]).as_slice())
));
assert!(config_visible(
"org-1",
"cfg-1",
Some(grants(&["score_config:_all_org-1"]).as_slice())
));
assert!(!config_visible(
"org-1",
"cfg-1",
Some(grants(&["score_config:cfg-2", "score_config:_all_org-2"]).as_slice())
));
assert!(!config_visible("org-1", "cfg-1", Some(&[])));
}
}
@@ -65,6 +65,8 @@ pub const ATTR_TARGET_STREAM: &str = "target_stream";
pub const ATTR_TARGET_STREAM_TYPE: &str = "target_stream_type";
pub const ATTR_TARGET_AGENT_NAME: &str = "target_agent_name";
pub const ATTR_TARGET_AGENT_ID: &str = "target_agent_id";
pub const ATTR_TARGET_AGENT_ENV: &str = "target_agent_env";
pub const ATTR_TARGET_AGENT_VERSION: &str = "target_agent_version";
// Identity of the evaluator
pub const ATTR_SCORER_ID: &str = "scorer_id";
@@ -164,6 +166,8 @@ mod tests {
ATTR_TARGET_STREAM_TYPE,
ATTR_TARGET_AGENT_NAME,
ATTR_TARGET_AGENT_ID,
ATTR_TARGET_AGENT_ENV,
ATTR_TARGET_AGENT_VERSION,
ATTR_SCORER_ID,
ATTR_SCORER_VERSION,
ATTR_SCORER_TYPE,
@@ -352,6 +352,18 @@ pub struct LlmScoreRecord {
/// Justification for this individual Score dimension.
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
/// Bounded excerpt of the target input the scorer evaluated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_preview: Option<String>,
/// Bounded excerpt of the target output the scorer evaluated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_preview: Option<String>,
/// Span the scorer read the target input from, when it came from one span.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_span_id: Option<String>,
/// Span the scorer read the target output from, when it came from one span.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_span_id: Option<String>,
/// Attempts this scorer dimension has consumed, 1-based. Resuming the
/// bounded sequence after a restart reads this and nothing else.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -436,6 +448,10 @@ impl Default for LlmScoreRecord {
job_id: None,
job_version: None,
reasoning: None,
input_preview: None,
output_preview: None,
input_span_id: None,
output_span_id: None,
attempt_count: None,
error_reason: None,
annotation_id: None,
@@ -484,6 +500,10 @@ impl LlmScoreRecord {
job_id: Some(String::new()),
job_version: Some(0),
reasoning: Some(String::new()),
input_preview: Some(String::new()),
output_preview: Some(String::new()),
input_span_id: Some(String::new()),
output_span_id: Some(String::new()),
skip_reason: Some(ExperimentSkipReason::NoReference),
attempt_count: Some(0),
error_reason: Some(LlmScoreErrorReason::AttemptFailed),
@@ -629,6 +649,10 @@ mod tests {
job_id: Some("job-1".to_string()),
job_version: Some(1),
reasoning: None,
input_preview: None,
output_preview: None,
input_span_id: None,
output_span_id: None,
attempt_count: None,
error_reason: None,
annotation_id: None,
@@ -771,6 +795,8 @@ mod tests {
assert!(!obj.contains_key("target_agent_id"));
assert!(obj.contains_key("job_id"));
assert!(obj.contains_key("reasoning"));
assert!(obj.contains_key("input_span_id"));
assert!(obj.contains_key("output_span_id"));
assert!(obj.contains_key("review_submission_comments"));
assert!(obj.contains_key("scorer_metadata"));
assert!(obj.contains_key("author"));
+2 -2
View File
@@ -18,6 +18,6 @@ pub mod evaluator_trace_exporter;
pub use o2_enterprise::enterprise::llm_evaluations::{
annotation_queues, annotations, datasets, discovery, evaluator_trace, experiments, playground,
prepared_scorers, providers, remote_tasks, score_configs, score_policy, score_writer, scorers,
secrets, sync_scoring,
prepared_scorers, providers, quality, remote_tasks, score_configs, score_policy, score_writer,
scorers, secrets, sync_scoring,
};
+2
View File
@@ -1605,6 +1605,8 @@ async fn process_llm_evaluation_node(
target_stream_type: ctx.source_stream_type.clone(),
target_agent_name: ctx.agent_name.clone(),
target_agent_id: ctx.agent_id.clone(),
target_agent_env: ctx.agent_env.clone(),
target_agent_version: ctx.agent_version.clone(),
scorer_id: None,
scorer_version: None,
scorer_type: None,
@@ -73,6 +73,8 @@ struct EvaluatorSpanReflection {
attributes_target_stream_type: String,
attributes_target_agent_name: String,
attributes_target_agent_id: String,
attributes_target_agent_env: String,
attributes_target_agent_version: String,
attributes_scorer_id: String,
attributes_scorer_version: String,
attributes_scorer_type: String,
@@ -129,6 +131,8 @@ impl EvaluatorSpanReflection {
attributes_target_stream_type: String::new(),
attributes_target_agent_name: String::new(),
attributes_target_agent_id: String::new(),
attributes_target_agent_env: String::new(),
attributes_target_agent_version: String::new(),
attributes_scorer_id: String::new(),
attributes_scorer_version: String::new(),
attributes_scorer_type: String::new(),
@@ -262,6 +266,8 @@ mod tests {
assert!(obj.contains_key("attributes_target_stream_type"));
assert!(obj.contains_key("attributes_target_agent_name"));
assert!(obj.contains_key("attributes_target_agent_id"));
assert!(obj.contains_key("attributes_target_agent_env"));
assert!(obj.contains_key("attributes_target_agent_version"));
assert!(obj.contains_key("attributes_scorer_id"));
assert!(obj.contains_key("attributes_scorer_version"));
assert!(obj.contains_key("attributes_scorer_type"));
-4
View File
@@ -369,10 +369,6 @@ const COLOR_ALLOWLIST = [
"web/src/components/slos/SloTimeSlicePreview.vue",
"web/src/components/traces/FlameGraphView.vue",
"web/src/enterprise/components/billings/usage.vue",
"web/src/enterprise/components/onlineEvals/quality/QualityBooleanBarsChart.vue",
"web/src/enterprise/components/onlineEvals/quality/QualityBooleanTrendChart.vue",
"web/src/enterprise/components/onlineEvals/quality/QualityCategoryBarsChart.vue",
"web/src/enterprise/components/onlineEvals/quality/QualityDistributionChart.vue",
"web/src/lib/core/EmptyState/OEmptyState.vue",
"web/src/lib/forms/Color/OColor.vue",
"web/src/lib/overlay/Tooltip/OTooltip.vue",
@@ -0,0 +1,365 @@
// @vitest-environment jsdom
import { defineComponent, h } from "vue";
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import store from "@/test/unit/helpers/store";
import { queryClient } from "@/composables/query/queryClient";
import { onlineEvalKeys } from "@/services/online-evals.service.querykeys";
import type { QualityScoresParams } from "@/services/online-evals.service";
// An absolute range in the URL, so the window only moves when the test says so.
const { START, END, DAY, mocks, route, replace, picker } = await vi.hoisted(async () => {
const { reactive } = await import("vue");
const START = 1_791_100_000_000_000;
return {
START,
END: START + 3_600_000_000,
DAY: 86_400_000_000,
mocks: {
list: vi.fn(),
scores: vi.fn(),
timeline: vi.fn(),
listAgents: vi.fn(),
},
route: reactive({ name: "aiEvaluations", query: {} as Record<string, string> }),
replace: vi.fn(() => Promise.resolve()),
picker: { window: { startTime: START, endTime: START + 3_600_000_000 } },
};
});
vi.mock("@/services/online-evals.service", async (importOriginal) => {
const actual: any = await importOriginal();
const empty = () => Promise.resolve([]);
return {
...actual,
default: {
...actual.default,
providers: { ...actual.default.providers, list: empty },
scoreConfigs: { ...actual.default.scoreConfigs, list: empty },
scorers: { ...actual.default.scorers, list: empty },
jobs: { ...actual.default.jobs, list: empty },
quality: { list: mocks.list, scores: mocks.scores, timeline: mocks.timeline },
},
};
});
vi.mock("@/services/gen-ai-agent-mapping.service", async (importOriginal) => {
const actual: any = await importOriginal();
return { ...actual, default: { ...actual.default, listAgents: mocks.listAgents } };
});
vi.mock("vue-router", () => ({
useRoute: () => route,
useRouter: () => ({
push: vi.fn(() => Promise.resolve()),
replace,
back: vi.fn(),
resolve: () => ({ href: "/" }),
}),
}));
import OnlineEvals from "./OnlineEvals.vue";
import { useAiDateRange } from "@/enterprise/composables/useAiDateRange";
const PickerStub = defineComponent({
name: "DateTimePickerDashboard",
inheritAttrs: false,
props: { modelValue: { type: Object, default: null } },
emits: ["update:modelValue"],
setup(props, { expose }) {
expose({ getConsumableDateTime: () => picker.window });
return () =>
h("div", {
"data-test": "picker-stub",
"data-type": props.modelValue?.valueType,
"data-period": props.modelValue?.relativeTimePeriod ?? "",
});
},
});
// Lets the test page the score list the way the real table's pager does.
const ScoresTableStub = defineComponent({
name: "OTable",
inheritAttrs: false,
emits: ["update:current-page"],
setup(_, { emit }) {
return () =>
h("button", { "data-test": "next-page", onClick: () => emit("update:current-page", 2) });
},
});
const PaneStub = defineComponent({
name: "QualityScorePane",
props: ["score"],
setup(props) {
return () => h("div", { "data-test": "pane-stub" }, props.score?.id ?? "");
},
});
const scoreRow = (id: string) => ({
id,
timestamp: 1,
refTimestamp: 1,
sourceType: "llm_judge",
targetScope: "trace",
targetId: id,
spanId: null,
traceId: id,
sessionId: null,
sourceStream: "default",
sourceStreamType: "traces",
value: "low",
unhealthy: false,
reasoning: null,
evaluatorTraceId: null,
taskId: null,
inputPreview: null,
outputPreview: null,
inputSpanId: null,
outputSpanId: null,
});
const PAGE = {
average: null,
distribution: [
{ key: "low", count: 10, unhealthy: 0 },
{ key: "medium", count: 15, unhealthy: 15 },
],
total: 25,
};
const scoreCalls = () => mocks.scores.mock.calls.map((call) => call[2] as QualityScoresParams);
function mountPage() {
return mount(OnlineEvals, {
props: { hideTabBar: true },
global: {
plugins: [store],
stubs: {
DateTimePickerDashboard: PickerStub,
AgentScopeCascade: true,
AiLastRefreshed: true,
OTable: ScoresTableStub,
QualityDistributionChart: true,
QualityScorePane: PaneStub,
QualityConfigsTable: true,
ScorerFormPage: true,
JobFormPage: true,
ImportScoreConfig: true,
ImportScorer: true,
ScoreConfigList: true,
ScorerList: true,
EvalJobList: true,
ScorerTypeDialog: true,
ScoreConfigDialog: true,
ScoreConfigDetail: true,
ScorerDetail: true,
EvalJobDetail: true,
ScoreConfigLibrary: true,
ScorerLibrary: true,
ConfirmDialog: true,
ODrawer: true,
},
},
});
}
/** Opens the toxicity detail on page 2, so page 1 stays in the cache as an earlier view. */
async function openOnSecondPage() {
const wrapper = mountPage();
await flushPromises();
await wrapper.find('[data-test="next-page"]').trigger("click");
await flushPromises();
expect(scoreCalls().map((params) => params.from)).toEqual([0, 10]);
// The trend takes no page, so paging leaves it alone.
expect(mocks.timeline).toHaveBeenCalledTimes(1);
for (const mock of Object.values(mocks)) mock.mockClear();
return wrapper;
}
const cachedPage = (from: number) =>
queryClient
.getQueryCache()
.findAll({ queryKey: onlineEvalKeys.quality("default") })
.filter((query) => query.queryKey[4] === "scores" && (query.queryKey[6] as any).from === from);
// Mounted pages read the shared reactive route, so each test unmounts its own.
enableAutoUnmount(afterEach);
function resetMocks() {
picker.window = { startTime: START, endTime: END };
replace.mockClear();
mocks.list.mockReset().mockResolvedValue([]);
mocks.listAgents.mockReset().mockResolvedValue({ agents: [] });
mocks.timeline.mockReset().mockResolvedValue({ timeline: [] });
mocks.scores.mockReset().mockImplementation((_org, _id, params) =>
Promise.resolve({
...PAGE,
list: [scoreRow(`s${params.from}`), scoreRow(`s${params.from + 1}`)],
from: params.from,
size: params.size,
}),
);
}
const listWindow = () => {
const params = mocks.list.mock.calls.at(-1)?.[1];
return params ? params.end_time - params.start_time : null;
};
describe("OnlineEvals Quality range", () => {
beforeEach(() => {
route.query = { tab: "quality" };
resetMocks();
});
it("opens Quality at Past 24 Hours, while the shared AI range keeps its 15-minute default", async () => {
const wrapper = mountPage();
await flushPromises();
const pickerStub = wrapper.find('[data-test="picker-stub"]');
expect(pickerStub.attributes("data-type")).toBe("relative");
expect(pickerStub.attributes("data-period")).toBe("24h");
expect(listWindow()).toBe(DAY);
expect(useAiDateRange().state.value.relativeTimePeriod).toBe("15m");
});
it("keeps a chosen range in the URL and leaves the LLM Insights and Sessions range alone", async () => {
const shared = JSON.stringify(useAiDateRange().state.value);
const wrapper = mountPage();
await flushPromises();
picker.window = { startTime: START, endTime: START + 7 * DAY };
wrapper.findComponent(PickerStub).vm.$emit("update:modelValue", {
valueType: "relative",
relativeTimePeriod: "7d",
startTime: START,
endTime: START + 7 * DAY,
});
await flushPromises();
expect(listWindow()).toBe(7 * DAY);
expect(replace).toHaveBeenLastCalledWith({
query: expect.objectContaining({ tab: "quality", period: "7d" }),
});
expect(JSON.stringify(useAiDateRange().state.value)).toBe(shared);
expect(localStorage.getItem("aiObservability:dateRange")).toBeNull();
});
it("reopens at the range in the URL after a reload", async () => {
route.query = { tab: "quality", period: "7d" };
const relative = mountPage();
await flushPromises();
expect(relative.find('[data-test="picker-stub"]').attributes("data-period")).toBe("7d");
expect(listWindow()).toBe(7 * DAY);
route.query = { tab: "quality", from: String(START), to: String(END) };
const absolute = mountPage();
await flushPromises();
expect(absolute.find('[data-test="picker-stub"]').attributes("data-type")).toBe("absolute");
expect(mocks.list).toHaveBeenLastCalledWith(
"default",
expect.objectContaining({ start_time: START, end_time: END }),
);
});
});
describe("OnlineEvals Quality detail header", () => {
beforeEach(() => {
route.query = { tab: "quality", config: "tox", from: String(START), to: String(END) };
resetMocks();
});
it("uses one header, the detail header, with the range, Refresh and the agent filters kept", async () => {
const wrapper = mountPage();
await flushPromises();
const headers = wrapper.findAll("header");
expect(headers).toHaveLength(1);
expect(headers[0].find('[data-test="quality-detail-back"]').exists()).toBe(true);
expect(headers[0].find('[data-test="quality-detail-edit-config"]').exists()).toBe(true);
expect(headers[0].find('[data-test="picker-stub"]').exists()).toBe(true);
expect(headers[0].find('[data-test="quality-refresh-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="quality-scope-bar"] agent-scope-cascade-stub').exists()).toBe(
true,
);
});
});
describe("OnlineEvals Quality round trip through a link", () => {
beforeEach(() => {
route.query = { tab: "quality", config: "tox", from: String(START), to: String(END) };
resetMocks();
});
it("writes the page and score with replace, then reopens them from the cache after Back", async () => {
const wrapper = await openOnSecondPage();
expect(replace).toHaveBeenLastCalledWith({
name: "aiEvaluations",
query: expect.objectContaining({ config: "tox", page: "2", score: "s10" }),
});
wrapper.unmount();
// Back from the trace: the page remounts from the URL it left with.
route.query = { ...route.query, page: "2", score: "s11" };
const restored = mountPage();
await flushPromises();
expect(restored.find('[data-test="pane-stub"]').text()).toBe("s11");
expect(mocks.list).not.toHaveBeenCalled();
expect(mocks.scores).not.toHaveBeenCalled();
expect(mocks.timeline).not.toHaveBeenCalled();
expect(mocks.listAgents).not.toHaveBeenCalled();
});
});
describe("OnlineEvals Quality Refresh", () => {
beforeEach(() => {
route.query = { tab: "quality", config: "tox", from: String(START), to: String(END) };
resetMocks();
});
it("fetches each read once, keeps the scores page, and marks earlier views stale", async () => {
const wrapper = await openOnSecondPage();
await wrapper.find('[data-test="quality-refresh-btn"]').trigger("click");
await flushPromises();
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.listAgents).toHaveBeenCalledTimes(1);
expect(mocks.timeline).toHaveBeenCalledTimes(1);
expect(scoreCalls()).toEqual([expect.objectContaining({ from: 10, start_time: START })]);
expect(cachedPage(0).every((query) => query.state.isInvalidated)).toBe(true);
expect(cachedPage(10).every((query) => !query.state.isInvalidated)).toBe(true);
});
it("forces the score pane's cached spans too, like the other reads on the view", async () => {
const wrapper = await openOnSecondPage();
// Loaded before the Refresh, so the "not fetched by this Refresh" rule applies.
const updatedAt = Date.now() - 1000;
const spansKey = onlineEvalKeys.qualitySpans("default", "default", ["span-1"]);
queryClient.setQueryData(spansKey, [], { updatedAt });
const otherOrg = onlineEvalKeys.qualitySpans("other", "default", ["span-1"]);
queryClient.setQueryData(otherOrg, [], { updatedAt });
await wrapper.find('[data-test="quality-refresh-btn"]').trigger("click");
await flushPromises();
expect(queryClient.getQueryState(spansKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherOrg)?.isInvalidated).toBe(false);
});
it("fetches each read once when the Refresh moves the window into a new minute", async () => {
const wrapper = await openOnSecondPage();
picker.window = { startTime: START + 120_000_000, endTime: END + 120_000_000 };
await wrapper.find('[data-test="quality-refresh-btn"]').trigger("click");
await flushPromises();
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.list).toHaveBeenCalledWith(
"default",
expect.objectContaining({ start_time: START + 120_000_000 }),
);
expect(scoreCalls()).toEqual([
expect.objectContaining({ from: 10, start_time: START + 120_000_000 }),
]);
// The reads of the old window stay in the cache, marked stale.
const oldWindow = cachedPage(10).filter(
(query) => (query.queryKey[6] as any).start_time < START + 60_000_000,
);
expect(oldWindow.length).toBe(1);
expect(oldWindow[0].state.isInvalidated).toBe(true);
});
});
+176 -229
View File
@@ -66,7 +66,7 @@ the Free Software Foundation, either version 3 of the License, or
</div>
<OPageHeader
v-if="hideTabBar && embeddedHeader"
v-if="hideTabBar && embeddedHeader && activeTab !== 'quality'"
:title="raw(embeddedHeader.title)"
:subtitle="raw(embeddedHeader.subtitle)"
:icon="embeddedHeader.icon"
@@ -132,16 +132,24 @@ the Free Software Foundation, either version 3 of the License, or
{{ addButtonLabel }}
</OButton>
</template>
<template v-else-if="activeTab === 'quality'" #actions>
<!-- Agent filter is rendered inside QualityPage (right-aligned, above
the KPIs) so it sits within the content container alongside the
data it filters — only the date picker + refresh stay here. -->
<!-- Last-refreshed indicator + labeled primary Refresh button, matching
the other AI pages' AiPageShell header. -->
</OPageHeader>
<!-- Quality draws its own page header, so an open config can take it over as the detail header. -->
<QualityPage
v-if="activeTab === 'quality'"
:score-configs="scoreConfigs"
:configs-loading="isLoading"
:date-window="qualityDateWindow"
:agent-params="qualityAgentParams"
:enabled="qualityScopeReady"
@status="(status) => (qualityStatus = status)"
>
<template #header-actions>
<!-- Last-refreshed indicator + labeled primary Refresh button, matching the other AI pages. -->
<AiLastRefreshed
class="me-1 max-lg:hidden"
:last-run-at="qualityLastRunAt"
:loading="qualityRefreshing"
:last-run-at="qualityStatus.updatedAt"
:loading="qualityBusy"
data-test="quality-last-refreshed"
/>
<DateTimePickerDashboard
@@ -155,17 +163,29 @@ the Free Software Foundation, either version 3 of the License, or
variant="primary"
size="sm-toolbar"
icon-left="refresh"
:disabled="qualityRefreshing"
:loading="qualityRefreshing"
:disabled="qualityBusy"
:loading="qualityBusy"
data-test="quality-refresh-btn"
@click="onQualityRefresh"
>
{{ t("common.refresh") }}
</OButton>
</template>
</OPageHeader>
<template #filters>
<AgentScopeCascade
prefix="quality"
:envs="qualityEnvs"
:agent-names="qualityAgentNames"
:versions="qualityVersions"
v-model:selected-env="qualitySelectedEnv"
v-model:selected-agent-name="qualitySelectedAgentName"
v-model:selected-version="qualitySelectedVersion"
/>
</template>
</QualityPage>
<section
v-else
class="online-evals__content bg-card-glass-bg flex min-h-0 flex-1 flex-col overflow-hidden"
>
<OTabs
@@ -187,22 +207,8 @@ the Free Software Foundation, either version 3 of the License, or
</OTabs>
<div class="online-evals__body flex min-h-0 flex-1">
<QualityPage
v-if="activeTab === 'quality'"
ref="qualityPageRef"
:agent-key="qualityAgentKey"
:agent-options="qualityAgentOptions"
:agents-loading="qualityAgentsLoading"
:date-window="qualityDateWindow"
:agent-filter="selectedQualityAgent"
:score-configs="scoreConfigs"
:configs-loading="isLoading"
@update:agent-key="onQualityAgentChange"
@ready="reloadQuality()"
@reload-configs="reloadLists()"
/>
<ScoreConfigList
v-else-if="activeTab === 'scoreConfigs'"
v-if="activeTab === 'scoreConfigs'"
:forbidden="scoreConfigsForbidden"
:rows="filteredRows as ScoreConfig[]"
:all-score-configs="scoreConfigs"
@@ -385,7 +391,7 @@ import {
setJobActiveMutation,
deleteEvalEntityMutation,
} from "@/services/online-evals.service.queries";
import { useMutation } from "@tanstack/vue-query";
import { useMutation, type Query } from "@tanstack/vue-query";
import { computed, nextTick, onBeforeMount, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useStore } from "vuex";
@@ -437,19 +443,22 @@ import AiLastRefreshed from "@/enterprise/components/AIObservability/AiLastRefre
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import DateTimePickerDashboard from "@/components/DateTimePickerDashboard.vue";
import type { DateWindow } from "./onlineEvals/composables/useQualityData";
import { useAiDateRange, resolveAiDateWindow } from "@/enterprise/composables/useAiDateRange";
import AgentScopeCascade from "@/enterprise/components/AIObservability/AgentScopeCascade.vue";
import { useAgentScope } from "@/enterprise/composables/useAgentScope";
import type { DateWindow } from "./onlineEvals/composables/useQualityList";
import {
DEFAULT_QUALITY_RANGE,
agentParamsFor,
qualityRangeFromQuery,
qualityRangeQuery,
} from "./onlineEvals/utils/qualityFormat";
import { ALL_AGENTS_VALUE } from "@/plugins/traces/llmAgentFilter";
import { resolveAiDateWindow, type AiDateState } from "@/enterprise/composables/useAiDateRange";
import { genAiAgentsQuery } from "@/services/gen-ai-agent-mapping.queries";
import { queryClient } from "@/composables/query/queryClient";
import { onlineEvalKeys } from "@/services/online-evals.service.querykeys";
import { downloadFile } from "@/utils/dom";
import type { I18nKey } from "@/types/i18n";
import {
ALL_AGENTS_VALUE,
agentFilterKey,
agentFilterLabel,
type AgentFilterSelection,
} from "./onlineEvals/utils/agentFilterSql";
import {
bulkExportFileName as bulkExportScoreConfigFileName,
exportScoreConfigFileName,
@@ -652,218 +661,160 @@ const importI18nKey = computed<"scorer" | "scoreConfig">(() =>
activeTab.value === "scorers" ? "scorer" : "scoreConfig",
);
// ── Quality tab: date picker + refresh state ─────────────────────────────
// The picker + refresh button live in the embedded OPageHeader's #actions
// slot (matching LLM Insights / Sessions). QualityPage consumes
// `qualityDateWindow` as a prop and exposes `refreshAll` + `isAnyLoading` for
// the Refresh button below.
//
// Date state is the shared `useAiDateRange()` ref — the same singleton
// driving LLM Insights and Sessions — so picking a window on one page
// lands on the other two (incl. across reloads via localStorage).
const { state: qualitySelectedDate } = useAiDateRange();
// Seed the window from the *persisted* AI date range (relative or absolute),
// resolved synchronously, so QualityPage's initial onMounted refresh queries
// the correct window from the very first paint. Fall back to the 15m default
// only if the persisted state can't be resolved.
const initialQualityWindow = resolveAiDateWindow(qualitySelectedDate.value);
const qualityDateWindow = ref<DateWindow>(
initialQualityWindow
? {
startUs: initialQualityWindow.startTime,
endUs: initialQualityWindow.endTime,
}
: {
startUs: (Date.now() - 15 * 60 * 1000) * 1000,
endUs: Date.now() * 1000,
},
// ── Quality tab: its own range (default Past 24 Hours, kept in the URL); LLM Insights and Sessions keep the shared AI range.
const qualitySelectedDate = ref<AiDateState>(
qualityRangeFromQuery(route.query) ?? { ...DEFAULT_QUALITY_RANGE },
);
const qualityAgents = ref<AgentFilterSelection[]>([]);
const qualityAgentKey = ref(ALL_AGENTS_VALUE);
const initialQualityWindow =
resolveAiDateWindow(qualitySelectedDate.value) ?? resolveAiDateWindow(DEFAULT_QUALITY_RANGE)!;
const qualityDateWindow = ref<DateWindow>({
startUs: initialQualityWindow.startTime,
endUs: initialQualityWindow.endTime,
});
const qualityDatePickerRef = ref<{
getConsumableDateTime: () => { startTime: number; endTime: number };
} | null>(null);
const qualityPageRef = ref<{
refreshAll: (reloadConfigs?: boolean) => Promise<void>;
isAnyLoading: boolean;
} | null>(null);
const qualityAgentOptions = computed(() => [
{ label: t("onlineEvals.quality.allAgents"), value: ALL_AGENTS_VALUE },
...qualityAgents.value.map((agent) => ({
label: raw(agentFilterLabel(agent)),
value: agentFilterKey(agent),
})),
]);
const selectedQualityAgent = computed<AgentFilterSelection | null>(() => {
if (qualityAgentKey.value === ALL_AGENTS_VALUE) return null;
return (
qualityAgents.value.find((agent) => agentFilterKey(agent) === qualityAgentKey.value) ?? null
);
// Env, Agent and Version start at "All"; the selection is local to the page and mirrored in the URL.
const {
agents: qualityAgents,
loadAgents: loadQualityAgentList,
envs: qualityEnvs,
agentNames: qualityAgentNames,
versions: qualityVersions,
selectedEnv: qualitySelectedEnv,
selectedAgentName: qualitySelectedAgentName,
selectedVersion: qualitySelectedVersion,
} = useAgentScope({
filterMode: ref<"stream" | "agent">("agent"),
activeStream: ref(""),
orgId: () => orgId.value,
getWindow: () => ({
start: qualityDateWindow.value.startUs,
end: qualityDateWindow.value.endUs,
}),
cascade: true,
allOption: true,
t,
});
// True while the agent list is being fetched (the FIRST phase of reloadQuality,
// before any quality data loader runs). QualityPage uses this to skeleton the
// agent dropdown + the KPI/table so the page reads as "loading" from the very
// start of a reload instead of looking idle until the data queries kick in.
const qualityAgentsLoading = ref(false);
const qualityAgentParams = computed(() =>
agentParamsFor(
qualityAgents.value,
qualitySelectedEnv.value,
qualitySelectedAgentName.value,
qualitySelectedVersion.value,
),
);
async function loadQualityAgents(force = false) {
const { startUs, endUs } = qualityDateWindow.value;
if (!orgId.value || !startUs || !endUs) return;
qualityAgentsLoading.value = true;
try {
const options = genAiAgentsQuery(orgId.value, startUs, endUs);
// Agents appear as they emit spans and no write expires the list, so a user refresh forces.
if (force) {
await queryClient.invalidateQueries({
queryKey: options.queryKey,
exact: true,
refetchType: "none",
});
}
const response = await queryClient.fetchQuery(options);
qualityAgents.value = response.agents;
const urlAgentName = qualityAgentNameFromUrl();
const urlAgent = urlAgentName
? qualityAgents.value.find((agent) => agent.name === urlAgentName)
: null;
if (urlAgent) qualityAgentKey.value = agentFilterKey(urlAgent);
if (
qualityAgentKey.value !== ALL_AGENTS_VALUE &&
!qualityAgents.value.some((agent) => agentFilterKey(agent) === qualityAgentKey.value)
) {
qualityAgentKey.value = ALL_AGENTS_VALUE;
}
if (urlAgentName && !urlAgent) syncQualityAgentUrl();
} catch (err) {
console.warn("Failed to load GenAI agents", err);
qualityAgents.value = [];
qualityAgentKey.value = ALL_AGENTS_VALUE;
} finally {
qualityAgentsLoading.value = false;
}
// Quality queries wait for the first agent list, so a deep-linked agent applies before the first request.
const qualityScopeReady = ref(false);
const qualityRefreshing = ref(false);
const qualityStatus = ref<{ updatedAt: number | null; fetching: boolean }>({
updatedAt: null,
fetching: false,
});
const qualityBusy = computed(() => qualityRefreshing.value || qualityStatus.value.fetching);
// `?env=&type=agent&agent=&version=`, the same keys LLM Insights writes; old `?type=agent&agent=` links still apply.
function applyQualityScopeFromUrl() {
const { env, type, agent, version } = route.query;
if (typeof env === "string") qualitySelectedEnv.value = env;
if (type === "agent" && typeof agent === "string") qualitySelectedAgentName.value = agent;
if (typeof version === "string") qualitySelectedVersion.value = version;
}
// Same `?type=agent&agent=<name>` convention LLM Insights uses, so links carry the agent between the two pages.
function qualityAgentNameFromUrl(): string | null {
return route.query.type === "agent" && typeof route.query.agent === "string"
? route.query.agent
: null;
}
function syncQualityAgentUrl() {
// One writer for the scope levels and the range, so two replaces in one tick cannot drop each other's keys.
function syncQualityUrl() {
// The route, not activeTab, decides: a link away from Quality may not have reached activeTab yet.
if (parseTabFromRoute(route.query.tab) !== "quality") return;
const query: Record<string, any> = { ...route.query };
const name = selectedQualityAgent.value?.name;
if (name) {
query.type = "agent";
query.agent = name;
} else {
delete query.agent;
if (query.type === "agent") delete query.type;
const params: Record<string, string | undefined> = {
env: qualitySelectedEnv.value,
agent: qualitySelectedAgentName.value,
version: qualitySelectedVersion.value,
...qualityRangeQuery(qualitySelectedDate.value),
};
for (const [key, value] of Object.entries(params)) {
if (value && value !== ALL_AGENTS_VALUE) query[key] = value;
else delete query[key];
}
if (query.agent) query.type = "agent";
else if (query.type === "agent") delete query.type;
// Unchanged URLs are left alone, so this cannot race the page's own page and score replace.
const sorted = (q: Record<string, unknown>) => JSON.stringify(Object.entries(q).sort());
if (sorted(query) === sorted(route.query)) return;
router.replace({ query }).catch(() => {});
}
async function loadQualityAgents(force = false) {
const { startUs, endUs } = qualityDateWindow.value;
if (!orgId.value) return;
// Agents appear as they emit spans and no write expires the list, so a user refresh forces.
if (force) {
await queryClient.invalidateQueries({
queryKey: genAiAgentsQuery(orgId.value, startUs, endUs).queryKey,
exact: true,
refetchType: "none",
});
}
await loadQualityAgentList(startUs, endUs);
if (!qualityScopeReady.value) {
applyQualityScopeFromUrl();
qualityScopeReady.value = true;
syncQualityUrl();
}
}
function syncQualityDateWindow() {
const picker = qualityDatePickerRef.value;
if (!picker) return;
const dt = picker.getConsumableDateTime();
if (dt && typeof dt.startTime === "number" && typeof dt.endTime === "number") {
qualityDateWindow.value = { startUs: dt.startTime, endUs: dt.endTime };
}
const picked = qualityDatePickerRef.value?.getConsumableDateTime();
const resolved =
picked && typeof picked.startTime === "number" && typeof picked.endTime === "number"
? picked
: resolveAiDateWindow(qualitySelectedDate.value);
if (resolved) qualityDateWindow.value = { startUs: resolved.startTime, endUs: resolved.endTime };
}
// ── Quality reload orchestration ─────────────────────────────────────────
// ONE path drives every quality fetch. Exactly three triggers reload
// everything (agent list + KPIs + table + charts):
// 1. The Quality page mounts → @ready
// 2. The user clicks Refresh → onQualityRefresh
// 3. The date-time window changes → watch(qualitySelectedDate)
// All three run the SAME sequence: re-anchor the window from the picker, load
// the agent list FIRST, then load the quality data — so the agent filter is
// always populated before the data it scopes is fetched.
//
// Changing the agent filter is a data-only reload (the agent list itself is
// unchanged) and is handled separately by onQualityAgentChange below.
const qualityReloading = ref(false);
async function reloadQuality(userRefresh = false) {
qualityReloading.value = true;
try {
// On the @ready trigger this runs from QualityPage's onMounted; wait a
// tick so the parent's `qualityPageRef` is guaranteed assigned before we
// call into it.
await nextTick();
syncQualityDateWindow();
await loadQualityAgents(userRefresh);
// The agent key may have changed; let the `agent-filter` prop reach QualityPage before it queries.
await nextTick();
await qualityPageRef.value?.refreshAll?.(userRefresh);
} finally {
qualityReloading.value = false;
}
}
// Trigger 2 — Refresh button. Re-anchors relative ranges ("Past 15 minutes")
// to "now" via the shared reload path.
function onQualityRefresh() {
void reloadQuality(true);
}
// Trigger 3 — date-time change. DateTimePickerDashboard's inner DateTime emits
// `update:modelValue` ONCE on mount (its onMounted calls saveDate → on:date-change),
// re-anchoring qualitySelectedDate to the same window it already holds. That echo
// would fire a second full reloadQuality on top of the @ready one, double-hitting
// every quality API. The picker remounts every time the quality tab is (re-)entered
// (it lives in the tab-scoped header slot), so this echo recurs on each entry — not
// just first load. `qualityDateEchoPending` is armed on entry (see watch(activeTab))
// and here swallows exactly the next (mount) change; genuine user date changes reload.
let qualityDateEchoPending = true;
watch(qualitySelectedDate, () => {
if (qualityDateEchoPending) {
qualityDateEchoPending = false;
return;
}
void reloadQuality();
syncQualityDateWindow();
if (qualityScopeReady.value) syncQualityUrl();
});
// Org switch — reset the agent filter and reload from scratch.
watch(orgId, () => {
qualityAgentKey.value = ALL_AGENTS_VALUE;
syncQualityAgentUrl();
void reloadQuality();
});
// User picked a different agent — only the data needs refetching; the agent
// list is unchanged. Driven explicitly from the dropdown's update event (not a
// watch on the key) so the programmatic reset inside loadQualityAgents()
// during reloadQuality() never double-fires a data reload.
async function onQualityAgentChange(key: string) {
qualityAgentKey.value = key;
syncQualityAgentUrl();
// `selectedQualityAgent` → the `agent-filter` prop → the child's
// `agentFilterRef` only update on the next render tick. Wait for it so
// `refreshAll()` queries the newly selected agent, not the previous one.
await nextTick();
await qualityPageRef.value?.refreshAll?.();
}
// Aggregated "is a quality reload in flight" — covers the agent-load phase
// plus every in-flight QualityPage loader, so the Refresh button spins from
// click to settle.
const qualityRefreshing = computed(
() => qualityReloading.value || (qualityPageRef.value?.isAnyLoading ?? false),
watch(
() => [activeTab.value, orgId.value, qualityDateWindow.value] as const,
([tab]) => {
if (tab === "quality" && !qualityRefreshing.value) void loadQualityAgents();
},
{ immediate: true },
);
// Last-refresh timestamp for the header's ORefreshButton — stamped when a
// reload settles (true → false), mirroring the Sessions / LLM Insights pages.
const qualityLastRunAt = ref<number | null>(null);
watch(qualityRefreshing, (isLoading, wasLoading) => {
if (wasLoading && !isLoading) qualityLastRunAt.value = Date.now();
});
// The config key is watched too: Back to the list restores an entry written before a later Env/Agent/Version change.
watch(
[qualitySelectedEnv, qualitySelectedAgentName, qualitySelectedVersion, () => route.query.config],
() => {
if (qualityScopeReady.value) syncQualityUrl();
},
);
// Refresh moves a relative range to now and forces every read on the Quality view.
async function onQualityRefresh() {
if (qualityRefreshing.value) return;
qualityRefreshing.value = true;
const startedAt = Date.now();
try {
syncQualityDateWindow();
await Promise.all([reloadLists(true), loadQualityAgents(true)]);
// Once the moved window has reached the keys, expire every quality read the Refresh has not already fetched; the active ones refetch.
await nextTick();
await queryClient.invalidateQueries({
queryKey: onlineEvalKeys.quality(orgId.value),
predicate: (query: Query) =>
query.state.dataUpdatedAt < startedAt && query.state.fetchStatus !== "fetching",
});
} finally {
qualityRefreshing.value = false;
}
}
const pendingDeleteLabel = computed(() => {
const tab = pendingDeleteTab.value;
@@ -889,10 +840,6 @@ const deleteDialogMessage = computed(() =>
watch(activeTab, (next) => {
filterQuery.value = "";
// Re-arm the date-picker mount-echo guard: entering quality remounts the
// picker, which emits `update:modelValue` once on mount. @ready already owns
// the entry reload, so that echo must be swallowed to avoid a double fetch.
if (next === "quality") qualityDateEchoPending = true;
if (route.query.tab !== next) {
router.replace({ query: { ...route.query, tab: next } }).catch(() => {});
}
@@ -1,151 +1,347 @@
// @vitest-environment jsdom
import { computed, defineComponent, ref } from "vue";
import { defineComponent } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
import { beforeEach, describe, expect, it, vi } from "vitest";
import en from "@/locales/languages/en-US.json";
import QualityPage from "./QualityPage.vue";
import store from "@/test/unit/helpers/store";
import { queryClient } from "@/composables/query/queryClient";
import { onlineEvalKeys } from "@/services/online-evals.service.querykeys";
import type { QualityConfigSummary } from "@/services/online-evals.service";
const refreshers = vi.hoisted(() => ({
data: vi.fn(),
configs: vi.fn(),
detail: vi.fn(),
charts: vi.fn(),
runs: vi.fn(),
}));
const { list, route, push, replace, back } = await vi.hoisted(async () => {
const { reactive } = await import("vue");
return {
list: vi.fn(),
route: reactive({ name: "aiEvaluations", query: {} as Record<string, string> }),
push: vi.fn(() => Promise.resolve()),
replace: vi.fn(() => Promise.resolve()),
back: vi.fn(),
};
});
vi.mock("@/services/online-evals.service", async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
default: { ...actual.default, quality: { list, scores: vi.fn() } },
};
});
vi.mock("vue-router", () => ({
useRoute: () => ({ name: "evaluations", query: {} }),
useRouter: () => ({ push: vi.fn(() => Promise.resolve()), replace: vi.fn() }),
}));
vi.mock("./composables/useQualityData", () => ({
useQualityData: () => ({
isLoading: ref(false),
kpis: ref([]),
deltaByKpi: computed(() => ({})),
refresh: refreshers.data,
useRoute: () => route,
// A string resolves like the router does for history.state.back; only name and query matter here.
useRouter: () => ({
push,
replace,
back,
resolve: (to: string) => {
const url = new URL(to, "http://o2.test");
return { name: "aiEvaluations", query: Object.fromEntries(url.searchParams) };
},
}),
}));
vi.mock("./composables/useQualityScoreConfigs", () => ({
useQualityScoreConfigs: () => ({
rows: ref([]),
isLoading: ref(false),
refresh: refreshers.configs,
}),
}));
vi.mock("./composables/useQualityConfigDetail", () => ({
useQualityConfigDetail: () => ({
isLoading: ref(false),
dataType: ref(null),
kpis: ref([]),
hasScores: ref(false),
booleanAgg: ref(null),
categoricalRows: ref([]),
refresh: refreshers.detail,
}),
}));
vi.mock("./composables/useQualityDetailCharts", () => ({
useQualityDetailCharts: () => ({
isLoading: ref(false),
numericTrend: ref([]),
numericDistribution: ref([]),
booleanTrend: ref([]),
booleanTrendSeries: ref([]),
refresh: refreshers.charts,
}),
}));
vi.mock("./composables/useQualityRuns", () => ({
useQualityRuns: () => ({
runs: ref([]),
counts: ref({}),
activeFilter: ref("all"),
currentPage: ref(1),
pageSize: ref(20),
totalCount: ref(0),
isLoading: ref(false),
error: ref(null),
refresh: refreshers.runs,
setFilter: vi.fn(),
setPagination: vi.fn(),
resolveEvaluatorSpanId: vi.fn(),
}),
}));
import QualityPage from "./QualityPage.vue";
const TableStub = defineComponent({
name: "QualityScoreConfigsTable",
emits: ["refresh", "select"],
template: `<button data-test="table-refresh" @click="$emit('refresh')" />`,
name: "QualityConfigsTable",
props: ["rows", "tiles", "search"],
emits: ["open", "update:search"],
template: `<div data-test="table-stub">{{ rows.length }} rows</div>`,
});
const DetailStub = defineComponent({
name: "QualityConfigDetail",
props: ["configId", "row", "scope", "only", "initialPage", "initialScoreId"],
emits: ["back", "position"],
template: `<div data-test="detail-stub">{{ configId }} {{ row?.name }} {{ scope }} {{ only }}</div>`,
});
function mountPage() {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en } });
const LIST: QualityConfigSummary[] = [
{
configId: "tox",
name: "toxicity",
dataType: "categorical",
status: "attention",
total: 114,
unhealthy: 67,
average: null,
lastScoredAt: 1,
scopeCounts: { span: 77, trace: 37, session: 0 },
topValue: { key: "medium", count: 67 },
},
{
configId: "sen",
name: "sentiment",
dataType: "categorical",
status: "unset",
total: 30,
unhealthy: null,
average: null,
lastScoredAt: 1,
scopeCounts: { span: 0, trace: 0, session: 30 },
topValue: { key: "neutral", count: 16 },
},
];
const TOX_CONFIG = {
id: "row-1",
entityId: "tox",
name: "toxicity",
version: 2,
dataType: "categorical" as const,
description: "Toxicity level of the response.",
healthyThreshold: { healthy_categories: ["low"] },
};
function mountPage(
dateWindow = { startUs: 1_000_000_000, endUs: 2_000_000_000 },
scoreConfigs: any[] = [],
) {
return mount(QualityPage, {
props: {
scoreConfigs: [],
dateWindow: { startUs: 1, endUs: 2 },
scoreConfigs,
configsLoading: false,
dateWindow,
agentParams: {},
enabled: true,
},
global: {
plugins: [i18n],
stubs: {
QualityScoreConfigsTable: TableStub,
QualityDetailPanel: true,
QualityKpiCard: true,
QualityKpiSkeleton: true,
KpiCardRow: true,
ODrawer: true,
OSelect: true,
OTag: true,
OSkeleton: true,
},
plugins: [store],
stubs: { QualityConfigsTable: TableStub, QualityConfigDetail: DetailStub },
},
slots: {
"header-actions": '<span data-test="header-actions-slot" />',
filters: '<span data-test="filters-slot" />',
},
});
}
function refreshCalls() {
return Object.values(refreshers).map((fn) => fn.mock.calls.length);
}
describe("QualityPage reloads", () => {
describe("QualityPage", () => {
beforeEach(() => {
Object.values(refreshers).forEach((fn) => fn.mockReset().mockResolvedValue(undefined));
route.query = {};
list.mockReset().mockResolvedValue(LIST);
push.mockClear();
replace.mockClear();
back.mockClear();
});
it("only signals ready on mount, without asking for the lists", async () => {
it("shows the configs table without ?config", async () => {
const wrapper = mountPage();
await flushPromises();
expect(wrapper.emitted("ready")).toHaveLength(1);
expect(wrapper.emitted("reload-configs")).toBeUndefined();
expect(wrapper.find('[data-test="table-stub"]').text()).toBe("2 rows");
expect(wrapper.find('[data-test="detail-stub"]').exists()).toBe(false);
});
it("recomputes without re-reading the lists on a mount, date or agent reload", async () => {
it("keeps the config search across a visit to a config's detail", async () => {
const wrapper = mountPage();
await (wrapper.vm as any).refreshAll();
expect(refreshCalls()).toEqual([1, 1, 1, 1, 1]);
expect(wrapper.emitted("reload-configs")).toBeUndefined();
});
it("asks for the lists on a user refresh", async () => {
const wrapper = mountPage();
await (wrapper.vm as any).refreshAll(true);
expect(wrapper.emitted("reload-configs")).toHaveLength(1);
expect(refreshCalls()).toEqual([1, 1, 1, 1, 1]);
});
it("asks for the lists when the table's refresh button is clicked", async () => {
const wrapper = mountPage();
await wrapper.find('[data-test="table-refresh"]').trigger("click");
await flushPromises();
wrapper.findComponent(TableStub).vm.$emit("update:search", "tox");
await flushPromises();
route.query = { tab: "quality", config: "tox" };
await flushPromises();
expect(wrapper.findComponent(TableStub).exists()).toBe(false);
route.query = { tab: "quality" };
await flushPromises();
expect(wrapper.findComponent(TableStub).props("search")).toBe("tox");
});
expect(wrapper.emitted("reload-configs")).toHaveLength(1);
expect(refreshCalls()).toEqual([1, 1, 1, 1, 1]);
it("shows the detail of the entity id, scope and filter in the URL", async () => {
route.query = { tab: "quality", config: "tox", scope: "trace", only: "unhealthy" };
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('[data-test="detail-stub"]').text()).toBe("tox toxicity trace unhealthy");
expect(wrapper.find('[data-test="table-stub"]').exists()).toBe(false);
});
it("titles the list with the module, with the shared header actions and filters", async () => {
const wrapper = mountPage();
await flushPromises();
expect(wrapper.find('[data-test="quality-page-title"]').text()).toBe("Quality");
expect(wrapper.find('[data-test="quality-detail-back"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-detail-edit-config"]').exists()).toBe(false);
expect(wrapper.find('[data-test="header-actions-slot"]').exists()).toBe(true);
expect(wrapper.find('[data-test="filters-slot"]').exists()).toBe(true);
});
it("turns the page header into the detail header: back, name, description, tags, Edit config", async () => {
route.query = { tab: "quality", config: "tox" };
const wrapper = mountPage(undefined, [TOX_CONFIG]);
await flushPromises();
expect(wrapper.find('[data-test="quality-page-title"]').text()).toBe("toxicity");
expect(wrapper.text()).toContain("Toxicity level of the response.");
const tags = wrapper.find('[data-test="quality-detail-tags"]');
expect(tags.findAllComponents({ name: "OTag" }).map((tag) => tag.text())).toEqual([
"Categorical",
"v2",
]);
expect(wrapper.find('[data-test="quality-detail-switcher"]').exists()).toBe(false);
expect(wrapper.find('[data-test="header-actions-slot"]').exists()).toBe(true);
expect(wrapper.find('[data-test="filters-slot"]').exists()).toBe(true);
// Opened from a link, the header's back button replaces the URL with the list.
await wrapper.find('[data-test="quality-detail-back"]').trigger("click");
expect(replace).toHaveBeenCalledWith({ name: "aiEvaluations", query: { tab: "quality" } });
});
it("puts the scope toggle with counts in the filter row, and hides it for a single scope", async () => {
route.query = { tab: "quality", config: "tox" };
const wrapper = mountPage();
await flushPromises();
const toggle = wrapper.find(
'[data-test="quality-scope-bar"] [data-test="quality-detail-scope"]',
);
expect(toggle.text()).toMatch(/All\s*114/);
expect(toggle.text()).toMatch(/Span\s*77/);
expect(toggle.text()).toMatch(/Trace\s*37/);
expect(toggle.text()).not.toContain("Session");
await wrapper.find('[data-test="quality-detail-scope-trace"]').trigger("click");
expect(replace).toHaveBeenLastCalledWith({
name: "aiEvaluations",
query: { tab: "quality", config: "tox", scope: "trace" },
});
route.query = { tab: "quality", config: "sen" };
await flushPromises();
expect(wrapper.find('[data-test="quality-detail-scope"]').exists()).toBe(false);
});
it("ignores URL values the controls cannot show", async () => {
route.query = { tab: "quality", config: "sen", scope: "trace", only: "unhealthy" };
const wrapper = mountPage();
await flushPromises();
// sentiment has no threshold and only session scores.
expect(wrapper.find('[data-test="detail-stub"]').text()).toBe("sen sentiment all all");
});
it("goes back through history when the entry before is the list, and replaces the URL after a deep link", async () => {
route.query = { tab: "quality" };
const wrapper = mountPage();
await flushPromises();
wrapper.findComponent(TableStub).vm.$emit("open", { configId: "tox" }, "all");
// What vue-router records for the pushed entry; it survives a round trip through a link.
window.history.replaceState({ back: "/ai/evaluations?tab=quality" }, "");
route.query = { tab: "quality", config: "tox", page: "2", score: "s12" };
await flushPromises();
wrapper.findComponent(DetailStub).vm.$emit("back");
expect(back).toHaveBeenCalledTimes(1);
expect(replace).not.toHaveBeenCalled();
window.history.replaceState({ back: "/ai/evaluations?tab=jobs" }, "");
route.query = { tab: "quality", config: "tox", scope: "span", page: "2", score: "s12" };
const linked = mountPage();
await flushPromises();
linked.findComponent(DetailStub).vm.$emit("back");
expect(back).toHaveBeenCalledTimes(1);
expect(replace).toHaveBeenCalledWith({ name: "aiEvaluations", query: { tab: "quality" } });
window.history.replaceState(null, "");
});
it("round-trips the scores page and the selected score through the URL with replace", async () => {
route.query = { tab: "quality", config: "tox", page: "3", score: "s27" };
const wrapper = mountPage();
await flushPromises();
const detail = wrapper.findComponent(DetailStub);
expect(detail.props("initialPage")).toBe(2);
expect(detail.props("initialScoreId")).toBe("s27");
detail.vm.$emit("position", { page: 2, scoreId: "s27" });
expect(replace).not.toHaveBeenCalled();
detail.vm.$emit("position", { page: 3, scoreId: "s30" });
expect(replace).toHaveBeenLastCalledWith({
name: "aiEvaluations",
query: { tab: "quality", config: "tox", page: "4", score: "s30" },
});
detail.vm.$emit("position", { page: 0, scoreId: null });
expect(replace).toHaveBeenLastCalledWith({
name: "aiEvaluations",
query: { tab: "quality", config: "tox" },
});
expect(push).not.toHaveBeenCalled();
});
it("keeps both keys when a scope change and the reset position land in one tick", async () => {
route.query = { tab: "quality", config: "tox", page: "3", score: "s27" };
const wrapper = mountPage();
await flushPromises();
await wrapper.find('[data-test="quality-detail-scope-trace"]').trigger("click");
wrapper.findComponent(DetailStub).vm.$emit("position", { page: 0, scoreId: "t1" });
expect(replace).toHaveBeenLastCalledWith({
name: "aiEvaluations",
query: { tab: "quality", config: "tox", scope: "trace", score: "t1" },
});
});
it("opens the Score Config editor in the same tab", async () => {
route.query = { tab: "quality", config: "tox" };
const open = vi.spyOn(window, "open").mockImplementation(() => null);
const wrapper = mountPage();
await flushPromises();
await wrapper.find('[data-test="quality-detail-edit-config"]').trigger("click");
expect(push).toHaveBeenLastCalledWith({
name: "aiEvaluations",
query: { org_identifier: "default", tab: "scoreConfigs", action: "update", id: "tox" },
});
expect(open).not.toHaveBeenCalled();
});
it("leaves the status and the healthy rule to the score pane, with or without a threshold", async () => {
for (const config of ["tox", "sen"]) {
route.query = { tab: "quality", config };
const wrapper = mountPage(undefined, [TOX_CONFIG]);
await flushPromises();
const tags = wrapper.find('[data-test="quality-detail-tags"]').text();
expect(tags).not.toMatch(/Needs attention|Threshold not set|Healthy if|No healthy threshold/);
expect(wrapper.find('[data-test="quality-detail-rule"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-detail-set-threshold"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-detail-edit-config"]').exists()).toBe(true);
wrapper.unmount();
}
});
it("puts the opened config and its filter in the URL", async () => {
route.query = { tab: "quality", scope: "span" };
const wrapper = mountPage();
await flushPromises();
wrapper.findComponent(TableStub).vm.$emit("open", { configId: "tox" }, "unhealthy");
expect(push).toHaveBeenCalledWith({
name: "aiEvaluations",
query: { tab: "quality", config: "tox", only: "unhealthy" },
});
});
it("reads the cache on remount and only a forced refetch reaches the server again", async () => {
mountPage().unmount();
await flushPromises();
mountPage();
await flushPromises();
expect(list).toHaveBeenCalledTimes(1);
// The Refresh in OnlineEvals forces every active read under this scope.
await queryClient.refetchQueries(
{ queryKey: onlineEvalKeys.quality("default"), type: "active" },
{ cancelRefetch: false },
);
expect(list).toHaveBeenCalledTimes(2);
});
it("reads a moved window with its own times", async () => {
const wrapper = mountPage();
await flushPromises();
await wrapper.setProps({ dateWindow: { startUs: 5_000_000_000, endUs: 9_000_000_000 } });
await flushPromises();
expect(list).toHaveBeenLastCalledWith("default", {
start_time: 5_000_000_000,
end_time: 9_000_000_000,
});
});
it("reports the oldest data time of the reads on screen", async () => {
const wrapper = mountPage();
await flushPromises();
const status = wrapper.emitted("status")?.at(-1)?.[0] as {
updatedAt: number;
fetching: boolean;
};
expect(status.fetching).toBe(false);
expect(status.updatedAt).toBeGreaterThan(0);
});
});
@@ -1,416 +1,365 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div
class="quality-page flex min-h-0 flex-1 flex-col gap-3.5 pt-3.5 pb-4"
data-test="quality-page"
>
<div class="px-page-edge flex items-center">
<div class="w-[17rem] flex-shrink-0">
<!-- While the agent list is loading we swap the select for a skeleton
of the same height so the control reads as "loading" (and can't be
opened on an empty list) instead of showing an empty dropdown. -->
<OSkeleton
type="text"
v-if="agentsLoading"
data-test="quality-agent-filter-skeleton"
class="h-8.5 w-full"
/>
<OSelect
v-else
v-model="agentModel"
:label="t('onlineEvals.quality.agentLabel')"
label-position="inside"
:placeholder="t('onlineEvals.quality.agentPlaceholder')"
:options="agentOptions || []"
labelKey="label"
valueKey="value"
class="rounded-default"
data-test="quality-agent-filter"
/>
</div>
</div>
<QualityKpiSkeleton v-if="showKpiSkeleton" :count="visibleKpis.length" class="px-page-edge" />
<KpiCardRow
v-else
gap="gap-2 max-lg:gap-1.5"
class="quality-page__kpis px-page-edge"
:aria-label="t('onlineEvals.quality.kpisAriaLabel')"
<div class="flex min-h-0 min-w-0 flex-1 flex-col" data-test="quality-page">
<!-- With a config open the page header is the detail header, as on the other detail pages. -->
<OPageHeader
:title="header.title"
:subtitle="header.subtitle"
:icon="configId ? undefined : 'star-rate'"
:back="
configId
? {
label: t('onlineEvals.quality.detail.back'),
onClick: closeConfig,
dataTest: 'quality-detail-back',
}
: undefined
"
title-data-test="quality-page-title"
>
<QualityKpiCard
v-for="kpi in visibleKpis"
:key="kpi.id"
:kpi="kpi"
:delta="deltaByKpi[kpi.id] ?? null"
:clickable="kpi.id === 'scorerFailures' && (kpi.value ?? 0) > 0"
@activate="openScorerFailures"
/>
</KpiCardRow>
<!-- Tier 2: the configs table is the persistent view; selecting a
row opens the detail in a right-side ODrawer (70% width). The user
keeps full context of the list behind the drawer. -->
<div class="quality-page__tier2 grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)] gap-3">
<QualityScoreConfigsTable
:rows="configRows"
:is-loading="isConfigsLoading || !!configsLoading || !!agentsLoading"
@select="selectConfig"
@refresh="refreshAll(true)"
/>
</div>
<ODrawer
bleed
v-model:open="detailDrawerOpen"
side="right"
:width="70"
:title="raw(selectedConfig?.name || '')"
data-test="quality-config-detail-drawer"
>
<!-- Type badge + version pulled out of the inner panel header so
the drawer chrome owns the entire identification block;
the inner panel no longer renders its own title row. -->
<template #header-right>
<OTag
v-if="
detailDataType === 'numeric' ||
detailDataType === 'categorical' ||
detailDataType === 'boolean'
"
type="evalDataType"
:value="detailDataType"
:label="raw(shortType(detailDataType))"
size="xs"
data-test="quality-detail-type-badge"
/>
<span
v-if="selectedConfig?.version"
class="qpd-version text-2xs text-text-secondary ms-1.5 [font-variant-numeric:tabular-nums]"
data-test="quality-detail-version-badge"
>{{ t("onlineEvals.versionPrefix") }}{{ selectedConfig.version }}</span
>
<template v-if="configId" #title-trail>
<div class="flex shrink-0 items-center gap-1.5" data-test="quality-detail-tags">
<OTag v-if="header.dataType" type="evalDataType" :value="header.dataType" />
<OTag
v-if="header.version != null"
variant="default-soft"
shape="rounded"
:label="raw(`v${header.version}`)"
/>
</div>
</template>
<template #actions>
<OButton
v-if="configId"
variant="outline"
size="sm"
data-test="quality-detail-edit-config"
@click="openEditConfig"
>
{{ t("onlineEvals.quality.detail.editConfig") }}
</OButton>
<slot name="header-actions" />
</template>
</OPageHeader>
<QualityDetailPanel
v-if="selectedConfig"
<section
class="bg-card-glass-bg flex min-h-0 flex-1 flex-col overflow-hidden"
data-test="quality-body"
>
<!-- Env, Agent and Version scope the list and the detail alike; the detail adds its scope toggle. -->
<div
class="px-page-edge flex shrink-0 flex-wrap items-center gap-2 py-2"
data-test="quality-scope-bar"
>
<slot name="filters" />
<OToggleGroup
v-if="configId && scopeOptions.length > 2"
:model-value="scope"
mobile-dropdown
class="ms-auto"
data-test="quality-detail-scope"
@update:model-value="
(value) => updateQuery({ scope: value === 'all' ? undefined : (value as string) })
"
>
<OToggleGroupItem
v-for="option in scopeOptions"
:key="option.value"
:value="option.value"
size="sm"
:data-test="`quality-detail-scope-${option.value}`"
>
{{ option.label }}
<span class="font-semibold tabular-nums">{{ formatCount(option.count) }}</span>
</OToggleGroupItem>
</OToggleGroup>
</div>
<QualityConfigDetail
v-if="configId"
:config-id="configId"
:row="selectedRow"
:config="selectedConfig"
:data-type="detailDataType"
:kpis="detailKpis"
:has-scores="detailHasScores"
:is-loading="isDetailLoading || isChartsLoading"
:numeric-trend="numericTrend"
:numeric-distribution="numericDistribution"
:numeric-threshold="numericThreshold"
:numeric-range="numericRange"
:boolean-agg="booleanAgg"
:boolean-trend="booleanTrend"
:boolean-trend-series="booleanTrendSeries"
:categorical-rows="categoricalRows"
:scope="detailScope"
:runs="qualityRuns"
:runs-counts="runsCounts"
:runs-filter="runsFilter"
:runs-current-page="runsCurrentPage"
:runs-page-size="runsPageSize"
:runs-total-count="runsTotalCount"
:runs-loading="isRunsLoading"
:runs-error="runsError"
@update:scope="detailScope = $event"
@open-run="openEvaluationRun"
@runs-filter-change="setRunsFilter"
@runs-pagination-change="setRunsPagination"
@back="clearSelection"
:scope="scope"
:only="only"
:date-window="dateWindow"
:agent-params="agentParams"
:enabled="enabled"
:initial-page="restoredPage"
:initial-score-id="restoredScoreId"
:initial-filter="restoredFilter"
@back="closeConfig"
@update:only="(value) => updateQuery({ only: value === 'all' ? undefined : value })"
@status="(status) => (detailStatus = status)"
@position="updatePosition"
/>
</ODrawer>
<QualityConfigsTable
v-else
v-model:tile-filter="tileFilter"
v-model:search="search"
:rows="visibleRows"
:tiles="tiles"
:loading="listQuery.isPending.value || configsLoading"
:forbidden="listStatus === 403"
:load-error="!!listQuery.error.value && listStatus !== 403"
@open="openConfig"
@retry="retryList"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, toRef, watch } from "vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { computed, ref, toRef, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { ScoreConfig } from "@/services/online-evals.service";
import { useQualityData, type DateWindow } from "./composables/useQualityData";
import { useQualityScoreConfigs, type ScoreConfigRow } from "./composables/useQualityScoreConfigs";
import { useQualityConfigDetail } from "./composables/useQualityConfigDetail";
import { useQualityDetailCharts } from "./composables/useQualityDetailCharts";
import KpiCardRow from "@/components/common/KpiCardRow.vue";
import { useQualityRuns, type QualityRunRow } from "./composables/useQualityRuns";
import QualityKpiCard from "./quality/QualityKpiCard.vue";
import QualityKpiSkeleton from "./quality/QualityKpiSkeleton.vue";
import QualityScoreConfigsTable from "./quality/QualityScoreConfigsTable.vue";
import QualityDetailPanel from "./quality/QualityDetailPanel.vue";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { useOrgId } from "@/composables/query/useOrgId";
import OPageHeader from "@/lib/core/PageHeader/OPageHeader.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import type {
EvalTargetScope,
QualityAgentParams,
ScoreConfig,
} from "@/services/online-evals.service";
import { useQualityList, type DateWindow } from "./composables/useQualityList";
import { dataTypeOf, entityId } from "./utils/evalEntity";
import { thresholdForConfig } from "./utils/scoreThreshold";
import { parseTabFromRoute } from "./utils/routeSync";
import {
buildEvaluatorAgentFilterWhere,
combineWhere,
type AgentFilterSelection,
} from "./utils/agentFilterSql";
import type { QualityScope } from "./utils/qualityScope";
import { b64EncodeUnicode } from "@/utils/zincutils";
QUALITY_SCOPES,
chartFilterFromQuery,
chartFilterToQuery,
formatCount,
scoreConfigLink,
type QualityChartFilter,
type QualityOnly,
type QualityRow,
type QualityScope,
} from "./utils/qualityFormat";
import QualityConfigsTable from "./quality/QualityConfigsTable.vue";
import QualityConfigDetail from "./quality/QualityConfigDetail.vue";
const props = defineProps<{
scoreConfigs: ScoreConfig[];
// Date window is owned by OnlineEvals (so the picker + refresh button live
// in the embedded OPageHeader). Quality just consumes it as a reactive
// input to its data loaders.
configsLoading: boolean;
dateWindow: DateWindow;
agentFilter?: AgentFilterSelection | null;
// Agent filter dropdown — state stays in OnlineEvals (it owns the agent
// list + derives `agentFilter`); QualityPage just renders the control and
// emits the selected key back via v-model.
agentKey?: string;
agentOptions?: { label: I18nText; value: string }[];
// True while OnlineEvals is still fetching the score-configs list. Until that
// resolves `scoreConfigs` is empty, so the table would otherwise flash "No
// Data" before its own skeleton kicks in. OR-ing this into the table's
// loading flag keeps the skeleton up from the very first paint.
configsLoading?: boolean;
// True during the agent-list fetch (first phase of the parent's reload).
// Drives the agent-dropdown skeleton plus the KPI/table skeletons so the
// whole page reads as "loading" from the start of a reload, not just once
// the data queries begin.
agentsLoading?: boolean;
agentParams: QualityAgentParams;
/** False until the agent list first loads, so a deep-linked agent is applied before the first request. */
enabled: boolean;
}>();
const emit = defineEmits<{
/** Re-read the score-configs list, which this page receives as a prop. */
(e: "reload-configs"): void;
(e: "update:agentKey", value: string): void;
// Fired once after mount so the parent can run the agents-first reload. The
// parent owns every reload trigger (mount / refresh / date / agent) — this
// page no longer self-loads on mount or on prop changes.
(e: "ready"): void;
/** Oldest data time and fetch state of the reads on screen, for the header's last-refreshed label. */
(e: "status", status: { updatedAt: number | null; fetching: boolean }): void;
}>();
const agentModel = computed<string>({
get: () => props.agentKey ?? "",
set: (value) => emit("update:agentKey", value),
});
const { t } = useI18nTyped();
const route = useRoute();
const router = useRouter();
const orgId = useOrgId();
const dateWindowRef = toRef(props, "dateWindow");
const agentFilterRef = toRef(props, "agentFilter");
const { listQuery, rows, visibleRows, tiles, tileFilter, listStatus } = useQualityList({
scoreConfigs: toRef(props, "scoreConfigs"),
dateWindow: toRef(props, "dateWindow"),
agentParams: toRef(props, "agentParams"),
enabled: toRef(props, "enabled"),
});
// The table unmounts while a config is open, so the page keeps its search, as it keeps the tile filter.
const search = ref("");
const { isLoading, kpis, deltaByKpi, refresh } = useQualityData(dateWindowRef, agentFilterRef);
// The detail view lives in the URL, so a reload or a shared link opens the same view.
const configId = computed(() => (typeof route.query.config === "string" ? route.query.config : ""));
const selectedRow = computed<QualityRow | null>(
() => rows.value.find((row) => row.configId === configId.value) ?? null,
);
const selectedConfig = computed<ScoreConfig | null>(
() => props.scoreConfigs.find((config) => entityId(config) === configId.value) ?? null,
);
// Placeholder KPIs can be hidden here without touching the render loop — add
// their ids to this set to filter them out of the v-for.
const HIDDEN_KPI_IDS = new Set<string>();
const visibleKpis = computed(() => kpis.value.filter((k) => !HIDDEN_KPI_IDS.has(k.id)));
const scoreConfigsRef = toRef(props, "scoreConfigs");
const {
rows: configRows,
isLoading: isConfigsLoading,
refresh: refreshConfigs,
} = useQualityScoreConfigs(scoreConfigsRef, dateWindowRef, t, agentFilterRef);
const selectedConfigId = ref<string | null>(routeConfigId());
const detailScope = ref<QualityScope>("all");
const selectedConfig = computed<ScoreConfig | null>(() => {
const id = selectedConfigId.value;
if (!id) return null;
return props.scoreConfigs.find((c) => String(c.id) === id) ?? null;
// URL values the controls cannot show (a scope without scores, Unhealthy without a threshold) fall back to All.
const scope = computed<QualityScope>(() => {
const value = route.query.scope as EvalTargetScope;
if (!QUALITY_SCOPES.includes(value)) return "all";
const counts = selectedRow.value?.scopeCounts;
return counts && !counts[value] ? "all" : value;
});
const only = computed<QualityOnly>(() => {
if (route.query.only !== "unhealthy") return "all";
const row = selectedRow.value;
const config = selectedConfig.value;
if (row) return row.unhealthy === null ? "all" : "unhealthy";
return config && !thresholdForConfig(config).label ? "all" : "unhealthy";
});
const {
isLoading: isDetailLoading,
dataType: detailDataType,
kpis: detailKpis,
hasScores: detailHasScores,
booleanAgg,
categoricalRows,
refresh: refreshDetail,
} = useQualityConfigDetail(selectedConfig, dateWindowRef, agentFilterRef, detailScope, t);
const {
isLoading: isChartsLoading,
numericTrend,
numericDistribution,
booleanTrend,
booleanTrendSeries,
refresh: refreshCharts,
} = useQualityDetailCharts(selectedConfig, dateWindowRef, agentFilterRef, detailScope, t);
const {
runs: qualityRuns,
counts: runsCounts,
activeFilter: runsFilter,
currentPage: runsCurrentPage,
pageSize: runsPageSize,
totalCount: runsTotalCount,
isLoading: isRunsLoading,
error: runsError,
refresh: refreshRuns,
setFilter: setRunsFilter,
setPagination: setRunsPagination,
resolveEvaluatorSpanId,
} = useQualityRuns(selectedConfig, dateWindowRef, agentFilterRef, detailScope);
const numericThreshold = computed(() => {
const cfg = selectedConfig.value;
if (!cfg) return null;
const ht: any = (cfg as any).healthyThreshold ?? (cfg as any).healthy_threshold;
if (!ht || ht.value == null || !ht.direction) return null;
/** The module title on the list; the config's name, description, type and version on the detail. */
const header = computed(() => {
if (!configId.value) {
return {
title: t("aiObservability.nav.quality"),
subtitle: t("aiObservability.subtitle.quality"),
dataType: null,
version: null,
};
}
const row = selectedRow.value;
const config = selectedConfig.value;
return {
value: Number(ht.value),
direction: ht.direction === "gte" ? "gte" : "lte",
} as const;
title: raw(row?.name ?? config?.name ?? configId.value),
subtitle: raw(row?.description || config?.description || ""),
dataType: row?.dataType ?? (config ? dataTypeOf(config) : null),
version: row?.version ?? config?.version ?? null,
};
});
const numericRange = computed(() => {
const cfg = selectedConfig.value;
if (!cfg) return null;
const r: any = (cfg as any).numericRange ?? (cfg as any).numeric_range;
if (!r || r.min == null || r.max == null) return null;
return { min: Number(r.min), max: Number(r.max) };
/** All, then each scope with scores; the toggle shows only when there are two or more. */
const scopeOptions = computed(() => {
const counts = selectedRow.value?.scopeCounts;
if (!counts) return [];
return [
{
value: "all",
label: t("onlineEvals.quality.scopes.all"),
count: selectedRow.value?.total ?? 0,
},
...QUALITY_SCOPES.filter((value) => counts[value] > 0).map((value) => ({
value,
label: t(`onlineEvals.quality.scopes.${value}`),
count: counts[value],
})),
];
});
async function refreshAll(reloadConfigs = false) {
// The score-configs list arrives as a prop, so refreshing only the derived
// aggregates leaves it untouched — and each of those bails out early when the
// list is empty, which is why the button appeared to do nothing at all. Ask
// the parent to re-read the list first, then recompute from it.
// Only a user refresh asks; mount, date and agent reloads reuse the list the parent holds.
if (reloadConfigs) emit("reload-configs");
await Promise.all([refresh(), refreshConfigs(), refreshDetail(), refreshCharts(), refreshRuns()]);
}
function openScorerFailures() {
const filter = combineWhere(
"attributes_status IN ('error', 'timeout')",
buildEvaluatorAgentFilterWhere(props.agentFilter ?? null),
);
function openEditConfig() {
router
.push({
name: "traces",
query: {
stream: "_evaluator",
query: b64EncodeUnicode(filter ?? "attributes_status IN ('error', 'timeout')"),
from: props.dateWindow.startUs,
to: props.dateWindow.endUs,
org_identifier: route.query.org_identifier,
},
})
.push(scoreConfigLink(String(route.name), orgId.value, configId.value, "update"))
.catch(() => {});
}
const isAnyLoading = computed(
() =>
isLoading.value ||
isConfigsLoading.value ||
isDetailLoading.value ||
isChartsLoading.value ||
isRunsLoading.value,
// Patches the route does not show yet, so two replaces in one tick do not drop each other's keys.
let pendingPatch: Record<string, string | undefined> = {};
watch(
() => route.query,
() => (pendingPatch = {}),
);
// Surface refresh + an aggregated loading flag so OnlineEvals can drive the
// Refresh button it now renders in the embedded OPageHeader actions slot.
defineExpose({ refreshAll, isAnyLoading });
/** Show the KPI skeleton whenever the KPI queries are running — on the initial
* load AND on every refresh — matching the rest of the app (e.g. LLM Insights),
* so a refresh gives clear feedback instead of leaving the cards frozen. Also
* shown during the agent-list fetch (the phase before the KPI query starts) so
* the page reads as loading from the very start of a reload. */
const showKpiSkeleton = computed(() => isLoading.value || !!props.agentsLoading);
// The parent (OnlineEvals) owns every reload trigger — mount, refresh button,
// date-time change, and agent change — and calls `refreshAll()` / `refreshConfigs()`
// via the exposed handle. This page only signals readiness; it does NOT watch
// `dateWindow`/`agentFilter` (doing so re-introduced the duplicate fetches that
// fired once from here and again from the parent's reload).
onMounted(() => {
emit("ready");
});
// The score-configs list arrives asynchronously from the parent's `loadAll()`,
// often AFTER the initial reload has run against an empty list. Re-run just the
// table aggregate when it lands so the rows populate.
watch(scoreConfigsRef, () => {
void refreshConfigs();
});
// URL ↔ selection
function routeConfigId(): string | null {
const v = route.query.config;
return typeof v === "string" && v.length > 0 ? v : null;
function updateQuery(patch: Record<string, string | undefined>, push = false) {
pendingPatch = { ...pendingPatch, ...patch };
const query: Record<string, any> = { ...route.query, ...pendingPatch };
for (const key of Object.keys(pendingPatch))
if (pendingPatch[key] === undefined) delete query[key];
if (!push && sameQuery(query, route.query)) return;
const location = { name: route.name as string, query };
(push ? router.push(location) : router.replace(location)).catch(() => {});
}
const sameQuery = (a: Record<string, unknown>, b: Record<string, unknown>) =>
JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
// The scores page (1-based) and selected score live in the URL, so Back from a link reopens the same score.
const restoredPage = computed(() => {
const value = Number(route.query.page);
return Number.isInteger(value) && value > 1 ? value - 1 : 0;
});
const restoredScoreId = computed(() =>
typeof route.query.score === "string" && route.query.score ? route.query.score : null,
);
// The chart filter travels with them: a page and score restored without it point into the wrong list.
const restoredFilter = computed(() => chartFilterFromQuery(route.query));
function updatePosition(position: {
page: number;
scoreId: string | null;
filter: QualityChartFilter | null;
}) {
updateQuery({
page: position.page > 0 ? String(position.page + 1) : undefined,
score: position.scoreId ?? undefined,
bucket: undefined,
value: undefined,
...chartFilterToQuery(position.filter),
});
}
function openConfig(row: QualityRow, onlyValue: QualityOnly) {
updateQuery(
{
config: row.configId,
scope: undefined,
only: onlyValue === "all" ? undefined : onlyValue,
page: undefined,
score: undefined,
bucket: undefined,
value: undefined,
},
true,
);
}
// "All configs" steps back when the entry before is the list, so it matches the browser Back button, even after a round trip through a link.
function previousIsList() {
const back = window.history.state?.back;
if (typeof back !== "string") return false;
const previous = router.resolve(back);
return (
previous.name === route.name &&
parseTabFromRoute(previous.query.tab) === "quality" &&
!previous.query.config
);
}
function closeConfig() {
if (previousIsList()) {
router.back();
return;
}
updateQuery({
config: undefined,
scope: undefined,
only: undefined,
page: undefined,
score: undefined,
bucket: undefined,
value: undefined,
});
}
function retryList() {
void listQuery.refetch();
}
const detailStatus = ref<{ updatedAt: number; fetching: boolean } | null>(null);
watch(configId, (id) => {
if (!id) detailStatus.value = null;
});
watch(
() => route.query.config,
() => {
const id = routeConfigId();
if (id !== selectedConfigId.value) {
detailScope.value = "all";
selectedConfigId.value = id;
}
const times = [listQuery.dataUpdatedAt.value];
if (configId.value && detailStatus.value) times.push(detailStatus.value.updatedAt);
const loaded = times.filter((time) => time > 0);
return {
updatedAt: loaded.length ? Math.min(...loaded) : null,
fetching: listQuery.isFetching.value || (!!configId.value && !!detailStatus.value?.fetching),
};
},
(status) => emit("status", status),
{ immediate: true, deep: true },
);
function selectConfig(row: ScoreConfigRow) {
// Reset before changing the config so its composable watcher never runs a
// request for the new config with the previous config's narrow scope.
detailScope.value = "all";
selectedConfigId.value = String(row.config.id);
const query: Record<string, any> = {
...route.query,
config: selectedConfigId.value,
};
router.push({ name: route.name as string, query }).catch(() => {});
}
function clearSelection() {
selectedConfigId.value = null;
const query: Record<string, any> = { ...route.query };
delete query.config;
router.replace({ name: route.name as string, query }).catch(() => {});
}
async function openEvaluationRun(run: QualityRunRow) {
if (!run.evaluatorTraceId) return;
const evaluatorSpanId = await resolveEvaluatorSpanId(run);
const timestampUs = run.timestampMs > 0 ? run.timestampMs * 1000 : 0;
const query: Record<string, any> = {
stream: "_evaluator",
trace_id: run.evaluatorTraceId,
from: timestampUs ? Math.max(0, timestampUs - 60_000_000) : props.dateWindow.startUs,
to: timestampUs ? timestampUs + 3_600_000_000 : props.dateWindow.endUs,
org_identifier: route.query.org_identifier,
};
if (evaluatorSpanId) query.span_id = evaluatorSpanId;
router
.push({
name: "traceDetails",
query,
})
.catch(() => {});
}
// ODrawer drives its `:open` via the presence of a selected config. Opening
// is owned by selectConfig() (from a row click); closing the drawer
// (backdrop click, Esc, header ×) routes through clearSelection so the
// `?config=` query param drops in sync.
const detailDrawerOpen = computed<boolean>({
get: () => selectedConfigId.value != null,
set: (open) => {
if (!open) clearSelection();
},
});
// Used by the drawer header's #header-right slot — same mapping the
// detail panel used for its in-panel badge so type/version chrome looks
// identical, just relocated into the drawer header.
function shortType(type: string): string {
if (type === "numeric") return t("onlineEvals.quality.dataTypes.numericShort");
if (type === "categorical") return t("onlineEvals.quality.dataTypes.categoricalShort");
if (type === "boolean") return t("onlineEvals.quality.dataTypes.booleanShort");
return "—";
}
</script>
@@ -1,395 +0,0 @@
// Per-config detail composable for the Tier-2 selected state.
// Runs a data_type-specific aggregate against `_llm_scores` filtered to one
// score_config_id, plus a uniqueness/total query so KPI cards can render.
import { computed, ref, watch, type Ref } from "vue";
import { type TranslateFn } from "@/types/i18n";
import { useLLMStreamQuery } from "@/plugins/traces/composables/useLLMStreamQuery";
import type { ScoreConfig } from "@/services/online-evals.service";
import { dataTypeOf, entityId } from "../utils/evalEntity";
import type { DateWindow } from "./useQualityData";
import {
buildScoresAgentFilterWhere,
combineWhere,
type AgentFilterSelection,
} from "../utils/agentFilterSql";
import { latestScoresFromSql } from "../utils/latestScoreSql";
import { qualityScopeWhere, type QualityScope } from "../utils/qualityScope";
import { escapeSqlString, thresholdForConfig } from "../utils/scoreThreshold";
import { healthyBooleanValue, healthyCategories } from "../utils/qualitySummary";
export interface DetailKpi {
id: string;
/** Localised title — falls back to plain text when no i18n key matches */
titleKey: string;
value: number | string | null;
/** Right-aligned secondary context line (`Range 0-1`, `= true`, etc.) */
context: string;
/** How to format the value when it's a number */
format: "number" | "percent" | "count" | "raw";
}
interface NumericAggRow {
numeric_values?: number | string;
avg_v?: number | string | null;
p50_v?: number | string | null;
p95_v?: number | string | null;
unhealthy?: number | string | null;
}
export interface BooleanAggRow {
trues?: number | string;
falses?: number | string;
}
export interface CategoricalAggRow {
value_categorical?: string | null;
c?: number | string;
}
interface VolumeAggRow {
score_results?: number | string;
targets_scored?: number | string;
}
function toNumber(v: unknown): number | null {
if (v == null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
function valueOf<T = any>(row: any, camel: string, snake: string): T | undefined {
if (row == null) return undefined;
return row[camel] ?? row[snake];
}
function numericSql(where: string, unhealthy: string | null): string {
return [
"SELECT",
" COUNT(value_numeric) AS numeric_values,",
" AVG(value_numeric) AS avg_v,",
" approx_percentile_cont(value_numeric, 0.5) AS p50_v,",
" approx_percentile_cont(value_numeric, 0.95) AS p95_v,",
` ${unhealthy ? `COUNT(CASE WHEN ${unhealthy} THEN 1 END)` : "CAST(NULL AS INTEGER)"} AS unhealthy`,
`FROM ${latestScoresFromSql(where)}`,
].join("\n");
}
function booleanSql(where: string): string {
return [
"SELECT",
" COUNT(CASE WHEN value_boolean = true THEN 1 END) AS trues,",
" COUNT(CASE WHEN value_boolean = false THEN 1 END) AS falses",
`FROM ${latestScoresFromSql(where)}`,
].join("\n");
}
function volumeSql(where: string): string {
return [
"SELECT",
" COUNT(*) AS score_results,",
" COUNT(DISTINCT CASE",
" WHEN _target_id IS NOT NULL AND _target_id <> ''",
" THEN CONCAT(_target_scope, ':', _target_id)",
" END) AS targets_scored",
`FROM ${latestScoresFromSql(where)}`,
].join("\n");
}
function categoricalSql(where: string): string {
return [
"SELECT",
" value_categorical,",
" COUNT(*) AS c",
`FROM ${latestScoresFromSql(where)}`,
"WHERE value_categorical IS NOT NULL",
"GROUP BY value_categorical",
"ORDER BY c DESC",
].join("\n");
}
export function useQualityConfigDetail(
selectedConfig: Ref<ScoreConfig | null>,
dateWindow: Ref<DateWindow>,
agentFilter: Ref<AgentFilterSelection | null | undefined>,
qualityScope: Ref<QualityScope>,
t: TranslateFn,
) {
const { executeQuery } = useLLMStreamQuery();
const isLoading = ref(false);
const numericAgg = ref<NumericAggRow | null>(null);
const booleanAgg = ref<BooleanAggRow | null>(null);
const categoricalRows = ref<CategoricalAggRow[]>([]);
const volumeAgg = ref<VolumeAggRow | null>(null);
let refreshGeneration = 0;
function clearData() {
numericAgg.value = null;
booleanAgg.value = null;
categoricalRows.value = [];
volumeAgg.value = null;
}
async function runQuery<T>(sqlText: string, label: string, startUs: number, endUs: number) {
try {
const hits = await executeQuery(sqlText, startUs, endUs, "logs");
console.debug(`[QualityDetail:${label}]`, { hitCount: hits.length });
return hits as T[];
} catch (err: any) {
console.warn(`[QualityDetail:${label}] query failed:`, err);
return [] as T[];
}
}
async function refresh() {
const generation = ++refreshGeneration;
const cfg = selectedConfig.value;
clearData();
if (!cfg) {
isLoading.value = false;
return;
}
isLoading.value = true;
try {
const { startUs, endUs } = dateWindow.value;
// `score_config_id` on `_llm_scores` is the entity_id, not the
// per-version row id — join on entity_id so cross-version edits don't
// break the detail panel.
const configId = escapeSqlString(entityId(cfg));
const where = combineWhere(
`CAST(score_config_id AS VARCHAR) = '${configId}'`,
buildScoresAgentFilterWhere(agentFilter.value ?? null),
qualityScopeWhere(qualityScope.value),
)!;
const type = dataTypeOf(cfg);
const threshold = thresholdForConfig(cfg);
const volumePromise = runQuery<VolumeAggRow>(volumeSql(where), "volume", startUs, endUs);
if (type === "numeric") {
const [volumeHits, hits] = await Promise.all([
volumePromise,
runQuery<NumericAggRow>(
numericSql(where, threshold.unhealthyExpr),
"numeric",
startUs,
endUs,
),
]);
if (generation !== refreshGeneration) return;
volumeAgg.value = volumeHits[0] ?? null;
numericAgg.value = hits[0] ?? null;
} else if (type === "boolean") {
const [volumeHits, hits] = await Promise.all([
volumePromise,
runQuery<BooleanAggRow>(booleanSql(where), "boolean", startUs, endUs),
]);
if (generation !== refreshGeneration) return;
volumeAgg.value = volumeHits[0] ?? null;
booleanAgg.value = hits[0] ?? null;
} else if (type === "categorical") {
const [volumeHits, hits] = await Promise.all([
volumePromise,
runQuery<CategoricalAggRow>(categoricalSql(where), "categorical", startUs, endUs),
]);
if (generation !== refreshGeneration) return;
volumeAgg.value = volumeHits[0] ?? null;
categoricalRows.value = hits;
}
} finally {
if (generation === refreshGeneration) isLoading.value = false;
}
}
// Opening a row or changing the detail scope refreshes here. Date-window /
// agent-filter changes are driven by the page's
// refreshAll(), so watching them here would double-fire the detail query
// alongside the KPI/table reload. No `immediate`: the initial load is
// covered by refreshAll() too.
watch(
() => [selectedConfig.value, qualityScope.value] as const,
() => {
void refresh();
},
);
const dataType = computed<"numeric" | "boolean" | "categorical" | "unknown">(() => {
if (!selectedConfig.value) return "unknown";
const t = dataTypeOf(selectedConfig.value);
if (t === "numeric" || t === "boolean" || t === "categorical") return t;
return "unknown";
});
const kpis = computed<DetailKpi[]>(() => {
const cfg = selectedConfig.value;
if (!cfg) return [];
const threshold = thresholdForConfig(cfg);
const scoreResults = toNumber(volumeAgg.value?.score_results) ?? 0;
const targetsScored = toNumber(volumeAgg.value?.targets_scored);
const targetCard: DetailKpi = {
id: "targetsScored",
titleKey: "targetsScored",
value: targetsScored,
context: t("onlineEvals.quality.detail.ofScoreResults", { count: scoreResults }),
format: "count",
};
if (dataType.value === "numeric") {
const agg = numericAgg.value;
const total = toNumber(agg?.numeric_values) ?? 0;
const unhealthy = toNumber(agg?.unhealthy);
const range = valueOf<any>(cfg, "numericRange", "numeric_range");
const rangeText =
range && range.min != null && range.max != null
? t("onlineEvals.quality.detail.range", { min: range.min, max: range.max })
: "";
const cards: DetailKpi[] = [
{
id: "avg",
titleKey: "average",
value: toNumber(agg?.avg_v),
context: rangeText,
format: "number",
},
{
id: "p50",
titleKey: "p50",
value: toNumber(agg?.p50_v),
context: "",
format: "number",
},
{
id: "p95",
titleKey: "p95",
value: toNumber(agg?.p95_v),
context: "",
format: "number",
},
];
if (threshold.unhealthyExpr != null) {
cards.push({
id: "unhealthy",
titleKey: "unhealthy",
value: total > 0 && unhealthy != null ? (unhealthy / total) * 100 : null,
context: threshold.label
? t("onlineEvals.quality.detail.healthyThreshold", {
threshold: threshold.label,
})
: "",
format: "percent",
});
}
cards.push(targetCard);
return cards;
}
if (dataType.value === "boolean") {
const agg = booleanAgg.value;
const trues = toNumber(agg?.trues) ?? 0;
const falses = toNumber(agg?.falses) ?? 0;
const typedTotal = trues + falses;
const expected = healthyBooleanValue(cfg);
if (expected == null) {
return [
{
id: "trueRate",
titleKey: "trueRate",
value: typedTotal > 0 ? (trues / typedTotal) * 100 : null,
context: "= true",
format: "percent",
},
{
id: "falseRate",
titleKey: "falseRate",
value: typedTotal > 0 ? (falses / typedTotal) * 100 : null,
context: "= false",
format: "percent",
},
targetCard,
];
}
const healthyCount = expected ? trues : falses;
const unhealthyCount = expected ? falses : trues;
return [
{
id: "healthy",
titleKey: "healthy",
value: typedTotal > 0 ? (healthyCount / typedTotal) * 100 : null,
context: `= ${expected}`,
format: "percent",
},
{
id: "unhealthy",
titleKey: "unhealthy",
value: typedTotal > 0 ? (unhealthyCount / typedTotal) * 100 : null,
context: `= ${!expected}`,
format: "percent",
},
targetCard,
];
}
if (dataType.value === "categorical") {
const total = categoricalRows.value.reduce((s, r) => s + (toNumber(r.c) ?? 0), 0);
const healthyCats = healthyCategories(cfg);
let healthyCount = 0;
let unhealthyCount = 0;
for (const r of categoricalRows.value) {
const c = toNumber(r.c) ?? 0;
if (r.value_categorical && healthyCats.includes(r.value_categorical)) {
healthyCount += c;
} else {
unhealthyCount += c;
}
}
const cards: DetailKpi[] = [];
if (healthyCats.length > 0) {
cards.push(
{
id: "healthy",
titleKey: "healthy",
value: total > 0 ? (healthyCount / total) * 100 : null,
context: healthyCats.join(" · "),
format: "percent",
},
{
id: "unhealthy",
titleKey: "unhealthy",
value: total > 0 ? (unhealthyCount / total) * 100 : null,
context: "rest of categories",
format: "percent",
},
);
}
cards.push(targetCard);
return cards;
}
return [];
});
// True when at least one score landed for this config in the window. Drives
// the detail panel's empty state so a freshly-created (but never-scored)
// config reads as "waiting for scores" instead of a wall of dashes.
const hasScores = computed<boolean>(() => {
if (dataType.value === "numeric") return (toNumber(numericAgg.value?.numeric_values) ?? 0) > 0;
if (dataType.value === "boolean") {
const trues = toNumber(booleanAgg.value?.trues) ?? 0;
const falses = toNumber(booleanAgg.value?.falses) ?? 0;
return trues + falses > 0;
}
if (dataType.value === "categorical")
return categoricalRows.value.reduce((s, r) => s + (toNumber(r.c) ?? 0), 0) > 0;
return false;
});
return {
isLoading,
dataType,
kpis,
hasScores,
booleanAgg,
categoricalRows,
refresh,
};
}
@@ -1,395 +0,0 @@
// Quality page data composable.
// Queries `_llm_scores` (Logs) and `_evaluator` (Traces) system streams
// for KPI values. Each refresh runs current-window and prev-window
// aggregates in parallel; one stream's failure doesn't break the page.
import { computed, ref, type Ref } from "vue";
import { useLLMStreamQuery } from "@/plugins/traces/composables/useLLMStreamQuery";
import {
buildEvaluatorAgentFilterWhere,
buildScoresAgentFilterWhere,
combineWhere,
type AgentFilterSelection,
} from "../utils/agentFilterSql";
import { latestScoresFromSql } from "../utils/latestScoreSql";
import { scopeCountsFromRow, type ScopeCounts } from "../utils/qualityScope";
export type TimeRangeKey = "last1h" | "last24h" | "last7d" | "last30d";
/** Absolute time window in microseconds-since-epoch (OO's `_timestamp` unit). */
export interface DateWindow {
startUs: number;
endUs: number;
}
export type HealthyDirection = "up" | "down" | "neutral";
/** Choose a histogram bucket interval that yields ~10-30 buckets across the window. */
export function chooseBucketInterval(windowMs: number): string {
const min = 60_000;
const hour = 60 * min;
const day = 24 * hour;
if (windowMs <= 2 * hour) return "5 minute";
if (windowMs <= 12 * hour) return "30 minute";
if (windowMs <= 2 * day) return "1 hour";
if (windowMs <= 14 * day) return "6 hour";
return "1 day";
}
export interface KpiCard {
id: "scoreResults" | "evaluationCost" | "scorerSuccess" | "scorerFailures" | "latencyP95";
value: number | null;
prevValue: number | null;
/** small series for the sparkline; oldest → newest. Empty when no data. */
sparkline: number[];
healthyDirection: HealthyDirection;
format: "percent" | "currency" | "count" | "seconds";
/** Current-window result volume split by evaluated target scope. */
scopeCounts?: ScopeCounts;
}
interface ScoresAggRow {
score_results?: number | string;
span_count?: number | string;
trace_count?: number | string;
session_count?: number | string;
}
interface EvaluatorAggRow {
total_runs?: number | string;
success_runs?: number | string;
failure_runs?: number | string;
latency_p95_ms?: number | string | null;
total_cost_usd?: number | string | null;
}
interface ScoresBucketRow {
bucket?: string | number;
score_results_c?: number | string;
}
interface EvaluatorBucketRow {
bucket?: string | number;
total?: number | string;
success?: number | string;
failures?: number | string;
latency_p95_ms?: number | string | null;
cost_usd?: number | string | null;
}
function toNumber(v: unknown): number | null {
if (v == null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
function emptyKpis(): KpiCard[] {
return [
{
id: "scoreResults",
value: null,
prevValue: null,
sparkline: [],
healthyDirection: "neutral",
format: "count",
},
{
id: "evaluationCost",
value: null,
prevValue: null,
sparkline: [],
healthyDirection: "neutral",
format: "currency",
},
{
id: "scorerSuccess",
value: null,
prevValue: null,
sparkline: [],
healthyDirection: "up",
format: "percent",
},
{
id: "scorerFailures",
value: null,
prevValue: null,
sparkline: [],
healthyDirection: "down",
format: "count",
},
{
id: "latencyP95",
value: null,
prevValue: null,
sparkline: [],
healthyDirection: "down",
format: "seconds",
},
];
}
// Org-wide score-result aggregate. latestScoresFromSql() normalizes legacy
// span/trace/session identity and removes superseded score versions before
// this combined-scope count runs.
function whereLines(whereClause: string | null): string[] {
return whereClause ? [`WHERE ${whereClause}`] : [];
}
function scoresSql(whereClause: string | null): string {
return [
"SELECT",
" COUNT(*) AS score_results,",
" COUNT(CASE WHEN _target_scope = 'span' THEN 1 END) AS span_count,",
" COUNT(CASE WHEN _target_scope = 'trace' THEN 1 END) AS trace_count,",
" COUNT(CASE WHEN _target_scope = 'session' THEN 1 END) AS session_count",
`FROM ${latestScoresFromSql(whereClause)}`,
].join("\n");
}
// `_evaluator` spans flatten OTel attributes under an `attributes_` prefix.
// `attributes_latency_ms` is auto-inferred as Utf8 when the first ingested
// value arrives quoted (OTel SDKs sometimes serialize numeric attributes as
// strings). `approx_percentile_cont` requires a numeric input, so we
// TRY_CAST to Double — TRY_CAST returns NULL on unparseable values and
// the percentile aggregator skips NULLs, keeping the query robust.
//
// Cost lives at the top level as `gen_ai_usage_cost` (OTel GenAI semantic
// convention) — not under `attributes_`. Already a Float64 in the schema,
// so no cast needed; SUM() naturally skips NULLs if any sneak in.
function evaluatorSql(whereClause: string | null): string {
return [
"SELECT",
" COUNT(CASE WHEN attributes_status IN ('success', 'error', 'timeout') THEN 1 END) AS total_runs,",
" COUNT(CASE WHEN attributes_status = 'success' THEN 1 END) AS success_runs,",
" COUNT(CASE WHEN attributes_status IN ('error', 'timeout') THEN 1 END) AS failure_runs,",
" approx_percentile_cont(CASE WHEN attributes_status IN ('success', 'error', 'timeout') THEN TRY_CAST(attributes_latency_ms AS DOUBLE) END, 0.95) AS latency_p95_ms,",
" SUM(CASE WHEN attributes_status IN ('success', 'error', 'timeout') THEN gen_ai_usage_cost END) AS total_cost_usd",
'FROM "_evaluator"',
...whereLines(whereClause),
].join("\n");
}
function scoresSparklineSql(interval: string, whereClause: string | null): string {
return [
"SELECT",
` histogram(_timestamp, '${interval}') AS bucket,`,
" COUNT(*) AS score_results_c",
`FROM ${latestScoresFromSql(whereClause)}`,
"GROUP BY bucket",
"ORDER BY bucket",
].join("\n");
}
function evaluatorSparklineSql(interval: string, whereClause: string | null): string {
return [
"SELECT",
` histogram(_timestamp, '${interval}') AS bucket,`,
" COUNT(CASE WHEN attributes_status IN ('success', 'error', 'timeout') THEN 1 END) AS total,",
" COUNT(CASE WHEN attributes_status = 'success' THEN 1 END) AS success,",
" COUNT(CASE WHEN attributes_status IN ('error', 'timeout') THEN 1 END) AS failures,",
" approx_percentile_cont(CASE WHEN attributes_status IN ('success', 'error', 'timeout') THEN TRY_CAST(attributes_latency_ms AS DOUBLE) END, 0.95) AS latency_p95_ms,",
" SUM(CASE WHEN attributes_status IN ('success', 'error', 'timeout') THEN gen_ai_usage_cost END) AS cost_usd",
'FROM "_evaluator"',
...whereLines(whereClause),
"GROUP BY bucket",
"ORDER BY bucket",
].join("\n");
}
export function useQualityData(
dateWindow: Ref<DateWindow>,
agentFilter?: Ref<AgentFilterSelection | null | undefined>,
) {
const { executeQuery } = useLLMStreamQuery();
const sourceStream = ref<string>("__all__");
const isLoading = ref(false);
const kpis = ref<KpiCard[]>(emptyKpis());
async function fetchHits<T>(
pageType: "logs" | "traces",
sql: string,
startUs: number,
endUs: number,
label: string,
): Promise<T[]> {
try {
const hits = await executeQuery(sql, startUs, endUs, pageType);
console.debug(`[Quality:${label}]`, {
pageType,
startUs,
endUs,
hitCount: hits.length,
});
return hits as T[];
} catch (err: any) {
console.warn(`[Quality:${label}] ${pageType} query failed:`, err);
return [];
}
}
async function fetchAgg<T>(
pageType: "logs" | "traces",
sql: string,
startUs: number,
endUs: number,
label: string,
): Promise<T | null> {
const hits = await fetchHits<T>(pageType, sql, startUs, endUs, label);
return hits.length > 0 ? hits[0] : null;
}
async function refresh() {
isLoading.value = true;
try {
const { startUs, endUs } = dateWindow.value;
const windowUs = endUs - startUs;
const prevEndUs = startUs;
const prevStartUs = startUs - windowUs;
const windowMs = windowUs / 1000;
const interval = chooseBucketInterval(windowMs);
const selectedAgent = agentFilter?.value ?? null;
const scoresWhere = combineWhere(
"score_config_id IS NOT NULL",
buildScoresAgentFilterWhere(selectedAgent),
);
const evaluatorWhere = buildEvaluatorAgentFilterWhere(selectedAgent);
const [scoresNow, scoresPrev, evalNow, evalPrev, scoresSeries, evalSeries] =
await Promise.all([
fetchAgg<ScoresAggRow>("logs", scoresSql(scoresWhere), startUs, endUs, "scores.now"),
fetchAgg<ScoresAggRow>(
"logs",
scoresSql(scoresWhere),
prevStartUs,
prevEndUs,
"scores.prev",
),
fetchAgg<EvaluatorAggRow>(
"traces",
evaluatorSql(evaluatorWhere),
startUs,
endUs,
"eval.now",
),
fetchAgg<EvaluatorAggRow>(
"traces",
evaluatorSql(evaluatorWhere),
prevStartUs,
prevEndUs,
"eval.prev",
),
fetchHits<ScoresBucketRow>(
"logs",
scoresSparklineSql(interval, scoresWhere),
startUs,
endUs,
"scores.spark",
),
fetchHits<EvaluatorBucketRow>(
"traces",
evaluatorSparklineSql(interval, evaluatorWhere),
startUs,
endUs,
"eval.spark",
),
]);
const scoreResultsSpark = scoresSeries.map((r) => toNumber(r.score_results_c) ?? 0);
const scorerSuccessSpark = evalSeries.map((r) => {
const tot = toNumber(r.total) ?? 0;
const ok = toNumber(r.success) ?? 0;
return tot > 0 ? (ok / tot) * 100 : 0;
});
const failuresSpark = evalSeries.map((r) => toNumber(r.failures) ?? 0);
const latencySpark = evalSeries.map((r) => {
const ms = toNumber(r.latency_p95_ms);
return ms != null ? ms / 1000 : 0;
});
const costSpark = evalSeries.map((r) => toNumber(r.cost_usd) ?? 0);
const scoreResultsNow = toNumber(scoresNow?.score_results);
const scoreResultsPrev = toNumber(scoresPrev?.score_results);
const totalNow = toNumber(evalNow?.total_runs);
const successNow = toNumber(evalNow?.success_runs);
const totalPrev = toNumber(evalPrev?.total_runs);
const successPrev = toNumber(evalPrev?.success_runs);
const scorerSuccessNow =
totalNow && totalNow > 0 && successNow != null ? (successNow / totalNow) * 100 : null;
const scorerSuccessPrev =
totalPrev && totalPrev > 0 && successPrev != null ? (successPrev / totalPrev) * 100 : null;
const latencyP95SecNow = toNumber(evalNow?.latency_p95_ms);
const latencyP95SecPrev = toNumber(evalPrev?.latency_p95_ms);
const costNow = toNumber(evalNow?.total_cost_usd);
const costPrev = toNumber(evalPrev?.total_cost_usd);
kpis.value = [
{
id: "scoreResults",
value: scoreResultsNow,
prevValue: scoreResultsPrev,
sparkline: scoreResultsSpark,
healthyDirection: "neutral",
format: "count",
scopeCounts: scoresNow ? scopeCountsFromRow(scoresNow) : undefined,
},
{
// Total USD spent by the evaluator (LLM-judge scorer calls) over
// the window. Sourced from `gen_ai_usage_cost` on `_evaluator` —
// top-level OTel GenAI semantic-convention column. Healthy
// direction is "down" (lower spend = better) so the delta arrow
// turns red when cost grows.
id: "evaluationCost",
value: costNow,
prevValue: costPrev,
sparkline: costSpark,
healthyDirection: "down",
format: "currency",
},
{
id: "scorerSuccess",
value: scorerSuccessNow,
prevValue: scorerSuccessPrev,
sparkline: scorerSuccessSpark,
healthyDirection: "up",
format: "percent",
},
{
id: "scorerFailures",
value: toNumber(evalNow?.failure_runs),
prevValue: toNumber(evalPrev?.failure_runs),
sparkline: failuresSpark,
healthyDirection: "down",
format: "count",
},
{
id: "latencyP95",
value: latencyP95SecNow != null ? latencyP95SecNow / 1000 : null,
prevValue: latencyP95SecPrev != null ? latencyP95SecPrev / 1000 : null,
sparkline: latencySpark,
healthyDirection: "down",
format: "seconds",
},
];
} finally {
isLoading.value = false;
}
}
const deltaByKpi = computed(() =>
kpis.value.reduce<Record<string, number | null>>((acc, k) => {
acc[k.id] = k.value != null && k.prevValue != null ? k.value - k.prevValue : null;
return acc;
}, {}),
);
return {
sourceStream,
isLoading,
kpis,
deltaByKpi,
refresh,
};
}
@@ -0,0 +1,320 @@
// @vitest-environment jsdom
import { defineComponent, ref } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import store from "@/test/unit/helpers/store";
import { withQueryClient } from "@/test/unit/helpers/queryClient";
import type {
QualityScore,
QualityScorePage,
QualityScoresParams,
QualityTimelineBucket,
} from "@/services/online-evals.service";
import type { QualityOnly, QualityScope } from "../utils/qualityFormat";
const { scores, timeline } = vi.hoisted(() => ({ scores: vi.fn(), timeline: vi.fn() }));
vi.mock("@/services/online-evals.service", async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
default: { ...actual.default, quality: { list: vi.fn(), scores, timeline } },
};
});
import { SCORES_PAGE_SIZE, useQualityDetail } from "./useQualityDetail";
let TOTAL = 25;
/** Scores written since the first read, which push the older ones down the list. */
let NEW_SCORES = 0;
const score = (index: number): QualityScore => ({
id: `s${index}`,
timestamp: index,
refTimestamp: index,
sourceType: "llm_judge",
targetScope: "trace",
targetId: `t${index}`,
spanId: null,
traceId: `t${index}`,
sessionId: null,
sourceStream: "default",
sourceStreamType: "traces",
value: 0.5,
unhealthy: false,
reasoning: null,
evaluatorTraceId: null,
taskId: null,
inputPreview: null,
outputPreview: null,
inputSpanId: null,
outputSpanId: null,
});
function page(params: QualityScoresParams): QualityScorePage {
const from = params.from ?? 0;
const size = params.size ?? 20;
const list = Array.from({ length: Math.max(0, Math.min(size, TOTAL - from)) }, (_, i) =>
score(from + i - NEW_SCORES),
);
return {
average: 0.5,
distribution: [
{ key: 0, lower: 0, upper: 0.5, count: 15, unhealthy: 6 },
{ key: 1, lower: 0.5, upper: 1, count: 10, unhealthy: 0 },
],
list,
total: TOTAL,
from,
size,
};
}
function mountDetail(
options: { initial?: { page: number; scoreId: string | null }; enabled?: boolean } = {},
) {
const state = {
configId: ref("cfg-1"),
scope: ref<QualityScope>("all"),
only: ref<QualityOnly>("all"),
dateWindow: ref({ startUs: 10, endUs: 20 }),
agentParams: ref<Record<string, string>>({}),
enabled: ref(options.enabled ?? true),
};
let api!: ReturnType<typeof useQualityDetail>;
const Host = defineComponent({
setup() {
api = useQualityDetail({ ...state, initial: options.initial });
return () => null;
},
});
mount(Host, { global: { plugins: [store, withQueryClient()] } });
return { state, api: () => api };
}
const lastParams = () => scores.mock.calls[scores.mock.calls.length - 1][2] as QualityScoresParams;
const BUCKET: QualityTimelineBucket = {
start: 10,
end: 20,
count: 25,
unhealthy: 6,
trueCount: null,
average: 0.5,
p95: 0.9,
};
describe("useQualityDetail", () => {
beforeEach(() => {
TOTAL = 25;
NEW_SCORES = 0;
scores.mockReset().mockImplementation((_org, _id, params) => Promise.resolve(page(params)));
timeline.mockReset().mockResolvedValue({ timeline: [BUCKET] });
});
it("reads the trend with the window, scope and agent only, so paging, only and the chart filter never refetch it", async () => {
const { state, api } = mountDetail();
await flushPromises();
expect(timeline).toHaveBeenCalledTimes(1);
expect(timeline).toHaveBeenCalledWith("default", "cfg-1", { start_time: 10, end_time: 20 });
expect(api().timeline.value).toEqual([BUCKET]);
api().setPage(2);
await flushPromises();
state.only.value = "unhealthy";
await flushPromises();
api().filter.value = { kind: "bucket", from: 0, to: 0 };
await flushPromises();
api().next();
await flushPromises();
expect(scores.mock.calls.length).toBeGreaterThan(3);
expect(timeline).toHaveBeenCalledTimes(1);
expect(api().timeline.value).toEqual([BUCKET]);
state.scope.value = "trace";
state.agentParams.value = { agent_name: "alpha" };
await flushPromises();
expect(timeline).toHaveBeenLastCalledWith("default", "cfg-1", {
start_time: 10,
end_time: 20,
scope: "trace",
agent_name: "alpha",
});
});
it("keeps the trend on screen while a new window loads, but not across configs", async () => {
const { state, api } = mountDetail();
await flushPromises();
timeline.mockReturnValue(new Promise(() => {}));
state.dateWindow.value = { startUs: 70_000_000, endUs: 80_000_000 };
await flushPromises();
expect(timeline).toHaveBeenCalledTimes(2);
expect(api().timelineQuery.isPending.value).toBe(false);
expect(api().timeline.value).toEqual([BUCKET]);
state.configId.value = "cfg-2";
await flushPromises();
expect(api().timelineQuery.isPending.value).toBe(true);
expect(api().timeline.value).toEqual([]);
});
it("asks for the first page of 10 and takes the counts from distribution[]", async () => {
const { api } = mountDetail();
await flushPromises();
expect(scores).toHaveBeenCalledWith("default", "cfg-1", {
start_time: 10,
end_time: 20,
from: 0,
size: SCORES_PAGE_SIZE,
});
expect(api().summary.value).toEqual({ total: 25, unhealthy: 6, status: "attention" });
expect(api().selected.value?.id).toBe("s0");
});
it("sends scope, only, the bucket filter and the page", async () => {
const { state, api } = mountDetail();
await flushPromises();
state.scope.value = "trace";
state.only.value = "unhealthy";
api().filter.value = { kind: "bucket", from: 0, to: 1 };
api().setPage(1);
await flushPromises();
expect(lastParams()).toEqual({
start_time: 10,
end_time: 20,
scope: "trace",
unhealthy_only: true,
bucket_from: 0,
bucket_to: 1,
from: 10,
size: SCORES_PAGE_SIZE,
});
api().filter.value = { kind: "value", value: "medium" };
await flushPromises();
expect(lastParams()).toMatchObject({ value: "medium", from: 0 });
expect(lastParams()).not.toHaveProperty("bucket_from");
});
it("resets the page and the selection on a scope, filter or config change", async () => {
const { state, api } = mountDetail();
await flushPromises();
const moveAway = async () => {
api().setPage(2);
await flushPromises();
api().select("s23");
expect(api().selected.value?.id).toBe("s23");
};
const expectReset = async () => {
expect(api().page.value).toBe(0);
await flushPromises();
expect(api().selected.value?.id).toBe("s0");
};
await moveAway();
api().filter.value = { kind: "bucket", from: 1, to: 1 };
await expectReset();
await moveAway();
state.scope.value = "trace";
// A scope change also drops the bucket filter.
expect(api().filter.value).toBeNull();
await expectReset();
await moveAway();
api().filter.value = { kind: "bucket", from: 0, to: 0 };
state.configId.value = "cfg-2";
expect(api().filter.value).toBeNull();
await expectReset();
await moveAway();
state.only.value = "unhealthy";
await expectReset();
});
it("keeps the page and the selected score when a Refresh moves the window", async () => {
const { state, api } = mountDetail();
await flushPromises();
api().setPage(1);
await flushPromises();
api().select("s13");
// Two scores arrive and the window moves to now: s13 slides two rows down the same page.
NEW_SCORES = 2;
state.dateWindow.value = { startUs: 70_000_000, endUs: 80_000_000 };
await flushPromises();
expect(lastParams()).toMatchObject({ start_time: 70_000_000, from: 10 });
expect(api().page.value).toBe(1);
expect(api().selected.value?.id).toBe("s13");
expect(api().list.value.indexOf(api().selected.value!)).toBe(5);
});
it("steps back to the last page when a shorter window leaves the page past the end", async () => {
const { state, api } = mountDetail();
await flushPromises();
api().setPage(2);
await flushPromises();
TOTAL = 12;
state.dateWindow.value = { startUs: 70_000_000, endUs: 80_000_000 };
await flushPromises();
expect(api().page.value).toBe(1);
expect(lastParams().from).toBe(10);
});
it("moves to the next page on the last row and back to the last row of the previous page", async () => {
const { api } = mountDetail();
await flushPromises();
api().select("s9");
expect(api().selected.value?.id).toBe("s9");
api().next();
await flushPromises();
expect(lastParams().from).toBe(10);
expect(api().selected.value?.id).toBe("s10");
api().prev();
await flushPromises();
expect(api().selected.value?.id).toBe("s9");
});
it("stops at the last score", async () => {
const { api } = mountDetail();
await flushPromises();
api().setPage(2);
await flushPromises();
api().select("s24");
expect(api().selected.value?.id).toBe("s24");
expect(api().canNext.value).toBe(false);
api().next();
expect(api().selected.value?.id).toBe("s24");
});
it("reopens the page and the score from the URL", async () => {
const { api } = mountDetail({ initial: { page: 1, scoreId: "s13" } });
await flushPromises();
expect(scores).toHaveBeenCalledTimes(1);
expect(lastParams().from).toBe(10);
expect(api().selected.value?.id).toBe("s13");
});
it("falls back to the first row when the restored score is not on the page", async () => {
const { api } = mountDetail({ initial: { page: 1, scoreId: "s99" } });
await flushPromises();
expect(api().selected.value?.id).toBe("s10");
});
it("keeps a restored page while the agent levels are restored before the first read", async () => {
const { state, api } = mountDetail({ initial: { page: 2, scoreId: "s21" }, enabled: false });
state.agentParams.value = { agent_name: "alpha" };
state.enabled.value = true;
await flushPromises();
expect(scores).toHaveBeenCalledTimes(1);
expect(lastParams()).toMatchObject({ agent_name: "alpha", from: 20 });
expect(api().selected.value?.id).toBe("s21");
// Once reading, an agent change starts the list over as before.
state.agentParams.value = { agent_name: "beta" };
expect(api().page.value).toBe(0);
});
it("reports the status of a failed scores request", async () => {
scores.mockReset().mockRejectedValue({ response: { status: 404 } });
const { api } = mountDetail();
await flushPromises();
expect(api().errorStatus.value).toBe(404);
});
});
@@ -0,0 +1,191 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, ref, watch, type Ref } from "vue";
import { useQuery, type Query } from "@tanstack/vue-query";
import { useOrgId } from "@/composables/query/useOrgId";
import type { QualityAgentParams } from "@/services/online-evals.service";
import { qualityScoresQuery, qualityTimelineQuery } from "@/services/online-evals.service.queries";
import {
chartFilterParams,
summarizeDistribution,
type QualityChartFilter,
type QualityOnly,
type QualityScope,
} from "../utils/qualityFormat";
import { httpStatus, type DateWindow } from "./useQualityList";
export const SCORES_PAGE_SIZE = 10;
export function useQualityDetail(opts: {
configId: Ref<string>;
scope: Ref<QualityScope>;
only: Ref<QualityOnly>;
dateWindow: Ref<DateWindow>;
agentParams: Ref<QualityAgentParams>;
enabled: Ref<boolean>;
/** Chart filter, page and score to reopen, read from the URL once. */
initial?: { page: number; scoreId: string | null; filter?: QualityChartFilter | null };
}) {
const orgId = useOrgId();
const filter = ref<QualityChartFilter | null>(opts.initial?.filter ?? null);
/** Zero-based page of the score list. */
const page = ref(opts.initial?.page ?? 0);
/** Selected score; when it is not on the page, `selectedIndex` picks the row. */
const selectedId = ref<string | null>(opts.initial?.scoreId ?? null);
const selectedIndex = ref(0);
const scopeParams = computed(() => ({
...opts.agentParams.value,
...(opts.scope.value !== "all" ? { scope: opts.scope.value } : {}),
}));
// What the list shows; a change starts it over. The window stays out, so a Refresh keeps the page.
const listParams = computed(() => ({
...scopeParams.value,
...(opts.only.value === "unhealthy" ? { unhealthy_only: true } : {}),
...chartFilterParams(filter.value),
}));
function goToPage(next: number, index = 0) {
page.value = next;
selectedIndex.value = index;
selectedId.value = null;
}
// Sync, so no request goes out with the previous config's or scope's bucket filter.
watch([opts.configId, opts.scope], () => (filter.value = null), { flush: "sync" });
// Before the first read the agent levels are still being restored from the URL, so they must not reset a restored page.
watch(
() => [opts.configId.value, JSON.stringify(listParams.value)],
() => {
if (opts.enabled.value) goToPage(0);
},
{ flush: "sync" },
);
// Keep what is on screen while another page, filter or window loads, never across configs (key[5] is the entity id).
const readOptions = () => ({
enabled: !!orgId.value && !!opts.configId.value && opts.enabled.value,
// A new function on every read: while it stays the same, the query reuses its last placeholder unchecked.
placeholderData: <T>(previous: T | undefined, previousQuery?: Query<any, any, any, any>) =>
previousQuery?.queryKey?.[5] === opts.configId.value ? previous : undefined,
});
const scoresQuery = useQuery(() =>
Object.assign(
qualityScoresQuery(orgId.value, opts.configId.value, {
start_time: opts.dateWindow.value.startUs,
end_time: opts.dateWindow.value.endUs,
...listParams.value,
from: page.value * SCORES_PAGE_SIZE,
size: SCORES_PAGE_SIZE,
}),
readOptions(),
),
);
const timelineQuery = useQuery(() =>
Object.assign(
qualityTimelineQuery(orgId.value, opts.configId.value, {
start_time: opts.dateWindow.value.startUs,
end_time: opts.dateWindow.value.endUs,
...scopeParams.value,
}),
readOptions(),
),
);
// A filter the data cannot take (a bucket on a categorical config, from an old or edited link) fails
// with a 400 that is never retried; drop it so the scores load instead of a dead-end error.
watch(
() => httpStatus(scoresQuery.error.value),
(status) => {
if (status === 400 && filter.value) filter.value = null;
},
);
const data = computed(() => scoresQuery.data.value);
const distribution = computed(() => data.value?.distribution ?? []);
const timeline = computed(() => timelineQuery.data.value ?? []);
const summary = computed(() => summarizeDistribution(distribution.value));
const list = computed(() => data.value?.list ?? []);
const total = computed(() => data.value?.total ?? 0);
const listLoading = computed(
() => scoresQuery.isPending.value || scoresQuery.isPlaceholderData.value,
);
const selected = computed(() => {
if (listLoading.value || !list.value.length) return null;
return (
list.value.find((score) => score.id === selectedId.value) ??
list.value[Math.min(selectedIndex.value, list.value.length - 1)]
);
});
const selectedRow = computed(() => (selected.value ? list.value.indexOf(selected.value) : -1));
const position = computed(() => page.value * SCORES_PAGE_SIZE + selectedRow.value);
const canPrev = computed(() => !!selected.value && position.value > 0);
const canNext = computed(() => !!selected.value && position.value < total.value - 1);
// A shorter window can leave the page past the end; step back to the last page.
watch(total, (count) => {
const last = Math.max(0, Math.ceil(count / SCORES_PAGE_SIZE) - 1);
if (!listLoading.value && page.value > last) goToPage(last);
});
function selectAt(index: number) {
selectedIndex.value = index;
selectedId.value = list.value[index]?.id ?? null;
}
/** Next score; past the last row it loads the next page and selects its first row. */
function next() {
if (!canNext.value) return;
if (selectedRow.value < list.value.length - 1) selectAt(selectedRow.value + 1);
else goToPage(page.value + 1);
}
/** Previous score; before the first row it loads the previous page and selects its last row. */
function prev() {
if (!canPrev.value) return;
if (selectedRow.value > 0) selectAt(selectedRow.value - 1);
else goToPage(page.value - 1, SCORES_PAGE_SIZE - 1);
}
function select(id: string) {
const index = list.value.findIndex((score) => score.id === id);
if (index >= 0) selectAt(index);
}
return {
scoresQuery,
timelineQuery,
filter,
page,
distribution,
timeline,
summary,
list,
total,
listLoading,
selected,
canPrev,
canNext,
next,
prev,
select,
setPage: (next: number) => goToPage(next),
average: computed(() => data.value?.average ?? null),
errorStatus: computed(() => httpStatus(scoresQuery.error.value)),
};
}
@@ -1,335 +0,0 @@
// Chart data for the Tier-2 selected-state detail panel.
// One query per chart family. Each query is scoped to a single
// score_config_id and the active time window.
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
import { ref, watch, type Ref } from "vue";
import { useLLMStreamQuery } from "@/plugins/traces/composables/useLLMStreamQuery";
import type { ScoreConfig } from "@/services/online-evals.service";
import { dataTypeOf, entityId } from "../utils/evalEntity";
import { chooseBucketInterval, type DateWindow } from "./useQualityData";
import {
buildScoresAgentFilterWhere,
combineWhere,
type AgentFilterSelection,
} from "../utils/agentFilterSql";
import { latestScoresFromSql } from "../utils/latestScoreSql";
import { qualityScopeWhere, type QualityScope } from "../utils/qualityScope";
import { healthyBooleanValue } from "../utils/qualitySummary";
export interface TrendPoint {
/** Bucket end in milliseconds */
t: number;
avg: number | null;
p95: number | null;
}
export interface DistributionBucket {
rangeStart: number;
rangeEnd: number;
label: I18nText;
count: number;
healthy: boolean;
}
export interface BooleanTrendPoint {
t: number;
passRate: number; // 0-100
total: number;
}
export interface BooleanTrendSeries {
/** Stable id derived from the group-by key (e.g. scorer_id, source_type, or "default"). */
id: string;
/** Display label shown in the legend / tooltip. */
label: I18nText;
points: BooleanTrendPoint[];
}
function toNumber(v: unknown): number | null {
if (v == null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
function escapeSqlString(s: string): string {
return s.replace(/'/g, "''");
}
function bucketToMs(bucket: unknown): number {
if (typeof bucket === "number") {
return bucket > 1e14 ? Math.round(bucket / 1000) : bucket;
}
if (typeof bucket === "string") {
const parsed = Date.parse(bucket);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
}
interface RawNumericTrendRow {
bucket?: string | number;
avg_v?: number | string | null;
p95_v?: number | string | null;
}
interface RawBooleanSplitRow {
bucket?: string | number;
series_key?: string | number | null;
total?: number | string;
healthy?: number | string;
}
function valueOf<T = any>(row: any, camel: string, snake: string): T | undefined {
if (row == null) return undefined;
return row[camel] ?? row[snake];
}
function numericRangeOf(config: ScoreConfig): { min: number; max: number } | null {
const r = valueOf<any>(config, "numericRange", "numeric_range");
if (!r) return null;
const min = toNumber(r.min);
const max = toNumber(r.max);
if (min == null || max == null || max <= min) return null;
return { min, max };
}
function healthyThresholdValue(
config: ScoreConfig,
): { value: number; direction: "gte" | "lte" } | null {
const ht = valueOf<any>(config, "healthyThreshold", "healthy_threshold");
if (!ht || ht.value == null || !ht.direction) return null;
const v = toNumber(ht.value);
if (v == null) return null;
return { value: v, direction: ht.direction === "gte" ? "gte" : "lte" };
}
export function useQualityDetailCharts(
selectedConfig: Ref<ScoreConfig | null>,
dateWindow: Ref<DateWindow>,
agentFilter: Ref<AgentFilterSelection | null | undefined>,
qualityScope: Ref<QualityScope>,
t: TranslateFn,
) {
const { executeQuery } = useLLMStreamQuery();
const isLoading = ref(false);
const numericTrend = ref<TrendPoint[]>([]);
const numericDistribution = ref<DistributionBucket[]>([]);
const booleanTrend = ref<BooleanTrendPoint[]>([]);
const booleanTrendSeries = ref<BooleanTrendSeries[]>([]);
let refreshGeneration = 0;
function clearData() {
numericTrend.value = [];
numericDistribution.value = [];
booleanTrend.value = [];
booleanTrendSeries.value = [];
}
async function runQuery<T>(
sqlText: string,
label: string,
startUs: number,
endUs: number,
): Promise<T[]> {
try {
const hits = await executeQuery(sqlText, startUs, endUs, "logs");
console.debug(`[QualityCharts:${label}]`, { hitCount: hits.length });
return hits as T[];
} catch (err: any) {
console.warn(`[QualityCharts:${label}] failed:`, err);
return [];
}
}
function buildDistribution(
values: number[],
range: { min: number; max: number },
threshold: { value: number; direction: "gte" | "lte" } | null,
): DistributionBucket[] {
if (values.length === 0) return [];
const buckets = 8;
const width = (range.max - range.min) / buckets;
if (!Number.isFinite(width) || width <= 0) return [];
const counts = new Array<number>(buckets).fill(0);
for (const v of values) {
let idx = Math.floor((v - range.min) / width);
if (idx < 0) idx = 0;
if (idx >= buckets) idx = buckets - 1;
counts[idx] += 1;
}
return counts.map((c, i) => {
const start = range.min + i * width;
const end = i === buckets - 1 ? range.max : start + width;
const healthy = threshold
? threshold.direction === "gte"
? start >= threshold.value
: start <= threshold.value
: false;
const decimals = width < 1 ? 1 : 0;
return {
rangeStart: start,
rangeEnd: end,
label: raw(`${start.toFixed(decimals)}–${end.toFixed(decimals)}`),
count: c,
healthy,
};
});
}
async function refresh() {
const generation = ++refreshGeneration;
const cfg = selectedConfig.value;
clearData();
if (!cfg) {
isLoading.value = false;
return;
}
isLoading.value = true;
try {
const { startUs, endUs } = dateWindow.value;
const interval = chooseBucketInterval((endUs - startUs) / 1000);
// `score_config_id` on `_llm_scores` is the entity_id, not the
// per-version row id — join on entity_id so trend/distribution chart
// queries survive version bumps.
const configId = escapeSqlString(entityId(cfg));
const type = dataTypeOf(cfg);
const where = combineWhere(
`CAST(score_config_id AS VARCHAR) = '${configId}'`,
buildScoresAgentFilterWhere(agentFilter.value ?? null),
qualityScopeWhere(qualityScope.value),
)!;
if (type === "numeric") {
const trendSql = [
"SELECT",
` histogram(_timestamp, '${interval}') AS bucket,`,
" AVG(value_numeric) AS avg_v,",
" approx_percentile_cont(value_numeric, 0.95) AS p95_v",
`FROM ${latestScoresFromSql(where)}`,
"GROUP BY bucket",
"ORDER BY bucket",
].join("\n");
const valuesSql = [
"SELECT value_numeric AS v",
`FROM ${latestScoresFromSql(where)}`,
"WHERE value_numeric IS NOT NULL",
].join("\n");
const [trendRows, valueRows] = await Promise.all([
runQuery<RawNumericTrendRow>(trendSql, "numeric.trend", startUs, endUs),
runQuery<{ v?: number | string }>(valuesSql, "numeric.values", startUs, endUs),
]);
if (generation !== refreshGeneration) return;
numericTrend.value = trendRows
.map((r) => ({
t: bucketToMs(r.bucket),
avg: toNumber(r.avg_v),
p95: toNumber(r.p95_v),
}))
.filter((r) => r.t > 0);
const range = numericRangeOf(cfg);
if (range) {
const values = valueRows.map((r) => toNumber(r.v)).filter((v): v is number => v != null);
numericDistribution.value = buildDistribution(values, range, healthyThresholdValue(cfg));
} else {
numericDistribution.value = [];
}
booleanTrend.value = [];
booleanTrendSeries.value = [];
} else if (type === "boolean") {
const expected = healthyBooleanValue(cfg);
const healthyValue = expected ?? true;
// Single pass-rate series — a constant series_key keeps the downstream
// grouped-series code path working as a one-element array.
const trendSql = [
"SELECT",
` histogram(_timestamp, '${interval}') AS bucket,`,
" '__default__' AS series_key,",
" COUNT(value_boolean) AS total,",
` COUNT(CASE WHEN value_boolean = ${healthyValue} THEN 1 END) AS healthy`,
`FROM ${latestScoresFromSql(where)}`,
"GROUP BY bucket, series_key",
"ORDER BY bucket",
].join("\n");
const rows = await runQuery<RawBooleanSplitRow>(trendSql, "boolean.trend", startUs, endUs);
if (generation !== refreshGeneration) return;
const groupedByKey = new Map<string, BooleanTrendPoint[]>();
for (const r of rows) {
const key = r.series_key != null ? String(r.series_key) : "__default__";
const total = toNumber(r.total) ?? 0;
const healthy = toNumber(r.healthy) ?? 0;
const point: BooleanTrendPoint = {
t: bucketToMs(r.bucket),
total,
passRate: total > 0 ? (healthy / total) * 100 : 0,
};
if (point.t === 0) continue;
if (!groupedByKey.has(key)) groupedByKey.set(key, []);
groupedByKey.get(key)!.push(point);
}
const series: BooleanTrendSeries[] = Array.from(groupedByKey.entries()).map(
([key, points]) => ({
id: key,
label:
key === "__default__"
? expected == null
? t("onlineEvals.quality.detail.legendTrueRate")
: t("onlineEvals.quality.detail.legendHealthyRate")
: raw(key),
points: points.sort((a, b) => a.t - b.t),
}),
);
// Sort by total volume descending so the dominant series renders first.
series.sort(
(a, b) =>
b.points.reduce((s, p) => s + p.total, 0) - a.points.reduce((s, p) => s + p.total, 0),
);
booleanTrendSeries.value = series;
// Keep the legacy flat array for any consumer still binding it; it
// mirrors the first (largest) series.
booleanTrend.value = series[0]?.points ?? [];
numericTrend.value = [];
numericDistribution.value = [];
} else {
numericTrend.value = [];
numericDistribution.value = [];
booleanTrend.value = [];
booleanTrendSeries.value = [];
}
} finally {
if (generation === refreshGeneration) isLoading.value = false;
}
}
// Opening a row or changing the detail scope refreshes here. Date-window /
// agent-filter changes are driven by the page's
// refreshAll(), so watching them here would double-fire the chart queries
// alongside the KPI/table reload. No `immediate`: the initial load is
// covered by refreshAll() too.
watch(
() => [selectedConfig.value, qualityScope.value] as const,
() => {
void refresh();
},
);
return {
isLoading,
numericTrend,
numericDistribution,
booleanTrend,
booleanTrendSeries,
refresh,
};
}
@@ -1,108 +0,0 @@
// @vitest-environment jsdom
const mockExecuteQuery = vi.fn();
vi.mock("@/plugins/traces/composables/useLLMStreamQuery", () => ({
useLLMStreamQuery: () => ({ executeQuery: mockExecuteQuery }),
}));
import { beforeEach, describe, expect, it, vi } from "vitest";
import { nextTick, ref } from "vue";
import type { ScoreConfig } from "@/services/online-evals.service";
import type { QualityScope } from "../utils/qualityScope";
import { gt } from "@/types/i18n";
import { useQualityConfigDetail } from "./useQualityConfigDetail";
import { useQualityDetailCharts } from "./useQualityDetailCharts";
const DATE_WINDOW = { startUs: 100, endUs: 200 };
async function flushAsync() {
await nextTick();
await Promise.resolve();
await Promise.resolve();
}
beforeEach(() => {
mockExecuteQuery.mockReset();
});
describe("Quality detail scope queries", () => {
it("scopes every KPI query and refreshes its data when scope changes", async () => {
mockExecuteQuery.mockImplementation(async (sql: string) => {
const targets = sql.includes("_target_scope = 'session'") ? 1 : 4;
if (sql.includes("COUNT(*) AS score_results")) {
return [{ score_results: targets, targets_scored: targets }];
}
return [{ value_categorical: "high", c: targets }];
});
const config = ref<ScoreConfig | null>({
id: "version-1",
entityId: "instruction-following",
name: "Instruction following",
version: 1,
dataType: "categorical",
healthyThreshold: { healthy_categories: ["high"] },
});
const scope = ref<QualityScope>("trace");
const detail = useQualityConfigDetail(config, ref(DATE_WINDOW), ref(null), scope, gt);
await detail.refresh();
expect(mockExecuteQuery).toHaveBeenCalledTimes(2);
expect(
mockExecuteQuery.mock.calls.every(([sql]) => sql.includes("_target_scope = 'trace'")),
).toBe(true);
expect(detail.kpis.value.find((kpi) => kpi.id === "targetsScored")?.value).toBe(4);
mockExecuteQuery.mockClear();
scope.value = "session";
await flushAsync();
expect(mockExecuteQuery).toHaveBeenCalledTimes(2);
expect(
mockExecuteQuery.mock.calls.every(([sql]) => sql.includes("_target_scope = 'session'")),
).toBe(true);
expect(detail.kpis.value.find((kpi) => kpi.id === "targetsScored")?.value).toBe(1);
});
it("scopes every chart query and refreshes chart data when scope changes", async () => {
mockExecuteQuery.mockImplementation(async (sql: string) => {
const value = sql.includes("_target_scope = 'session'") ? 1 : 4;
if (sql.includes("value_numeric AS v")) return [{ v: value }];
return [
{
bucket: "2026-07-16T00:00:00Z",
avg_v: value,
p95_v: value,
},
];
});
const config = ref<ScoreConfig | null>({
id: "version-1",
entityId: "answer-quality",
name: "Answer quality",
version: 1,
dataType: "numeric",
numericRange: { min: 0, max: 5 },
});
const scope = ref<QualityScope>("trace");
const charts = useQualityDetailCharts(config, ref(DATE_WINDOW), ref(null), scope);
await charts.refresh();
expect(mockExecuteQuery).toHaveBeenCalledTimes(2);
expect(
mockExecuteQuery.mock.calls.every(([sql]) => sql.includes("_target_scope = 'trace'")),
).toBe(true);
expect(charts.numericTrend.value[0]?.avg).toBe(4);
mockExecuteQuery.mockClear();
scope.value = "session";
await flushAsync();
expect(mockExecuteQuery).toHaveBeenCalledTimes(2);
expect(
mockExecuteQuery.mock.calls.every(([sql]) => sql.includes("_target_scope = 'session'")),
).toBe(true);
expect(charts.numericTrend.value[0]?.avg).toBe(1);
});
});
@@ -0,0 +1,170 @@
// @vitest-environment jsdom
import { defineComponent, ref } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import store from "@/test/unit/helpers/store";
import { withQueryClient } from "@/test/unit/helpers/queryClient";
import type { QualityConfigSummary, ScoreConfig } from "@/services/online-evals.service";
const { list } = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("@/services/online-evals.service", async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
default: { ...actual.default, quality: { list, scores: vi.fn() } },
};
});
import { joinQualityRows, qualityTiles, useQualityList } from "./useQualityList";
const summary = (overrides: Partial<QualityConfigSummary>): QualityConfigSummary => ({
configId: "id",
name: "name",
dataType: "numeric",
status: "healthy",
total: 10,
unhealthy: 0,
average: 0.5,
lastScoredAt: 100,
scopeCounts: { span: 0, trace: 10, session: 0 },
topValue: null,
...overrides,
});
const LIST: QualityConfigSummary[] = [
summary({ configId: "hal", name: "hallucination", status: "attention", unhealthy: 7, total: 38 }),
summary({
configId: "tox",
name: "toxicity",
dataType: "categorical",
status: "attention",
unhealthy: 67,
total: 114,
lastScoredAt: 1791199632199574,
topValue: { key: "medium", count: 67 },
}),
summary({ configId: "con", name: "conciseness", status: "unset", unhealthy: null }),
summary({
configId: "gro",
name: "groundedness_v2",
status: "no_data",
total: 0,
lastScoredAt: null,
average: null,
}),
];
const CONFIGS: ScoreConfig[] = [
{
id: "row-1",
entityId: "hal",
name: "hallucination",
version: 2,
dataType: "numeric",
description: "Likelihood that the response invents facts.",
numericRange: { min: 0, max: 1 },
healthyThreshold: { direction: "lte", value: 0.2 },
},
{
id: "row-2",
entityId: "tox",
name: "toxicity",
version: 1,
dataType: "categorical",
description: "Toxicity level of the response.",
healthyThreshold: { healthy_categories: ["low"] },
},
];
describe("joinQualityRows", () => {
it("adds description, version, range and the threshold rule by entity id", () => {
const rows = joinQualityRows(LIST, CONFIGS);
expect(rows[0]).toMatchObject({
configId: "hal",
description: "Likelihood that the response invents facts.",
version: 2,
thresholdLabel: "≤ 0.2",
range: { min: 0, max: 1 },
});
expect(rows[1].thresholdLabel).toBe("low");
// A config missing from the list keeps empty defaults and the 0 to 1 range.
expect(rows[2]).toMatchObject({ description: "", version: null, thresholdLabel: "" });
expect(rows[2].range).toEqual({ min: 0, max: 1 });
});
});
describe("qualityTiles", () => {
it("counts configs needing attention and configs without a result, by reason", () => {
expect(qualityTiles(joinQualityRows(LIST, CONFIGS))).toEqual({
total: 4,
attention: 2,
withoutResult: 2,
noScores: 1,
noThreshold: 1,
});
});
it("counts nothing on an empty list", () => {
expect(qualityTiles([])).toEqual({
total: 0,
attention: 0,
withoutResult: 0,
noScores: 0,
noThreshold: 0,
});
});
});
describe("useQualityList", () => {
beforeEach(() => {
list.mockReset().mockResolvedValue(LIST);
});
function mountList(agentParams = {}) {
let api!: ReturnType<typeof useQualityList>;
const Host = defineComponent({
setup() {
api = useQualityList({
scoreConfigs: ref(CONFIGS),
dateWindow: ref({ startUs: 1_000_000_000, endUs: 2_000_000_000 }),
agentParams: ref(agentParams),
enabled: ref(true),
});
return () => null;
},
});
mount(Host, { global: { plugins: [store, withQueryClient()] } });
return () => api;
}
it("reads the list with the window and the agent filter", async () => {
const api = mountList({ agent_id: "a1", agent_env: "prod" });
await flushPromises();
expect(list).toHaveBeenCalledWith("default", {
start_time: 1_000_000_000,
end_time: 2_000_000_000,
agent_id: "a1",
agent_env: "prod",
});
expect(api().rows.value.map((row) => row.configId)).toEqual(["hal", "tox", "con", "gro"]);
});
it("filters by one tile at a time: needing attention, or without a result", async () => {
const api = mountList();
await flushPromises();
const visible = () => api().visibleRows.value.map((row) => row.configId);
api().tileFilter.value = "attention";
expect(visible()).toEqual(["hal", "tox"]);
api().tileFilter.value = "withoutResult";
expect(visible()).toEqual(["con", "gro"]);
api().tileFilter.value = null;
expect(visible()).toHaveLength(4);
});
it("reports a 403 from the list request", async () => {
list.mockReset().mockRejectedValue({ response: { status: 403 } });
const api = mountList();
await flushPromises();
expect(api().listStatus.value).toBe(403);
});
});
@@ -0,0 +1,120 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, ref, type Ref } from "vue";
import { useQuery } from "@tanstack/vue-query";
import { useOrgId } from "@/composables/query/useOrgId";
import type {
QualityAgentParams,
QualityConfigSummary,
ScoreConfig,
} from "@/services/online-evals.service";
import { qualityListQuery } from "@/services/online-evals.service.queries";
import { entityId, valueOf } from "../utils/evalEntity";
import type { QualityRow } from "../utils/qualityFormat";
import { thresholdForConfig } from "../utils/scoreThreshold";
/** Absolute time window in microseconds. */
export interface DateWindow {
startUs: number;
endUs: number;
}
export const httpStatus = (error: unknown): number | undefined =>
(error as any)?.response?.status ?? (error as any)?.status;
/** Adds the description, version, range and threshold rule from the Score Config list. */
export function joinQualityRows(
list: QualityConfigSummary[],
configs: ScoreConfig[],
): QualityRow[] {
const byId = new Map(configs.map((config) => [entityId(config), config]));
return list.map((summary) => {
const config = byId.get(summary.configId);
const range = config ? valueOf<any>(config, "numericRange", "numeric_range") : null;
return {
...summary,
description: config?.description ?? "",
version: config?.version ?? null,
thresholdLabel: config ? String(thresholdForConfig(config).label) : "",
range: { min: Number(range?.min ?? 0), max: Number(range?.max ?? 1) },
};
});
}
/** The two clickable tiles; one filters the table at a time. */
export type QualityTileFilter = "attention" | "withoutResult";
const TILE_STATUSES: Record<QualityTileFilter, QualityRow["status"][]> = {
attention: ["attention"],
withoutResult: ["no_data", "unset"],
};
export const matchesTileFilter = (row: QualityRow, filter: QualityTileFilter) =>
TILE_STATUSES[filter].includes(row.status);
/** Tile values, all from the one list response. */
export function qualityTiles(rows: QualityRow[]) {
const count = (status: QualityRow["status"]) =>
rows.filter((row) => row.status === status).length;
const noScores = count("no_data");
const noThreshold = count("unset");
return {
total: rows.length,
attention: count("attention"),
withoutResult: noScores + noThreshold,
noScores,
noThreshold,
};
}
export function useQualityList(opts: {
scoreConfigs: Ref<ScoreConfig[]>;
dateWindow: Ref<DateWindow>;
agentParams: Ref<QualityAgentParams>;
enabled: Ref<boolean>;
}) {
const orgId = useOrgId();
const enabled = computed(() => !!orgId.value && opts.enabled.value);
const listQuery = useQuery(() =>
Object.assign(
qualityListQuery(orgId.value, {
start_time: opts.dateWindow.value.startUs,
end_time: opts.dateWindow.value.endUs,
...opts.agentParams.value,
}),
{ enabled: enabled.value },
),
);
const rows = computed(() => joinQualityRows(listQuery.data.value ?? [], opts.scoreConfigs.value));
const tiles = computed(() => qualityTiles(rows.value));
const tileFilter = ref<QualityTileFilter | null>(null);
const visibleRows = computed(() => {
const filter = tileFilter.value;
return filter ? rows.value.filter((row) => matchesTileFilter(row, filter)) : rows.value;
});
const listStatus = computed(() => httpStatus(listQuery.error.value));
return {
listQuery,
rows,
visibleRows,
tiles,
tileFilter,
listStatus,
};
}
@@ -1,263 +0,0 @@
// @vitest-environment jsdom
const mockExecuteQuery = vi.fn();
const mockExecuteQueryOnce = vi.fn();
vi.mock("@/plugins/traces/composables/useLLMStreamQuery", () => ({
useLLMStreamQuery: () => ({
executeQuery: mockExecuteQuery,
executeQueryOnce: mockExecuteQueryOnce,
cancelAll: vi.fn(),
}),
}));
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import type { ScoreConfig } from "@/services/online-evals.service";
import {
buildEvaluatorSpanLookupSql,
buildQualityRunsCountSql,
buildQualityRunsSql,
mapQualityRunRow,
useQualityRuns,
} from "./useQualityRuns";
const WINDOW = { startUs: 100, endUs: 200 };
const numericConfig: ScoreConfig = {
id: "config-version-1",
entityId: "answer-quality",
name: "Answer quality",
version: 1,
dataType: "numeric",
healthyThreshold: { direction: "gte", value: 0.8 },
};
beforeEach(() => {
mockExecuteQuery.mockReset();
mockExecuteQueryOnce.mockReset();
});
describe("Quality score SQL", () => {
it("builds the entire table from _llm_scores with canonical reasoning", () => {
const sql = buildQualityRunsSql(numericConfig, "agent_id = 'agent-1'", "trace", "all", 3, 20);
expect(sql).toContain('FROM "_llm_scores"');
expect(sql).toContain("PARTITION BY _evaluation_key");
expect(sql).toContain("WHERE _latest_score_rank = 1");
expect(sql).toContain("value_numeric");
expect(sql).toContain("value_categorical");
expect(sql).toContain("value_boolean");
expect(sql).toContain("reasoning");
expect(sql).toContain("agent_name");
expect(sql).toContain("agent_id");
expect(sql).toContain("evaluator_trace_id");
expect(sql).toContain("task_id");
expect(sql).toContain("agent_id = 'agent-1'");
expect(sql).toContain("= 'trace'");
expect(sql).toContain("ORDER BY _timestamp DESC, id DESC");
expect(sql).toContain("LIMIT 20 OFFSET 40");
expect(sql).not.toContain("_evaluator");
expect(sql).not.toContain("attributes_response");
});
it("applies the healthy condition for the unhealthy server page", () => {
const sql = buildQualityRunsSql(numericConfig, null, "all", "unhealthy", 1, 10);
const countsSql = buildQualityRunsCountSql(numericConfig, null, "all");
expect(sql).toContain("value_numeric < 0.8");
expect(countsSql).toContain("COUNT(CASE WHEN value_numeric < 0.8 THEN 1 END)");
expect(countsSql).toContain("COUNT(*) AS all_count");
expect(countsSql).toContain("PARTITION BY _evaluation_key");
expect(countsSql).toContain("WHERE _latest_score_rank = 1");
});
it("builds a targeted evaluator-span lookup without joining the runs table", () => {
const sql = buildEvaluatorSpanLookupSql("trace-'one", "task-'one", "score-'one");
expect(sql).toContain('FROM "_evaluator"');
expect(sql).toContain("trace-''one");
expect(sql).toContain("task-''one");
expect(sql).toContain("score-''one");
expect(sql).toContain("attributes_task_id");
expect(sql).toContain("attributes_score_id");
expect(sql).toContain("ORDER BY _timestamp DESC");
expect(sql).toContain("LIMIT 1");
});
it("falls back to the newest task attempt when there is no score id", () => {
const sql = buildEvaluatorSpanLookupSql("trace-1", "task-1");
expect(sql).toContain("trace-1");
expect(sql).toContain("attributes_task_id");
expect(sql).toContain("task-1");
expect(sql).not.toContain("attributes_score_id");
expect(sql).toContain("ORDER BY _timestamp DESC");
expect(sql).toContain("LIMIT 1");
});
});
describe("quality score mapping", () => {
it("uses typed score values and reasoning from the canonical score row", () => {
const row = mapQualityRunRow(
{
_timestamp: 1_784_006_837_417_547,
id: "score-1",
task_id: "task-1",
eval_run_id: "run-1",
evaluator_trace_id: "evaluation-trace",
target_scope: "trace",
target_id: "target-trace",
trace_id: "target-trace",
source_stream: "default",
agent_name: "Support agent",
agent_id: "support-agent",
value_numeric: "0.42",
reasoning: "The response omitted the requested evidence.",
},
numericConfig,
);
expect(row).toMatchObject({
id: "score-1",
taskId: "task-1",
evaluatorTraceId: "evaluation-trace",
evaluatorSpanId: "",
targetScope: "trace",
targetTraceId: "target-trace",
result: 0.42,
resultDisplay: "0.42",
reasoning: "The response omitted the requested evidence.",
agentName: "Support agent",
agentId: "support-agent",
health: "unhealthy",
isUnhealthy: true,
});
expect(row.timestampMs).toBe(1_784_006_837_418);
});
it("maps categorical and boolean values without a response payload", () => {
const categorical = mapQualityRunRow(
{
id: "cat-score",
evaluator_trace_id: "cat-trace",
value_categorical: "low",
reasoning: "missed constraints",
},
{
id: "cat",
name: "Instruction following",
version: 1,
dataType: "categorical",
healthyThreshold: { healthy_categories: ["high"] },
},
);
const boolean = mapQualityRunRow(
{
id: "bool-score",
evaluator_trace_id: "bool-trace",
value_boolean: false,
},
{
id: "bool",
name: "Output format",
version: 1,
dataType: "boolean",
healthyThreshold: { healthy_value: true },
},
);
expect(categorical).toMatchObject({
result: "low",
reasoning: "missed constraints",
resultDisplay: "low",
isUnhealthy: true,
});
expect(boolean).toMatchObject({
result: false,
resultDisplay: "false",
isUnhealthy: true,
});
});
});
describe("useQualityRuns", () => {
it("queries only the logs stream and maps the server page", async () => {
mockExecuteQueryOnce
.mockResolvedValueOnce([{ all_count: 42, unhealthy_count: 7 }])
.mockResolvedValueOnce([
{
id: "score-1",
task_id: "task-1",
evaluator_trace_id: "evaluation-trace",
agent_name: "Support agent",
agent_id: "support-agent",
value_numeric: 0.95,
reasoning: "complete",
},
]);
const { runs, counts, totalCount, refresh } = useQualityRuns(
ref<ScoreConfig | null>(numericConfig),
ref(WINDOW),
ref({
name: "Support agent",
id: "agent-1",
source_stream: "default",
source_stream_type: "traces",
}),
ref("span"),
);
await refresh();
expect(mockExecuteQueryOnce).toHaveBeenCalledTimes(2);
for (const [sql, startUs, endUs, pageType] of mockExecuteQueryOnce.mock.calls) {
expect(sql).toContain('FROM "_llm_scores"');
expect(sql).not.toContain("_evaluator");
expect(sql).not.toContain("attributes_response");
expect(startUs).toBe(100);
expect(endUs).toBe(200);
expect(pageType).toBe("logs");
}
expect(counts.value).toEqual({ all: 42, unhealthy: 7 });
expect(totalCount.value).toBe(42);
expect(runs.value[0]).toMatchObject({
id: "score-1",
taskId: "task-1",
result: 0.95,
reasoning: "complete",
evaluatorTraceId: "evaluation-trace",
agentName: "Support agent",
agentId: "support-agent",
health: "healthy",
});
});
it("resolves the exact score attempt within its task on row activation", async () => {
mockExecuteQuery.mockResolvedValueOnce([{ span_id: "evaluator-span-1" }]);
const { resolveEvaluatorSpanId } = useQualityRuns(
ref<ScoreConfig | null>(numericConfig),
ref({ startUs: 100, endUs: 200 }),
);
const run = mapQualityRunRow(
{
_timestamp: 1_784_006_837_417_547,
id: "score-1",
task_id: "task-1",
evaluator_trace_id: "evaluator-trace-1",
value_numeric: 0.95,
},
numericConfig,
);
await expect(resolveEvaluatorSpanId(run)).resolves.toBe("evaluator-span-1");
expect(mockExecuteQuery).toHaveBeenCalledWith(
expect.stringContaining("attributes_score_id"),
1_784_006_777_418_000,
1_784_010_437_418_000,
"traces",
);
expect(mockExecuteQuery.mock.calls[0][0]).toContain("evaluator-trace-1");
expect(mockExecuteQuery.mock.calls[0][0]).toContain("task-1");
expect(mockExecuteQuery.mock.calls[0][0]).toContain("score-1");
});
});
@@ -1,466 +0,0 @@
// Score Config detail — canonical score records with evaluator-trace drilldown.
//
// The table is intentionally sourced only from `_llm_scores`: typed values,
// reasoning, targets, pagination, and health classification all come from the
// canonical score record. `evaluator_trace_id` is retained only as the link to
// the `_evaluator` trace used for deeper investigation.
import { computed, ref, watch, type Ref } from "vue";
import { useLLMStreamQuery } from "@/plugins/traces/composables/useLLMStreamQuery";
import type { ScoreConfig } from "@/services/online-evals.service";
import { dataTypeOf, entityId } from "../utils/evalEntity";
import { latestScoresFromSql } from "../utils/latestScoreSql";
import {
buildScoresAgentFilterWhere,
combineWhere,
type AgentFilterSelection,
} from "../utils/agentFilterSql";
import type { QualityScope } from "../utils/qualityScope";
import {
escapeSqlString,
isScoreUnhealthy,
thresholdForConfig,
type ScoreValue,
} from "../utils/scoreThreshold";
import type { DateWindow } from "./useQualityData";
export type QualityRunStatus = "success";
export type QualityRunHealth = "healthy" | "unhealthy" | "unclassified";
export type QualityRunFilter = "all" | "unhealthy";
export interface QualityRunCounts {
all: number;
unhealthy: number | null;
}
export interface QualityRunsPagination {
page: number;
size: number;
}
export interface QualityRunRow {
id: string;
taskId: string;
timestampMs: number;
evaluatorTraceId: string;
evaluatorSpanId: string;
evalRunId: string;
status: QualityRunStatus;
result: ScoreValue;
resultDisplay: string;
reasoning: string;
health: QualityRunHealth;
isUnhealthy: boolean;
targetScope: "span" | "trace" | "session" | "unknown";
targetId: string;
targetSpanId: string;
targetTraceId: string;
targetSessionId: string;
targetStream: string;
targetStreamType: string;
agentName: string;
agentId: string;
agentEnv: string;
agentVersion: string;
scorerId: string;
jobId: string;
latencyMs: number | null;
errorKind: string;
}
export interface RawQualityScoreRow {
_timestamp?: number | string;
id?: string | null;
task_id?: string | null;
eval_run_id?: string | null;
evaluator_trace_id?: string | null;
target_scope?: string | null;
level?: string | null;
target_id?: string | null;
span_id?: string | null;
trace_id?: string | null;
session_id?: string | null;
source_stream?: string | null;
source_stream_type?: string | null;
agent_name?: string | null;
agent_id?: string | null;
agent_env?: string | null;
agent_version?: string | null;
scorer_id?: string | null;
job_id?: string | null;
value_numeric?: number | string | null;
value_categorical?: string | null;
value_boolean?: boolean | string | null;
reasoning?: string | null;
}
interface RawQualityRunCounts {
all_count?: number | string | null;
unhealthy_count?: number | string | null;
}
interface RawEvaluatorSpanLookupRow {
span_id?: string | null;
}
const DEFAULT_PAGE_SIZE = 10;
const MAX_PAGE_SIZE = 100;
const normalizedScopeSql = [
"LOWER(COALESCE(",
" NULLIF(CAST(target_scope AS VARCHAR), ''),",
" NULLIF(CAST(level AS VARCHAR), '')",
"))",
].join("\n");
function scoreBaseWhere(configId: string, agentWhere: string | null, scope: QualityScope): string {
const scopeWhere = scope === "all" ? null : `${normalizedScopeSql} = '${scope}'`;
return (
combineWhere(
`CAST(score_config_id AS VARCHAR) = '${escapeSqlString(configId)}'`,
"NULLIF(CAST(evaluator_trace_id AS VARCHAR), '') IS NOT NULL",
agentWhere,
scopeWhere,
) ?? "1 = 1"
);
}
export function buildQualityRunsCountSql(
config: ScoreConfig,
agentWhere: string | null,
scope: QualityScope,
): string {
const threshold = thresholdForConfig(config);
const unhealthyCount = threshold.unhealthyExpr
? `COUNT(CASE WHEN ${threshold.unhealthyExpr} THEN 1 END)`
: "CAST(NULL AS BIGINT)";
return [
"SELECT",
" COUNT(*) AS all_count,",
` ${unhealthyCount} AS unhealthy_count`,
`FROM ${latestScoresFromSql(scoreBaseWhere(entityId(config), agentWhere, scope))}`,
].join("\n");
}
export function buildQualityRunsSql(
config: ScoreConfig,
agentWhere: string | null,
scope: QualityScope,
filter: QualityRunFilter = "all",
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
): string {
const safePage = Math.max(1, Math.floor(page) || 1);
const safePageSize = Math.max(
1,
Math.min(MAX_PAGE_SIZE, Math.floor(pageSize) || DEFAULT_PAGE_SIZE),
);
const threshold = thresholdForConfig(config);
const filterWhere =
filter === "unhealthy"
? threshold.unhealthyExpr
? `(${threshold.unhealthyExpr})`
: "1 = 0"
: null;
const where = combineWhere(scoreBaseWhere(entityId(config), agentWhere, scope), filterWhere);
const offset = (safePage - 1) * safePageSize;
return [
"SELECT",
" _timestamp,",
" id,",
" task_id,",
" eval_run_id,",
" evaluator_trace_id,",
" target_scope,",
" level,",
" target_id,",
" span_id,",
" trace_id,",
" session_id,",
" source_stream,",
" source_stream_type,",
" agent_name,",
" agent_id,",
" agent_env,",
" agent_version,",
" scorer_id,",
" job_id,",
" value_numeric,",
" value_categorical,",
" value_boolean,",
" reasoning",
`FROM ${latestScoresFromSql(where)}`,
"ORDER BY _timestamp DESC, id DESC",
`LIMIT ${safePageSize} OFFSET ${offset}`,
].join("\n");
}
export function buildEvaluatorSpanLookupSql(
evaluatorTraceId: string,
taskId: string,
scoreId?: string,
): string {
return [
"SELECT span_id",
'FROM "_evaluator"',
`WHERE CAST(trace_id AS VARCHAR) = '${escapeSqlString(evaluatorTraceId)}'`,
` AND CAST(attributes_task_id AS VARCHAR) = '${escapeSqlString(taskId)}'`,
...(scoreId
? [` AND CAST(attributes_score_id AS VARCHAR) = '${escapeSqlString(scoreId)}'`]
: []),
"ORDER BY _timestamp DESC",
"LIMIT 1",
].join("\n");
}
function toNumber(value: unknown): number | null {
if (value == null || value === "") return null;
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function timestampToMs(value: unknown): number {
const numeric = toNumber(value);
if (numeric != null) {
return numeric > 1e14 ? Math.round(numeric / 1000) : numeric;
}
if (typeof value === "string") {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
}
function parseTargetScope(row: RawQualityScoreRow): QualityRunRow["targetScope"] {
const normalized = String(row.target_scope ?? row.level ?? "").toLowerCase();
if (normalized === "span" || normalized === "trace" || normalized === "session") {
return normalized;
}
return "unknown";
}
function canonicalScoreValue(score: RawQualityScoreRow, config: ScoreConfig): ScoreValue {
const dataType = dataTypeOf(config);
if (dataType === "numeric") return toNumber(score.value_numeric);
if (dataType === "categorical") {
return score.value_categorical == null ? null : String(score.value_categorical);
}
if (dataType === "boolean") {
if (typeof score.value_boolean === "boolean") return score.value_boolean;
if (score.value_boolean === "true" || score.value_boolean === "false") {
return score.value_boolean === "true";
}
}
return null;
}
function formatResult(result: ScoreValue): string {
if (result == null) return "—";
if (typeof result === "number") return result.toFixed(2);
if (typeof result === "boolean") return result ? "true" : "false";
return result;
}
export function mapQualityRunRow(raw: RawQualityScoreRow, config: ScoreConfig): QualityRunRow {
const result = canonicalScoreValue(raw, config);
const unhealthy = isScoreUnhealthy(config, result);
const targetScope = parseTargetScope(raw);
const targetId = String(
raw.target_id ??
(targetScope === "span"
? raw.span_id
: targetScope === "trace"
? raw.trace_id
: targetScope === "session"
? raw.session_id
: "") ??
"",
);
return {
id: String(raw.id ?? ""),
taskId: String(raw.task_id ?? ""),
timestampMs: timestampToMs(raw._timestamp),
evaluatorTraceId: String(raw.evaluator_trace_id ?? ""),
evaluatorSpanId: "",
evalRunId: String(raw.eval_run_id ?? ""),
status: "success",
result,
resultDisplay: formatResult(result),
reasoning: String(raw.reasoning ?? ""),
health: unhealthy == null ? "unclassified" : unhealthy ? "unhealthy" : "healthy",
isUnhealthy: unhealthy === true,
targetScope,
targetId,
targetSpanId: String(raw.span_id ?? ""),
targetTraceId: String(raw.trace_id ?? ""),
targetSessionId: String(raw.session_id ?? ""),
targetStream: String(raw.source_stream ?? ""),
targetStreamType: String(raw.source_stream_type ?? "traces"),
agentName: String(raw.agent_name ?? ""),
agentId: String(raw.agent_id ?? ""),
agentEnv: String(raw.agent_env ?? ""),
agentVersion: String(raw.agent_version ?? ""),
scorerId: String(raw.scorer_id ?? ""),
jobId: String(raw.job_id ?? ""),
latencyMs: null,
errorKind: "",
};
}
function parseCounts(row: RawQualityRunCounts | undefined): QualityRunCounts {
return {
all: toNumber(row?.all_count) ?? 0,
unhealthy: row?.unhealthy_count == null ? null : (toNumber(row.unhealthy_count) ?? 0),
};
}
export function useQualityRuns(
selectedConfig: Ref<ScoreConfig | null>,
dateWindow: Ref<DateWindow>,
agentFilter?: Ref<AgentFilterSelection | null | undefined>,
qualityScope?: Ref<QualityScope>,
) {
const { executeQuery, executeQueryOnce } = useLLMStreamQuery();
const runs = ref<QualityRunRow[]>([]);
const counts = ref<QualityRunCounts>({ all: 0, unhealthy: null });
const activeFilter = ref<QualityRunFilter>("all");
const currentPage = ref(1);
const pageSize = ref(DEFAULT_PAGE_SIZE);
const isLoading = ref(false);
const error = ref<string | null>(null);
let refreshGeneration = 0;
const totalCount = computed(() => {
if (activeFilter.value === "unhealthy") {
return counts.value.unhealthy ?? 0;
}
return counts.value.all;
});
function queryContext(config: ScoreConfig) {
const { startUs, endUs } = dateWindow.value;
const agentWhere = buildScoresAgentFilterWhere(agentFilter?.value ?? null);
const scope = qualityScope?.value ?? "all";
return { config, startUs, endUs, agentWhere, scope };
}
async function fetchPage(generation: number, config: ScoreConfig) {
const { startUs, endUs, agentWhere, scope } = queryContext(config);
const sql = buildQualityRunsSql(
config,
agentWhere,
scope,
activeFilter.value,
currentPage.value,
pageSize.value,
);
const hits = (await executeQueryOnce(sql, startUs, endUs, "logs")) as RawQualityScoreRow[];
if (generation !== refreshGeneration) return;
runs.value = hits.map((row) => mapQualityRunRow(row, config));
}
async function refresh() {
const generation = ++refreshGeneration;
const config = selectedConfig.value;
if (!config) {
runs.value = [];
counts.value = { all: 0, unhealthy: null };
isLoading.value = false;
error.value = null;
return;
}
isLoading.value = true;
try {
const { startUs, endUs, agentWhere, scope } = queryContext(config);
const countSql = buildQualityRunsCountSql(config, agentWhere, scope);
const [countHits] = await Promise.all([
executeQueryOnce(countSql, startUs, endUs, "logs"),
fetchPage(generation, config),
]);
if (generation !== refreshGeneration) return;
counts.value = parseCounts((countHits as RawQualityRunCounts[])[0]);
const maxPage = Math.max(1, Math.ceil(totalCount.value / pageSize.value));
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
await fetchPage(generation, config);
}
error.value = null;
} catch (caughtError) {
if (generation !== refreshGeneration) return;
console.warn("[QualityDetail:scores] query failed:", caughtError);
error.value = "query_failed";
} finally {
if (generation === refreshGeneration) isLoading.value = false;
}
}
async function setFilter(filter: QualityRunFilter) {
if (activeFilter.value === filter) return;
activeFilter.value = filter;
currentPage.value = 1;
await refresh();
}
async function setPagination(pagination: QualityRunsPagination) {
const nextSize = Math.max(
1,
Math.min(MAX_PAGE_SIZE, Math.floor(pagination.size) || DEFAULT_PAGE_SIZE),
);
currentPage.value =
nextSize === pageSize.value ? Math.max(1, Math.floor(pagination.page) || 1) : 1;
pageSize.value = nextSize;
await refresh();
}
async function resolveEvaluatorSpanId(run: QualityRunRow): Promise<string> {
if (run.evaluatorSpanId) return run.evaluatorSpanId;
if (!run.evaluatorTraceId || !run.taskId) return "";
const timestampUs = run.timestampMs > 0 ? run.timestampMs * 1000 : 0;
const { startUs, endUs } = dateWindow.value;
try {
const hits = (await executeQuery(
buildEvaluatorSpanLookupSql(run.evaluatorTraceId, run.taskId, run.id),
timestampUs ? Math.max(0, timestampUs - 60_000_000) : startUs,
timestampUs ? timestampUs + 3_600_000_000 : endUs,
"traces",
)) as RawEvaluatorSpanLookupRow[];
return String(hits[0]?.span_id ?? "");
} catch (caughtError) {
console.warn("[QualityDetail:scores] evaluator span lookup failed:", caughtError);
return "";
}
}
watch(
() => [
selectedConfig.value ? entityId(selectedConfig.value) : "",
qualityScope?.value ?? "all",
],
([configId], [previousConfigId]) => {
if (configId !== previousConfigId) activeFilter.value = "all";
currentPage.value = 1;
void refresh();
},
);
return {
runs,
counts,
activeFilter,
currentPage,
pageSize,
totalCount,
isLoading,
error,
refresh,
setFilter,
setPagination,
resolveEvaluatorSpanId,
};
}
@@ -1,158 +0,0 @@
// @vitest-environment jsdom
const mockExecuteQuery = vi.fn();
const mockExecuteQueryOnce = vi.fn();
vi.mock("@/plugins/traces/composables/useLLMStreamQuery", () => ({
useLLMStreamQuery: () => ({
executeQuery: mockExecuteQuery,
executeQueryOnce: mockExecuteQueryOnce,
}),
}));
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import type { ScoreConfig } from "@/services/online-evals.service";
import { gt } from "@/types/i18n";
import {
buildTrendSeries,
buildQualityConfigAggSql,
configHealthStatus,
useQualityScoreConfigs,
} from "./useQualityScoreConfigs";
beforeEach(() => {
mockExecuteQuery.mockReset();
mockExecuteQueryOnce.mockReset();
});
describe("buildQualityConfigAggSql", () => {
it("aggregates every quality scope and numeric threshold health", () => {
const config: ScoreConfig = {
id: "version-1",
entityId: "answer-quality",
name: "Answer quality",
version: 1,
dataType: "numeric",
healthyThreshold: { direction: "gte", value: 0.8 },
};
const sql = buildQualityConfigAggSql([config], "agent_id = 'agent-1'");
expect(sql).toContain("_target_scope = 'span'");
expect(sql).toContain("_target_scope = 'trace'");
expect(sql).toContain("_target_scope = 'session'");
expect(sql).toContain("COUNT(value_numeric) AS numeric_values");
expect(sql).toContain("value_numeric < 0.8");
expect(sql).toContain("AS unhealthy_scores");
expect(sql).toContain("agent_id = 'agent-1'");
});
it("applies categorical and boolean thresholds to the shared unhealthy count", () => {
const configs: ScoreConfig[] = [
{
id: "category-v1",
entityId: "instruction-following",
name: "Instruction following",
version: 1,
dataType: "categorical",
healthyThreshold: { healthy_categories: ["high"] },
},
{
id: "boolean-v1",
entityId: "output-format",
name: "Output format",
version: 1,
dataType: "boolean",
healthyThreshold: { healthy_value: true },
},
];
const sql = buildQualityConfigAggSql(configs, null);
expect(sql).toContain("value_categorical NOT IN ('high')");
expect(sql).toContain("value_boolean = false");
});
});
describe("configHealthStatus", () => {
it.each([
[0, null, false, "noData", 3],
[8, null, false, "noThreshold", 2],
[8, 0, true, "healthy", 1],
[8, 1, true, "unhealthy", 0],
] as const)(
"maps total=%s unhealthy=%s threshold=%s to %s",
(total, unhealthy, hasThreshold, status, priority) => {
expect(configHealthStatus(total, unhealthy, hasThreshold)).toEqual({
status,
priority,
});
},
);
});
describe("buildTrendSeries", () => {
it("fills empty buckets so a single observed period does not render flat", () => {
const startMs = Date.UTC(2026, 6, 14, 0, 0, 0);
const rows = [
{
score_config_id: "answer-quality",
bucket: "2026-07-14T00:00:00",
c: 1,
},
{
score_config_id: "answer-quality",
bucket: "2026-07-14T12:00:00",
c: 4,
},
];
expect(
buildTrendSeries(rows, startMs * 1000, (startMs + 18 * 3_600_000) * 1000, "6 hour"),
).toEqual({ "answer-quality": [1, 0, 4, 0] });
});
});
describe("useQualityScoreConfigs volume trend", () => {
it("loads the complete histogram with the full-range query path", async () => {
const startMs = Date.UTC(2026, 6, 14, 0, 0, 0);
const config: ScoreConfig = {
id: "version-1",
entityId: "answer-quality",
name: "Answer quality",
version: 1,
dataType: "numeric",
healthyThreshold: { direction: "gte", value: 0.8 },
};
mockExecuteQuery.mockResolvedValue([]);
mockExecuteQueryOnce.mockResolvedValue([
{
score_config_id: "answer-quality",
bucket: "2026-07-14T00:00:00",
c: 1,
},
{
score_config_id: "answer-quality",
bucket: "2026-07-14T00:10:00",
c: 4,
},
]);
const { rows, refresh } = useQualityScoreConfigs(
ref([config]),
ref({
startUs: startMs * 1000,
endUs: (startMs + 15 * 60_000) * 1000,
}),
gt,
);
await refresh();
expect(mockExecuteQueryOnce).toHaveBeenCalledTimes(1);
expect(mockExecuteQueryOnce.mock.calls[0][0]).toContain("histogram(");
expect(mockExecuteQueryOnce.mock.calls[0][0]).toContain("PARTITION BY _score_attempt_key");
expect(mockExecuteQueryOnce.mock.calls[0][0]).toContain("task_id");
expect(rows.value[0].trendSparkline).toEqual([1, 0, 4, 0]);
});
});
@@ -1,379 +0,0 @@
// Tier 2 Overview composable.
// Batched latest-score aggregates across span, trace, and session targets,
// keyed by each Score Config's stable entity_id.
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
import { computed, ref, type Ref } from "vue";
import { useLLMStreamQuery } from "@/plugins/traces/composables/useLLMStreamQuery";
import type { ScoreConfig } from "@/services/online-evals.service";
import { dataTypeOf, entityId } from "../utils/evalEntity";
import { chooseBucketInterval, type DateWindow } from "./useQualityData";
import {
buildScoresAgentFilterWhere,
combineWhere,
type AgentFilterSelection,
} from "../utils/agentFilterSql";
import { latestScoreAttemptsFromSql, latestScoresFromSql } from "../utils/latestScoreSql";
import { scopeCountsFromRow, type ScopeCounts } from "../utils/qualityScope";
import { qualitySummaryForConfig, type QualityFormat } from "../utils/qualitySummary";
import { buildUnhealthyCaseBranches, thresholdForConfig } from "../utils/scoreThreshold";
// "unhealthy" | "warn" | "noThreshold" are legacy — no longer produced here,
// but QualityConfigSidebar still branches on them.
export type ConfigStatus = "healthy" | "noData" | "unhealthy" | "warn" | "noThreshold";
export interface ScoreConfigRow {
config: ScoreConfig;
configId: string;
name: string;
description: I18nText;
dataType: "numeric" | "categorical" | "boolean" | "unknown";
totalScores: number;
scopeCounts: ScopeCounts;
qualityValue: number | string | null;
qualityFormat: QualityFormat;
qualityLabel: string;
hasThreshold: boolean;
thresholdLabel: string;
unhealthyCount: number | null;
unhealthyPercent: number | null;
lastUpdatedMs: number | null;
status: ConfigStatus;
statusPriority: number;
trendSparkline: number[];
/** Legacy field kept for QualityConfigSidebar, which guards on its absence.
`hasThreshold` is declared above and IS populated, so it is not repeated
here — the merge briefly had it twice. */
unhealthyPct?: number | null;
}
interface AggRow {
score_config_id?: string | null;
total_scores?: number | string;
span_count?: number | string;
trace_count?: number | string;
session_count?: number | string;
avg_numeric?: number | string | null;
numeric_values?: number | string;
unhealthy_scores?: number | string | null;
true_scores?: number | string;
false_scores?: number | string;
last_updated_us?: number | string | null;
}
interface TrendRow {
score_config_id?: string | null;
bucket?: string | number;
c?: number | string;
}
interface CategoryRow {
score_config_id?: string | null;
value_categorical?: string | null;
c?: number | string;
}
function bucketIntervalMs(interval: string): number {
const match = interval.trim().match(/^(\d+)\s+(minute|hour|day)$/i);
if (!match) return 60_000;
const amount = Number(match[1]);
const unitMs =
match[2].toLowerCase() === "day"
? 86_400_000
: match[2].toLowerCase() === "hour"
? 3_600_000
: 60_000;
return amount * unitMs;
}
function trendBucketMs(bucket: unknown): number | null {
if (typeof bucket === "number") {
return bucket > 1e14 ? Math.round(bucket / 1000) : bucket;
}
if (typeof bucket !== "string" || !bucket) return null;
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(bucket) ? bucket : `${bucket}Z`;
const parsed = Date.parse(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
export function buildTrendSeries(
rows: TrendRow[],
startUs: number,
endUs: number,
interval: string,
): Record<string, number[]> {
const intervalMs = bucketIntervalMs(interval);
const startMs = Math.floor(startUs / 1000 / intervalMs) * intervalMs;
const endMs = Math.floor(endUs / 1000 / intervalMs) * intervalMs;
const bucketCount = Math.max(1, Math.min(1000, Math.floor((endMs - startMs) / intervalMs) + 1));
const countsByConfig: Record<string, Map<number, number>> = {};
for (const row of rows) {
const id = row.score_config_id ? String(row.score_config_id) : "";
const bucketMs = trendBucketMs(row.bucket);
if (!id || bucketMs == null) continue;
const alignedBucket = Math.floor(bucketMs / intervalMs) * intervalMs;
if (!countsByConfig[id]) countsByConfig[id] = new Map();
countsByConfig[id].set(
alignedBucket,
(countsByConfig[id].get(alignedBucket) ?? 0) + (toNumber(row.c) ?? 0),
);
}
const seriesByConfig: Record<string, number[]> = {};
for (const [id, counts] of Object.entries(countsByConfig)) {
seriesByConfig[id] = Array.from({ length: bucketCount }, (_, index) => {
const bucketMs = startMs + index * intervalMs;
return counts.get(bucketMs) ?? 0;
});
}
return seriesByConfig;
}
function toNumber(v: unknown): number | null {
if (v == null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
/** The ID stored on score records under `score_config_id` is the Score
* Config's stable `entity_id` (the per-row version is tracked in a separate
* `score_config_version` column on `_llm_scores`). Joining on `entity_id`
* survives every version bump and matches the cross-version identifier the
* rest of the UI keys on. */
function joinId(config: ScoreConfig): string {
return entityId(config);
}
export function buildQualityConfigAggSql(
configs: ScoreConfig[],
agentWhere: string | null,
): string {
const where = combineWhere("score_config_id IS NOT NULL", agentWhere);
const unhealthyCase = buildUnhealthyCaseBranches(configs, "1");
return [
"SELECT",
" CAST(score_config_id AS VARCHAR) AS score_config_id,",
" COUNT(*) AS total_scores,",
" COUNT(CASE WHEN _target_scope = 'span' THEN 1 END) AS span_count,",
" COUNT(CASE WHEN _target_scope = 'trace' THEN 1 END) AS trace_count,",
" COUNT(CASE WHEN _target_scope = 'session' THEN 1 END) AS session_count,",
" AVG(value_numeric) AS avg_numeric,",
" COUNT(value_numeric) AS numeric_values,",
` ${unhealthyCase ? `COUNT(${unhealthyCase})` : "CAST(NULL AS INTEGER)"} AS unhealthy_scores,`,
" COUNT(CASE WHEN value_boolean = true THEN 1 END) AS true_scores,",
" COUNT(CASE WHEN value_boolean = false THEN 1 END) AS false_scores,",
" MAX(_timestamp) AS last_updated_us",
`FROM ${latestScoresFromSql(where)}`,
"GROUP BY score_config_id",
].join("\n");
}
function buildCategorySql(agentWhere: string | null): string {
const where = combineWhere("score_config_id IS NOT NULL", agentWhere);
return [
"SELECT",
" CAST(score_config_id AS VARCHAR) AS score_config_id,",
" value_categorical,",
" COUNT(*) AS c",
`FROM ${latestScoresFromSql(where)}`,
"WHERE value_categorical IS NOT NULL",
"GROUP BY score_config_id, value_categorical",
"ORDER BY score_config_id, c DESC, value_categorical",
].join("\n");
}
function buildTrendSql(interval: string, agentWhere: string | null): string {
const where = combineWhere("score_config_id IS NOT NULL", agentWhere);
return [
"SELECT",
" CAST(score_config_id AS VARCHAR) AS score_config_id,",
` histogram(_timestamp, '${interval}') AS bucket,`,
" COUNT(*) AS c",
`FROM ${latestScoreAttemptsFromSql(where)}`,
"GROUP BY score_config_id, bucket",
"ORDER BY bucket",
].join("\n");
}
export function configHealthStatus(
totalScores: number,
unhealthyScores: number | null,
hasThreshold: boolean,
): {
status: ConfigStatus;
priority: number;
} {
if (totalScores === 0) return { status: "noData", priority: 3 };
if (!hasThreshold) return { status: "noThreshold", priority: 2 };
if ((unhealthyScores ?? 0) > 0) {
return { status: "unhealthy", priority: 0 };
}
return { status: "healthy", priority: 1 };
}
export function useQualityScoreConfigs(
scoreConfigs: Ref<ScoreConfig[]>,
dateWindow: Ref<DateWindow>,
t: TranslateFn,
agentFilter?: Ref<AgentFilterSelection | null | undefined>,
) {
const { executeQuery, executeQueryOnce } = useLLMStreamQuery();
const isLoading = ref(false);
const aggByConfig = ref<Record<string, AggRow>>({});
const trendByConfig = ref<Record<string, number[]>>({});
const categoriesByConfig = ref<Record<string, CategoryRow[]>>({});
async function refresh() {
if (scoreConfigs.value.length === 0) {
aggByConfig.value = {};
trendByConfig.value = {};
categoriesByConfig.value = {};
return;
}
isLoading.value = true;
try {
const { startUs, endUs } = dateWindow.value;
const interval = chooseBucketInterval((endUs - startUs) / 1000);
const agentWhere = buildScoresAgentFilterWhere(agentFilter?.value ?? null);
const aggSql = buildQualityConfigAggSql(scoreConfigs.value, agentWhere);
const trendSql = buildTrendSql(interval, agentWhere);
const categorySql = buildCategorySql(agentWhere);
// `runQuery` swallows failures so one bad query doesn't blank the page.
const runQuery = async <T>(
sqlText: string,
label: string,
fullRange = false,
): Promise<T[] | null> => {
try {
const hits = await (fullRange ? executeQueryOnce : executeQuery)(
sqlText,
startUs,
endUs,
"logs",
);
console.debug(`[Quality:${label}]`, { hitCount: hits.length });
return hits as T[];
} catch (err: any) {
console.warn(`[Quality:${label}] query failed:`, err?.response?.data ?? err);
return null;
}
};
const [aggHits, trendHits, categoryHits] = await Promise.all([
runQuery<AggRow>(aggSql, "configs.agg").then((rows) => rows ?? []),
runQuery<TrendRow>(trendSql, "configs.trend", true).then((rows) => rows ?? []),
runQuery<CategoryRow>(categorySql, "configs.categories").then((rows) => rows ?? []),
]);
const byId: Record<string, AggRow> = {};
for (const hit of aggHits) {
if (hit.score_config_id) {
byId[String(hit.score_config_id)] = hit;
}
}
aggByConfig.value = byId;
// Surface ID-matching diagnostics. If any local config doesn't appear
// here as "matched", its row will say 0 in the table.
const localIds = scoreConfigs.value.map((c) => ({
name: c.name,
joinId: joinId(c),
}));
const hitIds = Object.keys(byId);
const matched = localIds.filter((l) => byId[l.joinId]);
const unmatchedLocal = localIds.filter((l) => !byId[l.joinId]);
const unmatchedHits = hitIds.filter((h) => !localIds.find((l) => l.joinId === h));
console.debug("[Quality:configs.match]", {
localIds,
hitIds,
matched,
unmatchedLocal,
unmatchedHits,
});
trendByConfig.value = buildTrendSeries(trendHits, startUs, endUs, interval);
const categoryById: Record<string, CategoryRow[]> = {};
for (const row of categoryHits) {
const id = row.score_config_id ? String(row.score_config_id) : "";
if (!id || row.value_categorical == null) continue;
if (!categoryById[id]) categoryById[id] = [];
categoryById[id].push(row);
}
categoriesByConfig.value = categoryById;
} finally {
isLoading.value = false;
}
}
const rows = computed<ScoreConfigRow[]>(() => {
const out: ScoreConfigRow[] = scoreConfigs.value.map((config) => {
const configId = entityId(config); // stable cross-version id for the row key
const lookup = joinId(config); // entity_id — matches score_config_id on `_llm_scores`
const agg = aggByConfig.value[lookup];
const total = toNumber(agg?.total_scores) ?? 0;
const lastUpdatedUs = toNumber(agg?.last_updated_us);
const dataType = (dataTypeOf(config) as ScoreConfigRow["dataType"]) || "unknown";
const threshold = thresholdForConfig(config);
const hasThreshold = threshold.unhealthyExpr != null;
const unhealthyCount = hasThreshold ? (toNumber(agg?.unhealthy_scores) ?? 0) : null;
const unhealthyPercent =
total > 0 && unhealthyCount != null ? (unhealthyCount / total) * 100 : null;
const { status, priority } = configHealthStatus(total, unhealthyCount, hasThreshold);
const summary = qualitySummaryForConfig(
config,
{
avgNumeric: toNumber(agg?.avg_numeric),
numericValues: toNumber(agg?.numeric_values) ?? 0,
numericUnhealthy: toNumber(agg?.unhealthy_scores),
trueScores: toNumber(agg?.true_scores) ?? 0,
falseScores: toNumber(agg?.false_scores) ?? 0,
},
(categoriesByConfig.value[lookup] ?? []).map((row) => ({
category: String(row.value_categorical),
count: toNumber(row.c) ?? 0,
})),
t,
);
return {
config,
configId,
name: config.name,
description: raw(config.description),
dataType,
totalScores: total,
scopeCounts: scopeCountsFromRow(agg),
...summary,
hasThreshold,
thresholdLabel: threshold.label,
unhealthyCount,
unhealthyPercent,
lastUpdatedMs: lastUpdatedUs != null ? Math.round(lastUpdatedUs / 1000) : null,
status,
statusPriority: priority,
trendSparkline: trendByConfig.value[lookup] ?? [],
};
});
out.sort((a, b) => {
if (a.statusPriority !== b.statusPriority) return a.statusPriority - b.statusPriority;
if (
a.status === "unhealthy" &&
b.status === "unhealthy" &&
a.unhealthyPercent !== b.unhealthyPercent
) {
return (b.unhealthyPercent ?? 0) - (a.unhealthyPercent ?? 0);
}
if (a.totalScores !== b.totalScores) return b.totalScores - a.totalScores;
return a.name.localeCompare(b.name);
});
return out;
});
return { rows, isLoading, refresh };
}
@@ -0,0 +1,83 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, type Ref } from "vue";
import { useQuery } from "@tanstack/vue-query";
import type { QualityScore } from "@/services/online-evals.service";
import { qualitySpansQuery } from "@/services/online-evals.service.queries";
import { extractLLMData } from "@/utils/llmUtils";
// The judge runs after its target ends, so its spans sit before the score write; the margin covers clocks running behind.
const WINDOW_MARGIN_US = 60_000_000;
function asText(value: unknown): string {
if (value == null) return "";
return typeof value === "string" ? value : JSON.stringify(value);
}
/** The full input and output a score was given, read in one query from the spans its scorer recorded. */
export function useScoreFullText(score: () => QualityScore | null, orgId: Ref<string>) {
const spanIds = computed(() => {
const target = score();
return [...new Set([target?.inputSpanId, target?.outputSpanId])].filter(
(id): id is string => !!id,
);
});
const enabled = computed(
() => !!orgId.value && !!score()?.sourceStream && spanIds.value.length > 0,
);
const spansQuery = useQuery(() => {
const target = score();
return Object.assign(
qualitySpansQuery(
orgId.value,
target?.sourceStreamType || "traces",
target?.sourceStream ?? "",
spanIds.value,
(target?.refTimestamp ?? 0) - WINDOW_MARGIN_US,
target?.timestamp ?? 0,
),
{ enabled: enabled.value },
);
});
const sideText = (spanId: string | null | undefined, side: "input" | "output") => {
const span = spanId ? spansQuery.data.value?.find((row) => row.span_id === spanId) : undefined;
return span ? asText(extractLLMData(span)?.[side]) : "";
};
const fullText = computed(() => {
const target = score();
return {
input: sideText(target?.inputSpanId, "input"),
output: sideText(target?.outputSpanId, "output"),
};
});
const loading = computed(() => enabled.value && spansQuery.isPending.value);
// A side whose full text could not be read shows its short preview instead.
const unavailable = computed(() => {
const target = score();
if (!target || loading.value) return false;
return (
(!fullText.value.input && !!target.inputPreview) ||
(!fullText.value.output && !!target.outputPreview)
);
});
return { fullText, loading, unavailable };
}
@@ -1,86 +0,0 @@
<template>
<div ref="chartEl" class="h-full min-h-30 w-full" data-test="quality-boolean-bars-chart" />
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useStore } from "vuex";
import * as echarts from "echarts";
import { chartColor } from "@/utils/chartTheme";
import { withChartFont } from "@/utils/fonts";
const props = defineProps<{
trueCount: number;
falseCount: number;
legendTrue: string;
legendFalse: string;
}>();
const chartEl = ref<HTMLElement | null>(null);
let chart: echarts.ECharts | null = null;
const store = useStore();
function buildOption(): echarts.EChartsOption {
const text = chartColor("--color-text-secondary");
const grid = chartColor("--color-border-subtle");
return {
grid: { left: 80, right: 32, top: 16, bottom: 16 },
tooltip: { trigger: "axis", confine: true },
xAxis: {
type: "value",
axisLine: { show: false },
axisLabel: { color: text, fontSize: 10 },
splitLine: { lineStyle: { color: grid } },
},
yAxis: {
type: "category",
data: [props.legendFalse, props.legendTrue],
axisLine: { lineStyle: { color: grid } },
axisLabel: { color: text, fontSize: 11 },
splitLine: { show: false },
},
series: [
{
type: "bar",
barWidth: 16,
data: [
{ value: props.falseCount, itemStyle: { color: "rgba(178, 84, 0, 0.85)" } },
{ value: props.trueCount, itemStyle: { color: "rgba(46, 125, 50, 0.85)" } },
],
label: {
show: true,
position: "right",
color: text,
fontSize: 10,
},
},
],
};
}
function render() {
if (!chart) return;
chart.setOption(withChartFont(buildOption()), true);
}
onMounted(() => {
if (!chartEl.value) return;
chart = echarts.init(chartEl.value, undefined, { renderer: "canvas" });
render();
});
watch(
() => [props.trueCount, props.falseCount, store.state.theme],
() => render(),
);
const resizeObserver = new ResizeObserver(() => chart?.resize());
onMounted(() => {
if (chartEl.value) resizeObserver.observe(chartEl.value);
});
onBeforeUnmount(() => {
resizeObserver.disconnect();
chart?.dispose();
chart = null;
});
</script>
@@ -1,123 +0,0 @@
<template>
<div ref="chartEl" class="h-full min-h-55 w-full" data-test="quality-boolean-trend-chart" />
</template>
<script setup lang="ts">
import { raw } from "@/types/i18n";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useStore } from "vuex";
import * as echarts from "echarts";
import { chartColor } from "@/utils/chartTheme";
import { getColorPalette } from "@/utils/dashboard/colorPalette";
import type { BooleanTrendPoint, BooleanTrendSeries } from "../composables/useQualityDetailCharts";
import { withChartFont } from "@/utils/fonts";
const props = defineProps<{
series?: BooleanTrendSeries[];
/** Fallback flat-array shape — used when caller doesn't yet pass series. */
points?: BooleanTrendPoint[];
legendPassRate: string;
}>();
const chartEl = ref<HTMLElement | null>(null);
let chart: echarts.ECharts | null = null;
const store = useStore();
function effectiveSeries(): BooleanTrendSeries[] {
if (props.series && props.series.length > 0) return props.series;
if (props.points && props.points.length > 0) {
return [{ id: "default", label: raw(props.legendPassRate), points: props.points }];
}
return [];
}
function buildOption(): echarts.EChartsOption {
const text = chartColor("--color-text-secondary");
const grid = chartColor("--color-border-subtle");
const seriesList = effectiveSeries();
const isSplit =
seriesList.length > 1 || seriesList.some((s) => s.id !== "__default__" && s.id !== "default");
// Split series cycle the shared chart-series palette (same categorical hues as
// every other multi-series chart in the app), in fixed order.
const palette = getColorPalette(store.state.theme);
const echSeries: echarts.SeriesOption[] = seriesList.map((s, idx) => {
const color = palette[idx % palette.length];
const label = s.id === "__default__" ? props.legendPassRate : s.label;
return {
name: label,
type: "line",
data: s.points.map((p) => [p.t, Number(p.passRate.toFixed(2))]),
smooth: true,
symbol: "circle",
symbolSize: 5,
lineStyle: { color, width: 2 },
itemStyle: { color },
areaStyle: isSplit
? undefined
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "rgba(46, 125, 50, 0.30)" },
{ offset: 1, color: "rgba(46, 125, 50, 0.02)" },
]),
},
};
});
return {
grid: { left: 44, right: 16, top: 28, bottom: 28 },
tooltip: {
trigger: "axis",
confine: true,
valueFormatter: (val) => (typeof val === "number" ? `${val.toFixed(1)}%` : String(val)),
},
legend: {
right: 0,
top: 0,
itemWidth: 12,
itemHeight: 8,
textStyle: { color: text, fontSize: 11 },
},
xAxis: {
type: "time",
axisLine: { lineStyle: { color: grid } },
axisLabel: { color: text, fontSize: 10 },
splitLine: { show: false },
},
yAxis: {
type: "value",
axisLine: { show: false },
axisLabel: { color: text, fontSize: 10, formatter: "{value}%" },
splitLine: { lineStyle: { color: grid } },
},
series: echSeries,
};
}
function render() {
if (!chart) return;
chart.setOption(withChartFont(buildOption()), true);
}
onMounted(() => {
if (!chartEl.value) return;
chart = echarts.init(chartEl.value, undefined, { renderer: "canvas" });
render();
});
watch(
() => [props.series, props.points, store.state.theme],
() => render(),
{ deep: true },
);
const resizeObserver = new ResizeObserver(() => chart?.resize());
onMounted(() => {
if (chartEl.value) resizeObserver.observe(chartEl.value);
});
onBeforeUnmount(() => {
resizeObserver.disconnect();
chart?.dispose();
chart = null;
});
</script>
@@ -1,106 +0,0 @@
<template>
<div ref="chartEl" class="h-full min-h-45 w-full" data-test="quality-category-bars-chart" />
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useStore } from "vuex";
import * as echarts from "echarts";
import { chartColor } from "@/utils/chartTheme";
import { withChartFont } from "@/utils/fonts";
interface CategoryRow {
value_categorical?: string | null;
c?: number | string;
}
const props = defineProps<{
rows: CategoryRow[];
healthyCategories: string[];
}>();
const chartEl = ref<HTMLElement | null>(null);
let chart: echarts.ECharts | null = null;
const store = useStore();
function toNumber(v: unknown): number {
if (v == null) return 0;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
function buildOption(): echarts.EChartsOption {
const text = chartColor("--color-text-secondary");
const grid = chartColor("--color-border-subtle");
// Sort by count DESC (per spec §7.6.6 categorical secondary chart).
const sorted = [...props.rows].sort((a, b) => toNumber(b.c) - toNumber(a.c));
const labels = sorted.map((r) => r.value_categorical ?? "(null)");
const data = sorted.map((r) => {
const healthy =
r.value_categorical != null && props.healthyCategories.includes(r.value_categorical);
return {
value: toNumber(r.c),
itemStyle: { color: healthy ? "rgba(46, 125, 50, 0.85)" : "rgba(178, 84, 0, 0.85)" },
};
});
return {
grid: { left: 110, right: 32, top: 16, bottom: 16 },
tooltip: { trigger: "axis", confine: true },
xAxis: {
type: "value",
axisLine: { show: false },
axisLabel: { color: text, fontSize: 10 },
splitLine: { lineStyle: { color: grid } },
},
yAxis: {
type: "category",
data: labels,
inverse: true,
axisLine: { lineStyle: { color: grid } },
axisLabel: { color: text, fontSize: 11 },
splitLine: { show: false },
},
series: [
{
type: "bar",
barWidth: 14,
data,
label: {
show: true,
position: "right",
color: text,
fontSize: 10,
},
},
],
};
}
function render() {
if (!chart) return;
chart.setOption(withChartFont(buildOption()), true);
}
onMounted(() => {
if (!chartEl.value) return;
chart = echarts.init(chartEl.value, undefined, { renderer: "canvas" });
render();
});
watch(
() => [props.rows, props.healthyCategories, store.state.theme],
() => render(),
{ deep: true },
);
const resizeObserver = new ResizeObserver(() => chart?.resize());
onMounted(() => {
if (chartEl.value) resizeObserver.observe(chartEl.value);
});
onBeforeUnmount(() => {
resizeObserver.disconnect();
chart?.dispose();
chart = null;
});
</script>
@@ -0,0 +1,511 @@
// @vitest-environment jsdom
import { defineComponent } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import store from "@/test/unit/helpers/store";
import { withQueryClient } from "@/test/unit/helpers/queryClient";
import type {
QualityScorePage,
QualityTimelineBucket,
ScoreConfig,
} from "@/services/online-evals.service";
import type { QualityChartFilter, QualityRow } from "../utils/qualityFormat";
const { scores, timeline } = vi.hoisted(() => ({ scores: vi.fn(), timeline: vi.fn() }));
vi.mock("@/services/online-evals.service", async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
default: { ...actual.default, quality: { list: vi.fn(), scores, timeline } },
};
});
vi.mock("vue-router", () => ({
useRoute: () => ({ name: "aiEvaluations", query: {} }),
useRouter: () => ({ push: vi.fn(), resolve: () => ({ href: "/" }) }),
}));
import QualityConfigDetail from "./QualityConfigDetail.vue";
// Emits a copy of the row, so the detail cannot rely on object identity.
const OTableStub = defineComponent({
name: "OTable",
props: {
data: { type: Array, default: () => [] },
pageSize: Number,
pageSizeOptions: Array,
},
emits: ["row-click"],
template: `<div data-test="score-list"><div v-for="row in data" :key="row.id" :data-test="'score-' + row.id" @click="$emit('row-click', { ...row })"><slot name="cell-score" :row="row" /></div></div>`,
});
const PaneStub = defineComponent({
name: "QualityScorePane",
props: { score: Object, thresholdLabel: String, loading: Boolean },
template: `<div data-test="pane-stub" :data-loading="String(!!loading)">{{ score?.id }}</div>`,
});
const score = (id: string) => ({
id,
timestamp: 1,
refTimestamp: 1,
sourceType: "llm_judge",
targetScope: "span",
targetId: id,
spanId: id,
traceId: "t",
sessionId: null,
sourceStream: "default",
sourceStreamType: "traces",
value: "medium",
unhealthy: true,
reasoning: null,
evaluatorTraceId: null,
taskId: null,
inputPreview: null,
outputPreview: null,
inputSpanId: id,
outputSpanId: id,
});
function row(overrides: Partial<QualityRow> = {}): QualityRow {
return {
configId: "tox",
name: "toxicity",
dataType: "categorical",
status: "attention",
total: 114,
unhealthy: 67,
average: null,
lastScoredAt: 1,
scopeCounts: { span: 77, trace: 37, session: 0 },
topValue: { key: "medium", count: 67 },
description: "Toxicity level of the response.",
version: 1,
thresholdLabel: "low",
range: { min: 0, max: 1 },
...overrides,
};
}
const TIMELINE: QualityTimelineBucket[] = [
{
start: 0,
end: 7_200_000_000,
count: 100,
unhealthy: 60,
trueCount: null,
average: null,
p95: null,
},
{
start: 7_200_000_000,
end: 14_400_000_000,
count: 14,
unhealthy: 7,
trueCount: null,
average: null,
p95: null,
},
];
const PAGE: QualityScorePage = {
average: null,
distribution: [
{ key: "low", count: 47, unhealthy: 0 },
{ key: "medium", count: 67, unhealthy: 67 },
],
list: [],
total: 114,
from: 0,
size: 10,
};
function mountDetail(
overrides: Partial<QualityRow> = {},
extra: {
row?: QualityRow | null;
config?: ScoreConfig | null;
initialPage?: number;
initialScoreId?: string | null;
initialFilter?: QualityChartFilter | null;
realTable?: boolean;
} = {},
) {
const current = row(overrides);
return mount(QualityConfigDetail, {
props: {
configId: current.configId,
row: extra.row === undefined ? current : extra.row,
config: extra.config ?? null,
scope: "all",
only: "all",
dateWindow: { startUs: 1, endUs: 2 },
agentParams: {},
enabled: true,
initialPage: extra.initialPage ?? 0,
initialScoreId: extra.initialScoreId ?? null,
initialFilter: extra.initialFilter ?? null,
},
global: {
plugins: [store, withQueryClient()],
stubs: {
...(extra.realTable ? {} : { OTable: OTableStub }),
QualityDistributionChart: true,
QualityTrendChart: true,
QualityScorePane: PaneStub,
},
},
});
}
describe("QualityConfigDetail", () => {
beforeEach(() => {
scores.mockReset().mockResolvedValue(PAGE);
timeline.mockReset().mockResolvedValue({ timeline: TIMELINE });
});
afterEach(() => {
document.body.innerHTML = "";
});
it("has no stat band: the toggle reads counts only, and the chart and review get the room", async () => {
const wrapper = mountDetail();
await flushPromises();
expect(wrapper.find('[data-test="quality-detail-summary"]').exists()).toBe(false);
expect(wrapper.find('[data-test^="quality-detail-stat-"]').exists()).toBe(false);
const unhealthy = wrapper.find('[data-test="quality-detail-only-unhealthy"]');
expect(unhealthy.text().replace(/\s+/g, " ")).toBe("Unhealthy 67");
expect(unhealthy.text()).not.toContain("%");
expect(wrapper.find('[data-test="quality-detail-only-all"]').text().replace(/\s+/g, " ")).toBe(
"All 114",
);
expect(wrapper.find('[data-test="quality-detail-distribution"] .h-55').exists()).toBe(true);
expect(wrapper.find('[data-test="quality-detail-review"]').classes()).toEqual(
expect.arrayContaining(["flex-1", "min-h-0"]),
);
});
it("shows the distribution and the trend as two equal bordered cards, stacked below lg", async () => {
const wrapper = mountDetail();
await flushPromises();
const distribution = wrapper.find('[data-test="quality-detail-distribution"]');
const trend = wrapper.find('[data-test="quality-detail-trend"]');
expect(distribution.element.parentElement!.className).toContain("grid-cols-2");
expect(distribution.element.parentElement!.className).toContain("max-lg:grid-cols-1");
for (const card of [distribution, trend]) {
expect(card.classes()).toEqual(expect.arrayContaining(["border", "border-border-default"]));
}
expect(trend.text()).toContain("Healthy rate over time");
expect(trend.find(".h-55").exists()).toBe(true);
});
it("shows a skeleton in the trend card while the trend loads, then the chart", async () => {
let resolve: (value: { timeline: QualityTimelineBucket[] }) => void = () => {};
timeline.mockReturnValue(new Promise((done) => (resolve = done)));
const wrapper = mountDetail();
await flushPromises();
const trend = () => wrapper.find('[data-test="quality-detail-trend"]');
expect(trend().find('[data-test="quality-detail-trend-loading"]').exists()).toBe(true);
expect(trend().findComponent({ name: "QualityTrendChart" }).exists()).toBe(false);
resolve({ timeline: TIMELINE });
await flushPromises();
expect(trend().find('[data-test="quality-detail-trend-loading"]').exists()).toBe(false);
expect(trend().findComponent({ name: "QualityTrendChart" }).props("timeline")).toEqual(
TIMELINE,
);
});
it("keeps the trend without a refetch or a skeleton while the score list pages", async () => {
const wrapper = mountDetail();
await flushPromises();
scores.mockImplementationOnce(() => new Promise(() => {}));
wrapper.findComponent({ name: "OTable" }).vm.$emit("update:current-page", 2);
await flushPromises();
expect(scores).toHaveBeenCalledTimes(2);
expect(timeline).toHaveBeenCalledTimes(1);
const trend = wrapper.find('[data-test="quality-detail-trend"]');
expect(trend.find('[data-test="quality-detail-trend-loading"]').exists()).toBe(false);
expect(trend.findComponent({ name: "QualityTrendChart" }).props("timeline")).toEqual(TIMELINE);
});
it("shows a load error with a retry in the trend card when only the trend fails", async () => {
timeline.mockRejectedValueOnce({ response: { status: 500 } });
const wrapper = mountDetail();
await flushPromises();
expect(wrapper.find('[data-test="quality-detail-error"]').exists()).toBe(false);
const error = wrapper.find('[data-test="quality-detail-trend-error"]');
expect(error.exists()).toBe(true);
await error.find("button").trigger("click");
await flushPromises();
expect(timeline).toHaveBeenCalledTimes(2);
expect(wrapper.find('[data-test="quality-detail-trend-error"]').exists()).toBe(false);
});
it("titles the trend by what it plots when the config has no threshold", async () => {
const noThreshold = TIMELINE.map((bucket) => ({ ...bucket, unhealthy: null }));
timeline.mockResolvedValue({ timeline: noThreshold });
const counts = mountDetail();
await flushPromises();
expect(counts.find('[data-test="quality-detail-trend"]').text()).toContain("Scores over time");
const booleans = mountDetail({ dataType: "boolean" });
await flushPromises();
expect(booleans.find('[data-test="quality-detail-trend"]').text()).toContain(
"True rate over time",
);
scores.mockResolvedValue({
...PAGE,
distribution: [{ key: 0, lower: 0, upper: 1, count: 114, unhealthy: null }],
});
const averages = mountDetail({ dataType: "numeric" });
await flushPromises();
expect(averages.find('[data-test="quality-detail-trend"]').text()).toContain("Trend");
});
it("puts the average of a numeric config in the chart title", async () => {
scores.mockResolvedValue({
...PAGE,
average: 0.37,
distribution: [
{ key: 0, lower: 0, upper: 0.5, count: 20, unhealthy: 20 },
{ key: 1, lower: 0.5, upper: 1, count: 26, unhealthy: 0 },
],
});
const wrapper = mountDetail({ dataType: "numeric", thresholdLabel: "≥ 0.5" });
await flushPromises();
const title = wrapper.find('[data-test="quality-detail-distribution"]').text();
expect(title).toContain("Score distribution");
expect(title).toContain("average 0.37");
});
it("keeps the boolean and categorical chart titles without an average", async () => {
const categorical = mountDetail();
await flushPromises();
expect(categorical.find('[data-test="quality-detail-average"]').exists()).toBe(false);
expect(categorical.find('[data-test="quality-detail-distribution"]').text()).toContain(
"Value distribution",
);
scores.mockResolvedValue({
...PAGE,
average: 0.4,
distribution: [
{ key: true, count: 10, unhealthy: 0 },
{ key: false, count: 4, unhealthy: 4 },
],
});
const boolean = mountDetail({ dataType: "boolean", thresholdLabel: "true" });
await flushPromises();
expect(boolean.find('[data-test="quality-detail-average"]').exists()).toBe(false);
});
it("disables Unhealthy with a reason when there is no threshold", async () => {
scores.mockResolvedValue({
...PAGE,
distribution: [
{ key: "neutral", count: 16, unhealthy: null },
{ key: "positive", count: 14, unhealthy: null },
],
});
const wrapper = mountDetail({ status: "unset", unhealthy: null, thresholdLabel: "" });
await flushPromises();
const unhealthy = wrapper.find('[data-test="quality-detail-only-unhealthy"]');
expect(unhealthy.attributes("disabled")).toBeDefined();
expect(unhealthy.text().replace(/\s+/g, " ")).toBe("Unhealthy 0");
const item = wrapper
.findAllComponents({ name: "OToggleGroupItem" })
.find((found) => found.props("value") === "unhealthy");
expect(item?.props("tooltip")).toBe("Set a healthy threshold to classify scores");
});
it("pages the score list ten at a time with no page-size select", async () => {
const wrapper = mountDetail();
await flushPromises();
const table = wrapper.findComponent({ name: "OTable" });
expect(table.props("pageSize")).toBe(10);
expect(table.props("pageSizeOptions")).toEqual([]);
// The real table, unchanged, renders no select for an empty option list.
scores.mockResolvedValue({
...PAGE,
list: Array.from({ length: 10 }, (_, index) => score(`s${index}`)),
});
const real = mountDetail({}, { realTable: true });
await flushPromises();
const bar = real.find('[data-test="o2-table-pagination-bottom"]');
expect(bar.find('[data-test="o2-table-page-size-select"]').exists()).toBe(false);
expect(bar.find('[data-test="o2-table-pagination-info"]').text()).toMatch(/1 - 10 .* 114/);
expect(bar.find('[data-test="o2-table-next-page-btn"]').exists()).toBe(true);
});
it("puts the pane in its loading state on the first load, a page change and a scope change", async () => {
const pane = (wrapper: ReturnType<typeof mountDetail>) =>
wrapper.find('[data-test="pane-stub"]');
let resolveFirst: (value: QualityScorePage) => void = () => {};
scores.mockImplementationOnce(() => new Promise((done) => (resolveFirst = done)));
const wrapper = mountDetail();
await flushPromises();
const firstLoad = wrapper.find('[data-test="quality-detail-loading"]');
expect(firstLoad.find('[data-test="pane-stub"]').attributes("data-loading")).toBe("true");
resolveFirst({ ...PAGE, list: [score("s1"), score("s2")] });
await flushPromises();
expect(wrapper.find('[data-test="quality-detail-loading"]').exists()).toBe(false);
expect(pane(wrapper).attributes("data-loading")).toBe("false");
expect(pane(wrapper).text()).toBe("s1");
scores.mockImplementationOnce(() => new Promise(() => {}));
wrapper.findComponent({ name: "OTable" }).vm.$emit("update:current-page", 2);
await flushPromises();
expect(pane(wrapper).attributes("data-loading")).toBe("true");
expect(pane(wrapper).text()).toBe("");
scores.mockImplementationOnce(() => new Promise(() => {}));
await wrapper.setProps({ scope: "trace" });
await flushPromises();
expect(pane(wrapper).attributes("data-loading")).toBe("true");
});
it("replaces the band and the review with an empty state when there are no scores", async () => {
scores.mockResolvedValue({ ...PAGE, distribution: [{ key: "low", count: 0, unhealthy: 0 }] });
const wrapper = mountDetail();
await flushPromises();
expect(wrapper.find('[data-test="quality-detail-empty"]').exists()).toBe(true);
expect(wrapper.find('[data-test="quality-detail-summary"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-detail-review"]').exists()).toBe(false);
});
it("shows no access on a 403 and not found on a 404, both with a way back", async () => {
scores.mockRejectedValue({ response: { status: 403 } });
const denied = mountDetail();
await flushPromises();
expect(denied.find('[data-test="quality-detail-error"]').text()).toContain("access");
expect(denied.text()).toContain("Back to all configs");
scores.mockReset().mockRejectedValue({ response: { status: 404 } });
const missing = mountDetail({ configId: "old-id" });
await flushPromises();
expect(missing.find('[data-test="quality-detail-error"]').text()).toContain(
"Score Config not found",
);
expect(missing.text()).toContain("Back to all configs");
});
it("shows the load error, not the empty state, when the request fails without a response", async () => {
scores.mockRejectedValue(Object.assign(new Error("Network Error"), { code: "ERR_NETWORK" }));
const wrapper = mountDetail();
await flushPromises();
expect(wrapper.find('[data-test="quality-detail-empty"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-detail-error"]').exists()).toBe(true);
});
it("drops a restored chart filter the backend rejects, and loads the scores without it", async () => {
scores.mockImplementation((_org, _id, params) =>
params.bucket_from != null
? Promise.reject({ response: { status: 400 } })
: Promise.resolve(PAGE),
);
const wrapper = mountDetail({}, { initialFilter: { kind: "bucket", from: 0, to: 0 } });
await flushPromises();
await flushPromises();
expect(scores.mock.calls.at(-1)?.[2]).not.toHaveProperty("bucket_from");
expect(wrapper.find('[data-test="quality-detail-error"]').exists()).toBe(false);
expect(wrapper.emitted("position")?.at(-1)?.[0]).toMatchObject({ filter: null });
});
it("takes the type and the threshold from the Score Config when the list has no row", async () => {
const config: ScoreConfig = {
id: "row-1",
entityId: "tox",
name: "toxicity",
version: 3,
dataType: "categorical",
description: "Toxicity level of the response.",
healthyThreshold: { healthy_categories: ["low"] },
};
const wrapper = mountDetail({}, { row: null, config });
await flushPromises();
expect(wrapper.find('[data-test="quality-detail-distribution"]').text()).toContain(
"Value distribution",
);
expect(wrapper.findComponent(PaneStub).props("thresholdLabel")).toBe("low");
});
it("falls back to the distribution for the type when the config is unknown too", async () => {
scores.mockResolvedValue({
...PAGE,
distribution: [{ key: 0, lower: 0, upper: 1, count: 3, unhealthy: 1 }],
});
const wrapper = mountDetail({}, { row: null });
await flushPromises();
expect(wrapper.find('[data-test="quality-detail-distribution"]').text()).toContain(
"Score distribution",
);
});
it("selects the clicked score row by id and shows it in the pane", async () => {
scores.mockResolvedValue({ ...PAGE, list: [score("s1"), score("s2"), score("s3")], total: 3 });
const wrapper = mountDetail();
await flushPromises();
expect(wrapper.find('[data-test="pane-stub"]').text()).toBe("s1");
await wrapper.find('[data-test="score-s3"]').trigger("click");
expect(wrapper.find('[data-test="pane-stub"]').text()).toBe("s3");
});
it("reports the page and the selected score, for the URL", async () => {
scores.mockResolvedValue({ ...PAGE, list: [score("s1"), score("s2"), score("s3")], total: 3 });
const wrapper = mountDetail();
await flushPromises();
expect(wrapper.emitted("position")?.at(-1)).toEqual([{ page: 0, scoreId: "s1", filter: null }]);
await wrapper.find('[data-test="score-s3"]').trigger("click");
expect(wrapper.emitted("position")?.at(-1)).toEqual([{ page: 0, scoreId: "s3", filter: null }]);
});
it("reopens the chart filter with its page and score, so Back lands in the same list", async () => {
scores.mockImplementation((_org, _id, params) =>
Promise.resolve({
...PAGE,
list: [score(`s${params.from}`), score(`s${params.from + 1}`)],
total: 12,
}),
);
const filter: QualityChartFilter = { kind: "value", value: "medium" };
const wrapper = mountDetail(
{},
{ initialPage: 1, initialScoreId: "s11", initialFilter: filter },
);
await flushPromises();
expect(scores.mock.calls[0][2]).toMatchObject({ from: 10, value: "medium" });
expect(wrapper.find('[data-test="pane-stub"]').text()).toBe("s11");
expect(wrapper.emitted("position")?.at(-1)).toEqual([{ page: 1, scoreId: "s11", filter }]);
});
it("reopens the page and the score it is given", async () => {
scores.mockImplementation((_org, _id, params) =>
Promise.resolve({
...PAGE,
list: [score(`s${params.from}`), score(`s${params.from + 1}`)],
total: 12,
}),
);
const wrapper = mountDetail({}, { initialPage: 1, initialScoreId: "s11" });
await flushPromises();
expect(scores.mock.calls[0][2]).toMatchObject({ from: 10 });
expect(wrapper.find('[data-test="pane-stub"]').text()).toBe("s11");
});
it("goes back on Esc, unless an overlay is open", async () => {
const wrapper = mountDetail();
await flushPromises();
const overlay = document.createElement("div");
overlay.setAttribute("role", "dialog");
document.body.appendChild(overlay);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(wrapper.emitted("back")).toBeUndefined();
overlay.remove();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(wrapper.emitted("back")).toHaveLength(1);
wrapper.unmount();
});
});
@@ -0,0 +1,479 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div
class="flex min-h-0 min-w-0 flex-1 flex-col max-lg:overflow-y-auto"
data-test="quality-detail"
>
<!-- Any failure, including one with no HTTP status (server unreachable), so it never reads as "no scores". -->
<div
v-if="scoresQuery.error.value"
class="flex min-h-0 flex-1 items-center justify-center"
data-test="quality-detail-error"
>
<OEmptyState
v-if="errorStatus === 403"
size="hero"
preset="no-access"
:action-label="t('onlineEvals.quality.detail.backToAll')"
@action="emit('back')"
/>
<OEmptyState
v-else-if="errorStatus === 404"
size="hero"
illustration="no-results"
:title="t('onlineEvals.quality.detail.notFound')"
:description="t('onlineEvals.quality.detail.notFoundDescription')"
:action-label="t('onlineEvals.quality.detail.backToAll')"
@action="emit('back')"
/>
<OEmptyState v-else size="hero" preset="load-error" @action="retry" />
</div>
<!-- First load keeps the detail's shape: chart, review toolbar, score list, and the pane with its own skeletons. -->
<div
v-else-if="scoresQuery.isPending.value"
class="flex min-h-0 flex-1 flex-col max-lg:min-h-128"
data-test="quality-detail-loading"
>
<div
class="px-page-edge border-border-default grid shrink-0 grid-cols-2 gap-2.5 border-b py-2 max-lg:grid-cols-1"
>
<OSkeleton v-for="card in 2" :key="card" type="rect" class="h-62 w-full" />
</div>
<div class="px-page-edge border-border-default shrink-0 border-b py-1.5">
<OSkeleton type="text" class="h-7 w-64" />
</div>
<div class="flex min-h-0 flex-1 max-md:flex-col">
<div
class="border-border-default flex w-96 shrink-0 flex-col gap-3 border-e p-3 max-lg:w-80 max-md:w-auto max-md:border-e-0 max-md:border-b"
>
<OSkeleton v-for="row in 5" :key="row" type="text" class="h-8 w-full" />
</div>
<QualityScorePane
:score="null"
threshold-label=""
:can-prev="false"
:can-next="false"
loading
/>
</div>
</div>
<div
v-else-if="summary.total === 0"
class="flex min-h-0 flex-1 items-center justify-center"
data-test="quality-detail-empty"
>
<OEmptyState
size="hero"
illustration="hourglass"
:title="
scope === 'all'
? t('onlineEvals.quality.detail.emptyTitle')
: t('onlineEvals.quality.detail.emptyTitleScoped', {
scope: t(`onlineEvals.quality.scopeNouns.${scope}`),
})
"
:description="t('onlineEvals.quality.detail.emptyDescription')"
/>
</div>
<template v-else>
<!-- Two equal cards with the LLM Insights chart height; they stack below lg. -->
<div
class="px-page-edge border-border-default grid shrink-0 grid-cols-2 gap-2.5 border-b py-2 max-lg:grid-cols-1"
>
<LLMPanelCard
class="min-w-0"
:title="
isNumeric
? t('onlineEvals.quality.detail.distributionNumeric')
: t('onlineEvals.quality.detail.distributionValues')
"
:subtitle="
isNumeric && average != null
? t('onlineEvals.quality.detail.averageValue', { value: formatScore(average) })
: undefined
"
data-test="quality-detail-distribution"
>
<div class="h-55 w-full">
<QualityDistributionChart
:buckets="distribution"
:filter="filter"
@select="onBarSelect"
/>
</div>
</LLMPanelCard>
<LLMPanelCard
class="min-w-0"
:title="t(`onlineEvals.quality.detail.trendTitle.${trend}`)"
data-test="quality-detail-trend"
>
<div class="h-55 w-full">
<OSkeleton
v-if="timelineQuery.isPending.value"
type="rect"
class="h-full w-full"
data-test="quality-detail-trend-loading"
/>
<OEmptyState
v-else-if="timelineQuery.error.value"
class="h-full"
size="inline"
preset="load-error"
:action-label="t('emptyState.loadError.action')"
action-icon="refresh"
data-test="quality-detail-trend-error"
@action="retryTimeline"
/>
<QualityTrendChart
v-else
:timeline="timeline"
:kind="trend"
:range="numericRange"
:threshold="threshold"
/>
</div>
</LLMPanelCard>
</div>
<div class="flex min-h-0 flex-1 flex-col max-lg:min-h-128" data-test="quality-detail-review">
<div
class="px-page-edge border-border-default flex shrink-0 flex-wrap items-center gap-2 border-b py-1.5"
>
<span class="text-text-heading text-sm font-semibold">{{
t("onlineEvals.quality.review.title")
}}</span>
<OToggleGroup
:model-value="only"
data-test="quality-detail-only"
@update:model-value="(value) => emit('update:only', value as QualityOnly)"
>
<OToggleGroupItem value="all" size="sm" data-test="quality-detail-only-all">
{{ t("onlineEvals.quality.review.all") }}
<span class="font-semibold tabular-nums">{{ formatCount(summary.total) }}</span>
</OToggleGroupItem>
<OToggleGroupItem
value="unhealthy"
size="sm"
:disabled="summary.unhealthy == null"
:tooltip="
summary.unhealthy == null
? t('onlineEvals.quality.review.unhealthyDisabled')
: undefined
"
data-test="quality-detail-only-unhealthy"
>
{{ t("onlineEvals.quality.review.unhealthy") }}
<span class="font-semibold tabular-nums">{{
formatCount(summary.unhealthy ?? 0)
}}</span>
</OToggleGroupItem>
</OToggleGroup>
<OTag
v-if="filter"
variant="primary-soft"
:label="chartFilterLabel(filter, distribution, t)"
removable
:remove-label="t('onlineEvals.quality.detail.clearFilter')"
data-test="quality-detail-filter-chip"
@remove="clearFilter"
/>
</div>
<div class="flex min-h-0 flex-1 max-md:flex-col">
<div
ref="listEl"
class="border-border-default flex min-h-0 w-96 shrink-0 flex-col border-e max-lg:w-80 max-md:h-96 max-md:w-auto max-md:border-e-0 max-md:border-b"
>
<OTable
class="min-h-0 flex-1"
:data="list"
:columns="scoreColumns"
row-key="id"
:show-header="false"
:show-global-filter="false"
:default-columns="false"
pagination="server"
sorting="none"
:total-count="total"
:current-page="page + 1"
:page-size="SCORES_PAGE_SIZE"
:page-size-options="[]"
:loading="scoresQuery.isPending.value"
:row-class="
(score: QualityScore) =>
score.id === selected?.id ? 'bg-table-row-selected-bg' : ''
"
data-test="quality-detail-scores"
@update:current-page="(next: number) => setPage(next - 1)"
@row-click="(score: QualityScore) => select(score.id)"
>
<template #cell-score="{ row: score }">
<div
class="flex min-w-0 items-start gap-2 py-1"
:data-test="`quality-score-row-${score.id}`"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span
class="truncate text-sm"
:class="score.inputPreview ? 'text-text-body' : 'text-text-muted'"
>{{
score.inputPreview || t("onlineEvals.quality.review.inputNotCaptured")
}}</span
>
<span class="text-text-secondary flex items-center gap-1 text-xs">
<OTimeCell :value="score.timestamp" unit="us" mode="absolute" />
<template v-if="multiScope">
<span>·</span>
<span>{{ t(`onlineEvals.quality.scopes.${score.targetScope}`) }}</span>
</template>
</span>
</div>
<OTag
class="shrink-0"
:variant="pillVariant(score)"
:label="raw(formatScoreValue(score.value, dataType))"
/>
</div>
</template>
<template #empty>
<OEmptyState
size="inline"
icon="filter-list"
:title="t('onlineEvals.quality.review.noMatch')"
/>
</template>
</OTable>
</div>
<QualityScorePane
:score="selected"
:threshold-label="thresholdLabel"
:can-prev="canPrev"
:can-next="canNext"
:loading="listLoading"
@prev="prev"
@next="next"
/>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, toRef, watch } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import OTag from "@/lib/core/Badge/OTag.vue";
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
import OTable from "@/lib/core/Table/OTable.vue";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import type {
QualityAgentParams,
QualityDistributionBucket,
QualityScore,
ScoreConfig,
ScoreDataType,
} from "@/services/online-evals.service";
import { SCORES_PAGE_SIZE, useQualityDetail } from "../composables/useQualityDetail";
import type { DateWindow } from "../composables/useQualityList";
import { dataTypeOf } from "../utils/evalEntity";
import { numericThreshold, thresholdForConfig } from "../utils/scoreThreshold";
import {
QUALITY_SCOPES,
chartFilterLabel,
distributionDataType,
formatCount,
formatScore,
formatScoreValue,
nextChartFilter,
trendKind,
type QualityChartFilter,
type QualityOnly,
type QualityRow,
type QualityScope,
} from "../utils/qualityFormat";
import QualityDistributionChart from "./QualityDistributionChart.vue";
import QualityScorePane from "./QualityScorePane.vue";
import QualityTrendChart from "./QualityTrendChart.vue";
import LLMPanelCard from "@/plugins/traces/LLMPanelCard.vue";
const props = defineProps<{
configId: string;
/** The config's list row; null until the list loads or when the list does not have it. */
row: QualityRow | null;
/** The Score Config itself, for the type and threshold when there is no list row. */
config: ScoreConfig | null;
scope: QualityScope;
only: QualityOnly;
dateWindow: DateWindow;
agentParams: QualityAgentParams;
enabled: boolean;
/** Zero-based page to reopen, from the URL. */
initialPage: number;
/** Score to reselect when it is on that page, from the URL. */
initialScoreId: string | null;
/** Chart filter the page and score belong to, from the URL. */
initialFilter?: QualityChartFilter | null;
}>();
const emit = defineEmits<{
(e: "back"): void;
(e: "update:only", only: QualityOnly): void;
(e: "status", status: { updatedAt: number; fetching: boolean }): void;
/** The chart filter, page and selected score, for the URL. */
(
e: "position",
position: { page: number; scoreId: string | null; filter: QualityChartFilter | null },
): void;
}>();
const { t } = useI18nTyped();
const {
scoresQuery,
timelineQuery,
filter,
page,
distribution,
timeline,
summary,
list,
total,
listLoading,
selected,
canPrev,
canNext,
next,
prev,
select,
setPage,
average,
errorStatus,
} = useQualityDetail({
configId: toRef(props, "configId"),
scope: toRef(props, "scope"),
only: toRef(props, "only"),
dateWindow: toRef(props, "dateWindow"),
agentParams: toRef(props, "agentParams"),
enabled: toRef(props, "enabled"),
initial: {
page: props.initialPage,
scoreId: props.initialScoreId,
filter: props.initialFilter ?? null,
},
});
// Without a list row, the Score Config list, then the distribution itself, says what the config is.
const dataType = computed<ScoreDataType>(
() =>
props.row?.dataType ??
(props.config
? (dataTypeOf(props.config) as ScoreDataType)
: distributionDataType(distribution.value)),
);
const isNumeric = computed(() => dataType.value === "numeric");
const thresholdLabel = computed(
() =>
props.row?.thresholdLabel ??
(props.config ? String(thresholdForConfig(props.config).label) : ""),
);
const trend = computed(() => trendKind(timeline.value, dataType.value));
const threshold = computed(() => (props.config ? numericThreshold(props.config) : null));
// The numeric buckets span the config's range, so the average axis matches the distribution.
const numericRange = computed(() => {
const first = distribution.value[0]?.lower;
const last = distribution.value[distribution.value.length - 1]?.upper;
return first != null && last != null ? { min: first, max: last } : null;
});
const multiScope = computed(
() => QUALITY_SCOPES.filter((scope) => (props.row?.scopeCounts[scope] ?? 0) > 0).length > 1,
);
const scoreColumns: OTableColumnDef<QualityScore>[] = [
{ id: "score", header: raw(""), accessorKey: "id", meta: { flex: true } },
];
function pillVariant(score: QualityScore): BadgeVariant {
if (score.unhealthy == null) return "default-soft";
return score.unhealthy ? "error-soft" : "success-soft";
}
function onBarSelect(bucket: QualityDistributionBucket, shift: boolean) {
filter.value = nextChartFilter(filter.value, bucket, shift);
}
function clearFilter() {
filter.value = null;
}
function retryTimeline() {
void timelineQuery.refetch();
}
function retry() {
void scoresQuery.refetch();
if (timelineQuery.error.value) retryTimeline();
}
// Keep the selected row in view as previous and next move through the list.
const listEl = ref<HTMLElement | null>(null);
watch(selected, async (score) => {
if (!score) return;
await nextTick();
listEl.value
?.querySelector(`[data-test="quality-score-row-${score.id}"]`)
?.scrollIntoView?.({ block: "nearest" });
});
// Reported once the page has loaded, so a page still loading never clears the score in the URL.
watch(
() => [page.value, selected.value?.id ?? null, listLoading.value, filter.value] as const,
([currentPage, scoreId, loading, chartFilter]) => {
if (!loading) emit("position", { page: currentPage, scoreId, filter: chartFilter });
},
{ immediate: true },
);
watch(
() =>
[
scoresQuery.dataUpdatedAt.value,
scoresQuery.isFetching.value || timelineQuery.isFetching.value,
] as const,
([updatedAt, fetching]) => emit("status", { updatedAt, fetching }),
{ immediate: true },
);
// Esc goes back to the list, unless a dialog, menu or dropdown is open and takes the key.
useShortcuts([
{
id: "qualityBack",
handler: () => {
if (document.querySelector('[role="dialog"], [role="menu"], [role="listbox"]')) return;
emit("back");
},
},
]);
</script>
@@ -0,0 +1,345 @@
// @vitest-environment jsdom
import { defineComponent } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import store from "@/test/unit/helpers/store";
import type { QualityRow } from "../utils/qualityFormat";
import {
matchesTileFilter,
qualityTiles,
type QualityTileFilter,
} from "../composables/useQualityList";
import QualityConfigsTable from "./QualityConfigsTable.vue";
const { push } = vi.hoisted(() => ({ push: vi.fn(() => Promise.resolve()) }));
vi.mock("vue-router", () => ({
useRoute: () => ({ name: "aiEvaluations", query: { tab: "quality" } }),
useRouter: () => ({ push, resolve: () => ({ href: "/web/ai/evaluations" }) }),
}));
// Renders every cell slot inside a clickable row, like OTable, so a stopped click stays in its cell.
const OTableStub = defineComponent({
name: "OTable",
props: {
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
},
emits: ["row-click"],
template: `
<div>
<slot name="subheader" />
<slot name="toolbar" />
<div
v-for="row in data"
:key="row.configId"
:data-test="'row-' + row.configId"
@click="$emit('row-click', row)"
>
<template v-for="column in columns" :key="column.id">
<slot :name="'cell-' + column.id" :row="row" />
</template>
</div>
<slot v-if="!data.length" name="empty" />
</div>
`,
});
function row(overrides: Partial<QualityRow>): QualityRow {
return {
configId: "id",
name: "name",
dataType: "numeric",
status: "healthy",
total: 10,
unhealthy: 0,
average: 0.5,
lastScoredAt: null,
scopeCounts: { span: 0, trace: 10, session: 0 },
topValue: null,
description: "",
version: 1,
thresholdLabel: "",
range: { min: 0, max: 1 },
...overrides,
};
}
const ROWS: QualityRow[] = [
row({
configId: "ins",
name: "instruction_following",
dataType: "categorical",
status: "attention",
total: 37,
unhealthy: 35,
average: null,
topValue: { key: "medium", count: 18 },
thresholdLabel: "high",
description: "Follows the instructions",
}),
row({ configId: "hal", name: "hallucination", status: "healthy", thresholdLabel: "≤ 0.2" }),
row({ configId: "con", name: "conciseness", status: "unset", unhealthy: null }),
row({
configId: "gro",
name: "groundedness_v2",
status: "no_data",
total: 0,
average: null,
scopeCounts: { span: 0, trace: 0, session: 0 },
}),
];
// The sample list's 16 configs: 11 need attention, 1 healthy, 3 have no threshold, 1 got no scores.
const TILES = qualityTiles([
...Array.from({ length: 11 }, (_, i) => row({ configId: `a${i}`, status: "attention" })),
row({ configId: "h", status: "healthy" }),
...Array.from({ length: 3 }, (_, i) =>
row({ configId: `u${i}`, status: "unset", unhealthy: null }),
),
row({ configId: "n", status: "no_data", total: 0 }),
]);
const ALL_GIVE_A_RESULT = { ...TILES, withoutResult: 0, noScores: 0, noThreshold: 0 };
// The note spaces its parts with a flex gap, so the text has bare "·" separators.
const noteOf = (wrapper: { find: (selector: string) => { text: () => string } }) =>
wrapper
.find('[data-test="quality-tile-without-result-note"]')
.text()
.replace(/\s*·\s*/g, " · ");
function mountTable(props: Partial<InstanceType<typeof QualityConfigsTable>["$props"]> = {}) {
return mount(QualityConfigsTable, {
props: {
rows: ROWS,
tiles: TILES,
tileFilter: null,
loading: false,
forbidden: false,
loadError: false,
...props,
},
global: {
plugins: [store],
stubs: { OTable: OTableStub, OEmptyState: true },
},
});
}
// Wires the tile filter like QualityPage does: the filter comes back as a prop and the rows narrow to it.
function mountWired() {
const wrapper = mountTable({
"onUpdate:tileFilter": (filter: QualityTileFilter | null) =>
wrapper.setProps({
tileFilter: filter,
rows: filter ? ROWS.filter((current) => matchesTileFilter(current, filter)) : ROWS,
}),
});
return wrapper;
}
const rowIds = (wrapper: ReturnType<typeof mountTable>) =>
wrapper.findAll('[data-test^="row-"]').map((found) => found.attributes("data-test"));
describe("QualityConfigsTable", () => {
it("shows the four statuses with the design labels and their rule line", () => {
const text = mountTable().text();
for (const label of ["Needs attention", "Healthy", "Threshold not set", "No score data"]) {
expect(text).toContain(label);
}
expect(text).toContain("Healthy if high");
expect(text).toContain("Healthy if ≤ 0.2");
expect(text).toContain("Set a healthy threshold");
expect(text).toContain("No scores in this window");
});
it("shows the typical score and the unhealthy count before the bold share", () => {
const wrapper = mountTable();
const ins = wrapper.find('[data-test="row-ins"]');
expect(ins.text()).toContain("medium");
expect(ins.text()).toContain("most common · 18 of 37");
const unhealthy = wrapper.find('[data-test="quality-configs-unhealthy-ins"]');
expect(unhealthy.text()).toMatch(/35 out of 37\s*94\.6%/);
expect(wrapper.find('[data-test="row-hal"]').text()).toContain("average, 0 to 1");
// No threshold or no scores: a dash instead of the button.
expect(wrapper.find('[data-test="quality-configs-unhealthy-con"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-configs-unhealthy-gro"]').exists()).toBe(false);
});
it("keeps the Unhealthy bar out of the accessible name, so its minimum sliver is never announced", () => {
const wrapper = mountTable({
rows: [row({ configId: "tiny", status: "attention", total: 5000, unhealthy: 1 })],
});
const cell = wrapper.find('[data-test="quality-configs-unhealthy-tiny"]');
const bar = cell.find('[role="progressbar"]');
expect(bar.attributes("aria-hidden")).toBe("true");
expect(cell.text()).toContain("<0.1%");
});
it("colours the Unhealthy bar green, orange or red by share, and keeps the text and its colours", () => {
const wrapper = mountTable({
rows: [
row({ configId: "low", status: "healthy", total: 10, unhealthy: 2 }),
row({ configId: "mid", status: "attention", total: 10, unhealthy: 5 }),
row({ configId: "high", status: "attention", total: 10, unhealthy: 9 }),
row({ configId: "none", status: "unset", total: 10, unhealthy: null }),
],
});
const fills = {
low: "bg-progress-bar-success",
mid: "bg-progress-bar-warning",
high: "bg-progress-bar-danger",
};
for (const [id, fill] of Object.entries(fills)) {
const cell = wrapper.find(`[data-test="quality-configs-unhealthy-${id}"]`);
expect(cell.find('[role="progressbar"] > div').classes()).toContain(fill);
for (const other of Object.values(fills).filter((value) => value !== fill)) {
expect(cell.find(`.${other}`).exists()).toBe(false);
}
}
const mid = wrapper.find('[data-test="quality-configs-unhealthy-mid"]');
expect(mid.find(".text-text-secondary").text()).toBe("5 out of 10");
expect(mid.find(".text-text-heading.font-semibold").text()).toBe("50.0%");
expect(wrapper.find('[data-test="quality-configs-unhealthy-none"]').exists()).toBe(false);
expect(wrapper.find('[data-test="row-none"]').find('[role="progressbar"]').exists()).toBe(
false,
);
});
it("opens the detail from a row click, and with the unhealthy filter from the Unhealthy cell only", async () => {
const wrapper = mountTable();
await wrapper.find('[data-test="row-hal"]').trigger("click");
expect(wrapper.emitted("open")).toEqual([[ROWS[1], "all"]]);
await wrapper.find('[data-test="quality-configs-unhealthy-ins"]').trigger("click");
expect(wrapper.emitted("open")).toEqual([
[ROWS[1], "all"],
[ROWS[0], "unhealthy"],
]);
});
it("highlights and filters from the attention tile, and clears on a second click, with no chip", async () => {
const wrapper = mountWired();
const tile = wrapper.find('[data-test="quality-tile-attention"]');
expect(tile.text()).toMatch(/11\s*of 16/);
expect(tile.attributes("aria-pressed")).toBe("false");
expect(tile.classes()).toContain("border-border-default");
await tile.trigger("click");
await flushPromises();
expect(wrapper.emitted("update:tileFilter")).toEqual([["attention"]]);
expect(tile.attributes("aria-pressed")).toBe("true");
expect(tile.classes()).toContain("border-accent");
expect(rowIds(wrapper)).toEqual(["row-ins"]);
expect(wrapper.find('[data-test="quality-tile-chip"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-configs-search"]').exists()).toBe(true);
await tile.trigger("click");
await flushPromises();
expect(wrapper.emitted("update:tileFilter")?.at(-1)).toEqual([null]);
expect(tile.attributes("aria-pressed")).toBe("false");
expect(tile.classes()).not.toContain("border-accent");
expect(rowIds(wrapper)).toEqual(["row-ins", "row-hal", "row-con", "row-gro"]);
});
it("counts the configs without a result and names the non-zero reasons", () => {
const tile = mountTable().find('[data-test="quality-tile-without-result"]');
expect(tile.text()).toMatch(/4\s*of 16/);
expect(tile.text()).toContain("Configs without a result");
expect(noteOf(tile)).toBe("1 got no scores · 3 have no threshold");
expect(tile.find(".text-icon-chip-warning-text").exists()).toBe(true);
const onlyThreshold = mountTable({
tiles: { ...TILES, withoutResult: 1, noScores: 0, noThreshold: 1 },
});
expect(noteOf(onlyThreshold)).toBe("1 has no threshold");
});
it("shows no failed-runs line or Eval Jobs link", () => {
const wrapper = mountTable();
expect(wrapper.text()).not.toContain("failed run");
expect(wrapper.text()).not.toContain("Eval Jobs");
});
it("switches from one tile to the other, one highlighted at a time, with no chip", async () => {
const wrapper = mountWired();
const attention = wrapper.find('[data-test="quality-tile-attention"]');
const withoutResult = wrapper.find('[data-test="quality-tile-without-result"]');
await attention.trigger("click");
await flushPromises();
await withoutResult.trigger("click");
await flushPromises();
expect(wrapper.emitted("update:tileFilter")).toEqual([["attention"], ["withoutResult"]]);
expect(withoutResult.attributes("aria-pressed")).toBe("true");
expect(withoutResult.classes()).toContain("border-accent");
expect(attention.attributes("aria-pressed")).toBe("false");
expect(attention.classes()).not.toContain("border-accent");
expect(rowIds(wrapper)).toEqual(["row-con", "row-gro"]);
expect(wrapper.find('[data-test="quality-tile-chip"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain("Without a result 4");
});
it("reads 0 of 16 and says every config gives a result, still clickable like the first tile", async () => {
const wrapper = mountTable({ tiles: ALL_GIVE_A_RESULT });
const tile = wrapper.find('[data-test="quality-tile-without-result"]');
expect(tile.text()).toMatch(/0\s*of 16/);
expect(noteOf(tile)).toBe("Every config gives a result");
expect(tile.find(".text-icon-chip-warning-text").exists()).toBe(false);
expect(tile.element.tagName).toBe("BUTTON");
await tile.trigger("click");
expect(wrapper.emitted("update:tileFilter")).toEqual([["withoutResult"]]);
});
it("opens the Score Config in the same tab", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
push.mockClear();
const wrapper = mountTable();
await wrapper.find('[data-test="quality-configs-config-link-hal"]').trigger("click");
expect(push).toHaveBeenLastCalledWith({
name: "aiEvaluations",
query: { org_identifier: "default", tab: "scoreConfigs", action: "view", id: "hal" },
});
expect(wrapper.emitted("open")).toBeUndefined();
expect(wrapper.emitted("update:tileFilter")).toBeUndefined();
expect(open).not.toHaveBeenCalled();
});
it("hides the tiles when a load failed or there are no configs, but shows them while loading", () => {
const none = {
...TILES,
total: 0,
attention: 0,
withoutResult: 0,
noScores: 0,
noThreshold: 0,
};
for (const props of [{ loadError: true }, { forbidden: true }, {}]) {
const table = mountTable({ rows: [], tiles: none, ...props });
expect(table.find('[data-test="quality-tiles"]').exists()).toBe(false);
expect(table.text()).not.toContain("Every config gives a result");
}
const loading = mountTable({ rows: [], tiles: none, loading: true });
expect(loading.find('[data-test="quality-tiles"]').exists()).toBe(true);
// No rows yet is not "every config gives a result".
expect(loading.find('[data-test="quality-tile-without-result-note"]').exists()).toBe(false);
expect(loading.text()).not.toContain("Every config gives a result");
});
it("offers Show them only when there is something to show and the tile is not already on", () => {
const note = '[data-test="quality-tile-attention-note"]';
expect(mountTable().find(note).text()).toBe("Show them");
expect(mountTable({ tileFilter: "attention" }).find(note).exists()).toBe(false);
expect(
mountTable({ tiles: { ...TILES, attention: 0 } })
.find(note)
.exists(),
).toBe(false);
});
it("matches the search on name and description", async () => {
const wrapper = mountTable();
await wrapper.find('[data-test="quality-configs-search"] input').setValue("instructions");
await flushPromises();
expect(wrapper.find('[data-test="row-ins"]').exists()).toBe(true);
expect(wrapper.find('[data-test="row-hal"]').exists()).toBe(false);
});
});
@@ -0,0 +1,396 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div
class="bg-card-glass-bg flex h-full min-h-0 min-w-0 flex-1 flex-col"
data-test="quality-configs"
>
<OTable
class="min-h-0 flex-1"
data-test="quality-configs-table"
:data="filteredRows"
:columns="columns"
row-key="configId"
:loading="loading"
:forbidden="forbidden"
:show-global-filter="false"
:default-columns="false"
:enable-column-resize="true"
:persist-columns="true"
table-id="quality-score-configs-v3"
sort-by="health"
sort-order="asc"
width="100%"
@row-click="(row: QualityRow) => emit('open', row, 'all')"
>
<template #subheader>
<!-- Counts from an empty list would read "0 of 0 … every config gives a result" next to an error or an empty org. -->
<div
v-if="loading || tiles.total > 0"
class="px-page-edge border-table-row-divider border-b py-1.5"
data-test="quality-tiles"
>
<OStatStrip>
<OTooltip :content="t('onlineEvals.quality.tiles.attentionTooltip')">
<OStatCard
class="grow basis-52 max-lg:shrink-0 max-lg:basis-auto"
:label="t('onlineEvals.quality.tiles.attention')"
:value="loading ? '—' : tiles.attention"
:max="tiles.total"
:tone="tiles.attention > 0 ? 'error' : 'neutral'"
clickable
:selected="tileFilter === 'attention'"
data-test="quality-tile-attention"
@click="toggleTile('attention')"
>
<template #value>
<span data-test="quality-tile-attention-value">{{
loading ? "—" : tiles.attention
}}</span>
<span v-if="!loading" class="text-text-secondary ms-1 text-sm font-normal">{{
t("onlineEvals.quality.tiles.ofTotal", { total: tiles.total })
}}</span>
</template>
<template v-if="tiles.attention > 0 && tileFilter !== 'attention'" #chart>
<span
class="text-text-secondary text-xs"
data-test="quality-tile-attention-note"
>{{ t("onlineEvals.quality.tiles.attentionNote") }}</span
>
</template>
</OStatCard>
</OTooltip>
<OTooltip :content="t('onlineEvals.quality.tiles.withoutResultTooltip')">
<OStatCard
class="grow basis-52 max-lg:shrink-0 max-lg:basis-auto"
:label="t('onlineEvals.quality.tiles.withoutResult')"
:value="loading ? '—' : tiles.withoutResult"
:max="tiles.total"
:tone="tiles.withoutResult > 0 ? 'warning' : 'neutral'"
clickable
:selected="tileFilter === 'withoutResult'"
data-test="quality-tile-without-result"
@click="toggleTile('withoutResult')"
>
<template #value>
<span data-test="quality-tile-without-result-value">{{
loading ? "—" : tiles.withoutResult
}}</span>
<span v-if="!loading" class="text-text-secondary ms-1 text-sm font-normal">{{
t("onlineEvals.quality.tiles.ofTotal", { total: tiles.total })
}}</span>
</template>
<!-- No rows yet would read as the all-clear, so the note waits for the list. -->
<template v-if="!loading" #chart>
<div
class="text-text-secondary flex flex-wrap items-center gap-x-1.5 text-xs"
data-test="quality-tile-without-result-note"
>
<template v-for="(part, index) in withoutResultParts" :key="index">
<span v-if="index">·</span>
<span>{{ part }}</span>
</template>
</div>
</template>
</OStatCard>
</OTooltip>
</OStatStrip>
</div>
</template>
<template #toolbar>
<div class="flex min-w-0 flex-1 items-center gap-2 max-md:contents">
<OSearchInput
v-model="search"
class="min-w-0 flex-1 max-md:min-w-40"
:placeholder="t('onlineEvals.quality.table.searchPlaceholder')"
data-test="quality-configs-search"
/>
</div>
</template>
<template #empty>
<div class="flex items-center justify-center py-8" data-test="quality-configs-empty">
<OEmptyState v-if="loadError" size="hero" preset="load-error" @action="emit('retry')" />
<OEmptyState
v-else
size="hero"
preset="no-score-configs"
:filtered="!!search.trim() || !!tileFilter"
@action="onEmptyAction"
/>
</div>
</template>
<template #cell-name="{ row }">
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 items-center gap-1.5">
<span class="text-text-heading truncate font-semibold">{{ row.name }}</span>
<OTag type="evalDataType" :value="row.dataType" size="xs" />
</div>
<span v-if="row.description" class="text-text-secondary truncate text-xs">{{
row.description
}}</span>
</div>
</template>
<template #cell-health="{ row }">
<div class="flex min-w-0 flex-col items-start gap-0.5" data-test="quality-configs-health">
<OTag type="qualityStatus" :value="row.status" />
<span class="text-text-secondary max-w-full truncate text-xs">{{ healthLine(row) }}</span>
</div>
</template>
<template #cell-typical="{ row }">
<div v-if="typicalScore(row, t)" class="flex min-w-0 flex-col gap-0.5">
<span class="text-text-heading truncate font-semibold">{{
typicalScore(row, t)?.value
}}</span>
<span class="text-text-secondary truncate text-xs">{{ typicalScore(row, t)?.sub }}</span>
</div>
<span v-else class="text-text-muted">—</span>
</template>
<template #cell-unhealthy="{ row }">
<OButton
v-if="unhealthyShare(row) != null"
variant="cell"
:title="t('onlineEvals.quality.table.openUnhealthy')"
:data-test="`quality-configs-unhealthy-${row.configId}`"
@click.stop="emit('open', row, 'unhealthy')"
>
<!-- Decorative: the text beside it states the share, and the minimum sliver would announce a wrong value. -->
<OProgressBar
:value="barShare(unhealthyShare(row))"
:variant="unhealthyTone(unhealthyShare(row)) ?? 'default'"
size="sm"
aria-hidden="true"
/>
<!-- Wraps rather than clips the percent when the counts run to six digits. -->
<span class="flex flex-wrap items-baseline gap-x-1.5 text-xs">
<span class="text-text-secondary">{{
t("onlineEvals.quality.outOf", {
count: formatCount(row.unhealthy ?? 0),
total: formatCount(row.total),
})
}}</span>
<span class="text-text-heading font-semibold">{{
formatPercent(row.unhealthy ?? 0, row.total)
}}</span>
</span>
</OButton>
<span v-else class="text-text-muted">—</span>
</template>
<template #cell-scores="{ row }">
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-text-heading font-semibold tabular-nums">{{
formatCount(row.total)
}}</span>
<span class="text-text-secondary truncate text-xs">{{
scopeMixText(row.scopeCounts, t)
}}</span>
</div>
</template>
<template #cell-updated="{ row }">
<OTimeCell :value="row.lastScoredAt" unit="us" />
</template>
<template #cell-actions="{ row }">
<OButton
variant="ghost-primary"
size="xs"
:data-test="`quality-configs-config-link-${row.configId}`"
@click.stop="openConfig(row)"
>
{{ t("onlineEvals.quality.table.configLink") }}
</OButton>
</template>
</OTable>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { useOrgId } from "@/composables/query/useOrgId";
import OTable from "@/lib/core/Table/OTable.vue";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import { COL, type OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OProgressBar from "@/lib/data/ProgressBar/OProgressBar.vue";
import OStatStrip from "@/lib/data/StatStrip/OStatStrip.vue";
import OStatCard from "@/lib/data/StatStrip/OStatCard.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { QualityTileFilter, qualityTiles } from "../composables/useQualityList";
import {
barShare,
formatCount,
formatPercent,
healthSortValue,
scopeMixText,
scoreConfigLink,
typicalScore,
typicalSortValue,
unhealthyShare,
unhealthyTone,
type QualityOnly,
type QualityRow,
} from "../utils/qualityFormat";
const props = defineProps<{
rows: QualityRow[];
tiles: ReturnType<typeof qualityTiles>;
/** The tile filtering the table, if any; one at a time. */
tileFilter: QualityTileFilter | null;
loading: boolean;
forbidden: boolean;
loadError: boolean;
}>();
const emit = defineEmits<{
(e: "update:tileFilter", value: QualityTileFilter | null): void;
(e: "open", row: QualityRow, only: QualityOnly): void;
(e: "retry"): void;
}>();
const { t } = useI18nTyped();
const route = useRoute();
const router = useRouter();
// Owned by the page, like the tile filter, so it survives opening a config and coming back.
const search = defineModel<string>("search", { default: "" });
const filteredRows = computed(() => {
const query = search.value.trim().toLowerCase();
if (!query) return props.rows;
return props.rows.filter(
(row) =>
row.name.toLowerCase().includes(query) || row.description.toLowerCase().includes(query),
);
});
// As on Alerts, the selected tile carries the filter and its ring: clicking it again clears, the other tile switches.
function toggleTile(filter: QualityTileFilter) {
emit("update:tileFilter", props.tileFilter === filter ? null : filter);
}
/** The non-zero reasons a config gives no result, or the all-clear line. */
const withoutResultParts = computed(() => {
const { noScores, noThreshold } = props.tiles;
const parts = [
noScores && t("onlineEvals.quality.tiles.noScores", { count: noScores }, noScores),
noThreshold && t("onlineEvals.quality.tiles.noThreshold", { count: noThreshold }, noThreshold),
].filter((part): part is I18nText => !!part);
return parts.length ? parts : [t("onlineEvals.quality.tiles.allGiveResult")];
});
const columns = computed<OTableColumnDef<QualityRow>[]>(() =>
[
{
id: "name",
header: t("onlineEvals.quality.table.columns.scoreConfig"),
accessorKey: "name",
sortable: true,
size: COL.name,
minSize: 200,
meta: { align: "left" as const, flex: true },
},
{
id: "health",
header: t("onlineEvals.quality.table.columns.health"),
accessorFn: healthSortValue,
sortable: true,
size: 200,
minSize: 150,
},
{
id: "typical",
header: t("onlineEvals.quality.table.columns.typicalScore"),
accessorFn: typicalSortValue,
sortable: true,
size: 190,
minSize: 140,
},
{
id: "unhealthy",
header: t("onlineEvals.quality.table.columns.unhealthy"),
accessorFn: (row: QualityRow) => unhealthyShare(row) ?? -1,
sortable: true,
size: 170,
minSize: 140,
},
{
id: "scores",
header: t("onlineEvals.quality.table.columns.scores"),
accessorKey: "total",
sortable: true,
size: 170,
minSize: 110,
},
{
id: "updated",
header: t("onlineEvals.quality.table.columns.updated"),
accessorFn: (row: QualityRow) => row.lastScoredAt ?? 0,
sortable: true,
size: 150,
minSize: 120,
},
{ id: "actions", header: raw(""), isAction: true, size: 110 },
].map((column) => ({
...column,
meta: { align: "left" as const, ...(column as any).meta },
hideable: column.id !== "name" && column.id !== "actions",
})),
);
function healthLine(row: QualityRow) {
if (row.status === "no_data") return t("onlineEvals.quality.noScoresInWindow");
if (row.unhealthy == null) return t("onlineEvals.quality.setThreshold");
return row.thresholdLabel
? t("onlineEvals.quality.healthyIf", { rule: row.thresholdLabel })
: raw("");
}
const orgId = useOrgId();
// Same tab: browser Back returns to this view with its URL state.
function openConfig(row: QualityRow) {
router
.push(scoreConfigLink(String(route.name), orgId.value, row.configId, "view"))
.catch(() => {});
}
function onEmptyAction(id?: string) {
if (id === "clear-filters") {
search.value = "";
emit("update:tileFilter", null);
return;
}
if (id !== "create") return;
router
.push({
name: route.name as string,
query: { org_identifier: orgId.value, tab: "scoreConfigs", action: "add" },
})
.catch(() => {});
}
</script>
@@ -1,114 +0,0 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
import { describe, expect, it } from "vitest";
import en from "@/locales/languages/en-US.json";
import type { ScoreConfig } from "@/services/online-evals.service";
import QualityDetailPanel from "./QualityDetailPanel.vue";
function mountPanel(scope: "all" | "span" | "trace" | "session" = "all") {
const i18n = createI18n({
legacy: false,
locale: "en",
messages: { en },
});
return mount(QualityDetailPanel, {
props: {
config: {
id: "config-1",
name: "Coherence",
} as ScoreConfig,
dataType: "numeric",
kpis: [],
hasScores: false,
isLoading: false,
numericTrend: [],
numericDistribution: [],
numericThreshold: null,
numericRange: null,
booleanAgg: null,
booleanTrend: [],
booleanTrendSeries: [],
categoricalRows: [],
scope,
runs: [],
runsCounts: { all: 0, unhealthy: null },
runsFilter: "all",
runsCurrentPage: 1,
runsPageSize: 10,
runsTotalCount: 0,
runsLoading: false,
runsError: null,
},
global: {
plugins: [i18n],
stubs: {
OEmptyState: true,
OSpinner: true,
QualityBooleanTrendChart: true,
QualityRunsTable: true,
},
},
});
}
describe("QualityDetailPanel", () => {
it("shows every scope option and emits a diagnostic scope change", async () => {
const wrapper = mountPanel();
expect(wrapper.find('[data-test="quality-detail-scope-all"]').text()).toBe("All");
expect(wrapper.find('[data-test="quality-detail-scope-span"]').text()).toBe("Span");
expect(wrapper.find('[data-test="quality-detail-scope-trace"]').text()).toBe("Trace");
expect(wrapper.find('[data-test="quality-detail-scope-session"]').text()).toBe("Session");
await wrapper.find('[data-test="quality-detail-scope-trace"]').trigger("click");
expect(wrapper.emitted("update:scope")).toEqual([["trace"]]);
});
it("exposes the selected scope through aria-pressed", () => {
const wrapper = mountPanel("session");
expect(
wrapper.find('[data-test="quality-detail-scope-session"]').attributes("aria-pressed"),
).toBe("true");
expect(wrapper.find('[data-test="quality-detail-scope-all"]').attributes("aria-pressed")).toBe(
"false",
);
expect(wrapper.find("o-empty-state-stub").attributes("title")).toBe(
"No Session scores in this window",
);
});
it("labels boolean trends according to whether healthy_value is configured", async () => {
const wrapper = mountPanel();
const point = { t: 1, passRate: 75, total: 4 };
await wrapper.setProps({
dataType: "boolean",
hasScores: true,
booleanTrend: [point],
booleanTrendSeries: [{ id: "__default__", label: "True rate", points: [point] }],
});
expect(wrapper.text()).toContain("True rate over time");
expect(
wrapper.findComponent({ name: "QualityBooleanTrendChart" }).props("legendPassRate"),
).toBe("True rate");
await wrapper.setProps({
config: {
id: "config-1",
name: "Coherence",
healthy_threshold: { healthy_value: false },
} as ScoreConfig,
});
expect(wrapper.text()).toContain("Healthy rate over time");
expect(
wrapper.findComponent({ name: "QualityBooleanTrendChart" }).props("legendPassRate"),
).toBe("Healthy rate");
});
});
@@ -1,440 +0,0 @@
<template>
<section
class="flex min-h-full flex-col gap-3 overflow-auto px-5 pt-4 pb-6"
data-test="quality-detail-panel"
>
<!-- Title row + type badge + version + description moved up into the
ODrawer header (QualityPage) so the panel content starts straight
from the KPI tiles without a duplicated identification block. -->
<div
class="rounded-default border-border-default bg-surface-base flex flex-wrap items-center justify-between gap-2 border px-3 py-2"
data-test="quality-detail-scope-filter"
>
<div>
<div class="text-2xs text-text-heading font-semibold">
{{ t("onlineEvals.quality.scopes.filterLabel") }}
</div>
<div class="text-3xs text-text-secondary">
{{ t("onlineEvals.quality.scopes.filterHint") }}
</div>
</div>
<OToggleGroup
:model-value="scope"
type="single"
:aria-label="t('onlineEvals.quality.scopes.filterLabel')"
@update:model-value="emit('update:scope', $event as QualityScope)"
>
<OToggleGroupItem
v-for="option in scopeOptions"
:key="option"
:value="option"
size="sm"
:data-test="`quality-detail-scope-${option}`"
>
{{ t(`onlineEvals.quality.scopes.${option}`) }}
</OToggleGroupItem>
</OToggleGroup>
</div>
<!-- Hide the previous scope's values while a new scope is loading. This
keeps the selected chip and the visible data from briefly disagreeing. -->
<div
v-if="isLoading"
class="rounded-default border-border-default text-text-secondary flex flex-col items-center gap-2 border border-dashed px-3 py-7 text-center text-xs"
>
<OSpinner size="sm" />
<span>{{ t("onlineEvals.quality.detail.loading") }}</span>
</div>
<!-- No scores landed for this config in the window: show one focused
empty state (same OEmptyState used across list pages) instead of a
grid of dashed KPI cards + a chart placeholder. -->
<div
v-else-if="!hasScores && runs.length === 0 && !runsLoading"
class="flex min-h-0 flex-1 items-center justify-center"
>
<OEmptyState
size="block"
illustration="hourglass"
:title="emptyTitle"
:description="emptyDescription"
data-test="quality-detail-empty"
/>
</div>
<template v-else>
<div
class="rounded-default border-border-default bg-surface-base flex flex-wrap items-center justify-between gap-2 border px-3 py-2.5"
data-test="quality-detail-health-summary"
>
<div class="min-w-0">
<div class="text-3xs text-text-secondary font-semibold tracking-wide uppercase">
{{ t("onlineEvals.quality.detail.health.title") }}
</div>
<OTruncatedText as="div" class="text-2xs text-text-secondary mt-0.5">
{{ healthSummary }}
</OTruncatedText>
</div>
<OTag
type="qualityStatus"
:value="healthStatus"
:label="t(`onlineEvals.quality.overview.status.${healthStatus}`)"
/>
</div>
<template v-if="hasScores">
<!-- KPI tiles reuse the exact card layout + text styles as the LLM
Sessions detail page (.kpi-card) so the AI module stays visually
consistent across pages. -->
<div class="grid grid-cols-[repeat(auto-fit,minmax(11.25rem,1fr))] gap-2.5">
<div
v-for="kpi in kpis"
:key="kpi.id"
class="rounded-surface border-border-default bg-surface-base flex flex-col gap-1 border px-[0.875rem] pt-[0.625rem] pb-[0.625rem] transition-shadow duration-200 hover:shadow-sm"
:data-test="`quality-detail-kpi-${kpi.id}`"
>
<div class="text-2xs text-text-secondary font-semibold">
{{ kpiTitle(kpi) }}
</div>
<div class="flex items-baseline gap-[0.2rem]">
<span class="text-text-heading text-2xl leading-none font-bold">
{{ formatKpi(kpi) }}
</span>
</div>
</div>
</div>
<section
v-if="dataType === 'numeric'"
class="card-container rounded-default border-border-default bg-surface-base border px-[0.875rem] py-3 pb-[0.875rem]"
>
<header class="mb-1.5">
<h4 class="text-compact text-text-heading m-0 font-semibold">
{{ t("onlineEvals.quality.detail.trendTitle") }}
<span class="text-2xs text-text-secondary font-normal">— {{ config.name }}</span>
</h4>
</header>
<div class="h-[16.25rem]">
<QualityTrendChart
v-if="numericTrend.length > 0"
:points="numericTrend"
:threshold="numericThreshold"
:y-min="numericRange?.min ?? null"
:y-max="numericRange?.max ?? null"
:legend-avg="t('onlineEvals.quality.detail.legendAvg')"
:legend-p95="raw('p95')"
:legend-threshold-fmt="t('onlineEvals.quality.detail.legendThreshold')"
/>
<p
v-else
class="text-text-secondary m-0 flex h-full items-center justify-center text-xs"
>
{{ t("onlineEvals.quality.detail.noData") }}
</p>
</div>
</section>
<section
v-if="dataType === 'numeric'"
class="card-container rounded-default border-border-default bg-surface-base border px-[0.875rem] py-3 pb-[0.875rem]"
>
<header class="mb-1.5">
<h4 class="text-compact text-text-heading m-0 font-semibold">
{{ t("onlineEvals.quality.detail.distributionTitle") }}
</h4>
</header>
<div class="h-[13.75rem]">
<QualityDistributionChart
v-if="numericDistribution.length > 0"
:buckets="numericDistribution"
:threshold="numericThreshold"
:legend-healthy="t('onlineEvals.quality.detail.legendHealthy')"
:legend-unhealthy="t('onlineEvals.quality.detail.legendUnhealthy')"
/>
<p
v-else
class="text-text-secondary m-0 flex h-full items-center justify-center text-xs"
>
{{ t("onlineEvals.quality.detail.noData") }}
</p>
</div>
</section>
<section
v-if="dataType === 'boolean'"
class="card-container rounded-default border-border-default bg-surface-base border px-[0.875rem] py-3 pb-[0.875rem]"
>
<header class="mb-1.5">
<h4 class="text-compact text-text-heading m-0 font-semibold">
{{ booleanRateTitle }}
<span class="text-2xs text-text-secondary font-normal">— {{ config.name }}</span>
</h4>
</header>
<div class="h-[16.25rem]">
<QualityBooleanTrendChart
v-if="booleanTrendSeries.length > 0"
:series="booleanTrendSeries"
:points="booleanTrend"
:legend-pass-rate="booleanRateLegend"
/>
<p
v-else
class="text-text-secondary m-0 flex h-full items-center justify-center text-xs"
>
{{ t("onlineEvals.quality.detail.noData") }}
</p>
</div>
</section>
<section
v-if="dataType === 'boolean'"
class="card-container rounded-default border-border-default bg-surface-base border px-[0.875rem] py-3 pb-[0.875rem]"
>
<header class="mb-1.5">
<h4 class="text-compact text-text-heading m-0 font-semibold">
{{ t("onlineEvals.quality.detail.trueFalseTitle") }}
</h4>
</header>
<div class="h-[7.5rem]">
<QualityBooleanBarsChart
v-if="booleanCounts.trueCount + booleanCounts.falseCount > 0"
:true-count="booleanCounts.trueCount"
:false-count="booleanCounts.falseCount"
:legend-true="
t('onlineEvals.quality.detail.legendTrueCount', {
count: booleanCounts.trueCount,
})
"
:legend-false="
t('onlineEvals.quality.detail.legendFalseCount', {
count: booleanCounts.falseCount,
})
"
/>
<p
v-else
class="text-text-secondary m-0 flex h-full items-center justify-center text-xs"
>
{{ t("onlineEvals.quality.detail.noData") }}
</p>
</div>
</section>
<section
v-if="dataType === 'categorical'"
class="card-container rounded-default border-border-default bg-surface-base border px-[0.875rem] py-3 pb-[0.875rem]"
>
<header class="mb-1.5">
<h4 class="text-compact text-text-heading m-0 font-semibold">
{{ t("onlineEvals.quality.detail.categoryDistributionTitle") }}
</h4>
</header>
<div class="h-[16.25rem]">
<QualityCategoryBarsChart
v-if="categoricalRows.length > 0"
:rows="categoricalRows"
:healthy-categories="healthyCategories"
/>
<p
v-else
class="text-text-secondary m-0 flex h-full items-center justify-center text-xs"
>
{{ t("onlineEvals.quality.detail.noData") }}
</p>
</div>
</section>
</template>
<QualityRunsTable
:config="config"
:rows="runs"
:active-filter="runsFilter"
:current-page="runsCurrentPage"
:page-size="runsPageSize"
:total-count="runsTotalCount"
:is-loading="runsLoading"
:error="runsError"
@open-run="emit('open-run', $event)"
@filter-change="emit('runs-filter-change', $event)"
@pagination-change="emit('runs-pagination-change', $event)"
/>
</template>
</section>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import type { ScoreConfig } from "@/services/online-evals.service";
import type {
BooleanAggRow,
CategoricalAggRow,
DetailKpi,
} from "../composables/useQualityConfigDetail";
import type {
BooleanTrendPoint,
BooleanTrendSeries,
DistributionBucket,
TrendPoint,
} from "../composables/useQualityDetailCharts";
import type { QualityScope } from "../utils/qualityScope";
import { healthyBooleanValue } from "../utils/qualitySummary";
import { thresholdForConfig } from "../utils/scoreThreshold";
import type {
QualityRunCounts,
QualityRunFilter,
QualityRunRow,
QualityRunsPagination,
} from "../composables/useQualityRuns";
import QualityTrendChart from "./QualityTrendChart.vue";
import QualityDistributionChart from "./QualityDistributionChart.vue";
import QualityBooleanTrendChart from "./QualityBooleanTrendChart.vue";
import QualityBooleanBarsChart from "./QualityBooleanBarsChart.vue";
import QualityCategoryBarsChart from "./QualityCategoryBarsChart.vue";
import QualityRunsTable from "./QualityRunsTable.vue";
const props = defineProps<{
config: ScoreConfig;
dataType: "numeric" | "boolean" | "categorical" | "unknown";
kpis: DetailKpi[];
hasScores: boolean;
isLoading: boolean;
numericTrend: TrendPoint[];
numericDistribution: DistributionBucket[];
numericThreshold: { value: number; direction: "gte" | "lte" } | null;
numericRange: { min: number; max: number } | null;
booleanAgg: BooleanAggRow | null;
booleanTrend: BooleanTrendPoint[];
booleanTrendSeries: BooleanTrendSeries[];
categoricalRows: CategoricalAggRow[];
scope: QualityScope;
runs: QualityRunRow[];
runsCounts: QualityRunCounts;
runsFilter: QualityRunFilter;
runsCurrentPage: number;
runsPageSize: number;
runsTotalCount: number;
runsLoading: boolean;
runsError: string | null;
}>();
// NOTE: the binding is required — the template calls emit() directly for
// update:scope, open-run, runs-filter-change and runs-pagination-change.
// a lint pass on main dropped `const emit =` here; on this branch the
// OToggleGroup scope filter uses it, so removing it silently stops the panel
// emitting.
const emit = defineEmits<{
(e: "back"): void;
(e: "update:scope", value: QualityScope): void;
(e: "open-run", value: QualityRunRow): void;
(e: "runs-filter-change", value: QualityRunFilter): void;
(e: "runs-pagination-change", value: QualityRunsPagination): void;
}>();
const { t } = useI18nTyped();
const threshold = computed(() => thresholdForConfig(props.config));
const unhealthyKpi = computed(() => props.kpis.find((kpi) => kpi.id === "unhealthy"));
const healthStatus = computed<"healthy" | "unhealthy" | "noThreshold" | "noData">(() => {
if (threshold.value.unhealthyExpr == null) return "noThreshold";
if (!props.hasScores) return "noData";
const unhealthy = unhealthyKpi.value?.value;
return typeof unhealthy === "number" && unhealthy > 0 ? "unhealthy" : "healthy";
});
const healthSummary = computed(() => {
if (threshold.value.unhealthyExpr == null) {
return t("onlineEvals.quality.detail.health.noThreshold");
}
const unhealthy = unhealthyKpi.value?.value;
if (typeof unhealthy !== "number") {
return t("onlineEvals.quality.detail.health.thresholdOnly", {
threshold: threshold.value.label,
});
}
return t("onlineEvals.quality.detail.health.summary", {
percent: unhealthy.toFixed(1),
threshold: threshold.value.label,
});
});
const scopeOptions = ["all", "span", "trace", "session"] as const;
const emptyDescription = computed(() => {
if (props.scope === "all") {
return t("onlineEvals.quality.detail.empty.description");
}
return t("onlineEvals.quality.detail.empty.descriptionScoped", {
scope: t(`onlineEvals.quality.scopes.${props.scope}`),
});
});
const emptyTitle = computed(() => {
if (props.scope === "all") {
return t("onlineEvals.quality.detail.empty.title");
}
return t("onlineEvals.quality.detail.empty.titleScoped", {
scope: t(`onlineEvals.quality.scopes.${props.scope}`),
});
});
const booleanRateHasThreshold = computed(() => healthyBooleanValue(props.config) != null);
const booleanRateTitle = computed(() =>
t(
booleanRateHasThreshold.value
? "onlineEvals.quality.detail.healthyRateTitle"
: "onlineEvals.quality.detail.trueRateTitle",
),
);
const booleanRateLegend = computed(() =>
t(
booleanRateHasThreshold.value
? "onlineEvals.quality.detail.legendHealthyRate"
: "onlineEvals.quality.detail.legendTrueRate",
),
);
function toNumber(v: unknown): number {
if (v == null) return 0;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
const booleanCounts = computed(() => ({
trueCount: toNumber(props.booleanAgg?.trues),
falseCount: toNumber(props.booleanAgg?.falses),
}));
const healthyCategories = computed<string[]>(() => {
const ht: any = (props.config as any).healthyThreshold ?? (props.config as any).healthy_threshold;
return ht?.healthy_categories || ht?.healthyCategories || [];
});
function kpiTitle(kpi: DetailKpi): string {
const key = kpi.titleKey ?? kpi.id;
return t(`onlineEvals.quality.detail.kpis.${key}`);
}
function formatKpi(kpi: DetailKpi): string {
if (kpi.value == null) return "—";
if (typeof kpi.value === "string") return kpi.value;
if (kpi.format === "percent") return `${kpi.value.toFixed(1)}%`;
if (kpi.format === "number") return kpi.value.toFixed(2);
if (kpi.format === "count") {
const v = kpi.value;
if (v >= 1000000) return `${(v / 1000000).toFixed(1)}M`;
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
return String(Math.round(v));
}
return String(kpi.value);
}
</script>
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import store from "@/test/unit/helpers/store";
import type { QualityDistributionBucket } from "@/services/online-evals.service";
vi.mock("@/components/dashboards/panels/ChartRenderer.vue", () => ({
// `__esModule` is load-bearing: the chart reaches this through defineAsyncComponent.
__esModule: true,
default: { name: "ChartRenderer", props: ["data"], template: "<div data-test='chart-stub' />" },
}));
import QualityDistributionChart from "./QualityDistributionChart.vue";
const BUCKETS: QualityDistributionBucket[] = [
{ key: 0, lower: 0, upper: 0.5, count: 3, unhealthy: 1 },
{ key: 1, lower: 0.5, upper: 1, count: 1200, unhealthy: 0 },
];
const mountChart = (filter: { kind: "bucket"; from: number; to: number } | null = null) =>
mount(QualityDistributionChart, {
props: { buckets: BUCKETS, filter },
global: { plugins: [store] },
});
describe("QualityDistributionChart", () => {
it("offers every bar as a keyboard button that filters like a click, shift widening the range", async () => {
const wrapper = mountChart();
const keys = wrapper.findAll('[data-test^="quality-distribution-key-"]');
expect(keys.map((key) => key.text())).toEqual(["< 0.5: 3 scores", "≥ 0.5: 1,200 scores"]);
expect(wrapper.find('[data-test="quality-distribution-keys"]').attributes("role")).toBe(
"group",
);
await keys[1].trigger("click", { shiftKey: true });
expect(wrapper.emitted("select")).toEqual([[BUCKETS[1], true]]);
});
it("marks the filtered bar as pressed", () => {
const keys = mountChart({ kind: "bucket", from: 1, to: 1 }).findAll(
'[data-test^="quality-distribution-key-"]',
);
expect(keys.map((key) => key.attributes("aria-pressed"))).toEqual(["false", "true"]);
});
it("groups the counts on the bars and in the tooltip", async () => {
const wrapper = mountChart();
await flushPromises();
const options = wrapper.findComponent({ name: "ChartRenderer" }).props("data").options;
const labelled = options.series.find((series: any) => series.label);
expect(labelled.label.formatter({ dataIndex: 1 })).toBe("1,200");
expect(options.tooltip.formatter([{ dataIndex: 1 }])).toContain("1,200 scores");
});
it("says 1 score, not 1 scores, in the tooltip", async () => {
const tooltipOf = async (unhealthy: number | null) => {
const wrapper = mount(QualityDistributionChart, {
props: { buckets: [{ key: 0, lower: 0, upper: 0.5, count: 1, unhealthy }], filter: null },
global: { plugins: [store] },
});
await flushPromises();
return wrapper
.findComponent({ name: "ChartRenderer" })
.props("data")
.options.tooltip.formatter([{ dataIndex: 0 }]);
};
expect(await tooltipOf(0)).toContain("1 score, 0 unhealthy");
expect(await tooltipOf(null)).toMatch(/1 score$/);
});
});
@@ -1,151 +1,180 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div ref="chartEl" class="h-full min-h-50 w-full" data-test="quality-distribution-chart" />
<div class="relative h-full min-h-0 w-full" data-test="quality-distribution-chart">
<!-- ChartRenderer, not PanelSchemaRenderer: only it forwards the bar click that filters the scores. -->
<ChartRenderer :data="{ options }" @click="onBarClick" />
<!-- The bars as buttons for keyboard users: hidden until one takes focus, then a row over the chart's foot. -->
<div
role="group"
:aria-label="t('onlineEvals.quality.detail.filterByBar')"
class="focus-within:bg-surface-base sr-only focus-within:not-sr-only focus-within:absolute focus-within:inset-x-0 focus-within:bottom-0 focus-within:flex focus-within:flex-wrap focus-within:gap-1 focus-within:p-1"
data-test="quality-distribution-keys"
>
<OButton
v-for="(bucket, index) in buckets"
:key="String(bucket.key)"
variant="outline"
size="chip"
:active="!!filter && bucketSelected(filter, bucket)"
:aria-pressed="String(!!filter && bucketSelected(filter, bucket))"
:data-test="`quality-distribution-key-${index}`"
@click="(event: MouseEvent) => emit('select', bucket, event.shiftKey)"
>
{{
t(
"onlineEvals.quality.detail.barButton",
{ label: labels[index], count: formatCount(bucket.count) },
bucket.count,
)
}}
</OButton>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, defineAsyncComponent } from "vue";
import { useStore } from "vuex";
import * as echarts from "echarts";
import { useI18nTyped } from "@/types/i18n";
import { chartColor } from "@/utils/chartTheme";
import { CHART_THRESHOLD_COLOR } from "@/utils/dashboard/colorPalette";
import type { DistributionBucket } from "../composables/useQualityDetailCharts";
import { withChartFont } from "@/utils/fonts";
const { t } = useI18nTyped();
import { escapeHtml } from "@/utils/html";
import type { QualityDistributionBucket } from "@/services/online-evals.service";
import OButton from "@/lib/core/Button/OButton.vue";
import {
bucketLabel,
bucketSelected,
formatCount,
type QualityChartFilter,
} from "../utils/qualityFormat";
const ChartRenderer = defineAsyncComponent(
() => import("@/components/dashboards/panels/ChartRenderer.vue"),
);
const props = defineProps<{
buckets: DistributionBucket[];
threshold: { value: number; direction: "gte" | "lte" } | null;
legendHealthy: I18nText;
legendUnhealthy: string;
buckets: QualityDistributionBucket[];
filter: QualityChartFilter | null;
}>();
const chartEl = ref<HTMLElement | null>(null);
let chart: echarts.ECharts | null = null;
const emit = defineEmits<{
(e: "select", bucket: QualityDistributionBucket, shift: boolean): void;
}>();
const { t } = useI18nTyped();
const store = useStore();
function buildOption(): echarts.EChartsOption {
const classified = computed(() => props.buckets.some((bucket) => bucket.unhealthy != null));
const labels = computed(() =>
props.buckets.map((bucket, index, all) =>
bucketLabel(bucket, { first: index === 0, last: index === all.length - 1 }),
),
);
const options = computed(() => {
// Read the theme so token colors re-resolve when it flips.
void store.state.theme;
const text = chartColor("--color-text-secondary");
const grid = chartColor("--color-border-subtle");
const labels = props.buckets.map((b) => b.label);
const seriesData = props.buckets.map((b) => ({
value: b.count,
itemStyle: {
color: b.healthy ? "rgba(46, 125, 50, 0.85)" : "rgba(178, 84, 0, 0.85)",
const point = (value: number, bucket: QualityDistributionBucket) => ({
value,
itemStyle: { opacity: bucketSelected(props.filter, bucket) ? 1 : 0.3 },
});
const countLabel = {
show: true,
position: "top",
color: text,
fontSize: 10,
formatter: (p: any) => {
const count = props.buckets[p.dataIndex]?.count;
return count ? formatCount(count) : "";
},
}));
const series: echarts.SeriesOption[] = [
{
name: t("onlineEvals.count"),
type: "bar",
data: seriesData,
barCategoryGap: "8%",
label: {
show: true,
position: "top",
color: text,
fontSize: 10,
},
},
];
if (props.threshold) {
const sign = props.threshold.direction === "gte" ? ">=" : "<=";
const thresholdLabel = t("onlineEvals.quality.detail.markLineHealthy", {
direction: sign,
value: props.threshold.value,
});
series.push({
name: thresholdLabel,
type: "line",
data: [],
markLine: {
silent: true,
symbol: "none",
label: {
formatter: thresholdLabel,
color: CHART_THRESHOLD_COLOR,
fontSize: 10,
position: "insideEndTop",
};
const series = classified.value
? [
{
name: t("onlineEvals.quality.detail.legendHealthy"),
type: "bar",
stack: "scores",
color: chartColor("--color-service-health-healthy"),
data: props.buckets.map((b) => point(b.count - (b.unhealthy ?? 0), b)),
},
lineStyle: { color: CHART_THRESHOLD_COLOR, type: "dashed", width: 1.2 },
data: [{ xAxis: thresholdBucketIndex() }],
},
});
}
{
name: t("onlineEvals.quality.detail.legendUnhealthy"),
type: "bar",
stack: "scores",
color: chartColor("--color-service-health-critical"),
label: countLabel,
data: props.buckets.map((b) => point(b.unhealthy ?? 0, b)),
},
]
: [
{
name: t("onlineEvals.quality.detail.legendScores"),
type: "bar",
color: chartColor("--color-chart-series-1"),
label: countLabel,
data: props.buckets.map((b) => point(b.count, b)),
},
];
return {
grid: { left: 40, right: 16, top: 28, bottom: 28 },
tooltip: { trigger: "axis", confine: true },
legend: {
right: 0,
top: 0,
itemWidth: 12,
itemHeight: 8,
textStyle: { color: text, fontSize: 11 },
data: [
{
name: props.legendHealthy,
icon: "rect",
itemStyle: { color: "rgba(46, 125, 50, 0.85)" } as any,
},
{
name: props.legendUnhealthy,
icon: "rect",
itemStyle: { color: "rgba(178, 84, 0, 0.85)" } as any,
},
],
grid: { left: 8, right: 8, top: 20, bottom: 4, containLabel: true },
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (params: any[]) => {
const index = params?.[0]?.dataIndex;
const bucket = props.buckets[index];
if (!bucket) return "";
const detail = classified.value
? t(
"onlineEvals.quality.detail.chartTooltip",
{
count: formatCount(bucket.count),
unhealthy: formatCount(bucket.unhealthy ?? 0),
},
bucket.count,
)
: t(
"onlineEvals.quality.detail.chartTooltipCount",
{ count: formatCount(bucket.count) },
bucket.count,
);
return `${escapeHtml(labels.value[index])}<br/>${escapeHtml(detail)}`;
},
},
xAxis: {
type: "category",
data: labels,
axisLine: { lineStyle: { color: grid } },
data: labels.value,
axisLabel: { color: text, fontSize: 10 },
splitLine: { show: false },
axisLine: { lineStyle: { color: grid } },
},
yAxis: {
type: "value",
axisLine: { show: false },
minInterval: 1,
axisLabel: { color: text, fontSize: 10 },
splitLine: { lineStyle: { color: grid } },
},
series,
};
}
function thresholdBucketIndex(): number {
if (!props.threshold) return -1;
const target = props.threshold.value;
const idx = props.buckets.findIndex((b) => target >= b.rangeStart && target <= b.rangeEnd);
return idx >= 0 ? idx : -1;
}
function render() {
if (!chart) return;
chart.setOption(withChartFont(buildOption()), true);
}
onMounted(() => {
if (!chartEl.value) return;
chart = echarts.init(chartEl.value, undefined, { renderer: "canvas" });
render();
});
watch(
() => [props.buckets, props.threshold, store.state.theme],
() => render(),
{ deep: true },
);
const resizeObserver = new ResizeObserver(() => chart?.resize());
onMounted(() => {
if (chartEl.value) resizeObserver.observe(chartEl.value);
});
onBeforeUnmount(() => {
resizeObserver.disconnect();
chart?.dispose();
chart = null;
});
function onBarClick(params: any) {
const bucket = props.buckets[params?.dataIndex];
if (bucket) emit("select", bucket, !!params?.event?.event?.shiftKey);
}
</script>
@@ -1,79 +0,0 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
import { describe, expect, it } from "vitest";
import en from "@/locales/languages/en-US.json";
import type { KpiCard } from "../composables/useQualityData";
import QualityKpiCard from "./QualityKpiCard.vue";
function mountCard(kpi: KpiCard, clickable = false) {
const i18n = createI18n({
legacy: false,
locale: "en",
messages: { en },
});
return mount(QualityKpiCard, {
props: { kpi, delta: null, clickable },
global: {
plugins: [i18n],
stubs: { KpiSparkline: true },
},
});
}
describe("QualityKpiCard", () => {
it("shows a compact target-scope breakdown for score results", () => {
const wrapper = mountCard({
id: "scoreResults",
value: 1255,
prevValue: 1000,
sparkline: [],
healthyDirection: "neutral",
format: "count",
scopeCounts: { span: 1200, trace: 50, session: 5 },
});
expect(wrapper.text()).toContain("Score Results");
expect(wrapper.find('[data-test="quality-kpi-scope-span"]').text()).toBe("Span1.2k");
expect(wrapper.find('[data-test="quality-kpi-scope-trace"]').text()).toBe("Trace50");
expect(wrapper.find('[data-test="quality-kpi-scope-session"]').text()).toBe("Session5");
});
it("uses operational scorer copy without a scope breakdown", () => {
const wrapper = mountCard({
id: "scorerSuccess",
value: 98.4,
prevValue: 97,
sparkline: [],
healthyDirection: "up",
format: "percent",
});
expect(wrapper.text()).toContain("Scorer Success");
expect(wrapper.find('[data-test="quality-kpi-scope-breakdown"]').exists()).toBe(false);
});
it("activates a clickable KPI with pointer and keyboard input", async () => {
const wrapper = mountCard(
{
id: "scorerFailures",
value: 2,
prevValue: 0,
sparkline: [],
healthyDirection: "down",
format: "count",
},
true,
);
expect(wrapper.attributes("role")).toBe("button");
expect(wrapper.attributes("tabindex")).toBe("0");
await wrapper.trigger("click");
await wrapper.trigger("keydown", { key: "Enter" });
expect(wrapper.emitted("activate")).toHaveLength(2);
});
});
@@ -1,196 +0,0 @@
<template>
<div
:class="[
'bg-card-glass-bg rounded-default border-border-default flex flex-col gap-0.5 overflow-hidden border px-3 pt-1.5 pb-1.5 transition-shadow duration-200 ease-in-out hover:shadow-sm max-lg:shrink-0 max-lg:basis-auto max-lg:px-1.5 max-lg:py-1',
clickable &&
'hover:border-accent focus-visible:ring-accent cursor-pointer focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-none',
]"
:data-test="`quality-kpi-${kpi.id}`"
:title="lgUp ? undefined : t(`onlineEvals.quality.kpis.${kpi.id}.title`)"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="activate"
@keydown.enter.prevent="activate"
@keydown.space.prevent="activate"
>
<div class="flex flex-col gap-0.5 max-lg:flex-row-reverse max-lg:items-center max-lg:gap-1.5">
<!-- Label + a metric icon in a soft corner tile — same KPI-card pattern
as LLM Insights / Session Detail so every card reads the same. -->
<div class="flex items-center justify-between gap-2">
<OTruncatedText
as="div"
class="kpi-label text-2xs text-text-secondary leading-normal font-semibold max-lg:hidden"
>
{{ t(`onlineEvals.quality.kpis.${kpi.id}.title`) }}
</OTruncatedText>
<span
class="rounded-default bg-surface-subtle text-text-secondary inline-flex h-6 w-6 shrink-0 items-center justify-center"
>
<OIcon :name="cardIcon" size="sm" />
</span>
</div>
<div class="flex items-baseline gap-[0.2rem]">
<template v-if="kpi.value != null">
<span class="text-text-secondary text-2xl leading-none font-bold max-lg:text-lg">
{{ bigNumber }}
</span>
<span v-if="unitLabel" class="text-compact text-text-secondary font-semibold">
{{ unitLabel }}
</span>
</template>
<span v-else class="text-text-muted text-base leading-none font-medium">
{{ t("onlineEvals.quality.kpis.noData") }}
</span>
</div>
<div
v-if="kpi.id === 'scoreResults' && kpi.scopeCounts"
class="text-3xs text-text-tertiary flex flex-wrap items-center gap-x-2 gap-y-0.5 font-medium max-lg:hidden"
data-test="quality-kpi-scope-breakdown"
>
<span
v-for="scope in scopes"
:key="scope"
class="inline-flex items-center gap-1 [font-variant-numeric:tabular-nums]"
:data-test="`quality-kpi-scope-${scope}`"
>
<span>{{ t(`onlineEvals.quality.scopes.${scope}`) }}</span>
<span class="text-text-secondary font-semibold">
{{ compactCount(kpi.scopeCounts[scope]) }}
</span>
</span>
</div>
<!-- Always render the delta row even when prev is missing — keeps
every card the same height. The body switches to a neutral
"no prior data" hint instead of hiding the row, so the user
can tell the comparison is *unavailable* (recent column /
short history) rather than zero. -->
<div
v-if="kpi.value != null"
class="text-3xs flex items-center gap-1 font-medium max-lg:hidden"
:class="{
'text-status-success-text': (delta != null ? trendSentiment : 'neutral') === 'good',
'text-error-600': (delta != null ? trendSentiment : 'neutral') === 'bad',
'text-text-muted': (delta != null ? trendSentiment : 'neutral') === 'neutral',
}"
>
<template v-if="delta != null">
<span class="kpi-trend-arrow">{{ trendArrow }}</span>
<span> {{ deltaText }} {{ t("onlineEvals.quality.kpis.vsPrev") }} </span>
</template>
<template v-else>
<span class="kpi-trend-arrow">–</span>
<span>{{ t("onlineEvals.quality.kpis.noPriorData") }}</span>
</template>
</div>
</div>
<KpiSparkline
v-if="kpi.sparkline && kpi.sparkline.length > 1"
:data="kpi.sparkline"
:color="sparkColor"
:height="20"
class="-mx-3 mt-auto -mb-1.5 max-lg:hidden"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useI18nTyped } from "@/types/i18n";
import KpiSparkline from "@/plugins/traces/KpiSparkline.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import type { KpiCard } from "../composables/useQualityData";
import useBreakpoint from "@/composables/useBreakpoint";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
const { lgUp } = useBreakpoint();
const props = defineProps<{
kpi: KpiCard;
delta: number | null;
clickable?: boolean;
}>();
const emit = defineEmits<{
(event: "activate"): void;
}>();
const { t } = useI18nTyped();
const scopes = ["span", "trace", "session"] as const;
function activate() {
if (props.clickable) emit("activate");
}
const ICONS: Record<KpiCard["id"], string> = {
scoreResults: "fact-check",
evaluationCost: "payments",
scorerSuccess: "check-circle",
scorerFailures: "error",
latencyP95: "schedule",
};
const cardIcon = computed(() => ICONS[props.kpi.id]);
const trendSentiment = computed<"good" | "bad" | "neutral">(() => {
if (props.delta == null || props.kpi.healthyDirection === "neutral" || props.delta === 0) {
return "neutral";
}
const movedUp = props.delta > 0;
if (props.kpi.healthyDirection === "up") return movedUp ? "good" : "bad";
return movedUp ? "bad" : "good";
});
const trendArrow = computed(() => {
if (props.delta == null || props.delta === 0) return "–";
return props.delta > 0 ? "▲" : "▼";
});
const deltaText = computed(() => {
if (props.delta == null) return "—";
const abs = Math.abs(props.delta);
if (props.kpi.format === "percent") return `${abs.toFixed(1)}%`;
if (props.kpi.format === "currency") return `$${abs.toFixed(2)}`;
if (props.kpi.format === "seconds") return `${abs.toFixed(2)}s`;
if (abs >= 100) {
const prev = props.kpi.prevValue ?? 0;
const pct = prev > 0 ? (abs / prev) * 100 : 0;
return `${pct.toFixed(1)}%`;
}
return `${Math.round(abs)}`;
});
const bigNumber = computed(() => {
const v = props.kpi.value;
if (v == null) return "—";
if (props.kpi.format === "percent") return v.toFixed(1);
// Currency: prefix the symbol on the main number itself — `unitLabel`
// stays empty so we don't end up with "$98.97 $". Mirrors how the
// delta line formats currency (`$${abs.toFixed(2)}`).
if (props.kpi.format === "currency") return `$${v.toFixed(2)}`;
if (props.kpi.format === "seconds") return v.toFixed(1);
if (v >= 1000000) return `${(v / 1000000).toFixed(1)}M`;
if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
return String(Math.round(v));
});
const unitLabel = computed(() => {
if (props.kpi.format === "percent") return "%";
if (props.kpi.format === "currency") return "";
if (props.kpi.format === "seconds") return "s";
return "";
});
function compactCount(value: number): string {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
return String(Math.round(value));
}
const sparkColor = computed(() => {
if (trendSentiment.value === "good") {
return "var(--color-status-success-text)";
}
if (trendSentiment.value === "bad") return "var(--color-status-error-text)";
return "var(--color-primary-500)";
});
</script>
@@ -1,34 +0,0 @@
<template>
<!-- Same KpiCardRow grid and the same card padding/background as the real
QualityKpiCard, so the tiles don't resize or change colour on load. -->
<KpiCardRow gap="gap-2">
<div
v-for="n in count"
:key="n"
class="bg-card-glass-bg border-border-default rounded-default flex min-h-24 flex-col gap-0.5 border px-3 pt-1.5 pb-1.5 max-lg:min-h-9 max-lg:w-20 max-lg:shrink-0"
data-test="quality-kpi-skeleton"
>
<OSkeleton type="text" class="h-2.75 w-[55%]" />
<div class="flex items-baseline justify-between gap-2">
<OSkeleton type="text" class="h-5.5 w-[50%]" />
<OSkeleton type="text" class="h-2.75 w-[22%]" />
</div>
<div class="mt-auto flex h-5 items-end gap-0.75">
<OSkeleton
type="text"
v-for="bar in 14"
:key="bar"
:style="{ height: `${30 + ((bar * 23) % 65)}%` }"
class="w-full"
/>
</div>
</div>
</KpiCardRow>
</template>
<script setup lang="ts">
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import KpiCardRow from "@/components/common/KpiCardRow.vue";
withDefaults(defineProps<{ count?: number }>(), { count: 5 });
</script>
@@ -1,157 +0,0 @@
// @vitest-environment jsdom
import { defineComponent } from "vue";
import { mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
import { describe, expect, it } from "vitest";
import en from "@/locales/languages/en-US.json";
import type { ScoreConfig } from "@/services/online-evals.service";
import type { QualityRunRow } from "../composables/useQualityRuns";
import QualityRunsTable from "./QualityRunsTable.vue";
const OTableStub = defineComponent({
name: "OTable",
props: {
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
pagination: { type: String, default: "client" },
sorting: { type: String, default: "client" },
totalCount: { type: Number, default: 0 },
},
template: `
<div data-test="stub-scores-table" @click="$emit('row-click', data[0])">
<span data-test="stub-row-count">{{ data.length }}</span>
<slot v-if="data.length" name="cell-agentName" :row="data[0]" />
</div>
`,
});
function run(id: string, overrides: Partial<QualityRunRow> = {}): QualityRunRow {
return {
id,
taskId: `task-${id}`,
timestampMs: 1,
evaluatorTraceId: `trace-${id}`,
evaluatorSpanId: "",
evalRunId: `run-${id}`,
status: "success",
result: 0.9,
resultDisplay: "0.90",
reasoning: "Canonical reasoning",
health: "healthy",
isUnhealthy: false,
targetScope: "span",
targetId: `target-${id}`,
targetSpanId: `target-${id}`,
targetTraceId: "",
targetSessionId: "",
targetStream: "default",
targetStreamType: "traces",
agentName: "Support agent",
agentId: "support-agent",
scorerId: "scorer-1",
jobId: "job-1",
latencyMs: null,
errorKind: "",
...overrides,
};
}
function mountTable(config: ScoreConfig) {
const i18n = createI18n({
legacy: false,
locale: "en",
messages: { en },
});
return mount(QualityRunsTable, {
props: {
config,
rows: [run("healthy"), run("second")],
activeFilter: "all",
currentPage: 2,
pageSize: 10,
totalCount: 42,
isLoading: false,
error: null,
},
global: {
plugins: [i18n],
stubs: {
OTable: OTableStub,
OIcon: true,
},
},
});
}
describe("QualityRunsTable", () => {
it("uses server pagination and switches the active filter", async () => {
const wrapper = mountTable({
id: "config-1",
name: "Answer relevance",
data_type: "numeric",
healthy_threshold: { direction: "gte", value: 0.7 },
} as ScoreConfig);
const table = wrapper.findComponent(OTableStub);
expect(table.props("pagination")).toBe("server");
expect(table.props("sorting")).toBe("none");
expect(table.props("totalCount")).toBe(42);
expect(wrapper.get('[data-test="stub-row-count"]').text()).toBe("2");
expect(wrapper.get('[data-test="quality-runs-filter-unhealthy"]').text()).toBe("Unhealthy");
await wrapper.get('[data-test="quality-runs-filter-unhealthy"]').trigger("click");
expect(wrapper.emitted("filter-change")).toEqual([["unhealthy"]]);
const reasoningColumn = (table.props("columns") as any[]).find(
(column) => column.id === "reasoning",
);
expect(reasoningColumn.header).toBe("Reasoning");
});
it("opens the evaluator trace when the run row is clicked", async () => {
const wrapper = mountTable({
id: "config-1",
name: "Answer relevance",
data_type: "numeric",
healthy_threshold: { direction: "gte", value: 0.7 },
} as ScoreConfig);
await wrapper.get('[data-test="stub-row-count"]').trigger("click");
expect(wrapper.emitted("open-run")?.[0]?.[0]).toMatchObject({
id: "healthy",
evaluatorTraceId: "trace-healthy",
});
});
it("renders the agent column from the score row", () => {
const wrapper = mountTable({
id: "config-1",
name: "Answer relevance",
data_type: "numeric",
healthy_threshold: { direction: "gte", value: 0.7 },
} as ScoreConfig);
const table = wrapper.findComponent(OTableStub);
const agentColumn = (table.props("columns") as any[]).find(
(column) => column.id === "agentName",
);
expect(agentColumn.header).toBe("Agent");
expect(wrapper.text()).toContain("Support agent");
});
it("disables unhealthy filtering until a threshold is configured", () => {
const wrapper = mountTable({
id: "config-1",
name: "Answer relevance",
data_type: "numeric",
} as ScoreConfig);
expect(
wrapper.get('[data-test="quality-runs-filter-unhealthy"]').attributes("disabled"),
).toBeDefined();
expect(wrapper.get('[data-test="quality-runs-no-threshold"]').text()).toContain(
"Set a healthy threshold",
);
});
});
@@ -1,310 +0,0 @@
<template>
<section
class="rounded-default border-border-default bg-surface-base overflow-hidden border"
data-test="quality-runs-section"
>
<header
class="border-border-default flex flex-wrap items-start justify-between gap-3 border-b px-3.5 py-3"
>
<div class="min-w-0">
<h4 class="text-compact text-text-heading m-0 font-semibold">
{{ t("onlineEvals.quality.runs.title") }}
</h4>
<p class="text-2xs text-text-secondary m-0 mt-0.5 max-w-[48rem] leading-relaxed">
{{ t("onlineEvals.quality.runs.hint") }}
</p>
</div>
<OToggleGroup
:model-value="activeFilter"
type="single"
:aria-label="t('onlineEvals.quality.runs.filterLabel')"
data-test="quality-runs-filter"
@update:model-value="emit('filter-change', $event as QualityRunFilter)"
>
<OToggleGroupItem
v-for="option in filterOptions"
:key="option.id"
:value="option.id"
size="sm"
:disabled="option.disabled"
:data-test="`quality-runs-filter-${option.id}`"
>
{{ option.label }}
</OToggleGroupItem>
</OToggleGroup>
</header>
<div
v-if="!hasThreshold"
class="border-border-default bg-surface-subtle text-2xs text-text-secondary flex items-center gap-2 border-b px-3.5 py-2"
data-test="quality-runs-no-threshold"
>
<OIcon name="info" size="xs" class="shrink-0" />
<span>{{ t("onlineEvals.quality.runs.noThresholdHint") }}</span>
</div>
<div
v-if="error"
class="border-border-default bg-status-error-bg text-2xs text-status-error-text flex items-center gap-2 border-b px-3.5 py-2"
data-test="quality-runs-query-error"
>
<OIcon name="error" size="xs" class="shrink-0" />
<span>{{ t("onlineEvals.quality.runs.queryError") }}</span>
</div>
<OTable
:data="rows"
:columns="columns"
row-key="id"
pagination="server"
sorting="none"
:loading="isLoading"
:fill-height="false"
:frame="false"
:show-global-filter="false"
:page-size="pageSize"
:current-page="currentPage"
:total-count="totalCount"
:page-size-options="[10, 20, 50]"
:empty-message="emptyMessage"
:enable-column-resize="true"
:persist-columns="true"
:horizontal-scroll="true"
:row-height="56"
table-id="quality-config-scores-v1"
:row-class="runRowClass"
:get-row-status-color="runStatusColor"
data-test="quality-runs-table"
@row-click="openRun"
@pagination-change="emit('pagination-change', $event)"
>
<template #cell-timestampMs="{ row }">
<span
class="text-2xs text-text-secondary whitespace-nowrap [font-variant-numeric:tabular-nums]"
>{{ formatTimestamp(row.timestampMs) }}</span
>
</template>
<template #cell-resultDisplay="{ row }">
<div class="flex flex-col items-start gap-0.5 leading-tight">
<span
class="font-semibold [font-variant-numeric:tabular-nums]"
:class="row.isUnhealthy ? 'text-status-error-text' : 'text-text-heading'"
>
{{ row.resultDisplay }}
</span>
<span
v-if="row.health !== 'unclassified'"
class="text-3xs"
:class="row.isUnhealthy ? 'text-status-error-text' : 'text-status-success-text'"
>
{{ t(`onlineEvals.quality.runs.health.${row.health}`) }}
</span>
</div>
</template>
<template #cell-reasoning="{ row }">
<span class="block max-w-full truncate" :title="reasoningFor(row)">
{{ reasoningFor(row) }}
</span>
</template>
<template #cell-targetId="{ row }">
<div class="flex min-w-0 flex-col items-start gap-0.5 leading-tight">
<span class="text-3xs text-text-tertiary font-medium tracking-wide uppercase">
{{ scopeLabel(row.targetScope) }}
</span>
<span
class="text-3xs text-text-heading max-w-full truncate font-mono"
:title="targetIdentity(row)"
>
{{ shortId(targetIdentity(row)) }}
</span>
</div>
</template>
<template #cell-agentName="{ row }">
<div class="flex min-w-0 flex-col items-start gap-0.5 leading-tight">
<span class="max-w-full truncate" :title="agentLabel(row)">
{{ agentLabel(row) }}
</span>
<!-- id + badges share the second line, matching how OAgentBadges sits
inline everywhere else it's used — stacking the badges as their
own third line was what made this cell read as cramped. -->
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
<span
v-if="row.agentName && row.agentId"
class="text-3xs text-text-tertiary max-w-full truncate font-mono"
:title="row.agentId"
>
{{ shortId(row.agentId) }}
</span>
<OAgentBadges
:env="row.agentEnv"
:version="row.agentVersion"
data-test="quality-runs-agent-badges"
/>
</div>
</div>
</template>
</OTable>
</section>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useI18nTyped } from "@/types/i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import OAgentBadges from "@/components/shared/OAgentBadges.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import type { ScoreConfig } from "@/services/online-evals.service";
import type {
QualityRunFilter,
QualityRunRow,
QualityRunsPagination,
} from "../composables/useQualityRuns";
import { thresholdForConfig } from "../utils/scoreThreshold";
const props = defineProps<{
config: ScoreConfig;
rows: QualityRunRow[];
activeFilter: QualityRunFilter;
currentPage: number;
pageSize: number;
totalCount: number;
isLoading: boolean;
error: string | null;
}>();
const emit = defineEmits<{
(event: "open-run", row: QualityRunRow): void;
(event: "filter-change", filter: QualityRunFilter): void;
(event: "pagination-change", pagination: QualityRunsPagination): void;
}>();
const { t } = useI18nTyped();
const hasThreshold = computed(() => thresholdForConfig(props.config).unhealthyExpr != null);
const filterOptions = computed(() => [
{
id: "all" as const,
label: t("onlineEvals.quality.runs.filters.all"),
disabled: false,
},
{
id: "unhealthy" as const,
label: t("onlineEvals.quality.runs.filters.unhealthy"),
disabled: !hasThreshold.value,
},
]);
const emptyMessage = computed(() =>
props.activeFilter === "unhealthy"
? t("onlineEvals.quality.runs.emptyUnhealthy")
: t("onlineEvals.quality.runs.empty"),
);
const columns = computed(() => [
{
id: "timestampMs",
header: t("onlineEvals.quality.runs.columns.time"),
accessorKey: "timestampMs",
sortable: false,
size: 160,
meta: { align: "left" },
},
{
id: "resultDisplay",
header: t("onlineEvals.quality.runs.columns.result"),
accessorKey: "resultDisplay",
sortable: false,
size: 135,
meta: { align: "left" },
},
{
id: "reasoning",
header: t("onlineEvals.quality.runs.columns.reasoning"),
accessorKey: "reasoning",
sortable: false,
hideable: true,
size: 380,
meta: { align: "left", flex: true },
},
{
id: "targetId",
header: t("onlineEvals.quality.runs.columns.target"),
accessorKey: "targetId",
sortable: false,
hideable: true,
size: 190,
meta: { align: "left" },
},
{
id: "agentName",
header: t("onlineEvals.quality.runs.columns.agent"),
accessorKey: "agentName",
sortable: false,
hideable: true,
size: 180,
meta: { align: "left" },
},
]);
function runRowClass(row: QualityRunRow): string {
return [
canOpen(row) ? "cursor-pointer" : "",
row.isUnhealthy
? "[background:linear-gradient(90deg,color-mix(in_srgb,var(--color-status-error-bg)_85%,transparent)_0%,transparent_82%)] [&>td]:bg-transparent"
: "",
]
.filter(Boolean)
.join(" ");
}
function runStatusColor(row: QualityRunRow): string | undefined {
return row.isUnhealthy ? "var(--color-status-error-text)" : undefined;
}
function canOpen(row: QualityRunRow): boolean {
return Boolean(row.evaluatorTraceId);
}
function openRun(row: QualityRunRow) {
if (canOpen(row)) emit("open-run", row);
}
function formatTimestamp(timestampMs: number): string {
if (!timestampMs) return "—";
return new Date(timestampMs).toLocaleString();
}
function reasoningFor(row: QualityRunRow): string {
return row.reasoning || t("onlineEvals.quality.runs.noReasoning");
}
function agentLabel(row: QualityRunRow): string {
return row.agentName || row.agentId || t("onlineEvals.quality.runs.unknownAgent");
}
function scopeLabel(scope: QualityRunRow["targetScope"]): string {
if (scope === "unknown") return t("onlineEvals.quality.runs.unknownScope");
return t(`onlineEvals.quality.scopes.${scope}`);
}
function targetIdentity(row: QualityRunRow): string {
if (row.targetScope === "span") return row.targetSpanId || row.targetId;
if (row.targetScope === "trace") return row.targetTraceId || row.targetId;
if (row.targetScope === "session") return row.targetSessionId || row.targetId;
return row.targetId;
}
function shortId(value: string): string {
if (!value) return "—";
if (value.length <= 18) return value;
return `${value.slice(0, 8)}…${value.slice(-6)}`;
}
</script>
@@ -1,161 +0,0 @@
// @vitest-environment jsdom
import { defineComponent } from "vue";
import { mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
import { describe, expect, it, vi } from "vitest";
import en from "@/locales/languages/en-US.json";
import type { ScoreConfigRow } from "../composables/useQualityScoreConfigs";
import QualityScoreConfigsTable from "./QualityScoreConfigsTable.vue";
vi.mock("vue-router", () => ({
useRoute: () => ({ name: "evaluations", query: {} }),
useRouter: () => ({ push: vi.fn() }),
}));
const OTableStub = defineComponent({
name: "OTable",
props: {
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
},
template: `
<div>
<div v-for="row in data" :key="row.configId">
<slot name="cell-status" :row="row" />
</div>
</div>
`,
});
const OTagStub = defineComponent({
name: "OTag",
props: { label: { type: String, default: "" } },
template: `<span>{{ label }}</span>`,
});
function row(
overrides: Partial<ScoreConfigRow> & Pick<ScoreConfigRow, "configId" | "name" | "status">,
): ScoreConfigRow {
return {
config: { id: overrides.configId, name: overrides.name } as any,
description: "",
dataType: "numeric",
totalScores: 10,
scopeCounts: { span: 10, trace: 0, session: 0 },
qualityValue: 0.8,
qualityFormat: "number",
qualityLabel: "Average",
hasThreshold: true,
thresholdLabel: "≥ 0.70",
unhealthyCount: 0,
unhealthyPercent: 0,
lastUpdatedMs: null,
statusPriority: 1,
trendSparkline: [],
...overrides,
};
}
describe("QualityScoreConfigsTable", () => {
it("shows explicit health labels and threshold context", () => {
const i18n = createI18n({
legacy: false,
locale: "en",
messages: { en },
});
const wrapper = mount(QualityScoreConfigsTable, {
props: {
isLoading: false,
rows: [
row({
configId: "unhealthy",
name: "Unhealthy scorer",
status: "unhealthy",
unhealthyCount: 2,
unhealthyPercent: 20,
statusPriority: 0,
}),
row({
configId: "healthy",
name: "Healthy scorer",
status: "healthy",
}),
row({
configId: "no-threshold",
name: "Unclassified scorer",
status: "noThreshold",
hasThreshold: false,
thresholdLabel: "",
unhealthyCount: null,
unhealthyPercent: null,
statusPriority: 2,
}),
row({
configId: "no-data",
name: "Empty scorer",
status: "noData",
totalScores: 0,
unhealthyCount: null,
unhealthyPercent: null,
statusPriority: 3,
}),
],
},
global: {
plugins: [i18n],
stubs: {
OTable: OTableStub,
OTag: OTagStub,
OInput: true,
OIcon: true,
OEmptyState: true,
OSpinner: true,
},
},
});
expect(wrapper.text()).toContain("Needs attention");
expect(wrapper.text()).toContain("20.0% below · Healthy if ≥ 0.70");
expect(wrapper.text()).toContain("Healthy");
expect(wrapper.text()).toContain("Threshold not set");
expect(wrapper.text()).toContain("Set a healthy threshold");
expect(wrapper.text()).toContain("No score data");
expect(wrapper.text()).toContain("No scores in this window");
});
describe("column widths", () => {
const mountColumns = () => {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en } });
const wrapper = mount(QualityScoreConfigsTable, {
props: { isLoading: false, rows: [] },
global: {
plugins: [i18n],
stubs: { OTable: OTableStub, OInput: true, OIcon: true, OEmptyState: true },
},
});
return wrapper.findComponent(OTableStub).props("columns") as any[];
};
it("gives every resizable column a minimum width", () => {
const missing = mountColumns()
.filter((column) => !column.minSize)
.map((column) => column.id);
expect(missing).toEqual([]);
});
it("keeps the Type column wide enough for the Categorical badge", () => {
const type = mountColumns().find((column) => column.id === "type");
expect(type.minSize).toBeGreaterThanOrEqual(104);
});
it("opens Total Scores wide enough that its header is not truncated", () => {
const totalScores = mountColumns().find((column) => column.id === "totalScores");
expect(totalScores.minSize).toBeGreaterThanOrEqual(112);
expect(totalScores.size).toBeGreaterThanOrEqual(totalScores.minSize);
});
});
});
@@ -1,399 +0,0 @@
<template>
<section class="flex min-h-0 flex-1 flex-col gap-2.5" data-test="quality-score-configs-overview">
<!-- The previous "waiting for scores" empty card (allZeroScores) is
intentionally gone: as long as at least one score config exists,
we render the table so the user sees the configured shapes. Each
row already handles the no-data case (status === 'noData' → "—")
so a fresh setup reads as "configs are here, scores will fill in"
rather than a blank screen. -->
<div class="flex min-h-0 flex-1 flex-col">
<OTable
data-test="quality-overview-table"
:data="filteredRows"
:columns="columns"
row-key="configId"
:loading="isLoading"
:show-global-filter="false"
:page-size="20"
:page-size-options="[20, 50, 100, 250, 500]"
:default-columns="false"
:enable-column-resize="true"
:persist-columns="true"
:column-visibility="defaultColumnVisibility"
table-id="quality-score-configs-v2"
width="100%"
class="h-full w-full"
@row-click="(row: any) => $emit('select', row)"
>
<!-- Filter moved into the table toolbar so OTable's column chooser
("Manage columns") renders next to it, matching the eval lists. -->
<template #toolbar>
<OSearchInput
v-model="filter"
:placeholder="t('onlineEvals.quality.overview.searchPlaceholder')"
size="sm"
class="min-w-0 flex-1"
data-test="quality-overview-filter-input"
/>
</template>
<template #toolbar-trailing>
<OButton
variant="outline"
size="icon-sm"
icon-left="refresh"
:loading="isLoading"
data-test="quality-overview-refresh-btn"
@click="emit('refresh')"
>
<OTooltip
side="bottom"
:content="t('common.refresh')"
shortcut-id="qualityOverviewRefresh"
/>
</OButton>
</template>
<template #empty>
<div class="flex items-center justify-center py-8" data-test="quality-overview-empty">
<OEmptyState
size="hero"
preset="no-score-configs"
:filtered="Boolean(filter.trim())"
@action="onEmptyAction"
/>
</div>
</template>
<template #cell-status="{ row }">
<div
class="flex min-w-0 flex-col items-start gap-0.5 leading-tight"
data-test="quality-overview-health"
>
<OTag
type="qualityStatus"
:value="row.status"
:label="t(`onlineEvals.quality.overview.status.${row.status}`)"
:aria-label="t(`onlineEvals.quality.overview.status.${row.status}`)"
/>
<OTruncatedText class="text-3xs text-text-secondary max-w-full">
{{ healthSummary(row) }}
</OTruncatedText>
</div>
</template>
<template #cell-name="{ row }">
<div class="text-text-heading font-semibold">
{{ row.name }}
</div>
</template>
<template #cell-type="{ row }">
<OTag v-if="row.dataType !== 'unknown'" type="evalDataType" :value="row.dataType" />
<span v-else class="text-text-secondary">—</span>
</template>
<template #cell-totalScores="{ row }">
<span class="[font-variant-numeric:tabular-nums]">{{
formatCount(row.totalScores)
}}</span>
</template>
<template #cell-quality="{ row }">
<div
v-if="row.qualityValue != null"
class="flex flex-col leading-tight"
data-test="quality-overview-quality"
>
<span class="font-semibold [font-variant-numeric:tabular-nums]">
{{ formatQuality(row) }}
</span>
<span
v-if="row.qualityLabel"
class="text-3xs text-text-secondary mt-0.5 leading-tight font-normal whitespace-normal"
>
{{ row.qualityLabel }}
</span>
</div>
<span v-else class="text-text-secondary">—</span>
</template>
<template #cell-scopeMix="{ row }">
<div
v-if="scopeItems(row).length > 0"
class="flex flex-wrap gap-1"
data-test="quality-overview-scope-mix"
>
<span
v-for="scope in scopeItems(row)"
:key="scope.id"
class="border-border-default bg-surface-base text-3xs text-text-secondary inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 leading-none [font-variant-numeric:tabular-nums]"
:data-test="`quality-overview-scope-${scope.id}`"
>
<span>{{ t(`onlineEvals.quality.scopes.${scope.id}`) }}</span>
<span class="text-text-heading font-semibold">{{ formatCount(scope.count) }}</span>
</span>
</div>
<span v-else class="text-text-secondary">—</span>
</template>
<template #cell-volumeTrend="{ row }">
<svg
v-if="row.trendSparkline.length > 0"
class="h-5 w-full"
:class="sparkClass(row.status)"
viewBox="0 0 100 20"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline
fill="none"
stroke="currentColor"
stroke-width="1.4"
:points="sparkPoints(row.trendSparkline)"
/>
</svg>
<span v-else class="text-text-secondary">—</span>
</template>
<template #cell-updated="{ row }">
<span
v-if="row.lastUpdatedMs"
class="text-2xs text-text-secondary [font-variant-numeric:tabular-nums]"
>
{{ relativeTime(row.lastUpdatedMs) }}
</span>
<span v-else class="text-text-secondary">—</span>
</template>
</OTable>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18nTyped } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { COL } from "@/lib/core/Table/OTable.types";
import { useRoute, useRouter } from "vue-router";
import type { ScoreConfigRow } from "../composables/useQualityScoreConfigs";
const props = defineProps<{
rows: ScoreConfigRow[];
isLoading: boolean;
}>();
const emit = defineEmits<{
(e: "select", row: ScoreConfigRow): void;
(e: "refresh"): void;
}>();
const { t } = useI18nTyped();
const route = useRoute();
const router = useRouter();
const filter = ref("");
// The "Create score config" CTA in the empty state can't be handled inline
// (the create dialog lives in the Score Configs tab's OnlineEvals shell).
// We hop tabs + set `action=add` so OnlineEvals's URL→state machine opens
// the create form on landing.
function onEmptyAction(id?: string) {
if (id === "clear-filters") {
filter.value = "";
return;
}
if (id !== "create") return;
const query: Record<string, any> = {
...route.query,
tab: "scoreConfigs",
action: "add",
};
delete query.config;
router.push({ name: route.name as string, query }).catch(() => {});
}
const filteredRows = computed(() => {
const q = filter.value.trim().toLowerCase();
if (!q) return props.rows;
return props.rows.filter(
(r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q),
);
});
function sparkClass(status: string): string {
if (status === "unhealthy" || status === "warn") return "text-status-warning-text";
if (status === "healthy") return "text-status-success-text";
if (status === "noData") return "text-text-secondary opacity-[0.55]";
return "text-text-secondary";
}
const defaultColumnVisibility = {
status: true,
name: true,
type: true,
quality: true,
totalScores: true,
scopeMix: true,
volumeTrend: true,
updated: true,
};
const columns = computed(() =>
[
{
id: "status",
header: t("onlineEvals.quality.overview.columns.health"),
accessorFn: (row: ScoreConfigRow) => row.statusPriority,
sortable: true,
size: 190,
minSize: 136,
meta: { align: "left" },
},
{
id: "name",
header: t("onlineEvals.quality.overview.columns.scoreConfig"),
accessorKey: "name",
sortable: true,
minSize: 200,
size: COL.name,
// `flex` (not `autoWidth`): fills leftover width AND stays resizable.
meta: { align: "left", flex: true },
},
{
id: "type",
header: t("onlineEvals.quality.overview.columns.type"),
accessorKey: "dataType",
sortable: true,
size: COL.type,
// Fits the widest badge ("Categorical") so resizing can't clip it.
minSize: 104,
meta: { align: "left" },
},
{
id: "quality",
header: t("onlineEvals.quality.overview.columns.quality"),
accessorKey: "qualityValue",
sortable: true,
size: 120,
minSize: 88,
meta: { align: "right" },
},
{
id: "totalScores",
header: t("onlineEvals.quality.overview.columns.totalScores"),
accessorKey: "totalScores",
sortable: true,
size: 120,
minSize: 112,
meta: { align: "right" },
},
{
id: "scopeMix",
header: t("onlineEvals.quality.overview.columns.scopeMix"),
sortable: false,
size: 240,
minSize: 96,
meta: { align: "left" },
},
{
id: "volumeTrend",
header: t("onlineEvals.quality.overview.columns.volumeTrend"),
sortable: false,
size: 120,
minSize: 104,
meta: { align: "left" },
},
{
id: "updated",
header: t("onlineEvals.quality.overview.columns.updated"),
accessorKey: "lastUpdatedMs",
sortable: true,
size: COL.date,
minSize: 112,
meta: { align: "left" },
},
].map((c: any) => ({
...c,
// Offer every column except the name and the leading status dot in the
// "Manage columns" chooser.
hideable: c.id !== "name" && c.id !== "status",
})),
);
function formatCount(n: number | null): string {
if (n == null) return "—";
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(Math.round(n));
}
function formatQuality(row: ScoreConfigRow): string {
const value = row.qualityValue;
if (value == null) return "—";
if (row.qualityFormat === "text" || typeof value === "string") {
return String(value);
}
if (row.qualityFormat === "percent") return `${value.toFixed(1)}%`;
return value.toFixed(2);
}
function healthSummary(row: ScoreConfigRow): string {
if (row.status === "noData") {
return t("onlineEvals.quality.overview.health.noDataHint");
}
if (!row.hasThreshold) {
return t("onlineEvals.quality.overview.health.noThresholdHint");
}
return t("onlineEvals.quality.overview.health.thresholdSummary", {
percent: (row.unhealthyPercent ?? 0).toFixed(1),
threshold: row.thresholdLabel,
});
}
function scopeItems(row: ScoreConfigRow) {
return (["span", "trace", "session"] as const)
.map((id) => ({ id, count: row.scopeCounts[id] }))
.filter((scope) => scope.count > 0);
}
function sparkPoints(series: number[]): string {
if (!series || series.length === 0) return "";
const points = series.length === 1 ? [series[0], series[0]] : series;
const min = Math.min(...points);
const max = Math.max(...points);
const range = max - min || 1;
return points
.map((value, idx) => {
const x = (idx / (points.length - 1)) * 100;
const y = 18 - ((value - min) / range) * 14;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
}
function relativeTime(timestampMs: number): string {
const diff = Date.now() - timestampMs;
if (diff < 0) return t("refreshButton.justNow");
const sec = Math.floor(diff / 1000);
if (sec < 60) return t("refreshButton.secondsAgo", { sec });
const min = Math.floor(sec / 60);
if (min < 60) return t("refreshButton.minutesAgo", { min });
const hr = Math.floor(min / 60);
if (hr < 24) return t("refreshButton.hoursAgo", { h: hr });
const day = Math.floor(hr / 24);
if (day < 30) {
return t("onlineEvals.quality.overview.relativeTime.daysAgo", { day });
}
const mo = Math.floor(day / 30);
return t("onlineEvals.quality.overview.relativeTime.monthsAgo", {
month: mo,
});
}
</script>
@@ -0,0 +1,513 @@
// @vitest-environment jsdom
import { defineComponent } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import store from "@/test/unit/helpers/store";
import { withQueryClient } from "@/test/unit/helpers/queryClient";
import type { QualityScore } from "@/services/online-evals.service";
import QualityScorePane from "./QualityScorePane.vue";
const { push, search } = vi.hoisted(() => ({
push: vi.fn(() => Promise.resolve()),
search: vi.fn(),
}));
vi.mock("@/services/search", () => ({ default: { search } }));
// The real box renders; only the heavy content renderer is replaced.
vi.mock("@/plugins/traces/LLMContentRenderer.vue", () => ({
default: {
name: "LLMContentRenderer",
props: ["content"],
template: `<div class="llm">{{ content }}</div>`,
},
}));
vi.mock("vue-router", () => ({
useRoute: () => ({ name: "aiEvaluations", query: {} }),
useRouter: () => ({ push }),
}));
const score = (overrides: Partial<QualityScore> = {}): QualityScore => ({
id: "s1",
timestamp: 1791199632199574,
refTimestamp: 1791199600000000,
sourceType: "llm_judge",
targetScope: "trace",
targetId: "70d86ea9c0ffee",
spanId: null,
traceId: "70d86ea9c0ffee",
sessionId: null,
sourceStream: "default",
sourceStreamType: "traces",
value: "medium",
unhealthy: true,
reasoning: "The answer ignores the format the user asked for.",
evaluatorTraceId: "eval-trace-1",
taskId: null,
inputPreview: "Write VRL to parse JSON from my nginx logs.",
outputPreview: null,
inputSpanId: "span-in",
outputSpanId: "span-out",
...overrides,
});
const DrawerStub = defineComponent({
name: "AddToDatasetDrawer",
props: ["open", "orgId", "refType", "refId", "sourceStream", "refTraceStartTime", "inputPreview"],
emits: ["update:open"],
template: `<div data-test="dataset-drawer-stub" />`,
});
const hits = (spans: Record<string, unknown>[]) => Promise.resolve({ data: { hits: spans } });
const span = (id: string, input: string, output: string) => ({
span_id: id,
gen_ai_input_messages: input,
gen_ai_output_messages: output,
});
/** The one search request the pane sent: its SQL and window. */
const sent = (call = 0) => {
const [options] = search.mock.calls[call];
return {
sql: options.query.query.sql,
start_time: options.query.query.start_time,
end_time: options.query.query.end_time,
page_type: options.page_type,
};
};
// A minute before the target time, up to the score write.
const WINDOW = { start_time: 1791199600000000 - 60_000_000, end_time: 1791199632199574 };
const io = (wrapper: ReturnType<typeof mountPane>) =>
wrapper.findAll('[data-test="quality-score-pane-io"] .llm').map((box) => box.text());
const skeletons = (wrapper: ReturnType<typeof mountPane>) =>
(["input", "output"] as const).filter((side) =>
wrapper.find(`[data-test="ai-review-content-skeleton-${side}"]`).exists(),
);
const unavailable = (wrapper: ReturnType<typeof mountPane>) =>
wrapper.find('[data-test="quality-score-pane-full-unavailable"]');
function mountPane(props: Record<string, unknown> = {}) {
return mount(QualityScorePane, {
props: {
score: score(),
thresholdLabel: "high",
canPrev: false,
canNext: true,
loading: false,
...props,
},
global: { plugins: [store, withQueryClient()], stubs: { AddToDatasetDrawer: DrawerStub } },
});
}
describe("QualityScorePane", () => {
beforeEach(() => {
vi.spyOn(window, "open").mockImplementation(() => null);
push.mockClear();
search.mockReset().mockImplementation(() => hits([]));
});
it("shows the health and the rule, but no target link, value or time", () => {
const header = mountPane().find('[data-test="quality-score-pane-header"]');
expect(header.text()).toContain("Unhealthy");
expect(header.find('[data-test="quality-score-pane-rule"]').text()).toBe("Healthy if high");
expect(header.find('[data-test="quality-score-pane-target"]').exists()).toBe(false);
expect(header.text()).not.toContain("70d86ea9");
expect(header.text()).not.toContain("Trace");
expect(header.text()).not.toContain("medium");
expect(header.text()).not.toMatch(/\d{4}-\d{2}-\d{2}|ago/);
});
it("says when no threshold is set, without a health tag", () => {
const header = mountPane({
score: score({ unhealthy: null }),
thresholdLabel: "",
}).find('[data-test="quality-score-pane-header"]');
expect(header.find('[data-test="quality-score-pane-health"]').exists()).toBe(false);
expect(header.text()).toContain("No healthy threshold set");
});
it("shows Input and Output side by side, with the missing output said inside its box", async () => {
// A score without span ids keeps its preview.
const wrapper = mountPane({
score: score({ inputSpanId: null, outputSpanId: null }),
});
await flushPromises();
const io = wrapper.find('[data-test="quality-score-pane-io"]');
expect(io.classes()).toEqual(expect.arrayContaining(["flex-1", "md:flex-row"]));
expect(io.find(".llm").text()).toBe("Write VRL to parse JSON from my nginx logs.");
expect(io.find('[data-test="ai-review-content-empty-output"]').text()).toBe(
"Output not captured",
);
expect(io.find('[data-test="ai-review-content-empty-input"]').exists()).toBe(false);
});
it("shows the reasoning in a bordered collapsible block, open by default", async () => {
const wrapper = mountPane();
await flushPromises();
const block = wrapper.find('[data-test="quality-score-pane-reasoning"]');
expect(block.classes()).toEqual(
expect.arrayContaining(["border", "border-border-default", "rounded-default"]),
);
expect(block.find("button").text()).toBe("Reasoning");
expect(block.find('[data-test="quality-score-pane-reasoning-text"]').text()).toBe(
"The answer ignores the format the user asked for.",
);
});
it("scrolls the body between the header and the actions, so the reasoning stays readable on a short window", () => {
expect(mountPane().find('[data-test="quality-score-pane-body"]').classes()).toEqual(
expect.arrayContaining(["min-h-0", "flex-1", "overflow-y-auto"]),
);
});
it("folds and unfolds the reasoning, and keeps it folded across scores", async () => {
const wrapper = mountPane();
await flushPromises();
const toggle = () =>
wrapper.find('[data-test="quality-score-pane-reasoning"] button').trigger("click");
await toggle();
expect(wrapper.find('[data-test="quality-score-pane-reasoning-text"]').exists()).toBe(false);
await wrapper.setProps({ score: score({ id: "s2", reasoning: "Second reasoning." }) });
expect(wrapper.text()).not.toContain("Second reasoning.");
await toggle();
await flushPromises();
expect(wrapper.find('[data-test="quality-score-pane-reasoning-text"]').text()).toBe(
"Second reasoning.",
);
});
it("says so inside the reasoning block when the judge gave none", async () => {
const wrapper = mountPane({ score: score({ reasoning: null }) });
await flushPromises();
expect(wrapper.find('[data-test="quality-score-pane-no-reasoning"]').text()).toBe(
"No reasoning recorded",
);
});
it("opens the target and the evaluator trace from the footer in the same tab", async () => {
const wrapper = mountPane();
const trace = {
name: "traceDetails",
query: {
org_identifier: "default",
from: 1791199600000000 - 3_600_000_000,
to: 1791199600000000 + 3_600_000_000,
stream: "default",
trace_id: "70d86ea9c0ffee",
},
};
await wrapper.find('[data-test="quality-score-pane-open-target"]').trigger("click");
expect(push).toHaveBeenLastCalledWith(trace);
await wrapper.find('[data-test="quality-score-pane-open-evaluator"]').trigger("click");
expect(push).toHaveBeenLastCalledWith({
name: "traceDetails",
query: {
org_identifier: "default",
stream: "_evaluator",
trace_id: "eval-trace-1",
from: 1791199632199574 - 3_600_000_000,
to: 1791199632199574 + 3_600_000_000,
},
});
expect(window.open).not.toHaveBeenCalled();
expect(wrapper.find('[data-test="quality-score-pane-open-target"]').text()).toBe("Open trace");
});
it("opens a session score on the session page in the same tab", async () => {
const wrapper = mountPane({
score: score({
targetScope: "session",
sessionId: "sess-1",
traceId: null,
targetId: "sess-1",
}),
});
await wrapper.find('[data-test="quality-score-pane-open-target"]').trigger("click");
expect(push).toHaveBeenLastCalledWith(
expect.objectContaining({
name: "sessionDetails",
query: expect.objectContaining({ session_id: "sess-1" }),
}),
);
expect(wrapper.find('[data-test="quality-score-pane-open-target"]').text()).toBe(
"Open session",
);
});
it("hides an action whose link cannot be built", () => {
const wrapper = mountPane({ score: score({ evaluatorTraceId: null }) });
expect(wrapper.find('[data-test="quality-score-pane-open-evaluator"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-score-pane-open-target"]').exists()).toBe(true);
});
it("adds a trace score to a dataset in a drawer on the page, without an expected output", async () => {
const wrapper = mountPane();
expect(wrapper.findComponent(DrawerStub).exists()).toBe(false);
const button = wrapper.find('[data-test="quality-score-pane-add-to-dataset"]');
expect(button.text()).toBe("Add to dataset");
expect(button.attributes("disabled")).toBeUndefined();
await button.trigger("click");
const drawer = wrapper.findComponent(DrawerStub);
expect(drawer.props()).toEqual({
open: true,
orgId: "default",
refType: "trace",
refId: "70d86ea9c0ffee",
sourceStream: "default",
refTraceStartTime: 1791199600000000,
inputPreview: undefined,
});
expect(push).not.toHaveBeenCalled();
expect(window.open).not.toHaveBeenCalled();
// A closed drawer unmounts, so the next open starts from the score then selected.
drawer.vm.$emit("update:open", false);
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(DrawerStub).exists()).toBe(false);
});
it("adds a span score by its span id", async () => {
const wrapper = mountPane({
score: score({ targetScope: "span", targetId: "span-9", spanId: "span-9" }),
});
await wrapper.find('[data-test="quality-score-pane-add-to-dataset"]').trigger("click");
expect(wrapper.findComponent(DrawerStub).props()).toEqual(
expect.objectContaining({
refType: "span",
refId: "span-9",
sourceStream: "default",
refTraceStartTime: 1791199600000000,
}),
);
});
it("shows Add to dataset disabled with the reason for a session score", async () => {
const wrapper = mountPane({
score: score({ targetScope: "session", sessionId: "sess-1", targetId: "sess-1" }),
});
const button = wrapper.find('[data-test="quality-score-pane-add-to-dataset"]');
const reason =
"Datasets take a single trace or span. Open the session to add one of its traces.";
// Focusable while unavailable, so keyboard and screen reader users reach the reason.
expect(button.attributes("disabled")).toBeUndefined();
expect(button.attributes("aria-disabled")).toBe("true");
const describedBy = button.attributes("aria-describedby");
expect(describedBy).toBeTruthy();
expect(wrapper.find(`[id="${describedBy}"]`).text()).toBe(reason);
expect(button.findComponent({ name: "OTooltip" }).props("content")).toBe(reason);
await button.trigger("click");
expect(wrapper.findComponent(DrawerStub).exists()).toBe(false);
});
it("disables Add to dataset when the score has no source stream", async () => {
const wrapper = mountPane({ score: score({ sourceStream: null }) });
const button = wrapper.find('[data-test="quality-score-pane-add-to-dataset"]');
expect(button.attributes("aria-disabled")).toBe("true");
expect(button.findComponent({ name: "OTooltip" }).props("content")).toBe(
"This score has no source stream to add from.",
);
await button.trigger("click");
expect(wrapper.findComponent(DrawerStub).exists()).toBe(false);
});
it("shows skeletons in both boxes while the span loads, then the span score's full text", async () => {
let resolve: (value: unknown) => void = () => {};
search.mockReturnValue(new Promise((done) => (resolve = done)));
const wrapper = mountPane({
score: score({
targetScope: "span",
spanId: "span-2",
targetId: "span-2",
inputSpanId: "span-2",
outputSpanId: "span-2",
}),
});
await flushPromises();
expect(search).toHaveBeenCalledTimes(1);
expect(sent()).toEqual({
sql: `SELECT * FROM "default" WHERE span_id IN ('span-2')`,
...WINDOW,
page_type: "traces",
});
// While loading: a skeleton in each box and in the reasoning, the frames kept, no preview and no note.
expect(skeletons(wrapper)).toEqual(["input", "output"]);
expect(wrapper.find('[data-test="quality-score-pane-reasoning-skeleton"]').exists()).toBe(true);
expect(wrapper.text()).not.toContain("The answer ignores the format");
expect(io(wrapper)).toEqual([]);
expect(wrapper.text()).not.toContain("Write VRL to parse JSON");
expect(wrapper.find('[data-test="ai-review-content-copy-input"]').exists()).toBe(true);
expect(unavailable(wrapper).exists()).toBe(false);
resolve({
data: {
hits: [
span(
"span-2",
"Write VRL to parse JSON from my nginx logs. Full prompt.",
"parse_json!(.message)",
),
],
},
});
await flushPromises();
expect(io(wrapper)).toEqual([
"Write VRL to parse JSON from my nginx logs. Full prompt.",
"parse_json!(.message)",
]);
expect(unavailable(wrapper).exists()).toBe(false);
expect(skeletons(wrapper)).toEqual([]);
expect(wrapper.find('[data-test="quality-score-pane-reasoning-text"]').text()).toBe(
"The answer ignores the format the user asked for.",
);
});
it("reads a session's input and output from the two spans its scorer recorded, in one query", async () => {
search.mockImplementation(() =>
hits([
span("last-turn", "Last question", "Last answer"),
span("first-turn", "First question", "First answer"),
]),
);
const wrapper = mountPane({
score: score({
targetScope: "session",
sessionId: "sess-1",
targetId: "sess-1",
traceId: null,
sourceStream: "agents",
inputSpanId: "first-turn",
outputSpanId: "last-turn",
}),
});
await flushPromises();
expect(search).toHaveBeenCalledTimes(1);
expect(sent().sql).toBe(`SELECT * FROM "agents" WHERE span_id IN ('first-turn', 'last-turn')`);
expect(io(wrapper)).toEqual(["First question", "Last answer"]);
expect(unavailable(wrapper).exists()).toBe(false);
});
it("sends no query and keeps the preview with the note when the score has no span ids", async () => {
const wrapper = mountPane({ score: score({ inputSpanId: null, outputSpanId: null }) });
await flushPromises();
expect(search).not.toHaveBeenCalled();
expect(io(wrapper)).toEqual(["Write VRL to parse JSON from my nginx logs."]);
expect(skeletons(wrapper)).toEqual([]);
expect(unavailable(wrapper).text()).toBe(
"Showing a short preview. The full trace could not be loaded.",
);
});
it("reads the side with a span id and keeps the preview on the side without one", async () => {
search.mockImplementation(() => hits([span("span-in", "Full question", "ignored")]));
const wrapper = mountPane({
score: score({ outputSpanId: null, outputPreview: "Short answer" }),
});
await flushPromises();
expect(sent().sql).toBe(`SELECT * FROM "default" WHERE span_id IN ('span-in')`);
expect(io(wrapper)).toEqual(["Full question", "Short answer"]);
expect(unavailable(wrapper).exists()).toBe(true);
});
it("escapes quotes in a span id", async () => {
mountPane({ score: score({ inputSpanId: "a'b", outputSpanId: null }) });
await flushPromises();
expect(sent().sql).toBe(`SELECT * FROM "default" WHERE span_id IN ('a''b')`);
});
it("escapes quotes in the stream name and searches the score's own stream type", async () => {
mountPane({
score: score({ sourceStream: 'odd"name', sourceStreamType: "logs", outputSpanId: null }),
});
await flushPromises();
expect(sent().sql).toBe(`SELECT * FROM "odd""name" WHERE span_id IN ('span-in')`);
expect(sent().page_type).toBe("logs");
});
it("keeps the preview and says the full text is not available when the query fails or finds no span", async () => {
search.mockRejectedValue(new Error("boom"));
const failed = mountPane();
await flushPromises();
expect(io(failed)).toEqual(["Write VRL to parse JSON from my nginx logs."]);
expect(unavailable(failed).text()).toBe(
"Showing a short preview. The full trace could not be loaded.",
);
expect(skeletons(failed)).toEqual([]);
search.mockReset().mockImplementation(() => hits([]));
const empty = mountPane({ score: score({ id: "s2", inputSpanId: "gone" }) });
await flushPromises();
expect(io(empty)).toEqual(["Write VRL to parse JSON from my nginx logs."]);
expect(unavailable(empty).exists()).toBe(true);
});
it("does not query without a source stream", async () => {
const wrapper = mountPane({ score: score({ sourceStream: null }) });
await flushPromises();
expect(search).not.toHaveBeenCalled();
expect(io(wrapper)).toEqual(["Write VRL to parse JSON from my nginx logs."]);
expect(unavailable(wrapper).exists()).toBe(true);
});
it("reads each score's spans once, coming back included", async () => {
search.mockImplementation(() =>
hits([span("span-in", "First question", "x"), span("span-2", "Span question", "y")]),
);
const wrapper = mountPane();
await flushPromises();
expect(io(wrapper)[0]).toBe("First question");
await wrapper.setProps({
score: score({ id: "s2", inputSpanId: "span-2", outputSpanId: "span-2" }),
});
await flushPromises();
expect(io(wrapper)[0]).toBe("Span question");
await wrapper.setProps({ score: score() });
await flushPromises();
expect(io(wrapper)[0]).toBe("First question");
expect(search).toHaveBeenCalledTimes(2);
});
it("keeps the frame and shows skeletons in the header, both boxes and the reasoning while the scores page loads", async () => {
const wrapper = mountPane({ score: null, loading: true, canNext: false });
await flushPromises();
expect(wrapper.find('[data-test="quality-score-pane-empty"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-score-pane-header-skeleton"]').exists()).toBe(true);
expect(wrapper.find('[data-test="quality-score-pane-health"]').exists()).toBe(false);
expect(skeletons(wrapper)).toEqual(["input", "output"]);
const reasoning = wrapper.find('[data-test="quality-score-pane-reasoning"]');
expect(reasoning.find('[data-test="quality-score-pane-reasoning-skeleton"]').exists()).toBe(
true,
);
expect(reasoning.text()).not.toContain("No reasoning recorded");
expect(wrapper.find('[data-test="quality-score-pane-add-to-dataset"]').exists()).toBe(false);
expect(search).not.toHaveBeenCalled();
// The page lands: the header shows, and the boxes and the reasoning wait for the full text.
await wrapper.setProps({ score: score(), loading: false });
expect(wrapper.find('[data-test="quality-score-pane-header-skeleton"]').exists()).toBe(false);
expect(wrapper.find('[data-test="quality-score-pane-reasoning-skeleton"]').exists()).toBe(true);
expect(skeletons(wrapper)).toEqual(["input", "output"]);
await flushPromises();
expect(skeletons(wrapper)).toEqual([]);
expect(wrapper.find('[data-test="quality-score-pane-reasoning-skeleton"]').exists()).toBe(
false,
);
expect(wrapper.find('[data-test="quality-score-pane-reasoning-text"]').exists()).toBe(true);
});
it("emits previous and next, and disables what cannot move", async () => {
const wrapper = mountPane();
expect(
wrapper.find('[data-test="quality-score-pane-prev"]').attributes("disabled"),
).toBeDefined();
await wrapper.find('[data-test="quality-score-pane-next"]').trigger("click");
expect(wrapper.emitted("next")).toHaveLength(1);
});
it("shows a placeholder without a selected score", () => {
const wrapper = mountPane({ score: null });
expect(wrapper.find('[data-test="quality-score-pane-empty"]').text()).toContain(
"No score selected",
);
});
});
@@ -0,0 +1,334 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<section class="flex min-h-0 min-w-0 flex-1 flex-col" data-test="quality-score-pane">
<div
v-if="!score && !loading"
class="text-text-secondary flex flex-1 items-center justify-center p-6 text-sm"
data-test="quality-score-pane-empty"
>
{{ t("onlineEvals.quality.pane.noneSelected") }}
</div>
<template v-else>
<!-- While the scores page loads there is no score yet: the frame stays and each part shows a skeleton. -->
<!-- The selected row shows the value, input and time, and the footer opens the target, so the header shows only the health and rule. -->
<header
class="border-border-default flex shrink-0 items-center gap-3 border-b px-3 py-2"
data-test="quality-score-pane-header"
>
<div v-if="score" class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<OTag
v-if="score.unhealthy != null"
type="qualityStatus"
:value="score.unhealthy ? 'attention' : 'healthy'"
:label="
score.unhealthy
? t('onlineEvals.quality.pane.unhealthy')
: t('onlineEvals.quality.pane.healthy')
"
data-test="quality-score-pane-health"
/>
<span class="text-text-secondary text-xs" data-test="quality-score-pane-rule">{{
thresholdLabel
? t("onlineEvals.quality.healthyIf", { rule: thresholdLabel })
: t("onlineEvals.quality.pane.noThreshold")
}}</span>
</div>
<div
v-else
class="flex min-w-0 flex-1 items-center gap-2"
data-test="quality-score-pane-header-skeleton"
>
<OSkeleton type="text" class="h-5 w-20" />
<OSkeleton type="text" class="h-3 w-32" />
</div>
<div class="flex shrink-0 items-center gap-1">
<OButton
variant="ghost"
size="icon-sm"
icon-left="chevron-left"
:disabled="!canPrev"
:title="t('onlineEvals.quality.pane.previous')"
data-test="quality-score-pane-prev"
@click="emit('prev')"
/>
<OButton
variant="ghost"
size="icon-sm"
icon-left="chevron-right"
:disabled="!canNext"
:title="t('onlineEvals.quality.pane.next')"
data-test="quality-score-pane-next"
@click="emit('next')"
/>
</div>
</header>
<!-- Scrolls on a short window, so the reasoning never slides under the action bar. -->
<div
class="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3"
data-test="quality-score-pane-body"
>
<!-- Input and Output side by side, filling the pane; fullscreen takes both, as on the queue workbench. -->
<div
ref="ioContainer"
class="[&:fullscreen]:bg-surface-base flex min-h-48 min-w-0 flex-1 flex-col gap-4 md:flex-row md:gap-3 [&:fullscreen]:p-4"
data-test="quality-score-pane-io"
>
<ReviewContentBox
class="min-w-0 md:flex-1"
fill
:label="t('aiObservability.queues.workbench.input')"
:content="inputText"
:loading="contentLoading"
content-type="input"
:instance-id="`quality-${score?.id}-input`"
:empty-text="t('onlineEvals.quality.review.inputNotCaptured')"
:fullscreen="fullscreenEl === ioContainer"
@toggle-fullscreen="toggleIoFullscreen"
/>
<ReviewContentBox
class="min-w-0 md:flex-1"
fill
:label="t('aiObservability.queues.workbench.output')"
:content="outputText"
:loading="contentLoading"
content-type="output"
:instance-id="`quality-${score?.id}-output`"
:empty-text="t('onlineEvals.quality.review.outputNotCaptured')"
:fullscreen="fullscreenEl === ioContainer"
@toggle-fullscreen="toggleIoFullscreen"
/>
</div>
<p
v-if="fullTextUnavailable"
class="text-text-secondary m-0 -mt-1.5 shrink-0 text-xs"
data-test="quality-score-pane-full-unavailable"
>
{{ t("onlineEvals.quality.pane.fullTextUnavailable") }}
</p>
<OCollapsible
v-model="reasoningOpen"
variant="sidebar"
class="border-border-default rounded-default shrink-0 overflow-hidden border"
data-test="quality-score-pane-reasoning"
>
<template #trigger>
<OIcon name="lightbulb-outline" size="xs" class="text-text-secondary shrink-0" />
<span class="text-text-secondary text-xs font-semibold">{{
t("onlineEvals.quality.pane.reasoning")
}}</span>
</template>
<!-- Waits for the input and output too, so the reasoning never shows before what it judges. -->
<div
v-if="contentLoading"
class="flex flex-col gap-2 px-3 pb-2"
data-test="quality-score-pane-reasoning-skeleton"
>
<OSkeleton type="text" class="h-3 w-full" />
<OSkeleton type="text" class="h-3 w-3/4" />
</div>
<p
v-else-if="score?.reasoning"
class="text-text-body m-0 max-h-40 overflow-y-auto px-3 pb-2 text-xs leading-relaxed whitespace-pre-wrap"
data-test="quality-score-pane-reasoning-text"
>
{{ score.reasoning }}
</p>
<p
v-else
class="text-text-secondary m-0 px-3 pb-2 text-xs italic"
data-test="quality-score-pane-no-reasoning"
>
{{ t("onlineEvals.quality.pane.noReasoning") }}
</p>
</OCollapsible>
</div>
<footer
class="border-border-default flex shrink-0 flex-wrap items-center gap-2 border-t px-3 py-2"
data-test="quality-score-pane-actions"
>
<template v-if="loading">
<OSkeleton type="text" class="h-6 w-24" />
<OSkeleton type="text" class="h-6 w-28" />
</template>
<OButton
v-if="targetLocation"
variant="outline"
size="xs"
data-test="quality-score-pane-open-target"
@click="navigate(targetLocation)"
>
{{ t(`onlineEvals.quality.pane.open.${score?.targetScope}`) }}
</OButton>
<OButton
v-if="evaluatorLocation"
variant="outline"
size="xs"
data-test="quality-score-pane-open-evaluator"
@click="navigate(evaluatorLocation)"
>
{{ t("onlineEvals.quality.pane.evaluatorTrace") }}
</OButton>
<OButton
v-if="score"
variant="outline"
size="xs"
:disabled="!datasetTarget"
focusable-unavailable
data-test="quality-score-pane-add-to-dataset"
@click="openDataset"
>
<template #unavailable-reason>{{ datasetBlockedReason }}</template>
{{ t("onlineEvals.quality.pane.addToDataset") }}
<OTooltip v-if="datasetBlockedReason" :content="datasetBlockedReason" />
</OButton>
</footer>
</template>
<AddToDatasetDrawer
v-if="drawerTarget"
:open="drawerOpen"
:org-id="orgId"
:ref-type="drawerTarget.refType"
:ref-id="drawerTarget.refId"
:source-stream="drawerTarget.sourceStream"
:ref-trace-start-time="drawerTarget.refTraceStartTime"
@update:open="updateDrawerOpen"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter, type RouteLocationRaw } from "vue-router";
import { useI18nTyped } from "@/types/i18n";
import { useOrgId } from "@/composables/query/useOrgId";
import { toggleFullscreen } from "@/utils/dom";
import { toast } from "@/lib/feedback/Toast/useToast";
import OButton from "@/lib/core/Button/OButton.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import ReviewContentBox from "@/enterprise/views/AIObservability/ReviewContentBox.vue";
import AddToDatasetDrawer from "@/enterprise/components/AIObservability/AddToDatasetDrawer.vue";
import type { QualityScore } from "@/services/online-evals.service";
import { evaluatorTraceLink, targetLink } from "../utils/qualityFormat";
import { useScoreFullText } from "../composables/useScoreFullText";
const props = defineProps<{
score: QualityScore | null;
/** Threshold rule such as "≥ 0.7"; empty without a threshold. */
thresholdLabel: string;
canPrev: boolean;
canNext: boolean;
loading: boolean;
}>();
const emit = defineEmits<{
(e: "prev"): void;
(e: "next"): void;
}>();
const { t } = useI18nTyped();
const router = useRouter();
const orgId = useOrgId();
// Survives score changes, so a folded reasoning stays folded while reading on.
const reasoningOpen = ref(true);
const targetLocation = computed(() => (props.score ? targetLink(props.score, orgId.value) : null));
const evaluatorLocation = computed(() =>
props.score ? evaluatorTraceLink(props.score, orgId.value) : null,
);
interface DatasetTarget {
refType: "trace" | "span";
refId: string;
sourceStream: string;
refTraceStartTime: number;
}
// A dataset item is one trace or span read back from its stream, so sessions and scores without a stream cannot be added.
const datasetTarget = computed<DatasetTarget | null>(() => {
const score = props.score;
if (!score || score.targetScope === "session" || !score.sourceStream) return null;
const refId =
score.targetScope === "span"
? (score.spanId ?? score.targetId)
: (score.traceId ?? score.targetId);
if (!refId) return null;
return {
refType: score.targetScope,
refId,
sourceStream: score.sourceStream,
refTraceStartTime: score.refTimestamp,
};
});
const datasetBlockedReason = computed(() => {
if (props.score?.targetScope === "session") return t("onlineEvals.quality.pane.datasetSession");
if (props.score && !props.score.sourceStream)
return t("onlineEvals.quality.pane.datasetNoStream");
return "";
});
// Held from the click, so moving to another score while the drawer is open cannot change what is added.
const drawerTarget = ref<DatasetTarget | null>(null);
const drawerOpen = ref(false);
function openDataset() {
if (!datasetTarget.value) return;
drawerTarget.value = datasetTarget.value;
drawerOpen.value = true;
}
function updateDrawerOpen(open: boolean) {
drawerOpen.value = open;
if (!open) drawerTarget.value = null;
}
// A side whose span the scorer did not record, or that cannot be read, keeps its preview.
const {
fullText,
loading: fullTextLoading,
unavailable: fullTextUnavailable,
} = useScoreFullText(() => props.score, orgId);
// Skeletons while the scores page or the selected score's full text loads.
const contentLoading = computed(() => props.loading || fullTextLoading.value);
const inputText = computed(() => fullText.value.input || props.score?.inputPreview || "");
const outputText = computed(() => fullText.value.output || props.score?.outputPreview || "");
// Same tab: browser Back returns to this score through the page and score in the URL.
function navigate(location: RouteLocationRaw) {
router.push(location).catch(() => {});
}
const ioContainer = ref<HTMLElement | null>(null);
const fullscreenEl = ref<Element | null>(null);
const syncFullscreen = () => (fullscreenEl.value = document.fullscreenElement);
function toggleIoFullscreen() {
if (!ioContainer.value) return;
void toggleFullscreen(ioContainer.value).catch(() => {
toast({ variant: "error", message: t("aiObservability.queues.workbench.fullscreenError") });
});
}
onMounted(() => document.addEventListener("fullscreenchange", syncFullscreen));
onBeforeUnmount(() => document.removeEventListener("fullscreenchange", syncFullscreen));
</script>
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import store from "@/test/unit/helpers/store";
import type { QualityTimelineBucket } from "@/services/online-evals.service";
vi.mock("@/components/dashboards/panels/ChartRenderer.vue", () => ({
// `__esModule` is load-bearing: the chart reaches this through defineAsyncComponent.
__esModule: true,
default: { name: "ChartRenderer", props: ["data"], template: "<div />" },
}));
import QualityTrendChart from "./QualityTrendChart.vue";
const TIMELINE: QualityTimelineBucket[] = [
{
start: 0,
end: 3_600_000_000,
count: 29_997,
unhealthy: 3,
trueCount: null,
average: 0.000324,
p95: 0.00091,
},
];
describe("QualityTrendChart", () => {
it("formats the tooltip values and counts, and the threshold, like the rest of the page", async () => {
const wrapper = mount(QualityTrendChart, {
props: {
timeline: TIMELINE,
kind: "score",
range: { min: 0, max: 0.001 },
threshold: { value: 250_000_000, direction: "gte" },
},
global: { plugins: [store] },
});
await flushPromises();
const options = wrapper.findComponent({ name: "ChartRenderer" }).props("data").options;
const html = options.tooltip.formatter([
{ dataIndex: 0, marker: "", seriesName: "Avg", value: [1, 0.000324] },
]);
expect(html).toContain("29,997 scores");
expect(html).toContain("0.00032");
expect(options.series.some((series: any) => String(series.name).includes("250,000,000"))).toBe(
true,
);
});
it("says 1 score, not 1 scores, for a bucket with one score", async () => {
const wrapper = mount(QualityTrendChart, {
props: {
timeline: [{ ...TIMELINE[0], count: 1 }],
kind: "score",
range: { min: 0, max: 1 },
threshold: null,
},
global: { plugins: [store] },
});
await flushPromises();
const options = wrapper.findComponent({ name: "ChartRenderer" }).props("data").options;
const html = options.tooltip.formatter([
{ dataIndex: 0, marker: "", seriesName: "Avg", value: [1, 0.5] },
]);
expect(html).toContain("1 score<br/>");
});
it("labels the score axis with at most 4 decimals, so float noise never shows", async () => {
const wrapper = mount(QualityTrendChart, {
props: { timeline: TIMELINE, kind: "score", range: { min: 0, max: 0.05 }, threshold: null },
global: { plugins: [store] },
});
await flushPromises();
const { formatter } = wrapper.findComponent({ name: "ChartRenderer" }).props("data").options
.yAxis.axisLabel;
expect(formatter(0.0499999999999996)).toBe("0.05");
expect(formatter(0.0002)).toBe("0.0002");
expect(formatter(0.00002)).toBe("0.00002");
expect(formatter(250_000_000)).toBe("250,000,000");
expect(formatter(0)).toBe("0");
});
});
@@ -1,101 +1,172 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div ref="chartEl" class="h-full min-h-55 w-full" data-test="quality-trend-chart" />
<div class="h-full min-h-0 w-full" data-test="quality-trend-chart">
<ChartRenderer :data="{ options }" />
</div>
</template>
<script setup lang="ts">
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, defineAsyncComponent } from "vue";
import { useStore } from "vuex";
import * as echarts from "echarts";
import { format, isSameDay } from "date-fns";
import { useI18nTyped } from "@/types/i18n";
import { chartColor } from "@/utils/chartTheme";
import { escapeHtml } from "@/utils/html";
import {
QUALITY_SCORE_LINE_COLOR,
CHART_THRESHOLD_COLOR,
QUALITY_SCORE_LINE_COLOR,
colorToRgba,
} from "@/utils/dashboard/colorPalette";
import type { TrendPoint } from "../composables/useQualityDetailCharts";
import { withChartFont } from "@/utils/fonts";
import type { QualityTimelineBucket } from "@/services/online-evals.service";
import {
formatAxis,
formatCount,
formatExact,
formatScore,
scoreAxisBounds,
trendBuckets,
trendPoints,
type QualityTrendKind,
} from "../utils/qualityFormat";
const ChartRenderer = defineAsyncComponent(
() => import("@/components/dashboards/panels/ChartRenderer.vue"),
);
const props = defineProps<{
points: TrendPoint[];
threshold: { value: number; direction: "gte" | "lte" } | null;
yMin?: number | null;
yMax?: number | null;
legendAvg: I18nText;
legendP95: string;
legendThresholdFmt: string;
timeline: QualityTimelineBucket[];
kind: QualityTrendKind;
/** Numeric configs: the y axis range and the healthy threshold line. */
range?: { min: number; max: number } | null;
threshold?: { value: number; direction: "gte" | "lte" } | null;
}>();
const { t } = useI18nTyped();
const chartEl = ref<HTMLElement | null>(null);
let chart: echarts.ECharts | null = null;
const store = useStore();
function buildOption(): echarts.EChartsOption {
const plotted = computed(() => trendBuckets(props.timeline));
const gradient = (color: string) => ({
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: colorToRgba(color, 0.3) },
{ offset: 1, color: colorToRgba(color, 0.02) },
],
});
const DAY_US = 86_400_000_000;
// The widest bucket is the step; the first and last can be cut short by the time range.
const step = computed(() => Math.max(0, ...props.timeline.map((b) => b.end - b.start)));
/** "Oct 8" for day buckets, "Oct 8, 14:00–15:00" for shorter ones. */
function bucketTime(bucket: QualityTimelineBucket): string {
const start = bucket.start / 1000;
const end = bucket.end / 1000;
if (step.value >= DAY_US) return format(start, "MMM d");
return `${format(start, "MMM d, HH:mm")}–${format(end, isSameDay(start, end) ? "HH:mm" : "MMM d, HH:mm")}`;
}
function shownValue(value: number): string {
if (props.kind === "healthyRate" || props.kind === "trueRate") return `${value.toFixed(1)}%`;
return props.kind === "count" ? formatCount(value) : formatScore(value);
}
function seriesFor(name: string, data: [number, number | null][], color: string, extra = {}) {
return {
name,
type: "line",
data,
color,
smooth: true,
symbol: "circle",
symbolSize: 5,
// A single bucket has no line to draw, so it needs its point.
showSymbol: props.kind !== "score" || data.length === 1,
lineStyle: { color, width: 2 },
...extra,
};
}
const options = computed(() => {
// Read the theme so token colors re-resolve when it flips.
void store.state.theme;
const text = chartColor("--color-text-secondary");
const grid = chartColor("--color-border-subtle");
const avgSeries = props.points.map((p) => [p.t, p.avg ?? null]);
const p95Series = props.points.map((p) => [p.t, p.p95 ?? null]);
const series: echarts.SeriesOption[] = [
{
name: props.legendAvg,
type: "line",
data: avgSeries,
smooth: true,
symbol: "none",
lineStyle: { color: QUALITY_SCORE_LINE_COLOR, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: colorToRgba(QUALITY_SCORE_LINE_COLOR, 0.3) },
{ offset: 1, color: colorToRgba(QUALITY_SCORE_LINE_COLOR, 0.02) },
]),
},
z: 3,
},
{
name: props.legendP95,
type: "line",
data: p95Series,
smooth: true,
symbol: "none",
lineStyle: { color: CHART_THRESHOLD_COLOR, width: 2, type: "dashed" },
z: 2,
},
];
if (props.threshold) {
const sign = props.threshold.direction === "gte" ? ">=" : "<=";
series.push({
name: props.legendThresholdFmt
.replace("{direction}", sign)
.replace("{value}", String(props.threshold.value)),
type: "line",
data: [],
lineStyle: { color: CHART_THRESHOLD_COLOR, type: "dashed" },
markLine: {
silent: true,
symbol: "none",
label: {
formatter: t("onlineEvals.quality.detail.markLineHealthy", {
direction: sign,
value: props.threshold.value,
}),
color: CHART_THRESHOLD_COLOR,
fontSize: 10,
position: "insideEndTop",
const rate = props.kind === "healthyRate" || props.kind === "trueRate";
const series: any[] = [];
if (props.kind === "score") {
series.push(
seriesFor(
t("onlineEvals.quality.detail.legendAvg"),
trendPoints(props.timeline, "average"),
QUALITY_SCORE_LINE_COLOR,
{ areaStyle: { color: gradient(QUALITY_SCORE_LINE_COLOR) }, z: 3 },
),
seriesFor("p95", trendPoints(props.timeline, "p95"), CHART_THRESHOLD_COLOR, {
lineStyle: { color: CHART_THRESHOLD_COLOR, width: 2, type: "dashed" },
z: 2,
}),
);
if (props.threshold) {
const sign = {
direction: props.threshold.direction === "gte" ? "≥" : "≤",
value: formatExact(props.threshold.value),
};
// Its own series, so the threshold gets a legend entry next to Avg and p95.
series.push({
name: t("onlineEvals.quality.detail.legendThreshold", sign),
type: "line",
data: [],
color: CHART_THRESHOLD_COLOR,
lineStyle: { color: CHART_THRESHOLD_COLOR, type: "dashed" },
markLine: {
silent: true,
symbol: "none",
label: {
formatter: t("onlineEvals.quality.detail.markLineHealthy", sign),
color: CHART_THRESHOLD_COLOR,
fontSize: 10,
position: "insideEndTop",
},
lineStyle: { color: CHART_THRESHOLD_COLOR, type: "dashed", width: 1.2 },
data: [{ yAxis: props.threshold.value }],
},
lineStyle: { color: CHART_THRESHOLD_COLOR, type: "dashed", width: 1.2 },
data: [{ yAxis: props.threshold.value }],
},
});
});
}
} else {
const color = rate
? chartColor("--color-service-health-healthy")
: chartColor("--color-chart-series-1");
series.push(
seriesFor(
t(`onlineEvals.quality.detail.trendLegend.${props.kind}`),
trendPoints(props.timeline, props.kind),
color,
{ areaStyle: { color: gradient(color) } },
),
);
}
return {
grid: { left: 44, right: 16, top: 28, bottom: 28 },
tooltip: { trigger: "axis", confine: true },
grid: { left: 8, right: 16, top: 28, bottom: 4, containLabel: true },
legend: {
right: 0,
top: 0,
@@ -103,48 +174,50 @@ function buildOption(): echarts.EChartsOption {
itemHeight: 8,
textStyle: { color: text, fontSize: 11 },
},
// Two short lines: when and how many, then the values side by side.
tooltip: {
trigger: "axis",
confine: true,
padding: [6, 10],
textStyle: { fontSize: 12 },
axisPointer: { type: "line", lineStyle: { color: grid } },
formatter: (params: any[]) => {
const bucket = plotted.value[params?.[0]?.dataIndex];
if (!bucket) return "";
const values = params
.filter((p: any) => p.value?.[1] != null)
.map(
(p: any) =>
`${p.marker}${escapeHtml(String(p.seriesName))} <b>${escapeHtml(shownValue(p.value[1]))}</b>`,
)
.join("&nbsp;&nbsp;&nbsp;");
const when = escapeHtml(bucketTime(bucket));
if (props.kind === "count") return `${when}<br/>${values}`;
const count = t(
"onlineEvals.quality.detail.chartTooltipCount",
{ count: formatCount(bucket.count) },
bucket.count,
);
return `${when} · ${escapeHtml(count)}<br/>${values}`;
},
},
xAxis: {
type: "time",
axisLabel: { color: text, fontSize: 10, hideOverlap: true },
axisLine: { lineStyle: { color: grid } },
axisLabel: { color: text, fontSize: 10 },
splitLine: { show: false },
},
yAxis: {
type: "value",
min: props.yMin ?? "dataMin",
max: props.yMax ?? "dataMax",
axisLine: { show: false },
axisLabel: { color: text, fontSize: 10 },
...(props.kind === "score"
? scoreAxisBounds(props.range, plotted.value)
: rate
? { min: 0, max: 100 }
: { min: 0, minInterval: 1 }),
axisLabel: { color: text, fontSize: 10, formatter: rate ? "{value}%" : formatAxis },
splitLine: { lineStyle: { color: grid } },
},
series,
};
}
function render() {
if (!chart) return;
chart.setOption(withChartFont(buildOption()), true);
}
onMounted(() => {
if (!chartEl.value) return;
chart = echarts.init(chartEl.value, undefined, { renderer: "canvas" });
render();
});
watch(
() => [props.points, props.threshold, props.yMin, props.yMax, store.state.theme],
() => render(),
{ deep: true },
);
const resizeObserver = new ResizeObserver(() => chart?.resize());
onMounted(() => {
if (chartEl.value) resizeObserver.observe(chartEl.value);
});
onBeforeUnmount(() => {
resizeObserver.disconnect();
chart?.dispose();
chart = null;
});
</script>
@@ -3,7 +3,6 @@ import {
agentFilterKey,
agentFilterLabel,
buildEvaluatorAgentFilterWhere,
buildScoresAgentFilterWhere,
combineWhere,
type AgentFilterSelection,
} from "./agentFilterSql";
@@ -16,30 +15,6 @@ const agentWithId: AgentFilterSelection = {
};
describe("agentFilterSql", () => {
it("filters _llm_scores inline on agent_id", () => {
const sql = buildScoresAgentFilterWhere(agentWithId);
expect(sql).toBe("agent_id = 'agent-123'");
});
it("falls back to agent_name when id is missing", () => {
const sql = buildScoresAgentFilterWhere({ ...agentWithId, id: null });
expect(sql).toBe("agent_name = 'support-agent'");
});
it("adds agent_version and agent_env clauses when the variant carries them", () => {
const sql = buildScoresAgentFilterWhere({
...agentWithId,
env: "production",
version: "1.3.0",
});
expect(sql).toBe(
"agent_id = 'agent-123' AND agent_version = '1.3.0' AND agent_env = 'production'",
);
});
it("filters _evaluator inline on attributes_target_agent_id", () => {
const sql = buildEvaluatorAgentFilterWhere(agentWithId);
@@ -53,14 +28,9 @@ describe("agentFilterSql", () => {
});
it("escapes agent values", () => {
const sql = buildScoresAgentFilterWhere({
name: "agent'one",
id: null,
source_stream: 'prod"traces',
source_stream_type: 'traces"prod',
});
const sql = buildEvaluatorAgentFilterWhere({ name: "agent'one", id: null });
expect(sql).toBe("agent_name = 'agent''one'");
expect(sql).toBe("attributes_target_agent_name = 'agent''one'");
});
it("keys identity by source stream and id-or-name", () => {
@@ -21,33 +21,9 @@ export function agentFilterLabel(agent: AgentFilterSelection): string {
return formatAgentOption(agent);
}
// `_llm_scores` and `_evaluator` carry the agent identity denormalized onto
// every row at ingest, so a selected agent is filtered inline on those columns
// — no trace_id subquery against the source stream. Agent id is preferred over
// name (names are display labels); we fall back to name only when id is absent.
export function buildScoresAgentFilterWhere(
agent: AgentFilterSelection | null | undefined,
): string | null {
if (!agent) return null;
const field = agent.id ? "agent_id" : "agent_name";
const value = agent.id ?? agent.name;
if (!value) return null;
// env/version are denormalized onto every _llm_scores row (agent_env /
// agent_version), so a selected (agent, env, version) variant filters by exact
// match — same as agent_id. Rows written before this change have NULL columns
// and won't match a version filter until the target is re-evaluated.
const clauses = [`${field} = '${escapeSqlString(String(value))}'`];
if (agent.version) {
clauses.push(`agent_version = '${escapeSqlString(String(agent.version))}'`);
}
if (agent.env) {
clauses.push(`agent_env = '${escapeSqlString(String(agent.env))}'`);
}
return clauses.join(" AND ");
}
// `_evaluator` carries the target agent on every run, so a selected agent filters inline, id before name.
export function buildEvaluatorAgentFilterWhere(
agent: AgentFilterSelection | null | undefined,
agent: Pick<AgentFilterSelection, "id" | "name"> | null | undefined,
): string | null {
if (!agent) return null;
const field = agent.id ? "attributes_target_agent_id" : "attributes_target_agent_name";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { latestScoreAttemptsFromSql, latestScoresFromSql } from "./latestScoreSql";
import { latestScoresFromSql } from "./latestScoreSql";
describe("latestScoreSql", () => {
it("selects one latest row per evaluation key with deterministic ordering", () => {
@@ -37,17 +37,3 @@ describe("latestScoreSql", () => {
expect(sql).toContain("END AS _evaluation_key");
});
});
describe("latestScoreAttemptsFromSql", () => {
it("keeps the newest export for each task without collapsing later tasks", () => {
const sql = latestScoreAttemptsFromSql("score_config_id IS NOT NULL");
expect(sql).toContain('FROM "_llm_scores"');
expect(sql).toContain("PARTITION BY _score_attempt_key");
expect(sql).toContain("NULLIF(CAST(task_id AS VARCHAR), '')");
expect(sql).toContain("ORDER BY _timestamp DESC");
expect(sql).toContain("WHERE _latest_attempt_rank = 1");
expect(sql).toContain("AND (score_config_id IS NOT NULL)");
expect(sql).toContain(") AS latest_score_attempts");
});
});
@@ -1,5 +1,4 @@
export const LATEST_SCORES_TABLE = "latest_scores";
export const LATEST_SCORE_ATTEMPTS_TABLE = "latest_score_attempts";
const NORMALIZED_SCOPE_SQL = [
"LOWER(COALESCE(",
@@ -76,54 +75,3 @@ export function latestScoresFromSql(whereClause?: string | null): string {
`) AS ${LATEST_SCORES_TABLE}`,
].join("\n");
}
/**
* Latest exported score for each executor task.
*
* A task is the identity of one scoring attempt. At-least-once queue delivery
* can export that task more than once with different score IDs, so the newest
* record wins. Unlike `latestScoresFromSql`, this intentionally preserves
* later tasks that re-evaluate the same target and is therefore the correct
* source for execution-volume trends.
*/
export function latestScoreAttemptsFromSql(whereClause?: string | null): string {
const where = whereClause?.trim();
return [
"(",
" SELECT *",
" FROM (",
" SELECT",
" *,",
" ROW_NUMBER() OVER (",
" PARTITION BY _score_attempt_key",
" ORDER BY _timestamp DESC, COALESCE(CAST(id AS VARCHAR), '') DESC",
" ) AS _latest_attempt_rank",
" FROM (",
" SELECT",
" *,",
" CASE",
" WHEN NULLIF(CAST(task_id AS VARCHAR), '') IS NOT NULL",
" THEN CAST(task_id AS VARCHAR)",
" WHEN NULLIF(CAST(id AS VARCHAR), '') IS NOT NULL",
" THEN CONCAT('legacy:', CAST(id AS VARCHAR))",
" ELSE " + LEGACY_ROW_KEY_SQL.replace(/\n/g, "\n "),
" END AS _score_attempt_key",
" FROM (",
" SELECT",
" *,",
" " + NORMALIZED_TARGET_ID_SQL.replace(/\n/g, "\n ") + " AS _target_id",
" FROM (",
" SELECT",
" *,",
" " + NORMALIZED_SCOPE_SQL.replace(/\n/g, "\n ") + " AS _target_scope",
' FROM "_llm_scores"',
" ) AS scope_normalized_attempts",
" ) AS target_normalized_attempts",
" ) AS key_normalized_attempts",
" ) AS ranked_attempts",
" WHERE _latest_attempt_rank = 1",
" AND _target_scope IN ('span', 'trace', 'session')",
...(where ? [` AND (${where})`] : []),
`) AS ${LATEST_SCORE_ATTEMPTS_TABLE}`,
].join("\n");
}
@@ -0,0 +1,588 @@
import { describe, expect, it } from "vitest";
import { gt } from "@/types/i18n";
import type {
QualityDistributionBucket,
QualityScore,
QualityTimelineBucket,
} from "@/services/online-evals.service";
import type { GenAiAgentListItem } from "@/services/gen-ai-agent-mapping.service";
import { ALL_AGENTS_VALUE } from "@/plugins/traces/llmAgentFilter";
import {
DEFAULT_QUALITY_RANGE,
agentParamsFor,
barShare,
bucketLabel,
chartFilterFromQuery,
chartFilterLabel,
chartFilterParams,
chartFilterToQuery,
distributionDataType,
trendKind,
trendPoints,
evaluatorTraceLink,
formatCount,
formatEdge,
formatExact,
formatNumber,
formatPercent,
formatScore,
formatScoreValue,
healthSortValue,
nextChartFilter,
qualityRangeFromQuery,
qualityRangeQuery,
scopeMixText,
scoreAxisBounds,
selectedAgents,
statusRank,
summarizeDistribution,
targetLink,
targetTraceId,
targetWindow,
typicalScore,
typicalSortValue,
unhealthyShare,
unhealthyTone,
type QualityRow,
} from "./qualityFormat";
import { thresholdForConfig } from "./scoreThreshold";
function row(overrides: Partial<QualityRow>): QualityRow {
return {
configId: "c",
name: "c",
dataType: "numeric",
status: "healthy",
total: 10,
unhealthy: 0,
average: 0.5,
lastScoredAt: 1,
scopeCounts: { span: 0, trace: 10, session: 0 },
topValue: null,
description: "",
version: 1,
thresholdLabel: "",
range: { min: 0, max: 1 },
...overrides,
};
}
const numericBuckets: QualityDistributionBucket[] = Array.from({ length: 10 }, (_, i) => ({
key: i,
lower: i / 10,
upper: (i + 1) / 10,
count: i,
unhealthy: i > 6 ? i : 0,
}));
describe("status rank and default sort", () => {
it("ranks attention, healthy, unset, then no data", () => {
expect(["no_data", "unset", "healthy", "attention"].map((s) => statusRank(s as any))).toEqual([
3, 2, 1, 0,
]);
});
it("sorts by status first, then the higher unhealthy share", () => {
const rows = [
row({ configId: "healthy", status: "healthy", unhealthy: 0 }),
row({ configId: "low", status: "attention", unhealthy: 2 }),
row({ configId: "nodata", status: "no_data", total: 0, unhealthy: 0 }),
row({ configId: "high", status: "attention", unhealthy: 9 }),
row({ configId: "unset", status: "unset", unhealthy: null }),
];
const sorted = [...rows].sort((a, b) => healthSortValue(a) - healthSortValue(b));
expect(sorted.map((r) => r.configId)).toEqual(["high", "low", "healthy", "unset", "nodata"]);
});
});
describe("unhealthy bar tone", () => {
it("bands the share: under 25% green, under 75% orange, 75% and up red", () => {
const cases: [number, string][] = [
[0, "success"],
[0.249, "success"],
[0.25, "warning"],
[0.749, "warning"],
[0.75, "danger"],
[1, "danger"],
];
for (const [share, tone] of cases) expect(unhealthyTone(share)).toBe(tone);
});
it("bands on the shown one-decimal percent, so the colour matches the text", () => {
// 0.24996 shows as 25.0% and 0.74996 as 75.0%.
expect(unhealthyTone(0.24996)).toBe("warning");
expect(unhealthyTone(0.74996)).toBe("danger");
expect(unhealthyTone(0.24949)).toBe("success");
});
it("has no tone without a threshold or without scores, where the cell shows no bar", () => {
expect(unhealthyTone(null)).toBeNull();
expect(unhealthyTone(unhealthyShare({ total: 10, unhealthy: null }))).toBeNull();
expect(unhealthyTone(unhealthyShare({ total: 0, unhealthy: 0 }))).toBeNull();
});
});
describe("typical score", () => {
it("shows the average on the config range for numeric configs", () => {
const value = typicalScore(row({ average: 0.55, range: { min: 0, max: 1 } }), gt);
expect(value).toEqual({ value: "0.55", sub: "average, 0 to 1" });
expect(typicalSortValue(row({ average: 0.55 }))).toBe(0.55);
});
it("keeps up to 4 decimals of an average, so a narrow range does not round onto its rule", () => {
const value = typicalScore(row({ average: 0.0195, range: { min: 0, max: 0.05 } }), gt);
expect(value?.value).toBe("0.0195");
});
it("shows the most common value for categorical and boolean configs", () => {
const categorical = row({
dataType: "categorical",
total: 37,
topValue: { key: "medium", count: 18 },
});
expect(typicalScore(categorical, gt)).toEqual({
value: "medium",
sub: "most common · 18 of 37",
});
expect(typicalSortValue(categorical)).toBeCloseTo(18 / 37);
const boolean = row({ dataType: "boolean", total: 166, topValue: { key: false, count: 166 } });
expect(typicalScore(boolean, gt)?.value).toBe("false");
});
it("is empty without scores", () => {
expect(typicalScore(row({ total: 0 }), gt)).toBeNull();
expect(typicalSortValue(row({ total: 0 }))).toBe(-1);
});
});
describe("summaries", () => {
it("builds the scope mix, skipping empty scopes", () => {
expect(scopeMixText({ span: 0, trace: 38, session: 8 }, gt)).toBe("Trace 38 · Session 8");
});
it("derives status and counts from distribution[] like the backend", () => {
expect(summarizeDistribution([])).toEqual({ total: 0, unhealthy: null, status: "no_data" });
expect(
summarizeDistribution([
{ key: true, count: 0, unhealthy: 0 },
{ key: false, count: 0, unhealthy: 0 },
]).status,
).toBe("no_data");
expect(
summarizeDistribution([
{ key: "low", count: 3, unhealthy: null },
{ key: "high", count: 2, unhealthy: null },
]),
).toEqual({ total: 5, unhealthy: null, status: "unset" });
expect(summarizeDistribution(numericBuckets)).toEqual({
total: 45,
unhealthy: 24,
status: "attention",
});
expect(summarizeDistribution([{ key: "low", count: 4, unhealthy: 0 }]).status).toBe("healthy");
});
it("tells the data type from the distribution when the config is unknown", () => {
expect(distributionDataType(numericBuckets)).toBe("numeric");
expect(distributionDataType([{ key: true, count: 1, unhealthy: 0 }])).toBe("boolean");
expect(distributionDataType([{ key: "low", count: 1, unhealthy: 0 }])).toBe("categorical");
});
it("labels numeric buckets by range and the others by value", () => {
expect(bucketLabel(numericBuckets[3])).toBe("0.3–0.4");
expect(bucketLabel({ key: false, count: 1, unhealthy: 0 })).toBe("false");
});
it("labels the edge bars open-ended, since they also hold the values outside the range", () => {
expect(bucketLabel(numericBuckets[0], { first: true })).toBe("< 0.1");
expect(bucketLabel(numericBuckets[9], { last: true })).toBe("≥ 0.9");
expect(bucketLabel(numericBuckets[0], { first: true, last: true })).toBe("0–0.1");
expect(bucketLabel({ key: "low", count: 1, unhealthy: 0 }, { first: true })).toBe("low");
});
});
describe("trend", () => {
const hour = 3_600_000_000;
const bucket = (
index: number,
fields: Partial<QualityTimelineBucket>,
): QualityTimelineBucket => ({
start: index * hour,
end: (index + 1) * hour,
count: 0,
unhealthy: null,
trueCount: null,
average: null,
p95: null,
...fields,
});
it("draws the score for numeric configs, the healthy rate with a threshold, else the true rate or the count", () => {
expect(trendKind([bucket(0, { count: 4, unhealthy: 1 })], "numeric")).toBe("score");
expect(trendKind([bucket(0, { count: 4, unhealthy: 1 })], "categorical")).toBe("healthyRate");
expect(trendKind([bucket(0, { count: 4, trueCount: 3 })], "boolean")).toBe("trueRate");
expect(trendKind([bucket(0, { count: 4 })], "categorical")).toBe("count");
});
it("plots only buckets with scores, at their midpoint", () => {
const timeline = [
bucket(0, { count: 4, unhealthy: 1, trueCount: 3, average: 0.25, p95: 0.6 }),
bucket(1, { count: 0, unhealthy: 0 }),
bucket(2, { count: 2, unhealthy: 2, trueCount: 0, average: 0.5, p95: 0.9 }),
];
expect(trendPoints(timeline, "average")).toEqual([
[1_800_000, 0.25],
[9_000_000, 0.5],
]);
expect(trendPoints(timeline, "p95").map(([, value]) => value)).toEqual([0.6, 0.9]);
expect(trendPoints(timeline, "healthyRate").map(([, value]) => value)).toEqual([75, 0]);
expect(trendPoints(timeline, "trueRate").map(([, value]) => value)).toEqual([75, 0]);
expect(trendPoints(timeline, "count").map(([, value]) => value)).toEqual([4, 2]);
});
it("widens the score axis to values outside the range, so no line is clipped", () => {
const timeline = [
bucket(0, { count: 3, average: -2, p95: 0.5 }),
bucket(1, { count: 3, average: 4, p95: 7.5 }),
];
expect(scoreAxisBounds({ min: 1, max: 5 }, timeline)).toEqual({ min: -2, max: 7.5 });
expect(scoreAxisBounds({ min: 0, max: 1 }, [bucket(0, { count: 1, average: 0.4 })])).toEqual({
min: 0,
max: 1,
});
expect(scoreAxisBounds(null, timeline)).toEqual({ min: "dataMin", max: "dataMax" });
});
});
describe("chart click to API params", () => {
it("selects a numeric bucket, widens it with shift, and clears on a second click", () => {
let filter = nextChartFilter(null, numericBuckets[3], false);
expect(chartFilterParams(filter)).toEqual({ bucket_from: 3, bucket_to: 3 });
filter = nextChartFilter(filter, numericBuckets[6], true);
expect(chartFilterParams(filter)).toEqual({ bucket_from: 3, bucket_to: 6 });
expect(chartFilterLabel(filter!, numericBuckets, gt)).toBe("0.3 to 0.7");
expect(chartFilterLabel({ kind: "bucket", from: 0, to: 2 }, numericBuckets, gt)).toBe(
"Below 0.3",
);
expect(chartFilterLabel({ kind: "bucket", from: 8, to: 9 }, numericBuckets, gt)).toBe(
"0.8 or more",
);
expect(chartFilterLabel({ kind: "bucket", from: 0, to: 9 }, numericBuckets, gt)).toBe(
"Any value",
);
filter = nextChartFilter(filter, numericBuckets[5], false);
expect(chartFilterParams(filter)).toEqual({ bucket_from: 5, bucket_to: 5 });
expect(nextChartFilter(filter, numericBuckets[5], false)).toBeNull();
});
it("selects one value for boolean and categorical bars", () => {
const medium = { key: "medium", count: 67, unhealthy: 67 };
const filter = nextChartFilter(null, medium, true);
expect(chartFilterParams(filter)).toEqual({ value: "medium" });
expect(nextChartFilter(filter, medium, false)).toBeNull();
expect(
chartFilterParams(nextChartFilter(null, { key: false, count: 1, unhealthy: 1 }, false)),
).toEqual({
value: "false",
});
expect(chartFilterParams(null)).toEqual({});
});
});
describe("agent params", () => {
const agents: GenAiAgentListItem[] = [
{
name: "a",
id: "a1",
source_stream: "s",
source_stream_type: "traces",
env: "prod",
version: "1",
},
{
name: "a",
id: "a1",
source_stream: "s",
source_stream_type: "traces",
env: "prod",
version: "2",
},
{
name: "b",
id: null,
source_stream: "s",
source_stream_type: "traces",
env: "dev",
version: "1",
},
];
const ALL = ALL_AGENTS_VALUE;
it("sends nothing for a level at All", () => {
expect(agentParamsFor(agents, ALL, ALL, ALL)).toEqual({});
expect(agentParamsFor(agents, "prod", ALL, ALL)).toEqual({ agent_env: "prod" });
});
it("sends the agent id when the matching agents share one, else the name", () => {
expect(agentParamsFor(agents, ALL, "a", "2")).toEqual({ agent_id: "a1", agent_version: "2" });
expect(agentParamsFor(agents, ALL, "b", ALL)).toEqual({ agent_name: "b" });
});
it("selects the agents every pinned level matches, and none for All", () => {
expect(selectedAgents(agents, ALL, ALL, ALL)).toBeNull();
expect(selectedAgents(agents, ALL, ALL, "1")?.map((a) => a.name)).toEqual(["a", "b"]);
expect(selectedAgents(agents, "staging", ALL, ALL)).toEqual([]);
});
});
describe("Quality range in the URL", () => {
it("defaults to Past 24 Hours", () => {
expect(DEFAULT_QUALITY_RANGE).toMatchObject({
valueType: "relative",
relativeTimePeriod: "24h",
});
});
it("reads a relative period or an absolute from/to, and ignores anything else", () => {
expect(qualityRangeFromQuery({ period: "7d" })).toEqual({
valueType: "relative",
startTime: null,
endTime: null,
relativeTimePeriod: "7d",
});
expect(qualityRangeFromQuery({ from: "100", to: "200" })).toEqual({
valueType: "absolute",
startTime: 100,
endTime: 200,
relativeTimePeriod: null,
});
expect(qualityRangeFromQuery({})).toBeNull();
expect(qualityRangeFromQuery({ period: "soon" })).toBeNull();
expect(qualityRangeFromQuery({ from: "200", to: "100" })).toBeNull();
expect(qualityRangeFromQuery({ from: "x", to: "y" })).toBeNull();
});
it("writes period or from/to, and nothing for the default range", () => {
expect(qualityRangeQuery(DEFAULT_QUALITY_RANGE)).toEqual({
period: undefined,
from: undefined,
to: undefined,
});
expect(qualityRangeQuery({ ...DEFAULT_QUALITY_RANGE, relativeTimePeriod: "1h" })).toEqual({
period: "1h",
from: undefined,
to: undefined,
});
expect(
qualityRangeQuery({
valueType: "absolute",
startTime: 100,
endTime: 200,
relativeTimePeriod: null,
}),
).toEqual({ period: undefined, from: "100", to: "200" });
});
});
describe("link windows", () => {
const score: QualityScore = {
id: "s1",
timestamp: 5_000_000_000,
refTimestamp: 4_000_000_000,
sourceType: "llm_judge",
targetScope: "span",
targetId: "span-1",
spanId: "span-1",
traceId: "trace-1",
sessionId: null,
sourceStream: "default",
sourceStreamType: "traces",
value: 0.2,
unhealthy: true,
reasoning: null,
evaluatorTraceId: "eval-1",
taskId: null,
inputPreview: null,
outputPreview: null,
inputSpanId: null,
outputSpanId: null,
};
it("opens the target trace an hour either side of its start, with the span selected", () => {
expect(targetLink(score, "org1")).toEqual({
name: "traceDetails",
query: {
org_identifier: "org1",
from: 4_000_000_000 - 3_600_000_000,
to: 4_000_000_000 + 3_600_000_000,
stream: "default",
trace_id: "trace-1",
span_id: "span-1",
},
});
});
it("opens a session on the session page", () => {
const session = {
...score,
targetScope: "session" as const,
sessionId: "sess-1",
traceId: null,
};
expect(targetLink(session, "org1")).toMatchObject({
name: "sessionDetails",
query: { session_id: "sess-1" },
});
});
it("shares one window between the links and the full-text fetch, and finds the trace", () => {
expect(targetWindow(score)).toEqual({
startTime: 4_000_000_000 - 3_600_000_000,
endTime: 4_000_000_000 + 3_600_000_000,
});
expect(targetTraceId(score)).toBe("trace-1");
expect(
targetTraceId({ ...score, targetScope: "trace", traceId: null, targetId: "trace-2" }),
).toBe("trace-2");
expect(targetTraceId({ ...score, traceId: null })).toBeNull();
expect(targetTraceId({ ...score, targetScope: "session", sessionId: "sess-1" })).toBeNull();
});
it("opens the evaluator trace around the score write time", () => {
expect(evaluatorTraceLink(score, "org1")).toEqual({
name: "traceDetails",
query: {
org_identifier: "org1",
stream: "_evaluator",
trace_id: "eval-1",
from: 5_000_000_000 - 3_600_000_000,
to: 5_000_000_000 + 3_600_000_000,
},
});
expect(evaluatorTraceLink({ ...score, evaluatorTraceId: null }, "org1")).toBeNull();
});
});
describe("chart filter in the URL", () => {
it("writes a bucket range and a value, and reads them back", () => {
for (const filter of [
{ kind: "bucket", from: 3, to: 5 } as const,
{ kind: "value", value: "medium" } as const,
]) {
expect(chartFilterFromQuery(chartFilterToQuery(filter))).toEqual(filter);
}
expect(chartFilterToQuery({ kind: "bucket", from: 2, to: 2 })).toEqual({ bucket: "2-2" });
expect(chartFilterToQuery(null)).toEqual({});
});
it("restores no filter from a missing or malformed value", () => {
expect(chartFilterFromQuery({})).toBeNull();
expect(chartFilterFromQuery({ bucket: "5-3" })).toBeNull();
expect(chartFilterFromQuery({ bucket: "3-12" })).toBeNull();
expect(chartFilterFromQuery({ bucket: "9-9" })).toEqual({ kind: "bucket", from: 9, to: 9 });
expect(chartFilterFromQuery({ bucket: "x" })).toBeNull();
expect(chartFilterFromQuery({ value: "" })).toBeNull();
expect(chartFilterFromQuery({ value: ["a", "b"] })).toBeNull();
});
});
describe("numbers by size", () => {
it("groups thousands and keeps tiny values distinct", () => {
expect(formatNumber(1_000_000_000)).toBe("1,000,000,000");
expect(formatNumber(0.7)).toBe("0.7");
expect(formatNumber(0)).toBe("0");
expect(formatNumber(-0.5)).toBe("-0.5");
expect(formatNumber(0.000324)).toBe("0.00032");
expect(formatScore(462_522_542.5)).toBe("462,522,542.50");
expect(formatScore(0.7)).toBe("0.70");
expect(formatScore(0.000324)).toBe("0.00032");
expect(formatScoreValue(0.0003, "numeric")).not.toBe(formatScoreValue(0.0007, "numeric"));
expect(formatCount(150_000)).toBe("150,000");
});
it("labels a tiny range by its real edges", () => {
expect(bucketLabel({ key: 3, lower: 0.0003, upper: 0.0004, count: 1, unhealthy: 0 })).toBe(
"0.0003–0.0004",
);
});
it("groups the counts in the typical score and the threshold in the rule", () => {
const top = typicalScore(
row({ dataType: "boolean", total: 5000, topValue: { key: false, count: 4999 } }),
gt,
);
expect(String(top?.sub)).toContain("4,999 of 5,000");
const config = {
name: "c",
dataType: "numeric",
healthyThreshold: { direction: "gte", value: 250_000_000 },
} as any;
expect(String(thresholdForConfig(config).label)).toBe("≥ 250,000,000");
});
});
describe("unhealthy share at the ends", () => {
it("never shows 0.0% for some or 100.0% for not all", () => {
expect(formatPercent(1, 5000)).toBe("<0.1%");
expect(formatPercent(4999, 5000)).toBe(">99.9%");
expect(formatPercent(0, 5)).toBe("0.0%");
expect(formatPercent(5, 5)).toBe("100.0%");
expect(formatPercent(25, 100)).toBe("25.0%");
expect(formatPercent(1, 0)).toBe("—");
});
it("keeps a visible sliver of bar for any non-zero share", () => {
expect(barShare(1 / 5000)).toBe(0.02);
expect(barShare(0.5)).toBe(0.5);
expect(barShare(0)).toBe(0);
expect(barShare(null)).toBe(0);
});
});
describe("categorical rule", () => {
const categorical = (healthyThreshold: unknown) =>
({ name: "c", dataType: "categorical", categories: ["a", "b"], healthyThreshold }) as any;
it("says none for an empty healthy list, which the backend reads as every score unhealthy", () => {
expect(String(thresholdForConfig(categorical({ healthy_categories: [] })).label)).toBe("none");
expect(String(thresholdForConfig(categorical({ healthy_categories: ["a"] })).label)).toBe("a");
expect(String(thresholdForConfig(categorical({})).label)).toBe("");
});
});
describe("precision that matches the data", () => {
it("shows a score with 2 to 4 decimals, so it never reads the same as the rule it fails", () => {
expect(formatScoreValue(0.019, "numeric")).toBe("0.019");
expect(formatScoreValue(0.6999, "numeric")).toBe("0.6999");
expect(formatScoreValue(0.5, "numeric")).toBe("0.50");
expect(formatScoreValue(1, "numeric")).toBe("1.00");
expect(formatScoreValue(0, "numeric")).toBe("0.00");
expect(formatScoreValue(0.00032, "numeric")).toBe("0.00032");
expect(formatScoreValue(462_522_542.5, "numeric")).toBe("462,522,542.50");
});
it("shows a configured threshold exactly, so it never contradicts a score", () => {
const config = {
name: "c",
dataType: "numeric",
healthyThreshold: { direction: "gte", value: 0.333 },
} as any;
expect(String(thresholdForConfig(config).label)).toBe("≥ 0.333");
expect(formatExact(1_250_000.125)).toBe("1,250,000.125");
});
it("labels narrow buckets with enough decimals to tell them apart", () => {
expect(bucketLabel({ key: 4, lower: 0.02, upper: 0.025, count: 1, unhealthy: 0 })).toBe(
"0.02–0.025",
);
expect(
bucketLabel({ key: 9, lower: 0.045, upper: 0.05, count: 1, unhealthy: 0 }, { last: true }),
).toBe("≥ 0.045");
expect(formatEdge(100_000_000, 100_000_000)).toBe("100,000,000");
});
it("never prints exponent notation for tiny values", () => {
expect(formatNumber(1e-9)).not.toContain("e");
expect(formatScore(2.5e-7)).not.toContain("e");
});
});
@@ -0,0 +1,498 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import type { LocationQuery, RouteLocationRaw } from "vue-router";
import type { AiDateState } from "@/enterprise/composables/useAiDateRange";
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
import type { GenAiAgentListItem } from "@/services/gen-ai-agent-mapping.service";
import type {
EvalTargetScope,
QualityAgentParams,
QualityConfigSummary,
QualityDistributionBucket,
QualityScore,
QualityScoresParams,
QualityStatus,
QualityTimelineBucket,
ScoreDataType,
} from "@/services/online-evals.service";
import { ALL_AGENTS_VALUE } from "@/plugins/traces/llmAgentFilter";
import type { ProgressBarVariant } from "@/lib/data/ProgressBar/OProgressBar.types";
/** A list row joined with the Score Config fields the API does not return. */
export interface QualityRow extends QualityConfigSummary {
description: string;
version: number | null;
/** Threshold rule such as "≥ 0.7"; empty without a threshold. */
thresholdLabel: string;
range: { min: number; max: number };
}
export type QualityScope = "all" | EvalTargetScope;
export type QualityOnly = "all" | "unhealthy";
/** A bar click on the distribution: a numeric bucket range or one exact value. */
export type QualityChartFilter =
{ kind: "bucket"; from: number; to: number } | { kind: "value"; value: string };
export const QUALITY_SCOPES: EvalTargetScope[] = ["span", "trace", "session"];
const STATUS_RANK: Record<QualityStatus, number> = {
attention: 0,
healthy: 1,
unset: 2,
no_data: 3,
};
// Detail views search a window; an hour either side of the target matches the Discovery page.
const LINK_WINDOW_US = 3_600_000_000;
export const statusRank = (status: QualityStatus): number => STATUS_RANK[status] ?? 4;
/** Unhealthy share, or null without a threshold or without scores. */
export function unhealthyShare(row: Pick<QualityConfigSummary, "total" | "unhealthy">) {
return row.total > 0 && row.unhealthy != null ? row.unhealthy / row.total : null;
}
/** Bar colour for an unhealthy share, banded on the percent the cell shows: under 25 green, under 75 orange, else red; null without a share. */
export function unhealthyTone(share: number | null): ProgressBarVariant | null {
if (share == null) return null;
const percent = Number((share * 100).toFixed(1));
if (percent < 25) return "success";
return percent < 75 ? "warning" : "danger";
}
/** Default sort: status rank first, then the higher unhealthy share. */
export const healthSortValue = (row: QualityConfigSummary): number =>
statusRank(row.status) * 10 - (unhealthyShare(row) ?? 0);
/** Numeric configs sort by the average; the others by the share of the top value. */
export function typicalSortValue(row: QualityConfigSummary): number {
if (!row.total) return -1;
if (row.dataType === "numeric") return row.average ?? -1;
return row.topValue ? row.topValue.count / row.total : -1;
}
// Below 0.01 two decimals collapse distinct values to 0, so tiny values keep 2 significant digits.
const isTiny = (value: number) => value !== 0 && Math.abs(value) < 0.01;
// Significant digits through toLocaleString, so 1e-9 reads "0.000000001", not "1e-9".
const formatTiny = (value: number) =>
value.toLocaleString("en-US", { maximumSignificantDigits: 2 });
/** A score or an average: 2 to 4 decimals, so 0.50 keeps its look and 0.019 never reads as a 0.02 rule; tiny values kept distinct. */
export const formatScore = (value: number): string =>
isTiny(value)
? formatTiny(value)
: value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 4 });
/** An axis tick: up to 4 decimals, thousands grouped, so float noise like 0.0499999999999996 reads "0.05"; smaller ticks keep 2 significant digits. */
export const formatAxis = (value: number): string =>
value !== 0 && Math.abs(value) < 0.0001
? formatTiny(value)
: value.toLocaleString("en-US", { maximumFractionDigits: 4 });
/** A number for labels and ranges: up to 2 decimals, thousands grouped ("1,000,000"), tiny values kept distinct ("0.00032"). */
export const formatNumber = (value: number): string =>
isTiny(value) ? formatTiny(value) : value.toLocaleString("en-US", { maximumFractionDigits: 2 });
/** A configured value as entered: thousands grouped, never rounded, so a 0.333 threshold reads "0.333". */
export const formatExact = (value: number): string =>
value.toLocaleString("en-US", { maximumFractionDigits: 20 });
/** A bucket edge with one more decimal than the bucket width needs, so neighbours never share a label: "0.045" for 0.005-wide buckets. */
export function formatEdge(value: number, width: number): string {
const decimals = width > 0 ? Math.min(20, Math.max(0, Math.ceil(-Math.log10(width)) + 1)) : 2;
return value.toLocaleString("en-US", { maximumFractionDigits: decimals });
}
/** A count with thousands grouped: "150,000". */
export const formatCount = (value: number): string => value.toLocaleString("en-US");
/** One decimal, but never "0.0%" for some or "100.0%" for not all: 1 in 5,000 reads "<0.1%". */
export function formatPercent(part: number, whole: number): string {
if (whole <= 0) return "—";
const percent = (part / whole) * 100;
if (part > 0 && percent < 0.05) return "<0.1%";
if (part < whole && percent >= 99.95) return ">99.9%";
return `${percent.toFixed(1)}%`;
}
/** Bar fill for a share: any non-zero share keeps a visible sliver, so it never looks like a healthy config. */
export const barShare = (share: number | null): number =>
share == null || share <= 0 ? 0 : Math.max(share, 0.02);
export function formatScoreValue(value: QualityScore["value"], dataType: ScoreDataType): string {
if (value == null) return "—";
if (dataType === "numeric" && typeof value === "number") return formatScore(value);
return String(value);
}
/** The typical score cell: the average for numeric configs, the top value for the others. */
export function typicalScore(
row: QualityRow,
t: TranslateFn,
): { value: I18nText; sub: I18nText } | null {
if (!row.total) return null;
if (row.dataType === "numeric") {
if (row.average == null) return null;
return {
value: raw(formatScore(row.average)),
sub: t("onlineEvals.quality.table.averageRange", {
min: formatNumber(row.range.min),
max: formatNumber(row.range.max),
}),
};
}
if (!row.topValue) return null;
return {
value: raw(String(row.topValue.key)),
sub: t("onlineEvals.quality.table.mostCommon", {
count: formatCount(row.topValue.count),
total: formatCount(row.total),
}),
};
}
/** "Trace 38 · Session 8", skipping empty scopes. */
export function scopeMixText(counts: QualityConfigSummary["scopeCounts"], t: TranslateFn): string {
return QUALITY_SCOPES.filter((scope) => counts[scope] > 0)
.map((scope) =>
t("onlineEvals.quality.scopeCount", {
scope: t(`onlineEvals.quality.scopes.${scope}`),
count: formatCount(counts[scope]),
}),
)
.join(" · ");
}
/** Totals and status from `distribution[]`, by the same rule as the backend. */
export function summarizeDistribution(distribution: QualityDistributionBucket[]): {
total: number;
unhealthy: number | null;
status: QualityStatus;
} {
const total = distribution.reduce((sum, bucket) => sum + bucket.count, 0);
const classified = distribution.length > 0 && distribution.every((b) => b.unhealthy != null);
const unhealthy = classified
? distribution.reduce((sum, bucket) => sum + (bucket.unhealthy ?? 0), 0)
: null;
let status: QualityStatus = "healthy";
if (total === 0) status = "no_data";
else if (unhealthy == null) status = "unset";
else if (unhealthy > 0) status = "attention";
return { total, unhealthy, status };
}
/** A bar's label. The backend folds values below the range into the first numeric bar and values above it into the last, so those two read open-ended: "< 0.1" and "≥ 0.9". */
export function bucketLabel(
bucket: QualityDistributionBucket,
edge: { first?: boolean; last?: boolean } = {},
): string {
if (bucket.lower != null && bucket.upper != null) {
const width = bucket.upper - bucket.lower;
const lower = formatEdge(bucket.lower, width);
const upper = formatEdge(bucket.upper, width);
if (edge.first && !edge.last) return `< ${upper}`;
if (edge.last && !edge.first) return `≥ ${lower}`;
return `${lower}–${upper}`;
}
return String(bucket.key);
}
/** The score trend's y axis: the config range, widened to any average or p95 outside it, so no line is clipped. */
export function scoreAxisBounds(
range: { min: number; max: number } | null | undefined,
timeline: QualityTimelineBucket[],
): { min: number | "dataMin"; max: number | "dataMax" } {
if (!range) return { min: "dataMin", max: "dataMax" };
const values = timeline
.flatMap((bucket) => [bucket.average, bucket.p95])
.filter((value): value is number => value != null && Number.isFinite(value));
return { min: Math.min(range.min, ...values), max: Math.max(range.max, ...values) };
}
/** The filter after a bar click: numeric bars select a bucket range (shift widens it), others one value; re-clicking the only bar clears it. */
export function nextChartFilter(
current: QualityChartFilter | null,
bucket: QualityDistributionBucket,
shift: boolean,
): QualityChartFilter | null {
if (typeof bucket.key === "number") {
const index = bucket.key;
if (shift && current?.kind === "bucket") {
return {
kind: "bucket",
from: Math.min(current.from, index),
to: Math.max(current.to, index),
};
}
if (current?.kind === "bucket" && current.from === index && current.to === index) return null;
return { kind: "bucket", from: index, to: index };
}
const value = String(bucket.key);
if (current?.kind === "value" && current.value === value) return null;
return { kind: "value", value };
}
export function chartFilterParams(
filter: QualityChartFilter | null,
): Pick<QualityScoresParams, "bucket_from" | "bucket_to" | "value"> {
if (!filter) return {};
if (filter.kind === "bucket") return { bucket_from: filter.from, bucket_to: filter.to };
return { value: filter.value };
}
/** The chart filter as URL values, so Back and a shared link reopen it with its page: "3-5" for a bucket range, else the exact value. */
export function chartFilterToQuery(filter: QualityChartFilter | null): {
bucket?: string;
value?: string;
} {
if (!filter) return {};
return filter.kind === "bucket"
? { bucket: `${filter.from}-${filter.to}` }
: { value: filter.value };
}
/** The backend always splits a numeric range into this many buckets. */
const NUMERIC_BUCKETS = 10;
/** The chart filter from URL values; a malformed or out-of-range value restores no filter. */
export function chartFilterFromQuery(query: {
bucket?: unknown;
value?: unknown;
}): QualityChartFilter | null {
if (typeof query.bucket === "string") {
const match = /^(\d+)-(\d+)$/.exec(query.bucket);
if (!match) return null;
const [from, to] = [Number(match[1]), Number(match[2])];
return from <= to && to < NUMERIC_BUCKETS ? { kind: "bucket", from, to } : null;
}
return typeof query.value === "string" && query.value
? { kind: "value", value: query.value }
: null;
}
/** Whether a bar is inside the active filter, so the chart can dim the rest. */
export function bucketSelected(
filter: QualityChartFilter | null,
bucket: QualityDistributionBucket,
): boolean {
if (!filter) return true;
if (filter.kind === "bucket") {
return typeof bucket.key === "number" && bucket.key >= filter.from && bucket.key <= filter.to;
}
return String(bucket.key) === filter.value;
}
/** Chip text for the active filter: "0.3 to 0.5" for a numeric range, else the value. */
export function chartFilterLabel(
filter: QualityChartFilter,
distribution: QualityDistributionBucket[],
t: TranslateFn,
): I18nText {
if (filter.kind === "value") return raw(filter.value);
const first = distribution.find((b) => b.key === filter.from);
const last = distribution.find((b) => b.key === filter.to);
// The same precision as the bar labels, so the chip and the bars read alike.
const width = first?.lower != null && first.upper != null ? first.upper - first.lower : 1;
const from = formatEdge(first?.lower ?? filter.from, width);
const to = formatEdge(last?.upper ?? filter.to + 1, width);
// The edge bars also hold the values outside the range, as their labels say.
const openLow = filter.from === distribution[0]?.key;
const openHigh = filter.to === distribution[distribution.length - 1]?.key;
if (openLow && openHigh) return t("onlineEvals.quality.detail.filterAny");
if (openLow) return t("onlineEvals.quality.detail.filterBelow", { to });
if (openHigh) return t("onlineEvals.quality.detail.filterAtLeast", { from });
return t("onlineEvals.quality.detail.filterRange", { from, to });
}
const pinned = (level: string) => !!level && level !== ALL_AGENTS_VALUE;
/** Agents the Env, Agent and Version levels select; null while every level is "All". */
export function selectedAgents(
agents: GenAiAgentListItem[],
env: string,
name: string,
version: string,
): GenAiAgentListItem[] | null {
if (!pinned(env) && !pinned(name) && !pinned(version)) return null;
return agents.filter(
(agent) =>
(!pinned(env) || agent.env === env) &&
(!pinned(name) || agent.name === name) &&
(!pinned(version) || agent.version === version),
);
}
/** API params for the levels: "All" sends nothing; the agent level sends the id its matches share, else the name. */
export function agentParamsFor(
agents: GenAiAgentListItem[],
env: string,
name: string,
version: string,
): QualityAgentParams {
const params: QualityAgentParams = {};
if (pinned(env)) params.agent_env = env;
if (pinned(version)) params.agent_version = version;
if (!pinned(name)) return params;
const ids = new Set((selectedAgents(agents, env, name, version) ?? []).map((a) => a.id ?? ""));
const [id] = ids;
if (ids.size === 1 && id) params.agent_id = id;
else params.agent_name = name;
return params;
}
/** The data type a distribution implies when the config is unknown: numeric buckets carry edges, boolean keys are booleans. */
export function distributionDataType(distribution: QualityDistributionBucket[]): ScoreDataType {
if (distribution.some((bucket) => bucket.lower != null)) return "numeric";
if (distribution.some((bucket) => typeof bucket.key === "boolean")) return "boolean";
return "categorical";
}
/** The trend, as the earlier Quality page drew it: average and p95 for numeric configs, the healthy rate with a threshold, else the true rate for boolean configs, else the score count. */
export type QualityTrendKind = "score" | "healthyRate" | "trueRate" | "count";
export type QualityTrendMetric = "average" | "p95" | "healthyRate" | "trueRate" | "count";
export function trendKind(
timeline: QualityTimelineBucket[],
dataType: ScoreDataType,
): QualityTrendKind {
if (dataType === "numeric") return "score";
if (timeline.some((bucket) => bucket.unhealthy != null)) return "healthyRate";
return dataType === "boolean" ? "trueRate" : "count";
}
/** Buckets that got scores; the trend skips the others and joins its points across them. */
export const trendBuckets = (timeline: QualityTimelineBucket[]) =>
timeline.filter((bucket) => bucket.count > 0);
function trendValue(bucket: QualityTimelineBucket, metric: QualityTrendMetric): number | null {
if (metric === "average") return bucket.average;
if (metric === "p95") return bucket.p95;
if (metric === "count") return bucket.count;
const part = metric === "healthyRate" ? bucket.count - (bucket.unhealthy ?? 0) : bucket.trueCount;
return part == null ? null : (part / bucket.count) * 100;
}
/** One point per bucket with scores, at the bucket's midpoint in milliseconds. */
export function trendPoints(
timeline: QualityTimelineBucket[],
metric: QualityTrendMetric,
): [number, number | null][] {
return trendBuckets(timeline).map((bucket) => [
(bucket.start + bucket.end) / 2000,
trendValue(bucket, metric),
]);
}
/** Quality's own default range; LLM Insights and Sessions keep the shared AI range and its default. */
export const DEFAULT_QUALITY_RANGE: AiDateState = {
valueType: "relative",
startTime: null,
endTime: null,
relativeTimePeriod: "24h",
};
/** The range in the URL: `period` when relative, `from` and `to` (µs) when absolute; null when absent or malformed. */
export function qualityRangeFromQuery(query: LocationQuery): AiDateState | null {
const { period, from, to } = query;
if (typeof period === "string" && /^\d+[smhdwM]$/.test(period)) {
return { valueType: "relative", startTime: null, endTime: null, relativeTimePeriod: period };
}
const start = Number(from);
const end = Number(to);
if (typeof from !== "string" || typeof to !== "string" || !(end > start) || start < 0)
return null;
return { valueType: "absolute", startTime: start, endTime: end, relativeTimePeriod: null };
}
/** URL params for a range; the default range and the keys a range does not use come back undefined, for the caller to drop. */
export function qualityRangeQuery(
range: AiDateState,
): Record<"period" | "from" | "to", string | undefined> {
if (range.valueType === "absolute" && range.startTime != null && range.endTime != null) {
return { period: undefined, from: String(range.startTime), to: String(range.endTime) };
}
const period = range.relativeTimePeriod ?? DEFAULT_QUALITY_RANGE.relativeTimePeriod;
return {
period: period === DEFAULT_QUALITY_RANGE.relativeTimePeriod ? undefined : (period ?? undefined),
from: undefined,
to: undefined,
};
}
/** The window in microseconds that the target links search: an hour either side of the target time. */
export const targetWindow = (score: QualityScore) => ({
startTime: score.refTimestamp - LINK_WINDOW_US,
endTime: score.refTimestamp + LINK_WINDOW_US,
});
/** The trace a span or trace score points at; null for a session. */
export const targetTraceId = (score: QualityScore): string | null =>
score.targetScope === "session"
? null
: (score.traceId ?? (score.targetScope === "trace" ? score.targetId : null));
/** Opens the scored span, trace or session around its start time. */
export function targetLink(score: QualityScore, org: string): RouteLocationRaw | null {
const { startTime, endTime } = targetWindow(score);
const query: Record<string, string | number> = {
org_identifier: org,
from: startTime,
to: endTime,
};
if (score.sourceStream) query.stream = score.sourceStream;
if (score.targetScope === "session") {
return score.sessionId
? { name: "sessionDetails", query: { ...query, session_id: score.sessionId } }
: null;
}
const traceId = targetTraceId(score);
if (!traceId) return null;
const spanId = score.targetScope === "span" ? (score.spanId ?? score.targetId) : null;
return {
name: "traceDetails",
query: { ...query, trace_id: traceId, ...(spanId ? { span_id: spanId } : {}) },
};
}
/** Opens the judge's own trace on `_evaluator`, around the score write time. */
export function evaluatorTraceLink(score: QualityScore, org: string): RouteLocationRaw | null {
if (!score.evaluatorTraceId) return null;
return {
name: "traceDetails",
query: {
org_identifier: org,
stream: "_evaluator",
trace_id: score.evaluatorTraceId,
from: score.timestamp - LINK_WINDOW_US,
to: score.timestamp + LINK_WINDOW_US,
},
};
}
/** Opens a Score Config on the Score Configs tab of the same page. */
export function scoreConfigLink(
routeName: string,
org: string,
entityId: string,
action: "view" | "update",
): RouteLocationRaw {
return {
name: routeName,
query: { org_identifier: org, tab: "scoreConfigs", action, id: entityId },
};
}
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { emptyScopeCounts, qualityScopeWhere, scopeCountsFromRow } from "./qualityScope";
describe("qualityScope", () => {
it("leaves the combined view unfiltered", () => {
expect(qualityScopeWhere("all")).toBeNull();
});
it.each(["span", "trace", "session"] as const)(
"filters the detail view to %s scores",
(scope) => {
expect(qualityScopeWhere(scope)).toBe(`_target_scope = '${scope}'`);
},
);
it("normalizes SQL aggregate values into scope counts", () => {
expect(
scopeCountsFromRow({
span_count: "4",
trace_count: 2,
session_count: null,
}),
).toEqual({ span: 4, trace: 2, session: 0 });
expect(emptyScopeCounts()).toEqual({ span: 0, trace: 0, session: 0 });
});
});
@@ -1,43 +0,0 @@
import type { EvalTargetScope } from "@/services/online-evals.service";
export type QualityScope = "all" | EvalTargetScope;
export type ScopeCounts = {
span: number;
trace: number;
session: number;
};
export const QUALITY_TARGET_SCOPES: readonly EvalTargetScope[] = ["span", "trace", "session"];
export function emptyScopeCounts(): ScopeCounts {
return { span: 0, trace: 0, session: 0 };
}
/** Predicate for the normalized columns exposed by latestScoresFromSql(). */
export function qualityScopeWhere(scope: QualityScope): string | null {
return scope === "all" ? null : `_target_scope = '${scope}'`;
}
export function scopeCountsFromRow(
row:
| {
span_count?: unknown;
trace_count?: unknown;
session_count?: unknown;
}
| null
| undefined,
): ScopeCounts {
const count = (value: unknown): number => {
if (value == null) return 0;
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
return {
span: count(row?.span_count),
trace: count(row?.trace_count),
session: count(row?.session_count),
};
}
@@ -1,118 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ScoreConfig } from "@/services/online-evals.service";
import { gt } from "@/types/i18n";
import { qualitySummaryForConfig } from "./qualitySummary";
function config(
dataType: "numeric" | "boolean" | "categorical",
healthyThreshold?: Record<string, unknown>,
): ScoreConfig {
return {
id: "cfg-1",
name: "quality",
version: 1,
dataType,
healthyThreshold,
};
}
const emptyAgg = {
avgNumeric: null,
numericValues: 0,
numericUnhealthy: null,
trueScores: 0,
falseScores: 0,
};
describe("qualitySummaryForConfig", () => {
it("uses the overall average for numeric configs", () => {
expect(
qualitySummaryForConfig(config("numeric"), { ...emptyAgg, avgNumeric: 0.82 }, [], gt),
).toEqual({
qualityValue: 0.82,
qualityFormat: "number",
qualityLabel: "Average",
});
});
it("adds the configured healthy rate to a numeric average", () => {
expect(
qualitySummaryForConfig(
config("numeric", { direction: "gte", value: 0.7 }),
{
...emptyAgg,
avgNumeric: 0.82,
numericValues: 10,
numericUnhealthy: 2,
},
[],
gt,
),
).toEqual({
qualityValue: 0.82,
qualityFormat: "number",
qualityLabel: "Average · 80.0% healthy",
});
});
it("respects false as the configured healthy boolean value", () => {
expect(
qualitySummaryForConfig(
config("boolean", { healthy_value: false }),
{ ...emptyAgg, trueScores: 2, falseScores: 8 },
[],
gt,
).qualityValue,
).toBe(80);
});
it("uses true rate when no healthy boolean value is configured", () => {
expect(
qualitySummaryForConfig(
config("boolean"),
{ ...emptyAgg, trueScores: 3, falseScores: 1 },
[],
gt,
),
).toEqual({
qualityValue: 75,
qualityFormat: "percent",
qualityLabel: "True rate",
});
});
it("calculates the configured healthy-category rate", () => {
expect(
qualitySummaryForConfig(
config("categorical", { healthy_categories: ["good", "great"] }),
emptyAgg,
[
{ category: "good", count: 5 },
{ category: "great", count: 2 },
{ category: "bad", count: 3 },
],
gt,
).qualityValue,
).toBe(70);
});
it("uses the top category when no healthy set is configured", () => {
expect(
qualitySummaryForConfig(
config("categorical"),
emptyAgg,
[
{ category: "neutral", count: 3 },
{ category: "good", count: 6 },
],
gt,
),
).toEqual({
qualityValue: "good",
qualityFormat: "text",
qualityLabel: "Top category",
});
});
});
@@ -1,34 +1,7 @@
import type { TranslateFn } from "@/types/i18n";
import type { ScoreConfig } from "@/services/online-evals.service";
import { dataTypeOf } from "./evalEntity";
import { thresholdForConfig } from "./scoreThreshold";
export type QualityFormat = "number" | "percent" | "text";
export interface QualitySummary {
qualityValue: number | string | null;
qualityFormat: QualityFormat;
qualityLabel: string;
}
export interface QualitySummaryAggregate {
avgNumeric: number | null;
numericValues: number;
numericUnhealthy: number | null;
trueScores: number;
falseScores: number;
}
export interface CategoryCount {
category: string;
count: number;
}
function valueOf<T = any>(row: any, camel: string, snake: string): T | undefined {
if (row == null) return undefined;
return row[camel] ?? row[snake];
}
import { valueOf } from "./evalEntity";
/** The configured healthy value of a boolean Score Config, or null without one. */
export function healthyBooleanValue(config: ScoreConfig): boolean | null {
const threshold = valueOf<any>(config, "healthyThreshold", "healthy_threshold");
const healthy = threshold?.healthy_value ?? threshold?.healthyValue;
@@ -36,86 +9,3 @@ export function healthyBooleanValue(config: ScoreConfig): boolean | null {
if (healthy === false || healthy === "false") return false;
return null;
}
export function healthyCategories(config: ScoreConfig): string[] {
const threshold = valueOf<any>(config, "healthyThreshold", "healthy_threshold");
const categories = threshold?.healthy_categories ?? threshold?.healthyCategories;
return Array.isArray(categories) ? categories.map(String) : [];
}
export function qualitySummaryForConfig(
config: ScoreConfig,
aggregate: QualitySummaryAggregate,
categoryCounts: CategoryCount[],
t: TranslateFn,
): QualitySummary {
const type = dataTypeOf(config);
if (type === "numeric") {
const threshold = thresholdForConfig(config);
const healthyRate =
threshold.unhealthyExpr != null &&
aggregate.numericUnhealthy != null &&
aggregate.numericValues > 0
? ((aggregate.numericValues - aggregate.numericUnhealthy) / aggregate.numericValues) * 100
: null;
return {
qualityValue: aggregate.avgNumeric,
qualityFormat: "number",
qualityLabel:
healthyRate == null
? t("onlineEvals.quality.detail.kpis.average")
: t("onlineEvals.quality.averageWithHealthyRate", {
rate: healthyRate.toFixed(1),
}),
};
}
if (type === "boolean") {
const expected = healthyBooleanValue(config);
const total = aggregate.trueScores + aggregate.falseScores;
const numerator =
expected === true
? aggregate.trueScores
: expected === false
? aggregate.falseScores
: aggregate.trueScores;
return {
qualityValue: total > 0 ? (numerator / total) * 100 : null,
qualityFormat: "percent",
qualityLabel: expected == null ? t("onlineEvals.trueRate") : t("onlineEvals.healthyRate"),
};
}
if (type === "categorical") {
const healthy = healthyCategories(config);
const total = categoryCounts.reduce((sum, item) => sum + item.count, 0);
if (healthy.length > 0) {
const healthySet = new Set(healthy);
const healthyCount = categoryCounts.reduce(
(sum, item) => sum + (healthySet.has(item.category) ? item.count : 0),
0,
);
return {
qualityValue: total > 0 ? (healthyCount / total) * 100 : null,
qualityFormat: "percent",
qualityLabel: t("onlineEvals.healthyRate"),
};
}
const top = [...categoryCounts].sort(
(a, b) => b.count - a.count || a.category.localeCompare(b.category),
)[0];
return {
qualityValue: top?.category ?? null,
qualityFormat: "text",
qualityLabel: t("onlineEvals.topCategory"),
};
}
return {
qualityValue: null,
qualityFormat: "text",
qualityLabel: "",
};
}
@@ -1,147 +1,55 @@
// Copyright 2026 OpenObserve Inc.
//
// Shared helpers that translate a Score Config's `healthy_threshold` into the
// SQL fragments needed to flag unhealthy rows on `_llm_scores`.
//
// Quality page composables — `useQualityData` (org-wide KPI), `useQualityScoreConfigs`
// (per-config table) and `useQualityConfigDetail` (single-config drill-in) — all need
// the exact same threshold semantics. Keeping the translation in one place ensures
// the "unhealthy" count on the KPI tile, the per-config row, and the detail panel
// agree across data types (numeric / categorical / boolean).
// A Score Config's healthy rule as text ("≥ 0.7", "true", "low, medium"), and the SQL escape the score queries share.
import { raw, type I18nText } from "@/types/i18n";
import { gt, raw, type I18nText } from "@/types/i18n";
import type { ScoreConfig } from "@/services/online-evals.service";
import { dataTypeOf, entityId } from "./evalEntity";
export interface ThresholdSql {
/** SQL fragment evaluating to truthy when the row is unhealthy under this
* config's `healthy_threshold`. `null` when the config has no threshold
* defined (we can't classify the row). */
unhealthyExpr: string | null;
/** Human label for the threshold, e.g. "≥ 0.7", "true", "good · great". */
label: I18nText;
}
export type ScoreValue = number | string | boolean | null;
import { dataTypeOf, valueOf } from "./evalEntity";
import { formatExact } from "./qualityFormat";
export function escapeSqlString(s: string): string {
return s.replace(/'/g, "''");
}
function valueOf<T = any>(row: any, camel: string, snake: string): T | undefined {
if (row == null) return undefined;
return row[camel] ?? row[snake];
/** The healthy rule as text; empty when the config has no usable threshold. */
/** A numeric config's threshold as a value and a direction, for the trend's threshold line; null otherwise. */
export function numericThreshold(
config: ScoreConfig,
): { value: number; direction: "gte" | "lte" } | null {
const ht = valueOf<any>(config, "healthyThreshold", "healthy_threshold");
if (dataTypeOf(config) !== "numeric" || ht?.value == null || !ht.direction) return null;
const value = Number(ht.value);
return Number.isFinite(value)
? { value, direction: ht.direction === "gte" ? "gte" : "lte" }
: null;
}
export function thresholdForConfig(config: ScoreConfig): ThresholdSql {
export function thresholdForConfig(config: ScoreConfig): { label: I18nText } {
const ht = valueOf<any>(config, "healthyThreshold", "healthy_threshold");
const type = dataTypeOf(config);
if (!ht) return { unhealthyExpr: null, label: raw("") };
if (!ht) return { label: raw("") };
if (type === "numeric") {
if (ht.value === undefined || ht.value === null || !ht.direction) {
return { unhealthyExpr: null, label: raw("") };
}
const op = ht.direction === "gte" ? "<" : ">";
const sym = ht.direction === "gte" ? "≥" : "≤";
return {
unhealthyExpr: `value_numeric ${op} ${Number(ht.value)}`,
label: raw(`${sym} ${ht.value}`),
};
if (ht.value === undefined || ht.value === null || !ht.direction) return { label: raw("") };
const value = Number(ht.value);
// Exact, never rounded: a rounded rule would contradict the scores it classifies.
const shown = Number.isFinite(value) ? formatExact(value) : String(ht.value);
return { label: raw(`${ht.direction === "gte" ? "≥" : "≤"} ${shown}`) };
}
if (type === "categorical") {
const list: string[] = ht.healthy_categories || ht.healthyCategories || [];
if (!Array.isArray(list) || list.length === 0) {
return { unhealthyExpr: null, label: raw("") };
}
const inList = list.map((c) => `'${escapeSqlString(String(c))}'`).join(", ");
return {
unhealthyExpr: `value_categorical NOT IN (${inList})`,
label: raw(list.join(" · ")),
};
const list: unknown = ht.healthy_categories ?? ht.healthyCategories;
if (!Array.isArray(list)) return { label: raw("") };
// A missing list means no threshold, but the backend marks every score unhealthy for an empty one, so the rule says so.
return { label: list.length ? raw(list.join(", ")) : gt("onlineEvals.quality.noHealthyValue") };
}
if (type === "boolean") {
const healthy = ht.healthy_value ?? ht.healthyValue;
if (healthy === undefined || healthy === null) {
return { unhealthyExpr: null, label: raw("") };
}
const expected = healthy === true || healthy === "true";
return {
unhealthyExpr: `value_boolean = ${!expected}`,
label: raw(String(expected)),
};
if (healthy === undefined || healthy === null) return { label: raw("") };
return { label: raw(String(healthy === true || healthy === "true")) };
}
return { unhealthyExpr: null, label: raw("") };
}
/** Classify one concrete score using the same semantics as
* `thresholdForConfig()`. `null` means the score cannot be classified because
* the config has no usable healthy threshold (or the result has no value).
*
* Keeping the client-side run table on this helper prevents it from drifting
* away from the SQL aggregates that drive the overview and detail KPIs. */
export function isScoreUnhealthy(config: ScoreConfig, score: ScoreValue): boolean | null {
if (score == null) return null;
const ht = valueOf<any>(config, "healthyThreshold", "healthy_threshold");
const type = dataTypeOf(config);
if (!ht) return null;
if (type === "numeric") {
if (ht.value === undefined || ht.value === null || !ht.direction) {
return null;
}
const numericScore = typeof score === "number" ? score : Number(String(score));
const threshold = Number(ht.value);
if (!Number.isFinite(numericScore) || !Number.isFinite(threshold)) {
return null;
}
return ht.direction === "gte" ? numericScore < threshold : numericScore > threshold;
}
if (type === "categorical") {
const healthy: string[] = ht.healthy_categories || ht.healthyCategories || [];
if (!Array.isArray(healthy) || healthy.length === 0) return null;
return !healthy.map(String).includes(String(score));
}
if (type === "boolean") {
const healthy = ht.healthy_value ?? ht.healthyValue;
if (healthy === undefined || healthy === null) return null;
const expected = healthy === true || healthy === "true";
const actual = score === true || score === "true";
return actual !== expected;
}
return null;
}
/** Build the per-config CASE branches that flag a `_llm_scores` row as
* unhealthy. The caller decides what each branch returns — `1` for
* total-row counts (`COUNT(CASE ... END)`) or `span_id` for distinct-span
* counts (`COUNT(DISTINCT CASE ... END)`).
*
* Returns `null` when no config in the list has a usable threshold; the
* caller should fall back to `CAST(NULL AS INTEGER)` so the column still
* exists in the result set. */
export function buildUnhealthyCaseBranches(
configs: ScoreConfig[],
thenExpr: string,
): string | null {
const branches: string[] = [];
for (const cfg of configs) {
const cfgId = entityId(cfg);
if (!cfgId) continue;
const t = thresholdForConfig(cfg);
if (!t.unhealthyExpr) continue;
branches.push(
` WHEN CAST(score_config_id AS VARCHAR) = '${escapeSqlString(cfgId)}' AND (${t.unhealthyExpr}) THEN ${thenExpr}`,
);
}
if (branches.length === 0) return null;
return ["CASE", ...branches, " END"].join("\n");
return { label: raw("") };
}
@@ -456,6 +456,90 @@ describe("useAgentScope — Env→Agent→Version cascade (Plan 2)", () => {
});
});
describe("useAgentScope — cascade with the All option (Quality)", () => {
const AGENTS: GenAiAgentListItem[] = [
{
name: "a",
id: "a",
source_stream: "s",
source_stream_type: "traces",
env: "prod",
version: "v1",
},
{
name: "a",
id: "a",
source_stream: "s",
source_stream_type: "traces",
env: "prod",
version: "v2",
},
{
name: "b",
id: "b",
source_stream: "s",
source_stream_type: "traces",
env: "dev",
version: "v1",
},
{
name: "x",
id: "x",
source_stream: "s",
source_stream_type: "traces",
env: null,
version: null,
},
];
function makeScope(allOption?: boolean) {
return useAgentScope({
filterMode: ref<"stream" | "agent">("agent"),
activeStream: ref(""),
orgId: () => "org1",
getWindow: () => ({ start: 100, end: 200 }),
cascade: true,
allOption,
agents: ref<GenAiAgentListItem[]>(AGENTS),
agentsLoaded: ref(true),
t,
});
}
it("starts every level on All and lists it first", () => {
const scope = makeScope(true);
expect(scope.selectedEnv.value).toBe(ALL_AGENTS_VALUE);
expect(scope.selectedAgentName.value).toBe(ALL_AGENTS_VALUE);
expect(scope.selectedVersion.value).toBe(ALL_AGENTS_VALUE);
expect(scope.envs.value.map((o) => o.value)).toEqual([ALL_AGENTS_VALUE, "prod", "dev"]);
expect(scope.agentNames.value.map((o) => o.value)).toEqual([ALL_AGENTS_VALUE, "a", "b", "x"]);
expect(scope.versions.value.map((o) => o.value)).toEqual([ALL_AGENTS_VALUE, "v1", "v2"]);
expect(scope.envs.value[0].label).toBe("common.all");
expect(scope.agentNames.value[0].label).toBe("traces.allAgents");
expect(scope.selectedAgent.value).toBeNull();
});
it("narrows the lower levels without picking a value, and resets them to All", () => {
const scope = makeScope(true);
scope.selectedEnv.value = "prod";
expect(scope.agentNames.value.map((o) => o.value)).toEqual([ALL_AGENTS_VALUE, "a"]);
expect(scope.selectedAgentName.value).toBe(ALL_AGENTS_VALUE);
scope.selectedAgentName.value = "a";
scope.selectedVersion.value = "v2";
scope.selectedEnv.value = "dev";
expect(scope.selectedAgentName.value).toBe(ALL_AGENTS_VALUE);
expect(scope.selectedVersion.value).toBe(ALL_AGENTS_VALUE);
});
it("without the option keeps the first-value default and no All entry", () => {
const scope = makeScope();
expect(scope.selectedEnv.value).toBe("prod");
expect(scope.selectedAgentName.value).toBe("a");
expect(scope.selectedVersion.value).toBe("v1");
expect(scope.envs.value.map((o) => o.value)).toEqual(["prod", "dev", UNSET]);
});
});
describe("useAgentScope — stream options", () => {
it("streamSelectOptions + selectedStreamCount reflect availableStreams and agent counts", async () => {
const activeStream = ref("stream_a");
@@ -87,6 +87,8 @@ export interface UseAgentScopeOptions {
* requiring an explicit version pick.
*/
versionAgnostic?: boolean;
/** Cascade only (Quality): each level offers and starts on "All"; the unset buckets are left out, as exact-match APIs cannot filter on them. */
allOption?: boolean;
/** Module-scoped agents ref (Sessions/LLM) or created locally (Graph/Behavior). */
agents?: Ref<GenAiAgentListItem[]>;
/** Module-scoped agentsLoaded ref, created locally when omitted. */
@@ -200,25 +202,54 @@ export function useAgentScope(opts: UseAgentScopeOptions): UseAgentScopeReturn {
return out;
}
const allOption = cascade && (opts.allOption ?? false);
// With allOption, an "All" level matches every agent and the unset bucket is not offered.
const levelMatches = (selected: string, value: string) =>
(allOption && selected === ALL_AGENTS_VALUE) || selected === value;
const withAll = (options: SelectOption[], allKey: string): SelectOption[] =>
allOption
? [
{ label: raw(opts.t(allKey)), value: ALL_AGENTS_VALUE },
...options.filter((o) => o.value !== UNSET),
]
: options;
const envs = computed<SelectOption[]>(() =>
cascade ? distinctOptions(agents.value.map(agentEnv), opts.t("traces.agentNoEnv")) : [],
cascade
? withAll(
distinctOptions(agents.value.map(agentEnv), opts.t("traces.agentNoEnv")),
"common.all",
)
: [],
);
const agentNames = computed<SelectOption[]>(() =>
cascade
? distinctOptions(
agents.value.filter((a) => agentEnv(a) === selectedEnv.value).map((a) => a.name),
? withAll(
distinctOptions(
agents.value
.filter((a) => levelMatches(selectedEnv.value, agentEnv(a)))
.map((a) => a.name),
),
"traces.allAgents",
)
: [],
);
const versions = computed<SelectOption[]>(() =>
cascade
? distinctOptions(
agents.value
.filter((a) => agentEnv(a) === selectedEnv.value && a.name === selectedAgentName.value)
.map(agentVersion),
opts.t("traces.agentNoVersion"),
? withAll(
distinctOptions(
agents.value
.filter(
(a) =>
levelMatches(selectedEnv.value, agentEnv(a)) &&
levelMatches(selectedAgentName.value, a.name),
)
.map(agentVersion),
opts.t("traces.agentNoVersion"),
),
"common.all",
)
: [],
);
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// Tests for useAiDateRange — the shared date-range singleton used by LLM
// Insights, LLM Sessions, and Quality. Covers:
// Insights and LLM Sessions. Covers:
// • initial load from localStorage (defaults, malformed JSON, garbled
// field types, valid persisted state)
// • writes mirror back to localStorage (incl. quota / private-mode
@@ -1,18 +1,6 @@
// Copyright 2026 OpenObserve Inc.
//
// Shared date-range state for the AI Observability module. LLM Insights,
// LLM Sessions, and Quality all bind to the same singleton ref so picking
// "Past 1h" on one page lands on the others (incl. across reloads — the
// state is mirrored to a single localStorage key).
//
// Each page keeps its own picker component:
// • LLM Insights / Sessions use `<date-time>` (default-type/-absolute/-relative)
// • Quality uses `<DateTimePickerDashboard>` (v-model with the same shape)
//
// The composable normalizes both to one shape, so a write from either
// surface is visible to the others on next mount (and reactively while
// they're all mounted in the same shell — the singleton ref makes the
// state reactive across components).
// Shared date range for LLM Insights and LLM Sessions, mirrored to one localStorage key; Quality keeps its own range in the URL.
import { ref, watch } from "vue";
@@ -66,4 +66,35 @@ describe("ReviewContentBox", () => {
"aiObservability.queues.workbench.exitFullscreen",
);
});
it("shows the empty text inside the box when there is no content", () => {
const wrapper = mountBox({ content: "", emptyText: "Output not captured" });
expect(wrapper.find('[data-test="ai-review-content-empty-input"]').text()).toBe(
"Output not captured",
);
expect(wrapper.find(".llm").exists()).toBe(false);
});
it("keeps rendering the content, and stays blank when empty, without the empty text", () => {
expect(mountBox({ emptyText: "Output not captured" }).find(".llm").exists()).toBe(true);
const blank = mountBox({ content: "" });
expect(blank.find('[data-test="ai-review-content-empty-input"]').exists()).toBe(false);
expect(blank.find(".llm").exists()).toBe(true);
});
it("shows no skeleton by default, and skeleton lines in place of the content while loading", () => {
const idle = mountBox({ emptyText: "Output not captured" });
expect(idle.find('[data-test="ai-review-content-skeleton-input"]').exists()).toBe(false);
expect(idle.find(".llm").exists()).toBe(true);
const loading = mountBox({ loading: true, emptyText: "Output not captured" });
const skeleton = loading.find('[data-test="ai-review-content-skeleton-input"]');
expect(skeleton.findAll('[role="status"]').length).toBeGreaterThan(1);
expect(loading.find(".llm").exists()).toBe(false);
expect(loading.find('[data-test="ai-review-content-empty-input"]').exists()).toBe(false);
// The frame keeps its label and buttons, so nothing moves when the content lands.
expect(loading.text()).toContain("Input");
expect(loading.find('[data-test="ai-review-content-copy-input"]').exists()).toBe(true);
expect(loading.find('[data-test="ai-review-content-expand-input"]').exists()).toBe(true);
});
});
@@ -42,7 +42,25 @@
class="border-card-glass-border rounded-default bg-code-bg overflow-x-auto overflow-y-auto border p-3"
:class="fill ? 'min-h-0 flex-1' : 'max-h-[24rem]'"
>
<div
v-if="loading"
class="flex flex-col gap-2"
:data-test="`ai-review-content-skeleton-${contentType}`"
>
<OSkeleton type="text" class="h-3 w-11/12" />
<OSkeleton type="text" class="h-3 w-full" />
<OSkeleton type="text" class="h-3 w-4/5" />
<OSkeleton type="text" class="h-3 w-2/3" />
</div>
<div
v-else-if="!content && emptyText"
class="text-text-secondary p-8 text-center text-sm italic"
:data-test="`ai-review-content-empty-${contentType}`"
>
{{ emptyText }}
</div>
<LLMContentRenderer
v-else
:content="content"
:content-type="contentType"
view-mode="formatted"
@@ -56,6 +74,7 @@
import { useI18nTyped, type I18nText } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import LLMContentRenderer from "@/plugins/traces/LLMContentRenderer.vue";
import { copyToClipboard } from "@/utils/clipboard";
@@ -69,8 +88,12 @@ const props = withDefaults(
fill?: boolean;
/** Whether the container this box belongs to is currently fullscreen. */
fullscreen?: boolean;
/** Shown inside the box when there is no content; without it an empty box stays blank. */
emptyText?: I18nText;
/** Skeleton lines in place of the content while it loads; the frame, label and buttons stay. */
loading?: boolean;
}>(),
{ fill: false, fullscreen: false },
{ fill: false, fullscreen: false, loading: false },
);
const emit = defineEmits<{ (_e: "toggle-fullscreen"): void }>();
+3 -6
View File
@@ -1817,14 +1817,11 @@ export const BADGE_GROUPS = {
mode: "dot",
shape: "pill",
class: "!bg-transparent !p-0 !ring-0",
// Keys are the Quality API `status` values (`no_data` normalises to `nodata`).
values: {
attention: { variant: "error-soft", labelKey: "components.badge.qualityStatus.attention" },
healthy: { variant: "success-soft", labelKey: "components.badge.qualityStatus.healthy" },
warn: { variant: "warning-soft", labelKey: "components.badge.qualityStatus.warn" },
unhealthy: { variant: "error-soft", labelKey: "components.badge.qualityStatus.unhealthy" },
nothreshold: {
variant: "default-soft",
labelKey: "components.badge.qualityStatus.nothreshold",
},
unset: { variant: "default-soft", labelKey: "components.badge.qualityStatus.unset" },
nodata: { variant: "default-soft", labelKey: "components.badge.qualityStatus.nodata" },
},
fallback: { variant: "default-soft" },
+14
View File
@@ -28,6 +28,20 @@ describe("OButton", () => {
// --- Props ---
it("cell variant keeps a visible focus ring and the control radius", () => {
const classes = mount(OButton, { props: { variant: "cell" } }).classes();
expect(classes).toContain("focus-visible:ring-3");
expect(classes).toContain("rounded-default");
expect(classes).toContain("h-auto!");
});
it("cell variant dims when unavailable but focusable, like the other variants", () => {
const classes = mount(OButton, {
props: { variant: "cell", disabled: true, focusableUnavailable: true },
}).classes();
expect(classes).toContain("opacity-60");
});
it('defaults to type="button"', () => {
const wrapper = mount(OButton);
expect(wrapper.attributes("type")).toBe("button");
+3 -1
View File
@@ -64,7 +64,9 @@ export type ButtonVariant =
| "tint-teal"
| "tint-purple"
| "tint-blue"
| "tint-orange";
| "tint-orange"
// Table-cell content that is itself the action: no chrome, full cell width, stacked lines.
| "cell";
/** Size controls height, padding, font-size, and border-radius */
export type ButtonSize =
+9
View File
@@ -284,6 +284,14 @@ const variantClasses: Record<NonNullable<ButtonProps["variant"]>, string> = {
"focus-visible:ring-3 focus-visible:ring-button-ghost-focus-ring",
"disabled:opacity-50",
].join(" "),
// cell: table-cell content that is itself the action; overrides the size box so the lines stack.
cell: [
"bg-transparent border-0 text-inherit text-left font-normal rounded-default",
"h-auto! w-full p-0! flex-col! items-stretch! justify-center! gap-1! whitespace-normal!",
"enabled:hover:opacity-80",
"focus-visible:ring-3 focus-visible:ring-button-ghost-focus-ring",
"disabled:opacity-60",
].join(" "),
// pricing-chip: Pill-shaped toggle chip for model pricing quick-setup template selection
"pricing-chip": [
"bg-transparent text-inherit border border-border-default",
@@ -357,6 +365,7 @@ const disabledVariantClasses: Record<NonNullable<ButtonProps["variant"]>, string
"tint-purple": "opacity-60",
"tint-blue": "opacity-60",
"tint-orange": "opacity-60",
cell: "opacity-60",
};
const unavailableClasses = computed(() => {
+2 -2
View File
@@ -503,8 +503,8 @@
--color-status-error-text: #cc3645;
--color-status-error-bg: #fff7f7;
/* Nudged from #208a3c (4.41:1) to clear 4.5:1 for its several genuine
plain-text uses (QualityRunsTable.vue, AppSessions.vue, MonitorRuns.vue
KPI values) — imperceptibly darker. */
plain-text uses (AppSessions.vue, MonitorRuns.vue KPI values) — imperceptibly
darker. */
--color-status-success-text: #20883b;
--color-status-success-bg: #f2fcf3;
}
@@ -200,6 +200,7 @@ export const SHORTCUT_MODULES: ShortcutModule[] = [
titleKey: "shortcuts.modules.onlineEvals",
pages: [
"shortcuts.pages.evalTemplates",
"shortcuts.pages.quality",
"shortcuts.pages.scorers",
"shortcuts.pages.evalJobs",
"shortcuts.pages.scoreConfigs",
@@ -1081,6 +1082,16 @@ export const SHORTCUT_REGISTRY: ShortcutGroup[] = [
],
},
// ── Online Evals — Quality ──────────────────────────────────────────────
{
pageKey: "shortcuts.pages.quality",
scope: "quality",
visible: onlineEvals,
shortcuts: [
{ id: "qualityBack", key: "escape", descriptionKey: "shortcuts.actions.qualityBack" },
],
},
// ── Online Evals — Scorers ──────────────────────────────────────────────
{
pageKey: "shortcuts.pages.scorers",
+105 -129
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "جميع الوكلاء",
"agentLabel": "وكيل",
"agentPlaceholder": "جميع الوكلاء",
"kpisAriaLabel": "مؤشرات الأداء الرئيسية من المستوى الأول",
"dataTypes": {
"numericShort": "نوم",
"categoricalShort": "قط",
"booleanShort": "بول"
"overview": {
"relativeTime": {
"daysAgo": "منذ {day}d"
}
},
"healthyIf": "سليم إذا {rule}",
"noHealthyValue": "لا يوجد",
"setThreshold": "عيّن عتبة صحية",
"noScoresInWindow": "لا توجد درجات في هذه النافذة",
"outOf": "{count} من {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "نطاق النقاط",
"filterHint": "قارن هذا التهيئة النقدية عبر نطاقات التقييم المستهدفة.",
"all": "الجميع",
"span": "الامتداد",
"trace": "التتبع",
"session": "الجلسة"
},
"kpis": {
"scoreResults": {
"title": "نتائج النتائج"
},
"evaluationCost": {
"title": "تكلفة التقييم"
},
"scorerSuccess": {
"title": "نجاح أدوات التقييم"
},
"scorerFailures": {
"title": "إخفاقات أدوات التقييم"
},
"latencyP95": {
"title": "زمن استجابة أدوات التقييم p95"
},
"vsPrev": "مقابل السابق",
"noPriorData": "لا توجد بيانات سابقة",
"noData": "—"
"scopeNouns": {
"span": "الامتداد",
"trace": "التتبع",
"session": "الجلسة"
},
"tiles": {
"attention": "إعدادات تحتاج إلى انتباه",
"ofTotal": "من {total}",
"attentionNote": "اعرضها",
"attentionTooltip": "إعدادات بها درجة غير سليمة واحدة على الأقل",
"withoutResult": "إعدادات بدون نتيجة",
"withoutResultTooltip": "الإعدادات التي لا تحتوي على درجات في هذا النطاق الزمني، أو التي لا تحتوي على عتبة صحية. انقر لعرضها.",
"noScores": "{count} بدون درجات | {count} بدون درجات",
"noThreshold": "{count} بدون عتبة | {count} بدون عتبة",
"allGiveResult": "كل إعداد يعطي نتيجة"
},
"table": {
"searchPlaceholder": "تصفية الإعدادات…",
"openUnhealthy": "افتح الدرجات غير السليمة",
"configLink": "الإعداد",
"averageRange": "المتوسط، {min} إلى {max}",
"mostCommon": "الأكثر شيوعًا · {count} من {total}",
"columns": {
"scoreConfig": "إعداد التقييم",
"health": "الحالة الصحية",
"typicalScore": "الدرجة النموذجية",
"unhealthy": "غير سليمة",
"scores": "الدرجات",
"updated": "آخر تحديث"
}
},
"detail": {
"loading": "جاري تحميل تقسيم كل إعداد...",
"noData": "لا توجد درجات حتى الآن لهذا التكوين في هذه النافذة.",
"empty": {
"title": "تنتظر نتائجك الأولى",
"titleScoped": "لا توجد درجات {scope} في هذه النافذة",
"description": "لم تصل أي درجات لهذا الإعداد في النطاق الزمني المحدد. ستظهر هنا تلقائيًا بمجرد تشغيل أداة تقييم تستخدمه.",
"descriptionScoped": "لم تصل أي درجات {scope} لهذا التكوين في النطاق الزمني المحدد. اختر نطاق آخر أو وسع النطاق الزمني."
"back": "كل الإعدادات",
"backToAll": "العودة إلى كل الإعدادات",
"editConfig": "تعديل الإعداد",
"notFound": "إعداد التقييم غير موجود",
"notFoundDescription": "ربما تم حذفه، أو يشير الرابط إلى إعداد قديم.",
"emptyTitle": "بانتظار أول درجاتك",
"emptyTitleScoped": "لا توجد درجات {scope} في هذه النافذة",
"emptyDescription": "لم تصل أي درجات لهذا الإعداد في النطاق الزمني المحدد. ستظهر هنا بمجرد تشغيل أداة تقييم تستخدمه.",
"distributionNumeric": "توزيع الدرجات",
"distributionValues": "توزيع القيم",
"averageValue": "المتوسط {value}",
"legendHealthy": "سليم",
"legendUnhealthy": "غير سليم",
"legendScores": "الدرجات",
"chartTooltip": "{count} درجة، {unhealthy} غير سليمة | {count} درجة، {unhealthy} غير سليمة",
"chartTooltipCount": "{count} درجة | {count} درجات",
"trendTitle": {
"score": "الاتجاه",
"healthyRate": "نسبة السليمة عبر الوقت",
"trueRate": "نسبة القيم الصحيحة عبر الوقت",
"count": "الدرجات عبر الوقت"
},
"trendLegend": {
"healthyRate": "نسبة السليمة",
"trueRate": "معدل القيم الصحيحة",
"count": "الدرجات"
},
"trendTitle": "الاتجاه",
"distributionTitle": "توزيع النقاط",
"categoryDistributionTitle": "توزيع الفئات",
"healthyRateTitle": "معدل صحي مع مرور الوقت",
"trueRateTitle": "المعدل الحقيقي مع مرور الوقت",
"trueFalseTitle": "صحيح مقابل خطأ",
"legendAvg": "AVG",
"legendThreshold": "عتبة {'{'}direction{'}'} {'{'}value{'}'}",
"legendThreshold": "العتبة {direction} {value}",
"markLineHealthy": "{direction} {value} صحي",
"healthyThreshold": "{threshold} صحي",
"legendHealthy": "صحي",
"legendUnhealthy": "غير صحي",
"legendHealthyRate": "معدل صحي",
"legendTrueRate": "المعدل الحقيقي",
"legendTrueCount": "صحيح ({count})",
"legendFalseCount": "false ({count})",
"kpis": {
"average": "المتوسط",
"p50": "P50",
"p95": "P95",
"unhealthy": "غير صحي",
"healthy": "سليم",
"trueRate": "المعدل الحقيقي",
"falseRate": "معدل الكاذب",
"coverage": "التغطية",
"targetsScored": "الأهداف المسجلة"
},
"health": {
"title": "الحالة الصحية",
"noThreshold": "لا يتم تحديد عتبة صحية. لا يمكن تصنيف الدرجات كصحية أو مهينة بعد.",
"thresholdOnly": "صحي إذا {threshold}",
"summary": "{percent}٪ تحت العتبة · صحي إذا {threshold}"
},
"ofScoreResults": "من إجمالي نتائج التقييم البالغ عددها {count}",
"range": "النطاق {min}–{max}"
"filterRange": "من {from} إلى {to}",
"filterBelow": "أقل من {to}",
"filterAtLeast": "{from} أو أكثر",
"filterAny": "أي قيمة",
"filterByBar": "فلترة الدرجات حسب القيمة",
"barButton": "{label}: {count} درجة | {label}: {count} درجة",
"clearFilter": "مسح الفلتر"
},
"runs": {
"review": {
"title": "درجات التقييم",
"hint": "تأتي الدرجات والتعليلات من _llm_scores. اختر تشغيلًا لفتح تتبع أداة التقييم الخاص بها لمزيد من التحقيق.",
"filterLabel": "تصفية درجات التقييم",
"noThresholdHint": "حدد عتبة صحية في هذا التكوين لتصنيف وتسليط الضوء على الدرجات غير الصحية.",
"noReasoning": "لم يتم تسجيل أي سبب",
"unknownAgent": "وكيل غير معروف",
"unknownScope": "نطاق غير معروف",
"queryError": "لم يكن بالإمكان تحديث درجات التقييم. لا تزال الصفحة الناجحة الأخيرة معروضة.",
"empty": "لا توجد درجات تقييم في هذا الإطار الزمني.",
"emptyUnhealthy": "لا توجد درجات غير صحية في هذا الإطار الزمني.",
"filters": {
"all": "جميع الدرجات",
"unhealthy": "غير صحي"
},
"health": {
"healthy": "سليم",
"unhealthy": "غير صحي"
},
"columns": {
"time": "تم التقييم",
"result": "الموسيقى التصويرية",
"reasoning": "الأسباب",
"target": "الهدف",
"agent": "وكيل"
}
"all": "الكل",
"unhealthy": "غير سليم",
"unhealthyDisabled": "عيّن عتبة سليمة لتصنيف الدرجات",
"inputNotCaptured": "لم يتم تسجيل المدخلات",
"outputNotCaptured": "لم يتم تسجيل المخرجات",
"noMatch": "لا توجد درجات تطابق هذا الفلتر"
},
"overview": {
"searchPlaceholder": "تصفية الإعدادات…",
"relativeTime": {
"daysAgo": "منذ {day}d",
"monthsAgo": "منذ {month}mo"
"pane": {
"noneSelected": "لم يتم اختيار أي درجة",
"healthy": "سليم",
"unhealthy": "غير سليم",
"noThreshold": "لم يتم تعيين عتبة سليمة",
"previous": "الدرجة السابقة",
"next": "الدرجة التالية",
"reasoning": "التعليل",
"noReasoning": "لم يتم تسجيل أي تعليل",
"fullTextUnavailable": "يتم عرض معاينة قصيرة. تعذّر تحميل التتبع الكامل.",
"open": {
"span": "فتح الامتداد",
"trace": "فتح التتبع",
"session": "فتح الجلسة"
},
"status": {
"healthy": "سليم",
"unhealthy": "يحتاج إلى اهتمام",
"noThreshold": "العتبة غير محددة",
"noData": "لا توجد بيانات نقاط"
},
"health": {
"thresholdSummary": "{percent}٪ أقل · صحي إذا {threshold}",
"noThresholdHint": "عيّن عتبة سليمة",
"noDataHint": "لا توجد نقاط في هذه النافذة"
},
"columns": {
"health": "الصحة",
"scoreConfig": "إعدادات النقاط",
"type": "النوع",
"quality": "الجودة",
"totalScores": "إجمالي النتائج",
"scopeMix": "مزيج التسكوب",
"volumeTrend": "اتجاه الحجم",
"updated": "تحديث"
}
},
"averageWithHealthyRate": "متوسط · {rate}٪ صحي"
"evaluatorTrace": "تتبع المقيِّم",
"addToDataset": "إضافة إلى مجموعة البيانات",
"datasetSession": "تقبل مجموعات البيانات تتبعًا واحدًا أو امتدادًا واحدًا. افتح الجلسة لإضافة أحد تتبعاتها.",
"datasetNoStream": "لا يوجد تدفق مصدر لهذه الدرجة للإضافة منه."
}
},
"singular": {
"jobs": "مهمة التقييم",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "استيراد ({count})",
"count": "الكونت",
"failedToDeriveSchema": "فشل في اشتقاق المخطط",
"failedToImportScoreConfig": "فشل في استيراد إعدادات النقاط",
"failedToImportScorer": "فشل استيراد أداة التقييم",
"failedToFetchCatalog": "فشل في جلب الكتالوج ({status})",
"failedToLoadCatalog": "فشل تحميل الكتالوج",
"failedToRunScorerTest": "فشل تشغيل اختبار أداة التقييم",
"healthyRate": "معدل صحي",
"invalidJsonFormat": "صيغة JSON غير صالحة",
"jsonIsEmpty": "JSON فارغ",
"scorerTestFailed": "فشل اختبار أداة التقييم",
"scorerTestSampleContext": "ملف فرنسا في البلاد: باريس هي العاصمة.",
"scorerTestSampleInput": "عاصمة فرنسا هي باريس، الواقعة على نهر السين.",
"scorerTestSampleOutput": "باريس هي عاصمة فرنسا.",
"topCategory": "الفئة العليا",
"trueRate": "المعدل الحقيقي",
"unknownError": "خطأ غير معروف",
"import": {
"createdSuccessfully": "\"{name}\" تم إنشاؤه بنجاح",
@@ -20191,6 +20166,7 @@
"evalTemplates": "التقييمات الإلكترونية — القوالب",
"scorers": "التقييمات عبر الإنترنت — أدوات التقييم",
"evalJobs": "التقييمات عبر الإنترنت — المهام",
"quality": "التقييمات المباشرة — الجودة",
"scoreConfigs": "التقييمات الإلكترونية — إعدادات النقاط",
"actions": "الإجراءات",
"llmProviders": "مزودو LLM",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "تحديث القائمة",
"scorersRefresh": "تحديث القائمة",
"evalJobsRefresh": "تحديث القائمة",
"qualityBack": "العودة إلى جميع الإعدادات",
"scoreConfigsRefresh": "تحديث القائمة",
"actionsRefresh": "تحديث القائمة",
"llmProvidersRefresh": "تحديث القائمة",
@@ -20552,11 +20529,10 @@
"high": "عالي"
},
"qualityStatus": {
"nothreshold": "لا عتبة",
"nodata": "لا توجد بيانات",
"attention": "يحتاج إلى انتباه",
"healthy": "سليم",
"warn": "تحذير",
"unhealthy": "غير صحي"
"unset": "لم يتم تعيين العتبة",
"nodata": "لا توجد بيانات تقييم"
},
"rumEventType": {
"longtask": "مهمة طويلة",
+106 -130
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Alle Agents",
"agentLabel": "Agent",
"agentPlaceholder": "Alle Agenten",
"kpisAriaLabel": "Tier-1-KPIs",
"dataTypes": {
"numericShort": "Numerisch",
"categoricalShort": "Kategorie",
"booleanShort": "Boolesch"
"overview": {
"relativeTime": {
"daysAgo": "vor {day}T"
}
},
"healthyIf": "Gesund, wenn {rule}",
"noHealthyValue": "keine",
"setThreshold": "Gesunden Schwellenwert festlegen",
"noScoresInWindow": "Keine Scores in diesem Zeitfenster",
"outOf": "{count} von {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Bewertungsumfang",
"filterHint": "Vergleichen Sie diese Bewertungskonfiguration über die Evaluierungszielbereiche hinweg.",
"all": "Alle",
"span": "Span",
"trace": "Trace",
"session": "Session"
},
"kpis": {
"scoreResults": {
"title": "Bewertungsergebnisse"
},
"evaluationCost": {
"title": "Kosten der Bewertung"
},
"scorerSuccess": {
"title": "Scorer-Erfolg"
},
"scorerFailures": {
"title": "Scorer-Fehler"
},
"latencyP95": {
"title": "Scorer-Latenz p95"
},
"vsPrev": "gegen voriges",
"noPriorData": "keine vorherigen Daten",
"noData": "—"
"scopeNouns": {
"span": "Span",
"trace": "Trace",
"session": "Sitzung"
},
"tiles": {
"attention": "Konfigurationen mit Handlungsbedarf",
"ofTotal": "von {total}",
"attentionNote": "Anzeigen",
"attentionTooltip": "Konfigurationen mit mindestens einem ungesunden Score",
"withoutResult": "Konfigurationen ohne Ergebnis",
"withoutResultTooltip": "Konfigurationen ohne Scores in diesem Zeitraum oder ohne gesunden Schwellenwert. Klicken, um sie anzuzeigen.",
"noScores": "{count} hat keine Scores erhalten | {count} haben keine Scores erhalten",
"noThreshold": "{count} hat keinen Schwellenwert | {count} haben keinen Schwellenwert",
"allGiveResult": "Jede Konfiguration liefert ein Ergebnis"
},
"table": {
"searchPlaceholder": "Konfigurationen filtern…",
"openUnhealthy": "Ungesunde Scores öffnen",
"configLink": "Konfiguration",
"averageRange": "Durchschnitt, {min} bis {max}",
"mostCommon": "am häufigsten · {count} von {total}",
"columns": {
"scoreConfig": "Score-Konfiguration",
"health": "Gesundheit",
"typicalScore": "Typischer Score",
"unhealthy": "Ungesund",
"scores": "Scores",
"updated": "Aktualisiert"
}
},
"detail": {
"loading": "Aufschlüsselung pro Konfiguration wird geladen...",
"noData": "Noch keine Ergebnisse für diese Konfiguration in diesem Fenster.",
"empty": {
"title": "Warten auf Ihre ersten Scores",
"titleScoped": "Keine {scope}-Bewertungen in diesem Zeitfenster",
"description": "Für diese Konfiguration sind im ausgewählten Zeitbereich keine Scores eingegangen. Sie erscheinen hier automatisch, sobald ein Scorer ausgeführt wird, der sie verwendet.",
"descriptionScoped": "Für diese Konfiguration sind im ausgewählten Zeitraum keine {scope}-Bewertungen eingegangen. Wählen Sie einen anderen Bereich oder erweitern Sie den Zeitraum."
"back": "Alle Konfigurationen",
"backToAll": "Zurück zu allen Konfigurationen",
"editConfig": "Konfiguration bearbeiten",
"notFound": "Score-Konfiguration nicht gefunden",
"notFoundDescription": "Möglicherweise wurde sie gelöscht, oder der Link verweist auf eine alte Konfiguration.",
"emptyTitle": "Warten auf Ihre ersten Scores",
"emptyTitleScoped": "Keine Scores für {scope} in diesem Zeitfenster",
"emptyDescription": "Für diese Konfiguration sind im ausgewählten Zeitraum keine Scores eingegangen. Sie erscheinen hier, sobald ein Scorer, der diese Konfiguration verwendet, ausgeführt wird.",
"distributionNumeric": "Score-Verteilung",
"distributionValues": "Wertverteilung",
"averageValue": "Durchschnitt {value}",
"legendHealthy": "Gesund",
"legendUnhealthy": "Ungesund",
"legendScores": "Scores",
"chartTooltip": "{count} Score, {unhealthy} ungesund | {count} Scores, {unhealthy} ungesund",
"chartTooltipCount": "{count} Score | {count} Scores",
"trendTitle": {
"score": "Trend",
"healthyRate": "Gesundheitsrate im Zeitverlauf",
"trueRate": "Anteil wahrer Werte im Zeitverlauf",
"count": "Scores im Zeitverlauf"
},
"trendLegend": {
"healthyRate": "Gesundheitsrate",
"trueRate": "Wahre Rate",
"count": "Bewertungen"
},
"trendTitle": "Trend",
"distributionTitle": "Verteilung der Punktzahlen",
"categoryDistributionTitle": "Kategorie Vertrieb",
"healthyRateTitle": "Gesundheitsrate im Zeitverlauf",
"trueRateTitle": "True-Rate im Zeitverlauf",
"trueFalseTitle": "Wahr gegen Falsch",
"legendAvg": "Durchschn.",
"legendThreshold": "Schwellenwert {'{'}direction{'}'} {'{'}value{'}'}",
"legendThreshold": "Schwellenwert {direction} {value}",
"markLineHealthy": "gesund {direction} {value}",
"healthyThreshold": "Gesund {threshold}",
"legendHealthy": "gesund",
"legendUnhealthy": "ungesund",
"legendHealthyRate": "Gesundheitsrate",
"legendTrueRate": "True-Rate",
"legendTrueCount": "wahr ({count})",
"legendFalseCount": "falsch ({count})",
"kpis": {
"average": "Durchschnittlich",
"p50": "P50",
"p95": "P95",
"unhealthy": "Ungesund",
"healthy": "Gesund",
"trueRate": "True-Rate",
"falseRate": "Falsche Rate",
"coverage": "Deckung",
"targetsScored": "Bewertete Ziele"
},
"health": {
"title": "Gesundheitsstatus",
"noThreshold": "Es ist kein gesunder Schwellenwert konfiguriert. Bewertungen können noch nicht als gesund oder verschlechternd klassifiziert werden.",
"thresholdOnly": "Gesund wenn {threshold}",
"summary": "{percent}% unter Schwellenwert · Gesund wenn {threshold}"
},
"ofScoreResults": "von {count} Score-Ergebnissen",
"range": "Bereich {min}–{max}"
"filterRange": "{from} bis {to}",
"filterBelow": "Unter {to}",
"filterAtLeast": "{from} oder mehr",
"filterAny": "Beliebiger Wert",
"filterByBar": "Bewertungen nach Wert filtern",
"barButton": "{label}: {count} Bewertung | {label}: {count} Bewertungen",
"clearFilter": "Filter zurücksetzen"
},
"runs": {
"title": "Evaluierungsbewertungen",
"hint": "Bewertungen und Begründungen stammen aus _llm_scores. Wählen Sie einen Lauf, um den zugehörigen Evaluator-Trace für weitere Untersuchungen zu öffnen.",
"filterLabel": "Evaluierungsbewertungen filtern",
"noThresholdHint": "Legen Sie einen gesunden Schwellenwert für diese Bewertungskonfiguration fest, um ungesunde Bewertungen zu klassifizieren und hervorzuheben.",
"review": {
"title": "Bewertungsergebnisse",
"all": "Alle",
"unhealthy": "Ungesund",
"unhealthyDisabled": "Legen Sie einen Schwellenwert fest, um Bewertungen als gesund einzustufen",
"inputNotCaptured": "Eingabe nicht erfasst",
"outputNotCaptured": "Ausgabe nicht erfasst",
"noMatch": "Keine Bewertungen entsprechen diesem Filter"
},
"pane": {
"noneSelected": "Keine Bewertung ausgewählt",
"healthy": "Gesund",
"unhealthy": "Ungesund",
"noThreshold": "Kein Schwellenwert für gesund festgelegt",
"previous": "Vorherige Bewertung",
"next": "Nächste Bewertung",
"reasoning": "Begründung",
"noReasoning": "Keine Begründung aufgezeichnet",
"unknownAgent": "Unbekannter Agent",
"unknownScope": "Unbekannter Bereich",
"queryError": "Bewertungsergebnisse konnten nicht aktualisiert werden. Die letzte erfolgreiche Seite wird weiterhin angezeigt.",
"empty": "Keine Bewertungsergebnisse in diesem Zeitraum.",
"emptyUnhealthy": "Keine ungesunden Ergebnisse in diesem Zeitraum.",
"filters": {
"all": "Alle Ergebnisse",
"unhealthy": "Ungesund"
"fullTextUnavailable": "Es wird eine kurze Vorschau angezeigt. Der vollständige Trace konnte nicht geladen werden.",
"open": {
"span": "Span öffnen",
"trace": "Trace öffnen",
"session": "Sitzung öffnen"
},
"health": {
"healthy": "Gesund",
"unhealthy": "Ungesund"
},
"columns": {
"time": "Bewertet",
"result": "Ergebnis",
"reasoning": "Begründung",
"target": "Ziel",
"agent": "Agent"
}
},
"overview": {
"searchPlaceholder": "Konfigurationen filtern...",
"relativeTime": {
"daysAgo": "vor {day}T",
"monthsAgo": "vor {month}M"
},
"status": {
"healthy": "Gesund",
"unhealthy": "Erfordert Aufmerksamkeit",
"noThreshold": "Schwellenwert nicht festgelegt",
"noData": "Keine Ergebnisdaten"
},
"health": {
"thresholdSummary": "{percent}% darunter · Gesund, wenn {threshold}",
"noThresholdHint": "Gesunden Schwellenwert festlegen",
"noDataHint": "Keine Ergebnisse in diesem Fenster"
},
"columns": {
"health": "Gesundheit",
"scoreConfig": "Score-Konfiguration",
"type": "Typ",
"quality": "Qualität",
"totalScores": "Gesamtpunktzahl",
"scopeMix": "Bereichsmix",
"volumeTrend": "Volumentrend",
"updated": "aktualisiert"
}
},
"averageWithHealthyRate": "Durchschnitt · {rate}% gesund"
"evaluatorTrace": "Evaluator-Trace",
"addToDataset": "Zum Datensatz hinzufügen",
"datasetSession": "Datensätze nehmen einen einzelnen Trace oder Span auf. Öffnen Sie die Sitzung, um einen ihrer Traces hinzuzufügen.",
"datasetNoStream": "Diese Bewertung hat keinen Quell-Stream, aus dem hinzugefügt werden kann."
}
},
"singular": {
"jobs": "Job evaluieren",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Importieren ({count})",
"count": "Anzahl",
"failedToDeriveSchema": "Schema konnte nicht abgeleitet werden",
"failedToImportScoreConfig": "Score-Konfiguration konnte nicht importiert werden",
"failedToImportScorer": "Scorer konnte nicht importiert werden",
"failedToFetchCatalog": "Katalog konnte nicht abgerufen werden ({status})",
"failedToLoadCatalog": "Katalog konnte nicht geladen werden",
"failedToRunScorerTest": "Scorer-Test konnte nicht ausgeführt werden",
"healthyRate": "Gesundheitsrate",
"invalidJsonFormat": "Ungültiges JSON-Format",
"jsonIsEmpty": "JSON ist leer",
"scorerTestFailed": "Scorer-Test fehlgeschlagen",
"scorerTestSampleContext": "Länderprofil Frankreich: Paris ist die Hauptstadt.",
"scorerTestSampleInput": "Die Hauptstadt Frankreichs ist Paris, das an der Seine liegt.",
"scorerTestSampleOutput": "Paris ist die Hauptstadt Frankreichs.",
"topCategory": "Top-Kategorie",
"trueRate": "Tatsächliche Rate",
"unknownError": "Unbekannter Fehler",
"import": {
"createdSuccessfully": "\"{name}\" wurde erfolgreich erstellt.",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Online-Evals — Vorlagen",
"scorers": "Online-Evals — Scorer",
"evalJobs": "Online-Evals — Jobs",
"quality": "Online Evals — Qualität",
"scoreConfigs": "Online-Evals — Score-Konfigurationen",
"actions": "Aktionen",
"llmProviders": "LLM-Anbieter",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Liste aktualisieren",
"scorersRefresh": "Liste aktualisieren",
"evalJobsRefresh": "Liste aktualisieren",
"qualityBack": "Zurück zu allen Konfigurationen",
"scoreConfigsRefresh": "Liste aktualisieren",
"actionsRefresh": "Liste aktualisieren",
"llmProvidersRefresh": "Liste aktualisieren",
@@ -20552,11 +20529,10 @@
"high": "Hoch"
},
"qualityStatus": {
"nothreshold": "Kein Schwellenwert",
"nodata": "Keine Daten",
"attention": "Erfordert Aufmerksamkeit",
"healthy": "Gesund",
"warn": "Warn",
"unhealthy": "Nicht gesund"
"unset": "Schwellenwert nicht festgelegt",
"nodata": "Keine Score-Daten"
},
"rumEventType": {
"longtask": "Lange Aufgabe",
+106 -130
View File
@@ -17152,143 +17152,122 @@
},
"quality": {
"allAgents": "All Agents",
"agentLabel": "Agent",
"agentPlaceholder": "All agents",
"kpisAriaLabel": "Tier 1 KPIs",
"dataTypes": {
"numericShort": "Num",
"categoricalShort": "Cat",
"booleanShort": "Bool"
"overview": {
"relativeTime": {
"daysAgo": "{day}d ago"
}
},
"healthyIf": "Healthy if {rule}",
"noHealthyValue": "none",
"setThreshold": "Set a healthy threshold",
"noScoresInWindow": "No scores in this window",
"outOf": "{count} out of {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Score scope",
"filterHint": "Compare this Score Config across evaluation target scopes.",
"all": "All",
"span": "Span",
"trace": "Trace",
"session": "Session"
},
"kpis": {
"scoreResults": {
"title": "Score Results"
},
"evaluationCost": {
"title": "Evaluation Cost"
},
"scorerSuccess": {
"title": "Scorer Success"
},
"scorerFailures": {
"title": "Scorer Failures"
},
"latencyP95": {
"title": "Scorer Latency p95"
},
"vsPrev": "vs prev",
"noPriorData": "no prior data",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "trace",
"session": "session"
},
"detail": {
"loading": "Loading per-config breakdown…",
"noData": "No scores yet for this config in this window.",
"empty": {
"title": "Waiting for your first scores",
"titleScoped": "No {scope} scores in this window",
"description": "No scores have landed for this config in the selected time range. They'll appear here automatically once a scorer using it runs.",
"descriptionScoped": "No {scope} scores have landed for this config in the selected time range. Choose another scope or widen the time range."
},
"trendTitle": "Trend",
"distributionTitle": "Score distribution",
"categoryDistributionTitle": "Category distribution",
"healthyRateTitle": "Healthy rate over time",
"trueRateTitle": "True rate over time",
"trueFalseTitle": "True vs False",
"legendAvg": "Avg",
"legendThreshold": "threshold {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "healthy {direction} {value}",
"healthyThreshold": "Healthy {threshold}",
"legendHealthy": "healthy",
"legendUnhealthy": "unhealthy",
"legendHealthyRate": "Healthy rate",
"legendTrueRate": "True rate",
"legendTrueCount": "true ({count})",
"legendFalseCount": "false ({count})",
"kpis": {
"average": "Average",
"p50": "P50",
"p95": "P95",
"unhealthy": "Unhealthy",
"healthy": "Healthy",
"trueRate": "True rate",
"falseRate": "False rate",
"coverage": "Coverage",
"targetsScored": "Targets scored"
},
"health": {
"title": "Health status",
"noThreshold": "No healthy threshold is configured. Scores cannot be classified as healthy or degrading yet.",
"thresholdOnly": "Healthy if {threshold}",
"summary": "{percent}% below threshold · Healthy if {threshold}"
},
"ofScoreResults": "of {count} score results",
"range": "Range {min}–{max}"
"tiles": {
"attention": "Configs needing attention",
"ofTotal": "of {total}",
"attentionNote": "Show them",
"attentionTooltip": "Configs with at least one unhealthy score",
"withoutResult": "Configs without a result",
"withoutResultTooltip": "Configs with no scores in this time range, or without a healthy threshold. Click to show them.",
"noScores": "{count} got no scores | {count} got no scores",
"noThreshold": "{count} has no threshold | {count} have no threshold",
"allGiveResult": "Every config gives a result"
},
"runs": {
"title": "Evaluation scores",
"hint": "Scores and reasoning come from _llm_scores. Select a run to open its evaluator trace for further investigation.",
"filterLabel": "Filter evaluation scores",
"noThresholdHint": "Set a healthy threshold on this Score Config to classify and highlight unhealthy scores.",
"noReasoning": "No reasoning recorded",
"unknownAgent": "Unknown agent",
"unknownScope": "Unknown scope",
"queryError": "Evaluation scores could not be refreshed. The last successful page is still shown.",
"empty": "No evaluation scores in this time range.",
"emptyUnhealthy": "No unhealthy scores in this time range.",
"filters": {
"all": "All scores",
"unhealthy": "Unhealthy"
},
"health": {
"healthy": "Healthy",
"unhealthy": "Unhealthy"
},
"columns": {
"time": "Evaluated",
"result": "Score",
"reasoning": "Reasoning",
"target": "Target",
"agent": "Agent"
}
},
"overview": {
"table": {
"searchPlaceholder": "Filter configs…",
"relativeTime": {
"daysAgo": "{day}d ago",
"monthsAgo": "{month}mo ago"
},
"status": {
"healthy": "Healthy",
"unhealthy": "Needs attention",
"noThreshold": "Threshold not set",
"noData": "No score data"
},
"health": {
"thresholdSummary": "{percent}% below · Healthy if {threshold}",
"noThresholdHint": "Set a healthy threshold",
"noDataHint": "No scores in this window"
},
"openUnhealthy": "Open the unhealthy scores",
"configLink": "Config",
"averageRange": "average, {min} to {max}",
"mostCommon": "most common · {count} of {total}",
"columns": {
"health": "Health",
"scoreConfig": "Score Config",
"type": "Type",
"quality": "Quality",
"totalScores": "Total Scores",
"scopeMix": "Scope Mix",
"volumeTrend": "Volume Trend",
"health": "Health",
"typicalScore": "Typical score",
"unhealthy": "Unhealthy",
"scores": "Scores",
"updated": "Updated"
}
},
"averageWithHealthyRate": "Average · {rate}% healthy"
"detail": {
"back": "All configs",
"backToAll": "Back to all configs",
"editConfig": "Edit config",
"notFound": "Score Config not found",
"notFoundDescription": "It may have been deleted, or the link points to an old config.",
"emptyTitle": "Waiting for your first scores",
"emptyTitleScoped": "No {scope} scores in this window",
"emptyDescription": "No scores landed for this config in the selected time range. They appear here once a scorer that uses it runs.",
"distributionNumeric": "Score distribution",
"distributionValues": "Value distribution",
"averageValue": "average {value}",
"legendHealthy": "Healthy",
"legendUnhealthy": "Unhealthy",
"legendScores": "Scores",
"chartTooltip": "{count} score, {unhealthy} unhealthy | {count} scores, {unhealthy} unhealthy",
"chartTooltipCount": "{count} score | {count} scores",
"trendTitle": {
"score": "Trend",
"healthyRate": "Healthy rate over time",
"trueRate": "True rate over time",
"count": "Scores over time"
},
"trendLegend": {
"healthyRate": "Healthy rate",
"trueRate": "True rate",
"count": "Scores"
},
"legendAvg": "Avg",
"legendThreshold": "threshold {direction} {value}",
"markLineHealthy": "healthy {direction} {value}",
"filterRange": "{from} to {to}",
"filterBelow": "Below {to}",
"filterAtLeast": "{from} or more",
"filterAny": "Any value",
"filterByBar": "Filter scores by value",
"barButton": "{label}: {count} score | {label}: {count} scores",
"clearFilter": "Clear filter"
},
"review": {
"title": "Evaluation scores",
"all": "All",
"unhealthy": "Unhealthy",
"unhealthyDisabled": "Set a healthy threshold to classify scores",
"inputNotCaptured": "Input not captured",
"outputNotCaptured": "Output not captured",
"noMatch": "No scores match this filter"
},
"pane": {
"noneSelected": "No score selected",
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"noThreshold": "No healthy threshold set",
"previous": "Previous score",
"next": "Next score",
"reasoning": "Reasoning",
"noReasoning": "No reasoning recorded",
"fullTextUnavailable": "Showing a short preview. The full trace could not be loaded.",
"open": {
"span": "Open span",
"trace": "Open trace",
"session": "Open session"
},
"evaluatorTrace": "Evaluator trace",
"addToDataset": "Add to dataset",
"datasetSession": "Datasets take a single trace or span. Open the session to add one of its traces.",
"datasetNoStream": "This score has no source stream to add from."
}
},
"singular": {
"jobs": "Eval Job",
@@ -18004,22 +17983,18 @@
}
},
"importCount": "Import ({count})",
"count": "Count",
"failedToDeriveSchema": "Failed to derive schema",
"failedToImportScoreConfig": "Failed to import score config",
"failedToImportScorer": "Failed to import scorer",
"failedToFetchCatalog": "Failed to fetch catalog ({status})",
"failedToLoadCatalog": "Failed to load catalog",
"failedToRunScorerTest": "Failed to run scorer test",
"healthyRate": "Healthy rate",
"invalidJsonFormat": "Invalid JSON format",
"jsonIsEmpty": "JSON is empty",
"scorerTestFailed": "Scorer test failed",
"scorerTestSampleContext": "France country profile: Paris is the capital city.",
"scorerTestSampleInput": "The capital of France is Paris, located on the Seine river.",
"scorerTestSampleOutput": "Paris is the capital of France.",
"topCategory": "Top category",
"trueRate": "True rate",
"unknownError": "Unknown error",
"import": {
"createdSuccessfully": "\"{name}\" created successfully",
@@ -20189,6 +20164,7 @@
"evalTemplates": "Online Evals — Templates",
"scorers": "Online Evals — Scorers",
"evalJobs": "Online Evals — Jobs",
"quality": "Online Evals — Quality",
"scoreConfigs": "Online Evals — Score Configs",
"actions": "Actions",
"llmProviders": "LLM Providers",
@@ -20304,6 +20280,7 @@
"evalTemplatesRefresh": "Refresh list",
"scorersRefresh": "Refresh list",
"evalJobsRefresh": "Refresh list",
"qualityBack": "Back to all configs",
"scoreConfigsRefresh": "Refresh list",
"actionsRefresh": "Refresh list",
"llmProvidersRefresh": "Refresh list",
@@ -20550,11 +20527,10 @@
"high": "High"
},
"qualityStatus": {
"nothreshold": "No Threshold",
"nodata": "No Data",
"attention": "Needs attention",
"healthy": "Healthy",
"warn": "Warn",
"unhealthy": "Unhealthy"
"unset": "Threshold not set",
"nodata": "No score data"
},
"rumEventType": {
"longtask": "Long Task",
+105 -129
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Todos los agentes",
"agentLabel": "Agente",
"agentPlaceholder": "Todos los agentes",
"kpisAriaLabel": "KPIs de nivel 1",
"dataTypes": {
"numericShort": "Núm.",
"categoricalShort": "Cat.",
"booleanShort": "Bool"
"overview": {
"relativeTime": {
"daysAgo": "hace {day}d"
}
},
"healthyIf": "Saludable si {rule}",
"noHealthyValue": "ninguno",
"setThreshold": "Establecer un umbral saludable",
"noScoresInWindow": "No hay puntuaciones en esta ventana",
"outOf": "{count} de {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Ámbito de puntuación",
"filterHint": "Compare esta configuración de puntuación en los ámbitos de destino de evaluación.",
"all": "Todo",
"span": "Span",
"trace": "Traza",
"session": "Sesión"
},
"kpis": {
"scoreResults": {
"title": "Resultados de puntuación"
},
"evaluationCost": {
"title": "Costo de evaluación"
},
"scorerSuccess": {
"title": "Éxito del evaluador"
},
"scorerFailures": {
"title": "Fallos del evaluador"
},
"latencyP95": {
"title": "Latencia p95 del evaluador"
},
"vsPrev": "contra el anterior",
"noPriorData": "sin datos previos",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "traza",
"session": "sesión"
},
"tiles": {
"attention": "Configuraciones que necesitan atención",
"ofTotal": "de {total}",
"attentionNote": "Muéstralas",
"attentionTooltip": "Configuraciones con al menos una puntuación no saludable",
"withoutResult": "Configuraciones sin resultado",
"withoutResultTooltip": "Configuraciones sin puntuaciones en este intervalo de tiempo o sin un umbral saludable. Haz clic para mostrarlas.",
"noScores": "{count} no obtuvo puntuaciones | {count} no obtuvieron puntuaciones",
"noThreshold": "{count} no tiene umbral | {count} no tienen umbral",
"allGiveResult": "Todas las configuraciones dan un resultado"
},
"table": {
"searchPlaceholder": "Filtrar configuraciones…",
"openUnhealthy": "Abrir las puntuaciones no saludables",
"configLink": "Configuración",
"averageRange": "promedio, de {min} a {max}",
"mostCommon": "más común · {count} de {total}",
"columns": {
"scoreConfig": "Configuración de puntuación",
"health": "Salud",
"typicalScore": "Puntuación típica",
"unhealthy": "No saludables",
"scores": "Puntuaciones",
"updated": "Actualizado"
}
},
"detail": {
"loading": "Cargando el desglose por configuración...",
"noData": "Aún no hay puntuaciones para esta configuración en esta ventana.",
"empty": {
"title": "Esperando tus primeras puntuaciones",
"titleScoped": "No hay puntuaciones de {scope} en esta ventana",
"description": "No han llegado puntuaciones para esta configuración en el rango de tiempo seleccionado. Aparecerán aquí automáticamente una vez que se ejecute un evaluador que la utilice.",
"descriptionScoped": "No se han recibido puntuaciones de {scope} para esta configuración en el rango de tiempo seleccionado. Elija otro ámbito o amplíe el rango de tiempo."
"back": "Todas las configuraciones",
"backToAll": "Volver a todas las configuraciones",
"editConfig": "Editar configuración",
"notFound": "Configuración de puntuación no encontrada",
"notFoundDescription": "Es posible que se haya eliminado o que el enlace apunte a una configuración antigua.",
"emptyTitle": "Esperando tus primeras puntuaciones",
"emptyTitleScoped": "No hay puntuaciones de {scope} en esta ventana",
"emptyDescription": "No se han recibido puntuaciones para esta configuración en el intervalo de tiempo seleccionado. Aparecerán aquí en cuanto se ejecute un evaluador que la use.",
"distributionNumeric": "Distribución de puntuaciones",
"distributionValues": "Distribución de valores",
"averageValue": "promedio {value}",
"legendHealthy": "Correcto",
"legendUnhealthy": "Incorrecto",
"legendScores": "Puntuaciones",
"chartTooltip": "{count} puntuación, {unhealthy} no saludable | {count} puntuaciones, {unhealthy} no saludables",
"chartTooltipCount": "{count} puntuación | {count} puntuaciones",
"trendTitle": {
"score": "Tendencia",
"healthyRate": "Tasa de saludables a lo largo del tiempo",
"trueRate": "Tasa de valores verdaderos a lo largo del tiempo",
"count": "Puntuaciones a lo largo del tiempo"
},
"trendLegend": {
"healthyRate": "Tasa de saludables",
"trueRate": "Tasa de verdaderos",
"count": "Puntuaciones"
},
"trendTitle": "Tendencia",
"distributionTitle": "Distribución de puntajes",
"categoryDistributionTitle": "Distribución de categorías",
"healthyRateTitle": "Tasa saludable a lo largo del tiempo",
"trueRateTitle": "Tasa verdadera a lo largo del tiempo",
"trueFalseTitle": "Verdadero contra falso",
"legendAvg": "Promedio",
"legendThreshold": "umbral {'{'}direction{'}'} {'{'}value{'}'}",
"legendThreshold": "umbral {direction} {value}",
"markLineHealthy": "saludable {direction} {value}",
"healthyThreshold": "Saludable {threshold}",
"legendHealthy": "saludable",
"legendUnhealthy": "insalubres",
"legendHealthyRate": "Tasa saludable",
"legendTrueRate": "Tasa verdadera",
"legendTrueCount": "verdadero ({count})",
"legendFalseCount": "falso ({count})",
"kpis": {
"average": "Promedio",
"p50": "P50",
"p95": "P95",
"unhealthy": "Insalubre",
"healthy": "Saludable",
"trueRate": "Tasa verdadera",
"falseRate": "Tasa falsa",
"coverage": "Cobertura",
"targetsScored": "Objetivos puntuados"
},
"health": {
"title": "Estado de salud",
"noThreshold": "No se ha configurado un umbral saludable. Las puntuaciones no se pueden clasificar como saludables o degradantes todavía.",
"thresholdOnly": "Saludable si {threshold}",
"summary": "{percent}% por debajo del umbral · Saludable si {threshold}"
},
"ofScoreResults": "de {count} resultados de puntuación",
"range": "Rango {min}–{max}"
"filterRange": "{from} a {to}",
"filterBelow": "Por debajo de {to}",
"filterAtLeast": "{from} o más",
"filterAny": "Cualquier valor",
"filterByBar": "Filtrar las puntuaciones por valor",
"barButton": "{label}: {count} puntuación | {label}: {count} puntuaciones",
"clearFilter": "Borrar filtro"
},
"runs": {
"review": {
"title": "Puntuaciones de evaluación",
"hint": "Las puntuaciones y el razonamiento provienen de _llm_scores. Selecciona una ejecución para abrir su traza del evaluador para investigar más a fondo.",
"filterLabel": "Filtrar puntuaciones de evaluación",
"noThresholdHint": "Establezca un umbral saludable en esta configuración de puntuación para clasificar y resaltar puntuaciones no saludables.",
"noReasoning": "No se registró razonamiento",
"unknownAgent": "Agente desconocido",
"unknownScope": "Ámbito desconocido",
"queryError": "No se pudieron actualizar las puntuaciones de evaluación. Aún se muestra la última página exitosa.",
"empty": "No hay puntuaciones de evaluación en este intervalo de tiempo.",
"emptyUnhealthy": "No hay puntuaciones no saludables en este intervalo de tiempo.",
"filters": {
"all": "Todas las puntuaciones",
"unhealthy": "No saludable"
},
"health": {
"healthy": "Saludable",
"unhealthy": "No saludable"
},
"columns": {
"time": "Evaluado",
"result": "Puntuación",
"reasoning": "Razonamiento",
"target": "Objetivo",
"agent": "Agente"
}
"all": "Todas",
"unhealthy": "No saludable",
"unhealthyDisabled": "Establece un umbral saludable para clasificar las puntuaciones",
"inputNotCaptured": "Entrada no capturada",
"outputNotCaptured": "Salida no capturada",
"noMatch": "Ninguna puntuación coincide con este filtro"
},
"overview": {
"searchPlaceholder": "Filtrar configuraciones…",
"relativeTime": {
"daysAgo": "hace {day}d",
"monthsAgo": "hace {month}mo"
"pane": {
"noneSelected": "Ninguna puntuación seleccionada",
"healthy": "Saludable",
"unhealthy": "No saludable",
"noThreshold": "No se ha establecido ningún umbral saludable",
"previous": "Puntuación anterior",
"next": "Puntuación siguiente",
"reasoning": "Razonamiento",
"noReasoning": "No se ha registrado ningún razonamiento",
"fullTextUnavailable": "Se muestra una vista previa corta. No se ha podido cargar la traza completa.",
"open": {
"span": "Abrir span",
"trace": "Abrir traza",
"session": "Abrir sesión"
},
"status": {
"healthy": "Saludable",
"unhealthy": "Requiere atención",
"noThreshold": "Umbral no establecido",
"noData": "Sin datos de puntuación"
},
"health": {
"thresholdSummary": "{percent}% por debajo · Saludable si {threshold}",
"noThresholdHint": "Establecer un umbral saludable",
"noDataHint": "Sin puntuaciones en esta ventana"
},
"columns": {
"health": "Salud",
"scoreConfig": "Configuración de puntuación",
"type": "Tipo",
"quality": "Calidad",
"totalScores": "Puntuaciones totales",
"scopeMix": "Combinación de ámbitos",
"volumeTrend": "Tendencia de volumen",
"updated": "Actualizada"
}
},
"averageWithHealthyRate": "Promedio · {rate}% saludable"
"evaluatorTrace": "Traza del evaluador",
"addToDataset": "Añadir al conjunto de datos",
"datasetSession": "Los conjuntos de datos aceptan una sola traza o span. Abre la sesión para añadir una de sus trazas.",
"datasetNoStream": "Esta puntuación no tiene ningún stream de origen del que añadir."
}
},
"singular": {
"jobs": "Trabajo de evaluación",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Importar ({count})",
"count": "Conteo",
"failedToDeriveSchema": "No se pudo derivar el esquema",
"failedToImportScoreConfig": "No se pudo importar la configuración de puntuación",
"failedToImportScorer": "No se pudo importar el evaluador",
"failedToFetchCatalog": "No se pudo obtener el catálogo ({status})",
"failedToLoadCatalog": "No se pudo cargar el catálogo",
"failedToRunScorerTest": "No se pudo ejecutar la prueba del evaluador",
"healthyRate": "Tasa de salud",
"invalidJsonFormat": "Formato JSON no válido",
"jsonIsEmpty": "JSON está vacío",
"scorerTestFailed": "La prueba del evaluador falló",
"scorerTestSampleContext": "Perfil del país France: Paris es la capital.",
"scorerTestSampleInput": "La capital de France es Paris, ubicada en el río Seine.",
"scorerTestSampleOutput": "Paris es la capital de France.",
"topCategory": "Categoría principal",
"trueRate": "Tasa verdadera",
"unknownError": "Error desconocido",
"import": {
"createdSuccessfully": "\"{name}\" creado con éxito",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Evaluaciones en línea — Plantillas",
"scorers": "Evaluaciones en línea — Evaluadores",
"evalJobs": "Evaluaciones en línea — Trabajos",
"quality": "Evaluaciones en línea — Calidad",
"scoreConfigs": "Evaluaciones en línea — Configuraciones de puntuación",
"actions": "Acciones",
"llmProviders": "Proveedores de LLM",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Actualizar lista",
"scorersRefresh": "Actualizar lista",
"evalJobsRefresh": "Actualizar lista",
"qualityBack": "Volver a todas las configuraciones",
"scoreConfigsRefresh": "Actualizar lista",
"actionsRefresh": "Actualizar lista",
"llmProvidersRefresh": "Actualizar lista",
@@ -20552,11 +20529,10 @@
"high": "Alto"
},
"qualityStatus": {
"nothreshold": "Sin umbral",
"nodata": "Sin datos",
"attention": "Requiere atención",
"healthy": "Saludable",
"warn": "Advertencia",
"unhealthy": "No saludable"
"unset": "Umbral no establecido",
"nodata": "Sin datos de puntuación"
},
"rumEventType": {
"longtask": "Tarea larga",
+106 -130
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Tous les agents",
"agentLabel": "Agent",
"agentPlaceholder": "Tous les agents",
"kpisAriaLabel": "KPI de niveau 1",
"dataTypes": {
"numericShort": "Num",
"categoricalShort": "Cat",
"booleanShort": "Bool"
"overview": {
"relativeTime": {
"daysAgo": "il y a {day}j"
}
},
"healthyIf": "Sain si {rule}",
"noHealthyValue": "aucun",
"setThreshold": "Définir un seuil de santé",
"noScoresInWindow": "Aucun score dans cette fenêtre",
"outOf": "{count} sur {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Portée du score",
"filterHint": "Comparez cette configuration de score entre les portées des cibles d'évaluation.",
"all": "Tout",
"span": "Span",
"trace": "Trace",
"session": "Session"
},
"kpis": {
"scoreResults": {
"title": "Résultats des scores"
},
"evaluationCost": {
"title": "Coût de l'évaluation"
},
"scorerSuccess": {
"title": "Succès du scoreur"
},
"scorerFailures": {
"title": "Échecs des évaluateurs"
},
"latencyP95": {
"title": "Latence du scoreur p95"
},
"vsPrev": "contre le précédent",
"noPriorData": "aucune donnée antérieure",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "trace",
"session": "session"
},
"detail": {
"loading": "Répartition du chargement par configuration...",
"noData": "Il n'y a pas encore de score pour cette configuration dans cette fenêtre.",
"empty": {
"title": "En attente de vos premiers scores",
"titleScoped": "Aucun score {scope} dans cette fenêtre",
"description": "Aucun score n'a été reçu pour cette configuration dans la plage temporelle sélectionnée. Ils apparaîtront ici automatiquement dès qu'un évaluateur l'utilisant s'exécutera.",
"descriptionScoped": "Aucun score {scope} n'a été enregistré pour cette configuration dans la plage horaire sélectionnée. Choisissez une autre portée ou élargissez la plage horaire."
},
"trendTitle": "Tendance",
"distributionTitle": "Répartition des scores",
"categoryDistributionTitle": "Distribution des catégories",
"healthyRateTitle": "Taux de santé dans le temps",
"trueRateTitle": "Taux de résultats vrais dans le temps",
"trueFalseTitle": "Vrai contre faux",
"legendAvg": "Moyenne",
"legendThreshold": "seuil {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "sain {direction} {value}",
"healthyThreshold": "Sain {threshold}",
"legendHealthy": "en bonne santé",
"legendUnhealthy": "mauvais pour la santé",
"legendHealthyRate": "Taux de santé",
"legendTrueRate": "Taux de résultats vrais",
"legendTrueCount": "vrai ({count})",
"legendFalseCount": "faux ({count})",
"kpis": {
"average": "Moyenne",
"p50": "P50",
"p95": "P95",
"unhealthy": "Malsain",
"healthy": "En bonne santé",
"trueRate": "Taux de résultats vrais",
"falseRate": "Taux de faux",
"coverage": "Couverture",
"targetsScored": "Cibles notées"
},
"health": {
"title": "Statut de santé",
"noThreshold": "Aucun seuil de santé n'est configuré. Les scores ne peuvent pas encore être classés comme sains ou en dégradation.",
"thresholdOnly": "Sain si {threshold}",
"summary": "{percent}% en dessous du seuil · Sain si {threshold}"
},
"ofScoreResults": "sur {count} résultats de score",
"range": "Plage {min}–{max}"
"tiles": {
"attention": "Configurations à surveiller",
"ofTotal": "sur {total}",
"attentionNote": "Les afficher",
"attentionTooltip": "Configurations avec au moins un score non sain",
"withoutResult": "Configurations sans résultat",
"withoutResultTooltip": "Configurations sans aucun score sur cette période, ou sans seuil de santé. Cliquez pour les afficher.",
"noScores": "{count} n'a obtenu aucun score | {count} n'ont obtenu aucun score",
"noThreshold": "{count} n'a pas de seuil | {count} n'ont pas de seuil",
"allGiveResult": "Chaque configuration donne un résultat"
},
"runs": {
"title": "Scores d'évaluation",
"hint": "Les scores et le raisonnement proviennent de _llm_scores. Sélectionnez une exécution pour ouvrir la trace de son évaluateur et approfondir l'investigation.",
"filterLabel": "Filtrer les scores d'évaluation",
"noThresholdHint": "Définissez un seuil de santé sur cette configuration de score pour classer et mettre en évidence les scores malsains.",
"noReasoning": "Aucun raisonnement enregistré",
"unknownAgent": "Agent inconnu",
"unknownScope": "Portée inconnue",
"queryError": "Les scores d'évaluation n'ont pas pu être actualisés. La dernière page réussie est toujours affichée.",
"empty": "Aucun score d'évaluation dans cette plage horaire.",
"emptyUnhealthy": "Aucun score insatisfaisant dans cette plage horaire.",
"filters": {
"all": "Tous les scores",
"unhealthy": "Insatisfaisant"
},
"health": {
"healthy": "Satisfaisant",
"unhealthy": "Insatisfaisant"
},
"columns": {
"time": "Évalué",
"result": "Score",
"reasoning": "Raisonnement",
"target": "Cible",
"agent": "Agent"
}
},
"overview": {
"table": {
"searchPlaceholder": "Filtrer les configurations…",
"relativeTime": {
"daysAgo": "il y a {day}j",
"monthsAgo": "il y a {month}mo"
},
"status": {
"healthy": "Satisfaisant",
"unhealthy": "Nécessite attention",
"noThreshold": "Seuil non défini",
"noData": "Aucune donnée de score"
},
"health": {
"thresholdSummary": "{percent}% en dessous · Satisfaisant si {threshold}",
"noThresholdHint": "Définir un seuil satisfaisant",
"noDataHint": "Aucun score dans cette fenêtre"
},
"openUnhealthy": "Ouvrir les scores non sains",
"configLink": "Configuration",
"averageRange": "moyenne, de {min} à {max}",
"mostCommon": "le plus fréquent · {count} sur {total}",
"columns": {
"scoreConfig": "Configuration de score",
"health": "Santé",
"scoreConfig": "Configuration du score",
"type": "Tapez",
"quality": "Qualité",
"totalScores": "Total des scores",
"scopeMix": "Mix de portées",
"volumeTrend": "Tendance du volume",
"typicalScore": "Score typique",
"unhealthy": "Non sain",
"scores": "Scores",
"updated": "Mis à jour"
}
},
"averageWithHealthyRate": "Moyenne · {rate}% sain"
"detail": {
"back": "Toutes les configurations",
"backToAll": "Retour à toutes les configurations",
"editConfig": "Modifier la configuration",
"notFound": "Configuration de score introuvable",
"notFoundDescription": "Elle a peut-être été supprimée, ou le lien pointe vers une ancienne configuration.",
"emptyTitle": "En attente de vos premiers scores",
"emptyTitleScoped": "Aucun score de {scope} dans cette fenêtre",
"emptyDescription": "Aucun score n'a été reçu pour cette configuration sur la période sélectionnée. Ils apparaîtront ici dès qu'un évaluateur qui l'utilise s'exécute.",
"distributionNumeric": "Distribution des scores",
"distributionValues": "Distribution des valeurs",
"averageValue": "moyenne {value}",
"legendHealthy": "Sain",
"legendUnhealthy": "Non sain",
"legendScores": "Scores",
"chartTooltip": "{count} score, {unhealthy} non sain | {count} scores, {unhealthy} non sains",
"chartTooltipCount": "{count} score | {count} scores",
"trendTitle": {
"score": "Tendance",
"healthyRate": "Taux de scores sains au fil du temps",
"trueRate": "Taux de valeurs vraies au fil du temps",
"count": "Scores au fil du temps"
},
"trendLegend": {
"healthyRate": "Taux de scores sains",
"trueRate": "Taux de vrai",
"count": "Scores"
},
"legendAvg": "Moyenne",
"legendThreshold": "seuil {direction} {value}",
"markLineHealthy": "sain {direction} {value}",
"filterRange": "de {from} à {to}",
"filterBelow": "Inférieur à {to}",
"filterAtLeast": "{from} ou plus",
"filterAny": "Toute valeur",
"filterByBar": "Filtrer les scores par valeur",
"barButton": "{label} : {count} score | {label} : {count} scores",
"clearFilter": "Effacer le filtre"
},
"review": {
"title": "Scores d'évaluation",
"all": "Tous",
"unhealthy": "Non sain",
"unhealthyDisabled": "Définissez un seuil de santé pour classer les scores",
"inputNotCaptured": "Entrée non capturée",
"outputNotCaptured": "Sortie non capturée",
"noMatch": "Aucun score ne correspond à ce filtre"
},
"pane": {
"noneSelected": "Aucun score sélectionné",
"healthy": "Sain",
"unhealthy": "Non sain",
"noThreshold": "Aucun seuil de santé défini",
"previous": "Score précédent",
"next": "Score suivant",
"reasoning": "Raisonnement",
"noReasoning": "Aucun raisonnement enregistré",
"fullTextUnavailable": "Affichage d'un court aperçu. La trace complète n'a pas pu être chargée.",
"open": {
"span": "Ouvrir le span",
"trace": "Ouvrir la trace",
"session": "Ouvrir la session"
},
"evaluatorTrace": "Trace de l'évaluateur",
"addToDataset": "Ajouter au jeu de données",
"datasetSession": "Les jeux de données acceptent une seule trace ou un seul span. Ouvrez la session pour ajouter l'une de ses traces.",
"datasetNoStream": "Ce score n'a pas de flux source à partir duquel effectuer l'ajout."
}
},
"singular": {
"jobs": "Emploi Eval",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Importer ({count})",
"count": "Nombre",
"failedToDeriveSchema": "Échec de la dérivation du schéma",
"failedToImportScoreConfig": "Échec de l'importation de la configuration de score",
"failedToImportScorer": "Échec de l'importation du scoreur",
"failedToFetchCatalog": "Échec de la récupération du catalogue ({status})",
"failedToLoadCatalog": "Échec du chargement du catalogue",
"failedToRunScorerTest": "Échec de l'exécution du test du scoreur",
"healthyRate": "Taux de santé",
"invalidJsonFormat": "Format JSON invalide",
"jsonIsEmpty": "JSON vide",
"scorerTestFailed": "Échec du test du scoreur",
"scorerTestSampleContext": "Profil du pays France : Paris est la capitale.",
"scorerTestSampleInput": "La capitale de la France est Paris, située sur la Seine.",
"scorerTestSampleOutput": "Paris est la capitale de la France.",
"topCategory": "Catégorie principale",
"trueRate": "Taux réel",
"unknownError": "Erreur inconnue",
"import": {
"createdSuccessfully": "\"{name}\" créé avec succès",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Evaluations en ligne — Modèles",
"scorers": "Évaluations en ligne — Évaluateurs",
"evalJobs": "Evals en ligne — Offres d'emploi",
"quality": "Evals en ligne — Qualité",
"scoreConfigs": "Evaluations en ligne — Configurations des scores",
"actions": "Actions",
"llmProviders": "Fournisseurs de LLM",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Actualiser la liste",
"scorersRefresh": "Actualiser la liste",
"evalJobsRefresh": "Actualiser la liste",
"qualityBack": "Retour à toutes les configurations",
"scoreConfigsRefresh": "Actualiser la liste",
"actionsRefresh": "Actualiser la liste",
"llmProvidersRefresh": "Actualiser la liste",
@@ -20552,11 +20529,10 @@
"high": "Élevé"
},
"qualityStatus": {
"nothreshold": "Aucun seuil",
"nodata": "Aucune donnée",
"attention": "Nécessite une attention",
"healthy": "Sain",
"warn": "Avertissement",
"unhealthy": "Non sain"
"unset": "Seuil non défini",
"nodata": "Aucune donnée de score"
},
"rumEventType": {
"longtask": "Tâche longue",
+107 -131
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Tutti gli agenti",
"agentLabel": "Agente",
"agentPlaceholder": "Tutti gli agenti",
"kpisAriaLabel": "KPI di livello 1",
"dataTypes": {
"numericShort": "Num",
"categoricalShort": "Cat",
"booleanShort": "Bool"
"overview": {
"relativeTime": {
"daysAgo": "{day} g fa"
}
},
"healthyIf": "Integro se {rule}",
"noHealthyValue": "nessuno",
"setThreshold": "Imposta una soglia di integrità",
"noScoresInWindow": "Nessun punteggio in questa finestra",
"outOf": "{count} su {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Ambito del punteggio",
"filterHint": "Confronta questa configurazione del punteggio tra gli ambiti di destinazione della valutazione.",
"all": "Tutti",
"span": "Span",
"trace": "Traccia",
"session": "Sessione"
},
"kpis": {
"scoreResults": {
"title": "Risultati del punteggio"
},
"evaluationCost": {
"title": "Costo di valutazione"
},
"scorerSuccess": {
"title": "Successo del valutatore"
},
"scorerFailures": {
"title": "Errori del valutatore"
},
"latencyP95": {
"title": "Latenza del valutatore p95"
},
"vsPrev": "vs precedente",
"noPriorData": "nessun dato precedente",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "traccia",
"session": "sessione"
},
"detail": {
"loading": "Caricamento della suddivisione per configurazione...",
"noData": "Non ci sono ancora punteggi per questa configurazione in questa finestra.",
"empty": {
"title": "In attesa dei tuoi primi punteggi",
"titleScoped": "Nessun punteggio {scope} in questa finestra",
"description": "Nessun punteggio è arrivato per questa configurazione nell'intervallo di tempo selezionato. Verranno visualizzati qui automaticamente non appena viene eseguito un valutatore che la utilizza.",
"descriptionScoped": "Nessun punteggio {scope} è stato ricevuto per questa configurazione nell'intervallo di tempo selezionato. Scegli un altro ambito o amplia l'intervallo di tempo."
},
"trendTitle": "Tendenza",
"distributionTitle": "Distribuzione del punteggio",
"categoryDistributionTitle": "Distribuzione delle categorie",
"healthyRateTitle": "Tasso di healthy nel tempo",
"trueRateTitle": "Tasso di risultati veri nel tempo",
"trueFalseTitle": "Vero vs falso",
"legendAvg": "Media",
"legendThreshold": "soglia {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "sano {direction} {value}",
"healthyThreshold": "Sano {threshold}",
"legendHealthy": "sano",
"legendUnhealthy": "non sano",
"legendHealthyRate": "Tasso di healthy",
"legendTrueRate": "Tasso di risultati veri",
"legendTrueCount": "vero ({count})",
"legendFalseCount": "falso ({count})",
"kpis": {
"average": "Media",
"p50": "P50",
"p95": "P95",
"unhealthy": "Non sano",
"healthy": "Sano",
"trueRate": "Tasso di risultati veri",
"falseRate": "Tasso di falso",
"coverage": "Copertura",
"targetsScored": "Target valutati"
},
"health": {
"title": "Stato di salute",
"noThreshold": "Nessuna soglia di healthy è configurata. I punteggi non possono ancora essere classificati come healthy o in degrado.",
"thresholdOnly": "Healthy se {threshold}",
"summary": "{percent}% sotto la soglia · Healthy se {threshold}"
},
"ofScoreResults": "di {count} risultati del punteggio",
"range": "Intervallo {min}–{max}"
"tiles": {
"attention": "Configurazioni che richiedono attenzione",
"ofTotal": "di {total}",
"attentionNote": "Mostrale",
"attentionTooltip": "Configurazioni con almeno un punteggio non integro",
"withoutResult": "Configurazioni senza un risultato",
"withoutResultTooltip": "Configurazioni senza punteggi in questo intervallo di tempo, o senza una soglia di integrità. Fai clic per mostrarle.",
"noScores": "{count} non ha ricevuto punteggi | {count} non hanno ricevuto punteggi",
"noThreshold": "{count} non ha una soglia | {count} non hanno una soglia",
"allGiveResult": "Ogni configurazione restituisce un risultato"
},
"runs": {
"title": "Punteggi di valutazione",
"hint": "I punteggi e il ragionamento provengono da _llm_scores. Seleziona una esecuzione per aprire la sua traccia di valutazione per ulteriori indagini.",
"filterLabel": "Filtra punteggi di valutazione",
"noThresholdHint": "Imposta una soglia di healthy su questa configurazione del punteggio per classificare ed evidenziare i punteggi non healthy.",
"noReasoning": "Nessuna motivazione registrata",
"unknownAgent": "Agente sconosciuto",
"unknownScope": "Ambito sconosciuto",
"queryError": "I punteggi di valutazione non possono essere aggiornati. Viene ancora mostrata l'ultima pagina con successo.",
"empty": "Nessun punteggio di valutazione in questo intervallo di tempo.",
"emptyUnhealthy": "Nessun punteggio non sano in questo intervallo di tempo.",
"filters": {
"all": "Tutti i punteggi",
"unhealthy": "Non sano"
},
"health": {
"healthy": "Sano",
"unhealthy": "Non sano"
},
"columns": {
"time": "Valutato",
"result": "Punteggio",
"reasoning": "Ragionamento",
"target": "Obiettivo",
"agent": "Agente"
}
},
"overview": {
"table": {
"searchPlaceholder": "Filtra configurazioni…",
"relativeTime": {
"daysAgo": "{day} g fa",
"monthsAgo": "{month} m fa"
},
"status": {
"healthy": "Sano",
"unhealthy": "Richiede attenzione",
"noThreshold": "Soglia non impostata",
"noData": "Nessun dato del punteggio"
},
"health": {
"thresholdSummary": "{percent}% sotto · Sano se {threshold}",
"noThresholdHint": "Imposta una soglia sana",
"noDataHint": "Nessun punteggio in questa finestra"
},
"openUnhealthy": "Apri i punteggi non integri",
"configLink": "Configurazione",
"averageRange": "media, da {min} a {max}",
"mostCommon": "più comune · {count} su {total}",
"columns": {
"health": "Salute",
"scoreConfig": "Configurazione del punteggio",
"type": "Tipo",
"quality": "Qualità",
"totalScores": "Punteggi totali",
"scopeMix": "Miscela di ambiti",
"volumeTrend": "Andamento del volume",
"scoreConfig": "Configurazione punteggio",
"health": "Integrità",
"typicalScore": "Punteggio tipico",
"unhealthy": "Non integri",
"scores": "Punteggi",
"updated": "Aggiornato"
}
},
"averageWithHealthyRate": "Media · {rate}% in salute"
"detail": {
"back": "Tutte le configurazioni",
"backToAll": "Torna a tutte le configurazioni",
"editConfig": "Modifica configurazione",
"notFound": "Configurazione punteggio non trovata",
"notFoundDescription": "Potrebbe essere stata eliminata oppure il link punta a una configurazione precedente.",
"emptyTitle": "In attesa dei primi punteggi",
"emptyTitleScoped": "Nessun punteggio per {scope} in questa finestra",
"emptyDescription": "Nessun punteggio è arrivato per questa configurazione nell'intervallo di tempo selezionato. Compariranno qui quando verrà eseguito un valutatore che la utilizza.",
"distributionNumeric": "Distribuzione dei punteggi",
"distributionValues": "Distribuzione dei valori",
"averageValue": "media {value}",
"legendHealthy": "Sano",
"legendUnhealthy": "Non sano",
"legendScores": "Punteggi",
"chartTooltip": "{count} punteggio, {unhealthy} non integro | {count} punteggi, {unhealthy} non integri",
"chartTooltipCount": "{count} punteggio | {count} punteggi",
"trendTitle": {
"score": "Andamento",
"healthyRate": "Tasso di integrità nel tempo",
"trueRate": "Tasso di valori veri nel tempo",
"count": "Punteggi nel tempo"
},
"trendLegend": {
"healthyRate": "Tasso di integrità",
"trueRate": "Tasso di veri",
"count": "Punteggi"
},
"legendAvg": "Media",
"legendThreshold": "soglia {direction} {value}",
"markLineHealthy": "sano {direction} {value}",
"filterRange": "da {from} a {to}",
"filterBelow": "Sotto {to}",
"filterAtLeast": "{from} o più",
"filterAny": "Qualsiasi valore",
"filterByBar": "Filtra i punteggi per valore",
"barButton": "{label}: {count} punteggio | {label}: {count} punteggi",
"clearFilter": "Cancella filtro"
},
"review": {
"title": "Punteggi di valutazione",
"all": "Tutti",
"unhealthy": "Non sani",
"unhealthyDisabled": "Imposta una soglia di salute per classificare i punteggi",
"inputNotCaptured": "Input non acquisito",
"outputNotCaptured": "Output non acquisito",
"noMatch": "Nessun punteggio corrisponde a questo filtro"
},
"pane": {
"noneSelected": "Nessun punteggio selezionato",
"healthy": "Sano",
"unhealthy": "Non sano",
"noThreshold": "Nessuna soglia di salute impostata",
"previous": "Punteggio precedente",
"next": "Punteggio successivo",
"reasoning": "Ragionamento",
"noReasoning": "Nessun ragionamento registrato",
"fullTextUnavailable": "Visualizzazione di un'anteprima breve. Impossibile caricare la traccia completa.",
"open": {
"span": "Apri span",
"trace": "Apri traccia",
"session": "Apri sessione"
},
"evaluatorTrace": "Traccia del valutatore",
"addToDataset": "Aggiungi al dataset",
"datasetSession": "I dataset accettano una singola traccia o span. Apri la sessione per aggiungere una delle sue tracce.",
"datasetNoStream": "Questo punteggio non ha uno stream di origine da cui aggiungere dati."
}
},
"singular": {
"jobs": "Job di valutazione",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Importa ({count})",
"count": "Conteggio",
"failedToDeriveSchema": "Impossibile derivare lo schema",
"failedToImportScoreConfig": "Impossibile importare la configurazione del punteggio",
"failedToImportScorer": "Impossibile importare il valutatore",
"failedToFetchCatalog": "Impossibile recuperare il catalogo ({status})",
"failedToLoadCatalog": "Impossibile caricare il catalogo",
"failedToRunScorerTest": "Impossibile eseguire il test del valutatore",
"healthyRate": "Tasso di salute",
"invalidJsonFormat": "Formato JSON non valido",
"jsonIsEmpty": "JSON è vuoto",
"scorerTestFailed": "Test del valutatore fallito",
"scorerTestSampleContext": "Profilo paese Francia: Parigi è la capitale.",
"scorerTestSampleInput": "La capitale della Francia è Parigi, situata sul fiume Senna.",
"scorerTestSampleOutput": "Parigi è la capitale della Francia.",
"topCategory": "Categoria principale",
"trueRate": "Tasso reale",
"unknownError": "Errore sconosciuto",
"import": {
"createdSuccessfully": "\"{name}\" creato con successo",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Valutazioni online — Template",
"scorers": "Valutazioni online — Valutatori",
"evalJobs": "Valutazioni online — Job",
"quality": "Valutazioni online — Qualità",
"scoreConfigs": "Valutazioni online — Configurazioni dei punteggi",
"actions": "Azioni",
"llmProviders": "Provider LLM",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Aggiorna elenco",
"scorersRefresh": "Aggiorna elenco",
"evalJobsRefresh": "Aggiorna elenco",
"qualityBack": "Torna a tutte le configurazioni",
"scoreConfigsRefresh": "Aggiorna elenco",
"actionsRefresh": "Aggiorna elenco",
"llmProvidersRefresh": "Aggiorna elenco",
@@ -20552,11 +20529,10 @@
"high": "Alto"
},
"qualityStatus": {
"nothreshold": "Nessuna soglia",
"nodata": "Nessun dato",
"attention": "Richiede attenzione",
"healthy": "Sano",
"warn": "Attenzione",
"unhealthy": "Non sano"
"unset": "Soglia non impostata",
"nodata": "Nessun dato di punteggio"
},
"rumEventType": {
"longtask": "Attività lunga",
+105 -129
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "すべてのエージェント",
"agentLabel": "エージェント",
"agentPlaceholder": "すべてのエージェント",
"kpisAriaLabel": "Tier 1 KPI",
"dataTypes": {
"numericShort": "数値",
"categoricalShort": "カテゴリ",
"booleanShort": "ブール"
"overview": {
"relativeTime": {
"daysAgo": "{day}日前"
}
},
"healthyIf": "{rule} の場合に正常",
"noHealthyValue": "なし",
"setThreshold": "正常しきい値を設定",
"noScoresInWindow": "この期間にスコアはありません",
"outOf": "{total} 件中 {count} 件",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "スコア範囲",
"filterHint": "このスコア設定を評価ターゲットスコープ間で比較します。",
"all": "すべて",
"span": "スパン",
"trace": "トレース",
"session": "セッション"
},
"kpis": {
"scoreResults": {
"title": "スコア結果"
},
"evaluationCost": {
"title": "評価コスト"
},
"scorerSuccess": {
"title": "スコアラー成功率"
},
"scorerFailures": {
"title": "スコアラーの失敗"
},
"latencyP95": {
"title": "スコアラーレイテンシ p95"
},
"vsPrev": "対前へ",
"noPriorData": "以前のデータなし",
"noData": "—"
"scopeNouns": {
"span": "スパン",
"trace": "トレース",
"session": "セッション"
},
"tiles": {
"attention": "注意が必要な設定",
"ofTotal": "{total} 中",
"attentionNote": "表示する",
"attentionTooltip": "異常スコアが 1 件以上ある設定",
"withoutResult": "結果のない設定",
"withoutResultTooltip": "この期間にスコアがない、または正常しきい値がない設定です。クリックすると表示されます。",
"noScores": "{count} 件にスコアがありません | {count} 件にスコアがありません",
"noThreshold": "{count} 件にしきい値がありません | {count} 件にしきい値がありません",
"allGiveResult": "すべての設定が結果を返します"
},
"table": {
"searchPlaceholder": "設定を絞り込む…",
"openUnhealthy": "異常スコアを開く",
"configLink": "設定",
"averageRange": "平均、{min}〜{max}",
"mostCommon": "最多 · {total} 中 {count}",
"columns": {
"scoreConfig": "スコア設定",
"health": "健全性",
"typicalScore": "代表スコア",
"unhealthy": "異常",
"scores": "スコア",
"updated": "更新日時"
}
},
"detail": {
"loading": "設定ごとの内訳を読み込んでいます...",
"noData": "このウィンドウには、この構成のスコアはまだありません。",
"empty": {
"title": "最初のスコアを待っています",
"titleScoped": "このウィンドウに{scope}のスコアがありません",
"description": "選択した時間範囲では、この設定のスコアはまだ取得されていません。この設定を使用するスコアラーが実行されると、ここに自動的に表示されます。",
"descriptionScoped": "選択した時間範囲では、この設定に対して{scope}のスコアが届いていません。別のスコープを選択するか、時間範囲を広げてください。"
"back": "すべての設定",
"backToAll": "すべての設定に戻る",
"editConfig": "設定を編集",
"notFound": "スコア設定が見つかりません",
"notFoundDescription": "削除されたか、リンクが古い設定を指している可能性があります。",
"emptyTitle": "最初のスコアを待っています",
"emptyTitleScoped": "この期間に{scope}のスコアはありません",
"emptyDescription": "選択した期間にこの設定のスコアはありません。この設定を使用するスコアラーが実行されると、ここに表示されます。",
"distributionNumeric": "スコア分布",
"distributionValues": "値の分布",
"averageValue": "平均 {value}",
"legendHealthy": "正常",
"legendUnhealthy": "異常",
"legendScores": "スコア",
"chartTooltip": "スコア {count} 件、異常 {unhealthy} 件 | スコア {count} 件、異常 {unhealthy} 件",
"chartTooltipCount": "スコア {count} 件 | スコア {count} 件",
"trendTitle": {
"score": "トレンド",
"healthyRate": "正常率の推移",
"trueRate": "True 率の推移",
"count": "スコアの推移"
},
"trendLegend": {
"healthyRate": "正常率",
"trueRate": "正解率",
"count": "スコア"
},
"trendTitle": "トレンド",
"distributionTitle": "スコア分布",
"categoryDistributionTitle": "カテゴリ分布",
"healthyRateTitle": "経時的な健全率",
"trueRateTitle": "経時的な真率",
"trueFalseTitle": "真と偽",
"legendAvg": "平均",
"legendThreshold": "しきい値 {'{'}direction{'}'} {'{'}value{'}'}",
"legendThreshold": "しきい値 {direction} {value}",
"markLineHealthy": "正常 {direction} {value}",
"healthyThreshold": "正常 {threshold}",
"legendHealthy": "健康",
"legendUnhealthy": "不健全です",
"legendHealthyRate": "健全率",
"legendTrueRate": "真率",
"legendTrueCount": "真 ({count})",
"legendFalseCount": "偽 ({count})",
"kpis": {
"average": "アベレージ",
"p50": "P50",
"p95": "P95",
"unhealthy": "不健康",
"healthy": "ヘルシー",
"trueRate": "真率",
"falseRate": "偽率",
"coverage": "カバレッジ",
"targetsScored": "スコアリング対象数"
},
"health": {
"title": "健全性ステータス",
"noThreshold": "健全性しきい値が設定されていません。スコアはまだ健全または劣化として分類できません。",
"thresholdOnly": "{threshold} の場合健全",
"summary": "しきい値を{percent}%下回る · {threshold} の場合健全"
},
"ofScoreResults": "{count} 件のスコア結果のうち",
"range": "範囲 {min}–{max}"
"filterRange": "{from} 〜 {to}",
"filterBelow": "{to} 未満",
"filterAtLeast": "{from} 以上",
"filterAny": "すべての値",
"filterByBar": "値でスコアをフィルター",
"barButton": "{label}: {count} スコア | {label}: {count} スコア",
"clearFilter": "フィルターをクリア"
},
"runs": {
"review": {
"title": "評価スコア",
"hint": "スコアと推論は _llm_scores から得られます。ランを選択すると、その評価トレースが開き、さらに調査できます。",
"filterLabel": "評価スコアをフィルター",
"noThresholdHint": "このスコア設定に健全性しきい値を設定して、不健全なスコアを分類および強調表示します。",
"noReasoning": "推論が記録されていません",
"unknownAgent": "不明なエージェント",
"unknownScope": "不明なスコープ",
"queryError": "評価スコアを更新できませんでした。最後に成功したページが表示されています。",
"empty": "この時間範囲に評価スコアはありません。",
"emptyUnhealthy": "この時間範囲に異常スコアはありません。",
"filters": {
"all": "すべてのスコア",
"unhealthy": "異常"
},
"health": {
"healthy": "正常",
"unhealthy": "異常"
},
"columns": {
"time": "評価済み",
"result": "スコア",
"reasoning": "推論",
"target": "ターゲット",
"agent": "エージェント"
}
"all": "すべて",
"unhealthy": "異常",
"unhealthyDisabled": "スコアを分類するには正常なしきい値を設定してください",
"inputNotCaptured": "入力がキャプチャされていません",
"outputNotCaptured": "出力がキャプチャされていません",
"noMatch": "このフィルターに一致するスコアはありません"
},
"overview": {
"searchPlaceholder": "フィルター構成...",
"relativeTime": {
"daysAgo": "{day}日前",
"monthsAgo": "{month}ヶ月前"
"pane": {
"noneSelected": "スコアが選択されていません",
"healthy": "正常",
"unhealthy": "異常",
"noThreshold": "正常なしきい値が設定されていません",
"previous": "前のスコア",
"next": "次のスコア",
"reasoning": "推論",
"noReasoning": "推論は記録されていません",
"fullTextUnavailable": "短いプレビューを表示しています。完全なトレースを読み込めませんでした。",
"open": {
"span": "スパンを開く",
"trace": "トレースを開く",
"session": "セッションを開く"
},
"status": {
"healthy": "正常",
"unhealthy": "注意が必要",
"noThreshold": "しきい値が設定されていません",
"noData": "スコアデータがありません"
},
"health": {
"thresholdSummary": "{percent}% 未満 · しきい値 {threshold} で正常",
"noThresholdHint": "正常しきい値を設定",
"noDataHint": "このウィンドウにスコアはありません"
},
"columns": {
"health": "ヘルス",
"scoreConfig": "スコア設定",
"type": "タイプ",
"quality": "品質",
"totalScores": "合計スコア",
"scopeMix": "スコープミックス",
"volumeTrend": "ボリュームトレンド",
"updated": "更新済み"
}
},
"averageWithHealthyRate": "平均 · {rate}% 正常"
"evaluatorTrace": "評価者のトレース",
"addToDataset": "データセットに追加",
"datasetSession": "データセットには 1 つのトレースまたはスパンを指定します。セッションを開いて、その中のトレースを追加してください。",
"datasetNoStream": "このスコアには追加元のソースストリームがありません。"
}
},
"singular": {
"jobs": "評価ジョブ",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "インポート ({count})",
"count": "カウント",
"failedToDeriveSchema": "スキーマの導出に失敗しました",
"failedToImportScoreConfig": "スコア設定のインポートに失敗しました",
"failedToImportScorer": "スコアラーのインポートに失敗しました",
"failedToFetchCatalog": "カタログの取得に失敗しました ({status})",
"failedToLoadCatalog": "カタログの読み込みに失敗しました",
"failedToRunScorerTest": "スコアラーテストの実行に失敗しました",
"healthyRate": "正常率",
"invalidJsonFormat": "JSON 形式が無効です",
"jsonIsEmpty": "JSON が空です",
"scorerTestFailed": "スコアラーテストが失敗しました",
"scorerTestSampleContext": "フランスの国プロフィール: パリは首都です。",
"scorerTestSampleInput": "フランスの首都はパリで、セーヌ川沿いにあります。",
"scorerTestSampleOutput": "パリはフランスの首都です。",
"topCategory": "トップカテゴリ",
"trueRate": "真の率",
"unknownError": "不明なエラー",
"import": {
"createdSuccessfully": "\"{name}\" を正常に作成しました",
@@ -20191,6 +20166,7 @@
"evalTemplates": "オンライン評価 — テンプレート",
"scorers": "オンライン評価 — スコアラー",
"evalJobs": "オンライン評価 — 求人",
"quality": "オンライン評価 — 品質",
"scoreConfigs": "オンライン評価 — スコア構成",
"actions": "アクション",
"llmProviders": "LLM プロバイダー",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "リストを更新",
"scorersRefresh": "リストを更新",
"evalJobsRefresh": "リストを更新",
"qualityBack": "すべての設定に戻る",
"scoreConfigsRefresh": "リストを更新",
"actionsRefresh": "リストを更新",
"llmProvidersRefresh": "リストを更新",
@@ -20552,11 +20529,10 @@
"high": "高"
},
"qualityStatus": {
"nothreshold": "しきい値なし",
"nodata": "データなし",
"attention": "要注意",
"healthy": "正常",
"warn": "注意",
"unhealthy": "異常"
"unset": "しきい値が未設定",
"nodata": "スコアデータなし"
},
"rumEventType": {
"longtask": "長時間タスク",
+106 -130
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "모든 에이전트",
"agentLabel": "에이전트",
"agentPlaceholder": "모든 에이전트",
"kpisAriaLabel": "티어 1 KPI",
"dataTypes": {
"numericShort": "숫자",
"categoricalShort": "범주",
"booleanShort": "불리언"
"overview": {
"relativeTime": {
"daysAgo": "{day}일 전"
}
},
"healthyIf": "{rule}이면 정상",
"noHealthyValue": "없음",
"setThreshold": "정상 임계값 설정",
"noScoresInWindow": "이 기간에 점수가 없습니다",
"outOf": "{count} / {total}",
"scopeCount": "{scope} {count}개",
"scopes": {
"filterLabel": "점수 범위",
"filterHint": "이 점수 구성을 평가 대상 범위에서 비교합니다.",
"all": "모두",
"span": "스팬",
"trace": "트레이스",
"session": "세션"
},
"kpis": {
"scoreResults": {
"title": "점수 결과"
},
"evaluationCost": {
"title": "평가 비용"
},
"scorerSuccess": {
"title": "스코어러 성공"
},
"scorerFailures": {
"title": "스코어러 실패"
},
"latencyP95": {
"title": "스코어러 지연 시간 p95"
},
"vsPrev": "vs 이전",
"noPriorData": "이전 데이터 없음",
"noData": "—"
"scopeNouns": {
"span": "스팬",
"trace": "트레이스",
"session": "세션"
},
"detail": {
"loading": "구성별 분석을 로드하는 중...",
"noData": "이 창에는 이 구성에 대한 점수가 아직 없습니다.",
"empty": {
"title": "첫 번째 점수를 기다리는 중",
"titleScoped": "이 창에 {scope} 점수가 없습니다",
"description": "선택한 시간 범위에서 이 구성에 대한 점수가 도착하지 않았습니다. 이를 사용하는 스코어러가 실행되면 여기에 자동으로 표시됩니다.",
"descriptionScoped": "선택한 시간 범위에서 이 구성에 대한 {scope} 점수가 없습니다. 다른 범위를 선택하거나 시간 범위를 넓히십시오."
},
"trendTitle": "트렌드",
"distributionTitle": "점수 분포",
"categoryDistributionTitle": "카테고리 분포",
"healthyRateTitle": "시간별 정상 비율",
"trueRateTitle": "시간별 긍정 비율",
"trueFalseTitle": "진실 대 거짓",
"legendAvg": "평균",
"legendThreshold": "임계값 {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "정상 {direction} {value}",
"healthyThreshold": "정상 {threshold}",
"legendHealthy": "정상",
"legendUnhealthy": "비정상",
"legendHealthyRate": "정상 비율",
"legendTrueRate": "긍정 비율",
"legendTrueCount": "참 ({count})",
"legendFalseCount": "거짓 ({count})",
"kpis": {
"average": "평균",
"p50": "P50",
"p95": "P95",
"unhealthy": "비정상",
"healthy": "정상",
"trueRate": "긍정 비율",
"falseRate": "거짓 비율",
"coverage": "커버리지",
"targetsScored": "채점된 대상"
},
"health": {
"title": "상태",
"noThreshold": "정상 임계값이 설정되어 있지 않습니다. 아직 점수를 정상 또는 저하로 분류할 수 없습니다.",
"thresholdOnly": "{threshold}이면 정상",
"summary": "임계값보다 {percent}% 낮음 · {threshold}이면 정상"
},
"ofScoreResults": "{count}개 점수 결과 중",
"range": "범위 {min}–{max}"
"tiles": {
"attention": "주의가 필요한 구성",
"ofTotal": "총 {total}개",
"attentionNote": "보기",
"attentionTooltip": "비정상 점수가 하나 이상 있는 구성",
"withoutResult": "결과가 없는 구성",
"withoutResultTooltip": "이 시간 범위에 점수가 없거나 정상 임계값이 없는 구성입니다. 클릭하여 표시하세요.",
"noScores": "{count}개는 점수가 없습니다 | {count}개는 점수가 없습니다",
"noThreshold": "{count}개는 임계값이 없습니다 | {count}개는 임계값이 없습니다",
"allGiveResult": "모든 구성이 결과를 제공합니다"
},
"runs": {
"title": "평가 점수",
"hint": "점수와 근거는 _llm_scores에서 가져옵니다. 실행을 선택하면 평가자 트레이스가 열려 추가 조사할 수 있습니다.",
"filterLabel": "평가 점수 필터링",
"noThresholdHint": "이 점수 구성에 정상 임계값을 설정하면 비정상 점수를 분류하고 강조 표시합니다.",
"noReasoning": "추론 기록 없음",
"unknownAgent": "알 수 없는 에이전트",
"unknownScope": "알 수 없는 범위",
"queryError": "평가 점수를 새로고침할 수 없습니다. 마지막으로 성공한 페이지가 계속 표시됩니다.",
"empty": "이 시간 범위에 평가 점수가 없습니다.",
"emptyUnhealthy": "이 시간 범위에 불량 점수가 없습니다.",
"filters": {
"all": "모든 점수",
"unhealthy": "불량"
},
"health": {
"healthy": "정상",
"unhealthy": "불량"
},
"columns": {
"time": "평가됨",
"result": "점수",
"reasoning": "추론",
"target": "대상",
"agent": "에이전트"
}
},
"overview": {
"table": {
"searchPlaceholder": "구성 필터링…",
"relativeTime": {
"daysAgo": "{day}일 전",
"monthsAgo": "{month}개월 전"
},
"status": {
"healthy": "정상",
"unhealthy": "주의 필요",
"noThreshold": "임계값 미설정",
"noData": "점수 데이터 없음"
},
"health": {
"thresholdSummary": "{percent}% 미만 · {threshold}이면 정상",
"noThresholdHint": "정상 임계값 설정",
"noDataHint": "이 기간에 점수 없음"
},
"openUnhealthy": "비정상 점수 열기",
"configLink": "구성",
"averageRange": "평균, {min} ~ {max}",
"mostCommon": "가장 많음 · {count} / {total}",
"columns": {
"health": "상태",
"scoreConfig": "점수 구성",
"type": "타입",
"quality": "품질",
"totalScores": "총점",
"scopeMix": "범위 혼합",
"volumeTrend": "볼륨 추세",
"health": "상태",
"typicalScore": "대표 점수",
"unhealthy": "비정상",
"scores": "점수",
"updated": "업데이트됨"
}
},
"averageWithHealthyRate": "평균 · {rate}% 정상"
"detail": {
"back": "모든 구성",
"backToAll": "모든 구성으로 돌아가기",
"editConfig": "구성 편집",
"notFound": "점수 구성을 찾을 수 없습니다",
"notFoundDescription": "삭제되었거나 링크가 이전 구성을 가리키고 있을 수 있습니다.",
"emptyTitle": "첫 점수를 기다리는 중입니다",
"emptyTitleScoped": "이 기간에 {scope} 점수가 없습니다",
"emptyDescription": "선택한 시간 범위에 이 구성에 대한 점수가 없습니다. 이 구성을 사용하는 스코어러가 실행되면 여기에 표시됩니다.",
"distributionNumeric": "점수 분포",
"distributionValues": "값 분포",
"averageValue": "평균 {value}",
"legendHealthy": "정상",
"legendUnhealthy": "비정상",
"legendScores": "점수",
"chartTooltip": "{count}개 점수, {unhealthy}개 비정상 | {count}개 점수, {unhealthy}개 비정상",
"chartTooltipCount": "{count}개 점수 | {count}개 점수",
"trendTitle": {
"score": "추세",
"healthyRate": "시간별 정상 비율",
"trueRate": "시간별 True 비율",
"count": "시간별 점수"
},
"trendLegend": {
"healthyRate": "정상 비율",
"trueRate": "정답률",
"count": "점수"
},
"legendAvg": "평균",
"legendThreshold": "임계값 {direction} {value}",
"markLineHealthy": "정상 {direction} {value}",
"filterRange": "{from} ~ {to}",
"filterBelow": "{to} 미만",
"filterAtLeast": "{from} 이상",
"filterAny": "모든 값",
"filterByBar": "값으로 점수 필터링",
"barButton": "{label}: {count}개 점수 | {label}: {count}개 점수",
"clearFilter": "필터 지우기"
},
"review": {
"title": "평가 점수",
"all": "전체",
"unhealthy": "비정상",
"unhealthyDisabled": "점수를 분류하려면 정상 임계값을 설정하세요",
"inputNotCaptured": "입력이 캡처되지 않음",
"outputNotCaptured": "출력이 캡처되지 않음",
"noMatch": "이 필터와 일치하는 점수가 없습니다"
},
"pane": {
"noneSelected": "선택된 점수가 없습니다",
"healthy": "정상",
"unhealthy": "비정상",
"noThreshold": "정상 임계값이 설정되지 않음",
"previous": "이전 점수",
"next": "다음 점수",
"reasoning": "추론",
"noReasoning": "기록된 추론이 없습니다",
"fullTextUnavailable": "짧은 미리보기를 표시합니다. 전체 트레이스를 불러올 수 없습니다.",
"open": {
"span": "스팬 열기",
"trace": "트레이스 열기",
"session": "세션 열기"
},
"evaluatorTrace": "평가자 트레이스",
"addToDataset": "데이터셋에 추가",
"datasetSession": "데이터셋에는 트레이스 또는 스팬 하나만 추가할 수 있습니다. 세션을 열어 해당 트레이스 중 하나를 추가하세요.",
"datasetNoStream": "이 점수에는 추가할 소스 스트림이 없습니다."
}
},
"singular": {
"jobs": "평가 작업",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "가져오기 ({count})",
"count": "개수",
"failedToDeriveSchema": "스키마 도출 실패",
"failedToImportScoreConfig": "점수 설정 가져오기 실패",
"failedToImportScorer": "스코어러를 가져오지 못했습니다",
"failedToFetchCatalog": "카탈로그를 가져오지 못했습니다 ({status})",
"failedToLoadCatalog": "카탈로그를 불러오지 못했습니다",
"failedToRunScorerTest": "스코어러 테스트를 실행하지 못했습니다",
"healthyRate": "정상 비율",
"invalidJsonFormat": "잘못된 JSON 형식",
"jsonIsEmpty": "JSON이 비어 있습니다",
"scorerTestFailed": "스코어러 테스트 실패",
"scorerTestSampleContext": "프랑스 국가 프로필: 파리는 수도입니다.",
"scorerTestSampleInput": "프랑스의 수도는 센강에 위치한 파리입니다.",
"scorerTestSampleOutput": "파리는 프랑스의 수도입니다.",
"topCategory": "최상위 카테고리",
"trueRate": "참 비율",
"unknownError": "알 수 없는 오류",
"import": {
"createdSuccessfully": "\"{name}\" 생성 성공",
@@ -20191,6 +20166,7 @@
"evalTemplates": "온라인 평가 — 템플릿",
"scorers": "온라인 평가 — 스코어러",
"evalJobs": "온라인 평가 — 작업",
"quality": "Online Evals — 품질",
"scoreConfigs": "온라인 평가 — 점수 구성",
"actions": "액션",
"llmProviders": "LLM 공급자",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "목록 새로 고침",
"scorersRefresh": "목록 새로 고침",
"evalJobsRefresh": "목록 새로 고침",
"qualityBack": "모든 구성으로 돌아가기",
"scoreConfigsRefresh": "목록 새로 고침",
"actionsRefresh": "목록 새로 고침",
"llmProvidersRefresh": "목록 새로 고침",
@@ -20552,11 +20529,10 @@
"high": "높음"
},
"qualityStatus": {
"nothreshold": "임계값 없음",
"nodata": "데이터 없음",
"attention": "주의 필요",
"healthy": "정상",
"warn": "경고",
"unhealthy": "비정상"
"unset": "임계값 미설정",
"nodata": "점수 데이터 없음"
},
"rumEventType": {
"longtask": "긴 작업",
+106 -130
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Alle Agents",
"agentLabel": "Agent",
"agentPlaceholder": "Alle agenten",
"kpisAriaLabel": "Tier 1 KPI's",
"dataTypes": {
"numericShort": "Getal",
"categoricalShort": "Categorie",
"booleanShort": "Boolean"
"overview": {
"relativeTime": {
"daysAgo": "{day}d geleden"
}
},
"healthyIf": "Gezond als {rule}",
"noHealthyValue": "geen",
"setThreshold": "Stel een gezonde drempelwaarde in",
"noScoresInWindow": "Geen scores in dit venster",
"outOf": "{count} van {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Scorebereik",
"filterHint": "Vergelijk deze Scoreconfiguratie over evaluatiedoelbereiken heen.",
"all": "Alle",
"span": "Span",
"trace": "Trace",
"session": "Sessie"
},
"kpis": {
"scoreResults": {
"title": "Scoreresultaten"
},
"evaluationCost": {
"title": "Evaluatiekosten"
},
"scorerSuccess": {
"title": "Geslaagde scorers"
},
"scorerFailures": {
"title": "Mislukte scorers"
},
"latencyP95": {
"title": "Scorer-latentie p95"
},
"vsPrev": "versus de vorige",
"noPriorData": "geen eerdere gegevens",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "trace",
"session": "sessie"
},
"detail": {
"loading": "Uitsplitsing per configuratie wordt geladen...",
"noData": "Nog geen scores voor deze configuratie in dit venster.",
"empty": {
"title": "Wachten op je eerste scores",
"titleScoped": "Geen {scope} scores in dit venster",
"description": "Er zijn nog geen scores binnengekomen voor deze configuratie in het geselecteerde tijdsbereik. Ze verschijnen hier automatisch zodra een scorer die deze gebruikt wordt uitgevoerd.",
"descriptionScoped": "Er zijn geen {scope} scores voor deze configuratie in het geselecteerde tijdsbereik. Kies een andere scope of vergroot het tijdsbereik."
},
"trendTitle": "Trend",
"distributionTitle": "Scoreverdeling",
"categoryDistributionTitle": "Distributie van categorieën",
"healthyRateTitle": "Gezondheidspercentage in de tijd",
"trueRateTitle": "Waarheidspercentage in de tijd",
"trueFalseTitle": "Waar versus onwaar",
"legendAvg": "Gem",
"legendThreshold": "drempel {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "gezond {direction} {value}",
"healthyThreshold": "Gezond {threshold}",
"legendHealthy": "gezond",
"legendUnhealthy": "ongezond",
"legendHealthyRate": "Gezondheidspercentage",
"legendTrueRate": "Waarheidspercentage",
"legendTrueCount": "waar ({count})",
"legendFalseCount": "onwaar ({count})",
"kpis": {
"average": "Gemiddeld",
"p50": "P50",
"p95": "P95",
"unhealthy": "Ongezond",
"healthy": "Gezond",
"trueRate": "Waarheidspercentage",
"falseRate": "Onwaarheidspercentage",
"coverage": "Dekking",
"targetsScored": "Doelen gescoord"
},
"health": {
"title": "Gezondheidsstatus",
"noThreshold": "Er is geen gezonde drempel geconfigureerd. Scores kunnen nog niet worden geclassificeerd als gezond of verslechterend.",
"thresholdOnly": "Gezond als {threshold}",
"summary": "{percent}% onder drempel · Gezond als {threshold}"
},
"ofScoreResults": "van {count} scoreresultaten",
"range": "Bereik {min}–{max}"
"tiles": {
"attention": "Configuraties die aandacht vereisen",
"ofTotal": "van {total}",
"attentionNote": "Toon ze",
"attentionTooltip": "Configuraties met minstens één ongezonde score",
"withoutResult": "Configuraties zonder resultaat",
"withoutResultTooltip": "Configuraties zonder scores in dit tijdsbereik, of zonder gezonde drempelwaarde. Klik om ze te tonen.",
"noScores": "{count} kreeg geen scores | {count} kregen geen scores",
"noThreshold": "{count} heeft geen drempelwaarde | {count} hebben geen drempelwaarde",
"allGiveResult": "Elke configuratie geeft een resultaat"
},
"runs": {
"title": "Evaluatiescores",
"hint": "Scores en redeneringen komen van _llm_scores. Selecteer een run om de evaluatortrace te openen voor verder onderzoek.",
"filterLabel": "Filter evaluatiescores",
"noThresholdHint": "Stel een gezonde drempel in op deze Scoreconfiguratie om ongezonde scores te classificeren en te markeren.",
"noReasoning": "Geen onderbouwing vastgelegd",
"unknownAgent": "Onbekende agent",
"unknownScope": "Onbekend bereik",
"queryError": "Evaluatiescores konden niet worden vernieuwd. De laatste succesvolle pagina wordt nog getoond.",
"empty": "Geen evaluatiescores in dit tijdsbereik.",
"emptyUnhealthy": "Geen ongezonde scores in dit tijdsbereik.",
"filters": {
"all": "Alle scores",
"unhealthy": "Ongezond"
},
"health": {
"healthy": "Gezond",
"unhealthy": "Ongezond"
},
"columns": {
"time": "Geëvalueerd",
"result": "Score",
"reasoning": "Redenering",
"target": "Doelwit",
"agent": "Agent"
}
},
"overview": {
"table": {
"searchPlaceholder": "Configuraties filteren…",
"relativeTime": {
"daysAgo": "{day}d geleden",
"monthsAgo": "{month}ma geleden"
},
"status": {
"healthy": "Gezond",
"unhealthy": "Aandacht nodig",
"noThreshold": "Drempel niet ingesteld",
"noData": "Geen scoregegevens"
},
"health": {
"thresholdSummary": "{percent}% onder · Gezond als {threshold}",
"noThresholdHint": "Stel een gezonde drempel in",
"noDataHint": "Geen scores in dit venster"
},
"openUnhealthy": "Open de ongezonde scores",
"configLink": "Configuratie",
"averageRange": "gemiddelde, {min} tot {max}",
"mostCommon": "meest voorkomend · {count} van {total}",
"columns": {
"health": "Gezondheid",
"scoreConfig": "Scoreconfiguratie",
"type": "Typ",
"quality": "Kwaliteit",
"totalScores": "Totale scores",
"scopeMix": "Bereikmix",
"volumeTrend": "Volumetrend",
"health": "Gezondheid",
"typicalScore": "Typische score",
"unhealthy": "Ongezond",
"scores": "Scores",
"updated": "Bijgewerkt"
}
},
"averageWithHealthyRate": "Gemiddelde · {rate}% gezond"
"detail": {
"back": "Alle configuraties",
"backToAll": "Terug naar alle configuraties",
"editConfig": "Configuratie bewerken",
"notFound": "Scoreconfiguratie niet gevonden",
"notFoundDescription": "Deze is mogelijk verwijderd, of de link verwijst naar een oude configuratie.",
"emptyTitle": "Wachten op je eerste scores",
"emptyTitleScoped": "Geen {scope}-scores in dit venster",
"emptyDescription": "Er zijn geen scores binnengekomen voor deze configuratie in het geselecteerde tijdsbereik. Ze verschijnen hier zodra een scorer die deze gebruikt draait.",
"distributionNumeric": "Scoreverdeling",
"distributionValues": "Waardeverdeling",
"averageValue": "gemiddelde {value}",
"legendHealthy": "Gezond",
"legendUnhealthy": "Ongezond",
"legendScores": "Scores",
"chartTooltip": "{count} score, {unhealthy} ongezond | {count} scores, {unhealthy} ongezond",
"chartTooltipCount": "{count} score | {count} scores",
"trendTitle": {
"score": "Trend",
"healthyRate": "Gezondheidsgraad in de tijd",
"trueRate": "Ware graad in de tijd",
"count": "Scores in de tijd"
},
"trendLegend": {
"healthyRate": "Gezondheidsgraad",
"trueRate": "Waarheidspercentage",
"count": "Scores"
},
"legendAvg": "Gem",
"legendThreshold": "drempel {direction} {value}",
"markLineHealthy": "gezond {direction} {value}",
"filterRange": "{from} tot {to}",
"filterBelow": "Onder {to}",
"filterAtLeast": "{from} of meer",
"filterAny": "Elke waarde",
"filterByBar": "Scores filteren op waarde",
"barButton": "{label}: {count} score | {label}: {count} scores",
"clearFilter": "Filter wissen"
},
"review": {
"title": "Evaluatiescores",
"all": "Alle",
"unhealthy": "Ongezond",
"unhealthyDisabled": "Stel een gezonde drempelwaarde in om scores te classificeren",
"inputNotCaptured": "Invoer niet vastgelegd",
"outputNotCaptured": "Uitvoer niet vastgelegd",
"noMatch": "Geen scores voldoen aan dit filter"
},
"pane": {
"noneSelected": "Geen score geselecteerd",
"healthy": "Gezond",
"unhealthy": "Ongezond",
"noThreshold": "Geen gezonde drempelwaarde ingesteld",
"previous": "Vorige score",
"next": "Volgende score",
"reasoning": "Redenering",
"noReasoning": "Geen redenering vastgelegd",
"fullTextUnavailable": "Een korte preview wordt getoond. De volledige trace kon niet worden geladen.",
"open": {
"span": "Span openen",
"trace": "Trace openen",
"session": "Sessie openen"
},
"evaluatorTrace": "Trace van de evaluator",
"addToDataset": "Toevoegen aan dataset",
"datasetSession": "Datasets nemen één trace of span. Open de sessie om een van de traces toe te voegen.",
"datasetNoStream": "Deze score heeft geen bron-stream om uit toe te voegen."
}
},
"singular": {
"jobs": "Eval Job",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Import ({count})",
"count": "Aantal",
"failedToDeriveSchema": "Kan schema niet afleiden",
"failedToImportScoreConfig": "Kan scoreconfiguratie niet importeren",
"failedToImportScorer": "Kan scorer niet importeren",
"failedToFetchCatalog": "Kan catalogus niet ophalen ({status})",
"failedToLoadCatalog": "Kan catalogus niet laden",
"failedToRunScorerTest": "Kan scorer-test niet uitvoeren",
"healthyRate": "Gezondheidspercentage",
"invalidJsonFormat": "Ongeldig JSON-formaat",
"jsonIsEmpty": "JSON is leeg",
"scorerTestFailed": "Scorer-test mislukt",
"scorerTestSampleContext": "France landprofiel: Paris is de hoofdstad.",
"scorerTestSampleInput": "De hoofdstad van France is Paris, gelegen aan de rivier de Seine.",
"scorerTestSampleOutput": "Paris is de hoofdstad van France.",
"topCategory": "Topcategorie",
"trueRate": "Werkelijk percentage",
"unknownError": "Onbekende fout",
"import": {
"createdSuccessfully": "\"{name}\" is succesvol aangemaakt",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Online evaluaties — Sjablonen",
"scorers": "Online evaluaties — Scorers",
"evalJobs": "Online evaluaties — Jobs",
"quality": "Online Evals — Kwaliteit",
"scoreConfigs": "Online evaluaties — Score-configuraties",
"actions": "Acties",
"llmProviders": "LLM-aanbieders",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Lijst verversen",
"scorersRefresh": "Lijst verversen",
"evalJobsRefresh": "Lijst verversen",
"qualityBack": "Terug naar alle configuraties",
"scoreConfigsRefresh": "Lijst verversen",
"actionsRefresh": "Lijst verversen",
"llmProvidersRefresh": "Lijst verversen",
@@ -20552,11 +20529,10 @@
"high": "Hoog"
},
"qualityStatus": {
"nothreshold": "Geen drempelwaarde",
"nodata": "Geen gegevens",
"attention": "Vereist aandacht",
"healthy": "Gezond",
"warn": "Waarschuwing",
"unhealthy": "Ongezond"
"unset": "Drempelwaarde niet ingesteld",
"nodata": "Geen scoregegevens"
},
"rumEventType": {
"longtask": "Lange taak",
+107 -131
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Wszyscy agenci",
"agentLabel": "Agent",
"agentPlaceholder": "Wszyscy agenci",
"kpisAriaLabel": "KPI poziomu 1",
"dataTypes": {
"numericShort": "Liczba",
"categoricalShort": "Kat.",
"booleanShort": "Bool"
"overview": {
"relativeTime": {
"daysAgo": "{day} dni temu"
}
},
"healthyIf": "Zdrowy, jeśli {rule}",
"noHealthyValue": "brak",
"setThreshold": "Ustaw próg zdrowia",
"noScoresInWindow": "Brak ocen w tym oknie",
"outOf": "{count} z {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Zakres oceny",
"filterHint": "Porównaj tę konfigurację oceny w różnych zakresach docelowych oceniania.",
"all": "Wszystkie",
"span": "Span",
"trace": "Trace",
"session": "Sesja"
},
"kpis": {
"scoreResults": {
"title": "Wyniki oceny"
},
"evaluationCost": {
"title": "Koszt oceny"
},
"scorerSuccess": {
"title": "Sukcesy ewaluatorów"
},
"scorerFailures": {
"title": "Błędy ewaluatorów"
},
"latencyP95": {
"title": "Opóźnienie ewaluatorów p95"
},
"vsPrev": "vs poprz",
"noPriorData": "brak wcześniejszych danych",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "trace",
"session": "sesja"
},
"detail": {
"loading": "Ładowanie podziału na konfigurację…",
"noData": "Brak ocen dla tej konfiguracji w tym oknie.",
"empty": {
"title": "Oczekiwanie na pierwsze oceny",
"titleScoped": "Brak wyników dla {scope} w tym oknie",
"description": "Żadne oceny nie trafiły do tej konfiguracji w wybranym zakresie czasu. Pojawią się tutaj automatycznie, gdy uruchomi się ewaluator, który jej używa.",
"descriptionScoped": "Żadne wyniki dla {scope} nie trafiły dla tej konfiguracji w wybranym zakresie czasu. Wybierz inny zakres lub poszerz zakres czasu."
},
"trendTitle": "Trend",
"distributionTitle": "Rozkład ocen",
"categoryDistributionTitle": "Rozkład kategorii",
"healthyRateTitle": "Wskaźnik zdrowia w czasie",
"trueRateTitle": "Wskaźnik prawdy w czasie",
"trueFalseTitle": "Prawda vs Fałsz",
"legendAvg": "Śr",
"legendThreshold": "próg {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "zdrowy {direction} {value}",
"healthyThreshold": "Zdrowy {threshold}",
"legendHealthy": "zdrowy",
"legendUnhealthy": "niezdrowy",
"legendHealthyRate": "Wskaźnik zdrowia",
"legendTrueRate": "Wskaźnik prawdy",
"legendTrueCount": "prawda ({count})",
"legendFalseCount": "fałsz ({count})",
"kpis": {
"average": "Średnia",
"p50": "P50",
"p95": "P95",
"unhealthy": "Niezdrowy",
"healthy": "Zdrowy",
"trueRate": "Wskaźnik prawdy",
"falseRate": "Wskaźnik fałszu",
"coverage": "Pokrycie",
"targetsScored": "Ocenione cele"
},
"health": {
"title": "Stan zdrowia",
"noThreshold": "Nie skonfigurowano progu zdrowia. Wyniki nie mogą być jeszcze klasyfikowane jako zdrowe lub pogarszające się.",
"thresholdOnly": "Zdrowy, jeśli {threshold}",
"summary": "{percent}% poniżej progu · Zdrowy, jeśli {threshold}"
},
"ofScoreResults": "z {count} wyników punktacji",
"range": "Zakres {min}–{max}"
"tiles": {
"attention": "Konfiguracje wymagające uwagi",
"ofTotal": "z {total}",
"attentionNote": "Pokaż je",
"attentionTooltip": "Konfiguracje z co najmniej jedną niezdrową oceną",
"withoutResult": "Konfiguracje bez wyniku",
"withoutResultTooltip": "Konfiguracje bez ocen w tym zakresie czasu lub bez progu zdrowia. Kliknij, aby je pokazać.",
"noScores": "{count} nie otrzymała ocen | {count} nie otrzymały ocen",
"noThreshold": "{count} nie ma progu | {count} nie mają progu",
"allGiveResult": "Każda konfiguracja zwraca wynik"
},
"runs": {
"title": "Oceny",
"hint": "Wyniki i uzasadnienie pochodzą z _llm_scores. Wybierz uruchomienie, aby otworzyć jego trace ewaluatora w celu dalszego zbadania.",
"filterLabel": "Filtruj oceny",
"noThresholdHint": "Ustaw próg zdrowia w tej konfiguracji oceny, aby klasyfikować i wyróżniać niezdrowe wyniki.",
"noReasoning": "Nie zapisano uzasadnienia",
"unknownAgent": "Nieznany agent",
"unknownScope": "Nieznany zakres",
"queryError": "Nie można odświeżyć wyników oceny. Nadal wyświetlana jest ostatnia pomyślna strona.",
"empty": "Brak wyników oceny w tym przedziale czasowym.",
"emptyUnhealthy": "Brak niezdrowych wyników w tym przedziale czasowym.",
"filters": {
"all": "Wszystkie wyniki",
"unhealthy": "Niezdrowy"
},
"health": {
"healthy": "Zdrowy",
"unhealthy": "Niezdrowy"
},
"columns": {
"time": "Oceniono",
"result": "Wynik",
"reasoning": "Uzasadnienie",
"target": "Cel",
"agent": "Agent"
}
},
"overview": {
"table": {
"searchPlaceholder": "Filtruj konfiguracje…",
"relativeTime": {
"daysAgo": "{day} dni temu",
"monthsAgo": "{month} mies. temu"
},
"status": {
"healthy": "Zdrowy",
"unhealthy": "Wymaga uwagi",
"noThreshold": "Próg nie ustawiony",
"noData": "Brak danych wyników"
},
"health": {
"thresholdSummary": "{percent}% poniżej · Zdrowy, jeśli {threshold}",
"noThresholdHint": "Ustaw zdrowy próg",
"noDataHint": "Brak wyników w tym oknie"
},
"openUnhealthy": "Otwórz niezdrowe oceny",
"configLink": "Konfiguracja",
"averageRange": "średnia, od {min} do {max}",
"mostCommon": "najczęstsza · {count} z {total}",
"columns": {
"health": "Zdrowie",
"scoreConfig": "Konfiguracja oceny",
"type": "Typ",
"quality": "Jakość",
"totalScores": "Łączna liczba ocen",
"scopeMix": "Mieszanka zakresów",
"volumeTrend": "Trend wolumenu",
"scoreConfig": "Konfiguracja ocen",
"health": "Stan zdrowia",
"typicalScore": "Typowa ocena",
"unhealthy": "Niezdrowe",
"scores": "Oceny",
"updated": "Zaktualizowano"
}
},
"averageWithHealthyRate": "Średnia · {rate}% zdrowych"
"detail": {
"back": "Wszystkie konfiguracje",
"backToAll": "Wróć do wszystkich konfiguracji",
"editConfig": "Edytuj konfigurację",
"notFound": "Nie znaleziono konfiguracji ocen",
"notFoundDescription": "Może została usunięta lub link wskazuje na starą konfigurację.",
"emptyTitle": "Oczekiwanie na pierwsze oceny",
"emptyTitleScoped": "Brak ocen {scope} w tym oknie",
"emptyDescription": "W wybranym zakresie czasu nie napłynęły żadne oceny dla tej konfiguracji. Pojawią się tutaj, gdy uruchomi się ewaluator, który jej używa.",
"distributionNumeric": "Rozkład ocen",
"distributionValues": "Rozkład wartości",
"averageValue": "średnia {value}",
"legendHealthy": "Prawidłowy",
"legendUnhealthy": "Nieprawidłowy",
"legendScores": "Oceny",
"chartTooltip": "{count} ocena, {unhealthy} niezdrowych | {count} ocen, {unhealthy} niezdrowych",
"chartTooltipCount": "{count} ocena | {count} ocen",
"trendTitle": {
"score": "Trend",
"healthyRate": "Odsetek zdrowych w czasie",
"trueRate": "Odsetek wartości prawdziwych w czasie",
"count": "Oceny w czasie"
},
"trendLegend": {
"healthyRate": "Odsetek zdrowych",
"trueRate": "Odsetek wartości true",
"count": "Oceny"
},
"legendAvg": "Śr",
"legendThreshold": "próg {direction} {value}",
"markLineHealthy": "zdrowy {direction} {value}",
"filterRange": "od {from} do {to}",
"filterBelow": "Poniżej {to}",
"filterAtLeast": "{from} lub więcej",
"filterAny": "Dowolna wartość",
"filterByBar": "Filtruj oceny według wartości",
"barButton": "{label}: {count} ocena | {label}: {count} ocen",
"clearFilter": "Wyczyść filtr"
},
"review": {
"title": "Wyniki ocen",
"all": "Wszystkie",
"unhealthy": "Niezdrowe",
"unhealthyDisabled": "Ustaw próg zdrowia, aby klasyfikować oceny",
"inputNotCaptured": "Nie przechwycono danych wejściowych",
"outputNotCaptured": "Nie przechwycono danych wyjściowych",
"noMatch": "Brak ocen pasujących do tego filtra"
},
"pane": {
"noneSelected": "Nie wybrano oceny",
"healthy": "Zdrowe",
"unhealthy": "Niezdrowe",
"noThreshold": "Nie ustawiono progu zdrowia",
"previous": "Poprzednia ocena",
"next": "Następna ocena",
"reasoning": "Rozumowanie",
"noReasoning": "Nie zapisano rozumowania",
"fullTextUnavailable": "Wyświetlany jest krótki podgląd. Nie udało się wczytać pełnego trace.",
"open": {
"span": "Otwórz span",
"trace": "Otwórz trace",
"session": "Otwórz sesję"
},
"evaluatorTrace": "Trace ewaluatora",
"addToDataset": "Dodaj do zestawu danych",
"datasetSession": "Zestawy danych przyjmują pojedynczy trace lub span. Otwórz sesję, aby dodać jeden z jej trace'ów.",
"datasetNoStream": "Ta ocena nie ma strumienia źródłowego, z którego można ją dodać."
}
},
"singular": {
"jobs": "Zadanie ewaluacji",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Importuj ({count})",
"count": "Liczba",
"failedToDeriveSchema": "Nie udało się wyprowadzić schematu",
"failedToImportScoreConfig": "Nie udało się zaimportować konfiguracji oceny",
"failedToImportScorer": "Nie udało się zaimportować ewaluatora",
"failedToFetchCatalog": "Nie udało się pobrać katalogu ({status})",
"failedToLoadCatalog": "Nie udało się wczytać katalogu",
"failedToRunScorerTest": "Nie udało się uruchomić testu ewaluatora",
"healthyRate": "Wskaźnik zdrowotności",
"invalidJsonFormat": "Nieprawidłowy format JSON",
"jsonIsEmpty": "JSON jest pusty",
"scorerTestFailed": "Test ewaluatora nie powiódł się",
"scorerTestSampleContext": "Profil kraju Francji: Paryż jest stolicą.",
"scorerTestSampleInput": "Stolicą Francji jest Paryż, położony nad Sekwaną.",
"scorerTestSampleOutput": "Paryż jest stolicą Francji.",
"topCategory": "Najwyższa kategoria",
"trueRate": "Wskaźnik prawdziwości",
"unknownError": "Nieznany błąd",
"import": {
"createdSuccessfully": "\"{name}\" zostało utworzone pomyślnie",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Ewaluacje online — Szablony",
"scorers": "Ewaluacje online — Ewaluatory",
"evalJobs": "Ewaluacje online — Zadania",
"quality": "Ewaluacje online — Jakość",
"scoreConfigs": "Ewaluacje online — Konfiguracje ocen",
"actions": "Akcje",
"llmProviders": "Dostawcy LLM",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Odśwież listę",
"scorersRefresh": "Odśwież listę",
"evalJobsRefresh": "Odśwież listę",
"qualityBack": "Wróć do wszystkich konfiguracji",
"scoreConfigsRefresh": "Odśwież listę",
"actionsRefresh": "Odśwież listę",
"llmProvidersRefresh": "Odśwież listę",
@@ -20552,11 +20529,10 @@
"high": "Wysoki"
},
"qualityStatus": {
"nothreshold": "Brak progu",
"nodata": "Brak danych",
"attention": "Wymaga uwagi",
"healthy": "Zdrowy",
"warn": "Ostrzeżenie",
"unhealthy": "Niezdrowy"
"unset": "Nie ustawiono progu",
"nodata": "Brak danych oceny"
},
"rumEventType": {
"longtask": "Długie zadanie",
+106 -130
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Todos os Agentes",
"agentLabel": "Agente",
"agentPlaceholder": "Todos os agentes",
"kpisAriaLabel": "KPIs de Nível 1",
"dataTypes": {
"numericShort": "Núm",
"categoricalShort": "Cat",
"booleanShort": "Bool"
"overview": {
"relativeTime": {
"daysAgo": "{day}d atrás"
}
},
"healthyIf": "Saudável se {rule}",
"noHealthyValue": "nenhum",
"setThreshold": "Definir um limiar saudável",
"noScoresInWindow": "Sem pontuações nesta janela",
"outOf": "{count} de {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Âmbito da pontuação",
"filterHint": "Compare esta Configuração de Pontuação entre âmbitos de destino de avaliação.",
"all": "Todos",
"span": "Span",
"trace": "Trace",
"session": "Sessão"
},
"kpis": {
"scoreResults": {
"title": "Resultados da Pontuação"
},
"evaluationCost": {
"title": "Custo de avaliação"
},
"scorerSuccess": {
"title": "Sucesso do Avaliador"
},
"scorerFailures": {
"title": "Falhas no avaliador"
},
"latencyP95": {
"title": "Latência do Avaliador p95"
},
"vsPrev": "versus anterior",
"noPriorData": "sem dados anteriores",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "trace",
"session": "sessão"
},
"detail": {
"loading": "A carregar discriminação por configuração...",
"noData": "Ainda não há pontuações para esta configuração nesta janela.",
"empty": {
"title": "A aguardar as suas primeiras pontuações",
"titleScoped": "Nenhuma pontuação de {scope} nesta janela",
"description": "Nenhuma pontuação foi registada para esta configuração no intervalo de tempo selecionado. Aparecerão aqui automaticamente assim que um avaliador que a utilize for executado.",
"descriptionScoped": "Nenhuma pontuação de {scope} foi recebida para esta configuração no intervalo de tempo selecionado. Escolha outro âmbito ou amplie o intervalo de tempo."
},
"trendTitle": "Tendência",
"distributionTitle": "Distribuição de pontuação",
"categoryDistributionTitle": "Distribuição de categorias",
"healthyRateTitle": "Taxa saudável ao longo do tempo",
"trueRateTitle": "Taxa verdadeira ao longo do tempo",
"trueFalseTitle": "Verdadeiro versus falso",
"legendAvg": "Média",
"legendThreshold": "limiar {'{'}direção{'}'} {'{'}valor{'}'}",
"markLineHealthy": "saudável {direction} {value}",
"healthyThreshold": "Saudável {threshold}",
"legendHealthy": "saudável",
"legendUnhealthy": "não saudável",
"legendHealthyRate": "Taxa saudável",
"legendTrueRate": "Taxa verdadeira",
"legendTrueCount": "verdadeiro ({count})",
"legendFalseCount": "falso ({count})",
"kpis": {
"average": "Média",
"p50": "P50",
"p95": "P95",
"unhealthy": "Não saudável",
"healthy": "Saudável",
"trueRate": "Taxa verdadeira",
"falseRate": "Taxa falsa",
"coverage": "Cobertura",
"targetsScored": "Alvos pontuados"
},
"health": {
"title": "Estado de saúde",
"noThreshold": "Nenhum limite saudável está configurado. As pontuações ainda não podem ser classificadas como saudáveis ou em degradação.",
"thresholdOnly": "Saudável se {threshold}",
"summary": "{percent}% abaixo do limite · Saudável se {threshold}"
},
"ofScoreResults": "de {count} resultados de pontuação",
"range": "Faixa {min}–{max}"
"tiles": {
"attention": "Configurações que precisam de atenção",
"ofTotal": "de {total}",
"attentionNote": "Mostrar",
"attentionTooltip": "Configurações com pelo menos uma pontuação não saudável",
"withoutResult": "Configurações sem resultado",
"withoutResultTooltip": "Configurações sem pontuações neste intervalo de tempo ou sem um limiar saudável. Clique para as mostrar.",
"noScores": "{count} não recebeu pontuações | {count} não receberam pontuações",
"noThreshold": "{count} não tem limiar | {count} não têm limiar",
"allGiveResult": "Cada configuração dá um resultado"
},
"runs": {
"title": "Pontuações de avaliação",
"hint": "As pontuações e o raciocínio vêm de _llm_scores. Selecione uma execução para abrir o trace do avaliador para investigação adicional.",
"filterLabel": "Filtrar pontuações de avaliação",
"noThresholdHint": "Defina um limite saudável nesta Configuração de Pontuação para classificar e destacar pontuações não saudáveis.",
"noReasoning": "Nenhum raciocínio registado",
"unknownAgent": "Agente desconhecido",
"unknownScope": "Âmbito desconhecido",
"queryError": "Não foi possível atualizar as pontuações de avaliação. A última página bem-sucedida continua a ser apresentada.",
"empty": "Nenhuma pontuação de avaliação neste intervalo de tempo.",
"emptyUnhealthy": "Nenhuma pontuação não saudável neste intervalo de tempo.",
"filters": {
"all": "Todas as pontuações",
"unhealthy": "Não saudável"
},
"health": {
"healthy": "Saudável",
"unhealthy": "Não saudável"
},
"columns": {
"time": "Avaliado",
"result": "Pontuação",
"reasoning": "Raciocínio",
"target": "Alvo",
"agent": "Agente"
}
},
"overview": {
"table": {
"searchPlaceholder": "Filtrar configurações…",
"relativeTime": {
"daysAgo": "{day}d atrás",
"monthsAgo": "{month}mo atrás"
},
"status": {
"healthy": "Saudável",
"unhealthy": "Precisa de atenção",
"noThreshold": "Limiar não definido",
"noData": "Sem dados de pontuação"
},
"health": {
"thresholdSummary": "{percent}% abaixo · Saudável se {threshold}",
"noThresholdHint": "Definir um limiar saudável",
"noDataHint": "Nenhuma pontuação nesta janela"
},
"openUnhealthy": "Abrir as pontuações não saudáveis",
"configLink": "Configuração",
"averageRange": "média, {min} a {max}",
"mostCommon": "mais comum · {count} de {total}",
"columns": {
"health": "Saúde",
"scoreConfig": "Configuração de pontuação",
"type": "Tipo",
"quality": "Qualidade",
"totalScores": "Pontuações totais",
"scopeMix": "Mistura de Âmbitos",
"volumeTrend": "Tendência de Volume",
"health": "Saúde",
"typicalScore": "Pontuação típica",
"unhealthy": "Não saudável",
"scores": "Pontuações",
"updated": "Atualizado"
}
},
"averageWithHealthyRate": "Média · {rate}% saudável"
"detail": {
"back": "Todas as configurações",
"backToAll": "Voltar a todas as configurações",
"editConfig": "Editar configuração",
"notFound": "Configuração de pontuação não encontrada",
"notFoundDescription": "Pode ter sido eliminada ou a ligação aponta para uma configuração antiga.",
"emptyTitle": "À espera das suas primeiras pontuações",
"emptyTitleScoped": "Sem pontuações de {scope} nesta janela",
"emptyDescription": "Não chegaram pontuações para esta configuração no intervalo de tempo selecionado. Aparecem aqui quando um avaliador que a utiliza for executado.",
"distributionNumeric": "Distribuição das pontuações",
"distributionValues": "Distribuição dos valores",
"averageValue": "média {value}",
"legendHealthy": "Saudável",
"legendUnhealthy": "Não saudável",
"legendScores": "Pontuações",
"chartTooltip": "{count} pontuação, {unhealthy} não saudável | {count} pontuações, {unhealthy} não saudáveis",
"chartTooltipCount": "{count} pontuação | {count} pontuações",
"trendTitle": {
"score": "Tendência",
"healthyRate": "Taxa de saudáveis ao longo do tempo",
"trueRate": "Taxa de verdadeiros ao longo do tempo",
"count": "Pontuações ao longo do tempo"
},
"trendLegend": {
"healthyRate": "Taxa de saudáveis",
"trueRate": "Taxa de verdadeiros",
"count": "Pontuações"
},
"legendAvg": "Média",
"legendThreshold": "limiar {direction} {value}",
"markLineHealthy": "saudável {direction} {value}",
"filterRange": "{from} a {to}",
"filterBelow": "Abaixo de {to}",
"filterAtLeast": "{from} ou mais",
"filterAny": "Qualquer valor",
"filterByBar": "Filtrar pontuações por valor",
"barButton": "{label}: {count} pontuação | {label}: {count} pontuações",
"clearFilter": "Limpar filtro"
},
"review": {
"title": "Pontuações de avaliação",
"all": "Todas",
"unhealthy": "Não saudáveis",
"unhealthyDisabled": "Defina um limiar para classificar pontuações como saudáveis",
"inputNotCaptured": "Entrada não capturada",
"outputNotCaptured": "Saída não capturada",
"noMatch": "Nenhuma pontuação corresponde a este filtro"
},
"pane": {
"noneSelected": "Nenhuma pontuação selecionada",
"healthy": "Saudável",
"unhealthy": "Não saudável",
"noThreshold": "Nenhum limiar de saúde definido",
"previous": "Pontuação anterior",
"next": "Pontuação seguinte",
"reasoning": "Raciocínio",
"noReasoning": "Nenhum raciocínio registado",
"fullTextUnavailable": "A mostrar uma pré-visualização curta. Não foi possível carregar o trace completo.",
"open": {
"span": "Abrir span",
"trace": "Abrir trace",
"session": "Abrir sessão"
},
"evaluatorTrace": "Trace do avaliador",
"addToDataset": "Adicionar ao conjunto de dados",
"datasetSession": "Os conjuntos de dados aceitam um único trace ou span. Abra a sessão para adicionar um dos seus traces.",
"datasetNoStream": "Esta pontuação não tem um stream de origem de onde adicionar."
}
},
"singular": {
"jobs": "Trabalho de avaliação",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Importar ({count})",
"count": "Contagem",
"failedToDeriveSchema": "Falha ao derivar esquema",
"failedToImportScoreConfig": "Falha ao importar configuração de pontuação",
"failedToImportScorer": "Falha ao importar avaliador",
"failedToFetchCatalog": "Falha ao obter catálogo ({status})",
"failedToLoadCatalog": "Falha ao carregar catálogo",
"failedToRunScorerTest": "Falha ao executar teste do avaliador",
"healthyRate": "Taxa saudável",
"invalidJsonFormat": "Formato JSON inválido",
"jsonIsEmpty": "JSON está vazio",
"scorerTestFailed": "Falha no teste do avaliador",
"scorerTestSampleContext": "Perfil do país França: Paris é a capital.",
"scorerTestSampleInput": "A capital de França é Paris, situada no rio Sena.",
"scorerTestSampleOutput": "Paris é a capital de França.",
"topCategory": "Categoria principal",
"trueRate": "Taxa verdadeira",
"unknownError": "Erro desconhecido",
"import": {
"createdSuccessfully": "\"{name}\" criado com sucesso",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Avaliações online — Modelos",
"scorers": "Avaliações online — Avaliadores",
"evalJobs": "Avaliações online — Tarefas",
"quality": "Avaliações online — Qualidade",
"scoreConfigs": "Avaliações online — Configurações de pontuação",
"actions": "Ações",
"llmProviders": "Fornecedores de LLM",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Atualizar lista",
"scorersRefresh": "Atualizar lista",
"evalJobsRefresh": "Atualizar lista",
"qualityBack": "Voltar a todas as configurações",
"scoreConfigsRefresh": "Atualizar lista",
"actionsRefresh": "Atualizar lista",
"llmProvidersRefresh": "Atualizar lista",
@@ -20552,11 +20529,10 @@
"high": "Alto"
},
"qualityStatus": {
"nothreshold": "Sem limite",
"nodata": "Sem dados",
"attention": "Requer atenção",
"healthy": "Saudável",
"warn": "Aviso",
"unhealthy": "Não saudável"
"unset": "Limiar não definido",
"nodata": "Sem dados de pontuação"
},
"rumEventType": {
"longtask": "Tarefa longa",
+107 -131
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Все агенты",
"agentLabel": "Агент",
"agentPlaceholder": "Все агенты",
"kpisAriaLabel": "КПЭ уровня 1",
"dataTypes": {
"numericShort": "Число",
"categoricalShort": "Категория",
"booleanShort": "Булев"
"overview": {
"relativeTime": {
"daysAgo": "{day} дн. назад"
}
},
"healthyIf": "В норме, если {rule}",
"noHealthyValue": "нет",
"setThreshold": "Задайте порог нормы",
"noScoresInWindow": "Нет оценок в этом окне",
"outOf": "{count} из {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Область оценки",
"filterHint": "Сравните эту конфигурацию оценок по разным областям оценки.",
"all": "Все",
"span": "Спан",
"trace": "Трейс",
"session": "Сессия"
},
"kpis": {
"scoreResults": {
"title": "Результаты оценок"
},
"evaluationCost": {
"title": "Стоимость оценки"
},
"scorerSuccess": {
"title": "Успешность оценщиков"
},
"scorerFailures": {
"title": "Сбои оценщиков"
},
"latencyP95": {
"title": "Задержка оценщика p95"
},
"vsPrev": "по сравнению с предыдущим",
"noPriorData": "нет предыдущих данных",
"noData": "—"
"scopeNouns": {
"span": "спанов",
"trace": "трейсов",
"session": "сессий"
},
"detail": {
"loading": "Загрузка разбивки по конфигурациям…",
"noData": "В этом окне пока нет оценок для этой конфигурации.",
"empty": {
"title": "Ожидание первых оценок",
"titleScoped": "Нет оценок {scope} в этом окне",
"description": "В выбранном временном диапазоне для этой конфигурации нет оценок. Они появятся здесь автоматически, как только будет запущен использующий её оценщик.",
"descriptionScoped": "Для этой конфигурации в выбранном временном диапазоне нет оценок {scope}. Выберите другую область или расширьте временной диапазон."
},
"trendTitle": "Тренд",
"distributionTitle": "Распределение оценок",
"categoryDistributionTitle": "Распределение по категориям",
"healthyRateTitle": "Доля здоровых со временем",
"trueRateTitle": "Доля истинных со временем",
"trueFalseTitle": "Истина / Ложь",
"legendAvg": "Сред",
"legendThreshold": "порог {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "здоров {direction} {value}",
"healthyThreshold": "Здоров {threshold}",
"legendHealthy": "здоровый",
"legendUnhealthy": "нездоровый",
"legendHealthyRate": "Доля здоровых",
"legendTrueRate": "Доля истинных",
"legendTrueCount": "истина ({count})",
"legendFalseCount": "ложь ({count})",
"kpis": {
"average": "Среднее",
"p50": "P50",
"p95": "P95",
"unhealthy": "Нездоровый",
"healthy": "Здоровый",
"trueRate": "Доля истинных",
"falseRate": "Доля ложных",
"coverage": "Покрытие",
"targetsScored": "Оценённые цели"
},
"health": {
"title": "Статус здоровья",
"noThreshold": "Порог здоровья не настроен. Оценки пока не могут быть классифицированы как здоровые или ухудшающиеся.",
"thresholdOnly": "Здорово, если {threshold}",
"summary": "{percent}% ниже порога · Здорово, если {threshold}"
},
"ofScoreResults": "всего результатов оценки: {count}",
"range": "Диапазон {min}–{max}"
"tiles": {
"attention": "Конфигурации, требующие внимания",
"ofTotal": "из {total}",
"attentionNote": "Показать их",
"attentionTooltip": "Конфигурации, у которых есть хотя бы одна оценка вне нормы",
"withoutResult": "Конфигурации без результата",
"withoutResultTooltip": "Конфигурации без оценок в этом диапазоне времени или без порога нормы. Нажмите, чтобы показать их.",
"noScores": "У {count} нет оценок | У {count} нет оценок",
"noThreshold": "У {count} нет порога | У {count} нет порога",
"allGiveResult": "Каждая конфигурация даёт результат"
},
"runs": {
"title": "Оценки",
"hint": "Оценки и обоснование берутся из _llm_scores. Выберите запуск, чтобы открыть его оценочный трейс для дальнейшего исследования.",
"filterLabel": "Фильтровать оценки",
"noThresholdHint": "Установите порог здоровья в этой конфигурации оценок, чтобы классифицировать и выделять нездоровые оценки.",
"noReasoning": "Обоснование не записано",
"unknownAgent": "Неизвестный агент",
"unknownScope": "Неизвестная область",
"queryError": "Не удалось обновить оценки. Показывается последняя успешная страница.",
"empty": "Нет оценок в этом временном диапазоне.",
"emptyUnhealthy": "Нет нездоровых оценок в этом временном диапазоне.",
"filters": {
"all": "Все оценки",
"unhealthy": "Нездоровый"
},
"health": {
"healthy": "Здоровый",
"unhealthy": "Нездоровый"
},
"columns": {
"time": "Оценено",
"result": "Оценка",
"reasoning": "Обоснование",
"target": "Цель",
"agent": "Агент"
}
},
"overview": {
"table": {
"searchPlaceholder": "Фильтр конфигураций…",
"relativeTime": {
"daysAgo": "{day} дн. назад",
"monthsAgo": "{month} мес. назад"
},
"status": {
"healthy": "Здоровый",
"unhealthy": "Требует внимания",
"noThreshold": "Порог не установлен",
"noData": "Нет данных оценки"
},
"health": {
"thresholdSummary": "{percent}% ниже · Здоровый, если {threshold}",
"noThresholdHint": "Установить здоровый порог",
"noDataHint": "Нет оценок в этом окне"
},
"openUnhealthy": "Открыть оценки вне нормы",
"configLink": "Конфигурация",
"averageRange": "среднее, от {min} до {max}",
"mostCommon": "чаще всего · {count} из {total}",
"columns": {
"health": "Здоровье",
"scoreConfig": "Конфигурация оценки",
"type": "Тип",
"quality": "Качество",
"totalScores": "Всего оценок",
"scopeMix": "Состав областей",
"volumeTrend": "Тенденция объема",
"scoreConfig": "Конфигурация оценок",
"health": "Норма",
"typicalScore": "Типичная оценка",
"unhealthy": "Вне нормы",
"scores": "Оценки",
"updated": "Обновлено"
}
},
"averageWithHealthyRate": "Среднее · {rate}% здоровых"
"detail": {
"back": "Все конфигурации",
"backToAll": "Назад ко всем конфигурациям",
"editConfig": "Редактировать конфигурацию",
"notFound": "Конфигурация оценок не найдена",
"notFoundDescription": "Возможно, она была удалена или ссылка ведёт на устаревшую конфигурацию.",
"emptyTitle": "Ожидание первых оценок",
"emptyTitleScoped": "Нет оценок {scope} в этом окне",
"emptyDescription": "За выбранный период для этой конфигурации не поступило ни одной оценки. Они появятся здесь, как только запустится использующий её оценщик.",
"distributionNumeric": "Распределение оценок",
"distributionValues": "Распределение значений",
"averageValue": "среднее {value}",
"legendHealthy": "В норме",
"legendUnhealthy": "Не в норме",
"legendScores": "Оценки",
"chartTooltip": "{count} оценок, {unhealthy} вне нормы | {count} оценок, {unhealthy} вне нормы",
"chartTooltipCount": "{count} оценка | {count} оценок",
"trendTitle": {
"score": "Тренд",
"healthyRate": "Доля в норме во времени",
"trueRate": "Доля истинных во времени",
"count": "Оценки во времени"
},
"trendLegend": {
"healthyRate": "Доля в норме",
"trueRate": "Доля истинных",
"count": "Оценки"
},
"legendAvg": "Сред",
"legendThreshold": "порог {direction} {value}",
"markLineHealthy": "здоров {direction} {value}",
"filterRange": "от {from} до {to}",
"filterBelow": "Ниже {to}",
"filterAtLeast": "{from} и более",
"filterAny": "Любое значение",
"filterByBar": "Фильтровать оценки по значению",
"barButton": "{label}: {count} оценка | {label}: {count} оценок",
"clearFilter": "Сбросить фильтр"
},
"review": {
"title": "Оценки качества",
"all": "Все",
"unhealthy": "Не в норме",
"unhealthyDisabled": "Задайте порог нормы для классификации оценок",
"inputNotCaptured": "Входные данные не сохранены",
"outputNotCaptured": "Выходные данные не сохранены",
"noMatch": "Нет оценок, соответствующих этому фильтру"
},
"pane": {
"noneSelected": "Оценка не выбрана",
"healthy": "В норме",
"unhealthy": "Не в норме",
"noThreshold": "Порог нормы не задан",
"previous": "Предыдущая оценка",
"next": "Следующая оценка",
"reasoning": "Обоснование",
"noReasoning": "Обоснование не записано",
"fullTextUnavailable": "Показан краткий предпросмотр. Полный трейс не удалось загрузить.",
"open": {
"span": "Открыть спан",
"trace": "Открыть трейс",
"session": "Открыть сессию"
},
"evaluatorTrace": "Трейс оценщика",
"addToDataset": "Добавить в датасет",
"datasetSession": "В датасет можно добавить один трейс или спан. Откройте сессию, чтобы добавить один из её трейсов.",
"datasetNoStream": "У этой оценки нет исходного потока для добавления."
}
},
"singular": {
"jobs": "Задача оценки",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Импорт ({count})",
"count": "Количество",
"failedToDeriveSchema": "Не удалось вывести схему",
"failedToImportScoreConfig": "Не удалось импортировать конфигурацию оценки",
"failedToImportScorer": "Не удалось импортировать оценщика",
"failedToFetchCatalog": "Не удалось получить каталог ({status})",
"failedToLoadCatalog": "Не удалось загрузить каталог",
"failedToRunScorerTest": "Не удалось выполнить тест оценщика",
"healthyRate": "Доля здоровых",
"invalidJsonFormat": "Неверный формат JSON",
"jsonIsEmpty": "JSON пуст",
"scorerTestFailed": "Тест оценщика не пройден",
"scorerTestSampleContext": "Профиль Франции: Париж — столица.",
"scorerTestSampleInput": "Столица Франции — Париж, расположенный на реке Сене.",
"scorerTestSampleOutput": "Париж — столица Франции.",
"topCategory": "Основная категория",
"trueRate": "Доля истинных",
"unknownError": "Неизвестная ошибка",
"import": {
"createdSuccessfully": "\"{name}\" успешно создан",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Онлайн-оценки — Шаблоны",
"scorers": "Онлайн-оценки — Оценщики",
"evalJobs": "Онлайн-оценки — Задания",
"quality": "Онлайн-оценки — Качество",
"scoreConfigs": "Онлайн-оценки — Конфигурации оценок",
"actions": "Действия",
"llmProviders": "LLM-провайдеры",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Обновить список",
"scorersRefresh": "Обновить список",
"evalJobsRefresh": "Обновить список",
"qualityBack": "Назад ко всем конфигурациям",
"scoreConfigsRefresh": "Обновить список",
"actionsRefresh": "Обновить список",
"llmProvidersRefresh": "Обновить список",
@@ -20552,11 +20529,10 @@
"high": "Высокий"
},
"qualityStatus": {
"nothreshold": "Без порога",
"nodata": "Нет данных",
"attention": "Требует внимания",
"healthy": "Исправен",
"warn": "Предупреждение",
"unhealthy": "Неисправен"
"unset": "Порог не задан",
"nodata": "Нет данных об оценке"
},
"rumEventType": {
"longtask": "Долгая задача",
+107 -131
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Tüm Aracılar",
"agentLabel": "Ajan",
"agentPlaceholder": "Tüm acenteler",
"kpisAriaLabel": "1. Kademe KPI'lar",
"dataTypes": {
"numericShort": "Sayı",
"categoricalShort": "Kategori",
"booleanShort": "Boole"
"overview": {
"relativeTime": {
"daysAgo": "{day}g önce"
}
},
"healthyIf": "{rule} ise sağlıklı",
"noHealthyValue": "yok",
"setThreshold": "Sağlıklı eşik belirle",
"noScoresInWindow": "Bu pencerede puan yok",
"outOf": "{count} / {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Skor kapsamı",
"filterHint": "Bu Skor Yapılandırmasını değerlendirme hedef kapsamlarında karşılaştırın.",
"all": "Tümü",
"span": "Span",
"trace": "İz",
"session": "Oturum"
},
"kpis": {
"scoreResults": {
"title": "Skor Sonuçları"
},
"evaluationCost": {
"title": "Değerlendirme Maliyeti"
},
"scorerSuccess": {
"title": "Puanlayıcı Başarısı"
},
"scorerFailures": {
"title": "Puanlayıcı Başarısızlıkları"
},
"latencyP95": {
"title": "Puanlayıcı Gecikmesi p95"
},
"vsPrev": "öncekine karşı",
"noPriorData": "önceki veri yok",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "iz",
"session": "oturum"
},
"detail": {
"loading": "Yapılandırma başına dökümü yükleniyor...",
"noData": "Bu pencerede bu yapılandırma için henüz puan yok.",
"empty": {
"title": "İlk puanlarınız bekleniyor",
"titleScoped": "Bu pencerede {scope} skoru yok",
"description": "Seçili zaman aralığında bu yapılandırma için henüz puan gelmedi. Bunu kullanan bir puanlayıcı çalıştığında otomatik olarak burada görünecekler.",
"descriptionScoped": "Seçilen zaman aralığında bu yapılandırma için {scope} skoru bulunamadı. Başka bir kapsam seçin veya zaman aralığını genişletin."
},
"trendTitle": "Eğilim",
"distributionTitle": "Puan dağılımı",
"categoryDistributionTitle": "Kategori dağılımı",
"healthyRateTitle": "Zaman içinde sağlıklı oranı",
"trueRateTitle": "Zaman içinde doğru oranı",
"trueFalseTitle": "Doğru vs Yanlış",
"legendAvg": "Ort",
"legendThreshold": "eşik {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "sağlıklı {direction} {value}",
"healthyThreshold": "Sağlıklı {threshold}",
"legendHealthy": "sağlıklı",
"legendUnhealthy": "sağlıksız",
"legendHealthyRate": "Sağlıklı oranı",
"legendTrueRate": "Doğru oranı",
"legendTrueCount": "doğru ({count})",
"legendFalseCount": "yanlış ({count})",
"kpis": {
"average": "Ortalama",
"p50": "P50",
"p95": "P95",
"unhealthy": "Sağlıksız",
"healthy": "Sağlıklı",
"trueRate": "Doğru oranı",
"falseRate": "Yanlış oranı",
"coverage": "Kapsam",
"targetsScored": "Skorlanan hedefler"
},
"health": {
"title": "Sağlık durumu",
"noThreshold": "Sağlıklı eşik yapılandırılmamış. Skorlar henüz sağlıklı veya bozuluyor olarak sınıflandırılamaz.",
"thresholdOnly": "Sağlıklı ise {threshold}",
"summary": "{percent}% eşiğin altında · {threshold} ise sağlıklı"
},
"ofScoreResults": "{count} skor sonucundan",
"range": "Aralık {min}–{max}"
"tiles": {
"attention": "Dikkat gerektiren yapılandırmalar",
"ofTotal": "{total} içinden",
"attentionNote": "Göster",
"attentionTooltip": "En az bir sağlıksız puan içeren yapılandırmalar",
"withoutResult": "Sonucu olmayan yapılandırmalar",
"withoutResultTooltip": "Bu zaman aralığında puanı olmayan veya sağlıklı eşiği bulunmayan yapılandırmalar. Göstermek için tıklayın.",
"noScores": "{count} tanesi puan almadı | {count} tanesi puan almadı",
"noThreshold": "{count} tanesinin eşiği yok | {count} tanesinin eşiği yok",
"allGiveResult": "Her yapılandırma bir sonuç verir"
},
"runs": {
"title": "Değerlendirme skorları",
"hint": "Puanlar ve gerekçeler _llm_scores'ten gelir. Daha fazla inceleme için değerlendirici izini açmak üzere bir çalıştırma seçin.",
"filterLabel": "Değerlendirme skorlarını filtrele",
"noThresholdHint": "Sağlıksız skorları sınıflandırmak ve vurgulamak için bu Skor Yapılandırmasında bir sağlıklı eşik belirleyin.",
"noReasoning": "Gerekçe kaydedilmedi",
"unknownAgent": "Bilinmeyen ajan",
"unknownScope": "Bilinmeyen kapsam",
"queryError": "Değerlendirme puanları yenilenemedi. Son başarılı sayfa hâlâ gösteriliyor.",
"empty": "Bu zaman aralığında değerlendirme puanı yok.",
"emptyUnhealthy": "Bu zaman aralığında sağlıksız puan yok.",
"filters": {
"all": "Tüm puanlar",
"unhealthy": "Sağlıksız"
},
"health": {
"healthy": "Sağlıklı",
"unhealthy": "Sağlıksız"
},
"table": {
"searchPlaceholder": "Yapılandırmaları filtrele…",
"openUnhealthy": "Sağlıksız puanları aç",
"configLink": "Yapılandırma",
"averageRange": "ortalama, {min} ile {max} arası",
"mostCommon": "en yaygın · {count} / {total}",
"columns": {
"time": "Değerlendirildi",
"result": "Puan",
"reasoning": "Gerekçe",
"target": "Hedef",
"agent": "Ajan"
}
},
"overview": {
"searchPlaceholder": "Yapılandırmaları filtrele...",
"relativeTime": {
"daysAgo": "{day}g önce",
"monthsAgo": "{month} ay önce"
},
"status": {
"healthy": "Sağlıklı",
"unhealthy": "İlgi gerektiriyor",
"noThreshold": "Eşik ayarlanmamış",
"noData": "Puan verisi yok"
},
"health": {
"thresholdSummary": "{percent}% altında · {threshold} ise sağlıklı",
"noThresholdHint": "Sağlıklı eşik belirle",
"noDataHint": "Bu pencerede puan yok"
},
"columns": {
"health": "Sağlık",
"scoreConfig": "Puan Yapılandırması",
"type": "Tür",
"quality": "Kalite",
"totalScores": "Toplam Skorlar",
"scopeMix": "Kapsam Karışımı",
"volumeTrend": "Hacim Trendi",
"health": "Sağlık",
"typicalScore": "Tipik puan",
"unhealthy": "Sağlıksız",
"scores": "Puanlar",
"updated": "Güncellendi"
}
},
"averageWithHealthyRate": "Ortalama · %{rate} sağlıklı"
"detail": {
"back": "Tüm yapılandırmalar",
"backToAll": "Tüm yapılandırmalara dön",
"editConfig": "Yapılandırmayı düzenle",
"notFound": "Puan Yapılandırması bulunamadı",
"notFoundDescription": "Silinmiş olabilir veya bağlantı eski bir yapılandırmayı işaret ediyor.",
"emptyTitle": "İlk puanlarınızı bekliyoruz",
"emptyTitleScoped": "Bu pencerede {scope} puanı yok",
"emptyDescription": "Seçili zaman aralığında bu yapılandırma için hiç puan gelmedi. Onu kullanan bir puanlayıcı çalıştığında burada görünürler.",
"distributionNumeric": "Puan dağılımı",
"distributionValues": "Değer dağılımı",
"averageValue": "ortalama {value}",
"legendHealthy": "Sağlıklı",
"legendUnhealthy": "Sağlıksız",
"legendScores": "Puanlar",
"chartTooltip": "{count} puan, {unhealthy} sağlıksız | {count} puan, {unhealthy} sağlıksız",
"chartTooltipCount": "{count} puan | {count} puan",
"trendTitle": {
"score": "Eğilim",
"healthyRate": "Zaman içinde sağlıklılık oranı",
"trueRate": "Zaman içinde doğru olma oranı",
"count": "Zaman içinde puanlar"
},
"trendLegend": {
"healthyRate": "Sağlıklılık oranı",
"trueRate": "Doğru oranı",
"count": "Puanlar"
},
"legendAvg": "Ort",
"legendThreshold": "eşik {direction} {value}",
"markLineHealthy": "sağlıklı {direction} {value}",
"filterRange": "{from} - {to}",
"filterBelow": "{to} altı",
"filterAtLeast": "{from} ve üzeri",
"filterAny": "Herhangi bir değer",
"filterByBar": "Puanları değere göre filtrele",
"barButton": "{label}: {count} puan | {label}: {count} puan",
"clearFilter": "Filtreyi temizle"
},
"review": {
"title": "Değerlendirme puanları",
"all": "Tümü",
"unhealthy": "Sağlıksız",
"unhealthyDisabled": "Puanları sınıflandırmak için sağlıklı bir eşik belirleyin",
"inputNotCaptured": "Girdi yakalanmadı",
"outputNotCaptured": "Çıktı yakalanmadı",
"noMatch": "Bu filtreyle eşleşen puan yok"
},
"pane": {
"noneSelected": "Puan seçilmedi",
"healthy": "Sağlıklı",
"unhealthy": "Sağlıksız",
"noThreshold": "Sağlıklı eşik belirlenmedi",
"previous": "Önceki puan",
"next": "Sonraki puan",
"reasoning": "Akıl yürütme",
"noReasoning": "Akıl yürütme kaydedilmedi",
"fullTextUnavailable": "Kısa bir önizleme gösteriliyor. Tam iz yüklenemedi.",
"open": {
"span": "Span'ı aç",
"trace": "İzi aç",
"session": "Oturumu aç"
},
"evaluatorTrace": "Değerlendirici izi",
"addToDataset": "Veri kümesine ekle",
"datasetSession": "Veri kümeleri tek bir iz veya span kabul eder. İzlerinden birini eklemek için oturumu açın.",
"datasetNoStream": "Bu puanın eklenebileceği bir kaynak akışı yok."
}
},
"singular": {
"jobs": "Değerlendirme İşi",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "İçe Aktar ({count})",
"count": "Sayı",
"failedToDeriveSchema": "Şema çıkarılamadı",
"failedToImportScoreConfig": "Skor yapılandırması içe aktarılamadı",
"failedToImportScorer": "Puanlayıcı içe aktarılamadı",
"failedToFetchCatalog": "Katalog alınamadı ({status})",
"failedToLoadCatalog": "Katalog yüklenemedi",
"failedToRunScorerTest": "Puanlayıcı testi çalıştırılamadı",
"healthyRate": "Sağlık oranı",
"invalidJsonFormat": "Geçersiz JSON biçimi",
"jsonIsEmpty": "JSON boş",
"scorerTestFailed": "Puanlayıcı testi başarısız oldu",
"scorerTestSampleContext": "Fransa ülke profili: Paris başkenttir.",
"scorerTestSampleInput": "Fransa'nın başkenti, Seine nehri üzerinde bulunan Paris'tir.",
"scorerTestSampleOutput": "Paris, Fransa'nın başkentidir.",
"topCategory": "Üst kategori",
"trueRate": "Gerçek oran",
"unknownError": "Bilinmeyen hata",
"import": {
"createdSuccessfully": "\"{name}\" başarıyla oluşturuldu",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Çevrimiçi Değerlendirmeler - Şablonlar",
"scorers": "Çevrimiçi Değerlendirmeler — Puanlayıcılar",
"evalJobs": "Çevrimiçi Değerlendirmeler — İşler",
"quality": "Çevrimiçi Değerlendirmeler — Kalite",
"scoreConfigs": "Çevrimiçi Değerlendirmeler - Puan Yapılandırmaları",
"actions": "Eylemler",
"llmProviders": "LLM Sağlayıcıları",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Listeyi yenile",
"scorersRefresh": "Listeyi yenile",
"evalJobsRefresh": "Listeyi yenile",
"qualityBack": "Tüm yapılandırmalara dön",
"scoreConfigsRefresh": "Listeyi yenile",
"actionsRefresh": "Listeyi yenile",
"llmProvidersRefresh": "Listeyi yenile",
@@ -20552,11 +20529,10 @@
"high": "Yüksek"
},
"qualityStatus": {
"nothreshold": "Eşik Yok",
"nodata": "Veri Yok",
"attention": "Dikkat gerekiyor",
"healthy": "Sağlıklı",
"warn": "Uyarı",
"unhealthy": "Sağlıksız"
"unset": "Eşik belirlenmedi",
"nodata": "Puan verisi yok"
},
"rumEventType": {
"longtask": "Uzun Görev",
+106 -130
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "Tất cả Tác nhân",
"agentLabel": "Tác nhân",
"agentPlaceholder": "Tất cả tác nhân",
"kpisAriaLabel": "KPI cấp 1",
"dataTypes": {
"numericShort": "Số",
"categoricalShort": "Danh mục",
"booleanShort": "Luận lý"
"overview": {
"relativeTime": {
"daysAgo": "{day} ngày trước"
}
},
"healthyIf": "Đạt nếu {rule}",
"noHealthyValue": "không có",
"setThreshold": "Thiết lập ngưỡng đạt",
"noScoresInWindow": "Không có điểm trong khoảng thời gian này",
"outOf": "{count} trên {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "Phạm vi điểm số",
"filterHint": "So sánh cấu hình điểm số này trên các phạm vi mục tiêu đánh giá.",
"all": "Tất cả",
"span": "Span",
"trace": "Trace",
"session": "Phiên"
},
"kpis": {
"scoreResults": {
"title": "Kết quả điểm số"
},
"evaluationCost": {
"title": "Chi phí đánh giá"
},
"scorerSuccess": {
"title": "Thành công của bộ chấm điểm"
},
"scorerFailures": {
"title": "Lỗi bộ chấm điểm"
},
"latencyP95": {
"title": "Độ trễ bộ chấm điểm p95"
},
"vsPrev": "so với trước",
"noPriorData": "không có dữ liệu trước đó",
"noData": "—"
"scopeNouns": {
"span": "span",
"trace": "trace",
"session": "phiên"
},
"detail": {
"loading": "Đang tải phân tích chi tiết theo cấu hình…",
"noData": "Chưa có điểm nào cho cấu hình này trong cửa sổ này.",
"empty": {
"title": "Đang chờ điểm đầu tiên của bạn",
"titleScoped": "Không có điểm {scope} nào trong cửa sổ này",
"description": "Không có điểm nào được ghi cho cấu hình này trong khoảng thời gian đã chọn. Chúng sẽ tự động xuất hiện khi bộ chấm điểm sử dụng nó chạy.",
"descriptionScoped": "Không có điểm {scope} nào cho cấu hình này trong khoảng thời gian đã chọn. Hãy chọn phạm vi khác hoặc mở rộng khoảng thời gian."
},
"trendTitle": "Xu hướng",
"distributionTitle": "Phân bố điểm",
"categoryDistributionTitle": "Phân bố danh mục",
"healthyRateTitle": "Tỷ lệ lành mạnh theo thời gian",
"trueRateTitle": "Tỷ lệ đúng theo thời gian",
"trueFalseTitle": "Đúng vs Sai",
"legendAvg": "TB",
"legendThreshold": "ngưỡng {'{'}hướng{'}'} {'{'}giá trị{'}'}",
"markLineHealthy": "khỏe mạnh {direction} {value}",
"healthyThreshold": "Khỏe mạnh {threshold}",
"legendHealthy": "khỏe mạnh",
"legendUnhealthy": "không khỏe mạnh",
"legendHealthyRate": "Tỷ lệ lành mạnh",
"legendTrueRate": "Tỷ lệ đúng",
"legendTrueCount": "đúng ({count})",
"legendFalseCount": "sai ({count})",
"kpis": {
"average": "Trung bình",
"p50": "P50",
"p95": "P95",
"unhealthy": "Không khỏe mạnh",
"healthy": "Khỏe mạnh",
"trueRate": "Tỷ lệ đúng",
"falseRate": "Tỷ lệ sai",
"coverage": "Phạm vi phủ sóng",
"targetsScored": "Mục tiêu đã chấm điểm"
},
"health": {
"title": "Trạng thái sức khỏe",
"noThreshold": "Chưa cấu hình ngưỡng lành mạnh. Điểm số chưa thể được phân loại là lành mạnh hoặc suy giảm.",
"thresholdOnly": "Lành mạnh nếu {threshold}",
"summary": "{percent}% dưới ngưỡng · Lành mạnh nếu {threshold}"
},
"ofScoreResults": "trên {count} kết quả điểm",
"range": "Khoảng {min}–{max}"
"tiles": {
"attention": "Cấu hình cần chú ý",
"ofTotal": "trên {total}",
"attentionNote": "Hiển thị chúng",
"attentionTooltip": "Cấu hình có ít nhất một điểm không đạt",
"withoutResult": "Cấu hình không có kết quả",
"withoutResultTooltip": "Cấu hình không có điểm trong khoảng thời gian này hoặc không có ngưỡng đạt. Nhấp để hiển thị chúng.",
"noScores": "{count} không có điểm | {count} không có điểm",
"noThreshold": "{count} không có ngưỡng | {count} không có ngưỡng",
"allGiveResult": "Mọi cấu hình đều cho kết quả"
},
"runs": {
"title": "Điểm đánh giá",
"hint": "Điểm số và lý giải đến từ _llm_scores. Chọn một lần chạy để mở trace đánh giá của nó nhằm điều tra thêm.",
"filterLabel": "Lọc điểm đánh giá",
"noThresholdHint": "Đặt ngưỡng lành mạnh trên cấu hình điểm số này để phân loại và làm nổi bật các điểm không lành mạnh.",
"noReasoning": "Không có lý luận nào được ghi lại",
"unknownAgent": "Tác nhân không xác định",
"unknownScope": "Phạm vi không xác định",
"queryError": "Không thể làm mới điểm đánh giá. Trang thành công cuối cùng vẫn được hiển thị.",
"empty": "Không có điểm đánh giá nào trong khoảng thời gian này.",
"emptyUnhealthy": "Không có điểm không lành mạnh nào trong khoảng thời gian này.",
"filters": {
"all": "Tất cả điểm",
"unhealthy": "Không lành mạnh"
},
"health": {
"healthy": "Lành mạnh",
"unhealthy": "Không lành mạnh"
},
"columns": {
"time": "Đã đánh giá",
"result": "Điểm",
"reasoning": "Lý luận",
"target": "Mục tiêu",
"agent": "Tác nhân"
}
},
"overview": {
"table": {
"searchPlaceholder": "Lọc cấu hình…",
"relativeTime": {
"daysAgo": "{day} ngày trước",
"monthsAgo": "{month} tháng trước"
},
"status": {
"healthy": "Lành mạnh",
"unhealthy": "Cần chú ý",
"noThreshold": "Ngưỡng chưa được thiết lập",
"noData": "Không có dữ liệu điểm"
},
"health": {
"thresholdSummary": "{percent}% dưới · Lành mạnh nếu {threshold}",
"noThresholdHint": "Đặt ngưỡng lành mạnh",
"noDataHint": "Không có điểm trong cửa sổ này"
},
"openUnhealthy": "Mở các điểm không đạt",
"configLink": "Cấu hình",
"averageRange": "trung bình, {min} đến {max}",
"mostCommon": "phổ biến nhất · {count} trên {total}",
"columns": {
"health": "Sức khỏe",
"scoreConfig": "Cấu hình điểm",
"type": "Loại",
"quality": "Chất lượng",
"totalScores": "Tổng điểm",
"scopeMix": "Hỗn hợp phạm vi",
"volumeTrend": "Xu hướng khối lượng",
"health": "Trạng thái",
"typicalScore": "Điểm điển hình",
"unhealthy": "Không đạt",
"scores": "Điểm",
"updated": "Đã cập nhật"
}
},
"averageWithHealthyRate": "Trung bình · {rate}% khỏe mạnh"
"detail": {
"back": "Tất cả cấu hình",
"backToAll": "Quay lại tất cả cấu hình",
"editConfig": "Chỉnh sửa cấu hình",
"notFound": "Không tìm thấy cấu hình điểm",
"notFoundDescription": "Có thể nó đã bị xóa hoặc liên kết trỏ đến một cấu hình cũ.",
"emptyTitle": "Đang chờ những điểm đầu tiên của bạn",
"emptyTitleScoped": "Không có điểm {scope} trong khoảng thời gian này",
"emptyDescription": "Không có điểm nào được ghi nhận cho cấu hình này trong khoảng thời gian đã chọn. Chúng sẽ xuất hiện ở đây khi một bộ chấm điểm sử dụng cấu hình này chạy.",
"distributionNumeric": "Phân bố điểm",
"distributionValues": "Phân bố giá trị",
"averageValue": "trung bình {value}",
"legendHealthy": "Bình thường",
"legendUnhealthy": "Bất thường",
"legendScores": "Điểm",
"chartTooltip": "{count} điểm, {unhealthy} không đạt | {count} điểm, {unhealthy} không đạt",
"chartTooltipCount": "{count} điểm | {count} điểm",
"trendTitle": {
"score": "Xu hướng",
"healthyRate": "Tỷ lệ đạt theo thời gian",
"trueRate": "Tỷ lệ đúng theo thời gian",
"count": "Điểm theo thời gian"
},
"trendLegend": {
"healthyRate": "Tỷ lệ đạt",
"trueRate": "Tỷ lệ đúng",
"count": "Điểm số"
},
"legendAvg": "TB",
"legendThreshold": "ngưỡng {direction} {value}",
"markLineHealthy": "khỏe mạnh {direction} {value}",
"filterRange": "{from} đến {to}",
"filterBelow": "Dưới {to}",
"filterAtLeast": "{from} trở lên",
"filterAny": "Mọi giá trị",
"filterByBar": "Lọc điểm số theo giá trị",
"barButton": "{label}: {count} điểm | {label}: {count} điểm",
"clearFilter": "Xóa bộ lọc"
},
"review": {
"title": "Điểm đánh giá",
"all": "Tất cả",
"unhealthy": "Không đạt chuẩn",
"unhealthyDisabled": "Đặt ngưỡng đạt chuẩn để phân loại điểm số",
"inputNotCaptured": "Chưa ghi nhận đầu vào",
"outputNotCaptured": "Chưa ghi nhận đầu ra",
"noMatch": "Không có điểm nào khớp với bộ lọc này"
},
"pane": {
"noneSelected": "Chưa chọn điểm nào",
"healthy": "Đạt chuẩn",
"unhealthy": "Không đạt chuẩn",
"noThreshold": "Chưa đặt ngưỡng đạt chuẩn",
"previous": "Điểm trước",
"next": "Điểm tiếp theo",
"reasoning": "Lập luận",
"noReasoning": "Không có lập luận nào được ghi lại",
"fullTextUnavailable": "Đang hiển thị bản xem trước ngắn. Không thể tải toàn bộ trace.",
"open": {
"span": "Mở span",
"trace": "Mở trace",
"session": "Mở phiên"
},
"evaluatorTrace": "Trace của bộ đánh giá",
"addToDataset": "Thêm vào bộ dữ liệu",
"datasetSession": "Bộ dữ liệu chỉ nhận một trace hoặc span. Mở phiên để thêm một trong các trace của phiên đó.",
"datasetNoStream": "Điểm này không có stream nguồn để thêm từ đó."
}
},
"singular": {
"jobs": "Công việc đánh giá",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "Nhập ({count})",
"count": "Số lượng",
"failedToDeriveSchema": "Không thể suy ra lược đồ",
"failedToImportScoreConfig": "Không thể nhập cấu hình điểm số",
"failedToImportScorer": "Không thể nhập bộ chấm điểm",
"failedToFetchCatalog": "Không thể tải danh mục ({status})",
"failedToLoadCatalog": "Không thể tải danh mục",
"failedToRunScorerTest": "Không thể chạy kiểm tra bộ chấm điểm",
"healthyRate": "Tỷ lệ khỏe mạnh",
"invalidJsonFormat": "Định dạng JSON không hợp lệ",
"jsonIsEmpty": "JSON trống",
"scorerTestFailed": "Kiểm tra bộ chấm điểm thất bại",
"scorerTestSampleContext": "Hồ sơ quốc gia Pháp: Paris là thủ đô.",
"scorerTestSampleInput": "Thủ đô của Pháp là Paris, nằm trên sông Seine.",
"scorerTestSampleOutput": "Paris là thủ đô của Pháp.",
"topCategory": "Danh mục hàng đầu",
"trueRate": "Tỷ lệ đúng",
"unknownError": "Lỗi không xác định",
"import": {
"createdSuccessfully": "\"{name}\" đã được tạo thành công",
@@ -20191,6 +20166,7 @@
"evalTemplates": "Đánh giá trực tuyến — Mẫu",
"scorers": "Đánh giá trực tuyến — Bộ chấm điểm",
"evalJobs": "Đánh giá trực tuyến — Công việc",
"quality": "Online Evals — Chất lượng",
"scoreConfigs": "Đánh giá trực tuyến — Cấu hình điểm",
"actions": "Hành động",
"llmProviders": "Nhà cung cấp LLM",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "Làm mới danh sách",
"scorersRefresh": "Làm mới danh sách",
"evalJobsRefresh": "Làm mới danh sách",
"qualityBack": "Quay lại tất cả cấu hình",
"scoreConfigsRefresh": "Làm mới danh sách",
"actionsRefresh": "Làm mới danh sách",
"llmProvidersRefresh": "Làm mới danh sách",
@@ -20552,11 +20529,10 @@
"high": "Cao"
},
"qualityStatus": {
"nothreshold": "Không có ngưỡng",
"nodata": "Không có dữ liệu",
"attention": "Cần chú ý",
"healthy": "Khỏe mạnh",
"warn": "Cảnh báo",
"unhealthy": "Không khỏe"
"unset": "Chưa đặt ngưỡng",
"nodata": "Không có dữ liệu điểm số"
},
"rumEventType": {
"longtask": "Tác vụ dài",
+104 -128
View File
@@ -17154,143 +17154,122 @@
},
"quality": {
"allAgents": "全部智能体",
"agentLabel": "智能体",
"agentPlaceholder": "所有智能体",
"kpisAriaLabel": "一级KPI",
"dataTypes": {
"numericShort": "数值",
"categoricalShort": "分类",
"booleanShort": "布尔"
"overview": {
"relativeTime": {
"daysAgo": "{day} 天前"
}
},
"healthyIf": "若 {rule} 则健康",
"noHealthyValue": "无",
"setThreshold": "设置健康阈值",
"noScoresInWindow": "此时间窗口内无评分",
"outOf": "{count} / {total}",
"scopeCount": "{scope} {count}",
"scopes": {
"filterLabel": "评分范围",
"filterHint": "在不同评估目标范围内比较此评分配置。",
"all": "全部",
"span": "Span",
"trace": "Trace",
"session": "Session"
},
"kpis": {
"scoreResults": {
"title": "评分结果"
},
"evaluationCost": {
"title": "评估成本"
},
"scorerSuccess": {
"title": "评分器成功率"
},
"scorerFailures": {
"title": "评分器失败"
},
"latencyP95": {
"title": "评分器延迟 p95"
},
"vsPrev": "vs 上一页",
"noPriorData": "无先前数据",
"noData": "—"
"scopeNouns": {
"span": "Span",
"trace": "追踪",
"session": "会话"
},
"tiles": {
"attention": "需要关注的配置",
"ofTotal": "共 {total}",
"attentionNote": "查看",
"attentionTooltip": "至少有一个不健康评分的配置",
"withoutResult": "没有结果的配置",
"withoutResultTooltip": "此时间范围内没有评分的配置,或未设置健康阈值的配置。点击查看。",
"noScores": "{count} 个未获得评分 | {count} 个未获得评分",
"noThreshold": "{count} 个未设置阈值 | {count} 个未设置阈值",
"allGiveResult": "所有配置均有结果"
},
"table": {
"searchPlaceholder": "过滤配置…",
"openUnhealthy": "查看不健康评分",
"configLink": "配置",
"averageRange": "平均值,{min} 至 {max}",
"mostCommon": "最常见 · {count} / {total}",
"columns": {
"scoreConfig": "评分配置",
"health": "健康度",
"typicalScore": "典型评分",
"unhealthy": "不健康",
"scores": "评分",
"updated": "更新时间"
}
},
"detail": {
"loading": "正在加载每个配置的细分...",
"noData": "此窗口中此配置尚无分数。",
"empty": {
"title": "正在等待您的首批评分",
"titleScoped": "此窗口中无{scope}评分",
"description": "在所选时间范围内,此配置尚无评分。一旦使用它的评分器运行,评分将自动显示在此处。",
"descriptionScoped": "在所选时间范围内,此配置尚无{scope}评分。请选择其他范围或扩大时间范围。"
},
"trendTitle": "趋势",
"distributionTitle": "分数分布",
"categoryDistributionTitle": "类别分布",
"healthyRateTitle": "随时间变化的健康率",
"trueRateTitle": "随时间变化的真实率",
"trueFalseTitle": "对与错",
"legendAvg": "平均值",
"legendThreshold": "阈值 {'{'}direction{'}'} {'{'}value{'}'}",
"markLineHealthy": "健康 {direction} {value}",
"healthyThreshold": "健康 {threshold}",
"back": "所有配置",
"backToAll": "返回所有配置",
"editConfig": "编辑配置",
"notFound": "未找到评分配置",
"notFoundDescription": "它可能已被删除,或链接指向了旧配置。",
"emptyTitle": "等待首批评分",
"emptyTitleScoped": "此窗口内没有 {scope} 评分",
"emptyDescription": "所选时间范围内,此配置没有收到评分。使用该配置的评分器运行后,评分会显示在这里。",
"distributionNumeric": "评分分布",
"distributionValues": "值分布",
"averageValue": "平均值 {value}",
"legendHealthy": "健康",
"legendUnhealthy": "不健康",
"legendHealthyRate": "健康率",
"legendTrueRate": "真实率",
"legendTrueCount": "true ({count})",
"legendFalseCount": "false ({count})",
"kpis": {
"average": "平均值",
"p50": "P50",
"p95": "P95",
"unhealthy": "不健康",
"healthy": "健康",
"legendScores": "评分",
"chartTooltip": "{count} 个评分,{unhealthy} 个不健康 | {count} 个评分,{unhealthy} 个不健康",
"chartTooltipCount": "{count} 个评分 | {count} 个评分",
"trendTitle": {
"score": "趋势",
"healthyRate": "健康率随时间变化",
"trueRate": "真实率随时间变化",
"count": "评分随时间变化"
},
"trendLegend": {
"healthyRate": "健康率",
"trueRate": "真实率",
"falseRate": "假比率",
"coverage": "覆盖范围",
"targetsScored": "已评分目标"
"count": "评分"
},
"health": {
"title": "健康状态",
"noThreshold": "尚未配置健康阈值。评分尚无法分类为健康或降级。",
"thresholdOnly": "如果{threshold}则健康",
"summary": "低于阈值{percent}% · 如果{threshold}则健康"
},
"ofScoreResults": "在 {count} 个评分结果中",
"range": "范围 {min}–{max}"
"legendAvg": "平均值",
"legendThreshold": "阈值 {direction} {value}",
"markLineHealthy": "健康 {direction} {value}",
"filterRange": "{from} 至 {to}",
"filterBelow": "低于 {to}",
"filterAtLeast": "{from} 及以上",
"filterAny": "任意值",
"filterByBar": "按值筛选评分",
"barButton": "{label}:{count} 个评分 | {label}:{count} 个评分",
"clearFilter": "清除过滤器"
},
"runs": {
"review": {
"title": "评估评分",
"hint": "评分和推理来自 _llm_scores。选择一次运行以打开其评估器追踪进行进一步调查。",
"filterLabel": "筛选评估评分",
"noThresholdHint": "在此评分配置上设置健康阈值,以分类和突出显示不健康的评分。",
"all": "全部",
"unhealthy": "不健康",
"unhealthyDisabled": "设置健康阈值以对评分进行分类",
"inputNotCaptured": "未捕获输入",
"outputNotCaptured": "未捕获输出",
"noMatch": "没有评分匹配此过滤器"
},
"pane": {
"noneSelected": "未选择评分",
"healthy": "健康",
"unhealthy": "不健康",
"noThreshold": "未设置健康阈值",
"previous": "上一个评分",
"next": "下一个评分",
"reasoning": "推理",
"noReasoning": "未记录推理",
"unknownAgent": "未知智能体",
"unknownScope": "未知作用域",
"queryError": "评估分数无法刷新。仍显示最后成功的页面。",
"empty": "此时间范围内没有评估分数。",
"emptyUnhealthy": "此时间范围内没有不健康分数。",
"filters": {
"all": "所有分数",
"unhealthy": "不健康"
"fullTextUnavailable": "正在显示简短预览。无法加载完整追踪。",
"open": {
"span": "打开 Span",
"trace": "打开追踪",
"session": "打开会话"
},
"health": {
"healthy": "健康",
"unhealthy": "不健康"
},
"columns": {
"time": "已评估",
"result": "分数",
"reasoning": "原因",
"target": "目标",
"agent": "智能体"
}
},
"overview": {
"searchPlaceholder": "筛选配置...",
"relativeTime": {
"daysAgo": "{day} 天前",
"monthsAgo": "{month} 个月前"
},
"status": {
"healthy": "健康",
"unhealthy": "需要注意",
"noThreshold": "阈值未设置",
"noData": "无分数数据"
},
"health": {
"thresholdSummary": "{percent}% 低于 · 如果 {threshold} 则为健康",
"noThresholdHint": "设置健康阈值",
"noDataHint": "此窗口内无分数"
},
"columns": {
"health": "健康",
"scoreConfig": "分数配置",
"type": "类型",
"quality": "质量",
"totalScores": "总分数",
"scopeMix": "作用域混合",
"volumeTrend": "容量趋势",
"updated": "已更新"
}
},
"averageWithHealthyRate": "平均 · {rate}% 健康"
"evaluatorTrace": "评估器追踪",
"addToDataset": "添加到数据集",
"datasetSession": "数据集接受单条追踪或 Span。打开会话以添加其中的一条追踪。",
"datasetNoStream": "此评分没有可添加的源数据流。"
}
},
"singular": {
"jobs": "评估任务",
@@ -18006,22 +17985,18 @@
}
},
"importCount": "导入 ({count})",
"count": "计数",
"failedToDeriveSchema": "无法推导架构",
"failedToImportScoreConfig": "无法导入评分配置",
"failedToImportScorer": "无法导入评分器",
"failedToFetchCatalog": "无法获取目录({status})",
"failedToLoadCatalog": "无法加载目录",
"failedToRunScorerTest": "无法运行评分器测试",
"healthyRate": "健康率",
"invalidJsonFormat": "无效的 JSON 格式",
"jsonIsEmpty": "JSON 为空",
"scorerTestFailed": "评分器测试失败",
"scorerTestSampleContext": "法国国家概况:巴黎是首都。",
"scorerTestSampleInput": "法国的首都是巴黎,位于塞纳河畔。",
"scorerTestSampleOutput": "巴黎是法国的首都。",
"topCategory": "顶级类别",
"trueRate": "真实率",
"unknownError": "未知错误",
"import": {
"createdSuccessfully": "“{name}”创建成功",
@@ -20191,6 +20166,7 @@
"evalTemplates": "在线评估—模板",
"scorers": "在线评估 — 评分器",
"evalJobs": "在线评估 — 任务",
"quality": "在线评估 — 质量",
"scoreConfigs": "在线评估—分数配置",
"actions": "操作",
"llmProviders": "LLM 提供商",
@@ -20306,6 +20282,7 @@
"evalTemplatesRefresh": "刷新清单",
"scorersRefresh": "刷新清单",
"evalJobsRefresh": "刷新清单",
"qualityBack": "返回所有配置",
"scoreConfigsRefresh": "刷新清单",
"actionsRefresh": "刷新清单",
"llmProvidersRefresh": "刷新清单",
@@ -20552,11 +20529,10 @@
"high": "高"
},
"qualityStatus": {
"nothreshold": "无阈值",
"nodata": "无数据",
"attention": "需要关注",
"healthy": "健康",
"warn": "警告",
"unhealthy": "不健康"
"unset": "未设置阈值",
"nodata": "无评分数据"
},
"rumEventType": {
"longtask": "长任务",
+105 -129
View File
@@ -17149,143 +17149,122 @@
},
"quality": {
"allAgents": "所有代理",
"agentLabel": "代理",
"agentPlaceholder": "所有代理",
"kpisAriaLabel": "第一層關鍵績效指標",
"dataTypes": {
"numericShort": "數值",
"categoricalShort": "類別",
"booleanShort": "布林"
"overview": {
"relativeTime": {
"daysAgo": "{day} 天前"
}
},
"healthyIf": "符合 {rule} 時為健康",
"noHealthyValue": "無",
"setThreshold": "設定健康閾值",
"noScoresInWindow": "此時間範圍內沒有分數",
"outOf": "{count} 個,共 {total} 個",
"scopeCount": "{scope}:{count}",
"scopes": {
"filterLabel": "分數範圍",
"filterHint": "比較此分數設定在不同評估目標範圍間的結果。",
"all": "全部",
"span": "Span",
"trace": "Trace",
"session": "Session"
},
"kpis": {
"scoreResults": {
"title": "分數結果"
},
"evaluationCost": {
"title": "評估成本"
},
"scorerSuccess": {
"title": "評分器成功率"
},
"scorerFailures": {
"title": "評分器失敗數"
},
"latencyP95": {
"title": "評分器延遲第 95 百分位數"
},
"vsPrev": "對比前期",
"noPriorData": "無先前資料",
"noData": "—"
"scopeNouns": {
"span": "Span",
"trace": "追蹤",
"session": "工作階段"
},
"tiles": {
"attention": "需要注意的設定",
"ofTotal": "共 {total}",
"attentionNote": "顯示它們",
"attentionTooltip": "至少有一個不健康分數的設定",
"withoutResult": "沒有結果的設定",
"withoutResultTooltip": "此時間範圍內沒有分數,或沒有健康閾值的設定。點擊以顯示它們。",
"noScores": "{count} 個沒有分數 | {count} 個沒有分數",
"noThreshold": "{count} 個沒有閾值 | {count} 個沒有閾值",
"allGiveResult": "每個設定都有結果"
},
"table": {
"searchPlaceholder": "篩選設定…",
"openUnhealthy": "開啟不健康的分數",
"configLink": "設定",
"averageRange": "平均,{min} 至 {max}",
"mostCommon": "最常見 · {count}/{total}",
"columns": {
"scoreConfig": "評分設定",
"health": "健康狀態",
"typicalScore": "典型分數",
"unhealthy": "不健康",
"scores": "分數",
"updated": "已更新"
}
},
"detail": {
"loading": "正在載入各設定的分解資料…",
"noData": "此時段內此設定尚無評分。",
"empty": {
"title": "正在等待您的第一筆評分",
"titleScoped": "此視窗中沒有 {scope} 分數",
"description": "在選定的時間範圍內,此設定尚無評分到達。一旦使用它的評分器執行後,評分將自動顯示於此處。",
"descriptionScoped": "所選時間範圍內此設定尚無 {scope} 分數。請選擇其他範圍或擴大時間範圍。"
},
"trendTitle": "趨勢",
"distributionTitle": "評分分佈",
"categoryDistributionTitle": "類別分佈",
"healthyRateTitle": "健康率隨時間變化",
"trueRateTitle": "真實率隨時間變化",
"trueFalseTitle": "True 對 False",
"legendAvg": "平均值",
"legendThreshold": "臨界值 {'{'}方向{'}'} {'{'}值{'}'}",
"markLineHealthy": "健康 {direction} {value}",
"healthyThreshold": "健康 {threshold}",
"back": "所有設定",
"backToAll": "返回所有設定",
"editConfig": "編輯設定",
"notFound": "找不到評分設定",
"notFoundDescription": "它可能已被刪除,或連結指向舊的設定。",
"emptyTitle": "等待您的第一批分數",
"emptyTitleScoped": "此時間範圍內沒有 {scope} 分數",
"emptyDescription": "在選取的時間範圍內,此設定沒有收到任何分數。使用此設定的評分器執行後,分數就會顯示在這裡。",
"distributionNumeric": "分數分佈",
"distributionValues": "值分佈",
"averageValue": "平均 {value}",
"legendHealthy": "健康",
"legendUnhealthy": "不健康",
"legendHealthyRate": "健康率",
"legendTrueRate": "真實率",
"legendTrueCount": "真 ({count})",
"legendFalseCount": "假 ({count})",
"kpis": {
"average": "平均值",
"p50": "P50",
"p95": "P95",
"unhealthy": "不健康",
"healthy": "健康",
"trueRate": "真實率",
"falseRate": "虛假率",
"coverage": "涵蓋率",
"targetsScored": "已評分目標"
"legendScores": "分數",
"chartTooltip": "{count} 個分數,{unhealthy} 個不健康 | {count} 個分數,{unhealthy} 個不健康",
"chartTooltipCount": "{count} 個分數 | {count} 個分數",
"trendTitle": {
"score": "趨勢",
"healthyRate": "健康率隨時間變化",
"trueRate": "True 比率隨時間變化",
"count": "分數隨時間變化"
},
"health": {
"title": "健康狀態",
"noThreshold": "尚未設定健康臨界值。分數尚無法分類為健康或退化。",
"thresholdOnly": "若 {threshold} 則健康",
"summary": "低於臨界值 {percent}% · 若 {threshold} 則健康"
"trendLegend": {
"healthyRate": "健康率",
"trueRate": "正確率",
"count": "評分"
},
"ofScoreResults": "在 {count} 個評分結果中",
"range": "範圍 {min}–{max}"
"legendAvg": "平均值",
"legendThreshold": "閾值 {direction} {value}",
"markLineHealthy": "健康 {direction} {value}",
"filterRange": "{from} 至 {to}",
"filterBelow": "低於 {to}",
"filterAtLeast": "{from} 以上",
"filterAny": "任意值",
"filterByBar": "依值篩選評分",
"barButton": "{label}:{count} 個評分 | {label}:{count} 個評分",
"clearFilter": "清除篩選"
},
"runs": {
"title": "評估分數",
"hint": "分數和推理來自 _llm_scores。選擇一個執行以開啟其評估器追蹤進行進一步調查。",
"filterLabel": "篩選評估分數",
"noThresholdHint": "在此分數設定上設定健康臨界值,以分類並標記不健康的分數。",
"noReasoning": "無記錄的推理",
"unknownAgent": "未知代理",
"unknownScope": "未知範圍",
"queryError": "無法刷新評估分數。仍顯示最後成功的頁面。",
"empty": "此時間範圍內無評估分數。",
"emptyUnhealthy": "此時間範圍內無不健康分數。",
"filters": {
"all": "所有分數",
"unhealthy": "不健康"
},
"health": {
"healthy": "健康",
"unhealthy": "不健康"
},
"columns": {
"time": "已評估",
"result": "分數",
"reasoning": "推理",
"target": "目標",
"agent": "代理"
}
"review": {
"title": "評估評分",
"all": "全部",
"unhealthy": "不健康",
"unhealthyDisabled": "設定健康閾值以分類評分",
"inputNotCaptured": "未擷取輸入",
"outputNotCaptured": "未擷取輸出",
"noMatch": "沒有評分符合此篩選條件"
},
"overview": {
"searchPlaceholder": "篩選設定…",
"relativeTime": {
"daysAgo": "{day} 天前",
"monthsAgo": "{month} 個月前"
"pane": {
"noneSelected": "未選取評分",
"healthy": "健康",
"unhealthy": "不健康",
"noThreshold": "未設定健康閾值",
"previous": "上一筆評分",
"next": "下一筆評分",
"reasoning": "推理過程",
"noReasoning": "未記錄推理過程",
"fullTextUnavailable": "目前顯示簡短預覽,無法載入完整追蹤。",
"open": {
"span": "開啟 Span",
"trace": "開啟追蹤",
"session": "開啟工作階段"
},
"status": {
"healthy": "健康",
"unhealthy": "需要注意",
"noThreshold": "未設定閾值",
"noData": "無分數資料"
},
"health": {
"thresholdSummary": "{percent}% 低於 · 若 {threshold} 則健康",
"noThresholdHint": "設定健康閾值",
"noDataHint": "此視窗中無分數"
},
"columns": {
"health": "健康狀態",
"scoreConfig": "評分設定",
"type": "類型",
"quality": "品質",
"totalScores": "總評分數",
"scopeMix": "範圍混合",
"volumeTrend": "數量趨勢",
"updated": "更新時間"
}
},
"averageWithHealthyRate": "平均 · {rate}% 健康"
"evaluatorTrace": "評估器追蹤",
"addToDataset": "新增至資料集",
"datasetSession": "資料集只接受單一追蹤或 Span。請開啟工作階段以新增其中一筆追蹤。",
"datasetNoStream": "此評分沒有可新增的來源串流。"
}
},
"singular": {
"jobs": "評估作業",
@@ -18001,22 +17980,18 @@
}
},
"importCount": "匯入 ({count})",
"count": "計數",
"failedToDeriveSchema": "無法推導結構",
"failedToImportScoreConfig": "無法匯入評分配置",
"failedToImportScorer": "無法匯入評分器",
"failedToFetchCatalog": "無法取得目錄 ({status})",
"failedToLoadCatalog": "無法載入目錄",
"failedToRunScorerTest": "無法執行評分器測試",
"healthyRate": "健康率",
"invalidJsonFormat": "無效的 JSON 格式",
"jsonIsEmpty": "JSON 為空",
"scorerTestFailed": "評分器測試失敗",
"scorerTestSampleContext": "法國國家概況:巴黎是首都。",
"scorerTestSampleInput": "法國的首都是巴黎,位於塞納河畔。",
"scorerTestSampleOutput": "巴黎是法國的首都。",
"topCategory": "頂層類別",
"trueRate": "真實率",
"unknownError": "未知錯誤",
"import": {
"createdSuccessfully": "\"{name}\" 已成功建立",
@@ -20186,6 +20161,7 @@
"evalTemplates": "線上評估 — 範本",
"scorers": "線上評估 — 評分器",
"evalJobs": "線上評估 — 作業",
"quality": "線上評估 — 品質",
"scoreConfigs": "線上評估 — 分數設定",
"actions": "動作",
"llmProviders": "LLM 提供者",
@@ -20301,6 +20277,7 @@
"evalTemplatesRefresh": "刷新列表",
"scorersRefresh": "刷新列表",
"evalJobsRefresh": "刷新列表",
"qualityBack": "返回所有設定",
"scoreConfigsRefresh": "刷新列表",
"actionsRefresh": "刷新列表",
"llmProvidersRefresh": "刷新列表",
@@ -20547,11 +20524,10 @@
"high": "高"
},
"qualityStatus": {
"nothreshold": "無門檻",
"nodata": "無資料",
"attention": "需要關注",
"healthy": "健康",
"warn": "警告",
"unhealthy": "不健康"
"unset": "未設定閾值",
"nodata": "無評分資料"
},
"rumEventType": {
"longtask": "長任務",
@@ -14,9 +14,17 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { mutationOptions, queryOptions } from "@tanstack/vue-query";
import onlineEvalsService from "./online-evals.service";
import onlineEvalsService, {
type QualityConfigSummary,
type QualityListParams,
type QualityScorePage,
type QualityScoresParams,
type QualityTimelineBucket,
} from "./online-evals.service";
import { onlineEvalKeys } from "./online-evals.service.querykeys";
import search from "./search";
import { MEDIUM_STALE_TIME } from "@/composables/query/cachePolicy";
import { quantizeRange, stableFilters } from "@/composables/query/queryClient";
/** One entry serves both Settings › LLM Providers and Online Evals, so it takes the shorter of the two tiers. */
export const providersQuery = (org: string) =>
@@ -47,6 +55,61 @@ export const evalJobsQuery = (org: string) =>
staleTime: MEDIUM_STALE_TIME,
});
// The page derives its window from `Date.now()`, so each key rounds it to the minute; the request keeps the exact range.
const quantizedKey = (params: QualityScoresParams) => {
const { start, end } = quantizeRange(params.start_time, params.end_time);
const key: Record<string, unknown> = { ...params, start_time: start, end_time: end };
return stableFilters(key);
};
export const qualityListQuery = (org: string, params: QualityListParams) =>
queryOptions({
queryKey: onlineEvalKeys.qualityList(org, quantizedKey(params)),
queryFn: (): Promise<QualityConfigSummary[]> => onlineEvalsService.quality.list(org, params),
staleTime: MEDIUM_STALE_TIME,
});
export const qualityScoresQuery = (org: string, entityId: string, params: QualityScoresParams) =>
queryOptions({
queryKey: onlineEvalKeys.qualityScores(org, entityId, quantizedKey(params)),
queryFn: (): Promise<QualityScorePage> =>
onlineEvalsService.quality.scores(org, entityId, params),
staleTime: MEDIUM_STALE_TIME,
});
// Takes no page, filter or "only" params, so paging and drilling into the list never refetch the trend.
export const qualityTimelineQuery = (org: string, entityId: string, params: QualityListParams) =>
queryOptions({
queryKey: onlineEvalKeys.qualityTimeline(org, entityId, quantizedKey(params)),
queryFn: async (): Promise<QualityTimelineBucket[]> =>
(await onlineEvalsService.quality.timeline(org, entityId, params)).timeline ?? [],
staleTime: MEDIUM_STALE_TIME,
});
/** The spans a score's judge read, by id, from a window in microseconds that holds them. */
export const qualitySpansQuery = (
org: string,
streamType: string,
stream: string,
spanIds: string[],
startTime: number,
endTime: number,
) =>
queryOptions({
queryKey: onlineEvalKeys.qualitySpans(org, streamType, stream, spanIds),
queryFn: async (): Promise<Record<string, unknown>[]> => {
const ids = spanIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(", ");
const sql = `SELECT * FROM "${stream.replace(/"/g, '""')}" WHERE span_id IN (${ids})`;
const response = await search.search({
org_identifier: org,
query: { query: { sql, start_time: startTime, end_time: endTime, from: 0 } },
page_type: streamType,
});
return response.data?.hits ?? [];
},
staleTime: MEDIUM_STALE_TIME,
});
// ── Writes ──────────────────────────────────────────────────────────────────
//
// Every write drops the whole onlineEvals scope: the four lists share entity
@@ -28,4 +28,15 @@ export const onlineEvalKeys = {
scoreConfigs: (org: string) => orgKey(org, "onlineEvals", "scoreConfigs"),
scorers: (org: string) => orgKey(org, "onlineEvals", "scorers"),
jobs: (org: string) => orgKey(org, "onlineEvals", "jobs"),
/** Every read on the Quality page, so its Refresh can force them together. */
quality: (org: string) => orgKey(org, "onlineEvals", "quality"),
qualityList: (org: string, params: Record<string, unknown>) =>
orgKey(org, "onlineEvals", "quality", "list", params),
qualityScores: (org: string, entityId: string, params: Record<string, unknown>) =>
orgKey(org, "onlineEvals", "quality", "scores", entityId, params),
qualityTimeline: (org: string, entityId: string, params: Record<string, unknown>) =>
orgKey(org, "onlineEvals", "quality", "timeline", entityId, params),
/** Spans never change once written, so the read window stays out of the key. */
qualitySpans: (org: string, streamType: string, stream: string, spanIds: string[]) =>
orgKey(org, "onlineEvals", "quality", "spans", streamType, stream, spanIds),
};
@@ -228,6 +228,69 @@ describe("mutation endpoints return response.data directly", () => {
});
});
describe("quality APIs", () => {
it("quality.list sends snake_case params with whole-µs times and drops empty optionals", async () => {
mockGet.mockResolvedValue({ data: { list: [{ configId: "c1" }] } });
const result = await onlineEvalsService.quality.list("acme", {
start_time: 1791112620869676.5,
end_time: 1791199020869676,
agent_env: "prod",
agent_name: "",
agent_id: undefined,
});
expect(mockGet).toHaveBeenCalledWith("/api/acme/score_configs/quality", {
params: { start_time: 1791112620869676, end_time: 1791199020869676, agent_env: "prod" },
});
expect(result).toEqual([{ configId: "c1" }]);
});
it("quality.scores hits the per-config path with the filter and page params", async () => {
const page = { average: null, distribution: [], list: [], total: 0, from: 10, size: 10 };
mockGet.mockResolvedValue({ data: page });
const result = await onlineEvalsService.quality.scores("acme", "7512827247535849473", {
start_time: 1,
end_time: 2,
scope: "trace",
unhealthy_only: true,
bucket_from: 3,
bucket_to: 5,
value: undefined,
from: 10,
size: 10,
});
expect(mockGet).toHaveBeenCalledWith("/api/acme/score_configs/7512827247535849473/quality", {
params: {
start_time: 1,
end_time: 2,
scope: "trace",
unhealthy_only: true,
bucket_from: 3,
bucket_to: 5,
from: 10,
size: 10,
},
});
expect(result).toBe(page);
});
it("quality.timeline hits the per-config timeline path with whole-µs times and drops empty optionals", async () => {
const body = { timeline: [] };
mockGet.mockResolvedValue({ data: body });
const result = await onlineEvalsService.quality.timeline("acme", "7512827247535849473", {
start_time: 1.5,
end_time: 2,
scope: "session",
agent_name: "",
agent_env: "prod",
});
expect(mockGet).toHaveBeenCalledWith(
"/api/acme/score_configs/7512827247535849473/quality/timeline",
{ params: { start_time: 1, end_time: 2, scope: "session", agent_env: "prod" } },
);
expect(result).toBe(body);
});
});
describe("jobs.create analytics", () => {
beforeEach(() => vi.mocked(analytics.track).mockClear());
+136
View File
@@ -283,6 +283,114 @@ export interface EvalJobHealth {
lastError?: EvalJobLastError | null;
}
export type QualityStatus = "attention" | "healthy" | "unset" | "no_data";
export interface QualityScopeCounts {
span: number;
trace: number;
session: number;
}
/** One row of the Quality list API. Times are microseconds. */
export interface QualityConfigSummary {
configId: string;
name: string;
dataType: ScoreDataType;
status: QualityStatus;
total: number;
unhealthy: number | null;
average: number | null;
lastScoredAt: number | null;
scopeCounts: QualityScopeCounts;
topValue: { key: string | boolean; count: number } | null;
}
export interface QualityAgentParams {
agent_id?: string;
agent_name?: string;
agent_env?: string;
agent_version?: string;
}
export interface QualityListParams extends QualityAgentParams {
start_time: number;
end_time: number;
scope?: EvalTargetScope;
}
export interface QualityScoresParams extends QualityListParams {
unhealthy_only?: boolean;
bucket_from?: number;
bucket_to?: number;
value?: string;
from?: number;
size?: number;
}
/** Numeric buckets key on their index; boolean and categorical buckets key on the value. */
export interface QualityDistributionBucket {
key: number | string | boolean;
lower?: number;
upper?: number;
count: number;
unhealthy: number | null;
}
/** Latest scores written in one slice of the time range; times in microseconds. */
export interface QualityTimelineBucket {
start: number;
end: number;
count: number;
unhealthy: number | null;
/** Boolean configs only. */
trueCount: number | null;
/** Numeric configs only, like `p95`. */
average: number | null;
p95: number | null;
}
export interface QualityScore {
id: string;
timestamp: number;
refTimestamp: number;
sourceType: string;
targetScope: EvalTargetScope;
targetId: string | null;
spanId: string | null;
traceId: string | null;
sessionId: string | null;
sourceStream: string | null;
sourceStreamType: string | null;
value: number | string | boolean | null;
unhealthy: boolean | null;
reasoning: string | null;
evaluatorTraceId: string | null;
taskId: string | null;
inputPreview: string | null;
outputPreview: string | null;
/** The spans the scorer read the input and output from; null when it recorded none. */
inputSpanId: string | null;
outputSpanId: string | null;
}
export interface QualityScorePage {
average: number | null;
distribution: QualityDistributionBucket[];
list: QualityScore[];
total: number;
from: number;
size: number;
}
// The API rejects unknown and malformed params, so empty optionals are dropped and times are whole microseconds.
const qualityQuery = (params: QualityListParams | QualityScoresParams) => {
const out: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
out[key] = key === "start_time" || key === "end_time" ? Math.floor(value as number) : value;
}
return out;
};
const unwrapList = <T>(response: any, key = "list"): T[] => {
const data = response?.data;
if (Array.isArray(data)) return data;
@@ -385,6 +493,34 @@ const onlineEvalsService = {
(await http().post(`/api/${orgId}/scorers/llm_judge/output_schema`, payload)).data,
},
quality: {
list: async (orgId: string, params: QualityListParams): Promise<QualityConfigSummary[]> =>
unwrapList<QualityConfigSummary>(
await http().get(`/api/${orgId}/score_configs/quality`, { params: qualityQuery(params) }),
),
scores: async (
orgId: string,
entityId: string,
params: QualityScoresParams,
): Promise<QualityScorePage> =>
(
await http().get(`/api/${orgId}/score_configs/${encodeURIComponent(entityId)}/quality`, {
params: qualityQuery(params),
})
).data,
timeline: async (
orgId: string,
entityId: string,
params: QualityListParams,
): Promise<{ timeline: QualityTimelineBucket[] }> =>
(
await http().get(
`/api/${orgId}/score_configs/${encodeURIComponent(entityId)}/quality/timeline`,
{ params: qualityQuery(params) },
)
).data,
},
jobs: {
list: async (orgId: string, status?: EvalJobStatus): Promise<EvalJob[]> => {
const query = status ? `?status=${encodeURIComponent(status)}` : "";