diff --git a/app/(main)/assessment/AssessmentWorkspace.tsx b/app/(main)/assessment/AssessmentWorkspace.tsx new file mode 100644 index 00000000..502403d4 --- /dev/null +++ b/app/(main)/assessment/AssessmentWorkspace.tsx @@ -0,0 +1,26 @@ +"use client"; + +// Shared body for every /assessment sub-route. The route decides which tab is +// active via `initialTab`; the page shell for each route just renders this. +import { Suspense } from "react"; +import { Loader } from "@/app/components/ui"; +import PageLayout from "@/app/components/assessment/PageLayout"; +import { useAssessmentWorkflow } from "@/app/hooks/useAssessmentWorkflow"; +import type { AssessmentTabId } from "@/app/lib/types/assessment"; + +function WorkspaceContent({ initialTab }: { initialTab: AssessmentTabId }) { + const layoutProps = useAssessmentWorkflow(initialTab); + return ; +} + +export default function AssessmentWorkspace({ + initialTab = "datasets", +}: { + initialTab?: AssessmentTabId; +}) { + return ( + }> + + + ); +} diff --git a/app/(main)/assessment/[tab]/page.tsx b/app/(main)/assessment/[tab]/page.tsx new file mode 100644 index 00000000..3c67a2ea --- /dev/null +++ b/app/(main)/assessment/[tab]/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { notFound, useParams } from "next/navigation"; +import { ASSESSMENT_ROUTE_SEGMENT_TO_TAB } from "@/app/lib/assessment/constants"; +import AssessmentWorkspace from "../AssessmentWorkspace"; + +// Sub-routes /assessment/config | /assessment/experiment | /assessment/runs. +// Datasets lives at the base /assessment route. +export default function AssessmentTabPage() { + const params = useParams<{ tab: string }>(); + const tab = ASSESSMENT_ROUTE_SEGMENT_TO_TAB[params?.tab ?? ""]; + if (!tab) notFound(); + return ; +} diff --git a/app/(main)/assessment/page.tsx b/app/(main)/assessment/page.tsx index e8e295bb..ec1fb328 100644 --- a/app/(main)/assessment/page.tsx +++ b/app/(main)/assessment/page.tsx @@ -1,19 +1,5 @@ -"use client"; - -import { Suspense } from "react"; -import { Loader } from "@/app/components/ui"; -import PageLayout from "@/app/components/assessment/PageLayout"; -import { useAssessmentWorkflow } from "@/app/hooks/useAssessmentWorkflow"; - -function PageContent() { - const layoutProps = useAssessmentWorkflow(); - return ; -} +import AssessmentWorkspace from "./AssessmentWorkspace"; export default function Page() { - return ( - }> - - - ); + return ; } diff --git a/app/api/assessment/datasets/[dataset_id]/rows/route.ts b/app/api/assessment/datasets/[dataset_id]/rows/route.ts new file mode 100644 index 00000000..0fdef8cb --- /dev/null +++ b/app/api/assessment/datasets/[dataset_id]/rows/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; +import { proxyErrorResponse, proxyJsonResponse } from "@/app/api/_routeProxy"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ dataset_id: string }> }, +) { + try { + const { dataset_id } = await params; + return await proxyJsonResponse( + request, + `/api/v1/assessment/datasets/${dataset_id}/rows`, + { method: "GET" }, + ); + } catch (error: unknown) { + return proxyErrorResponse("Assessment dataset rows proxy error:", error); + } +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 6cc7d447..cec786f2 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -42,6 +42,7 @@ export default function Sidebar({ const [expandedMenus, setExpandedMenus] = useState>({ Evaluations: true, Configurations: false, + Assessment: true, }); const [showLoginModal, setShowLoginModal] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false); diff --git a/app/components/assessment/AssessmentChildRunCard.tsx b/app/components/assessment/AssessmentChildRunCard.tsx index 2faa07d1..c2ce4cb3 100644 --- a/app/components/assessment/AssessmentChildRunCard.tsx +++ b/app/components/assessment/AssessmentChildRunCard.tsx @@ -126,6 +126,21 @@ export default function AssessmentChildRunCard({ )} + {childRun.cost && ( +
+ Cost:{" "} + + ${childRun.cost.total.toFixed(4)} + + {childRun.cost.pre_filter && ( + + · assessment ${childRun.cost.assessment.toFixed(4)} · + pre-filter ${childRun.cost.pre_filter.total.toFixed(4)} + + )} +
+ )} + {configError && ( diff --git a/app/components/assessment/ColumnMapperStep.tsx b/app/components/assessment/ColumnMapperStep.tsx index fd29e869..79a406df 100644 --- a/app/components/assessment/ColumnMapperStep.tsx +++ b/app/components/assessment/ColumnMapperStep.tsx @@ -1,144 +1,121 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button, Select } from "@/app/components/ui"; -import { - ASSESSMENT_ROLE_OPTION_MAP, - ASSESSMENT_ROLE_OPTIONS, - ATTACHMENT_FORMATS, -} from "@/app/lib/assessment/constants"; +import { TrashIcon } from "@/app/components/icons"; import type { Attachment, - ColumnConfig, - ColumnRole, + ColumnMapping, ColumnMapperStepProps, } from "@/app/lib/types/assessment"; -import { buildColumnConfigs, colorMapping } from "@/app/lib/utils/assessment"; +import type { AssessmentColumnType } from "@/app/lib/types/configs"; + +interface InputField { + id: string; + name: string; + type: AssessmentColumnType; + strict: boolean; +} + +const INPUT_TYPE_OPTIONS: Array<{ + value: AssessmentColumnType; + label: string; +}> = [ + { value: "text", label: "Text" }, + { value: "image", label: "Image" }, + { value: "pdf", label: "PDF" }, +]; + +const STRICT_OPTIONS = [ + { value: "false", label: "Optional" }, + { value: "true", label: "Required" }, +]; + +function toFields(mapping: ColumnMapping): InputField[] { + const strictColumns = new Set(mapping.strictColumns ?? []); + const textFields = mapping.textColumns.map((name, index) => ({ + id: `t${index}`, + name, + type: "text" as AssessmentColumnType, + strict: strictColumns.has(name), + })); + const attachmentFields = mapping.attachments.map((attachment, index) => ({ + id: `a${index}`, + name: attachment.column, + type: (attachment.type === "pdf" ? "pdf" : "image") as AssessmentColumnType, + strict: strictColumns.has(attachment.column), + })); + return [...textFields, ...attachmentFields]; +} + +// Serialize the manual field list back into the ColumnMapping shape that +// buildAssessmentInputSchema already understands (text -> input, image/pdf -> +// attachment with format "url"); strict column names are collected separately. +function toMapping(fields: InputField[]): ColumnMapping { + const textColumns: string[] = []; + const attachments: Attachment[] = []; + const strictColumns: string[] = []; + for (const field of fields) { + const name = field.name.trim(); + if (!name) continue; + if (field.type === "text") { + textColumns.push(name); + } else { + attachments.push({ column: name, type: field.type, format: "url" }); + } + if (field.strict) strictColumns.push(name); + } + return { textColumns, attachments, groundTruthColumns: [], strictColumns }; +} export default function ColumnMapperStep({ - columns, columnMapping, setColumnMapping, onNext, - onBack, + syncToken, }: ColumnMapperStepProps) { - const [columnConfigs, setColumnConfigs] = useState(() => - buildColumnConfigs(columns, columnMapping), + const [fields, setFields] = useState(() => + toFields(columnMapping), ); + const nextId = useRef(0); + // Re-seed the local field list from the mapping whenever a config is + // (re)loaded. Keyed on syncToken only (not columnMapping) so ongoing edits — + // which flow local -> columnMapping via commit() — are never clobbered. + const mappingRef = useRef(columnMapping); useEffect(() => { - setColumnConfigs(buildColumnConfigs(columns, columnMapping)); - }, [columns, columnMapping]); - - const updateRole = (index: number, role: ColumnRole) => { - if (role === "ground_truth") { + mappingRef.current = columnMapping; + }); + const isFirstSyncRef = useRef(true); + useEffect(() => { + if (isFirstSyncRef.current) { + isFirstSyncRef.current = false; return; } + setFields(toFields(mappingRef.current)); + }, [syncToken]); - setColumnConfigs((prev) => { - const current = prev[index]; - const next = [...prev]; - - if (role !== "attachment") { - next[index] = { role }; - return next; - } - - next[index] = { - role, - attachmentType: current?.attachmentType || "mixed", - attachmentFormat: current?.attachmentFormat || "url", - }; - return next; - }); + const commit = (updated: InputField[]) => { + setFields(updated); + setColumnMapping(toMapping(updated)); }; - const updateAttachmentType = ( - index: number, - type: "image" | "pdf" | "mixed", - ) => { - setColumnConfigs((prev) => { - const next = [...prev]; - next[index] = { - ...prev[index], - role: "attachment", - attachmentType: type, - attachmentFormat: "url", - }; - return next; - }); - }; - - const updateAttachmentFormat = (index: number, format: string) => { - setColumnConfigs((prev) => { - const next = [...prev]; - next[index] = { - ...prev[index], - role: "attachment", - attachmentFormat: format, - }; - return next; - }); - }; - - const patchAttachment = (index: number, patch: Partial) => { - setColumnConfigs((prev) => { - const next = [...prev]; - next[index] = { ...prev[index], role: "attachment", ...patch }; - return next; - }); - }; + const addField = () => + commit([ + ...fields, + { id: `f${nextId.current++}`, name: "", type: "text", strict: false }, + ]); - const handleNext = () => { - const textColumns: string[] = []; - const attachments: Attachment[] = []; + const updateField = (id: string, patch: Partial) => + commit( + fields.map((field) => (field.id === id ? { ...field, ...patch } : field)), + ); - columnConfigs.forEach((config, index) => { - const column = columns[index]; - if (!column) return; + const removeField = (id: string) => + commit(fields.filter((field) => field.id !== id)); - if (config.role === "text") { - textColumns.push(column); - } else if ( - config.role === "attachment" && - config.attachmentType && - config.attachmentFormat - ) { - const attachment: Attachment = { - column, - type: config.attachmentType, - format: config.attachmentFormat as Attachment["format"], - }; - if (config.attachmentType === "mixed" && config.attachmentTypeColumn) { - const map: Record = {}; - const split = (s?: string) => - (s || "") - .split(",") - .map((v) => v.trim()) - .filter(Boolean); - split(config.attachmentImageValues).forEach( - (v) => (map[v] = "image"), - ); - split(config.attachmentPdfValues).forEach((v) => (map[v] = "pdf")); - if (Object.keys(map).length > 0) { - attachment.type_column = config.attachmentTypeColumn; - attachment.type_value_map = map; - } - } - attachments.push(attachment); - } - }); - - setColumnMapping({ textColumns, attachments, groundTruthColumns: [] }); - onNext(); - }; - - const mappedCount = columnConfigs.filter( - (config) => config.role !== "unmapped", - ).length; - const hasMappedColumn = columnConfigs.some( - (config) => config.role === "text" || config.role === "attachment", - ); + const namedCount = fields.filter((field) => field.name.trim()).length; + const hasField = namedCount > 0; return (
@@ -146,254 +123,132 @@ export default function ColumnMapperStep({

- Map Columns + Input Schema

- Choose a role for each column. + Define the input fields this configuration expects. Field names + are referenced in the prompt as{" "} + + {"{field_name}"} + + . No dataset needed.

- {mappedCount}/{columns.length} mapped + {namedCount} field{namedCount === 1 ? "" : "s"}
- {columns.length === 0 ? ( -
-

- No columns found. -

-

- Go back and select a dataset first. -

-
- ) : ( -
- {columns.map((column, index) => { - const config = columnConfigs[index] || { - role: "unmapped" as ColumnRole, - }; - const activeOption = - ASSESSMENT_ROLE_OPTION_MAP[config.role] || - ASSESSMENT_ROLE_OPTION_MAP.unmapped; - const roleVisuals = colorMapping(activeOption.value); - - return ( -
-
-
-
-
- - - {column} - -
-
- -
- {ASSESSMENT_ROLE_OPTIONS.map((option) => { - const isGroundTruth = option.value === "ground_truth"; - const isActive = config.role === option.value; - return ( - - ); - })} -
+
+ {fields.length === 0 ? ( +
+

+ No input fields yet +

+

+ Add the fields your assessment reads, then reference them in the + prompt as{" "} + + {"{field_name}"} + + . +

+
+ ) : ( +
+
+ Field name + Type + Format + Required + +
+
+ {fields.map((field) => { + const isAttachment = field.type !== "text"; + return ( +
+ + updateField(field.id, { name: event.target.value }) + } + placeholder="field name" + className="h-9 min-w-0 rounded-lg border border-border bg-bg-primary px-3 text-sm text-text-primary outline-none focus:ring-1" + /> + + updateField(field.id, { + strict: event.target.value === "true", + }) + } + options={STRICT_OPTIONS} + className="h-9 w-full cursor-pointer rounded-lg border border-border bg-bg-primary px-2.5 py-1.5 text-sm text-text-primary outline-none focus:ring-1" + /> +
+ ); + })} +
+
+ )} - {config.role === "attachment" && ( - <> -
- -
- - {(config.attachmentType || "mixed") === "mixed" && ( -
- - Mixed: pick a column whose value tells each - row's type, then list which values mean image - vs PDF. - - - -
- )} -
- )} - - )} -
-
- ); - })} -
- )} + +
-
- - -
- - {hasMappedColumn - ? "Ready to continue." - : "Map at least one Text or Attachment column."} - - -
diff --git a/app/components/assessment/ConfigPanel.tsx b/app/components/assessment/ConfigPanel.tsx index ad3edabe..3f719d8d 100644 --- a/app/components/assessment/ConfigPanel.tsx +++ b/app/components/assessment/ConfigPanel.tsx @@ -1,67 +1,35 @@ "use client"; -import { Button } from "@/app/components/ui"; -import { DatabaseIcon } from "@/app/components/icons"; import { ASSESSMENT_CONFIG_STEPS } from "@/app/lib/assessment/constants"; import type { ConfigPanelProps } from "@/app/lib/types/assessment"; import ColumnMapperStep from "./ColumnMapperStep"; +import ConfigSelectStep from "./ConfigSelectStep"; import PrefilterStep from "./PrefilterStep"; -import PostProcessingStep from "./PostProcessingStep"; import PromptAndConfigStep from "./PromptAndConfigStep"; -import ReviewStep from "./ReviewStep"; import Stepper from "./Stepper"; export default function ConfigPanel({ - canSubmitAssessment, - columns, columnMapping, completedSteps, configStep, configs, - datasetId, - experimentName, - formState, - hasDataset, - isSubmitting, prefilterConfig, outputSchema, systemInstruction, promptTemplate, - postProcessingConfig, - setPostProcessingConfig, - sampleRow, - setActiveTabToDatasets, setColumnMapping, setConfigStep, setConfigs, - setExperimentName, setPrefilterConfig, setOutputSchema, setSystemInstruction, setPromptTemplate, - submitBlockerMessage, - onSubmit, onStepComplete, + configSeed, + onStartNewConfig, + onLoadExistingConfig, + onForbidden, }: ConfigPanelProps) { - if (!hasDataset) { - return ( -
-
- -

- No dataset selected -

-

- Select a dataset first from the Datasets tab -

- -
-
- ); - } - return ( <> + onStepComplete(1)} + /> + + +
onStepComplete(1)} - onBack={setActiveTabToDatasets} + onNext={() => onStepComplete(2)} + syncToken={configSeed?.nonce} />
a.column)} prefilterConfig={prefilterConfig} setPrefilterConfig={setPrefilterConfig} - onNext={() => onStepComplete(2)} - onBack={() => setConfigStep(1)} + onNext={() => onStepComplete(3)} + onBack={() => setConfigStep(2)} + syncToken={configSeed?.nonce} />
onStepComplete(3)} - onBack={() => setConfigStep(2)} - /> -
- -
- onStepComplete(4)} onBack={() => setConfigStep(3)} />
- -
- setConfigStep(4)} - onEditStep={setConfigStep} - /> -
); diff --git a/app/components/assessment/ConfigSelectStep.tsx b/app/components/assessment/ConfigSelectStep.tsx new file mode 100644 index 00000000..e53f63ae --- /dev/null +++ b/app/components/assessment/ConfigSelectStep.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button, Loader } from "@/app/components/ui"; +import { VersionPill } from "@/app/components"; +import { + ChevronDownIcon, + PlusIcon, + RefreshIcon, + SearchIcon, +} from "@/app/components/icons"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { useToast } from "@/app/hooks/useToast"; +import { + fetchConfigPage, + fetchConfigVersionDetail, + fetchConfigVersionsPage, +} from "@/app/lib/utils/assessmentFetcher"; +import { handleForbiddenError } from "@/app/lib/utils/assessment"; +import { formatRelativeTime } from "@/app/lib/utils"; +import type { ConfigSelectStepProps } from "@/app/lib/types/assessment"; +import type { + AssessmentConfigBlob, + ConfigPublic, + ConfigVersionItems, +} from "@/app/lib/types/configs"; + +interface ConfigSelectCardProps { + config: ConfigPublic; + apiKey: string; + tag: string; + isLoadingId: string | null; + onPick: (config: ConfigPublic, version: number) => void; +} + +function ConfigSelectCard({ + config, + apiKey, + tag, + isLoadingId, + onPick, +}: ConfigSelectCardProps) { + const toast = useToast(); + const [expanded, setExpanded] = useState(false); + const [versions, setVersions] = useState(null); + const [isLoadingVersions, setIsLoadingVersions] = useState(false); + + const toggle = async () => { + if (expanded) { + setExpanded(false); + return; + } + setExpanded(true); + if (versions) return; + setIsLoadingVersions(true); + try { + const page = await fetchConfigVersionsPage(apiKey, config.id, { + limit: 100, + tag, + }); + setVersions(page.items); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to load versions", + ); + } finally { + setIsLoadingVersions(false); + } + }; + + return ( +
+ + + {expanded && ( +
+ {isLoadingVersions ? ( + + ) : versions && versions.length > 0 ? ( +
    + {versions.map((item) => { + const loadingKey = `${config.id}:${item.version}`; + return ( +
  • + +
  • + ); + })} +
+ ) : ( +

+ No versions found +

+ )} +
+ )} +
+ ); +} + +export default function ConfigSelectStep({ + onStartNew, + onLoadExisting, + onForbidden, + onNext, + tag = "ASSESSMENT", +}: ConfigSelectStepProps) { + const { activeKey, isAuthenticated } = useAuth(); + const apiKey = activeKey?.key ?? ""; + const toast = useToast(); + + const [configs, setConfigs] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [search, setSearch] = useState(""); + const [loadingVersionKey, setLoadingVersionKey] = useState( + null, + ); + + const loadConfigs = () => { + if (!isAuthenticated) return; + setIsLoading(true); + fetchConfigPage({ apiKey, limit: 100, tag }) + .then((page) => setConfigs(page.items)) + .catch((err) => { + if (err instanceof Error && handleForbiddenError(err, onForbidden)) { + return; + } + toast.error( + err instanceof Error ? err.message : "Failed to load configurations", + ); + }) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => { + if (!isAuthenticated) return; + loadConfigs(); + + }, [isAuthenticated, apiKey, tag]); + + const filtered = useMemo(() => { + // The scope is enforced by the `tag` query param on the fetch. + const query = search.trim().toLowerCase(); + if (!query) return configs; + return configs.filter((config) => + config.name.toLowerCase().includes(query), + ); + }, [configs, search]); + + const handleNewConfig = () => { + onStartNew(); + onNext(); + }; + + const handlePickVersion = async (config: ConfigPublic, version: number) => { + if (loadingVersionKey) return; + setLoadingVersionKey(`${config.id}:${version}`); + try { + const detail = await fetchConfigVersionDetail( + apiKey, + config.id, + version, + tag, + ); + const blob = detail.config_blob as unknown as AssessmentConfigBlob; + onLoadExisting(blob, config.id, config.name); + onNext(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to load configuration", + ); + } finally { + // This step stays mounted (ConfigPanel hides it via CSS), so always clear + // the loading flag — otherwise it stays stuck on return to this step. + setLoadingVersionKey(null); + } + }; + + return ( +
+
+
+

+ Configuration +

+

+ Start a new configuration, or load a saved one (and version) to edit + into a new version. +

+
+ +
+
+ + setSearch(event.target.value)} + placeholder="Search configs..." + className="w-full rounded-full bg-bg-secondary py-3 pl-11 pr-4 text-sm text-text-primary placeholder:text-neutral focus:bg-bg-primary focus:outline-none focus:ring-1 focus:ring-accent-primary" + /> +
+ + +
+ + {isLoading ? ( + + ) : filtered.length === 0 ? ( +
+

+ {search.trim() + ? `No configs match "${search.trim()}"` + : "No configurations yet"} +

+

+ Use “New Config” to author one from scratch. +

+
+ ) : ( +
+ {filtered.map((config) => ( + void handlePickVersion(cfg, version)} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/app/components/assessment/DatasetsTab.tsx b/app/components/assessment/DatasetsTab.tsx index b45fd705..ad302120 100644 --- a/app/components/assessment/DatasetsTab.tsx +++ b/app/components/assessment/DatasetsTab.tsx @@ -9,13 +9,10 @@ import CreatePanel from "@/app/components/assessment/datasets/CreatePanel"; import DatasetList from "@/app/components/assessment/datasets/DatasetList"; export default function DatasetsTab(props: DatasetsTabProps) { - const { datasetId, onNext } = props; const { datasets, isLoading, - isLoadingColumns, viewingId, - canProceed, datasetName, datasetDescription, uploadedFile, @@ -35,7 +32,6 @@ export default function DatasetsTab(props: DatasetsTabProps) { handleFileSelect, resetForm, handleCreateDataset, - handleDatasetSelect, handleViewDataset, handleDeleteDataset, handleDrop, @@ -45,15 +41,10 @@ export default function DatasetsTab(props: DatasetsTabProps) {

- Evaluation Runs + Assessment Runs

+ void handleSelectDataset(event.target.value) + } + /> + )} + {datasetName && ( +

+ Selected: {datasetName} + {isLoadingColumns + ? " · loading columns…" + : datasetColumns.length > 0 + ? ` · ${datasetColumns.length} columns` + : ""} +

+ )} +
+ +
+
+ User prompt +
+ +
+ + + +
+
+ +
+ +
+ + ); +} diff --git a/app/components/assessment/PageLayout.tsx b/app/components/assessment/PageLayout.tsx index 84dd490e..472e063f 100644 --- a/app/components/assessment/PageLayout.tsx +++ b/app/components/assessment/PageLayout.tsx @@ -1,29 +1,34 @@ "use client"; -// Top-level layout for /assessment: sidebar, tab navigation, and active tab content. +// Top-level layout for /assessment. Tabs are sidebar sub-items (routes), so the +// active tab is driven by the URL rather than an in-page tab bar. +import { usePathname } from "next/navigation"; import Sidebar from "@/app/components/Sidebar"; -import { TabNavigation } from "@/app/components/ui"; import PageHeader from "@/app/components/PageHeader"; import { useApp } from "@/app/lib/context/AppContext"; import type { PageLayoutProps } from "@/app/lib/types/assessment"; import ConfigPanel from "./ConfigPanel"; import DatasetsTab from "./DatasetsTab"; import EvaluationsTab from "./EvaluationsTab"; +import ExperimentTab from "./ExperimentTab"; export default function PageLayout({ activeTab, - tabs, - onTabSwitch, datasetsTabProps, configPanelProps, + experimentTabProps, evaluationsTabProps, }: PageLayoutProps) { const { sidebarCollapsed } = useApp(); + const pathname = usePathname(); return (
- +
- onTabSwitch(tabId as typeof activeTab)} - /> - {activeTab === "datasets" && (
@@ -51,6 +50,12 @@ export default function PageLayout({
+ {activeTab === "experiment" && ( +
+ +
+ )} + {activeTab === "results" && (
diff --git a/app/components/assessment/PrefilterStep.tsx b/app/components/assessment/PrefilterStep.tsx index 0ad16cd5..b2c65895 100644 --- a/app/components/assessment/PrefilterStep.tsx +++ b/app/components/assessment/PrefilterStep.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button, Modal } from "@/app/components/ui"; import { ExpandIcon } from "@/app/components/icons"; import CompactToggleSwitch from "@/app/components/assessment/CompactToggleSwitch"; @@ -60,6 +60,7 @@ export default function PrefilterStep({ setPrefilterConfig, onNext, onBack, + syncToken, }: PrefilterStepProps) { const [trEnabled, setTrEnabled] = useState( () => !!prefilterConfig?.topic_relevance, @@ -84,6 +85,30 @@ export default function PrefilterStep({ ); const [isPromptModalOpen, setIsPromptModalOpen] = useState(false); + // Re-seed local state from props whenever a config is (re)loaded. Keyed on + // syncToken only so ongoing edits (which flow to prefilterConfig on Next) are + // not clobbered mid-edit. + const propsRef = useRef({ prefilterConfig, attachmentColumns }); + useEffect(() => { + propsRef.current = { prefilterConfig, attachmentColumns }; + }); + const isFirstSyncRef = useRef(true); + useEffect(() => { + if (isFirstSyncRef.current) { + isFirstSyncRef.current = false; + return; + } + const { prefilterConfig: pf, attachmentColumns: attach } = propsRef.current; + setTrEnabled(!!pf?.topic_relevance); + setDupEnabled(!!pf?.duplicate_detection); + setTrColumns(pf?.topic_relevance?.columns ?? []); + setTrAttachmentColumns(pf?.topic_relevance?.attachment_columns ?? attach); + setTrPrompt( + pf?.topic_relevance?.prompt ?? DEFAULT_PREFILTER_TOPIC_RELEVANCE_PROMPT, + ); + setDupColumns(pf?.duplicate_detection?.columns ?? []); + }, [syncToken]); + const trHasColumns = trColumns.length > 0 || trAttachmentColumns.length > 0; const handleNext = () => { @@ -113,11 +138,11 @@ export default function PrefilterStep({

- Eliminatory + Pre-filter

Optional pre-filters run before the LLM batch. Rows that fail Topic - Relevance are excluded from Evaluation and flagged in the export. + Relevance are excluded from Assessment and flagged in the export.

@@ -128,7 +153,7 @@ export default function PrefilterStep({ Topic Relevance
- Gate: rows with decision=REJECT are excluded from Evaluation. + Gate: rows with decision=REJECT are excluded from Assessment.
- Evaluation prompt / rubric + Assessment prompt / rubric *
@@ -234,7 +259,7 @@ export default function PrefilterStep({
Passthrough: runs only on rows that passed Topic Relevance. - Results appear in export; does not gate Evaluation. + Results appear in export; does not gate Assessment.
setIsPromptModalOpen(false)} - title="Evaluation prompt / rubric" + title="Assessment prompt / rubric" maxWidth="max-w-4xl" maxHeight="max-h-[85vh]" > @@ -295,13 +320,13 @@ export default function PrefilterStep({
{!trEnabled && !dupEnabled - ? "No filters enabled — Eliminatory will be skipped." + ? "No filters enabled — Pre-filter will be skipped." : canProceed ? "Ready to continue." : "Complete required fields above."}
diff --git a/app/components/assessment/PromptAndConfigStep.tsx b/app/components/assessment/PromptAndConfigStep.tsx index 6e1eafb1..72f6e386 100644 --- a/app/components/assessment/PromptAndConfigStep.tsx +++ b/app/components/assessment/PromptAndConfigStep.tsx @@ -1,8 +1,10 @@ "use client"; -import { Button } from "@/app/components/ui"; -import { ChevronLeftIcon } from "@/app/components/icons"; +import { useState } from "react"; +import { Button, Modal } from "@/app/components/ui"; +import { ChevronLeftIcon, ExpandIcon } from "@/app/components/icons"; import { usePromptAndConfigStep } from "@/app/hooks/usePromptAndConfigStep"; +import { ASSESSMENT_TAG } from "@/app/lib/assessment/constants"; import type { PromptAndConfigStepProps } from "@/app/lib/types/assessment"; import { AssessmentConfiguration, @@ -13,16 +15,11 @@ import { export default function PromptAndConfigStep(props: PromptAndConfigStepProps) { const { - textColumns, - sampleRow, systemInstruction, setSystemInstruction, - promptTemplate, - setPromptTemplate, configs, outputSchema, setOutputSchema, - onNext, onBack, } = props; @@ -30,26 +27,6 @@ export default function PromptAndConfigStep(props: PromptAndConfigStepProps) { promptStatus, responseSummary, hasConfiguredResponseFormat, - canProceed, - nextBlockerMessage, - configMode, - setConfigMode, - removeSelection, - filteredConfigCards, - searchQuery, - setSearchQuery, - isLoadingConfigs, - hasMoreConfigs, - nextConfigSkip, - expandedConfigId, - versionStateByConfig, - latestModelByConfig, - loadingSelectionKeys, - isSelected, - loadConfigs, - loadVersions, - toggleConfigExpansion, - toggleVersionSelection, currentProvider, currentModel, providerModels, @@ -58,24 +35,70 @@ export default function PromptAndConfigStep(props: PromptAndConfigStepProps) { configName, commitMessage, isSaving, + saveMode, + setSaveMode, + versionConfigId, + setVersionConfigId, + filteredConfigCards, setConfigName, setCommitMessage, handleProviderChange, handleModelChange, updateDraftParam, handleCreateAndAdd, + configBlob, } = usePromptAndConfigStep(props); + const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); + const [isPromptFullOpen, setIsPromptFullOpen] = useState(false); + + const handleDownloadConfig = () => { + // Shape matches the API body the save will use: + // - new config -> POST /configs : { name, tag, commit_message, config_blob } + // - new version -> POST /configs/{id}/versions : { config_blob, commit_message } + // name/commit are left as skeleton placeholders for the user to fill. + const payload = + saveMode === "version" + ? { + config_blob: configBlob, + commit_message: commitMessage.trim(), + } + : { + name: configName.trim(), + tag: ASSESSMENT_TAG, + commit_message: commitMessage.trim(), + config_blob: configBlob, + }; + const baseName = + saveMode === "version" + ? configName.trim() || versionConfigId || "assessment-config-version" + : configName.trim() || "assessment-config"; + const fileName = baseName.replace(/[^a-z0-9-_]+/gi, "-"); + const url = URL.createObjectURL( + new Blob([JSON.stringify(payload, null, 2)], { + type: "application/json", + }), + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${fileName}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + }; + return (

- Evaluation + Assessment

- Write the task on the left. Tune behavior and output on the right. + Edit the system prompt, response format, and model on the left — + scroll to reach each. Live preview on the right.

-
- +
+
+ -
+ +
-
+ setIsPromptFullOpen(false)} + title="System prompt" + maxWidth="max-w-3xl" + maxHeight="max-h-[90vh]" + > +
+          {systemInstruction}
+        
+
+ +
diff --git a/app/components/assessment/ReviewStep.tsx b/app/components/assessment/ReviewStep.tsx index 3f68cdd8..a9246f23 100644 --- a/app/components/assessment/ReviewStep.tsx +++ b/app/components/assessment/ReviewStep.tsx @@ -83,7 +83,7 @@ export default function ReviewStep({ Review & Submit

- Verify your evaluation configuration before submitting. + Verify your assessment configuration before submitting.

diff --git a/app/components/assessment/datasets/CreatePanel.tsx b/app/components/assessment/datasets/CreatePanel.tsx index ce0dd22f..51b29d8c 100644 --- a/app/components/assessment/datasets/CreatePanel.tsx +++ b/app/components/assessment/datasets/CreatePanel.tsx @@ -51,7 +51,7 @@ export default function CreatePanel({ Create New Dataset

- Upload a CSV file for evaluation + Upload a CSV file for assessment

diff --git a/app/components/assessment/datasets/DatasetList.tsx b/app/components/assessment/datasets/DatasetList.tsx index cbaf5a5a..f7385cc9 100644 --- a/app/components/assessment/datasets/DatasetList.tsx +++ b/app/components/assessment/datasets/DatasetList.tsx @@ -9,47 +9,30 @@ import { Button } from "@/app/components/ui"; interface DatasetListProps { datasets: Dataset[]; - datasetId: string; isLoading: boolean; - isLoadingColumns: boolean; viewingId: number | null; - canProceed: boolean; - onSelectDataset: (id: string, name?: string) => void; onViewDataset: (datasetId: number, name: string) => void; onRequestDelete: ValueSetter; - onNext: () => void; } export default function DatasetList({ datasets, - datasetId, isLoading, - isLoadingColumns, viewingId, - canProceed, - onSelectDataset, onViewDataset, onRequestDelete, - onNext, }: DatasetListProps) { return (
-
-
-

- Datasets -

-

- Use an existing dataset from the list, or create a new dataset - from the form on the right. -

-
- {isLoadingColumns && ( - - Loading columns... - - )} +
+

+ Datasets +

+

+ Manage your dataset library. Create a dataset from the form on the + right; pick one to run when you set up an experiment. +

{isLoading ? ( @@ -66,102 +49,72 @@ export default function DatasetList({
) : (
- {datasets.map((dataset) => { - const isSelected = datasetId === dataset.dataset_id.toString(); - return ( -
- onSelectDataset( - dataset.dataset_id.toString(), - dataset.dataset_name, - ) - } - > -
-
-
-
-
- {dataset.dataset_name} -
-
- {dataset.description && ( - - )} -
- {dataset.total_items} items - {dataset.original_items > 0 && - dataset.original_items !== dataset.total_items && ( - <> - · - {dataset.original_items} original - - )} + {datasets.map((dataset) => ( +
+
+
+
+
+
+ {dataset.dataset_name}
-
- - + {dataset.description && ( + + )} +
+ {dataset.total_items} items + {dataset.original_items > 0 && + dataset.original_items !== dataset.total_items && ( + <> + · + {dataset.original_items} original + + )}
+
+ + +
- ); - })} +
+ ))}
)}
- -
-
- - {canProceed - ? "Dataset selected. Continue to AI configuration." - : "Select a dataset to continue."} - - -
-
); } diff --git a/app/components/assessment/prompt-config/AssessmentConfiguration.tsx b/app/components/assessment/prompt-config/AssessmentConfiguration.tsx index a0465778..49b23a25 100644 --- a/app/components/assessment/prompt-config/AssessmentConfiguration.tsx +++ b/app/components/assessment/prompt-config/AssessmentConfiguration.tsx @@ -1,32 +1,9 @@ "use client"; -import { RadioGroup } from "@/app/components/ui"; import type { AssessmentConfigurationProps } from "@/app/lib/types/assessment"; -import type { ConfigMode } from "@/app/lib/types/assessment/config"; import ConfigCreator from "./ConfigCreator"; -import SavedConfigs from "./SavedConfigs"; -import SelectedConfigs from "./SelectedConfigs"; export default function AssessmentConfiguration({ - configMode, - setConfigMode, - configs, - onRemoveConfig, - configCards, - searchQuery, - setSearchQuery, - isLoadingConfigs, - hasMoreConfigs, - nextConfigSkip, - expandedConfigId, - versionStateByConfig, - latestModelByConfig, - loadingSelectionKeys, - isSelected, - onLoadMoreConfigs, - onLoadVersions, - onToggleConfigExpansion, - onToggleVersionSelection, currentProvider, currentModel, providerModels, @@ -35,6 +12,13 @@ export default function AssessmentConfiguration({ configName, commitMessage, isSaving, + isSaveModalOpen, + setIsSaveModalOpen, + saveMode, + setSaveMode, + versionConfigId, + setVersionConfigId, + existingConfigs, setConfigName, setCommitMessage, onProviderChange, @@ -47,73 +31,38 @@ export default function AssessmentConfiguration({
- AI Configuration + Model Selection
- {configs.length > 0 - ? `${configs.length} selected` - : "Choose at least one configuration"} + Choose the provider and model this configuration runs on.
- {configs.length > 0 && ( -
- -
- )} -
-
- - value={configMode} - onChange={setConfigMode} - ariaLabel="Config source" - options={[ - { value: "existing", label: "Saved" }, - { value: "create", label: "New" }, - ]} - /> -
- - {configMode === "existing" && ( - - )} - - {configMode === "create" && ( - - )} +
); diff --git a/app/components/assessment/prompt-config/ConfigCreator.tsx b/app/components/assessment/prompt-config/ConfigCreator.tsx index 524f9d58..605fdedd 100644 --- a/app/components/assessment/prompt-config/ConfigCreator.tsx +++ b/app/components/assessment/prompt-config/ConfigCreator.tsx @@ -1,6 +1,11 @@ -import { Button, Field, Select } from "@/app/components/ui"; +"use client"; + +import { Button, Field, Modal, RadioGroup, Select } from "@/app/components/ui"; import { PROVIDER_OPTIONS } from "@/app/lib/data/assessmentModels"; -import type { ConfigCreatorProps } from "@/app/lib/types/assessment"; +import type { + ConfigCreatorProps, + ConfigSaveMode, +} from "@/app/lib/types/assessment"; import type { CompletionConfig } from "@/app/lib/types/configs"; import ConfigParamControl from "./ConfigParamControl"; @@ -16,6 +21,13 @@ export default function ConfigCreator({ configName, commitMessage, isSaving, + isSaveModalOpen, + setIsSaveModalOpen, + saveMode, + setSaveMode, + versionConfigId, + setVersionConfigId, + existingConfigs, setConfigName, setCommitMessage, onProviderChange, @@ -23,7 +35,14 @@ export default function ConfigCreator({ onParamChange, onSave, }: ConfigCreatorProps) { - const saveDisabled = isSaving || !configName.trim(); + const confirmDisabled = + isSaving || + (saveMode === "version" ? !versionConfigId : !configName.trim()); + + const handleConfirm = () => { + void onSave(); + setIsSaveModalOpen(false); + }; return (
@@ -87,29 +106,73 @@ export default function ConfigCreator({
-
- - -
- - +
+ + value={saveMode} + onChange={setSaveMode} + ariaLabel="Save mode" + options={[ + { value: "new", label: "New configuration" }, + { value: "version", label: "New version of existing" }, + ]} + /> + + {saveMode === "new" ? ( + + ) : ( +
+ +