From f43069a840ffc23b9cb7819a2cfe07d6a70d59ff Mon Sep 17 00:00:00 2001 From: Beinan Date: Wed, 15 Jul 2026 05:18:40 +0000 Subject: [PATCH 1/2] feat(master-ui): browse experiment rollout records --- crates/lance-context-api/src/lib.rs | 10 + crates/lance-context-core/src/api_impl.rs | 3 +- crates/lance-context-core/src/lib.rs | 6 +- .../lance-context-core/src/rollout_store.rs | 154 ++++++- crates/lance-context-master/src/routes.rs | 310 ++++++++++++- crates/lance-context-master/ui/src/App.tsx | 430 +++++++++++++++++- crates/lance-context-master/ui/src/api.ts | 80 ++++ crates/lance-context-master/ui/src/styles.css | 397 +++++++++++++++- 8 files changed, 1368 insertions(+), 22 deletions(-) diff --git a/crates/lance-context-api/src/lib.rs b/crates/lance-context-api/src/lib.rs index 6106251..b5666fc 100644 --- a/crates/lance-context-api/src/lib.rs +++ b/crates/lance-context-api/src/lib.rs @@ -883,6 +883,16 @@ pub struct ExperimentListResponse { pub total: i64, } +/// Paginated rollout records for one experiment in the master data browser. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExperimentRecordsResponse { + pub records: Vec, + /// Whether another page exists after this response. + pub has_more: bool, + pub limit: usize, + pub offset: usize, +} + /// State of a manual or automatic compaction job for one experiment. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case", tag = "state")] diff --git a/crates/lance-context-core/src/api_impl.rs b/crates/lance-context-core/src/api_impl.rs index 96084a6..328cc64 100644 --- a/crates/lance-context-core/src/api_impl.rs +++ b/crates/lance-context-core/src/api_impl.rs @@ -463,7 +463,8 @@ fn rollout_record_from_add_request(r: &AddRolloutRequest) -> RolloutRecord { } } -fn rollout_record_to_dto(r: RolloutRecord) -> RolloutRecordDto { +#[must_use] +pub fn rollout_record_to_dto(r: RolloutRecord) -> RolloutRecordDto { RolloutRecordDto { id: r.id, rollout_id: r.rollout_id, diff --git a/crates/lance-context-core/src/lib.rs b/crates/lance-context-core/src/lib.rs index 7032126..cf78999 100644 --- a/crates/lance-context-core/src/lib.rs +++ b/crates/lance-context-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod serde; mod storage; mod store; +pub use api_impl::rollout_record_to_dto; pub use context::{Context, ContextEntry, Snapshot}; pub use eval::{ AbReport, EvalConfig, EvalQuery, EvalQuerySet, EvalReport, MetricScores, QueryEval, @@ -35,7 +36,10 @@ pub use record::{ }; pub use registry::{RegistryEntry, RolloutRegistry}; pub use rollout::{RolloutRecord, ROLE_ARTIFACT, ROLE_ASSISTANT, ROLE_GRADE, ROLE_TOOL}; -pub use rollout_store::{rollout_schema, RolloutObservation, RolloutStore, RolloutStoreOptions}; +pub use rollout_store::{ + rollout_schema, RolloutFilters, RolloutObservation, RolloutPage, RolloutStore, + RolloutStoreOptions, +}; pub use storage::{create_local_dir_if_needed, join_uri, validate_store_name, MAX_STORE_NAME_LEN}; pub use store::{ CompactionConfig, CompactionStats, ContextStore, ContextStoreOptions, DistanceMetric, diff --git a/crates/lance-context-core/src/rollout_store.rs b/crates/lance-context-core/src/rollout_store.rs index dce35cf..204d5d9 100644 --- a/crates/lance-context-core/src/rollout_store.rs +++ b/crates/lance-context-core/src/rollout_store.rs @@ -107,6 +107,51 @@ pub struct RolloutObservation { pub pending_wal_generations: i64, } +/// Exact-match filters for rollout record browsing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RolloutFilters { + pub id: Option, + pub rollout_id: Option, + pub problem_id: Option, + pub dataset: Option, + pub role: Option, + pub content_type: Option, + pub policy_version: Option, + pub artifact_type: Option, + pub include_in_training: Option, +} + +impl RolloutFilters { + fn expression(&self) -> Option { + let mut clauses = Vec::new(); + for (column, value) in [ + ("id", self.id.as_deref()), + ("rollout_id", self.rollout_id.as_deref()), + ("problem_id", self.problem_id.as_deref()), + ("dataset", self.dataset.as_deref()), + ("role", self.role.as_deref()), + ("content_type", self.content_type.as_deref()), + ("policy_version", self.policy_version.as_deref()), + ("artifact_type", self.artifact_type.as_deref()), + ] { + if let Some(value) = value.filter(|value| !value.is_empty()) { + clauses.push(format!("{column} = '{}'", value.replace('\'', "''"))); + } + } + if let Some(value) = self.include_in_training { + clauses.push(format!("include_in_training = {value}")); + } + (!clauses.is_empty()).then(|| clauses.join(" AND ")) + } +} + +/// One server-side paginated rollout query result. +#[derive(Debug, Clone)] +pub struct RolloutPage { + pub records: Vec, + pub has_more: bool, +} + /// Configuration for opening a [`RolloutStore`]. #[derive(Debug, Clone, Default)] pub struct RolloutStoreOptions { @@ -793,6 +838,40 @@ impl RolloutStore { Ok(results) } + /// Filter and page rollout rows in the LSM execution plan. + /// + /// Reads one row beyond the requested page to report `has_more`, avoiding + /// an unbounded full-table count on every UI request. Artifact bytes are + /// projected out, matching [`Self::list`]. + pub async fn list_filtered( + &self, + filters: &RolloutFilters, + limit: usize, + offset: usize, + ) -> LanceResult { + let shard_snapshots = self.wal_shard_snapshots().await?; + let filter = filters.expression(); + + let columns = self.non_blob_columns(); + let refs: Vec<&str> = columns.iter().map(String::as_str).collect(); + let mut page_scanner = self + .lsm_scanner_with_snapshots(shard_snapshots) + .project(&refs); + if let Some(filter) = &filter { + page_scanner = page_scanner.filter(filter)?; + } + page_scanner = page_scanner.limit(limit.saturating_add(1), Some(offset)); + + let mut stream = page_scanner.try_into_stream().await?; + let mut records = Vec::new(); + while let Some(batch) = stream.try_next().await? { + records.extend(batch_to_rollout_records(&batch)?); + } + let has_more = records.len() > limit; + records.truncate(limit); + Ok(RolloutPage { records, has_more }) + } + /// Retrieve a single rollout row by its unique id, including any freshly /// appended (MemWAL-flushed) row on any instance. `binary_payload` is /// projected out (fetch bytes via [`Self::get_blob`]). @@ -864,11 +943,15 @@ impl RolloutStore { async fn lsm_scanner(&self) -> LanceResult { let shard_snapshots = self.wal_shard_snapshots().await?; - Ok(LsmScanner::new( + Ok(self.lsm_scanner_with_snapshots(shard_snapshots)) + } + + fn lsm_scanner_with_snapshots(&self, shard_snapshots: Vec) -> LsmScanner { + LsmScanner::new( Arc::new(self.dataset.clone()), shard_snapshots, vec!["id".to_string()], - )) + ) } /// Read the latest manifest for every MemWAL shard. Manifest reads are @@ -1860,6 +1943,73 @@ mod tests { }); } + #[test] + fn filtered_listing_pages_across_shards_and_escapes_values() { + let dir = TempDir::new().unwrap(); + let uri = dir.path().to_string_lossy().to_string(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let options = |shard: &str| RolloutStoreOptions { + shard_id: Some(shard.to_string()), + ..Default::default() + }; + let mut instance_a = RolloutStore::open_with_options(&uri, options("filter-a")) + .await + .unwrap(); + let mut quoted = assistant_record("row-'quoted"); + quoted.rollout_id = "rollout-alpha".to_string(); + quoted.policy_version = Some("policy-a".to_string()); + instance_a + .add(&[quoted, assistant_record("row-a")]) + .await + .unwrap(); + + let mut instance_b = RolloutStore::open_with_options(&uri, options("filter-b")) + .await + .unwrap(); + let artifact = artifact_record("row-b", b"blob"); + instance_b.add(&[artifact]).await.unwrap(); + + let reader = RolloutStore::open(&uri).await.unwrap(); + let quoted_page = reader + .list_filtered( + &RolloutFilters { + id: Some("row-'quoted".to_string()), + ..Default::default() + }, + 25, + 0, + ) + .await + .unwrap(); + assert!(!quoted_page.has_more); + assert_eq!(quoted_page.records[0].id, "row-'quoted"); + + let policy_page = reader + .list_filtered( + &RolloutFilters { + rollout_id: Some("rollout-alpha".to_string()), + role: Some(ROLE_ASSISTANT.to_string()), + policy_version: Some("policy-a".to_string()), + ..Default::default() + }, + 25, + 0, + ) + .await + .unwrap(); + assert!(!policy_page.has_more); + assert_eq!(policy_page.records[0].id, "row-'quoted"); + + let page = reader + .list_filtered(&RolloutFilters::default(), 1, 1) + .await + .unwrap(); + assert!(page.has_more); + assert_eq!(page.records.len(), 1); + }); + } + /// Read the number of un-merged flushed generations recorded for a store's /// own write shard. Used by merge tests to assert the manifest drains. async fn flushed_generation_count(store: &RolloutStore) -> usize { diff --git a/crates/lance-context-master/src/routes.rs b/crates/lance-context-master/src/routes.rs index 4cd1370..79026a0 100644 --- a/crates/lance-context-master/src/routes.rs +++ b/crates/lance-context-master/src/routes.rs @@ -2,14 +2,20 @@ use std::sync::Arc; +use axum::body::Body; use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::Response; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Deserialize; use lance_context_api::{ - CompactJobStatus, ExperimentDetail, ExperimentListResponse, ExperimentSummary, + CompactJobStatus, ExperimentDetail, ExperimentListResponse, ExperimentRecordsResponse, + ExperimentSummary, +}; +use lance_context_core::{ + rollout_record_to_dto, RolloutFilters, RolloutStore, RolloutStoreOptions, }; use crate::error::MasterError; @@ -32,6 +38,10 @@ fn default_limit() -> usize { 50 } +fn default_records_limit() -> usize { + 25 +} + /// Query params for the detail endpoint. #[derive(Debug, Deserialize)] pub struct DetailParams { @@ -41,6 +51,33 @@ pub struct DetailParams { pub fresh: bool, } +/// Query params for server-side rollout record filtering and pagination. +#[derive(Debug, Deserialize)] +pub struct RecordListParams { + #[serde(default = "default_records_limit")] + pub limit: usize, + #[serde(default)] + pub offset: usize, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub rollout_id: Option, + #[serde(default)] + pub problem_id: Option, + #[serde(default)] + pub dataset: Option, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub content_type: Option, + #[serde(default)] + pub policy_version: Option, + #[serde(default)] + pub artifact_type: Option, + #[serde(default)] + pub include_in_training: Option, +} + /// `GET /api/v1/experiments` pub async fn list_experiments( State(state): State>, @@ -92,6 +129,72 @@ pub async fn get_experiment( } } +/// `GET /api/v1/experiments/{name}/records` +pub async fn list_experiment_records( + State(state): State>, + Path(name): Path, + Query(params): Query, +) -> Result, MasterError> { + let store = open_registered_store(&state, &name).await?; + let limit = params.limit.clamp(1, 100); + let filters = RolloutFilters { + id: non_empty(params.id), + rollout_id: non_empty(params.rollout_id), + problem_id: non_empty(params.problem_id), + dataset: non_empty(params.dataset), + role: non_empty(params.role), + content_type: non_empty(params.content_type), + policy_version: non_empty(params.policy_version), + artifact_type: non_empty(params.artifact_type), + include_in_training: params.include_in_training, + }; + let page = store + .list_filtered(&filters, limit, params.offset) + .await + .map_err(MasterError::from_lance)?; + + Ok(Json(ExperimentRecordsResponse { + records: page + .records + .into_iter() + .map(rollout_record_to_dto) + .collect(), + has_more: page.has_more, + limit, + offset: params.offset, + })) +} + +/// `GET /api/v1/experiments/{name}/records/{id}/blob` +pub async fn download_experiment_blob( + State(state): State>, + Path((name, id)): Path<(String, String)>, +) -> Result { + let store = open_registered_store(&state, &name).await?; + let record = store + .get_by_id(&id) + .await + .map_err(MasterError::from_lance)? + .ok_or_else(|| MasterError::NotFound(format!("record '{}' does not exist", id)))?; + let bytes = store + .get_blob(&id) + .await + .map_err(MasterError::from_lance)? + .ok_or_else(|| MasterError::NotFound(format!("record '{}' has no blob", id)))?; + + let content_type = HeaderValue::from_str(&record.content_type) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); + let filename = blob_filename(&record); + let disposition = HeaderValue::from_str(&format!("attachment; filename=\"{filename}\"")) + .map_err(|err| MasterError::Internal(err.to_string()))?; + + Response::builder() + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_DISPOSITION, disposition) + .body(Body::from(bytes)) + .map_err(|err| MasterError::Internal(err.to_string())) +} + /// `POST /api/v1/rescan` — trigger one immediate full scan. pub async fn rescan( State(state): State>, @@ -147,16 +250,69 @@ pub fn api_router() -> Router> { Router::new() .route("/experiments", get(list_experiments)) .route("/experiments/{name}", get(get_experiment)) + .route("/experiments/{name}/records", get(list_experiment_records)) + .route( + "/experiments/{name}/records/{id}/blob", + get(download_experiment_blob), + ) .route("/experiments/{name}/compact", post(compact_experiment)) .route("/experiments/{name}/compact/status", get(compact_status)) .route("/rescan", post(rescan)) } +async fn open_registered_store( + state: &MasterState, + name: &str, +) -> Result { + let entry = state + .registry + .write() + .await + .get(name) + .await + .map_err(MasterError::from_lance)? + .ok_or_else(|| MasterError::NotFound(format!("experiment '{}' does not exist", name)))?; + RolloutStore::open_existing_with_options(&entry.uri, RolloutStoreOptions::default()) + .await + .map_err(MasterError::from_lance) +} + +fn non_empty(value: Option) -> Option { + value.filter(|value| !value.is_empty()) +} + +fn blob_filename(record: &lance_context_core::RolloutRecord) -> String { + let candidate = record + .metadata + .as_ref() + .and_then(|metadata| metadata.get("filename")) + .and_then(serde_json::Value::as_str) + .unwrap_or(&record.id); + let sanitized: String = candidate + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .take(180) + .collect(); + if sanitized.is_empty() { + "rollout-blob".to_string() + } else { + sanitized + } +} + #[cfg(test)] mod tests { use super::*; use crate::config::MasterConfig; - use lance_context_core::{RolloutRegistry, RolloutStore}; + use chrono::Utc; + use lance_context_core::{RolloutRecord, RolloutRegistry, RolloutStore}; + use serde_json::json; use tempfile::TempDir; fn test_config(dir: &TempDir) -> MasterConfig { @@ -173,6 +329,43 @@ mod tests { } } + fn test_record(id: &str, with_blob: bool) -> RolloutRecord { + let bytes = with_blob.then(|| b"master-blob".to_vec()); + RolloutRecord { + id: id.to_string(), + rollout_id: "rollout-1".to_string(), + problem_id: "problem-1".to_string(), + dataset: Some("gsm8k".to_string()), + sequence_order: 1, + role: if with_blob { "artifact" } else { "assistant" }.to_string(), + created_at: Utc::now(), + content: Some("answer".to_string()), + content_type: if with_blob { "image/png" } else { "text/plain" }.to_string(), + input_tokens: None, + output_tokens: Some(vec![1, 2]), + num_input_tokens: None, + num_output_tokens: Some(2), + output_logprobs: None, + input_logprobs: None, + ref_logprobs: None, + loss_mask: None, + advantage: None, + reward: Some(1.0), + raw_reward: None, + grader_id: None, + score: Some(0.9), + include_in_training: Some(true), + exclude_reason: None, + policy_version: Some("policy-a".to_string()), + relationships: Vec::new(), + payload_size: bytes.as_ref().map(|bytes| bytes.len() as i64), + binary_payload: bytes, + payload_checksum: None, + artifact_type: with_blob.then(|| "screenshot".to_string()), + metadata: with_blob.then(|| json!({"filename": "grade result.png"})), + } + } + /// Create N experiments via core, register them, scan, and assert the stats /// table + list endpoint reflect them. #[tokio::test] @@ -325,4 +518,115 @@ mod tests { .unwrap() .is_none()); } + + #[tokio::test] + async fn records_endpoint_filters_pages_and_rejects_unknown_experiment() { + let dir = TempDir::new().unwrap(); + let state = MasterState::new(test_config(&dir)).await.unwrap(); + let uri = state.rollout_uri("records"); + let mut store = RolloutStore::open(&uri).await.unwrap(); + store + .add(&[ + test_record("assistant-1", false), + test_record("artifact-1", true), + ]) + .await + .unwrap(); + state + .registry + .write() + .await + .upsert("records", &uri) + .await + .unwrap(); + + let Json(page) = list_experiment_records( + State(state.clone()), + Path("records".to_string()), + Query(RecordListParams { + limit: 1, + offset: 0, + id: None, + rollout_id: Some("rollout-1".to_string()), + problem_id: None, + dataset: Some("gsm8k".to_string()), + role: Some("artifact".to_string()), + content_type: None, + policy_version: Some("policy-a".to_string()), + artifact_type: Some("screenshot".to_string()), + include_in_training: Some(true), + }), + ) + .await + .unwrap(); + assert!(!page.has_more); + assert_eq!(page.limit, 1); + assert_eq!(page.records[0].id, "artifact-1"); + assert!(page.records[0].binary_payload.is_none()); + + let missing = list_experiment_records( + State(state), + Path("missing".to_string()), + Query(RecordListParams { + limit: 25, + offset: 0, + id: None, + rollout_id: None, + problem_id: None, + dataset: None, + role: None, + content_type: None, + policy_version: None, + artifact_type: None, + include_in_training: None, + }), + ) + .await; + assert!(matches!(missing, Err(MasterError::NotFound(_)))); + } + + #[tokio::test] + async fn blob_endpoint_sets_download_headers_and_returns_bytes() { + let dir = TempDir::new().unwrap(); + let state = MasterState::new(test_config(&dir)).await.unwrap(); + let uri = state.rollout_uri("blobs"); + let mut store = RolloutStore::open(&uri).await.unwrap(); + store + .add(&[ + test_record("artifact-1", true), + test_record("assistant-1", false), + ]) + .await + .unwrap(); + state + .registry + .write() + .await + .upsert("blobs", &uri) + .await + .unwrap(); + + let response = download_experiment_blob( + State(state.clone()), + Path(("blobs".to_string(), "artifact-1".to_string())), + ) + .await + .unwrap(); + assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png"); + assert_eq!( + response.headers()[header::CONTENT_DISPOSITION], + "attachment; filename=\"grade_result.png\"" + ); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&bytes[..], b"master-blob"); + + let no_blob = download_experiment_blob( + State(state), + Path(("blobs".to_string(), "assistant-1".to_string())), + ) + .await; + assert!(matches!(no_blob, Err(MasterError::NotFound(_)))); + } } diff --git a/crates/lance-context-master/ui/src/App.tsx b/crates/lance-context-master/ui/src/App.tsx index 3ae3682..2e5e1f9 100644 --- a/crates/lance-context-master/ui/src/App.tsx +++ b/crates/lance-context-master/ui/src/App.tsx @@ -1,12 +1,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState, type FormEvent } from "react"; import { compactionStatus, + experimentBlobUrl, getExperiment, + listExperimentRecords, listExperiments, triggerCompaction, type CompactJobStatus, type ExperimentSummary, + type RecordFilters, + type RolloutRecord, } from "./api"; import { useUiStore } from "./store"; @@ -64,6 +68,30 @@ function CloseIcon() { ); } +function DownloadIcon() { + return ( + + + + ); +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ); +} + /* ---- compaction status --------------------------------------------------- */ function StatusPill({ status }: { status: CompactJobStatus | undefined }) { @@ -180,11 +208,375 @@ function Metric({ label, value, wide }: { label: string; value: string; wide?: b ); } +const EMPTY_RECORD_FILTERS: RecordFilters = { + id: "", + rollout_id: "", + problem_id: "", + dataset: "", + role: "", + policy_version: "", + artifact_type: "", + include_in_training: "", +}; + +function fmtBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} + +function preview(content: string | undefined): string { + if (!content) return "—"; + return content.length > 120 ? `${content.slice(0, 117)}…` : content; +} + +function JsonBlock({ value }: { value: unknown }) { + return
{JSON.stringify(value, null, 2)}
; +} + +function RecordDetails({ record, name }: { record: RolloutRecord; name: string }) { + return ( +
+
+
+ ID + {record.id} +
+
+ Rollout ID + {record.rollout_id} +
+
+ Problem ID + {record.problem_id} +
+
+ Dataset + {record.dataset ?? "—"} +
+
+ Content type + {record.content_type} +
+
+ Sequence + {record.sequence_order} +
+
+ Input tokens + {record.num_input_tokens ?? record.input_tokens?.length ?? "—"} +
+
+ Output tokens + {record.num_output_tokens ?? record.output_tokens?.length ?? "—"} +
+
+ Training + + {record.include_in_training == null + ? "—" + : record.include_in_training + ? "included" + : "excluded"} + +
+
+ Policy + {record.policy_version ?? "—"} +
+
+ Artifact type + {record.artifact_type ?? "—"} +
+
+ Checksum + {record.payload_checksum ?? "—"} +
+
+ + {record.content && ( +
+ Content +
{record.content}
+
+ )} + {record.exclude_reason && ( +
+ Exclude reason +

{record.exclude_reason}

+
+ )} + {record.metadata != null && ( +
+ Metadata + +
+ )} + {(record.relationships?.length ?? 0) > 0 && ( +
+ Relationships + +
+ )} + {(record.input_tokens || record.output_tokens || record.loss_mask) && ( +
+ Token data + +
+ )} + {record.payload_size != null && ( + + + Download blob ({fmtBytes(record.payload_size)}) + + )} +
+ ); +} + +function RecordsView({ name }: { name: string }) { + const [draft, setDraft] = useState({ ...EMPTY_RECORD_FILTERS }); + const [filters, setFilters] = useState({ ...EMPTY_RECORD_FILTERS }); + const [page, setPage] = useState(0); + const [expanded, setExpanded] = useState(null); + const pageSize = 25; + const records = useQuery({ + queryKey: ["experiment-records", name, filters, page], + queryFn: () => listExperimentRecords(name, filters, pageSize, page * pageSize), + placeholderData: (previous) => previous, + }); + const rows = records.data?.records ?? []; + const hasMore = records.data?.has_more ?? false; + + const setFilter = (key: keyof RecordFilters, value: string) => { + setDraft((current) => ({ ...current, [key]: value })); + }; + const apply = (event: FormEvent) => { + event.preventDefault(); + setFilters({ ...draft }); + setPage(0); + setExpanded(null); + }; + const clear = () => { + setDraft({ ...EMPTY_RECORD_FILTERS }); + setFilters({ ...EMPTY_RECORD_FILTERS }); + setPage(0); + setExpanded(null); + }; + const hasFilters = Object.values(filters).some(Boolean); + + return ( +
+
+ + + + + + + + +
+ + +
+
+ +
+ + {rows.length === 0 + ? "no records" + : `records ${fmtInt(page * pageSize + 1)}–${fmtInt(page * pageSize + rows.length)}`} + + {hasFilters && filtered} + {records.isFetching && syncing} +
+ +
+ {records.isError &&
{String(records.error)}
} + {!records.isError && ( + + + + + + + + + + + + + + + {records.isLoading && + Array.from({ length: 6 }).map((_, index) => ( + + {Array.from({ length: 9 }).map((__, cell) => ( + + ))} + + ))} + {!records.isLoading && + rows.map((record) => { + const open = expanded === record.id; + const signal = record.score ?? record.reward; + return ( + + + + + + + + + + + + + {open && ( + + + + )} + + ); + })} + +
+ IDRolloutRoleContentReward / scorePolicyCreatedBlob
+
+
+ + + + {record.rollout_id} + {record.role} + + {preview(record.content)} + {signal == null ? "—" : signal.toFixed(3)}{record.policy_version ?? "—"}{fmtTime(Date.parse(record.created_at))} + {record.payload_size != null ? ( + + + + ) : ( + + )} +
+ +
+ )} + {!records.isLoading && !records.isError && rows.length === 0 && ( +
No rollout records match these filters.
+ )} +
+ + {(page > 0 || hasMore) && ( +
+ page {page + 1} + + +
+ )} +
+ ); +} + function Drawer({ name }: { name: string }) { const select = useUiStore((s) => s.select); + const [tab, setTab] = useState<"records" | "overview">("records"); const detail = useQuery({ queryKey: ["experiment", name], - queryFn: () => getExperiment(name, true), + queryFn: () => getExperiment(name), }); const d = detail.data; @@ -211,14 +603,34 @@ function Drawer({ name }: { name: string }) { +
+ + +
+
- {detail.isLoading && ( + {tab === "overview" && detail.isLoading && (
loading experiment…
)} - {d && ( + {tab === "records" && } + {tab === "overview" && d && ( <>
Storage
@@ -234,9 +646,11 @@ function Drawer({ name }: { name: string }) { )}
-
- -
+ {tab === "overview" && ( +
+ +
+ )} ); @@ -365,7 +779,7 @@ export function App() {
)} - {selected && } + {selected && } ); } diff --git a/crates/lance-context-master/ui/src/api.ts b/crates/lance-context-master/ui/src/api.ts index b9ff1a9..5b3d2ea 100644 --- a/crates/lance-context-master/ui/src/api.ts +++ b/crates/lance-context-master/ui/src/api.ts @@ -18,6 +18,63 @@ export interface ExperimentListResponse { total: number; } +export interface Relationship { + target_id: string; + relation: string; + weight?: number; +} + +export interface RolloutRecord { + id: string; + rollout_id: string; + problem_id: string; + dataset?: string; + sequence_order: number; + role: string; + created_at: string; + content?: string; + content_type: string; + input_tokens?: number[]; + output_tokens?: number[]; + num_input_tokens?: number; + num_output_tokens?: number; + output_logprobs?: number[]; + input_logprobs?: number[]; + ref_logprobs?: number[]; + loss_mask?: number[]; + advantage?: number; + reward?: number; + raw_reward?: number; + grader_id?: string; + score?: number; + include_in_training?: boolean; + exclude_reason?: string; + policy_version?: string; + relationships?: Relationship[]; + payload_size?: number; + payload_checksum?: string; + artifact_type?: string; + metadata?: unknown; +} + +export interface RecordFilters { + id: string; + rollout_id: string; + problem_id: string; + dataset: string; + role: string; + policy_version: string; + artifact_type: string; + include_in_training: string; +} + +export interface ExperimentRecordsResponse { + records: RolloutRecord[]; + has_more: boolean; + limit: number; + offset: number; +} + export type CompactJobStatus = | { state: "queued" } | { state: "running" } @@ -55,6 +112,29 @@ export async function getExperiment( return json(await fetch(`${API}/experiments/${encodeURIComponent(name)}${q}`)); } +export async function listExperimentRecords( + name: string, + filters: RecordFilters, + limit: number, + offset: number, +): Promise { + const params = new URLSearchParams(); + params.set("limit", String(limit)); + params.set("offset", String(offset)); + for (const [key, value] of Object.entries(filters)) { + if (value) params.set(key, value); + } + return json( + await fetch( + `${API}/experiments/${encodeURIComponent(name)}/records?${params.toString()}`, + ), + ); +} + +export function experimentBlobUrl(name: string, id: string): string { + return `${API}/experiments/${encodeURIComponent(name)}/records/${encodeURIComponent(id)}/blob`; +} + export async function triggerCompaction(name: string): Promise { return json( await fetch(`${API}/experiments/${encodeURIComponent(name)}/compact`, { diff --git a/crates/lance-context-master/ui/src/styles.css b/crates/lance-context-master/ui/src/styles.css index 09ba623..c697a73 100644 --- a/crates/lance-context-master/ui/src/styles.css +++ b/crates/lance-context-master/ui/src/styles.css @@ -60,9 +60,7 @@ body, body { margin: 0; - background: - radial-gradient(1200px 600px at 80% -10%, rgba(109, 124, 255, 0.06), transparent 60%), - var(--bg); + background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; @@ -137,7 +135,7 @@ body { .brand__name { font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; font-size: 15px; } .brand__sub { @@ -203,7 +201,7 @@ body { font-family: var(--mono); font-size: 22px; font-weight: 500; - letter-spacing: -0.02em; + letter-spacing: 0; } .stat__value .unit { color: var(--text-dim); @@ -469,7 +467,7 @@ td .muted { top: 0; right: 0; bottom: 0; - width: min(520px, 92vw); + width: min(1120px, 96vw); background: var(--panel); border-left: 1px solid var(--border-strong); z-index: 50; @@ -496,7 +494,7 @@ td .muted { .drawer__title { font-size: 16px; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; word-break: break-all; display: flex; align-items: center; @@ -540,6 +538,42 @@ td .muted { flex: 1; } +.drawer__tabs { + display: flex; + gap: 20px; + padding: 0 22px; + border-bottom: 1px solid var(--border); + background: var(--bg-elev); +} + +.drawer__tab { + position: relative; + padding: 12px 1px 11px; + border: 0; + background: transparent; + color: var(--text-muted); + font: 500 12px var(--sans); + cursor: pointer; +} + +.drawer__tab:hover { + color: var(--text); +} + +.drawer__tab--active { + color: var(--text); +} + +.drawer__tab--active::after { + content: ""; + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + background: var(--accent); +} + .metric-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -591,6 +625,313 @@ td .muted { margin: 4px 0 10px; } +/* ---- Record browser ----------------------------------------------------- */ + +.records-view { + min-width: 0; +} + +.record-filters { + display: grid; + grid-template-columns: repeat(4, minmax(130px, 1fr)); + gap: 12px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border); +} + +.record-filters label { + display: grid; + gap: 6px; + min-width: 0; +} + +.record-filters label > span, +.record-detail__grid span, +.record-detail__section > span { + color: var(--text-dim); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.record-filters input, +.record-filters select { + width: 100%; + min-width: 0; + height: 34px; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + background: var(--bg-elev); + color: var(--text); + font: 12px var(--sans); +} + +.record-filters input:focus, +.record-filters select:focus { + border-color: var(--accent-border); + box-shadow: 0 0 0 3px var(--accent-dim); +} + +.record-filters__actions { + display: flex; + align-items: end; + gap: 8px; +} + +.records-summary { + display: flex; + align-items: center; + gap: 9px; + min-height: 42px; + color: var(--text-muted); + font: 11px var(--mono); +} + +.filter-state { + padding: 2px 6px; + border: 1px solid var(--accent-border); + border-radius: 4px; + background: var(--accent-dim); + color: var(--accent-hover); +} + +.records-sync { + margin-left: auto; + color: var(--text-dim); +} + +.records-table-wrap { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); +} + +.records-table { + width: 100%; + min-width: 990px; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; +} + +.records-table th { + padding: 9px 10px; + border-bottom: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text-dim); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.05em; + text-align: left; + text-transform: uppercase; + white-space: nowrap; +} + +.records-table th:first-child, +.records-table td:first-child { + width: 38px; + padding-right: 4px; +} + +.records-table th:nth-child(2) { + width: 145px; +} + +.records-table th:nth-child(3) { + width: 135px; +} + +.records-table th:nth-child(4) { + width: 82px; +} + +.records-table th:nth-child(6) { + width: 112px; +} + +.records-table th:nth-child(7) { + width: 105px; +} + +.records-table th:nth-child(8) { + width: 130px; +} + +.records-table th:nth-child(9) { + width: 54px; +} + +.records-table td { + height: 45px; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.records-table .num { + text-align: right; +} + +.record-row { + transition: background 0.12s; +} + +.record-row:hover, +.record-row--open { + background: var(--bg-hover); +} + +.record-id, +.record-expand { + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; +} + +.record-id { + display: block; + max-width: 100%; + overflow: hidden; + padding: 0; + font: 500 11px var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.record-id:hover { + color: var(--accent-hover); +} + +.record-expand { + display: grid; + width: 26px; + height: 26px; + padding: 0; + place-items: center; + color: var(--text-dim); +} + +.chevron { + transition: transform 0.15s; +} + +.chevron--open { + transform: rotate(90deg); +} + +.record-content { + color: var(--text-muted); +} + +.mono { + font-family: var(--mono); +} + +.role { + display: inline-flex; + max-width: 100%; + padding: 2px 6px; + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--bg-elev-2); + color: var(--text-muted); + font: 10px var(--mono); + text-overflow: ellipsis; +} + +.role--assistant { + border-color: var(--accent-border); + background: var(--accent-dim); + color: var(--accent-hover); +} + +.role--grade { + border-color: rgba(63, 185, 80, 0.3); + background: var(--ok-dim); + color: var(--ok); +} + +.role--artifact { + border-color: rgba(210, 153, 34, 0.3); + background: var(--warn-dim); + color: var(--warn); +} + +.iconbtn--inline { + width: 28px; + height: 28px; +} + +.record-detail-row td { + height: auto; + padding: 0; + overflow: visible; + border-bottom: 1px solid var(--border-strong); + white-space: normal; +} + +.record-detail { + padding: 16px 18px 18px 42px; + background: var(--bg-elev); +} + +.record-detail__grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px 18px; +} + +.record-detail__grid > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.record-detail code { + overflow-wrap: anywhere; + color: var(--text-muted); + font: 11px/1.45 var(--mono); +} + +.record-detail__section { + display: grid; + gap: 6px; + margin-top: 16px; +} + +.record-detail__section pre, +.record-detail__section p, +.json-block { + max-height: 280px; + margin: 0; + padding: 10px 12px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel); + color: var(--text-muted); + font: 11px/1.55 var(--mono); + white-space: pre-wrap; + word-break: break-word; +} + +.blob-download { + margin-top: 16px; + text-decoration: none; +} + +.records-pager { + margin-bottom: 2px; +} + /* ---- States ------------------------------------------------------------- */ .empty, @@ -637,7 +978,49 @@ td .muted { } @media (max-width: 720px) { + .app { + padding-right: 14px; + padding-left: 14px; + } + .stats { grid-template-columns: repeat(2, 1fr); } + + .drawer { + width: 100vw; + } + + .drawer__head, + .drawer__body, + .drawer__actions { + padding-right: 14px; + padding-left: 14px; + } + + .drawer__tabs { + padding: 0 14px; + } + + .record-filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .record-filters__actions { + grid-column: 1 / -1; + } + + .record-detail { + padding-left: 14px; + } + + .record-detail__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 420px) { + .record-detail__grid { + grid-template-columns: 1fr; + } } From 1aa4c3a5f8e4efab22523ffeee18daa3d3452d20 Mon Sep 17 00:00:00 2001 From: Beinan Date: Wed, 15 Jul 2026 05:22:56 +0000 Subject: [PATCH 2/2] fix(python): sync lockfile package version --- python/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/uv.lock b/python/uv.lock index b44cd93..d1c95f7 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1185,7 +1185,7 @@ wheels = [ [[package]] name = "lance-context" -version = "0.4.0" +version = "0.6.3" source = { editable = "." } dependencies = [ { name = "pyarrow" },