From 854cfbbf63c7741a1aac2f88b35d7233aad0d725 Mon Sep 17 00:00:00 2001 From: Forrest Murray Date: Fri, 10 Apr 2026 10:49:32 -0400 Subject: [PATCH 01/84] feat: fetch available models from Databricks instead of hardcoded list Replace the hardcoded MODEL_MAPPING with a live API call to Databricks serving-endpoints. The backend uses async httpx to avoid blocking the event loop, and the frontend fetches models via useAvailableModels and builds options dynamically with buildModelOptions. All components now store and pass endpoint names directly instead of translating between display names and backend names. Also switches model prefetching from an eager useEffect in WorkflowContext to intent-based prefetchQuery on hover/focus of navigation buttons, and clears Databricks auth env vars that can override token auth in the MLflow intake service. Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/client/services/ApiService.ts | 21 + .../src/client/services/WorkshopsService.ts | 21 + client/src/components/AnnotationStartPage.tsx | 12 +- .../src/components/DiscoveryAnalysisTab.tsx | 26 +- .../DiscoveryStartPage.modelSelector.test.tsx | 35 +- client/src/components/DiscoveryStartPage.tsx | 26 +- .../src/components/FacilitatorDashboard.tsx | 19 +- client/src/components/GeneralDashboard.tsx | 12 +- client/src/components/RoleBasedWorkflow.tsx | 9 +- .../src/components/RubricSuggestionPanel.tsx | 13 +- .../FacilitatorDiscoveryWorkspace.tsx | 21 +- client/src/hooks/useWorkshopApi.ts | 35 + client/src/pages/IRRResultsDemo.tsx | 2 +- client/src/pages/JudgeTuningPage.tsx | 78 +- client/src/utils/modelMapping.test.ts | 28 +- client/src/utils/modelMapping.ts | 165 +- server/routers/workshops.py | 44 + server/services/databricks_service.py | 51 +- server/services/mlflow_intake_service.py | 4 +- uv.lock | 7341 ++++++++--------- 20 files changed, 4007 insertions(+), 3956 deletions(-) diff --git a/client/src/client/services/ApiService.ts b/client/src/client/services/ApiService.ts index 6fc74466..a67110f1 100644 --- a/client/src/client/services/ApiService.ts +++ b/client/src/client/services/ApiService.ts @@ -1772,6 +1772,27 @@ export class ApiService { }, }); } + /** + * List Available Models + * List available model serving endpoints for a workshop's Databricks workspace. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static listAvailableModelsWorkshopsWorkshopIdAvailableModelsGet( + workshopId: string, + ): CancelablePromise>> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/available-models', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } /** * Get Mlflow Intake Status * Get MLflow intake status for a workshop. diff --git a/client/src/client/services/WorkshopsService.ts b/client/src/client/services/WorkshopsService.ts index 7eb8b70e..78c8eb40 100644 --- a/client/src/client/services/WorkshopsService.ts +++ b/client/src/client/services/WorkshopsService.ts @@ -1579,6 +1579,27 @@ export class WorkshopsService { }, }); } + /** + * List Available Models + * List available model serving endpoints for a workshop's Databricks workspace. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static listAvailableModelsWorkshopsWorkshopIdAvailableModelsGet( + workshopId: string, + ): CancelablePromise>> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/available-models', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } /** * Get Mlflow Intake Status * Get MLflow intake status for a workshop. diff --git a/client/src/components/AnnotationStartPage.tsx b/client/src/components/AnnotationStartPage.tsx index 3e411eff..72ab60a1 100644 --- a/client/src/components/AnnotationStartPage.tsx +++ b/client/src/components/AnnotationStartPage.tsx @@ -8,13 +8,13 @@ import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useQueryClient } from '@tanstack/react-query'; import { useWorkshopContext } from '@/context/WorkshopContext'; -import { useRubric, useAllTraces } from '@/hooks/useWorkshopApi'; +import { useRubric, useAllTraces, useAvailableModels } from '@/hooks/useWorkshopApi'; import { WorkshopsService } from '@/client'; import { Play, Users, Star, ClipboardList, CheckCircle, Settings, Database, Scale, Binary, MessageSquareText, Shuffle, Brain, Lightbulb } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; import { parseRubricQuestions } from '@/utils/rubricUtils'; -import { MODEL_MAPPING } from '@/utils/modelMapping'; +import { buildModelOptions } from '@/utils/modelMapping'; interface AnnotationStartPageProps { onStartAnnotation?: () => void; @@ -31,6 +31,8 @@ export const AnnotationStartPage: React.FC = ({ onStar const [autoEvaluateEnabled, setAutoEvaluateEnabled] = React.useState(true); const { data: rubric } = useRubric(workshopId!); const { data: traces } = useAllTraces(workshopId!); + const { data: availableModels } = useAvailableModels(workshopId!); + const modelOptions = React.useMemo(() => availableModels ? buildModelOptions(availableModels) : [], [availableModels]); const totalTraces = traces?.length || 0; const rubricQuestions = rubric ? parseRubricQuestions(rubric.question) : []; @@ -260,9 +262,9 @@ export const AnnotationStartPage: React.FC = ({ onStar - {Object.entries(MODEL_MAPPING).map(([displayName, endpointName]) => ( - - {displayName} + {modelOptions.map((option) => ( + + {option.label} ))} diff --git a/client/src/components/DiscoveryAnalysisTab.tsx b/client/src/components/DiscoveryAnalysisTab.tsx index e37a344d..801f2b35 100644 --- a/client/src/components/DiscoveryAnalysisTab.tsx +++ b/client/src/components/DiscoveryAnalysisTab.tsx @@ -5,7 +5,7 @@ * view findings, disagreements, and previous analysis history. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -25,14 +25,14 @@ import { Loader2, ArrowUpRight, } from 'lucide-react'; -import { getModelOptions, getBackendModelName } from '@/utils/modelMapping'; +import { buildModelOptions } from '@/utils/modelMapping'; import { useDiscoveryAnalyses, useRunDiscoveryAnalysis, useCreateDraftRubricItem, + useAvailableModels, type DiscoveryAnalysis, } from '@/hooks/useWorkshopApi'; -import { useQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; interface DiscoveryAnalysisTabProps { @@ -42,22 +42,11 @@ interface DiscoveryAnalysisTabProps { export const DiscoveryAnalysisTab: React.FC = ({ workshopId, userId }) => { const [template, setTemplate] = useState('evaluation_criteria'); - const [modelName, setModelName] = useState('Claude Sonnet 4.5'); + const [modelName, setModelName] = useState(''); const [selectedAnalysisId, setSelectedAnalysisId] = useState(null); - // Check if Databricks is configured (mlflow config exists) - const { data: mlflowConfig } = useQuery({ - queryKey: ['mlflowConfig', workshopId], - queryFn: async () => { - const response = await fetch(`/workshops/${workshopId}/mlflow-config`); - if (!response.ok) return null; - return response.json(); - }, - enabled: !!workshopId, - }); - - const hasMlflowConfig = !!mlflowConfig; - const modelOptions = getModelOptions(hasMlflowConfig); + const { data: availableModels } = useAvailableModels(workshopId); + const modelOptions = useMemo(() => availableModels ? buildModelOptions(availableModels) : [], [availableModels]); const { data: analyses, isLoading: analysesLoading } = useDiscoveryAnalyses(workshopId); const runAnalysis = useRunDiscoveryAnalysis(workshopId); @@ -68,9 +57,8 @@ export const DiscoveryAnalysisTab: React.FC = ({ work : analyses?.[0] ?? null; const handleRunAnalysis = () => { - const backendModel = getBackendModelName(modelName); runAnalysis.mutate( - { template, model: backendModel }, + { template, model: modelName || modelOptions[0]?.value || '' }, { onSuccess: (result) => { toast.success('Analysis completed successfully'); diff --git a/client/src/components/DiscoveryStartPage.modelSelector.test.tsx b/client/src/components/DiscoveryStartPage.modelSelector.test.tsx index 047d393d..3a7485d5 100644 --- a/client/src/components/DiscoveryStartPage.modelSelector.test.tsx +++ b/client/src/components/DiscoveryStartPage.modelSelector.test.tsx @@ -3,7 +3,6 @@ import { describe, expect, it, vi, beforeEach, beforeAll } from 'vitest'; import { render, screen } from '@testing-library/react'; import { DiscoveryStartPage } from './DiscoveryStartPage'; -import { getModelOptions } from '@/utils/modelMapping'; // Polyfill pointer-capture and scrollIntoView for Radix UI in jsdom beforeAll(() => { @@ -20,6 +19,10 @@ beforeAll(() => { // --- mock return values --------------------------------------------------- const mockWorkshop = { data: { discovery_questions_model_name: null } as Record | undefined }; +const mockAvailableModels = { data: [ + { name: 'databricks-claude-opus-4-5', state: 'READY', task: 'llm/v1/chat' }, + { name: 'databricks-gpt-5-1', state: 'READY', task: 'llm/v1/chat' }, +] as Array<{ name: string; state: string; task: string }> | undefined }; const mockMlflowConfig = { data: null as Record | null | undefined }; const mockUpdateModel = { mutate: vi.fn() }; const mockAllTraces = { data: [] as unknown[] }; @@ -27,6 +30,7 @@ const mockAllTraces = { data: [] as unknown[] }; vi.mock('@/hooks/useWorkshopApi', () => ({ useWorkshop: () => mockWorkshop, useMLflowConfig: () => mockMlflowConfig, + useAvailableModels: () => mockAvailableModels, useUpdateDiscoveryModel: () => mockUpdateModel, useAllTraces: () => mockAllTraces, })); @@ -75,46 +79,23 @@ describe('@spec:DISCOVERY_SPEC Model selector on DiscoveryStartPage', () => { }); it('calls mutate via useUpdateDiscoveryModel on model change', () => { - // Verify the handleModelChange logic indirectly: the component passes - // handleModelChange as the onValueChange callback to the Select. - // We verify the mutation function exists and is wired up by confirming - // the component uses the mock. We also verify the backend model name - // mapping is correct since handleModelChange calls getBackendModelName. mockMlflowConfig.data = { id: 'cfg-1' }; render(); - // The trigger renders, confirming the component is wired with useUpdateDiscoveryModel const trigger = screen.getByTestId('model-selector'); expect(trigger).toBeInTheDocument(); - // Simulate what handleModelChange does for a Databricks model selection: - // It calls updateModelMutation.mutate({ model_name: getBackendModelName(value) }) - // We call mutate directly to verify the mock is correctly set up. + // Simulate what handleModelChange does — it passes the value directly mockUpdateModel.mutate({ model_name: 'databricks-claude-opus-4-5' }); expect(mockUpdateModel.mutate).toHaveBeenCalledWith({ model_name: 'databricks-claude-opus-4-5', }); }); - it('disables Databricks models when no mlflow config', () => { - // When mlflowConfig is null, getModelOptions(false) marks all Databricks - // models as disabled. The component passes !!mlflowConfig to getModelOptions. - mockMlflowConfig.data = null; - - // Verify via getModelOptions that all model options are disabled when there's no config - const options = getModelOptions(false); - for (const option of options) { - expect(option.disabled).toBe(true); - } - - // Verify they become enabled when mlflow config exists - const enabledOptions = getModelOptions(true); - for (const option of enabledOptions) { - expect(option.disabled).toBe(false); - } + it('shows no Databricks models when available-models returns empty', () => { + mockAvailableModels.data = undefined; - // Render to confirm the component uses mlflowConfig correctly render(); const trigger = screen.getByTestId('model-selector'); expect(trigger).toBeInTheDocument(); diff --git a/client/src/components/DiscoveryStartPage.tsx b/client/src/components/DiscoveryStartPage.tsx index 0c2b9256..026e0f80 100644 --- a/client/src/components/DiscoveryStartPage.tsx +++ b/client/src/components/DiscoveryStartPage.tsx @@ -10,8 +10,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { useWorkshopContext } from '@/context/WorkshopContext'; import { useWorkflowContext } from '@/context/WorkflowContext'; import { WorkshopsService } from '@/client'; -import { useAllTraces, useWorkshop, useMLflowConfig, useUpdateDiscoveryModel } from '@/hooks/useWorkshopApi'; -import { getModelOptions, getBackendModelName, getFrontendModelName } from '@/utils/modelMapping'; +import { useAllTraces, useWorkshop, useMLflowConfig, useUpdateDiscoveryModel, useAvailableModels } from '@/hooks/useWorkshopApi'; +import { buildModelOptions, getDisplayName } from '@/utils/modelMapping'; import { Play, Users, Search, Lightbulb, Database, Settings, Shuffle, Brain } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { toast } from 'sonner'; @@ -35,15 +35,17 @@ export const DiscoveryStartPage: React.FC = ({ onStartD // Model selection const { data: workshop } = useWorkshop(workshopId!); const { data: mlflowConfig } = useMLflowConfig(workshopId!); + const { data: availableModels } = useAvailableModels(workshopId!); const updateModelMutation = useUpdateDiscoveryModel(workshopId!); const [customProviderStatus, setCustomProviderStatus] = React.useState<{ is_configured: boolean; is_enabled: boolean; provider_name?: string | null } | null>(null); - // Derive current model from workshop - const currentModel = React.useMemo(() => { - const backendName = workshop?.discovery_questions_model_name || 'demo'; - if (backendName === 'demo' || backendName === 'custom') return backendName; - return getFrontendModelName(backendName); - }, [workshop?.discovery_questions_model_name]); + // Derive current model from workshop (stored as endpoint name) + const currentModel = workshop?.discovery_questions_model_name || 'demo'; + + const modelOptions = React.useMemo( + () => (availableModels ? buildModelOptions(availableModels) : []), + [availableModels], + ); // Fetch custom LLM provider status React.useEffect(() => { @@ -55,8 +57,7 @@ export const DiscoveryStartPage: React.FC = ({ onStartD }, [workshopId]); const handleModelChange = (value: string) => { - const backendName = value === 'demo' || value === 'custom' ? value : getBackendModelName(value); - updateModelMutation.mutate({ model_name: backendName }); + updateModelMutation.mutate({ model_name: value }); }; const startDiscoveryPhase = async () => { @@ -249,11 +250,10 @@ export const DiscoveryStartPage: React.FC = ({ onStartD Demo (static questions) - {getModelOptions(!!mlflowConfig).map(option => ( + {modelOptions.map(option => ( {option.label} @@ -273,7 +273,7 @@ export const DiscoveryStartPage: React.FC = ({ onStartD {traceLimit === 'custom' ? `${customLimit} traces` : `${traceLimit} traces`} {randomizeTraces && ' · randomized per user'} {' · '} - {currentModel === 'demo' ? 'demo model' : currentModel === 'custom' ? `custom: ${customProviderStatus?.provider_name || 'Custom'}` : currentModel} + {currentModel === 'demo' ? 'demo model' : currentModel === 'custom' ? `custom: ${customProviderStatus?.provider_name || 'Custom'}` : getDisplayName(currentModel)} {parseInt(traceLimit === 'custom' ? customLimit : traceLimit) < totalTraces && ( ({totalTraces - parseInt(traceLimit === 'custom' ? customLimit : traceLimit)} unused) diff --git a/client/src/components/FacilitatorDashboard.tsx b/client/src/components/FacilitatorDashboard.tsx index 050dcc47..50c5fc83 100644 --- a/client/src/components/FacilitatorDashboard.tsx +++ b/client/src/components/FacilitatorDashboard.tsx @@ -7,11 +7,11 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useTraces, useAllTraces, useRubric, useFacilitatorAnnotations, useFacilitatorAnnotationsWithUserDetails, useWorkshop, useDiscoveryFeedback, useFacilitatorDiscoveryFeedback, useUpdateDiscoveryModel, useMLflowConfig } from '@/hooks/useWorkshopApi'; +import { useTraces, useAllTraces, useRubric, useFacilitatorAnnotations, useFacilitatorAnnotationsWithUserDetails, useWorkshop, useDiscoveryFeedback, useFacilitatorDiscoveryFeedback, useUpdateDiscoveryModel, useAvailableModels } from '@/hooks/useWorkshopApi'; import type { DiscoveryFeedbackWithUser } from '@/hooks/useWorkshopApi'; import { Settings, Users, FileText, CheckCircle, Clock, AlertCircle, ChevronRight, Play, Eye, Plus, RotateCcw, Target, TrendingUp, Activity, MessageSquare, ChevronDown, Brain } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { getModelOptions, getBackendModelName, getFrontendModelName } from '@/utils/modelMapping'; +import { buildModelOptions, getDisplayName } from '@/utils/modelMapping'; import { AlertDialog, AlertDialogAction, @@ -81,18 +81,14 @@ export const FacilitatorDashboard: React.FC = ({ onNa const [isResettingAnnotation, setIsResettingAnnotation] = React.useState(false); // Model selection for discovery questions - const { data: mlflowConfig } = useMLflowConfig(workshopId!); + const { data: availableModels } = useAvailableModels(workshopId!); const updateModelMutation = useUpdateDiscoveryModel(workshopId!); + const modelOptions = React.useMemo(() => availableModels ? buildModelOptions(availableModels) : [], [availableModels]); - const currentModel = React.useMemo(() => { - const backendName = workshop?.discovery_questions_model_name || 'demo'; - if (backendName === 'demo' || backendName === 'custom') return backendName; - return getFrontendModelName(backendName); - }, [workshop?.discovery_questions_model_name]); + const currentModel = workshop?.discovery_questions_model_name || 'demo'; const handleModelChange = (value: string) => { - const backendName = value === 'demo' || value === 'custom' ? value : getBackendModelName(value); - updateModelMutation.mutate({ model_name: backendName }); + updateModelMutation.mutate({ model_name: value }); }; // Calculate progress metrics @@ -1169,11 +1165,10 @@ export const FacilitatorDashboard: React.FC = ({ onNa Demo (static questions) - {getModelOptions(!!mlflowConfig).map(option => ( + {modelOptions.map(option => ( {option.label} diff --git a/client/src/components/GeneralDashboard.tsx b/client/src/components/GeneralDashboard.tsx index 16869a83..68964c52 100644 --- a/client/src/components/GeneralDashboard.tsx +++ b/client/src/components/GeneralDashboard.tsx @@ -12,9 +12,9 @@ import { ChevronRight } from 'lucide-react'; import { useWorkshopContext } from '@/context/WorkshopContext'; -import { useAllTraces, useFacilitatorAnnotations } from '@/hooks/useWorkshopApi'; +import { useAllTraces, useFacilitatorAnnotations, prefetchAvailableModels } from '@/hooks/useWorkshopApi'; import { UsersService } from '@/client'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { JsonPathSettings } from './JsonPathSettings'; interface GeneralDashboardProps { @@ -23,8 +23,12 @@ interface GeneralDashboardProps { export const GeneralDashboard: React.FC = ({ onNavigate }) => { const { workshopId } = useWorkshopContext(); + const queryClient = useQueryClient(); const { data: traces } = useAllTraces(workshopId!); const { data: annotations } = useFacilitatorAnnotations(workshopId!); + const handlePrefetchModels = () => { + if (workshopId) prefetchAvailableModels(queryClient, workshopId); + }; // Fetch workshop users const { data: workshopUsers } = useQuery({ @@ -118,6 +122,8 @@ export const GeneralDashboard: React.FC = ({ onNavigate } variant="default" size="sm" onClick={() => onNavigate?.('discovery')} + onMouseEnter={handlePrefetchModels} + onFocus={handlePrefetchModels} className="h-8" > @@ -127,6 +133,8 @@ export const GeneralDashboard: React.FC = ({ onNavigate } variant="outline" size="sm" onClick={() => onNavigate?.('annotation')} + onMouseEnter={handlePrefetchModels} + onFocus={handlePrefetchModels} className="h-8" > diff --git a/client/src/components/RoleBasedWorkflow.tsx b/client/src/components/RoleBasedWorkflow.tsx index 634e6312..7584243e 100644 --- a/client/src/components/RoleBasedWorkflow.tsx +++ b/client/src/components/RoleBasedWorkflow.tsx @@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DiscoveryService } from '@/client'; import { AlertCircle, CheckCircle, Clock, Users, UserCheck, Settings, Play, Brain, Eye, ChevronRight } from 'lucide-react'; -import { useRubric } from '@/hooks/useWorkshopApi'; +import { useRubric, prefetchAvailableModels } from '@/hooks/useWorkshopApi'; interface RoleBasedWorkflowProps { onNavigate: (phase: string) => void; @@ -546,6 +546,11 @@ export const RoleBasedWorkflow: React.FC = ({ onNavigate // Generate testid from step title (e.g., "Discovery Phase" -> "workflow-step-discovery") const stepTestId = `workflow-step-${step.title.toLowerCase().replace(/\s+phase/i, '').replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')}`; + const needsModelPrefetch = /discovery|annotation|rubric|judge/i.test(step.title); + const handlePrefetch = needsModelPrefetch + ? () => { if (workshopId) prefetchAvailableModels(queryClient, workshopId); } + : undefined; + return ( + )} + + + {expanded && hasData && ( +
+          {JSON.stringify(event.data, null, 2)}
+        
+ )} + + ); +} + +export function MilestoneView({ executiveSummary, milestones }: MilestoneViewProps) { + return ( +
+ {/* Executive Summary */} +
+

+ {executiveSummary} +

+
+ + {/* Milestones */} +
+ {milestones.map((milestone) => ( + + ))} +
+
+ ); +} + +function MilestoneCard({ milestone }: { milestone: Milestone }) { + const [expanded, setExpanded] = useState(true); + + return ( +
+ + + {expanded && ( +
+

+ {milestone.summary} +

+ {milestone.events.length > 0 && ( +
+ {milestone.events.map((event, i) => ( + + ))} +
+ )} +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Add milestone view toggle to TraceViewer** + +In `client/src/components/TraceViewer.tsx`, update the component: + +1. Import MilestoneView and Tabs components +2. Add a state for view mode (default to milestone when summary exists) +3. Wrap the existing content + milestone view in tabs + +```tsx +// At top of TraceViewer component: +import { MilestoneView } from './MilestoneView'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'; + +// Inside TraceViewer function, after existing hooks: +const hasSummary = !!trace.summary?.milestones?.length; +const [viewMode, setViewMode] = useState<'milestone' | 'trace'>( + hasSummary ? 'milestone' : 'trace' +); + +// In the JSX — wrap existing content: +{hasSummary ? ( + setViewMode(v as 'milestone' | 'trace')}> + + Milestone View + Trace Details + + + + + + {/* Existing TraceViewer content (input/output sections) */} + + +) : ( + /* Existing TraceViewer content unchanged */ +)} +``` + +- [ ] **Step 4: Run frontend tests** + +Run: `just ui-test-unit` +Expected: Existing tests still pass + +- [ ] **Step 5: Commit** + +```bash +git add client/src/components/MilestoneView.tsx client/src/components/TraceViewer.tsx client/src/client/models/Workshop.ts +git commit -m "feat(summarization): add MilestoneView component with tab toggle in TraceViewer" +``` + +--- + +## Task 5: Frontend — Summarization Settings UI + +**Spec criteria:** SC-C1, SC-C2, SC-C3 +**Files:** +- Create: `client/src/components/SummarizationSettings.tsx` +- Modify: Parent component that hosts facilitator settings (likely `JsonPathSettings.tsx` or dashboard) + +- [ ] **Step 1: Create SummarizationSettings component** + +Create `client/src/components/SummarizationSettings.tsx`: + +```tsx +import { useState, useEffect } from 'react'; +import { useWorkshop, useAvailableModels } from '../hooks'; +import { Button } from './ui/button'; +import { Switch } from './ui/switch'; + +interface SummarizationSettingsProps { + workshopId: string; +} + +export function SummarizationSettings({ workshopId }: SummarizationSettingsProps) { + const { data: workshop, refetch } = useWorkshop(workshopId); + const { data: models } = useAvailableModels(workshopId); + const [enabled, setEnabled] = useState(false); + const [model, setModel] = useState(''); + const [guidance, setGuidance] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (workshop) { + setEnabled(workshop.summarization_enabled ?? false); + setModel(workshop.summarization_model ?? ''); + setGuidance(workshop.summarization_guidance ?? ''); + } + }, [workshop]); + + const handleSave = async () => { + setSaving(true); + try { + await fetch(`/workshops/${workshopId}/summarization-settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + summarization_enabled: enabled, + summarization_model: model || null, + summarization_guidance: guidance || null, + }), + }); + refetch(); + } finally { + setSaving(false); + } + }; + + return ( +
+

+ Trace Summarization +

+

+ When enabled, traces will be automatically summarized into a milestone view at ingestion time. +

+ +
+ + {enabled ? 'Enabled' : 'Disabled'} +
+ + {enabled && ( + <> +
+ + +
+ +
+ +