From 70d5ee2c48530d3ef963ea54ec3a1f5c725b9115 Mon Sep 17 00:00:00 2001 From: Forrest Murray Date: Tue, 6 Jan 2026 20:29:32 -0500 Subject: [PATCH 01/24] adds generated questions and summaries --- .../src/components/FacilitatorDashboard.tsx | 243 +- client/src/pages/FindingsReviewPage.tsx | 608 -- client/src/pages/TraceViewerDemo.tsx | 462 +- client/src/pages/WorkshopDemoLanding.tsx | 3 - .../0004_discovery_questions_model.py | 49 + .../0005_discovery_questions_table.py | 57 + .../0006_discovery_summaries_table.py | 50 + server/database.py | 34 + server/models.py | 1 + server/routers/workshops.py | 1828 +++--- server/services/database_service.py | 5048 ++++++++--------- server/services/databricks_service.py | 834 +-- 12 files changed, 4675 insertions(+), 4542 deletions(-) delete mode 100644 client/src/pages/FindingsReviewPage.tsx create mode 100644 migrations/versions/0004_discovery_questions_model.py create mode 100644 migrations/versions/0005_discovery_questions_table.py create mode 100644 migrations/versions/0006_discovery_summaries_table.py diff --git a/client/src/components/FacilitatorDashboard.tsx b/client/src/components/FacilitatorDashboard.tsx index b68e39ab..68fb088f 100644 --- a/client/src/components/FacilitatorDashboard.tsx +++ b/client/src/components/FacilitatorDashboard.tsx @@ -7,7 +7,8 @@ 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 { useFacilitatorFindings, useFacilitatorFindingsWithUserDetails, useTraces, useAllTraces, useRubric, useFacilitatorAnnotations, useFacilitatorAnnotationsWithUserDetails, useWorkshop } from '@/hooks/useWorkshopApi'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useFacilitatorFindings, useFacilitatorFindingsWithUserDetails, useTraces, useAllTraces, useRubric, useFacilitatorAnnotations, useFacilitatorAnnotationsWithUserDetails, useWorkshop, useMLflowConfig } from '@/hooks/useWorkshopApi'; import { Settings, Users, FileText, CheckCircle, Clock, AlertCircle, BarChart, ChevronRight, Play, Eye, Plus, RotateCcw } from 'lucide-react'; import { AlertDialog, @@ -26,6 +27,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { PhaseControlButton } from './PhaseControlButton'; import { toast } from 'sonner'; import { parseRubricQuestions } from '@/utils/rubricUtils'; +import { getBackendModelName, getFrontendModelName, getModelOptions } from '@/utils/modelMapping'; interface FacilitatorDashboardProps { onNavigate: (phase: string) => void; @@ -41,6 +43,7 @@ export const FacilitatorDashboard: React.FC = ({ onNa // Get all workshop data const { data: workshop } = useWorkshop(workshopId!); + const { data: mlflowConfig } = useMLflowConfig(workshopId!); const { data: allFindings } = useFacilitatorFindings(workshopId!); const { data: allFindingsWithUserDetails } = useFacilitatorFindingsWithUserDetails(workshopId!); // Facilitators viewing all traces - don't need personalized ordering @@ -93,7 +96,7 @@ export const FacilitatorDashboard: React.FC = ({ onNa if (focusPhase === 'annotation') { // Use annotations with user details return annotationsWithUserDetails ? - Object.entries( + (Object.entries( annotationsWithUserDetails.reduce((acc, annotation) => { const userId = annotation.user_id; if (!acc[userId]) { @@ -102,12 +105,12 @@ export const FacilitatorDashboard: React.FC = ({ onNa acc[userId].count += 1; return acc; }, {} as Record) - ).map(([userId, data]) => ({ userId, userName: data.userName, count: data.count })) + ) as Array<[string, { count: number; userName: string }]>).map(([userId, data]) => ({ userId, userName: data.userName, count: data.count })) : []; } else { // Use discovery findings with user details (default) return allFindingsWithUserDetails ? - Object.entries( + (Object.entries( allFindingsWithUserDetails.reduce((acc, finding) => { const userId = finding.user_id; if (!acc[userId]) { @@ -116,7 +119,7 @@ export const FacilitatorDashboard: React.FC = ({ onNa acc[userId].count += 1; return acc; }, {} as Record) - ).map(([userId, data]) => ({ userId, userName: data.userName, count: data.count })) + ) as Array<[string, { count: number; userName: string }]>).map(([userId, data]) => ({ userId, userName: data.userName, count: data.count })) : []; } }, [focusPhase, allFindingsWithUserDetails, annotationsWithUserDetails]); @@ -290,6 +293,13 @@ export const FacilitatorDashboard: React.FC = ({ onNa // Judge name state - used for MLflow feedback entries const [judgeName, setJudgeName] = React.useState(workshop?.judge_name || 'workshop_judge'); const [isSavingJudgeName, setIsSavingJudgeName] = React.useState(false); + + // Discovery question model selection (workshop-level) + const [discoveryQuestionsModel, setDiscoveryQuestionsModel] = React.useState('demo'); + const [isSavingDiscoveryQuestionsModel, setIsSavingDiscoveryQuestionsModel] = React.useState(false); + const [summariesLoading, setSummariesLoading] = React.useState(false); + const [summariesError, setSummariesError] = React.useState(null); + const [summaries, setSummaries] = React.useState(null); // Derive judge name from rubric question title const deriveJudgeNameFromRubric = (questionTitle: string): string => { @@ -312,6 +322,20 @@ export const FacilitatorDashboard: React.FC = ({ onNa } } }, [workshop?.judge_name, rubric]); + + // Sync discovery question model from workshop data + React.useEffect(() => { + const savedBackend = (workshop as any)?.discovery_questions_model_name as string | undefined; + if (!savedBackend) { + setDiscoveryQuestionsModel('demo'); + return; + } + if (savedBackend === 'demo') { + setDiscoveryQuestionsModel('demo'); + return; + } + setDiscoveryQuestionsModel(getFrontendModelName(savedBackend)); + }, [workshop]); const handleSaveJudgeName = async () => { if (!judgeName.trim()) { @@ -340,6 +364,52 @@ export const FacilitatorDashboard: React.FC = ({ onNa } }; + const handleSaveDiscoveryQuestionsModel = async () => { + if (!workshopId) return; + setIsSavingDiscoveryQuestionsModel(true); + try { + const backendModel = discoveryQuestionsModel === 'demo' ? 'demo' : getBackendModelName(discoveryQuestionsModel); + const response = await fetch(`/workshops/${workshopId}/discovery-questions-model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_name: backendModel }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to save discovery question model' })); + throw new Error(error.detail || 'Failed to save discovery question model'); + } + + queryClient.invalidateQueries({ queryKey: ['workshop', workshopId] }); + toast.success('Discovery question model saved'); + } catch (error: any) { + toast.error(`Failed to save discovery question model: ${error?.message || 'Unknown error'}`); + } finally { + setIsSavingDiscoveryQuestionsModel(false); + } + }; + + const handleGenerateDiscoverySummaries = async () => { + if (!workshopId) return; + setSummariesLoading(true); + setSummariesError(null); + try { + const response = await fetch(`/workshops/${workshopId}/discovery-summaries`, { method: 'POST' }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to generate summaries' })); + throw new Error(error.detail || 'Failed to generate summaries'); + } + const data: any = await response.json(); + setSummaries(data); + toast.success('Discovery summaries generated'); + } catch (err: any) { + setSummariesError(err?.message || 'Failed to generate summaries'); + toast.error(err?.message || 'Failed to generate summaries'); + } finally { + setSummariesLoading(false); + } + }; + const handleAddAdditionalTraces = async () => { const phase = focusPhase || currentPhase; const phaseLabel = phase === 'annotation' ? 'annotation' : 'discovery'; @@ -888,24 +958,159 @@ export const FacilitatorDashboard: React.FC = ({ onNa focusPhase === 'annotation' ? 'md:grid-cols-2 lg:grid-cols-3' : 'md:grid-cols-3' }`}> - {/* View All Findings - Hide during discovery and annotation focus */} - {focusPhase !== 'discovery' && focusPhase !== 'annotation' && ( - - )} + {/* NOTE: Findings review page has been removed; use the Discovery monitor + summaries instead. */} {/* Discovery-specific actions */} {focusPhase === 'discovery' && ( <> + {/* Discovery Question LLM */} +
+
+ +
+
Discovery Question LLM
+
+ Controls which model generates discovery questions for participants +
+
+
+ + {!mlflowConfig && ( +

+ + MLflow/Databricks config is not set; only “Static (no LLM)” is available. +

+ )} + +
+ + +
+
+ + {/* LLM Summaries (Discovery) */} +
+
+
+
LLM Summaries
+
+ Themes, patterns, and tendencies of the model’s behaviors (overall, by user, by trace) +
+
+ +
+ + {summariesError && ( +

{summariesError}

+ )} + + {!summaries && ( +

+ Generate summaries once participants have started submitting findings. +

+ )} + + {summaries && ( +
+
+
Overall
+
+ {['themes', 'patterns', 'tendencies', 'risks_or_failure_modes', 'strengths'].map((k) => ( +
+
{k.replace(/_/g, ' ')}
+
    + {(summaries?.overall?.[k] || []).map((item: string, idx: number) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+
+ +
+
By User
+
+ {(summaries?.by_user || []).map((u: any, idx: number) => ( +
+
+ {u.user_name || 'User'} ({u.user_id}) +
+
+ {['themes', 'tendencies', 'notable_insights'].map((k) => ( +
+
{k.replace(/_/g, ' ')}
+
    + {(u?.[k] || []).map((item: string, j: number) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+
+ ))} +
+
+ +
+
By Trace
+
+ {(summaries?.by_trace || []).map((t: any, idx: number) => ( +
+
+ Trace {t.trace_id} +
+
+ {['themes', 'tendencies', 'notable_behaviors'].map((k) => ( +
+
{k.replace(/_/g, ' ')}
+
    + {(t?.[k] || []).map((item: string, j: number) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+
+ ))} +
+
+
+ )} +
+ {/* Add Additional Traces */}
diff --git a/client/src/pages/FindingsReviewPage.tsx b/client/src/pages/FindingsReviewPage.tsx deleted file mode 100644 index c6c5608b..00000000 --- a/client/src/pages/FindingsReviewPage.tsx +++ /dev/null @@ -1,608 +0,0 @@ -/** - * FindingsReviewPage Component - * - * Dedicated page for facilitators to review all discovery findings in a summary format. - * Shows findings organized by trace with filtering capabilities. - */ - -import React, { useState } from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { FileText, Users, Search, Filter, Eye, ArrowLeft } from 'lucide-react'; -import { useWorkshopContext } from '@/context/WorkshopContext'; -import { useUser, useRoleCheck } from '@/context/UserContext'; -import { useFacilitatorFindings, useTraces, useAllTraces } from '@/hooks/useWorkshopApi'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - -interface FindingsReviewPageProps { - onBack?: () => void; -} - -export const FindingsReviewPage: React.FC = ({ onBack }) => { - const { workshopId } = useWorkshopContext(); - const { user } = useUser(); - const { isFacilitator } = useRoleCheck(); - const queryClient = useQueryClient(); - const [searchFilter, setSearchFilter] = useState(''); - const [userFilter, setUserFilter] = useState('all'); - const [selectedTraceId, setSelectedTraceId] = useState(null); - - // Get all workshop data with user details - const { data: allFindingsWithUsers } = useQuery({ - queryKey: ['facilitator-findings-with-users', workshopId], - queryFn: async () => { - const response = await fetch(`/workshops/${workshopId}/findings-with-users`); - if (!response.ok) throw new Error('Failed to fetch findings'); - return response.json(); - }, - enabled: !!workshopId, - }); - - // Use all traces for facilitator review (no personalized ordering needed) - const { data: traces } = useAllTraces(workshopId!); - - // Get discovery completion status - const { data: completionStatus, refetch: refetchCompletionStatus } = useQuery({ - queryKey: ['discovery-completion-status', workshopId], - queryFn: async () => { - const response = await fetch(`/workshops/${workshopId}/discovery-completion-status`); - if (!response.ok) throw new Error('Failed to fetch completion status'); - return response.json(); - }, - enabled: !!workshopId, - }); - - // Redirect non-facilitators - if (!isFacilitator) { - return ( -
-
- -
- Facilitator Access Required -
-
- This findings review is only available to workshop facilitators -
-
-
- ); - } - - // Process findings data - const findingsByTrace = React.useMemo(() => { - if (!allFindingsWithUsers || !traces) return new Map(); - - const map = new Map(); - allFindingsWithUsers.forEach(finding => { - if (!map.has(finding.trace_id)) { - map.set(finding.trace_id, []); - } - map.get(finding.trace_id).push(finding); - }); - return map; - }, [allFindingsWithUsers, traces]); - - // Get unique users - const uniqueUsers = React.useMemo(() => { - if (!allFindingsWithUsers) return []; - return Array.from(new Set(allFindingsWithUsers.map(f => f.user_id))); - }, [allFindingsWithUsers]); - - // Filter findings - const filteredFindings = React.useMemo(() => { - if (!allFindingsWithUsers) return []; - - let filtered = allFindingsWithUsers; - - // Filter by user if specified - if (userFilter !== 'all') { - filtered = filtered.filter(f => f.user_id === userFilter); - } - - // Filter by search text - if (searchFilter) { - filtered = filtered.filter(f => - f.insight.toLowerCase().includes(searchFilter.toLowerCase()) || - f.user_name.toLowerCase().includes(searchFilter.toLowerCase()) || - f.user_email.toLowerCase().includes(searchFilter.toLowerCase()) - ); - } - - return filtered; - }, [allFindingsWithUsers, userFilter, searchFilter]); - - // Get trace for selected finding details - const getTraceById = (traceId: string) => { - return traces?.find(t => t.id === traceId); - }; - - const formatUserId = (userId: string) => { - if (userId.startsWith('demo_')) { - return userId.replace('_', ' ').toUpperCase(); - } - return userId; - }; - - return ( -
-
- {/* Header */} -
- {onBack && ( - - )} -
-
- -
-
-

Discovery Findings Review

-

- Review all participant insights and discoveries from the workshop -

-
-
-
- - {/* Summary Stats */} -
- - -
- -
-
{allFindingsWithUsers?.length || 0}
-
Total Findings
-
-
-
-
- - - -
- -
-
{uniqueUsers.length}
-
Active Users
-
-
-
-
- - - -
- -
-
{findingsByTrace.size}
-
Traces Reviewed
-
-
-
-
- - - -
- -
-
{filteredFindings.length}
-
Filtered Results
-
-
-
-
-
- - {/* Discovery Completion Status */} - {completionStatus && ( - - - - - Discovery Completion Status - - - Track participant progress and manage phase progression - - - -
- {/* Progress Summary */} -
-
-
-
- {completionStatus.completed_participants}/{completionStatus.total_participants} -
-
Participants Complete
-
-
-
- {Math.round(completionStatus.completion_percentage)}% -
-
Completion Rate
-
-
- - {/* Progress Bar */} -
-
-
-
-
-
- - {/* Participant Status */} -
- {Object.values(completionStatus.participant_status).map((status: any) => ( -
-
-
-
- {status.user_name} - {status.user_email} -
- - {status.role} - -
- - {status.completed ? 'Complete' : 'In Progress'} - -
- ))} -
- - {/* Facilitator Actions */} -
-
- {completionStatus.all_completed ? ( - -
- All participants have completed discovery - - ) : ( - -
- Waiting for {completionStatus.total_participants - completionStatus.completed_participants} participants to complete - - )} -
- -
- - - {completionStatus.all_completed && ( - - )} -
-
-
- - - )} - - {/* Filters */} - - - - - Search & Filter Findings - - - -
-
- setSearchFilter(e.target.value)} - className="w-full" - /> -
- -
-
-
- - {/* Main Content */} - - - All Findings - By Trace - By User - - - {/* All Findings View */} - - - - All Findings ({filteredFindings.length}) - - Chronological list of all discovery findings - - - -
- {filteredFindings.length > 0 ? ( - filteredFindings.map((finding) => { - const trace = getTraceById(finding.trace_id); - return ( -
-
-
-
- - {finding.user_name} - - - {finding.user_email} - -
- - Trace: {trace?.id?.slice(0, 8) || 'Unknown'}... - -
- - {new Date(finding.created_at).toLocaleString()} - -
-
-
- {finding.insight} -
-
-
- ); - }) - ) : ( -
- -

No findings match your current filters

-
- )} -
-
-
-
- - {/* By Trace View */} - - - - Findings Organized by Trace - - See all findings grouped by the traces they analyze - - - -
- {Array.from(findingsByTrace.entries()).map(([traceId, traceFindings]) => { - const trace = getTraceById(traceId); - const filteredTraceFindings = traceFindings.filter(f => - filteredFindings.some(ff => ff.id === f.id) - ); - - if (filteredTraceFindings.length === 0) return null; - - return ( -
-
-

- Trace: {traceId.slice(0, 8)}... - - {filteredTraceFindings.length} finding{filteredTraceFindings.length !== 1 ? 's' : ''} - -

- {trace && ( -
- Input: {trace.input.slice(0, 100)}... -
- )} -
-
- {filteredTraceFindings.map((finding) => ( -
-
-
- - {finding.user_name} - - - {finding.user_email} - -
- - {new Date(finding.created_at).toLocaleString()} - -
-
- {finding.insight} -
-
- ))} -
-
- ); - })} -
-
-
-
- - {/* By User View */} - - - - Findings Organized by User - - See all findings grouped by contributor - - - -
- {uniqueUsers.map(userId => { - const userFindings = filteredFindings.filter(f => f.user_id === userId); - if (userFindings.length === 0) return null; - - const user = allFindingsWithUsers?.find(f => f.user_id === userId); - - return ( -
-
-

- {user ? user.user_name : formatUserId(userId)} - - {userFindings.length} finding{userFindings.length !== 1 ? 's' : ''} - -

- {user && ( -

{user.user_email}

- )} -
-
- {userFindings.map((finding) => { - const trace = getTraceById(finding.trace_id); - return ( -
-
- - Trace: {trace?.id?.slice(0, 8) || 'Unknown'}... - - - {new Date(finding.created_at).toLocaleString()} - -
-
- {finding.insight} -
-
- ); - })} -
-
- ); - })} -
-
-
-
-
-
-
- ); -}; \ No newline at end of file diff --git a/client/src/pages/TraceViewerDemo.tsx b/client/src/pages/TraceViewerDemo.tsx index 401fce75..26ae753a 100644 --- a/client/src/pages/TraceViewerDemo.tsx +++ b/client/src/pages/TraceViewerDemo.tsx @@ -35,6 +35,62 @@ const convertTraceToTraceData = (trace: Trace): TraceData => ({ mlflow_experiment_id: trace.mlflow_experiment_id || undefined }); +type DiscoveryQuestion = { + id: string; + prompt: string; + placeholder?: string | null; +}; + +type DiscoveryQuestionsResponse = { + questions: DiscoveryQuestion[]; +}; + +const QA_DELIMITER = '\n\n---\n\n'; + +function parseInsightToResponses(insight: string): Record { + const text = (insight || '').trim(); + if (!text) return {}; + + // New format: repeated blocks + // QID: q_1 + // Q: ... + // A: ... + if (text.includes('QID:') && text.includes('\nA:')) { + const blocks = text.split(QA_DELIMITER); + const out: Record = {}; + for (const block of blocks) { + const qidMatch = block.match(/^QID:\s*(.+)$/m); + const qid = (qidMatch?.[1] || '').trim(); + if (!qid) continue; + const answerIdx = block.indexOf('\nA:'); + if (answerIdx === -1) continue; + const answer = block.slice(answerIdx + 4).trim(); // after "\nA:" + out[qid] = answer; + } + return out; + } + + // Legacy format: Quality/Improvement + const parts = text.split('\n\nImprovement Analysis: '); + if (parts.length === 2) { + const qualityPart = parts[0].replace('Quality Assessment: ', ''); + const improvementPart = parts[1]; + return { q_1: qualityPart, q_2: improvementPart }; + } + + // Fallback: treat as a single answer + return { q_1: text }; +} + +function serializeResponsesToInsight(questions: DiscoveryQuestion[], responses: Record): string { + if (!questions.length) return ''; + const blocks = questions.map((q) => { + const answer = (responses[q.id] || '').trim(); + return `QID: ${q.id}\nQ: ${q.prompt}\nA: ${answer}`; + }); + return blocks.join(QA_DELIMITER); +} + export function TraceViewerDemo() { const { workshopId } = useWorkshopContext(); const { currentPhase } = useWorkflowContext(); @@ -78,13 +134,15 @@ export function TraceViewerDemo() { ); } const [currentTraceIndex, setCurrentTraceIndex] = useState(0); - const [question1Response, setQuestion1Response] = useState(''); - const [question2Response, setQuestion2Response] = useState(''); + const [responsesByQuestionId, setResponsesByQuestionId] = useState>({}); const [submittedFindings, setSubmittedFindings] = useState>(new Set()); const [isCompletingDiscovery, setIsCompletingDiscovery] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isNavigating, setIsNavigating] = useState(false); const [showTableView, setShowTableView] = useState(false); + const [discoveryQuestions, setDiscoveryQuestions] = useState([]); + const [discoveryQuestionsLoading, setDiscoveryQuestionsLoading] = useState(false); + const [discoveryQuestionsError, setDiscoveryQuestionsError] = useState(null); const previousTraceId = useRef(null); const hasAutoNavigated = useRef(false); const previousTraceCount = useRef(0); @@ -105,6 +163,61 @@ export function TraceViewerDemo() { }, [traces]); const currentTrace = traceData[currentTraceIndex]; + // Fetch discovery questions for this specific user + trace + useEffect(() => { + if (!workshopId || !user?.id || !currentTrace?.id) return; + + const controller = new AbortController(); + setDiscoveryQuestionsLoading(true); + setDiscoveryQuestionsError(null); + + const url = `/workshops/${workshopId}/traces/${currentTrace.id}/discovery-questions?user_id=${encodeURIComponent(user.id)}`; + + fetch(url, { signal: controller.signal }) + .then(async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to fetch discovery questions' })); + throw new Error(error.detail || 'Failed to fetch discovery questions'); + } + return response.json() as Promise; + }) + .then((data) => { + setDiscoveryQuestions(Array.isArray(data?.questions) ? data.questions : []); + }) + .catch((err: any) => { + if (err?.name === 'AbortError') return; + console.error('Failed to fetch discovery questions:', err); + setDiscoveryQuestions([]); + setDiscoveryQuestionsError(err?.message || 'Failed to fetch discovery questions'); + }) + .finally(() => { + if (!controller.signal.aborted) setDiscoveryQuestionsLoading(false); + }); + + return () => controller.abort(); + }, [workshopId, user?.id, currentTrace?.id]); + + const appendDiscoveryQuestion = async () => { + if (!workshopId || !user?.id || !currentTrace?.id) return; + setDiscoveryQuestionsLoading(true); + setDiscoveryQuestionsError(null); + try { + const url = `/workshops/${workshopId}/traces/${currentTrace.id}/discovery-questions?user_id=${encodeURIComponent(user.id)}&append=true`; + const response = await fetch(url); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to generate another question' })); + throw new Error(error.detail || 'Failed to generate another question'); + } + const data = (await response.json()) as DiscoveryQuestionsResponse; + setDiscoveryQuestions(Array.isArray(data?.questions) ? data.questions : []); + } catch (err: any) { + console.error('Failed to append discovery question:', err); + setDiscoveryQuestionsError(err?.message || 'Failed to generate another question'); + } finally { + setDiscoveryQuestionsLoading(false); + } + }; + // Check if discovery phase is complete const allTracesHaveFindings = traceData.length > 0 && traceData.every(trace => submittedFindings.has(trace.id)); const isDiscoveryComplete = allTracesHaveFindings && submittedFindings.size === traceData.length; @@ -114,15 +227,7 @@ export function TraceViewerDemo() { if (existingFindings && existingFindings.length > 0) { existingFindings.forEach(finding => { const insight = finding.insight || ''; - const parts = insight.split('\n\nImprovement Analysis: '); - if (parts.length === 2) { - const qualityPart = parts[0].replace('Quality Assessment: ', ''); - const improvementPart = parts[1]; - savedStateRef.current.set(finding.trace_id, { q1: qualityPart, q2: improvementPart }); - } else { - // Couldn't parse, treat as raw text - savedStateRef.current.set(finding.trace_id, { q1: insight, q2: '' }); - } + savedStateRef.current.set(finding.trace_id, insight); }); } }, [existingFindings?.length]); // Only run when findings count changes @@ -134,34 +239,33 @@ export function TraceViewerDemo() { const existingFinding = existingFindings?.find(finding => finding.trace_id === currentTrace.id); if (existingFinding) { - // Parse and populate the existing finding text const insight = existingFinding.insight || ''; - const parts = insight.split('\n\nImprovement Analysis: '); - if (parts.length === 2) { - const qualityPart = parts[0].replace('Quality Assessment: ', ''); - const improvementPart = parts[1]; - setQuestion1Response(qualityPart); - setQuestion2Response(improvementPart); - } else { - // Couldn't parse, treat as raw text - setQuestion1Response(insight); - setQuestion2Response(''); - } + setResponsesByQuestionId(parseInsightToResponses(insight)); } else { - // Clear responses for new trace - setQuestion1Response(''); - setQuestion2Response(''); + setResponsesByQuestionId({}); } previousTraceId.current = currentTrace.id; } }, [currentTrace?.id, existingFindings]); + // Ensure we have response keys for all questions + useEffect(() => { + if (!discoveryQuestions.length) return; + setResponsesByQuestionId(prev => { + const next = { ...prev }; + for (const q of discoveryQuestions) { + if (next[q.id] === undefined) next[q.id] = ''; + } + return next; + }); + }, [discoveryQuestions]); + // Navigate to first incomplete trace (only on initial load) and handle trace additions useEffect(() => { if (existingFindings && traceData.length > 0) { const validTraceIds = new Set(traceData.map(t => t.id)); - const completedTraceIds = new Set(existingFindings + const completedTraceIds = new Set(existingFindings .filter(f => validTraceIds.has(f.trace_id)) // Only count findings for current traces .map(f => f.trace_id) ); @@ -202,7 +306,7 @@ export function TraceViewerDemo() { useEffect(() => { if (existingFindings && traceData.length > 0) { const validTraceIds = new Set(traceData.map(t => t.id)); - const completedTraceIds = new Set(existingFindings + const completedTraceIds = new Set(existingFindings .filter(f => validTraceIds.has(f.trace_id)) // Only count findings for current traces .map(f => f.trace_id) ); @@ -222,7 +326,7 @@ export function TraceViewerDemo() { // Track saved state per trace (better than global refs) - const savedStateRef = useRef>(new Map()); + const savedStateRef = useRef>(new Map()); const savingTracesRef = useRef>(new Set()); // Track which traces are currently saving const isSavingRef = useRef(false); // Track if any user-initiated save is in progress const saveStatusRef = useRef>(new Map()); // Track save status per trace @@ -250,15 +354,14 @@ export function TraceViewerDemo() { }; // Save finding function - optimized to track state per trace - const saveFinding = useCallback(async (q1: string, q2: string, traceId: string, isBackground: boolean = false): Promise => { - // Allow saving if at least one field has content (both fields are not required) - if ((!q1.trim() && !q2.trim()) || !traceId) { + const saveFinding = useCallback(async (responses: Record, traceId: string, isBackground: boolean = false): Promise => { + const hasAnyContent = Object.values(responses || {}).some((v) => !!v && v.trim().length > 0); + if (!hasAnyContent || !traceId) { // No content to save, but this is not an error - return true to allow navigation return true; } - - const q1Trimmed = q1.trim(); - const q2Trimmed = q2.trim(); + const content = serializeResponsesToInsight(discoveryQuestions, responses); + const contentTrimmed = content.trim(); // Check if this trace is already being saved (prevent duplicate saves) if (savingTracesRef.current.has(traceId)) { @@ -275,14 +378,10 @@ export function TraceViewerDemo() { } // Check if content has actually changed from last saved for this trace - const savedState = savedStateRef.current.get(traceId); - if (savedState) { - const hasChanged = q1Trimmed !== savedState.q1 || q2Trimmed !== savedState.q2; - if (!hasChanged) { + const savedContent = savedStateRef.current.get(traceId); + if (savedContent !== undefined) { + if ((savedContent || '').trim() === contentTrimmed) { console.log(`No changes detected for trace ${traceId}, skipping save`); - // Even though we skip the save, ensure the trace is marked as submitted - // This fixes the issue where "Complete" doesn't record the last trace - setSubmittedFindings(prev => new Set([...prev, traceId])); return true; // No change needed, return success } } @@ -299,29 +398,27 @@ export function TraceViewerDemo() { } try { - const content = `Quality Assessment: ${q1Trimmed}\n\nImprovement Analysis: ${q2Trimmed}`; - - console.log('Saving finding:', { traceId, q1Length: q1Trimmed.length, q2Length: q2Trimmed.length, isBackground }); + console.log('Saving finding:', { traceId, length: contentTrimmed.length, isBackground }); // Use retry logic for background saves, direct call for user-initiated saves if (isBackground) { await retryWithBackoff(() => submitFinding.mutateAsync({ trace_id: traceId, user_id: user?.id || 'demo_user', - insight: content + insight: contentTrimmed }), 3, 1000); // 3 retries with exponential backoff } else { await submitFinding.mutateAsync({ trace_id: traceId, user_id: user?.id || 'demo_user', - insight: content + insight: contentTrimmed }); } setSubmittedFindings(prev => new Set([...prev, traceId])); // Update saved state for this trace AFTER successful save - savedStateRef.current.set(traceId, { q1: q1Trimmed, q2: q2Trimmed }); + savedStateRef.current.set(traceId, contentTrimmed); if (isBackground) { saveStatusRef.current.set(traceId, 'saved'); } @@ -335,8 +432,7 @@ export function TraceViewerDemo() { response: error?.response?.data, status: error?.response?.status, traceId, - q1Length: q1Trimmed.length, - q2Length: q2Trimmed.length, + contentLength: contentTrimmed.length, isBackground }); @@ -359,23 +455,21 @@ export function TraceViewerDemo() { } }, [submitFinding, user?.id]); - // NOTE: Removed blur auto-save as it conflicts with button clicks - // The Next/Previous buttons already handle saving before navigation - - // Track navigation using ref (more reliable than state for preventing double-clicks) - const isNavigatingRef = useRef(false); + // Handle blur on textareas - save immediately when user clicks away + const handleTextareaBlur = async () => { + if (!currentTrace) return; + await saveFinding(responsesByQuestionId, currentTrace.id); + }; - // Navigate to next trace - save first, then navigate - const nextTrace = async () => { + // Navigate to next trace - optimistic navigation with async background save + const nextTrace = () => { if (!currentTrace) { console.warn('nextTrace: No current trace'); return; } - - // Use ref to prevent concurrent navigation (more reliable than React state) - if (isNavigatingRef.current) { - console.warn('nextTrace: Already navigating (ref check)'); - return; + if (isNavigating) { + console.warn('nextTrace: Already navigating', { isNavigating }); + return; // Prevent concurrent navigation } // Check if we can navigate @@ -384,37 +478,47 @@ export function TraceViewerDemo() { return; // Already at last trace } - // Set navigating flag immediately using ref - isNavigatingRef.current = true; + console.log('nextTrace: Starting optimistic navigation', { currentTraceIndex, nextIndex: currentTraceIndex + 1 }); setIsNavigating(true); - try { - // Store current trace data for save - const currentTraceId = currentTrace.id; - const q1ToSave = question1Response.trim(); - const q2ToSave = question2Response.trim(); - const hasContent = q1ToSave || q2ToSave; - - console.log('nextTrace: Starting navigation', { currentTraceIndex, nextIndex: currentTraceIndex + 1, hasContent }); - - // Save FIRST if there's content (await to ensure it completes) - if (hasContent) { - console.log('nextTrace: Saving content before navigation', { traceId: currentTraceId }); - await saveFinding(q1ToSave, q2ToSave, currentTraceId, true); - console.log('nextTrace: Save completed for trace:', currentTraceId); - } - - // Then navigate - const nextIndex = currentTraceIndex + 1; - setQuestion1Response(''); - setQuestion2Response(''); - setCurrentTraceIndex(nextIndex); - console.log('nextTrace: Navigated to index', nextIndex); - - } finally { - // Clear navigating flags - isNavigatingRef.current = false; - setIsNavigating(false); + // Store current trace data for background save + const currentTraceId = currentTrace.id; + const responsesToSave = responsesByQuestionId; + const hasContent = Object.values(responsesToSave || {}).some((v) => !!v && v.trim().length > 0); + + // Navigate immediately (optimistic) + const nextIndex = currentTraceIndex + 1; + console.log('nextTrace: Navigating to index', nextIndex); + + // Clear the responses for the new trace first + setResponsesByQuestionId({}); + // Navigate synchronously + setCurrentTraceIndex(nextIndex); + + // Clear navigating flag immediately after state update + setIsNavigating(false); + + // Save in background (async, non-blocking) with automatic retry + if (hasContent) { + console.log('nextTrace: Saving content in background', { traceId: currentTraceId }); + saveFinding(responsesToSave, currentTraceId, true) // isBackground=true (includes retry logic) + .then((success) => { + if (success) { + console.log('nextTrace: Background save successful for trace:', currentTraceId); + } else { + // Save failed after retries - log but don't show intrusive toast + // The save status is tracked in saveStatusRef, user can see it if they navigate back + console.warn('nextTrace: Background save failed after retries for trace:', currentTraceId); + // Only show a subtle notification if it's a persistent failure + // The retry logic should handle most transient failures + } + }) + .catch((error) => { + // This shouldn't happen as saveFinding catches errors, but log just in case + console.error('nextTrace: Unexpected background save error:', error); + }); + } else { + console.log('nextTrace: No content to save'); } }; @@ -449,17 +553,15 @@ export function TraceViewerDemo() { } }; - // Navigate to previous trace - save first, then navigate - const prevTrace = async () => { + // Navigate to previous trace - optimistic navigation with async background save + const prevTrace = () => { if (!currentTrace) { console.warn('prevTrace: No current trace'); return; } - - // Use ref to prevent concurrent navigation (more reliable than React state) - if (isNavigatingRef.current) { - console.warn('prevTrace: Already navigating (ref check)'); - return; + if (isNavigating) { + console.warn('prevTrace: Already navigating', { isNavigating }); + return; // Prevent concurrent navigation } // Check if we can navigate @@ -468,45 +570,53 @@ export function TraceViewerDemo() { return; // Already at first trace } - // Set navigating flag immediately using ref - isNavigatingRef.current = true; + console.log('prevTrace: Starting optimistic navigation', { currentTraceIndex, prevIndex: currentTraceIndex - 1 }); setIsNavigating(true); - try { - // Store current trace data for save - const currentTraceId = currentTrace.id; - const q1ToSave = question1Response.trim(); - const q2ToSave = question2Response.trim(); - const hasContent = q1ToSave || q2ToSave; - - console.log('prevTrace: Starting navigation', { currentTraceIndex, prevIndex: currentTraceIndex - 1, hasContent }); - - // Save FIRST if there's content (await to ensure it completes) - if (hasContent) { - console.log('prevTrace: Saving content before navigation', { traceId: currentTraceId }); - await saveFinding(q1ToSave, q2ToSave, currentTraceId, true); - console.log('prevTrace: Save completed for trace:', currentTraceId); - } - - // Then navigate - const prevIndex = currentTraceIndex - 1; - setQuestion1Response(''); - setQuestion2Response(''); - setCurrentTraceIndex(prevIndex); - console.log('prevTrace: Navigated to index', prevIndex); - - } finally { - // Clear navigating flags - isNavigatingRef.current = false; - setIsNavigating(false); + // Store current trace data for background save + const currentTraceId = currentTrace.id; + const responsesToSave = responsesByQuestionId; + const hasContent = Object.values(responsesToSave || {}).some((v) => !!v && v.trim().length > 0); + + // Navigate immediately (optimistic) + const prevIndex = currentTraceIndex - 1; + console.log('prevTrace: Navigating to index', prevIndex); + + // Clear the responses for the new trace first + setResponsesByQuestionId({}); + // Navigate synchronously + setCurrentTraceIndex(prevIndex); + + // Clear navigating flag immediately after state update + setIsNavigating(false); + + // Save in background (async, non-blocking) with automatic retry + if (hasContent) { + console.log('prevTrace: Saving content in background', { traceId: currentTraceId }); + saveFinding(responsesToSave, currentTraceId, true) // isBackground=true (includes retry logic) + .then((success) => { + if (success) { + console.log('prevTrace: Background save successful for trace:', currentTraceId); + } else { + // Save failed after retries - log but don't show intrusive toast + console.warn('prevTrace: Background save failed after retries for trace:', currentTraceId); + } + }) + .catch((error) => { + // This shouldn't happen as saveFinding catches errors, but log just in case + console.error('prevTrace: Unexpected background save error:', error); + }); + } else { + console.log('prevTrace: No content to save'); } }; const handleSubmitFinding = async () => { - if (!currentTrace || !question1Response.trim() || !question2Response.trim() || isSaving) return; + const hasAnyResponse = Object.values(responsesByQuestionId || {}).some((v) => !!v && v.trim().length > 0); + if (!currentTrace || !hasAnyResponse || isSaving) return; // Use the saveFinding function to ensure consistent behavior and prevent concurrent saves - await saveFinding(question1Response, question2Response, currentTrace.id); + await saveFinding(responsesByQuestionId, currentTrace.id); }; // SECURITY: Block access if no valid user (prevent undefined user access) @@ -727,35 +837,56 @@ export function TraceViewerDemo() { You don't have permission to submit findings. You can view the traces but cannot contribute insights.

)} + {discoveryQuestionsLoading && ( +

Loading questions…

+ )} + {discoveryQuestionsError && ( +

+ + {discoveryQuestionsError} +

+ )} -
- -