diff --git a/app/(main)/assessment/page.tsx b/app/(main)/assessment/page.tsx index a0ac3581..ad9e8b08 100644 --- a/app/(main)/assessment/page.tsx +++ b/app/(main)/assessment/page.tsx @@ -1,6 +1,13 @@ "use client"; -import { Suspense, useCallback, useMemo, useRef, useState } from "react"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useRouter } from "next/navigation"; import Loader from "@/app/components/Loader"; import { useToast } from "@/app/components/Toast"; @@ -15,6 +22,8 @@ import type { AssessmentTab, AssessmentTabId, ConfigSelection, + PrefilterConfig, + PostProcessingConfig, SchemaProperty, } from "@/app/lib/types/assessment"; import PageLayout from "@/app/components/assessment/PageLayout"; @@ -93,6 +102,10 @@ function PageContent() { const [systemInstruction, setSystemInstruction] = useState(""); const [outputSchema, setOutputSchema] = useState([]); const [configs, setConfigs] = useState([]); + const [prefilterConfig, setPrefilterConfig] = + useState(null); + const [postProcessingConfig, setPostProcessingConfig] = + useState(null); const handleForbidden = useCallback( (options?: { notify?: boolean }) => { @@ -141,6 +154,10 @@ function PageContent() { [setDataset], ); + useEffect(() => { + setPrefilterConfig(null); + }, [datasetId]); + const outputSchemaJson = useMemo( () => schemaToJsonSchema(outputSchema), [outputSchema], @@ -183,13 +200,20 @@ function PageContent() { system_instruction: systemInstruction.trim() || null, text_columns: columnMapping.textColumns, attachments: columnMapping.attachments.map( - ({ column, type, format }) => ({ column, type, format }), + ({ column, type, format, type_column, type_value_map }) => ({ + column, + type, + format, + ...(type_column ? { type_column, type_value_map } : {}), + }), ), output_schema: outputSchemaJson, configs: configs.map(({ config_id, config_version }) => ({ config_id, config_version, })), + prefilter_config: prefilterConfig ?? null, + post_processing_config: postProcessingConfig ?? null, }), }); @@ -202,6 +226,8 @@ function PageContent() { setPromptTemplate(""); setOutputSchema([]); setConfigs([]); + setPrefilterConfig(null); + setPostProcessingConfig(null); setActiveTab("results"); } catch (error) { if (handleForbiddenError(error, handleForbiddenWithNotify)) return; @@ -218,8 +244,10 @@ function PageContent() { datasetId, experimentName, handleForbiddenWithNotify, + prefilterConfig, outputSchema, outputSchemaJson, + postProcessingConfig, promptTemplate, activeKey, systemInstruction, @@ -238,6 +266,8 @@ function PageContent() { promptTemplate, outputSchema, configs, + prefilterConfig, + postProcessingConfig, }; const hasDataset = !!datasetId && columns.length > 0; @@ -272,7 +302,9 @@ function PageContent() { const effectiveCompletedConfigSteps = useMemo(() => { const merged = new Set(completedConfigSteps); if (hasMapperSelection) merged.add(1); - if (canReachReview) merged.add(2); + if (hasMapperSelection) merged.add(2); // Prefilter is optional and always passable + if (canReachReview) merged.add(3); + if (canReachReview) merged.add(4); // Post Processing is optional and always passable return merged; }, [canReachReview, completedConfigSteps, hasMapperSelection]); @@ -299,10 +331,12 @@ function PageContent() { completedSteps: effectiveCompletedConfigSteps, configStep, configs, + datasetId, experimentName, formState, hasDataset, isSubmitting, + prefilterConfig, outputSchema, systemInstruction, promptTemplate, @@ -312,9 +346,12 @@ function PageContent() { setConfigStep, setConfigs, setExperimentName, + setPrefilterConfig, setOutputSchema, setSystemInstruction, setPromptTemplate, + postProcessingConfig, + setPostProcessingConfig, submitBlockerMessage, onSubmit: handleSubmit, onStepComplete: handleConfigNext, diff --git a/app/(main)/assessment/results/[runId]/page.tsx b/app/(main)/assessment/results/[runId]/page.tsx new file mode 100644 index 00000000..61de3c73 --- /dev/null +++ b/app/(main)/assessment/results/[runId]/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "next/navigation"; +import dynamic from "next/dynamic"; +import Loader from "@/app/components/Loader"; +import { useToast } from "@/app/components/Toast"; +import { useAuth } from "@/app/lib/context/AuthContext"; +import { apiFetch } from "@/app/lib/apiClient"; +import { jsonResultsToTableData } from "@/app/lib/assessment/results"; +import { SPREADSHEET_PREVIEW_ROW_LIMIT } from "@/app/lib/assessment/constants"; + +const SpreadsheetView = dynamic( + () => import("@/app/components/assessment/SpreadsheetView"), + { + ssr: false, + loading: () => ( +
+ +
+ ), + }, +); + +export default function AssessmentResultsPage() { + const params = useParams<{ runId: string }>(); + const searchParams = useSearchParams(); + const toast = useToast(); + const { apiKeys, isAuthenticated, isHydrated } = useAuth(); + const apiKey = apiKeys[0]?.key ?? ""; + + const runId = Number(params?.runId); + const title = searchParams.get("title") ?? `Run ${runId}`; + + const [headers, setHeaders] = useState(null); + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isHydrated) return; + if (!isAuthenticated) { + setError("You must be signed in to view this run."); + return; + } + if (!Number.isFinite(runId) || runId <= 0) { + setError("Invalid run id."); + return; + } + + let cancelled = false; + (async () => { + try { + const json = await apiFetch< + { data?: Record[] } | Record[] + >(`/api/assessment/runs/${runId}/results?export_format=json`, apiKey); + const results: Record[] = Array.isArray(json) + ? json + : json.data || []; + const table = jsonResultsToTableData(results, { + rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT, + }); + if (cancelled) return; + if (results.length > SPREADSHEET_PREVIEW_ROW_LIMIT) { + toast.warning( + `Preview capped at ${SPREADSHEET_PREVIEW_ROW_LIMIT} rows. Download CSV for full data.`, + ); + } + setHeaders(table.headers); + setRows(table.rows); + } catch (err) { + if (cancelled) return; + const msg = + err instanceof Error ? err.message : "Failed to load results"; + setError(msg); + toast.error(msg); + } + })(); + + return () => { + cancelled = true; + }; + }, [apiKey, isAuthenticated, isHydrated, runId, toast]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!headers || !rows) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/app/api/assessment/runs/[run_id]/resume/route.ts b/app/api/assessment/runs/[run_id]/resume/route.ts new file mode 100644 index 00000000..5996c168 --- /dev/null +++ b/app/api/assessment/runs/[run_id]/resume/route.ts @@ -0,0 +1,28 @@ +// BFF proxy — POST /api/v1/assessment/runs/:id/resume +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; +import type { RouteContext } from "@/app/lib/types/assessment"; + +export async function POST( + request: NextRequest, + context: RouteContext<"run_id">, +) { + try { + const { run_id } = await context.params; + const { status, data } = await apiClient( + request, + `/api/v1/assessment/runs/${run_id}/resume`, + { method: "POST" }, + ); + + return NextResponse.json(data, { status }); + } catch (error: unknown) { + console.error("Assessment run resume proxy error:", error); + return NextResponse.json( + { + error: "Failed to forward assessment run resume request", + }, + { status: 500 }, + ); + } +} diff --git a/app/components/assessment/ColumnMapperStep.tsx b/app/components/assessment/ColumnMapperStep.tsx index 0fa075d1..fbb59118 100644 --- a/app/components/assessment/ColumnMapperStep.tsx +++ b/app/components/assessment/ColumnMapperStep.tsx @@ -47,14 +47,17 @@ export default function ColumnMapperStep({ next[index] = { role, - attachmentType: current?.attachmentType || "image", + attachmentType: current?.attachmentType || "mixed", attachmentFormat: current?.attachmentFormat || "url", }; return next; }); }; - const updateAttachmentType = (index: number, type: "image" | "pdf") => { + const updateAttachmentType = ( + index: number, + type: "image" | "pdf" | "mixed", + ) => { setColumnConfigs((prev) => { const next = [...prev]; next[index] = { @@ -79,6 +82,14 @@ export default function ColumnMapperStep({ }); }; + const patchAttachment = (index: number, patch: Partial) => { + setColumnConfigs((prev) => { + const next = [...prev]; + next[index] = { ...prev[index], role: "attachment", ...patch }; + return next; + }); + }; + const handleNext = () => { const textColumns: string[] = []; const attachments: Attachment[] = []; @@ -94,11 +105,28 @@ export default function ColumnMapperStep({ config.attachmentType && config.attachmentFormat ) { - attachments.push({ + 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); } }); @@ -212,46 +240,126 @@ export default function ColumnMapperStep({ {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. + + + +
+ )} + + )} + )} @@ -288,7 +396,7 @@ export default function ColumnMapperStep({ disabled={!hasText} className="!rounded-lg" > - Next: Prompt Editor + Next: Eliminatory diff --git a/app/components/assessment/ConfigPanel.tsx b/app/components/assessment/ConfigPanel.tsx index ea2ac359..2a8b4e2e 100644 --- a/app/components/assessment/ConfigPanel.tsx +++ b/app/components/assessment/ConfigPanel.tsx @@ -1,11 +1,13 @@ "use client"; -// Multi-step wizard (Column Mapper → Prompt & Config → Review) for configuring an assessment run. +// Multi-step wizard (Mapper → Eliminatory → Evaluation → Post Processing → Review) import { Button } from "@/app/components"; 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 PrefilterStep from "./PrefilterStep"; +import PostProcessingStep from "./PostProcessingStep"; import PromptAndConfigStep from "./PromptAndConfigStep"; import ReviewStep from "./ReviewStep"; import Stepper from "./Stepper"; @@ -17,19 +19,24 @@ export default function ConfigPanel({ completedSteps, configStep, configs, + datasetId, experimentName, formState, hasDataset, isSubmitting, + prefilterConfig, outputSchema, systemInstruction, promptTemplate, + postProcessingConfig, + setPostProcessingConfig, sampleRow, setActiveTabToDatasets, setColumnMapping, setConfigStep, setConfigs, setExperimentName, + setPrefilterConfig, setOutputSchema, setSystemInstruction, setPromptTemplate, @@ -82,10 +89,27 @@ export default function ConfigPanel({ onBack={setActiveTabToDatasets} /> +
+ a.column)} + prefilterConfig={prefilterConfig} + setPrefilterConfig={setPrefilterConfig} + onNext={() => onStepComplete(2)} + onBack={() => setConfigStep(1)} + /> +
+ +
onStepComplete(2)} - onBack={() => setConfigStep(1)} + onNext={() => onStepComplete(3)} + onBack={() => setConfigStep(2)} />
+
+ onStepComplete(4)} + onBack={() => setConfigStep(3)} + /> +
+ +
setConfigStep(2)} + onBack={() => setConfigStep(4)} onEditStep={setConfigStep} />
diff --git a/app/components/assessment/DownloadDropdown.tsx b/app/components/assessment/DownloadDropdown.tsx index de2d455f..ec28398c 100644 --- a/app/components/assessment/DownloadDropdown.tsx +++ b/app/components/assessment/DownloadDropdown.tsx @@ -1,9 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Button } from "@/app/components"; import { ChevronDownIcon } from "@/app/components/icons"; import DownloadIcon from "@/app/components/icons/assessment/DownloadIcon"; +import LoadingSpinner from "@/app/components/assessment/LoadingSpinner"; import type { ExportFormat } from "@/app/lib/types/assessment"; interface DownloadDropdownProps { @@ -12,7 +14,7 @@ interface DownloadDropdownProps { loading?: boolean; } -import LoadingSpinner from "@/app/components/assessment/LoadingSpinner"; +const MENU_WIDTH = 144; export default function DownloadDropdown({ onDownload, @@ -20,19 +22,39 @@ export default function DownloadDropdown({ loading, }: DownloadDropdownProps) { const [open, setOpen] = useState(false); - const ref = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [rect, setRect] = useState<{ top: number; left: number } | null>(null); + + // Position the portal menu under the trigger, right-aligned. + useLayoutEffect(() => { + if (!open || !triggerRef.current) return; + const r = triggerRef.current.getBoundingClientRect(); + setRect({ top: r.bottom + 4, left: r.right - MENU_WIDTH }); + }, [open]); useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) + if (!open) return; + const handlePointer = (e: MouseEvent) => { + const t = e.target as Node; + if (!triggerRef.current?.contains(t) && !menuRef.current?.contains(t)) { setOpen(false); - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); + } + }; + // Fixed menu can't follow scroll — close instead of drift. + const handleScrollOrResize = () => setOpen(false); + document.addEventListener("mousedown", handlePointer); + window.addEventListener("scroll", handleScrollOrResize, true); + window.addEventListener("resize", handleScrollOrResize); + return () => { + document.removeEventListener("mousedown", handlePointer); + window.removeEventListener("scroll", handleScrollOrResize, true); + window.removeEventListener("resize", handleScrollOrResize); + }; + }, [open]); return ( -
+
- {open && ( -
- {( - [ - ["csv", "CSV File"], - ["xlsx", "Excel Sheet"], - ] as const - ).map(([fmt, label]) => ( - - ))} -
- )} + {open && + rect && + typeof document !== "undefined" && + createPortal( +
+ {( + [ + ["csv", "CSV File"], + ["xlsx", "Excel Sheet"], + ] as const + ).map(([fmt, label]) => ( + + ))} +
, + document.body, + )}
); } diff --git a/app/components/assessment/EvaluationsTab.tsx b/app/components/assessment/EvaluationsTab.tsx index a25cfd88..79a26484 100644 --- a/app/components/assessment/EvaluationsTab.tsx +++ b/app/components/assessment/EvaluationsTab.tsx @@ -1,6 +1,7 @@ "use client"; // Assessment Evaluations tab — shows run cards with status, retry, and CSV export. +import { Fragment } from "react"; import { Button, RunsListSkeleton } from "@/app/components"; import Select from "@/app/components/Select"; import { useToast } from "@/app/components/Toast"; @@ -10,12 +11,14 @@ import { EyeIcon, RefreshIcon, } from "@/app/components/icons"; -import SpreadsheetModal from "./SpreadsheetModal"; import DownloadDropdown from "./DownloadDropdown"; +import PostProcessingPanel from "./PostProcessingPanel"; import { canRetryStatus, formatStatusLabel, getResultTone, + getStageProgress, + hasViewableResults, isCompletedStatus, isFailedStatus, } from "@/app/lib/assessment/results"; @@ -25,11 +28,12 @@ import { STATUS_FILTER_OPTIONS, } from "@/app/lib/assessment/constants"; import { formatRelativeTime } from "@/app/lib/utils"; -import type { EvaluationsTabProps } from "@/app/lib/types/assessment"; +import type { + EvaluationsTabProps, + PostProcessingConfig, +} from "@/app/lib/types/assessment"; import useAssessmentResults from "@/app/hooks/useAssessmentResults"; -import LoadingSpinner from "@/app/components/assessment/LoadingSpinner"; - export default function EvaluationsTab({ onForbidden }: EvaluationsTabProps) { const toast = useToast(); const { @@ -43,19 +47,20 @@ export default function EvaluationsTab({ onForbidden }: EvaluationsTabProps) { statusFilter, setStatusFilter, rerunningId, + resumingId, retryingAssessmentId, expandedId, downloadingId, - previewLoading, - previewModal, - setPreviewModal, loadAssessments, handleExpand, handleRetryAssessment, handleRerun, + handleResume, handlePreview, handleAssessmentDownload, handleRunDownload, + handleSavePostProcessing, + handleFetchRunColumns, } = useAssessmentResults({ onForbidden, toast }); return ( @@ -236,7 +241,12 @@ export default function EvaluationsTab({ onForbidden }: EvaluationsTabProps) { const isCompletedChild = isCompletedStatus( childRun.status, ); + const stageProgress = + getStageProgress(childRun); + const canPreview = hasViewableResults(childRun); const isRerunning = rerunningId === childRun.id; + const isResuming = resumingId === childRun.id; + const canResume = Boolean(childRun.stage); const configKey = childRun.config_id && childRun.config_version ? `${childRun.config_id}:${childRun.config_version}` @@ -258,135 +268,247 @@ export default function EvaluationsTab({ onForbidden }: EvaluationsTabProps) { const previewLabel = `${configName}${childRun.config_version ? ` v${childRun.config_version}` : ""}`; return ( -
-
-
-
- - {configName} - - {childRun.config_version !== null && ( - - v{childRun.config_version} +
+
+
+
+
+ + {configName} - )} - {configDetail?.provider && - configDetail?.model && ( - - {configDetail.provider}/ - {configDetail.model} + {childRun.config_version !== + null && ( + + v{childRun.config_version} )} -
+ {configDetail?.provider && + configDetail?.model && ( + + {configDetail.provider}/ + {configDetail.model} + + )} +
-
- {isConfigLoading - ? "Loading configuration details..." - : configDetail?.description || - configDetail?.commitMessage || - "No description available for this configuration."} -
+
+ {isConfigLoading + ? "Loading configuration details..." + : configDetail?.description || + configDetail?.commitMessage || + "No description available for this configuration."} +
-
- - {childRun.total_items} items - - {childRun.updated_at && ( +
- {formatRelativeTime( - childRun.updated_at, - )} + {childRun.total_items} items + {childRun.updated_at && ( + + {formatRelativeTime( + childRun.updated_at, + )} + + )} + {childRun.config_id && ( + + ID{" "} + {childRun.config_id.slice(0, 8)} + + )} +
+ {childRun.prefilter_total_rows != + null && ( +
+ Prefilter:{" "} + {childRun.prefilter_total_passed ?? + 0} + /{childRun.prefilter_total_rows}{" "} + passed + {childRun.prefilter_total_rejected != + null && + childRun.prefilter_total_rejected > + 0 && ( + + ·{" "} + { + childRun.prefilter_total_rejected + }{" "} + rejected + + )} +
)} - {childRun.config_id && ( - - ID{" "} - {childRun.config_id.slice(0, 8)} - + + {stageProgress.length > 0 && ( +
+ {stageProgress.map((s, i) => { + const done = + s.status === "completed"; + const failed = + s.status === "failed"; + const active = + s.status === "processing"; + const nodeClass = done + ? "bg-status-success border-status-success text-white" + : failed + ? "bg-status-error border-status-error text-white" + : active + ? "border-status-warning text-status-warning-text" + : "border-border text-text-secondary"; + const labelClass = done + ? "text-text-primary" + : active + ? "text-status-warning-text" + : failed + ? "text-status-error-text" + : "text-text-secondary"; + const prevDone = + i > 0 && + stageProgress[i - 1] + .status === "completed"; + return ( + + {i > 0 && ( +
+ )} +
+ + {done + ? "✓" + : failed + ? "✗" + : active + ? "●" + : ""} + + + {s.label} + +
+ + ); + })} +
)} -
- {configError && ( -
- {configError} -
- )} - {isFailedChild && - childRun.error_message && ( + {configError && (
- {childRun.error_message} + {configError}
)} -
+ {isFailedChild && + childRun.error_message && ( +
+ {childRun.error_message} +
+ )} +
-
- - {formatStatusLabel(childRun.status)} - - {isCompletedChild && ( - - )} - {isCompletedChild && ( - - handleRunDownload( - childRun.id, - fmt, - ) - } - loading={ - downloadingId === - `run-${childRun.id}` - } - /> - )} - {isFailedChild && ( - - )} + Preview + + )} + {isCompletedChild && ( + + handleRunDownload( + childRun.id, + fmt, + ) + } + loading={ + downloadingId === + `run-${childRun.id}` + } + /> + )} + {isFailedChild && canResume && ( + + )} + {isFailedChild && ( + + )} +
+ {isCompletedChild && ( + + handleFetchRunColumns(childRun.id) + } + onSave={async ( + cfg: PostProcessingConfig, + ) => { + await handleSavePostProcessing( + childRun.id, + cfg, + ); + toast.success( + "Post-processing saved. Re-open preview to see updated results.", + ); + }} + /> + )}
); }) @@ -410,17 +532,6 @@ export default function EvaluationsTab({ onForbidden }: EvaluationsTabProps) { ))}
- - {previewModal && ( - setPreviewModal(null)} - /> - )}
); } diff --git a/app/components/assessment/PostProcessingPanel.tsx b/app/components/assessment/PostProcessingPanel.tsx new file mode 100644 index 00000000..b8856418 --- /dev/null +++ b/app/components/assessment/PostProcessingPanel.tsx @@ -0,0 +1,434 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/app/components"; +import { ChevronDownIcon, CloseIcon } from "@/app/components/icons"; +import FormulaInput from "@/app/components/shared/FormulaInput"; +import { + POST_PROCESSING_FILTER_OPS, + POST_PROCESSING_NO_VALUE_OPS, + emptyPostProcessingConfig, +} from "@/app/lib/assessment/constants"; +import type { + PostProcessingConfig, + PostProcessingComputedColumn, + PostProcessingFilterRule, + PostProcessingPanelProps, + PostProcessingSortRule, +} from "@/app/lib/types/assessment"; + +export default function PostProcessingPanel({ + availableColumns: seedColumns, + fetchColumns, + initialConfig, + onSave, +}: PostProcessingPanelProps) { + const [config, setConfig] = useState( + initialConfig ?? emptyPostProcessingConfig(), + ); + const [saving, setSaving] = useState(false); + const [open, setOpen] = useState(false); + const [fetchedColumns, setFetchedColumns] = useState(null); + const availableColumns = fetchedColumns ?? seedColumns; + + // Fetch columns once when panel first opens + useEffect(() => { + if (!open || fetchedColumns !== null || !fetchColumns) return; + fetchColumns() + .then(setFetchedColumns) + .catch(() => setFetchedColumns([])); + }, [fetchColumns, fetchedColumns, open]); + + // computed columns include user-defined ones for formula autocomplete + const allColumns = [ + ...availableColumns, + ...config.computed_columns.map((c) => c.name).filter(Boolean), + ]; + + const addComputedColumn = () => { + setConfig((prev) => ({ + ...prev, + computed_columns: [...prev.computed_columns, { name: "", formula: "" }], + })); + }; + + const updateComputedColumn = ( + i: number, + patch: Partial, + ) => { + setConfig((prev) => { + const next = [...prev.computed_columns]; + next[i] = { ...next[i], ...patch }; + return { ...prev, computed_columns: next }; + }); + }; + + const removeComputedColumn = (i: number) => { + setConfig((prev) => ({ + ...prev, + computed_columns: prev.computed_columns.filter((_, idx) => idx !== i), + })); + }; + + const addSort = () => { + setConfig((prev) => ({ + ...prev, + sort: [...prev.sort, { column: allColumns[0] ?? "", direction: "desc" }], + })); + }; + + const updateSort = (i: number, patch: Partial) => { + setConfig((prev) => { + const next = [...prev.sort]; + next[i] = { ...next[i], ...patch }; + return { ...prev, sort: next }; + }); + }; + + const removeSort = (i: number) => { + setConfig((prev) => ({ + ...prev, + sort: prev.sort.filter((_, idx) => idx !== i), + })); + }; + + const addFilter = () => { + setConfig((prev) => ({ + ...prev, + filter: [ + ...prev.filter, + { column: allColumns[0] ?? "", op: "eq", value: "" }, + ], + })); + }; + + const updateFilter = ( + i: number, + patch: Partial, + ) => { + setConfig((prev) => { + const next = [...prev.filter]; + next[i] = { ...next[i], ...patch } as PostProcessingFilterRule; + return { ...prev, filter: next }; + }); + }; + + const removeFilter = (i: number) => { + setConfig((prev) => ({ + ...prev, + filter: prev.filter.filter((_, idx) => idx !== i), + })); + }; + + const handleSave = async () => { + setSaving(true); + try { + await onSave(config); + } finally { + setSaving(false); + } + }; + + const hasRules = + config.computed_columns.length > 0 || + config.sort.length > 0 || + config.filter.length > 0; + + return ( +
+ + + {open && ( +
+
+

+ Available columns — type{" "} + + @ + {" "} + in formula to reference +

+
+ {availableColumns.map((col) => ( + + {col} + + ))} +
+
+ +
+
+ + Computed columns + + +
+ + {config.computed_columns.length === 0 && ( +

+ No computed columns. Add one to create formulas like{" "} + + @Novelty_score + @Feasibility_score + + . +

+ )} + +
+ {config.computed_columns.map((col, i) => ( +
+
+ + updateComputedColumn(i, { name: e.target.value }) + } + placeholder="Column name" + className="w-full rounded-lg border border-border bg-bg-primary px-3 py-2 text-sm text-text-primary outline-none focus:border-accent-primary sm:w-36 flex-shrink-0" + /> +
+ + updateComputedColumn(i, { formula: v }) + } + columns={allColumns} + placeholder="@Novelty_score + @Feasibility_score" + /> +
+
+ +
+ ))} +
+
+ +
+
+ + Filter{" "} + + (AND logic) + + + +
+ + {config.filter.length === 0 && ( +

+ No filters — all rows included. +

+ )} + +
+ {config.filter.map((rule, i) => ( +
+ + + + + {!POST_PROCESSING_NO_VALUE_OPS.has(rule.op) && ( + + updateFilter(i, { value: e.target.value }) + } + placeholder="value" + className="w-28 flex-shrink-0 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-sm text-text-primary outline-none focus:border-accent-primary" + /> + )} + + +
+ ))} +
+
+ +
+
+ + Sort{" "} + + (priority order) + + + +
+ + {config.sort.length === 0 && ( +

+ No sort rules — original row order preserved. +

+ )} + +
+ {config.sort.map((rule, i) => ( +
+ + {i + 1} + + + + +
+ {(["asc", "desc"] as const).map((d) => ( + + ))} +
+ + +
+ ))} +
+
+ +
+ {hasRules && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/app/components/assessment/PostProcessingStep.tsx b/app/components/assessment/PostProcessingStep.tsx new file mode 100644 index 00000000..bf2e227f --- /dev/null +++ b/app/components/assessment/PostProcessingStep.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { Button } from "@/app/components"; +import { CloseIcon } from "@/app/components/icons"; +import FormulaInput from "@/app/components/shared/FormulaInput"; +import { + POST_PROCESSING_FILTER_OPS, + POST_PROCESSING_NO_VALUE_OPS, + emptyPostProcessingConfig, +} from "@/app/lib/assessment/constants"; +import type { + PostProcessingConfig, + PostProcessingFilterRule, + PostProcessingComputedColumn, + PostProcessingSortRule, + PostProcessingStepProps, +} from "@/app/lib/types/assessment"; + +export default function PostProcessingStep({ + postProcessingConfig, + setPostProcessingConfig, + columnMapping, + outputSchema, + onNext, + onBack, +}: PostProcessingStepProps) { + const config = postProcessingConfig ?? emptyPostProcessingConfig(); + + const update = (patch: Partial) => { + const next = { ...config, ...patch }; + // If all sections empty, store null (no post-processing) + const isEmpty = + next.computed_columns.length === 0 && + next.sort.length === 0 && + next.filter.length === 0; + setPostProcessingConfig(isEmpty ? null : next); + }; + + // Derive available columns: input columns + L2 output schema fields + fixed L1 fields + const inputCols = columnMapping.textColumns; + const outputCols = outputSchema.map((f) => f.name).filter(Boolean); + const prefilterCols = [ + "topic_relevance_decision", + "topic_relevance_reasoning", + "duplicate_detection_verdict", + "duplicate_detection_reason", + "duplicate_detection_match_title", + ]; + const availableCols = [...inputCols, ...outputCols, ...prefilterCols]; + + // Computed columns available for formula autocomplete (includes user-defined ones) + const allCols = [ + ...availableCols, + ...config.computed_columns.map((c) => c.name).filter(Boolean), + ]; + + const addComputed = () => + update({ + computed_columns: [...config.computed_columns, { name: "", formula: "" }], + }); + + const updateComputed = ( + i: number, + patch: Partial, + ) => { + const next = [...config.computed_columns]; + next[i] = { ...next[i], ...patch }; + update({ computed_columns: next }); + }; + + const removeComputed = (i: number) => + update({ + computed_columns: config.computed_columns.filter((_, idx) => idx !== i), + }); + + const addFilter = () => + update({ + filter: [ + ...config.filter, + { column: allCols[0] ?? "", op: "eq", value: "" }, + ], + }); + + const updateFilter = ( + i: number, + patch: Partial, + ) => { + const next = [...config.filter]; + next[i] = { ...next[i], ...patch } as PostProcessingFilterRule; + update({ filter: next }); + }; + + const removeFilter = (i: number) => + update({ filter: config.filter.filter((_, idx) => idx !== i) }); + + const addSort = () => + update({ + sort: [...config.sort, { column: allCols[0] ?? "", direction: "desc" }], + }); + + const updateSort = (i: number, patch: Partial) => { + const next = [...config.sort]; + next[i] = { ...next[i], ...patch }; + update({ sort: next }); + }; + + const removeSort = (i: number) => + update({ sort: config.sort.filter((_, idx) => idx !== i) }); + + return ( +
+
+
+

+ Post Processing +

+

+ Optional. Define computed columns, filters, and sort rules applied + to results at export time. This step is optional — click Next to + skip. +

+
+ +
+

+ Available columns — type{" "} + @{" "} + in formulas to reference +

+
+ {availableCols.map((col) => ( + + {col} + + ))} +
+
+ +
+
+
+
+ Computed columns +
+
+ Create new columns using formulas like{" "} + + @Novelty_score + @Feasibility_score + +
+
+ +
+ + {config.computed_columns.length > 0 && ( +
+ {config.computed_columns.map((col, i) => ( +
+
+ + + updateComputed(i, { name: e.target.value }) + } + placeholder="e.g. Total_score" + className="w-full rounded-lg border border-border bg-bg-primary px-3 py-2 text-sm text-text-primary outline-none focus:border-accent-primary" + /> +
+ + = + +
+ updateComputed(i, { formula: v })} + columns={allCols} + placeholder="@Novelty_score + @Feasibility_score" + /> +
+ +
+ ))} +
+ )} +
+ +
+
+
+
+ Filter +
+
+ AND logic — all rules must match +
+
+ +
+ + {config.filter.length > 0 && ( +
+ {config.filter.map((rule, i) => ( +
+ + + + + {!POST_PROCESSING_NO_VALUE_OPS.has(rule.op) && ( + + updateFilter(i, { value: e.target.value }) + } + placeholder="value" + className="w-28 flex-shrink-0 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-sm text-text-primary outline-none focus:border-accent-primary" + /> + )} + + +
+ ))} +
+ )} +
+ +
+
+
+
+ Sort +
+
+ Priority order — first rule wins +
+
+ +
+ + {config.sort.length > 0 && ( +
+ {config.sort.map((rule, i) => ( +
+ + {i + 1} + + + + +
+ {(["asc", "desc"] as const).map((d) => ( + + ))} +
+ + +
+ ))} +
+ )} +
+
+ +
+
+ +
+ + {postProcessingConfig + ? "Post-processing configured." + : "Optional — skip to continue."} + + +
+
+
+
+ ); +} diff --git a/app/components/assessment/PrefilterStep.tsx b/app/components/assessment/PrefilterStep.tsx new file mode 100644 index 00000000..45ae1b67 --- /dev/null +++ b/app/components/assessment/PrefilterStep.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/app/components"; +import Modal from "@/app/components/Modal"; +import { ExpandIcon } from "@/app/components/icons"; +import CompactToggleSwitch from "@/app/components/assessment/CompactToggleSwitch"; +import { DEFAULT_PREFILTER_TOPIC_RELEVANCE_PROMPT } from "@/app/lib/assessment/constants"; +import type { + PrefilterConfig, + PrefilterStepProps, +} from "@/app/lib/types/assessment"; + +function ColumnChips({ + columns, + selected, + onChange, +}: { + columns: string[]; + selected: string[]; + onChange: (cols: string[]) => void; +}) { + const toggle = (col: string) => { + onChange( + selected.includes(col) + ? selected.filter((c) => c !== col) + : [...selected, col], + ); + }; + + return ( +
+ {columns.map((col) => { + const active = selected.includes(col); + return ( + + ); + })} + {columns.length === 0 && ( + + No columns available. Map columns in the previous step. + + )} +
+ ); +} + +export default function PrefilterStep({ + columns, + attachmentColumns = [], + prefilterConfig, + setPrefilterConfig, + onNext, + onBack, +}: PrefilterStepProps) { + const [trEnabled, setTrEnabled] = useState( + () => !!prefilterConfig?.topic_relevance, + ); + const [dupEnabled, setDupEnabled] = useState( + () => !!prefilterConfig?.duplicate_detection, + ); + const [trColumns, setTrColumns] = useState( + () => prefilterConfig?.topic_relevance?.columns ?? [], + ); + const [trAttachmentColumns, setTrAttachmentColumns] = useState( + () => + prefilterConfig?.topic_relevance?.attachment_columns ?? attachmentColumns, + ); + const [trPrompt, setTrPrompt] = useState( + () => + prefilterConfig?.topic_relevance?.prompt ?? + DEFAULT_PREFILTER_TOPIC_RELEVANCE_PROMPT, + ); + const [dupColumns, setDupColumns] = useState( + () => prefilterConfig?.duplicate_detection?.columns ?? [], + ); + const [isPromptModalOpen, setIsPromptModalOpen] = useState(false); + + const handleNext = () => { + const config: PrefilterConfig = {}; + if (trEnabled && trColumns.length > 0 && trPrompt.trim()) { + config.topic_relevance = { + columns: trColumns, + prompt: trPrompt.trim(), + ...(trAttachmentColumns.length > 0 + ? { attachment_columns: trAttachmentColumns } + : {}), + }; + } + if (dupEnabled && dupColumns.length > 0) { + config.duplicate_detection = { columns: dupColumns }; + } + setPrefilterConfig(Object.keys(config).length > 0 ? config : null); + onNext(); + }; + + const trValid = !trEnabled || (trColumns.length > 0 && !!trPrompt.trim()); + const dupValid = !dupEnabled || dupColumns.length > 0; + const canProceed = trValid && dupValid; + + return ( +
+
+
+

+ Eliminatory +

+

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

+
+ +
+
+
+
+ Topic Relevance +
+
+ Gate: rows with decision=REJECT are excluded from Evaluation. +
+
+ setTrEnabled((v) => !v)} + title="Enable Topic Relevance" + /> +
+ + {trEnabled && ( +
+
+ + + {trColumns.length === 0 && ( +

+ Select at least one column. +

+ )} + {trColumns.length > 0 && ( +

+ Export will include:{" "} + {trColumns + .map((c) => ( + + topic_relevance_{c} + + )) + .reduce((acc, el, i) => { + if (i === 0) return [el]; + return [...acc, ", ", el]; + }, [])}{" "} + (true/false per column) +

+ )} +
+ + {attachmentColumns.length > 0 && ( +
+ + +

+ {trAttachmentColumns.length > 0 + ? "Selected documents are sent to the model — Topic Relevance is judged on text and these documents." + : "No documents selected — Topic Relevance uses text columns only."} +

+
+ )} + +
+
+ + Evaluation prompt / rubric + * + + +
+