diff --git a/.claude/commands/verify.md b/.claude/commands/verify.md deleted file mode 100644 index 17413f78..00000000 --- a/.claude/commands/verify.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -allowed-tools: Bash(just:*), Bash(git status:*) -argument-hint: [scope] - "all", "affected", "backend", "frontend", "e2e" -description: Run tests to verify code changes ---- - -# Verify Code Changes - -You are verifying code changes using the project's test infrastructure. - -## Scope: $ARGUMENTS - -Based on the scope provided, run the appropriate verification: - -### If scope is "all" or empty: -Run the full verification suite: -1. `just test-server` - Python unit tests -2. `just ui-lint` - TypeScript/ESLint checks -3. `just ui-test-unit` - React unit tests -4. `just e2e` - End-to-end tests - -### If scope is "affected": -1. First, check `git status` to see what files changed -2. If Python files changed: run `just test-server` -3. If TypeScript/React files changed: run `just ui-lint && just ui-test-unit` -4. If E2E-relevant changes (UI components, API routes): run `just e2e` - -### If scope is "backend": -Run only backend verification: -1. `just test-server` - -### If scope is "frontend": -Run only frontend verification: -1. `just ui-lint` -2. `just ui-test-unit` - -### If scope is "e2e": -Run only E2E tests: -1. `just e2e` - -## On Failure - -If any test fails: -1. Report which tests failed with the error output -2. Suggest fixes based on the error messages -3. Ask if the user wants you to fix the issues - -## Reference - -See the verification-testing skill in `.claude/skills/verification-testing/` for detailed testing patterns and mocking guidance. diff --git a/.claude/settings.json b/.claude/settings.json index f94be4e3..c050a055 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,9 +5,7 @@ "Bash(git push --force:*)", "Bash(git reset --hard:*)", "Bash(alembic revision:*)", - "Bash(alembic downgrade:*)", - "Edit(specs/*)", - "Write(specs/*)" + "Bash(alembic downgrade:*)" ], "ask": [ "Bash(git push:*)", @@ -15,7 +13,9 @@ "Edit(server/models/*)", "Edit(alembic/*)", "Edit(.github/*)", - "Write(alembic/*)" + "Write(alembic/*)", + "Edit(specs/*)", + "Write(specs/*)" ], "allow": [ "Bash(just:*)", @@ -29,4 +29,4 @@ "Read(*)" ] } -} +} \ No newline at end of file diff --git a/.claude/skills/verification-testing/SKILL.md b/.claude/skills/verification-testing/SKILL.md index 692fe409..76a37783 100644 --- a/.claude/skills/verification-testing/SKILL.md +++ b/.claude/skills/verification-testing/SKILL.md @@ -1,97 +1,156 @@ --- name: verification-testing -description: "Code verification and testing for the Human Evaluation Workshop. Use when (1) running tests after code changes, (2) writing new unit tests (pytest/vitest), (3) writing E2E tests with Playwright/TestScenario, (4) debugging test failures, (5) understanding what to mock in E2E tests, (6) verifying a feature implementation. Covers the full test pyramid: unit tests -> integration tests -> E2E tests." +description: "Code verification and testing for the Human Evaluation Workshop. Use when (1) checking implementation progress against specs, (2) running tests after code changes, (3) writing new tests, (4) debugging test failures. Covers unit tests, integration tests, and E2E tests." --- # Verification & Testing -## Quick Verification Commands +*IMPORTANT:* BEHAVIORS WHICH AREN'T YET IMPLEMENTED OR STUBBED WITH A PLACEHOLDER SHOULD NOT YIELD PASSING TESTS! -Run these commands to verify code changes: +## Common Questions (Start Here) -| Command | Purpose | When to Use | -|---------|---------|-------------| -| `just test-server` | Python unit tests | After backend changes | -| `just ui-test-unit` | React unit tests | After frontend changes | -| `just ui-lint` | TypeScript/ESLint | Before committing | -| `just e2e` | Full E2E tests | After any feature change | +### "How far along is SPEC_NAME implementation?" -## Verification Workflow +**Use `just spec-status` - do NOT run tests unnecessarily.** -### After Implementing a Feature +```bash +just spec-status SPEC_NAME +``` -1. **Read the relevant spec** in `specs/` to understand success criteria -2. **Run unit tests** for the layer you changed: - - Backend: `just test-server` - - Frontend: `just ui-test-unit` -3. **Run linting**: `just ui-lint` -4. **Run E2E tests**: `just e2e` -5. **Add new tests** if the feature isn't covered +This shows coverage percentage and any recent test results. To get detailed uncovered requirements: -## Reference Files +```bash +just spec-coverage --specs SPEC_NAME --json | jq '{ + coverage: .specs.SPEC_NAME.coverage_percent, + covered: .specs.SPEC_NAME.covered_count, + total: .specs.SPEC_NAME.requirement_count, + uncovered: .specs.SPEC_NAME.uncovered +}' +``` + +**Summarize results for the user** - don't just dump JSON output. + +### "Which tests cover SPEC_NAME?" + +```bash +# Python tests +grep -r "@pytest.mark.spec(\"SPEC_NAME\")" tests/ + +# E2E tests +grep -l "@spec:SPEC_NAME" client/tests/e2e/*.spec.ts + +# All test counts by type +just spec-coverage --specs SPEC_NAME --json | jq '.specs.SPEC_NAME.tests_by_type' +``` -| Reference | Purpose | When to Read | -|-----------|---------|--------------| -| `e2e-patterns.md` | TestScenario builder API | When writing E2E tests | -| `mocking.md` | E2E mocking + MLflow/external service mocking | When adding new endpoints or testing integrations | -| `unit-tests.md` | pytest and vitest patterns | When writing unit tests | +### "Are the tests passing?" -## Key Concepts +**Only run tests if the user asks to verify implementation works**, not just to check progress. -### Test Pyramid +```bash +# After running tests, get concise summary +just test-summary +# Or filter by spec +just test-summary --spec SPEC_NAME ``` - ┌─────────┐ - │ E2E │ ← Playwright (slow, high confidence) - └────┬────┘ - ┌───────┴───────┐ - │ Integration │ ← API tests (medium speed) - └───────┬───────┘ -┌────────────┴────────────┐ -│ Unit Tests │ ← pytest/vitest (fast) -└─────────────────────────┘ + +### "Which requirements are uncovered?" + +```bash +just spec-coverage --json | jq '.specs | to_entries[] | select(.value.uncovered | length > 0) | {spec: .key, uncovered: .value.uncovered}' ``` -### E2E Mocking Strategy +--- -**Mock by default** - The test infrastructure mocks all API calls unless you opt out: +## Quick Commands Reference -```typescript -// Everything mocked (default) -const scenario = await TestScenario.create(page) - .withWorkshop() - .build(); - -// Selective real API -const scenario = await TestScenario.create(page) - .withWorkshop() - .withReal('/users/auth/login') // Only auth is real - .build(); - -// Full integration (no mocks) -const scenario = await TestScenario.create(page) - .withWorkshop() - .withRealApi() - .build(); +| Command | Purpose | +|---------|---------| +| `just spec-status SPEC_NAME` | Coverage + recent test results for a spec | +| `just spec-coverage` | Full coverage report (all specs) | +| `just spec-coverage --affected` | Coverage for specs affected by recent changes | +| `just test-summary` | Concise test results after running tests | +| `just test-server` | Run all Python unit tests | +| `just ui-test-unit` | Run all React unit tests | +| `just e2e` | Run all E2E tests | +| `just e2e-spec SPEC_NAME` | Run E2E tests for a specific spec | + +## Running Tests for a Specific Spec + +```bash +# Python unit tests +just test-server-spec SPEC_NAME + +# React unit tests +just ui-test-unit-spec SPEC_NAME + +# E2E tests (headless by default) +just e2e-spec SPEC_NAME + +# E2E with visible browser +just e2e-spec SPEC_NAME headed +``` + +### E2E Timeout Configuration + +If tests are timing out, increase Playwright timeouts via environment variables: + +```bash +# Increase test timeout (default: 30s) and expect timeout (default: 5s) +PW_TEST_TIMEOUT=60000 PW_EXPECT_TIMEOUT=10000 just e2e-spec SPEC_NAME ``` -### Adding Mocks for New Endpoints +Server logs are suppressed by default during E2E tests. To view them: +- Check `.test-results/api-server.log` and `.test-results/ui-server.log` +- Or run servers manually with `just e2e-servers` (logs to stdout) + +## Test Tagging + +All tests must be tagged with spec markers: + +**Python (pytest):** +```python +@pytest.mark.spec("SPEC_NAME") +@pytest.mark.req("requirement text") # optional, links to specific requirement +def test_something(): ... +``` -If you add a new API endpoint, add a mock handler in `client/tests/lib/mocks/api-mocker.ts`: +**Playwright (E2E):** +```typescript +test.use({ tag: ['@spec:SPEC_NAME', '@req:requirement text'] }); +``` +**Vitest (unit):** ```typescript -this.routes.push({ - pattern: /\/workshops\/([a-f0-9-]+)\/your-endpoint$/i, - get: async (route) => { - await route.fulfill({ json: this.store.yourData }); - }, -}); +// @spec SPEC_NAME +// @req requirement text ``` -## Critical Files +## Test File Locations + +| Type | Location | Tag Format | +|------|----------|------------| +| Python unit | `tests/unit/` | `@pytest.mark.spec("SPEC")` | +| Python integration | `tests/integration/` | `@pytest.mark.spec("SPEC")` | +| React unit | `client/src/**/*.test.ts` | `// @spec SPEC` comment | +| E2E | `client/tests/e2e/*.spec.ts` | `test.use({ tag: ['@spec:SPEC'] })` | + +## Verification Workflow + +After implementing a feature: + +1. **Check coverage**: `just spec-status SPEC_NAME` +2. **Run relevant tests**: + - Backend changes: `just test-server-spec SPEC_NAME` + - Frontend changes: `just ui-test-unit-spec SPEC_NAME` + - Full feature: `just e2e-spec SPEC_NAME` +3. **Get results**: `just test-summary` +4. **Lint**: `just ui-lint` + +## Reference Files -- `specs/TESTING_SPEC.md` - Full testing specification -- `client/tests/lib/README.md` - E2E test infrastructure docs -- `client/tests/lib/mocks/api-mocker.ts` - Mock handlers -- `client/tests/lib/scenario-builder.ts` - TestScenario class -- `justfile` - All test commands +For detailed patterns, see: +- `e2e-patterns.md` - TestScenario builder API for E2E tests +- `mocking.md` - How to mock API endpoints in E2E tests +- `unit-tests.md` - pytest and vitest patterns diff --git a/.coverage b/.coverage index 9ad07667..69153e8d 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.cursor/rules/always-use-uv.mdc b/.cursor/rules/always-use-uv.mdc new file mode 100644 index 00000000..84089c18 --- /dev/null +++ b/.cursor/rules/always-use-uv.mdc @@ -0,0 +1,4 @@ +--- +description: when running python commands or python executables like pytest use `uv run python ...` or `uv run ` +alwaysApply: false +--- diff --git a/.cursor/rules/use-just-recipes.mdc b/.cursor/rules/use-just-recipes.mdc new file mode 100644 index 00000000..c227f7f6 --- /dev/null +++ b/.cursor/rules/use-just-recipes.mdc @@ -0,0 +1,4 @@ +--- +description: When running things like unit tests, e2e tests, migrations, first look for a corresponding just recipe @justfile +alwaysApply: false +--- diff --git a/.gitignore b/.gitignore index 8d0f9abc..a50620ff 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,10 @@ workshop.db *.db-shm *.db-wal *.csv -.databricks/ \ No newline at end of file +.databricks/ + +# Test results (JSON reports for LLM agents) +.test-results/ +htmlcov/ +coverage/ +.coverage \ No newline at end of file diff --git a/CODEOWNERS.txt b/CODEOWNERS.txt index a461ebde..7437172d 100644 --- a/CODEOWNERS.txt +++ b/CODEOWNERS.txt @@ -1,2 +1,4 @@ vivian-xie-db -Pallavi Koppol \ No newline at end of file +Pallavi Koppol +forrestmurray-db +Forrest Murray \ No newline at end of file diff --git a/client/EXHAUSTIVE_DEPS_ANALYSIS.md b/client/EXHAUSTIVE_DEPS_ANALYSIS.md new file mode 100644 index 00000000..b95e9c3d --- /dev/null +++ b/client/EXHAUSTIVE_DEPS_ANALYSIS.md @@ -0,0 +1,299 @@ +# Exhaustive Deps Analysis + +Analysis of `react-hooks/exhaustive-deps` warnings aligned with TanStack Query best practices. + +## Core Principle + +> "Server state is totally different... Is persisted remotely, requires asynchronous APIs, implies shared ownership, can become 'out of date'" + +Any `useEffect` that fetches data is an **anti-pattern** when TanStack Query is available. Many of these warnings are symptoms of architectural issues, not just missing dependencies. + +--- + +## Category 1: Data Fetching Anti-Patterns + +These should be refactored to use TanStack Query instead of `useEffect` + `fetch` + `setState`. + +### IntakePage.tsx:100 - `loadStatus` + +**Current (anti-pattern):** +```tsx +useEffect(() => { + loadStatus(); +}, [workshopId]); + +const loadStatus = async () => { + const response = await fetch(`/workshops/${workshopId}/mlflow-status`); + const statusData = await response.json(); + setStatus(statusData); + setConfig(prev => ({ ...prev, ...statusData.config })); +}; +``` + +**Problem:** Manual server state management - no caching, no deduping, no background refetch, no stale detection. + +**Fix:** Create a proper hook in `useWorkshopApi.ts`: +```tsx +export function useMLflowStatus(workshopId: string) { + return useQuery({ + queryKey: ['mlflow-status', workshopId], + queryFn: async () => { + const response = await fetch(`/workshops/${workshopId}/mlflow-status`); + if (!response.ok) throw new Error('Failed to fetch status'); + return response.json(); + }, + enabled: !!workshopId, + }); +} + +// In IntakePage.tsx +const { data: status } = useMLflowStatus(workshopId); +``` + +--- + +### JudgeTuningPage.tsx:279 - `loadInitialData` + +**Current (anti-pattern):** +```tsx +useEffect(() => { + if (workshopId) { + loadInitialData(); + } +}, [workshopId]); +``` + +**Problem:** Same anti-pattern - manual data fetching that bypasses TanStack Query's benefits. + +**Fix:** The page already uses `useRubric` and `useFacilitatorAnnotations`. Verify `loadInitialData` isn't duplicating those queries. If it's fetching additional data, create proper hooks. + +--- + +## Category 2: Derived State + +These use `useEffect` to transform data from query results. Should use `select` option or `useMemo` instead. + +### AnnotationDemo.tsx:350, 377, 431 - Processing `existingAnnotations` + +**Current Pattern:** +```tsx +const { data: existingAnnotations } = useUserAnnotations(workshopId!, user); + +// Multiple useEffects to transform this data: +useEffect(() => { + if (existingAnnotations && existingAnnotations.length > 0) { + existingAnnotations.forEach(annotation => { + // ... complex transformation into savedStateRef + }); + } +}, [existingAnnotations?.length, rubricQuestions.length]); +``` + +**Problem:** Using `useEffect` to derive/transform data from a query result. This is client-side computation, not server state management. + +**Fix Options:** + +1. **Use `select` in the query** (if transformation is always needed): +```tsx +const { data: processedAnnotations } = useUserAnnotations(workshopId!, user, { + select: (annotations) => annotations.map(a => ({ + ...a, + parsedRatings: parseRatings(a), + parsedComment: parseLoadedComment(a.comment) + })) +}); +``` + +2. **Use `useMemo`** (for local transformations): +```tsx +const savedState = useMemo(() => { + if (!existingAnnotations) return new Map(); + const map = new Map(); + existingAnnotations.forEach(annotation => { + map.set(annotation.trace_id, { + ratings: parseRatings(annotation), + ...parseLoadedComment(annotation.comment) + }); + }); + return map; +}, [existingAnnotations]); +``` + +Additionally, `parseLoadedComment` should be moved outside the component (it's a pure function with no closures). + +--- + +### TraceViewerDemo.tsx:214 - Processing `existingFindings` + +**Same pattern** - useEffect to transform TanStack Query data into a ref. + +**Fix:** Use `useMemo`: +```tsx +const savedState = useMemo(() => { + const map = new Map(); + existingFindings?.forEach(finding => { + map.set(finding.trace_id, finding.insight || ''); + }); + return map; +}, [existingFindings]); +``` + +--- + +### IRRResultsDemo.tsx:179 - `perMetricScores` + +**Current (creates new reference every render):** +```tsx +const perMetricScores = irrResult?.details?.per_metric_scores || {}; +``` + +**Fix:** +```tsx +const perMetricScores = useMemo( + () => irrResult?.details?.per_metric_scores ?? {}, + [irrResult?.details?.per_metric_scores] +); +``` + +--- + +## Category 3: Client State Synchronization + +These are legitimate client state sync patterns (not server state). Fix by memoizing functions. + +### WorkshopContext.tsx:140, 148, 183 - `handleSetWorkshopId` + +**This is legitimate client state sync** - coordinating workshopId across URL, localStorage, and UserContext. Not server state. + +**Fix:** Wrap in `useCallback`: +```tsx +const handleSetWorkshopId = useCallback((id: string | null) => { + if (id !== workshopId) { + queryClient.invalidateQueries(); + queryClient.clear(); + setWorkshopId(id); + setWorkshop(null); + if (id) { + localStorage.setItem('workshop_id', id); + } else { + localStorage.removeItem('workshop_id'); + } + } +}, [workshopId, queryClient]); +``` + +Then add `handleSetWorkshopId` to all three useEffect dependency arrays. + +--- + +### DBSQLExportPage.tsx:144, 156 - `saveStateToStorage` + +**This is client state persistence** (localStorage), not server state. + +**Fix:** Wrap in `useCallback`: +```tsx +const saveStateToStorage = useCallback((overrides = {}) => { + localStorage.setItem(storageKey, JSON.stringify({ + state: { databricksHost, databricksToken, httpPath, catalog, schemaName, ...overrides }, + timestamp: Date.now() + })); +}, [workshopId, databricksHost, databricksToken, httpPath, catalog, schemaName]); +``` + +--- + +## Category 4: Real Bugs + +### TraceViewerDemo.tsx:437 - Missing `discoveryQuestions` in useCallback + +**Current:** +```tsx +const saveFinding = useCallback(async (responses, traceId, isBackground) => { + const content = serializeResponsesToInsight(discoveryQuestions, responses); + // ... +}, [submitFinding, user?.id]); // BUG: missing discoveryQuestions +``` + +**Problem:** If `discoveryQuestions` changes, the callback will use stale questions. This is a real bug that could cause data corruption. + +**Fix:** Add to deps: +```tsx +}, [submitFinding, user?.id, discoveryQuestions]); +``` + +--- + +## Category 5: Intentional Exclusions + +These are cases where dependencies are intentionally excluded. Add disable comments with explanations. + +### WorkflowContext.tsx:123 + +The effect computes and sets `completedPhases` - adding it as a dep would cause infinite loop. + +```tsx +}, [traces, findings, rubric, annotations, participants, workshopId, user, workshop?.current_phase]); +// eslint-disable-next-line react-hooks/exhaustive-deps -- writes to completedPhases, cannot be a dependency +``` + +--- + +### JudgeTuningPage.tsx:301 + +Intentionally runs only on mount to load cached localStorage data, not on every question change. + +```tsx +}, [workshopId]); // Only run on mount, not on question change +// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes selectedQuestionIndex to only run on mount +``` + +--- + +### WorkshopDemoLanding.tsx:207 + +Error recovery logic that intentionally doesn't re-run when `createWorkshop` or `setWorkshopId` references change. + +```tsx +}, [workshopError, workshopId, user?.role, isAutoRecovering]); +// eslint-disable-next-line react-hooks/exhaustive-deps -- error recovery should only trigger on error/workshopId changes +``` + +--- + +### WorkshopDemoLanding.tsx:306 + +Navigation logic that intentionally excludes `isManualNavigation` to avoid re-running when the flag changes. + +```tsx +}, [user, workshop, currentPhase, currentView]); +// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes isManualNavigation to prevent circular updates +``` + +--- + +## Summary Table + +| File | Line | Issue | Category | Fix | +|------|------|-------|----------|-----| +| IntakePage.tsx | 100 | loadStatus | Data Fetching | **Create useMLflowStatus hook** | +| JudgeTuningPage.tsx | 279 | loadInitialData | Data Fetching | **Use existing TanStack hooks** | +| AnnotationDemo.tsx | 350,377,431 | parseLoadedComment, rubricQuestions | Derived State | **useMemo + move function outside** | +| TraceViewerDemo.tsx | 214 | existingFindings | Derived State | **useMemo** | +| IRRResultsDemo.tsx | 179 | perMetricScores | Derived State | **useMemo** | +| WorkshopContext.tsx | 140,148,183 | handleSetWorkshopId | Client State | **useCallback** | +| DBSQLExportPage.tsx | 144,156 | saveStateToStorage | Client State | **useCallback** | +| TraceViewerDemo.tsx | 437 | discoveryQuestions | **Real Bug** | **Add to deps** | +| WorkflowContext.tsx | 123 | completedPhases, workshop | Intentional | **Disable comment** | +| JudgeTuningPage.tsx | 301 | selectedQuestionIndex | Intentional | **Disable comment** | +| WorkshopDemoLanding.tsx | 207 | createWorkshop, etc. | Intentional | **Disable comment** | +| WorkshopDemoLanding.tsx | 306 | isManualNavigation | Intentional | **Disable comment** | + +--- + +## Implementation Order + +1. **Fix the real bug** (TraceViewerDemo:437) - Highest priority, could cause data issues +2. **Create useMLflowStatus hook** (IntakePage refactor) - Proper TanStack Query pattern +3. **useMemo fixes** (AnnotationDemo, TraceViewerDemo, IRRResultsDemo) - Remove derived state anti-pattern +4. **useCallback fixes** (WorkshopContext, DBSQLExportPage) - Stabilize function references +5. **Disable comments** for intentional exclusions - Document the reasoning diff --git a/client/bypassed-login-layout.png b/client/bypassed-login-layout.png deleted file mode 100644 index 1af736b8..00000000 Binary files a/client/bypassed-login-layout.png and /dev/null differ diff --git a/client/debug-screenshot.png b/client/debug-screenshot.png deleted file mode 100644 index c4c84843..00000000 Binary files a/client/debug-screenshot.png and /dev/null differ diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 00000000..bcf97b89 --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,62 @@ +// eslint.config.js +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import pluginQuery from '@tanstack/eslint-plugin-query'; +import tseslint from 'typescript-eslint'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default tseslint.config( + // Global ignores + { ignores: ['dist', 'src/client/**'] }, + + // TanStack Query recommended config + ...pluginQuery.configs['flat/recommended'], + + // Main config for TS/TSX files + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + languageOptions: { + ecmaVersion: 2020, + sourceType: 'module', + globals: globals.browser, + parserOptions: { + project: ['./tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-misused-promises': 'warn', + '@typescript-eslint/await-thenable': 'warn', + '@typescript-eslint/require-await': 'warn', + '@typescript-eslint/no-base-to-string': 'warn', + }, + } +); diff --git a/client/layout-error-check.png b/client/layout-error-check.png deleted file mode 100644 index c4c84843..00000000 Binary files a/client/layout-error-check.png and /dev/null differ diff --git a/client/package-lock.json b/client/package-lock.json index 032d36b9..e636bea2 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@playwright/test": "^1.54.1", + "@tanstack/eslint-plugin-query": "^5.91.3", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -68,6 +69,7 @@ "tailwindcss": "^3.4.0", "terser": "^5.44.1", "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", "vite": "^5.0.8", "vitest": "^2.1.9" } @@ -749,9 +751,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -768,9 +770,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -3230,6 +3232,167 @@ "node": ">=4" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.91.3", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.3.tgz", + "integrity": "sha512-5GMGZMYFK9dOvjpdedjJs4hU40EdPuO2AjzObQzP7eOSsikunCfrXaU3oNGXSsvoU9ve1Z1xQZZuDyPi0C1M7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.48.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tanstack/eslint-plugin-query/node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/@tanstack/query-core": { "version": "5.82.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.82.0.tgz", @@ -3603,6 +3766,42 @@ } } }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", @@ -3621,6 +3820,23 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/type-utils": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", @@ -4677,9 +4893,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8635,9 +8851,9 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -9199,6 +9415,54 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -9355,6 +9619,263 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/typescript-eslint/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript-eslint/node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/client/package.json b/client/package.json index e5cda12c..9d3debc2 100644 --- a/client/package.json +++ b/client/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "start": "npx vite", - "dev": "npx vite", + "dev": "concurrently -k \"npm run dev:vite\" \"npm run typecheck:watch\"", + "dev:vite": "npx vite", "build": "npx vite build", "preview": "npx vite preview", "test": "npx playwright test", @@ -13,7 +14,9 @@ "test:unit": "vitest run", "test:unit:watch": "vitest", "test:unit:coverage": "vitest run --coverage", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "typecheck": "tsc -p tsconfig.eslint.json --noEmit", + "typecheck:watch": "tsc -p tsconfig.eslint.json --noEmit --watch", + "lint": "eslint .", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"", "knip": "knip" }, @@ -57,6 +60,7 @@ }, "devDependencies": { "@playwright/test": "^1.54.1", + "@tanstack/eslint-plugin-query": "^5.91.3", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -68,6 +72,7 @@ "@vitejs/plugin-react-swc": "^3.10.2", "@vitest/coverage-v8": "^2.1.9", "autoprefixer": "^10.4.16", + "concurrently": "^9.2.1", "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", @@ -78,6 +83,7 @@ "tailwindcss": "^3.4.0", "terser": "^5.44.1", "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", "vite": "^5.0.8", "vitest": "^2.1.9" } diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 38348412..e5dd73d7 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -3,14 +3,32 @@ import { defineConfig, devices } from '@playwright/test'; const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3000'; const useWebServer = !process.env.PW_NO_WEBSERVER; +// JSON reporter for LLM agents (token-efficient test results) +const useJsonReporter = process.env.PW_JSON_REPORT === '1'; + +// Verbose console error logging - set PW_VERBOSE_CONSOLE=1 to see all console output +const verboseConsole = process.env.PW_VERBOSE_CONSOLE === '1'; + +// Timeout configuration: can be overridden via environment variables +const testTimeout = process.env.PW_TEST_TIMEOUT + ? parseInt(process.env.PW_TEST_TIMEOUT, 10) + : 30_000; +const expectTimeout = process.env.PW_EXPECT_TIMEOUT + ? parseInt(process.env.PW_EXPECT_TIMEOUT, 10) + : 5_000; + export default defineConfig({ testDir: './tests', - timeout: 60_000, + timeout: testTimeout, expect: { - timeout: 10_000, + timeout: expectTimeout, }, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, + // Reporter configuration: JSON for agents, line for humans + reporter: useJsonReporter + ? [['json', { outputFile: '../.test-results/playwright.json' }]] + : [['list']], use: { baseURL, trace: 'retain-on-failure', diff --git a/client/src/client/core/CancelablePromise.ts b/client/src/client/core/CancelablePromise.ts index 68ff6ed2..d70de929 100644 --- a/client/src/client/core/CancelablePromise.ts +++ b/client/src/client/core/CancelablePromise.ts @@ -117,7 +117,7 @@ export class CancelablePromise implements Promise { cancelHandler(); } } catch (error) { - // Silent fail for cancellation errors + console.warn('Cancellation threw an error', error); return; } } diff --git a/client/src/client/core/request.ts b/client/src/client/core/request.ts index 06746ac9..f83d7119 100644 --- a/client/src/client/core/request.ts +++ b/client/src/client/core/request.ts @@ -243,7 +243,7 @@ export const getResponseBody = async (response: Response): Promise => { } } } catch (error) { - // Silent fail for error body parsing + console.error(error); } } return undefined; diff --git a/client/src/client/index.ts b/client/src/client/index.ts index 791bd997..95c810b5 100644 --- a/client/src/client/index.ts +++ b/client/src/client/index.ts @@ -7,11 +7,15 @@ export { CancelablePromise, CancelError } from './core/CancelablePromise'; export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; +export type { AlignmentRequest } from './models/AlignmentRequest'; export type { Annotation } from './models/Annotation'; export type { AnnotationCreate } from './models/AnnotationCreate'; export type { AuthResponse } from './models/AuthResponse'; export type { Body_call_chat_completion_databricks_chat_post } from './models/Body_call_chat_completion_databricks_chat_post'; export type { Body_call_serving_endpoint_databricks_call_post } from './models/Body_call_serving_endpoint_databricks_call_post'; +export type { Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post } from './models/Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post'; +export type { Body_upload_csv_traces_workshops__workshop_id__csv_upload_post } from './models/Body_upload_csv_traces_workshops__workshop_id__csv_upload_post'; +export type { ConvergenceMetricsResponse } from './models/ConvergenceMetricsResponse'; export type { DatabricksChatCompletion } from './models/DatabricksChatCompletion'; export type { DatabricksChatMessage } from './models/DatabricksChatMessage'; export type { DatabricksConfig } from './models/DatabricksConfig'; @@ -21,44 +25,59 @@ export type { DatabricksEndpointInfo } from './models/DatabricksEndpointInfo'; export type { DatabricksResponse } from './models/DatabricksResponse'; export type { DBSQLExportRequest } from './models/DBSQLExportRequest'; export type { DBSQLExportResponse } from './models/DBSQLExportResponse'; +export type { DiscoveryCoverage } from './models/DiscoveryCoverage'; export type { DiscoveryFinding } from './models/DiscoveryFinding'; export type { DiscoveryFindingCreate } from './models/DiscoveryFindingCreate'; +export type { DiscoveryQuestion } from './models/DiscoveryQuestion'; +export type { DiscoveryQuestionsModelConfig } from './models/DiscoveryQuestionsModelConfig'; +export type { DiscoveryQuestionsResponse } from './models/DiscoveryQuestionsResponse'; +export type { DiscoverySummariesResponse } from './models/DiscoverySummariesResponse'; +export type { DiscussionPromptResponse } from './models/DiscussionPromptResponse'; export type { FacilitatorConfigCreate } from './models/FacilitatorConfigCreate'; export type { HTTPValidationError } from './models/HTTPValidationError'; export type { IRRResult } from './models/IRRResult'; +export type { JsonPathPreviewRequest } from './models/JsonPathPreviewRequest'; +export type { JsonPathSettingsUpdate } from './models/JsonPathSettingsUpdate'; export type { JudgeEvaluation } from './models/JudgeEvaluation'; export type { JudgeEvaluationDirectRequest } from './models/JudgeEvaluationDirectRequest'; export type { JudgeEvaluationRequest } from './models/JudgeEvaluationRequest'; export type { JudgeEvaluationResult } from './models/JudgeEvaluationResult'; export type { JudgeExportConfig } from './models/JudgeExportConfig'; export type { JudgePerformanceMetrics } from './models/JudgePerformanceMetrics'; -export type { JudgePrompt, JudgeType } from './models/JudgePrompt'; +export type { JudgePrompt } from './models/JudgePrompt'; export type { JudgePromptCreate } from './models/JudgePromptCreate'; +export { JudgeType } from './models/JudgeType'; +export type { KeyDisagreementResponse } from './models/KeyDisagreementResponse'; export type { MLflowIntakeConfig } from './models/MLflowIntakeConfig'; export type { MLflowIntakeConfigCreate } from './models/MLflowIntakeConfigCreate'; export type { MLflowIntakeStatus } from './models/MLflowIntakeStatus'; export type { MLflowTraceInfo } from './models/MLflowTraceInfo'; +export type { PromoteFindingRequest } from './models/PromoteFindingRequest'; export type { Rubric } from './models/Rubric'; export type { RubricCreate } from './models/RubricCreate'; +export type { SimpleEvaluationRequest } from './models/SimpleEvaluationRequest'; +export type { SubmitFindingV2Request } from './models/SubmitFindingV2Request'; export type { Trace } from './models/Trace'; export type { TraceUpload } from './models/TraceUpload'; +export type { UpdateThresholdsRequest } from './models/UpdateThresholdsRequest'; export type { User } from './models/User'; export type { UserCreate } from './models/UserCreate'; export type { UserInvite } from './models/UserInvite'; export type { UserLogin } from './models/UserLogin'; export type { UserPermissions } from './models/UserPermissions'; -export type { UserRole } from './models/UserRole'; -export type { UserStatus } from './models/UserStatus'; +export { UserRole } from './models/UserRole'; +export { UserStatus } from './models/UserStatus'; export type { ValidationError } from './models/ValidationError'; export type { Workshop } from './models/Workshop'; export type { WorkshopCreate } from './models/WorkshopCreate'; export type { WorkshopParticipant } from './models/WorkshopParticipant'; -export type { WorkshopPhase } from './models/WorkshopPhase'; -export type { WorkshopStatus } from './models/WorkshopStatus'; +export { WorkshopPhase } from './models/WorkshopPhase'; +export { WorkshopStatus } from './models/WorkshopStatus'; export { ApiService } from './services/ApiService'; export { DatabricksService } from './services/DatabricksService'; export { DbsqlExportService } from './services/DbsqlExportService'; export { DefaultService } from './services/DefaultService'; +export { DiscoveryService } from './services/DiscoveryService'; export { UsersService } from './services/UsersService'; export { WorkshopsService } from './services/WorkshopsService'; diff --git a/client/src/client/models/AlignmentRequest.ts b/client/src/client/models/AlignmentRequest.ts new file mode 100644 index 00000000..f262a58b --- /dev/null +++ b/client/src/client/models/AlignmentRequest.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Request model for running judge alignment. + */ +export type AlignmentRequest = { + judge_name: string; + judge_prompt: string; + evaluation_model_name: string; + alignment_model_name?: (string | null); + prompt_id?: (string | null); + judge_type?: (string | null); +}; + diff --git a/client/src/client/models/Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post.ts b/client/src/client/models/Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post.ts new file mode 100644 index 00000000..547285d9 --- /dev/null +++ b/client/src/client/models/Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post = { + file: Blob; + databricks_host?: string; + databricks_token?: string; + experiment_id?: string; +}; + diff --git a/client/src/client/models/Body_upload_csv_traces_workshops__workshop_id__csv_upload_post.ts b/client/src/client/models/Body_upload_csv_traces_workshops__workshop_id__csv_upload_post.ts new file mode 100644 index 00000000..b8ab3136 --- /dev/null +++ b/client/src/client/models/Body_upload_csv_traces_workshops__workshop_id__csv_upload_post.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Body_upload_csv_traces_workshops__workshop_id__csv_upload_post = { + file: Blob; +}; + diff --git a/client/src/client/models/ConvergenceMetricsResponse.ts b/client/src/client/models/ConvergenceMetricsResponse.ts new file mode 100644 index 00000000..33350a86 --- /dev/null +++ b/client/src/client/models/ConvergenceMetricsResponse.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Cross-participant agreement metrics. + */ +export type ConvergenceMetricsResponse = { + theme_agreement?: Record; + overall_alignment_score?: number; +}; + diff --git a/client/src/client/models/DiscoveryCoverage.ts b/client/src/client/models/DiscoveryCoverage.ts new file mode 100644 index 00000000..9e48bd7b --- /dev/null +++ b/client/src/client/models/DiscoveryCoverage.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Coverage state for discovery questions. + */ +export type DiscoveryCoverage = { + covered: Array; + missing: Array; +}; + diff --git a/client/src/client/models/DiscoveryFinding.ts b/client/src/client/models/DiscoveryFinding.ts index b4fe15af..4c41356f 100644 --- a/client/src/client/models/DiscoveryFinding.ts +++ b/client/src/client/models/DiscoveryFinding.ts @@ -8,6 +8,7 @@ export type DiscoveryFinding = { trace_id: string; user_id: string; insight: string; + category?: (string | null); created_at?: string; }; diff --git a/client/src/client/models/DiscoveryFindingCreate.ts b/client/src/client/models/DiscoveryFindingCreate.ts index f136952f..4c7943c9 100644 --- a/client/src/client/models/DiscoveryFindingCreate.ts +++ b/client/src/client/models/DiscoveryFindingCreate.ts @@ -6,5 +6,6 @@ export type DiscoveryFindingCreate = { trace_id: string; user_id: string; insight: string; + category?: (string | null); }; diff --git a/client/src/client/models/DiscoveryQuestion.ts b/client/src/client/models/DiscoveryQuestion.ts new file mode 100644 index 00000000..55814b3a --- /dev/null +++ b/client/src/client/models/DiscoveryQuestion.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A single discovery-phase question rendered in the participant UI. + */ +export type DiscoveryQuestion = { + id: string; + prompt: string; + placeholder?: (string | null); + category?: (string | null); +}; + diff --git a/client/src/client/models/DiscoveryQuestionsModelConfig.ts b/client/src/client/models/DiscoveryQuestionsModelConfig.ts new file mode 100644 index 00000000..995eeb4e --- /dev/null +++ b/client/src/client/models/DiscoveryQuestionsModelConfig.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Workshop-level config for discovery question generation. + */ +export type DiscoveryQuestionsModelConfig = { + model_name: string; +}; + diff --git a/client/src/client/models/DiscoveryQuestionsResponse.ts b/client/src/client/models/DiscoveryQuestionsResponse.ts new file mode 100644 index 00000000..085ddeb7 --- /dev/null +++ b/client/src/client/models/DiscoveryQuestionsResponse.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DiscoveryCoverage } from './DiscoveryCoverage'; +import type { DiscoveryQuestion } from './DiscoveryQuestion'; +/** + * Response model for discovery questions with coverage metadata. + */ +export type DiscoveryQuestionsResponse = { + questions: Array; + can_generate_more?: boolean; + stop_reason?: (string | null); + coverage: DiscoveryCoverage; +}; + diff --git a/client/src/client/models/DiscoverySummariesResponse.ts b/client/src/client/models/DiscoverySummariesResponse.ts new file mode 100644 index 00000000..90d52372 --- /dev/null +++ b/client/src/client/models/DiscoverySummariesResponse.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ConvergenceMetricsResponse } from './ConvergenceMetricsResponse'; +import type { DiscussionPromptResponse } from './DiscussionPromptResponse'; +import type { KeyDisagreementResponse } from './KeyDisagreementResponse'; +/** + * LLM-generated summaries of discovery findings for facilitators. + */ +export type DiscoverySummariesResponse = { + overall: Record; + by_user: Array>; + by_trace: Array>; + candidate_rubric_questions?: Array; + key_disagreements?: Array; + discussion_prompts?: Array; + convergence?: ConvergenceMetricsResponse; + ready_for_rubric?: boolean; +}; + diff --git a/client/src/client/models/DiscussionPromptResponse.ts b/client/src/client/models/DiscussionPromptResponse.ts new file mode 100644 index 00000000..6ad626e1 --- /dev/null +++ b/client/src/client/models/DiscussionPromptResponse.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A facilitator discussion prompt. + */ +export type DiscussionPromptResponse = { + theme: string; + prompt: string; +}; + diff --git a/client/src/client/models/JsonPathPreviewRequest.ts b/client/src/client/models/JsonPathPreviewRequest.ts new file mode 100644 index 00000000..d83bb1b4 --- /dev/null +++ b/client/src/client/models/JsonPathPreviewRequest.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Request model for previewing JSONPath extraction. + */ +export type JsonPathPreviewRequest = { + input_jsonpath?: (string | null); + output_jsonpath?: (string | null); +}; + diff --git a/client/src/client/models/JsonPathSettingsUpdate.ts b/client/src/client/models/JsonPathSettingsUpdate.ts new file mode 100644 index 00000000..ea89f8eb --- /dev/null +++ b/client/src/client/models/JsonPathSettingsUpdate.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Request model for updating JSONPath settings. + */ +export type JsonPathSettingsUpdate = { + input_jsonpath?: (string | null); + output_jsonpath?: (string | null); +}; + diff --git a/client/src/client/models/JudgeEvaluation.ts b/client/src/client/models/JudgeEvaluation.ts index e593a228..bab20a2e 100644 --- a/client/src/client/models/JudgeEvaluation.ts +++ b/client/src/client/models/JudgeEvaluation.ts @@ -10,16 +10,12 @@ export type JudgeEvaluation = { workshop_id: string; prompt_id: string; trace_id: string; - // For rubric judges (1-5 scale) predicted_rating?: (number | null); human_rating?: (number | null); - // For binary judges (pass/fail) predicted_binary?: (boolean | null); human_binary?: (boolean | null); - // For freeform judges (text feedback) predicted_feedback?: (string | null); human_feedback?: (string | null); - // Common fields confidence?: (number | null); reasoning?: (string | null); }; diff --git a/client/src/client/models/JudgePrompt.ts b/client/src/client/models/JudgePrompt.ts index ee853011..f3e0d7e1 100644 --- a/client/src/client/models/JudgePrompt.ts +++ b/client/src/client/models/JudgePrompt.ts @@ -2,12 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ - -/** - * Type of judge evaluation. - */ -export type JudgeType = 'likert' | 'binary' | 'freeform'; - +import type { JudgeType } from './JudgeType'; /** * Judge prompt model. */ @@ -21,7 +16,7 @@ export type JudgePrompt = { model_name?: string; model_parameters?: (Record | null); binary_labels?: (Record | null); - rating_scale?: number; + rating_scale?: (number | null); created_by: string; created_at?: string; performance_metrics?: (Record | null); diff --git a/client/src/client/models/JudgePromptCreate.ts b/client/src/client/models/JudgePromptCreate.ts index f264d33e..8a678f66 100644 --- a/client/src/client/models/JudgePromptCreate.ts +++ b/client/src/client/models/JudgePromptCreate.ts @@ -2,8 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { JudgeType } from './JudgePrompt'; - +import type { JudgeType } from './JudgeType'; /** * Request model for creating a judge prompt. */ @@ -13,7 +12,7 @@ export type JudgePromptCreate = { */ prompt_text: string; /** - * Type of judge: rubric, binary, or freeform + * Type of judge: likert, binary, or freeform */ judge_type?: JudgeType; /** @@ -29,12 +28,12 @@ export type JudgePromptCreate = { */ model_parameters?: (Record | null); /** - * Custom labels for binary judge + * Custom labels for binary judge, e.g. {"pass": "Pass", "fail": "Fail"} */ binary_labels?: (Record | null); /** * Rating scale for rubric judge (default 5-point) */ - rating_scale?: number; + rating_scale?: (number | null); }; diff --git a/client/src/client/models/JudgeType.ts b/client/src/client/models/JudgeType.ts new file mode 100644 index 00000000..d4f261f4 --- /dev/null +++ b/client/src/client/models/JudgeType.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Type of judge evaluation. + */ +export enum JudgeType { + LIKERT = 'likert', + BINARY = 'binary', + FREEFORM = 'freeform', +} diff --git a/client/src/client/models/KeyDisagreementResponse.ts b/client/src/client/models/KeyDisagreementResponse.ts new file mode 100644 index 00000000..a542828d --- /dev/null +++ b/client/src/client/models/KeyDisagreementResponse.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * A disagreement between participants. + */ +export type KeyDisagreementResponse = { + theme: string; + trace_ids?: Array; + viewpoints?: Array; +}; + diff --git a/client/src/client/models/PromoteFindingRequest.ts b/client/src/client/models/PromoteFindingRequest.ts new file mode 100644 index 00000000..47752315 --- /dev/null +++ b/client/src/client/models/PromoteFindingRequest.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Request to promote a finding. + */ +export type PromoteFindingRequest = { + finding_id: string; + promoter_id: string; +}; + diff --git a/client/src/client/models/Rubric.ts b/client/src/client/models/Rubric.ts index d989d8cc..3b8a2fab 100644 --- a/client/src/client/models/Rubric.ts +++ b/client/src/client/models/Rubric.ts @@ -2,8 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { JudgeType } from './JudgePrompt'; - +import type { JudgeType } from './JudgeType'; export type Rubric = { id: string; workshop_id: string; diff --git a/client/src/client/models/RubricCreate.ts b/client/src/client/models/RubricCreate.ts index 5803929b..6c0bff28 100644 --- a/client/src/client/models/RubricCreate.ts +++ b/client/src/client/models/RubricCreate.ts @@ -2,13 +2,21 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { JudgeType } from './JudgePrompt'; - +import type { JudgeType } from './JudgeType'; export type RubricCreate = { question: string; created_by: string; - judge_type?: JudgeType; + /** + * Type of judge: likert, binary, or freeform + */ + judge_type?: (JudgeType | null); + /** + * Custom labels for binary judge + */ binary_labels?: (Record | null); - rating_scale?: number; + /** + * Rating scale for rubric judge + */ + rating_scale?: (number | null); }; diff --git a/client/src/client/models/SimpleEvaluationRequest.ts b/client/src/client/models/SimpleEvaluationRequest.ts new file mode 100644 index 00000000..66e1a9a6 --- /dev/null +++ b/client/src/client/models/SimpleEvaluationRequest.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Request model for simple model serving evaluation (no MLflow). + */ +export type SimpleEvaluationRequest = { + judge_prompt: string; + endpoint_name: string; + prompt_id?: (string | null); + judge_type?: (string | null); +}; + diff --git a/client/src/client/models/SubmitFindingV2Request.ts b/client/src/client/models/SubmitFindingV2Request.ts new file mode 100644 index 00000000..c3637017 --- /dev/null +++ b/client/src/client/models/SubmitFindingV2Request.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Request to submit a finding with classification. + */ +export type SubmitFindingV2Request = { + trace_id: string; + user_id: string; + text: string; +}; + diff --git a/client/src/client/models/UpdateThresholdsRequest.ts b/client/src/client/models/UpdateThresholdsRequest.ts new file mode 100644 index 00000000..8c600a4d --- /dev/null +++ b/client/src/client/models/UpdateThresholdsRequest.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Request to update trace thresholds. + */ +export type UpdateThresholdsRequest = { + thresholds: Record; +}; + diff --git a/client/src/client/models/UserLogin.ts b/client/src/client/models/UserLogin.ts index aaa676b7..cee2ec32 100644 --- a/client/src/client/models/UserLogin.ts +++ b/client/src/client/models/UserLogin.ts @@ -5,6 +5,6 @@ export type UserLogin = { email: string; password: string; - workshop_id?: string; // Required for participants/SMEs to validate access + workshop_id?: (string | null); }; diff --git a/client/src/client/models/UserRole.ts b/client/src/client/models/UserRole.ts index e4ae9a09..6f26f41b 100644 --- a/client/src/client/models/UserRole.ts +++ b/client/src/client/models/UserRole.ts @@ -2,4 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type UserRole = 'facilitator' | 'sme' | 'participant'; +export enum UserRole { + FACILITATOR = 'facilitator', + SME = 'sme', + PARTICIPANT = 'participant', +} diff --git a/client/src/client/models/UserStatus.ts b/client/src/client/models/UserStatus.ts index 387fed97..4309d23f 100644 --- a/client/src/client/models/UserStatus.ts +++ b/client/src/client/models/UserStatus.ts @@ -2,4 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type UserStatus = 'active' | 'inactive' | 'pending'; +export enum UserStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + PENDING = 'pending', +} diff --git a/client/src/client/models/Workshop.ts b/client/src/client/models/Workshop.ts index c6a7888d..01bd3525 100644 --- a/client/src/client/models/Workshop.ts +++ b/client/src/client/models/Workshop.ts @@ -19,6 +19,7 @@ export type Workshop = { discovery_randomize_traces?: boolean; annotation_randomize_traces?: boolean; judge_name?: string; + discovery_questions_model_name?: string; input_jsonpath?: (string | null); output_jsonpath?: (string | null); created_at?: string; diff --git a/client/src/client/models/WorkshopPhase.ts b/client/src/client/models/WorkshopPhase.ts index 76c64df5..f954a1df 100644 --- a/client/src/client/models/WorkshopPhase.ts +++ b/client/src/client/models/WorkshopPhase.ts @@ -2,4 +2,12 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type WorkshopPhase = 'intake' | 'discovery' | 'rubric' | 'annotation' | 'results' | 'judge_tuning' | 'unity_volume'; +export enum WorkshopPhase { + INTAKE = 'intake', + DISCOVERY = 'discovery', + RUBRIC = 'rubric', + ANNOTATION = 'annotation', + RESULTS = 'results', + JUDGE_TUNING = 'judge_tuning', + UNITY_VOLUME = 'unity_volume', +} diff --git a/client/src/client/models/WorkshopStatus.ts b/client/src/client/models/WorkshopStatus.ts index 8877be02..25939493 100644 --- a/client/src/client/models/WorkshopStatus.ts +++ b/client/src/client/models/WorkshopStatus.ts @@ -2,4 +2,8 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type WorkshopStatus = 'active' | 'completed' | 'cancelled'; +export enum WorkshopStatus { + ACTIVE = 'active', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} diff --git a/client/src/client/services/ApiService.ts b/client/src/client/services/ApiService.ts index 68c98d18..701cb1b2 100644 --- a/client/src/client/services/ApiService.ts +++ b/client/src/client/services/ApiService.ts @@ -2,11 +2,14 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { AlignmentRequest } from '../models/AlignmentRequest'; import type { Annotation } from '../models/Annotation'; import type { AnnotationCreate } from '../models/AnnotationCreate'; import type { AuthResponse } from '../models/AuthResponse'; import type { Body_call_chat_completion_databricks_chat_post } from '../models/Body_call_chat_completion_databricks_chat_post'; import type { Body_call_serving_endpoint_databricks_call_post } from '../models/Body_call_serving_endpoint_databricks_call_post'; +import type { Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post } from '../models/Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post'; +import type { Body_upload_csv_traces_workshops__workshop_id__csv_upload_post } from '../models/Body_upload_csv_traces_workshops__workshop_id__csv_upload_post'; import type { DatabricksConfig } from '../models/DatabricksConfig'; import type { DatabricksConnectionTest } from '../models/DatabricksConnectionTest'; import type { DatabricksEndpointInfo } from '../models/DatabricksEndpointInfo'; @@ -15,8 +18,13 @@ import type { DBSQLExportRequest } from '../models/DBSQLExportRequest'; import type { DBSQLExportResponse } from '../models/DBSQLExportResponse'; import type { DiscoveryFinding } from '../models/DiscoveryFinding'; import type { DiscoveryFindingCreate } from '../models/DiscoveryFindingCreate'; +import type { DiscoveryQuestionsModelConfig } from '../models/DiscoveryQuestionsModelConfig'; +import type { DiscoveryQuestionsResponse } from '../models/DiscoveryQuestionsResponse'; +import type { DiscoverySummariesResponse } from '../models/DiscoverySummariesResponse'; import type { FacilitatorConfigCreate } from '../models/FacilitatorConfigCreate'; import type { IRRResult } from '../models/IRRResult'; +import type { JsonPathPreviewRequest } from '../models/JsonPathPreviewRequest'; +import type { JsonPathSettingsUpdate } from '../models/JsonPathSettingsUpdate'; import type { JudgeEvaluation } from '../models/JudgeEvaluation'; import type { JudgeEvaluationDirectRequest } from '../models/JudgeEvaluationDirectRequest'; import type { JudgeEvaluationRequest } from '../models/JudgeEvaluationRequest'; @@ -29,10 +37,14 @@ import type { MLflowIntakeConfig } from '../models/MLflowIntakeConfig'; import type { MLflowIntakeConfigCreate } from '../models/MLflowIntakeConfigCreate'; import type { MLflowIntakeStatus } from '../models/MLflowIntakeStatus'; import type { MLflowTraceInfo } from '../models/MLflowTraceInfo'; +import type { PromoteFindingRequest } from '../models/PromoteFindingRequest'; import type { Rubric } from '../models/Rubric'; import type { RubricCreate } from '../models/RubricCreate'; +import type { SimpleEvaluationRequest } from '../models/SimpleEvaluationRequest'; +import type { SubmitFindingV2Request } from '../models/SubmitFindingV2Request'; import type { Trace } from '../models/Trace'; import type { TraceUpload } from '../models/TraceUpload'; +import type { UpdateThresholdsRequest } from '../models/UpdateThresholdsRequest'; import type { User } from '../models/User'; import type { UserCreate } from '../models/UserCreate'; import type { UserInvite } from '../models/UserInvite'; @@ -48,6 +60,38 @@ import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class ApiService { + /** + * List Workshops + * List all workshops, optionally filtered by facilitator or user. + * + * Args: + * facilitator_id: If provided, only return workshops created by this facilitator + * user_id: If provided, return all workshops the user has access to (as facilitator or participant) + * db: Database session + * + * Returns: + * List of workshops sorted by creation date (newest first) + * @param facilitatorId + * @param userId + * @returns Workshop Successful Response + * @throws ApiError + */ + public static listWorkshopsWorkshopsGet( + facilitatorId?: (string | null), + userId?: (string | null), + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/', + query: { + 'facilitator_id': facilitatorId, + 'user_id': userId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } /** * Create Workshop * Create a new workshop. @@ -89,6 +133,113 @@ export class ApiService { }, }); } + /** + * Update Judge Name + * Update the judge name for the workshop. Should be set before annotation phase. + * @param workshopId + * @param judgeName + * @returns any Successful Response + * @throws ApiError + */ + public static updateJudgeNameWorkshopsWorkshopIdJudgeNamePut( + workshopId: string, + judgeName: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/workshops/{workshop_id}/judge-name', + path: { + 'workshop_id': workshopId, + }, + query: { + 'judge_name': judgeName, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Update Jsonpath Settings + * Update JSONPath settings for trace display customization. + * + * These settings allow facilitators to configure JSONPath queries that + * extract specific values from trace inputs and outputs for cleaner display + * in the TraceViewer. + * @param workshopId + * @param requestBody + * @returns Workshop Successful Response + * @throws ApiError + */ + public static updateJsonpathSettingsWorkshopsWorkshopIdJsonpathSettingsPut( + workshopId: string, + requestBody: JsonPathSettingsUpdate, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/workshops/{workshop_id}/jsonpath-settings', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Preview Jsonpath + * Preview JSONPath extraction against the first trace in the workshop. + * + * This allows facilitators to test their JSONPath queries before saving + * to verify they extract the expected content. + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static previewJsonpathWorkshopsWorkshopIdPreviewJsonpathPost( + workshopId: string, + requestBody: JsonPathPreviewRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/preview-jsonpath', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Resync Annotations + * Re-sync all annotations to MLflow with the current workshop judge_name. + * + * This is useful when the judge_name changes after annotations were created. + * Creates new MLflow feedback entries with the correct judge_name. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static resyncAnnotationsWorkshopsWorkshopIdResyncAnnotationsPost( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/resync-annotations', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } /** * Upload Traces * Upload traces to a workshop. @@ -135,7 +286,7 @@ export class ApiService { */ public static getTracesWorkshopsWorkshopIdTracesGet( workshopId: string, - userId: string, + userId?: (string | null), ): CancelablePromise> { return __request(OpenAPI, { method: 'GET', @@ -151,6 +302,29 @@ export class ApiService { }, }); } + /** + * Delete All Traces + * Delete all traces for a workshop and reset to intake phase (facilitator only). + * + * This allows starting over with new trace data. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static deleteAllTracesWorkshopsWorkshopIdTracesDelete( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/workshops/{workshop_id}/traces', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } /** * Get All Traces * Get ALL traces for a workshop, unfiltered by phase. @@ -198,7 +372,6 @@ export class ApiService { } /** * Submit Finding - * Submit a discovery finding. * @param workshopId * @param requestBody * @returns DiscoveryFinding Successful Response @@ -223,7 +396,6 @@ export class ApiService { } /** * Get Findings - * Get discovery findings for a workshop, optionally filtered by user. * @param workshopId * @param userId * @returns DiscoveryFinding Successful Response @@ -270,7 +442,6 @@ export class ApiService { } /** * Get Findings With User Details - * Get discovery findings with user details for facilitator view. * @param workshopId * @param userId * @returns any Successful Response @@ -559,12 +730,6 @@ export class ApiService { } /** * Begin Discovery Phase - * Begin the discovery phase and distribute traces to participants. - * - * Args: - * workshop_id: The workshop ID - * trace_limit: Optional limit on number of traces to use (default: all) - * db: Database session * @param workshopId * @param traceLimit * @returns any Successful Response @@ -687,6 +852,15 @@ export class ApiService { /** * Begin Annotation Phase * Begin the annotation phase with a subset of traces. + * + * Args: + * workshop_id: The workshop ID + * request: JSON body with optional fields: + * - trace_limit: Number of traces to use (default: 10, -1 for all) + * - randomize: Whether to randomize trace order per user (default: False) + * + * When randomize=False (default): All SMEs see traces in the same chronological order. + * When randomize=True: All SMEs see the same set of traces but in different random orders. * @param workshopId * @param requestBody * @returns any Successful Response @@ -709,9 +883,56 @@ export class ApiService { }, }); } + /** + * Reset Discovery + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static resetDiscoveryWorkshopsWorkshopIdResetDiscoveryPost( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/reset-discovery', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Reset Annotation + * Reset a workshop back to before annotation phase started (facilitator only). + * + * This allows changing the annotation configuration (e.g., trace selection, randomization). + * + * IMPORTANT: This clears ALL SME annotation progress: + * - All annotations submitted by SMEs + * + * Traces are kept, but SMEs will start fresh from the beginning. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static resetAnnotationWorkshopsWorkshopIdResetAnnotationPost( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/reset-annotation', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } /** * Advance To Discovery - * Advance workshop from INTAKE to DISCOVERY phase (facilitator only). * @param workshopId * @returns any Successful Response * @throws ApiError @@ -842,7 +1063,6 @@ export class ApiService { } /** * Generate Discovery Test Data - * Generate realistic discovery findings for testing. * @param workshopId * @returns any Successful Response * @throws ApiError @@ -1403,9 +1623,84 @@ export class ApiService { }, }); } + /** + * Upload Csv Traces + * Upload traces from a MLflow trace export CSV file. + * + * Expected CSV format (MLflow export): + * - Required columns: request_preview, response_preview + * - Optional columns: trace_id, execution_duration_ms, state, request, response, + * spans, tags, trace_metadata, trace_location, assessments, etc. + * + * Example from MLflow export: + * trace_id,request_preview,response_preview,execution_duration_ms,state,... + * "tr-abc123","What is Python?","Python is a programming language",150,"OK",... + * @param workshopId + * @param formData + * @returns any Successful Response + * @throws ApiError + */ + public static uploadCsvTracesWorkshopsWorkshopIdCsvUploadPost( + workshopId: string, + formData: Body_upload_csv_traces_workshops__workshop_id__csv_upload_post, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/csv-upload', + path: { + 'workshop_id': workshopId, + }, + formData: formData, + mediaType: 'multipart/form-data', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Upload Csv And Log To Mlflow + * Upload CSV with request/response data and log each row as an MLflow trace. + * + * This enables customers who don't have existing MLflow traces to participate + * in the Judge Builder workshop by uploading conversational data as CSV. + * + * Expected CSV format: + * - Required columns: request_preview, response_preview + * - Optional columns: any additional metadata + * + * The endpoint will: + * 1. Parse the CSV file + * 2. For each row, create an MLflow trace with the request/response + * 3. Store the traces locally with their MLflow trace IDs + * + * Environment variables used if parameters not provided: + * - DATABRICKS_HOST + * - DATABRICKS_TOKEN + * - MLFLOW_EXPERIMENT_ID + * @param workshopId + * @param formData + * @returns any Successful Response + * @throws ApiError + */ + public static uploadCsvAndLogToMlflowWorkshopsWorkshopIdCsvUploadToMlflowPost( + workshopId: string, + formData: Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/csv-upload-to-mlflow', + path: { + 'workshop_id': workshopId, + }, + formData: formData, + mediaType: 'multipart/form-data', + errors: { + 422: `Validation Error`, + }, + }); + } /** * Mark User Discovery Complete - * Mark a user as having completed discovery for a workshop. * @param workshopId * @param userId * @returns any Successful Response @@ -1429,7 +1724,6 @@ export class ApiService { } /** * Get Discovery Completion Status - * Get discovery completion status for all users in a workshop. * @param workshopId * @returns any Successful Response * @throws ApiError @@ -1450,7 +1744,6 @@ export class ApiService { } /** * Is User Discovery Complete - * Check if a user has completed discovery for a workshop. * @param workshopId * @param userId * @returns any Successful Response @@ -1495,56 +1788,595 @@ export class ApiService { }); } /** - * Login - * Authenticate a user with email and password. - * @param requestBody - * @returns AuthResponse Successful Response + * Update Trace Alignment Inclusion + * Update whether a trace should be included in judge alignment. + * + * This allows facilitators to exclude traces with SME disagreement from the alignment process. + * @param workshopId + * @param traceId + * @param includeInAlignment + * @returns Trace Successful Response * @throws ApiError */ - public static loginUsersAuthLoginPost( - requestBody: UserLogin, - ): CancelablePromise { + public static updateTraceAlignmentInclusionWorkshopsWorkshopIdTracesTraceIdAlignmentPatch( + workshopId: string, + traceId: string, + includeInAlignment: boolean, + ): CancelablePromise { return __request(OpenAPI, { - method: 'POST', - url: '/users/auth/login', - body: requestBody, - mediaType: 'application/json', + method: 'PATCH', + url: '/workshops/{workshop_id}/traces/{trace_id}/alignment', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + query: { + 'include_in_alignment': includeInAlignment, + }, errors: { 422: `Validation Error`, }, }); } /** - * Create User - * Create a new user (no authentication required). - * @param requestBody - * @returns any Successful Response + * Get Traces For Alignment + * Get all traces that are marked for inclusion in judge alignment. + * + * Returns only traces where include_in_alignment is True. + * @param workshopId + * @returns Trace Successful Response * @throws ApiError */ - public static createUserUsersPost( - requestBody: UserCreate, - ): CancelablePromise { + public static getTracesForAlignmentWorkshopsWorkshopIdTracesForAlignmentGet( + workshopId: string, + ): CancelablePromise> { return __request(OpenAPI, { - method: 'POST', - url: '/users/', - body: requestBody, - mediaType: 'application/json', + method: 'GET', + url: '/workshops/{workshop_id}/traces-for-alignment', + path: { + 'workshop_id': workshopId, + }, errors: { 422: `Validation Error`, }, }); } /** - * List Users - * List users, optionally filtered by workshop or role. + * Aggregate Trace Feedback + * Aggregate all SME feedback for a trace and store it on the trace. + * + * This concatenates all non-empty comments from annotations on this trace + * into a single sme_feedback field for use in alignment. * @param workshopId - * @param role - * @returns User Successful Response + * @param traceId + * @returns Trace Successful Response * @throws ApiError */ - public static listUsersUsersGet( - workshopId?: (string | null), - role?: (UserRole | null), + public static aggregateTraceFeedbackWorkshopsWorkshopIdTracesTraceIdAggregateFeedbackPost( + workshopId: string, + traceId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/traces/{trace_id}/aggregate-feedback', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Aggregate All Trace Feedback + * Aggregate SME feedback for all annotated traces in the workshop. + * + * This is a batch operation that processes all traces and updates their sme_feedback fields. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static aggregateAllTraceFeedbackWorkshopsWorkshopIdAggregateAllFeedbackPost( + workshopId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/aggregate-all-feedback', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Start Alignment Job + * Start an alignment job in the background and return a job ID for polling. + * + * This is more reliable than SSE streaming as it avoids proxy buffering issues. + * Use GET /alignment-job/{job_id} to poll for status and logs. + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static startAlignmentJobWorkshopsWorkshopIdStartAlignmentPost( + workshopId: string, + requestBody: AlignmentRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/start-alignment', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Alignment Job Status + * Get the status and logs of an alignment job. + * + * Use `since_log_index` to get only new logs since the last poll. + * This allows efficient incremental updates without re-sending all logs. + * + * Returns: + * - status: pending, running, completed, or failed + * - logs: list of log messages (or new logs if since_log_index provided) + * - log_count: total number of logs + * - result: alignment result (if completed) + * - error: error message (if failed) + * @param workshopId + * @param jobId + * @param sinceLogIndex + * @returns any Successful Response + * @throws ApiError + */ + public static getAlignmentJobStatusWorkshopsWorkshopIdAlignmentJobJobIdGet( + workshopId: string, + jobId: string, + sinceLogIndex?: number, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/alignment-job/{job_id}', + path: { + 'workshop_id': workshopId, + 'job_id': jobId, + }, + query: { + 'since_log_index': sinceLogIndex, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Start Evaluation Job + * Start an evaluation job in the background and return a job ID for polling. + * + * This is more reliable than SSE streaming as it avoids proxy buffering issues. + * Use GET /evaluation-job/{job_id} to poll for status and logs. + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static startEvaluationJobWorkshopsWorkshopIdStartEvaluationPost( + workshopId: string, + requestBody: AlignmentRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/start-evaluation', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Start Simple Evaluation + * Start a simple evaluation job using Databricks Model Serving (no MLflow required). + * + * This endpoint evaluates the judge prompt by directly calling a Databricks model serving + * endpoint. This is useful when MLflow is not available or configured. + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static startSimpleEvaluationWorkshopsWorkshopIdStartSimpleEvaluationPost( + workshopId: string, + requestBody: SimpleEvaluationRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/start-simple-evaluation', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Evaluation Job Status + * Get the status and logs of an evaluation job. + * + * Use `since_log_index` to get only new logs since the last poll. + * This allows efficient incremental updates without re-sending all logs. + * + * Returns: + * - status: pending, running, completed, or failed + * - logs: list of log messages (or new logs if since_log_index provided) + * - log_count: total number of logs + * - result: evaluation result (if completed) + * - error: error message (if failed) + * @param workshopId + * @param jobId + * @param sinceLogIndex + * @returns any Successful Response + * @throws ApiError + */ + public static getEvaluationJobStatusWorkshopsWorkshopIdEvaluationJobJobIdGet( + workshopId: string, + jobId: string, + sinceLogIndex?: number, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/evaluation-job/{job_id}', + path: { + 'workshop_id': workshopId, + 'job_id': jobId, + }, + query: { + 'since_log_index': sinceLogIndex, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Alignment Status + * Get the current alignment status for a workshop. + * + * Returns information about: + * - Number of traces available for alignment + * - Whether evaluation has been run + * - Whether alignment is ready to run + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static getAlignmentStatusWorkshopsWorkshopIdAlignmentStatusGet( + workshopId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/alignment-status', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Discovery Questions + * @param workshopId + * @param traceId + * @param userId + * @param append + * @returns DiscoveryQuestionsResponse Successful Response + * @throws ApiError + */ + public static getDiscoveryQuestionsWorkshopsWorkshopIdTracesTraceIdDiscoveryQuestionsGet( + workshopId: string, + traceId: string, + userId?: (string | null), + append: boolean = false, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/traces/{trace_id}/discovery-questions', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + query: { + 'user_id': userId, + 'append': append, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Update Discovery Questions Model + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static updateDiscoveryQuestionsModelWorkshopsWorkshopIdDiscoveryQuestionsModelPut( + workshopId: string, + requestBody: DiscoveryQuestionsModelConfig, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/workshops/{workshop_id}/discovery-questions-model', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Generate Discovery Summaries + * @param workshopId + * @param refresh + * @returns DiscoverySummariesResponse Successful Response + * @throws ApiError + */ + public static generateDiscoverySummariesWorkshopsWorkshopIdDiscoverySummariesPost( + workshopId: string, + refresh: boolean = false, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/discovery-summaries', + path: { + 'workshop_id': workshopId, + }, + query: { + 'refresh': refresh, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Discovery Summaries + * @param workshopId + * @returns DiscoverySummariesResponse Successful Response + * @throws ApiError + */ + public static getDiscoverySummariesWorkshopsWorkshopIdDiscoverySummariesGet( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/discovery-summaries', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Submit Finding V2 + * Submit finding with real-time classification (v2 assisted facilitation). + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static submitFindingV2WorkshopsWorkshopIdFindingsV2Post( + workshopId: string, + requestBody: SubmitFindingV2Request, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/findings-v2', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Trace Discovery State + * Get full structured state for facilitator. + * @param workshopId + * @param traceId + * @returns any Successful Response + * @throws ApiError + */ + public static getTraceDiscoveryStateWorkshopsWorkshopIdTracesTraceIdDiscoveryStateGet( + workshopId: string, + traceId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/traces/{trace_id}/discovery-state', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Discovery Progress + * Get fuzzy global progress for participants. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static getDiscoveryProgressWorkshopsWorkshopIdDiscoveryProgressGet( + workshopId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/discovery-progress', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Promote Finding + * Promote finding to draft rubric. + * @param workshopId + * @param findingId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static promoteFindingWorkshopsWorkshopIdFindingsFindingIdPromotePost( + workshopId: string, + findingId: string, + requestBody: PromoteFindingRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/findings/{finding_id}/promote', + path: { + 'workshop_id': workshopId, + 'finding_id': findingId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Update Trace Thresholds + * Update thresholds for trace. + * @param workshopId + * @param traceId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static updateTraceThresholdsWorkshopsWorkshopIdTracesTraceIdThresholdsPut( + workshopId: string, + traceId: string, + requestBody: UpdateThresholdsRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'PUT', + url: '/workshops/{workshop_id}/traces/{trace_id}/thresholds', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Draft Rubric + * Get all promoted findings. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static getDraftRubricWorkshopsWorkshopIdDraftRubricGet( + workshopId: string, + ): CancelablePromise>> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/draft-rubric', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Login + * Authenticate a user with email and password. + * @param requestBody + * @returns AuthResponse Successful Response + * @throws ApiError + */ + public static loginUsersAuthLoginPost( + requestBody: UserLogin, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/users/auth/login', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Create User + * Create a new user (no authentication required). + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static createUserUsersPost( + requestBody: UserCreate, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/users/', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * List Users + * List users, optionally filtered by workshop or role. + * @param workshopId + * @param role + * @returns User Successful Response + * @throws ApiError + */ + public static listUsersUsersGet( + workshopId?: (string | null), + role?: (UserRole | null), ): CancelablePromise> { return __request(OpenAPI, { method: 'GET', @@ -1887,6 +2719,34 @@ export class ApiService { }, }); } + /** + * Update User Role In Workshop + * Update a user's role in a workshop (SME <-> Participant). + * @param workshopId + * @param userId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static updateUserRoleInWorkshopUsersWorkshopsWorkshopIdUsersUserIdRolePut( + workshopId: string, + userId: string, + requestBody: Record, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/users/workshops/{workshop_id}/users/{user_id}/role', + path: { + 'workshop_id': workshopId, + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } /** * Auto Assign Annotations * Automatically balance annotation assignments across SMEs and participants. diff --git a/client/src/client/services/DiscoveryService.ts b/client/src/client/services/DiscoveryService.ts new file mode 100644 index 00000000..ded95b59 --- /dev/null +++ b/client/src/client/services/DiscoveryService.ts @@ -0,0 +1,510 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DiscoveryFinding } from '../models/DiscoveryFinding'; +import type { DiscoveryFindingCreate } from '../models/DiscoveryFindingCreate'; +import type { DiscoveryQuestionsModelConfig } from '../models/DiscoveryQuestionsModelConfig'; +import type { DiscoveryQuestionsResponse } from '../models/DiscoveryQuestionsResponse'; +import type { DiscoverySummariesResponse } from '../models/DiscoverySummariesResponse'; +import type { PromoteFindingRequest } from '../models/PromoteFindingRequest'; +import type { SubmitFindingV2Request } from '../models/SubmitFindingV2Request'; +import type { UpdateThresholdsRequest } from '../models/UpdateThresholdsRequest'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class DiscoveryService { + /** + * Submit Finding + * @param workshopId + * @param requestBody + * @returns DiscoveryFinding Successful Response + * @throws ApiError + */ + public static submitFindingWorkshopsWorkshopIdFindingsPost( + workshopId: string, + requestBody: DiscoveryFindingCreate, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/findings', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Findings + * @param workshopId + * @param userId + * @returns DiscoveryFinding Successful Response + * @throws ApiError + */ + public static getFindingsWorkshopsWorkshopIdFindingsGet( + workshopId: string, + userId?: (string | null), + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/findings', + path: { + 'workshop_id': workshopId, + }, + query: { + 'user_id': userId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Clear Findings + * Clear all findings for a workshop (for testing). + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static clearFindingsWorkshopsWorkshopIdFindingsDelete( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/workshops/{workshop_id}/findings', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Findings With User Details + * @param workshopId + * @param userId + * @returns any Successful Response + * @throws ApiError + */ + public static getFindingsWithUserDetailsWorkshopsWorkshopIdFindingsWithUsersGet( + workshopId: string, + userId?: (string | null), + ): CancelablePromise>> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/findings-with-users', + path: { + 'workshop_id': workshopId, + }, + query: { + 'user_id': userId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Begin Discovery Phase + * @param workshopId + * @param traceLimit + * @returns any Successful Response + * @throws ApiError + */ + public static beginDiscoveryPhaseWorkshopsWorkshopIdBeginDiscoveryPost( + workshopId: string, + traceLimit?: (number | null), + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/begin-discovery', + path: { + 'workshop_id': workshopId, + }, + query: { + 'trace_limit': traceLimit, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Reset Discovery + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static resetDiscoveryWorkshopsWorkshopIdResetDiscoveryPost( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/reset-discovery', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Advance To Discovery + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static advanceToDiscoveryWorkshopsWorkshopIdAdvanceToDiscoveryPost( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/advance-to-discovery', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Generate Discovery Test Data + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static generateDiscoveryTestDataWorkshopsWorkshopIdGenerateDiscoveryDataPost( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/generate-discovery-data', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Mark User Discovery Complete + * @param workshopId + * @param userId + * @returns any Successful Response + * @throws ApiError + */ + public static markUserDiscoveryCompleteWorkshopsWorkshopIdUsersUserIdCompleteDiscoveryPost( + workshopId: string, + userId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/users/{user_id}/complete-discovery', + path: { + 'workshop_id': workshopId, + 'user_id': userId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Discovery Completion Status + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static getDiscoveryCompletionStatusWorkshopsWorkshopIdDiscoveryCompletionStatusGet( + workshopId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/discovery-completion-status', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Is User Discovery Complete + * @param workshopId + * @param userId + * @returns any Successful Response + * @throws ApiError + */ + public static isUserDiscoveryCompleteWorkshopsWorkshopIdUsersUserIdDiscoveryCompleteGet( + workshopId: string, + userId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/users/{user_id}/discovery-complete', + path: { + 'workshop_id': workshopId, + 'user_id': userId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Discovery Questions + * @param workshopId + * @param traceId + * @param userId + * @param append + * @returns DiscoveryQuestionsResponse Successful Response + * @throws ApiError + */ + public static getDiscoveryQuestionsWorkshopsWorkshopIdTracesTraceIdDiscoveryQuestionsGet( + workshopId: string, + traceId: string, + userId?: (string | null), + append: boolean = false, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/traces/{trace_id}/discovery-questions', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + query: { + 'user_id': userId, + 'append': append, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Update Discovery Questions Model + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static updateDiscoveryQuestionsModelWorkshopsWorkshopIdDiscoveryQuestionsModelPut( + workshopId: string, + requestBody: DiscoveryQuestionsModelConfig, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/workshops/{workshop_id}/discovery-questions-model', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Generate Discovery Summaries + * @param workshopId + * @param refresh + * @returns DiscoverySummariesResponse Successful Response + * @throws ApiError + */ + public static generateDiscoverySummariesWorkshopsWorkshopIdDiscoverySummariesPost( + workshopId: string, + refresh: boolean = false, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/discovery-summaries', + path: { + 'workshop_id': workshopId, + }, + query: { + 'refresh': refresh, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Discovery Summaries + * @param workshopId + * @returns DiscoverySummariesResponse Successful Response + * @throws ApiError + */ + public static getDiscoverySummariesWorkshopsWorkshopIdDiscoverySummariesGet( + workshopId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/discovery-summaries', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Submit Finding V2 + * Submit finding with real-time classification (v2 assisted facilitation). + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static submitFindingV2WorkshopsWorkshopIdFindingsV2Post( + workshopId: string, + requestBody: SubmitFindingV2Request, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/findings-v2', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Trace Discovery State + * Get full structured state for facilitator. + * @param workshopId + * @param traceId + * @returns any Successful Response + * @throws ApiError + */ + public static getTraceDiscoveryStateWorkshopsWorkshopIdTracesTraceIdDiscoveryStateGet( + workshopId: string, + traceId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/traces/{trace_id}/discovery-state', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Discovery Progress + * Get fuzzy global progress for participants. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static getDiscoveryProgressWorkshopsWorkshopIdDiscoveryProgressGet( + workshopId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/discovery-progress', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Promote Finding + * Promote finding to draft rubric. + * @param workshopId + * @param findingId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static promoteFindingWorkshopsWorkshopIdFindingsFindingIdPromotePost( + workshopId: string, + findingId: string, + requestBody: PromoteFindingRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/findings/{finding_id}/promote', + path: { + 'workshop_id': workshopId, + 'finding_id': findingId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Update Trace Thresholds + * Update thresholds for trace. + * @param workshopId + * @param traceId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static updateTraceThresholdsWorkshopsWorkshopIdTracesTraceIdThresholdsPut( + workshopId: string, + traceId: string, + requestBody: UpdateThresholdsRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'PUT', + url: '/workshops/{workshop_id}/traces/{trace_id}/thresholds', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Draft Rubric + * Get all promoted findings. + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static getDraftRubricWorkshopsWorkshopIdDraftRubricGet( + workshopId: string, + ): CancelablePromise>> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/draft-rubric', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } +} diff --git a/client/src/client/services/UsersService.ts b/client/src/client/services/UsersService.ts index d9173276..e9a13107 100644 --- a/client/src/client/services/UsersService.ts +++ b/client/src/client/services/UsersService.ts @@ -409,6 +409,34 @@ export class UsersService { }, }); } + /** + * Update User Role In Workshop + * Update a user's role in a workshop (SME <-> Participant). + * @param workshopId + * @param userId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static updateUserRoleInWorkshopUsersWorkshopsWorkshopIdUsersUserIdRolePut( + workshopId: string, + userId: string, + requestBody: Record, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/users/workshops/{workshop_id}/users/{user_id}/role', + path: { + 'workshop_id': workshopId, + 'user_id': userId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } /** * Auto Assign Annotations * Automatically balance annotation assignments across SMEs and participants. diff --git a/client/src/client/services/WorkshopsService.ts b/client/src/client/services/WorkshopsService.ts index f920d77f..03431b21 100644 --- a/client/src/client/services/WorkshopsService.ts +++ b/client/src/client/services/WorkshopsService.ts @@ -2,11 +2,14 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { AlignmentRequest } from '../models/AlignmentRequest'; import type { Annotation } from '../models/Annotation'; import type { AnnotationCreate } from '../models/AnnotationCreate'; -import type { DiscoveryFinding } from '../models/DiscoveryFinding'; -import type { DiscoveryFindingCreate } from '../models/DiscoveryFindingCreate'; +import type { Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post } from '../models/Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post'; +import type { Body_upload_csv_traces_workshops__workshop_id__csv_upload_post } from '../models/Body_upload_csv_traces_workshops__workshop_id__csv_upload_post'; import type { IRRResult } from '../models/IRRResult'; +import type { JsonPathPreviewRequest } from '../models/JsonPathPreviewRequest'; +import type { JsonPathSettingsUpdate } from '../models/JsonPathSettingsUpdate'; import type { JudgeEvaluation } from '../models/JudgeEvaluation'; import type { JudgeEvaluationDirectRequest } from '../models/JudgeEvaluationDirectRequest'; import type { JudgeEvaluationRequest } from '../models/JudgeEvaluationRequest'; @@ -21,6 +24,7 @@ import type { MLflowIntakeStatus } from '../models/MLflowIntakeStatus'; import type { MLflowTraceInfo } from '../models/MLflowTraceInfo'; import type { Rubric } from '../models/Rubric'; import type { RubricCreate } from '../models/RubricCreate'; +import type { SimpleEvaluationRequest } from '../models/SimpleEvaluationRequest'; import type { Trace } from '../models/Trace'; import type { TraceUpload } from '../models/TraceUpload'; import type { Workshop } from '../models/Workshop'; @@ -30,6 +34,38 @@ import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class WorkshopsService { + /** + * List Workshops + * List all workshops, optionally filtered by facilitator or user. + * + * Args: + * facilitator_id: If provided, only return workshops created by this facilitator + * user_id: If provided, return all workshops the user has access to (as facilitator or participant) + * db: Database session + * + * Returns: + * List of workshops sorted by creation date (newest first) + * @param facilitatorId + * @param userId + * @returns Workshop Successful Response + * @throws ApiError + */ + public static listWorkshopsWorkshopsGet( + facilitatorId?: (string | null), + userId?: (string | null), + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/', + query: { + 'facilitator_id': facilitatorId, + 'user_id': userId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } /** * Create Workshop * Create a new workshop. @@ -72,104 +108,104 @@ export class WorkshopsService { }); } /** - * Upload Traces - * Upload traces to a workshop. + * Update Judge Name + * Update the judge name for the workshop. Should be set before annotation phase. * @param workshopId - * @param requestBody - * @returns Trace Successful Response + * @param judgeName + * @returns any Successful Response * @throws ApiError */ - public static uploadTracesWorkshopsWorkshopIdTracesPost( + public static updateJudgeNameWorkshopsWorkshopIdJudgeNamePut( workshopId: string, - requestBody: Array, - ): CancelablePromise> { + judgeName: string, + ): CancelablePromise { return __request(OpenAPI, { - method: 'POST', - url: '/workshops/{workshop_id}/traces', + method: 'PUT', + url: '/workshops/{workshop_id}/judge-name', path: { 'workshop_id': workshopId, }, - body: requestBody, - mediaType: 'application/json', + query: { + 'judge_name': judgeName, + }, errors: { 422: `Validation Error`, }, }); } /** - * Get Traces - * Get traces for a workshop in user-specific order. - * - * Args: - * workshop_id: The workshop ID - * user_id: The user ID (REQUIRED for personalized trace ordering) - * db: Database session - * - * Returns: - * List of traces in user-specific order + * Update Jsonpath Settings + * Update JSONPath settings for trace display customization. * - * Raises: - * HTTPException: If workshop not found or user_id not provided + * These settings allow facilitators to configure JSONPath queries that + * extract specific values from trace inputs and outputs for cleaner display + * in the TraceViewer. * @param workshopId - * @param userId - * @returns Trace Successful Response + * @param requestBody + * @returns Workshop Successful Response * @throws ApiError */ - public static getTracesWorkshopsWorkshopIdTracesGet( + public static updateJsonpathSettingsWorkshopsWorkshopIdJsonpathSettingsPut( workshopId: string, - userId: string, - ): CancelablePromise> { + requestBody: JsonPathSettingsUpdate, + ): CancelablePromise { return __request(OpenAPI, { - method: 'GET', - url: '/workshops/{workshop_id}/traces', + method: 'PUT', + url: '/workshops/{workshop_id}/jsonpath-settings', path: { 'workshop_id': workshopId, }, - query: { - 'user_id': userId, - }, + body: requestBody, + mediaType: 'application/json', errors: { 422: `Validation Error`, }, }); } /** - * Get All Traces - * Get ALL traces for a workshop, unfiltered by phase. + * Preview Jsonpath + * Preview JSONPath extraction against the first trace in the workshop. + * + * This allows facilitators to test their JSONPath queries before saving + * to verify they extract the expected content. * @param workshopId - * @returns Trace Successful Response + * @param requestBody + * @returns any Successful Response * @throws ApiError */ - public static getAllTracesWorkshopsWorkshopIdAllTracesGet( + public static previewJsonpathWorkshopsWorkshopIdPreviewJsonpathPost( workshopId: string, - ): CancelablePromise> { + requestBody: JsonPathPreviewRequest, + ): CancelablePromise> { return __request(OpenAPI, { - method: 'GET', - url: '/workshops/{workshop_id}/all-traces', + method: 'POST', + url: '/workshops/{workshop_id}/preview-jsonpath', path: { 'workshop_id': workshopId, }, + body: requestBody, + mediaType: 'application/json', errors: { 422: `Validation Error`, }, }); } /** - * Get Original Traces - * Get only the original intake traces for a workshop (no duplicates). + * Resync Annotations + * Re-sync all annotations to MLflow with the current workshop judge_name. * - * This endpoint is used for judge tuning where we only want to evaluate - * the original traces, not multiple instances from different annotators. + * This is useful when the judge_name changes after annotations were created. + * Creates new MLflow feedback entries with the correct judge_name. * @param workshopId - * @returns Trace Successful Response + * @returns any Successful Response * @throws ApiError */ - public static getOriginalTracesWorkshopsWorkshopIdOriginalTracesGet( + public static resyncAnnotationsWorkshopsWorkshopIdResyncAnnotationsPost( workshopId: string, - ): CancelablePromise> { + ): CancelablePromise { return __request(OpenAPI, { - method: 'GET', - url: '/workshops/{workshop_id}/original-traces', + method: 'POST', + url: '/workshops/{workshop_id}/resync-annotations', path: { 'workshop_id': workshopId, }, @@ -179,20 +215,20 @@ export class WorkshopsService { }); } /** - * Submit Finding - * Submit a discovery finding. + * Upload Traces + * Upload traces to a workshop. * @param workshopId * @param requestBody - * @returns DiscoveryFinding Successful Response + * @returns Trace Successful Response * @throws ApiError */ - public static submitFindingWorkshopsWorkshopIdFindingsPost( + public static uploadTracesWorkshopsWorkshopIdTracesPost( workshopId: string, - requestBody: DiscoveryFindingCreate, - ): CancelablePromise { + requestBody: Array, + ): CancelablePromise> { return __request(OpenAPI, { method: 'POST', - url: '/workshops/{workshop_id}/findings', + url: '/workshops/{workshop_id}/traces', path: { 'workshop_id': workshopId, }, @@ -204,20 +240,31 @@ export class WorkshopsService { }); } /** - * Get Findings - * Get discovery findings for a workshop, optionally filtered by user. + * Get Traces + * Get traces for a workshop in user-specific order. + * + * Args: + * workshop_id: The workshop ID + * user_id: The user ID (REQUIRED for personalized trace ordering) + * db: Database session + * + * Returns: + * List of traces in user-specific order + * + * Raises: + * HTTPException: If workshop not found or user_id not provided * @param workshopId * @param userId - * @returns DiscoveryFinding Successful Response + * @returns Trace Successful Response * @throws ApiError */ - public static getFindingsWorkshopsWorkshopIdFindingsGet( + public static getTracesWorkshopsWorkshopIdTracesGet( workshopId: string, userId?: (string | null), - ): CancelablePromise> { + ): CancelablePromise> { return __request(OpenAPI, { method: 'GET', - url: '/workshops/{workshop_id}/findings', + url: '/workshops/{workshop_id}/traces', path: { 'workshop_id': workshopId, }, @@ -230,18 +277,20 @@ export class WorkshopsService { }); } /** - * Clear Findings - * Clear all findings for a workshop (for testing). + * Delete All Traces + * Delete all traces for a workshop and reset to intake phase (facilitator only). + * + * This allows starting over with new trace data. * @param workshopId * @returns any Successful Response * @throws ApiError */ - public static clearFindingsWorkshopsWorkshopIdFindingsDelete( + public static deleteAllTracesWorkshopsWorkshopIdTracesDelete( workshopId: string, ): CancelablePromise { return __request(OpenAPI, { method: 'DELETE', - url: '/workshops/{workshop_id}/findings', + url: '/workshops/{workshop_id}/traces', path: { 'workshop_id': workshopId, }, @@ -251,25 +300,44 @@ export class WorkshopsService { }); } /** - * Get Findings With User Details - * Get discovery findings with user details for facilitator view. + * Get All Traces + * Get ALL traces for a workshop, unfiltered by phase. * @param workshopId - * @param userId - * @returns any Successful Response + * @returns Trace Successful Response * @throws ApiError */ - public static getFindingsWithUserDetailsWorkshopsWorkshopIdFindingsWithUsersGet( + public static getAllTracesWorkshopsWorkshopIdAllTracesGet( workshopId: string, - userId?: (string | null), - ): CancelablePromise>> { + ): CancelablePromise> { return __request(OpenAPI, { method: 'GET', - url: '/workshops/{workshop_id}/findings-with-users', + url: '/workshops/{workshop_id}/all-traces', path: { 'workshop_id': workshopId, }, - query: { - 'user_id': userId, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Original Traces + * Get only the original intake traces for a workshop (no duplicates). + * + * This endpoint is used for judge tuning where we only want to evaluate + * the original traces, not multiple instances from different annotators. + * @param workshopId + * @returns Trace Successful Response + * @throws ApiError + */ + public static getOriginalTracesWorkshopsWorkshopIdOriginalTracesGet( + workshopId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/original-traces', + path: { + 'workshop_id': workshopId, }, errors: { 422: `Validation Error`, @@ -539,37 +607,6 @@ export class WorkshopsService { }, }); } - /** - * Begin Discovery Phase - * Begin the discovery phase and distribute traces to participants. - * - * Args: - * workshop_id: The workshop ID - * trace_limit: Optional limit on number of traces to use (default: all) - * db: Database session - * @param workshopId - * @param traceLimit - * @returns any Successful Response - * @throws ApiError - */ - public static beginDiscoveryPhaseWorkshopsWorkshopIdBeginDiscoveryPost( - workshopId: string, - traceLimit?: (number | null), - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/workshops/{workshop_id}/begin-discovery', - path: { - 'workshop_id': workshopId, - }, - query: { - 'trace_limit': traceLimit, - }, - errors: { - 422: `Validation Error`, - }, - }); - } /** * Add Traces * Add additional traces to the current active phase (discovery or annotation). @@ -669,6 +706,15 @@ export class WorkshopsService { /** * Begin Annotation Phase * Begin the annotation phase with a subset of traces. + * + * Args: + * workshop_id: The workshop ID + * request: JSON body with optional fields: + * - trace_limit: Number of traces to use (default: 10, -1 for all) + * - randomize: Whether to randomize trace order per user (default: False) + * + * When randomize=False (default): All SMEs see traces in the same chronological order. + * When randomize=True: All SMEs see the same set of traces but in different random orders. * @param workshopId * @param requestBody * @returns any Successful Response @@ -692,18 +738,25 @@ export class WorkshopsService { }); } /** - * Advance To Discovery - * Advance workshop from INTAKE to DISCOVERY phase (facilitator only). + * Reset Annotation + * Reset a workshop back to before annotation phase started (facilitator only). + * + * This allows changing the annotation configuration (e.g., trace selection, randomization). + * + * IMPORTANT: This clears ALL SME annotation progress: + * - All annotations submitted by SMEs + * + * Traces are kept, but SMEs will start fresh from the beginning. * @param workshopId * @returns any Successful Response * @throws ApiError */ - public static advanceToDiscoveryWorkshopsWorkshopIdAdvanceToDiscoveryPost( + public static resetAnnotationWorkshopsWorkshopIdResetAnnotationPost( workshopId: string, ): CancelablePromise { return __request(OpenAPI, { method: 'POST', - url: '/workshops/{workshop_id}/advance-to-discovery', + url: '/workshops/{workshop_id}/reset-annotation', path: { 'workshop_id': workshopId, }, @@ -822,27 +875,6 @@ export class WorkshopsService { }, }); } - /** - * Generate Discovery Test Data - * Generate realistic discovery findings for testing. - * @param workshopId - * @returns any Successful Response - * @throws ApiError - */ - public static generateDiscoveryTestDataWorkshopsWorkshopIdGenerateDiscoveryDataPost( - workshopId: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/workshops/{workshop_id}/generate-discovery-data', - path: { - 'workshop_id': workshopId, - }, - errors: { - 422: `Validation Error`, - }, - }); - } /** * Generate Rubric Test Data * Generate realistic rubric for testing. @@ -1386,42 +1418,149 @@ export class WorkshopsService { }); } /** - * Mark User Discovery Complete - * Mark a user as having completed discovery for a workshop. + * Upload Csv Traces + * Upload traces from a MLflow trace export CSV file. + * + * Expected CSV format (MLflow export): + * - Required columns: request_preview, response_preview + * - Optional columns: trace_id, execution_duration_ms, state, request, response, + * spans, tags, trace_metadata, trace_location, assessments, etc. + * + * Example from MLflow export: + * trace_id,request_preview,response_preview,execution_duration_ms,state,... + * "tr-abc123","What is Python?","Python is a programming language",150,"OK",... * @param workshopId - * @param userId + * @param formData * @returns any Successful Response * @throws ApiError */ - public static markUserDiscoveryCompleteWorkshopsWorkshopIdUsersUserIdCompleteDiscoveryPost( + public static uploadCsvTracesWorkshopsWorkshopIdCsvUploadPost( workshopId: string, - userId: string, + formData: Body_upload_csv_traces_workshops__workshop_id__csv_upload_post, ): CancelablePromise> { return __request(OpenAPI, { method: 'POST', - url: '/workshops/{workshop_id}/users/{user_id}/complete-discovery', + url: '/workshops/{workshop_id}/csv-upload', + path: { + 'workshop_id': workshopId, + }, + formData: formData, + mediaType: 'multipart/form-data', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Upload Csv And Log To Mlflow + * Upload CSV with request/response data and log each row as an MLflow trace. + * + * This enables customers who don't have existing MLflow traces to participate + * in the Judge Builder workshop by uploading conversational data as CSV. + * + * Expected CSV format: + * - Required columns: request_preview, response_preview + * - Optional columns: any additional metadata + * + * The endpoint will: + * 1. Parse the CSV file + * 2. For each row, create an MLflow trace with the request/response + * 3. Store the traces locally with their MLflow trace IDs + * + * Environment variables used if parameters not provided: + * - DATABRICKS_HOST + * - DATABRICKS_TOKEN + * - MLFLOW_EXPERIMENT_ID + * @param workshopId + * @param formData + * @returns any Successful Response + * @throws ApiError + */ + public static uploadCsvAndLogToMlflowWorkshopsWorkshopIdCsvUploadToMlflowPost( + workshopId: string, + formData: Body_upload_csv_and_log_to_mlflow_workshops__workshop_id__csv_upload_to_mlflow_post, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/csv-upload-to-mlflow', path: { 'workshop_id': workshopId, - 'user_id': userId, }, + formData: formData, + mediaType: 'multipart/form-data', errors: { 422: `Validation Error`, }, }); } /** - * Get Discovery Completion Status - * Get discovery completion status for all users in a workshop. + * Migrate Annotations To Multi Metric + * Migrate old annotations (with single 'rating' field) to new format (with 'ratings' dict). + * This populates the 'ratings' dictionary by copying the legacy 'rating' value to all rubric questions. * @param workshopId * @returns any Successful Response * @throws ApiError */ - public static getDiscoveryCompletionStatusWorkshopsWorkshopIdDiscoveryCompletionStatusGet( + public static migrateAnnotationsToMultiMetricWorkshopsWorkshopIdMigrateAnnotationsPost( workshopId: string, ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/migrate-annotations', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Update Trace Alignment Inclusion + * Update whether a trace should be included in judge alignment. + * + * This allows facilitators to exclude traces with SME disagreement from the alignment process. + * @param workshopId + * @param traceId + * @param includeInAlignment + * @returns Trace Successful Response + * @throws ApiError + */ + public static updateTraceAlignmentInclusionWorkshopsWorkshopIdTracesTraceIdAlignmentPatch( + workshopId: string, + traceId: string, + includeInAlignment: boolean, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/workshops/{workshop_id}/traces/{trace_id}/alignment', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + query: { + 'include_in_alignment': includeInAlignment, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Traces For Alignment + * Get all traces that are marked for inclusion in judge alignment. + * + * Returns only traces where include_in_alignment is True. + * @param workshopId + * @returns Trace Successful Response + * @throws ApiError + */ + public static getTracesForAlignmentWorkshopsWorkshopIdTracesForAlignmentGet( + workshopId: string, + ): CancelablePromise> { return __request(OpenAPI, { method: 'GET', - url: '/workshops/{workshop_id}/discovery-completion-status', + url: '/workshops/{workshop_id}/traces-for-alignment', path: { 'workshop_id': workshopId, }, @@ -1431,23 +1570,116 @@ export class WorkshopsService { }); } /** - * Is User Discovery Complete - * Check if a user has completed discovery for a workshop. + * Aggregate Trace Feedback + * Aggregate all SME feedback for a trace and store it on the trace. + * + * This concatenates all non-empty comments from annotations on this trace + * into a single sme_feedback field for use in alignment. + * @param workshopId + * @param traceId + * @returns Trace Successful Response + * @throws ApiError + */ + public static aggregateTraceFeedbackWorkshopsWorkshopIdTracesTraceIdAggregateFeedbackPost( + workshopId: string, + traceId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/traces/{trace_id}/aggregate-feedback', + path: { + 'workshop_id': workshopId, + 'trace_id': traceId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Aggregate All Trace Feedback + * Aggregate SME feedback for all annotated traces in the workshop. + * + * This is a batch operation that processes all traces and updates their sme_feedback fields. * @param workshopId - * @param userId * @returns any Successful Response * @throws ApiError */ - public static isUserDiscoveryCompleteWorkshopsWorkshopIdUsersUserIdDiscoveryCompleteGet( + public static aggregateAllTraceFeedbackWorkshopsWorkshopIdAggregateAllFeedbackPost( workshopId: string, - userId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/aggregate-all-feedback', + path: { + 'workshop_id': workshopId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Start Alignment Job + * Start an alignment job in the background and return a job ID for polling. + * + * This is more reliable than SSE streaming as it avoids proxy buffering issues. + * Use GET /alignment-job/{job_id} to poll for status and logs. + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static startAlignmentJobWorkshopsWorkshopIdStartAlignmentPost( + workshopId: string, + requestBody: AlignmentRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/start-alignment', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Alignment Job Status + * Get the status and logs of an alignment job. + * + * Use `since_log_index` to get only new logs since the last poll. + * This allows efficient incremental updates without re-sending all logs. + * + * Returns: + * - status: pending, running, completed, or failed + * - logs: list of log messages (or new logs if since_log_index provided) + * - log_count: total number of logs + * - result: alignment result (if completed) + * - error: error message (if failed) + * @param workshopId + * @param jobId + * @param sinceLogIndex + * @returns any Successful Response + * @throws ApiError + */ + public static getAlignmentJobStatusWorkshopsWorkshopIdAlignmentJobJobIdGet( + workshopId: string, + jobId: string, + sinceLogIndex?: number, ): CancelablePromise> { return __request(OpenAPI, { method: 'GET', - url: '/workshops/{workshop_id}/users/{user_id}/discovery-complete', + url: '/workshops/{workshop_id}/alignment-job/{job_id}', path: { 'workshop_id': workshopId, - 'user_id': userId, + 'job_id': jobId, + }, + query: { + 'since_log_index': sinceLogIndex, }, errors: { 422: `Validation Error`, @@ -1455,19 +1687,118 @@ export class WorkshopsService { }); } /** - * Migrate Annotations To Multi Metric - * Migrate old annotations (with single 'rating' field) to new format (with 'ratings' dict). - * This populates the 'ratings' dictionary by copying the legacy 'rating' value to all rubric questions. + * Start Evaluation Job + * Start an evaluation job in the background and return a job ID for polling. + * + * This is more reliable than SSE streaming as it avoids proxy buffering issues. + * Use GET /evaluation-job/{job_id} to poll for status and logs. * @param workshopId + * @param requestBody * @returns any Successful Response * @throws ApiError */ - public static migrateAnnotationsToMultiMetricWorkshopsWorkshopIdMigrateAnnotationsPost( + public static startEvaluationJobWorkshopsWorkshopIdStartEvaluationPost( workshopId: string, + requestBody: AlignmentRequest, ): CancelablePromise> { return __request(OpenAPI, { method: 'POST', - url: '/workshops/{workshop_id}/migrate-annotations', + url: '/workshops/{workshop_id}/start-evaluation', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Start Simple Evaluation + * Start a simple evaluation job using Databricks Model Serving (no MLflow required). + * + * This endpoint evaluates the judge prompt by directly calling a Databricks model serving + * endpoint. This is useful when MLflow is not available or configured. + * @param workshopId + * @param requestBody + * @returns any Successful Response + * @throws ApiError + */ + public static startSimpleEvaluationWorkshopsWorkshopIdStartSimpleEvaluationPost( + workshopId: string, + requestBody: SimpleEvaluationRequest, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'POST', + url: '/workshops/{workshop_id}/start-simple-evaluation', + path: { + 'workshop_id': workshopId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Evaluation Job Status + * Get the status and logs of an evaluation job. + * + * Use `since_log_index` to get only new logs since the last poll. + * This allows efficient incremental updates without re-sending all logs. + * + * Returns: + * - status: pending, running, completed, or failed + * - logs: list of log messages (or new logs if since_log_index provided) + * - log_count: total number of logs + * - result: evaluation result (if completed) + * - error: error message (if failed) + * @param workshopId + * @param jobId + * @param sinceLogIndex + * @returns any Successful Response + * @throws ApiError + */ + public static getEvaluationJobStatusWorkshopsWorkshopIdEvaluationJobJobIdGet( + workshopId: string, + jobId: string, + sinceLogIndex?: number, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/evaluation-job/{job_id}', + path: { + 'workshop_id': workshopId, + 'job_id': jobId, + }, + query: { + 'since_log_index': sinceLogIndex, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Alignment Status + * Get the current alignment status for a workshop. + * + * Returns information about: + * - Number of traces available for alignment + * - Whether evaluation has been run + * - Whether alignment is ready to run + * @param workshopId + * @returns any Successful Response + * @throws ApiError + */ + public static getAlignmentStatusWorkshopsWorkshopIdAlignmentStatusGet( + workshopId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/workshops/{workshop_id}/alignment-status', path: { 'workshop_id': workshopId, }, diff --git a/client/src/components/AnnotationAssignmentManager.tsx b/client/src/components/AnnotationAssignmentManager.tsx index df44641c..77b244a4 100644 --- a/client/src/components/AnnotationAssignmentManager.tsx +++ b/client/src/components/AnnotationAssignmentManager.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useWorkshopContext } from '@/context/WorkshopContext'; import { useUser, useRoleCheck } from '@/context/UserContext'; -import { UsersService, WorkshopsService } from '@/client'; +import { UsersService, WorkshopsService, UserRole, type User, type WorkshopParticipant, type Trace } from '@/client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -19,32 +19,6 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; -interface User { - id: string; - email: string; - name: string; - role: 'facilitator' | 'sme' | 'participant'; - workshop_id: string; - status: 'active' | 'inactive' | 'pending'; -} - -interface WorkshopParticipant { - user_id: string; - workshop_id: string; - role: 'facilitator' | 'sme' | 'participant'; - assigned_traces: string[]; - annotation_quota?: number; - joined_at: string; -} - -interface Trace { - id: string; - workshop_id: string; - input: string; - output: string; - created_at: string; -} - export const AnnotationAssignmentManager: React.FC = () => { const { workshopId } = useWorkshopContext(); const { user } = useUser(); @@ -57,33 +31,33 @@ export const AnnotationAssignmentManager: React.FC = () => { const [error, setError] = useState(null); const [assigning, setAssigning] = useState(false); - useEffect(() => { - if (workshopId && canAssignAnnotations) { - loadData(); - } - }, [workshopId, canAssignAnnotations]); - - const loadData = async () => { + const loadData = useCallback(async () => { + if (!workshopId) return; try { setLoading(true); setError(null); const [participantsRes, usersRes, tracesRes] = await Promise.all([ - UsersService.getWorkshopParticipantsUsersWorkshopsWorkshopIdParticipantsGet(workshopId!), - UsersService.listUsersUsersUsersGet(workshopId), - WorkshopsService.getTracesWorkshopsWorkshopIdTracesGet(workshopId!, 'all') + UsersService.getWorkshopParticipantsUsersWorkshopsWorkshopIdParticipantsGet(workshopId), + UsersService.listUsersUsersGet(workshopId), + WorkshopsService.getTracesWorkshopsWorkshopIdTracesGet(workshopId, 'all') ]); setParticipants(participantsRes); setUsers(usersRes); setTraces(tracesRes); } catch (err: any) { - setError(err.response?.data?.detail || 'Failed to load data'); } finally { setLoading(false); } - }; + }, [workshopId]); + + useEffect(() => { + if (workshopId && canAssignAnnotations) { + loadData(); + } + }, [workshopId, canAssignAnnotations, loadData]); const handleAutoAssign = async () => { try { @@ -152,7 +126,7 @@ export const AnnotationAssignmentManager: React.FC = () => { ); } - const annotators = participants.filter(p => p.role === 'sme' || p.role === 'participant'); + const annotators = participants.filter((p) => p.role === UserRole.SME || p.role === UserRole.PARTICIPANT); const getUserById = (userId: string) => users.find(u => u.id === userId); const getAssignmentStats = () => { @@ -263,7 +237,7 @@ export const AnnotationAssignmentManager: React.FC = () => {
- {participant.role === 'sme' ? ( + {participant.role === UserRole.SME ? ( ) : ( diff --git a/client/src/components/AnnotationReviewPage.tsx b/client/src/components/AnnotationReviewPage.tsx index 0cd66106..d49fcb7b 100644 --- a/client/src/components/AnnotationReviewPage.tsx +++ b/client/src/components/AnnotationReviewPage.tsx @@ -54,8 +54,13 @@ export function AnnotationReviewPage({ onBack }: AnnotationReviewPageProps) { const { workshopId } = useWorkshopContext(); const { user } = useUser(); const [currentTraceIndex, setCurrentTraceIndex] = useState(0); - - // Check if user is logged in + + // Fetch data - pass user ID for personalized trace ordering (must be before early returns) + const { data: traces, isLoading: tracesLoading } = useTraces(workshopId!, user?.id); + const { data: rubric, isLoading: rubricLoading } = useRubric(workshopId!); + const { data: userAnnotations } = useUserAnnotations(workshopId!, user); + + // Check if user is logged in (after all hooks) if (!user || !user.id) { return (
@@ -70,11 +75,6 @@ export function AnnotationReviewPage({ onBack }: AnnotationReviewPageProps) {
); } - - // Fetch data - pass user ID for personalized trace ordering - const { data: traces, isLoading: tracesLoading } = useTraces(workshopId!, user.id); - const { data: rubric, isLoading: rubricLoading } = useRubric(workshopId!); - const { data: userAnnotations } = useUserAnnotations(workshopId!, user); // Filter to only show traces that have annotations const annotatedTraces = traces?.filter(trace => @@ -198,10 +198,7 @@ export function AnnotationReviewPage({ onBack }: AnnotationReviewPageProps) { {currentTrace && ( - + )} diff --git a/client/src/components/DraftRubricPanel.tsx b/client/src/components/DraftRubricPanel.tsx new file mode 100644 index 00000000..227dbabe --- /dev/null +++ b/client/src/components/DraftRubricPanel.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { FileText, Trash2, Copy, Check } from 'lucide-react'; + +interface DraftRubricItem { + id: string; + text: string; + source_trace_id: string; + promoted_by: string; + promoted_at?: string; +} + +interface DraftRubricPanelProps { + items: DraftRubricItem[]; + onRemove?: (itemId: string) => void; + onCopyText?: (text: string) => void; +} + +export const DraftRubricPanel: React.FC = ({ + items = [], + onRemove, + onCopyText, +}) => { + const [copiedId, setCopiedId] = React.useState(null); + + const handleCopy = (itemId: string, text: string) => { + navigator.clipboard.writeText(text); + setCopiedId(itemId); + setTimeout(() => setCopiedId(null), 2000); + onCopyText?.(text); + }; + + if (items.length === 0) { + return ( + + +
+ +

Draft Rubric Staging Area

+

+ Promoted findings will appear here as candidates for rubric items +

+
+
+
+ ); + } + + return ( + + + + + Draft Rubric Items ({items.length}) + + + +
+ {items.map((item) => ( +
+
+

{item.text}

+
+ + {onRemove && ( + + )} +
+
+ +
+ + Source: {item.source_trace_id.slice(0, 8)}... + + + By: {item.promoted_by} + + {item.promoted_at && ( + + {new Date(item.promoted_at).toLocaleDateString()} + + )} +
+
+ ))} +
+ +
+

+ 💡 These items are candidates for your rubric. Review and refine them before finalizing your evaluation criteria. +

+
+
+
+ ); +}; diff --git a/client/src/components/FacilitatorDashboard.tsx b/client/src/components/FacilitatorDashboard.tsx index d92c2af9..149edbc2 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, @@ -25,8 +26,11 @@ import { Label } from '@/components/ui/label'; import { useQueryClient } from '@tanstack/react-query'; import { PhaseControlButton } from './PhaseControlButton'; import { JsonPathSettings } from './JsonPathSettings'; +import { TraceDiscoveryPanel } from './TraceDiscoveryPanel'; import { toast } from 'sonner'; import { parseRubricQuestions } from '@/utils/rubricUtils'; +import { getBackendModelName, getFrontendModelName, getModelOptions } from '@/utils/modelMapping'; +import { useTraceDiscoveryState, useUpdateTraceThresholds, usePromoteFinding, useGenerateDiscoveryQuestion } from '@/hooks/useWorkshopApi'; interface FacilitatorDashboardProps { onNavigate: (phase: string) => void; @@ -42,6 +46,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 @@ -50,34 +55,45 @@ export const FacilitatorDashboard: React.FC = ({ onNa const { data: annotations } = useFacilitatorAnnotations(workshopId!); const { data: annotationsWithUserDetails } = useFacilitatorAnnotationsWithUserDetails(workshopId!); - // Redirect non-facilitators - if (!isFacilitator) { - return ( -
-
- -
- Facilitator Access Required -
-
- This dashboard is only available to workshop facilitators -
-
-
- ); - } + // Expanded trace ID state - must be declared before hooks that use it + const [expandedTraceId, setExpandedTraceId] = React.useState(null); + + // TraceDiscoveryPanel hooks - only enabled when a trace is expanded + const { data: traceDiscoveryState, isLoading: isLoadingDiscoveryState } = useTraceDiscoveryState(workshopId!, expandedTraceId || ''); + const updateThresholdsMutation = useUpdateTraceThresholds(workshopId!, expandedTraceId || ''); + const promoteFindingMutation = usePromoteFinding(workshopId!); + const generateQuestionMutation = useGenerateDiscoveryQuestion(workshopId!, expandedTraceId || ''); - // Calculate progress metrics + // Additional traces functionality - separate state for each phase + const [discoveryTracesCount, setDiscoveryTracesCount] = React.useState(''); + const [annotationTracesCount, setAnnotationTracesCount] = React.useState(''); + const [isAddingTraces, setIsAddingTraces] = React.useState(false); + const [isReorderingTraces, setIsReorderingTraces] = React.useState(false); + const [isResettingDiscovery, setIsResettingDiscovery] = React.useState(false); + const [isResettingAnnotation, setIsResettingAnnotation] = React.useState(false); + + // 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); + + // Calculate progress metrics (used in hooks below) // For discovery: use active discovery traces count or all traces - const discoveryTraceCount = ((workshop?.current_phase === 'discovery' || focusPhase === 'discovery') && workshop?.active_discovery_trace_ids?.length) - ? workshop.active_discovery_trace_ids.length + const discoveryTraceCount = ((workshop?.current_phase === 'discovery' || focusPhase === 'discovery') && workshop?.active_discovery_trace_ids?.length) + ? workshop.active_discovery_trace_ids.length : (traces?.length || 0); - - // For annotation: use active annotation traces count or all traces + + // For annotation: use active annotation traces count or all traces const annotationTraceCount = (workshop?.current_phase === 'annotation' && workshop?.active_annotation_trace_ids?.length) ? workshop.active_annotation_trace_ids.length : (traces?.length || 0); - + const totalTraces = traces?.length || 0; // Keep for general use const tracesWithFindings = allFindings ? new Set(allFindings.map(f => f.trace_id)) : new Set(); const completedDiscoveryTraces = Math.min(tracesWithFindings.size, discoveryTraceCount); @@ -85,16 +101,16 @@ export const FacilitatorDashboard: React.FC = ({ onNa // Get user participation stats with user names const activeUsers = allFindings ? new Set(allFindings.map(f => f.user_id)) : new Set(); - + // For annotation phase, use annotation-based active users const activeAnnotators = annotations ? new Set(annotations.map(a => a.user_id)) : new Set(); - + // Calculate user contributions based on phase const userContributions = React.useMemo(() => { 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]) { @@ -103,12 +119,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]) { @@ -117,7 +133,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]); @@ -280,18 +296,6 @@ export const FacilitatorDashboard: React.FC = ({ onNa } }; - // Additional traces functionality - separate state for each phase - const [discoveryTracesCount, setDiscoveryTracesCount] = React.useState(''); - const [annotationTracesCount, setAnnotationTracesCount] = React.useState(''); - const [isAddingTraces, setIsAddingTraces] = React.useState(false); - const [isReorderingTraces, setIsReorderingTraces] = React.useState(false); - const [isResettingDiscovery, setIsResettingDiscovery] = React.useState(false); - const [isResettingAnnotation, setIsResettingAnnotation] = React.useState(false); - - // Judge name state - used for MLflow feedback entries - const [judgeName, setJudgeName] = React.useState(workshop?.judge_name || 'workshop_judge'); - const [isSavingJudgeName, setIsSavingJudgeName] = React.useState(false); - // Derive judge name from rubric question title const deriveJudgeNameFromRubric = (questionTitle: string): string => { // Convert to snake_case and append _judge @@ -313,7 +317,38 @@ 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]); + + // Redirect non-facilitators (after all hooks) + if (!isFacilitator) { + return ( +
+
+ +
+ Facilitator Access Required +
+
+ This dashboard is only available to workshop facilitators +
+
+
+ ); + } + const handleSaveJudgeName = async () => { if (!judgeName.trim()) { toast.error('Please enter a valid judge name'); @@ -341,6 +376,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'; @@ -807,59 +888,117 @@ export const FacilitatorDashboard: React.FC = ({ onNa {traceCoverageDetails.length > 0 ? (
- {traceCoverageDetails.map((trace) => ( -
-
-
-
-

- Trace: {trace.traceId.slice(0, 20)}... -

- - {trace.reviewCount} review{trace.reviewCount !== 1 ? 's' : ''} - - = 2 ? 'default' : 'outline'} - className="text-xs" - > - {trace.uniqueReviewers} reviewer{trace.uniqueReviewers !== 1 ? 's' : ''} - -
-

- {trace.input.slice(0, 120)}... -

- {trace.reviewers.length > 0 && ( -
- {trace.reviewers.map(reviewer => { - // Find the user name from the findings with user details - const userFinding = allFindingsWithUserDetails?.find(f => f.user_id === reviewer); - const reviewerName = userFinding?.user_name || reviewer; - return ( - - {reviewerName} - - ); - })} + {traceCoverageDetails.map((trace) => { + // Get the actual trace ID (not MLflow trace ID) for discovery state + const actualTrace = traces?.find(t => (t.mlflow_trace_id || t.id) === trace.traceId); + const actualTraceId = actualTrace?.id || trace.traceId; + const isExpanded = expandedTraceId === actualTraceId; + + return ( +
+
setExpandedTraceId(isExpanded ? null : actualTraceId)} + > +
+
+
+ +

+ Trace: {trace.traceId.slice(0, 20)}... +

+ + {trace.reviewCount} review{trace.reviewCount !== 1 ? 's' : ''} + + = 2 ? 'default' : 'outline'} + className="text-xs" + > + {trace.uniqueReviewers} reviewer{trace.uniqueReviewers !== 1 ? 's' : ''} + +
+

+ {trace.input.slice(0, 120)}... +

+ {trace.reviewers.length > 0 && ( +
+ {trace.reviewers.map(reviewer => { + // Find the user name from the findings with user details + const userFinding = allFindingsWithUserDetails?.find(f => f.user_id === reviewer); + const reviewerName = userFinding?.user_name || reviewer; + return ( + + {reviewerName} + + ); + })} +
+ )}
- )} -
-
-
-
0 ? 'text-amber-600' : 'text-slate-400' - }`}> - {trace.isFullyReviewed ? '✓ Complete' : - trace.reviewCount > 0 ? 'In Progress' : 'Pending'} +
+
+
0 ? 'text-amber-600' : 'text-slate-400' + }`}> + {trace.isFullyReviewed ? '✓ Complete' : + trace.reviewCount > 0 ? 'In Progress' : 'Pending'} +
+
+ + {/* TraceDiscoveryPanel - shown when trace is expanded */} + {isExpanded && ( +
+ {isLoadingDiscoveryState ? ( +
+
+

Loading discovery state...

+
+ ) : traceDiscoveryState ? ( + { + generateQuestionMutation.mutate(undefined, { + onSuccess: () => toast.success('Question generated successfully'), + onError: (err) => toast.error(`Failed to generate question: ${err.message}`), + }); + }} + onPromote={(findingId) => { + promoteFindingMutation.mutate( + { findingId, promoterId: user?.id || 'facilitator' }, + { + onSuccess: () => toast.success('Finding promoted to draft rubric'), + onError: (err) => toast.error(`Failed to promote finding: ${err.message}`), + } + ); + }} + onUpdateThresholds={(newThresholds) => { + updateThresholdsMutation.mutate(newThresholds, { + onSuccess: () => toast.success('Thresholds updated'), + onError: (err) => toast.error(`Failed to update thresholds: ${err.message}`), + }); + }} + /> + ) : ( +
+

No discovery state available for this trace

+
+ )} +
+ )}
-
- ))} + ); + })}
) : (
@@ -889,24 +1028,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/components/FacilitatorInvitationManager.tsx b/client/src/components/FacilitatorInvitationManager.tsx index 2eb4e08e..82b843d4 100644 --- a/client/src/components/FacilitatorInvitationManager.tsx +++ b/client/src/components/FacilitatorInvitationManager.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useWorkshopContext } from '@/context/WorkshopContext'; import { useUser } from '@/context/UserContext'; import { Button } from '@/components/ui/button'; @@ -38,25 +38,26 @@ export const FacilitatorInvitationManager: React.FC = () => { }); // Fetch existing invitations - const fetchInvitations = async () => { + const fetchInvitations = useCallback(async () => { + if (!workshopId) return; try { const response = await fetch(`/users/invitations/?workshop_id=${workshopId}`); if (response.ok) { const data = await response.json(); setInvitations(data); } else { - + // Silently ignore non-OK responses } } catch (error) { - + // Silently ignore fetch errors } - }; + }, [workshopId]); useEffect(() => { if (workshopId) { fetchInvitations(); } - }, [workshopId]); + }, [workshopId, fetchInvitations]); const handleCreateInvitation = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/client/src/components/FacilitatorUserManager.tsx b/client/src/components/FacilitatorUserManager.tsx index 1ef4d939..9e76c2bf 100644 --- a/client/src/components/FacilitatorUserManager.tsx +++ b/client/src/components/FacilitatorUserManager.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useUser } from '@/context/UserContext'; import { useWorkshopContext } from '@/context/WorkshopContext'; -import { UsersService } from '@/client'; +import { UsersService, UserRole, type User } from '@/client'; import { useWorkshop } from '@/hooks/useWorkshopApi'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -14,15 +14,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { AlertCircle, RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; -interface User { - id: string; - email: string; - name: string; - role: 'facilitator' | 'sme' | 'participant'; - status: string; - created_at: string; -} - export const FacilitatorUserManager: React.FC = () => { const { user } = useUser(); const { workshopId } = useWorkshopContext(); @@ -36,19 +27,12 @@ export const FacilitatorUserManager: React.FC = () => { const [newUser, setNewUser] = useState({ email: '', name: '', - role: 'participant' as 'sme' | 'participant' + role: UserRole.PARTICIPANT }); - useEffect(() => { - if (workshopId) { - loadUsers(); - } - }, [workshopId]); - - - const loadUsers = async () => { + const loadUsers = useCallback(async () => { if (!workshopId) return; - + setIsLoading(true); setError(null); try { @@ -60,7 +44,13 @@ export const FacilitatorUserManager: React.FC = () => { } finally { setIsLoading(false); } - }; + }, [workshopId]); + + useEffect(() => { + if (workshopId) { + loadUsers(); + } + }, [workshopId, loadUsers]); const handleAddUser = async (e: React.FormEvent) => { e.preventDefault(); @@ -82,7 +72,7 @@ export const FacilitatorUserManager: React.FC = () => { ); setSuccess(`User ${newUser.email} added successfully.`); - setNewUser({ email: '', name: '', role: 'participant' }); + setNewUser({ email: '', name: '', role: UserRole.PARTICIPANT }); loadUsers(); // Refresh the user list } catch (error: any) { setError(error.response?.data?.detail || 'Failed to add user'); @@ -93,20 +83,20 @@ export const FacilitatorUserManager: React.FC = () => { const [updatingRoleUserId, setUpdatingRoleUserId] = useState(null); - const getRoleBadgeColor = (role: string) => { + const getRoleBadgeColor = (role: UserRole) => { switch (role) { - case 'facilitator': + case UserRole.FACILITATOR: return 'bg-blue-100 text-blue-800'; - case 'sme': + case UserRole.SME: return 'bg-green-100 text-green-800'; - case 'participant': + case UserRole.PARTICIPANT: return 'bg-gray-100 text-gray-800'; default: return 'bg-gray-100 text-gray-800'; } }; - const handleRoleChange = async (userId: string, newRole: 'sme' | 'participant') => { + const handleRoleChange = async (userId: string, newRole: UserRole.SME | UserRole.PARTICIPANT) => { if (!workshopId) return; setUpdatingRoleUserId(userId); @@ -131,7 +121,7 @@ export const FacilitatorUserManager: React.FC = () => { } }; - if (!user || user.role !== 'facilitator') { + if (!user || user.role !== UserRole.FACILITATOR) { return (
@@ -196,14 +186,14 @@ export const FacilitatorUserManager: React.FC = () => {
@@ -271,10 +261,10 @@ export const FacilitatorUserManager: React.FC = () => {
- {users.filter(u => u.role === 'sme').length} SMEs + {users.filter(u => u.role === UserRole.SME).length} SMEs - {users.filter(u => u.role === 'participant').length} Participants + {users.filter(u => u.role === UserRole.PARTICIPANT).length} Participants
@@ -324,7 +314,7 @@ export const FacilitatorUserManager: React.FC = () => { {u.name} {u.email} - {u.role === 'facilitator' ? ( + {u.role === UserRole.FACILITATOR ? ( Facilitator @@ -332,12 +322,12 @@ export const FacilitatorUserManager: React.FC = () => {
+ setThresholds({ ...thresholds, [category]: parseInt(e.target.value) || 3 }) + } + className="h-8 text-xs" + data-testid={`threshold-input-${category}`} + /> +
+ ))} +
+ +
+ + + + {/* Disagreements */} + {disagreements.length > 0 && ( + + + + + Detected Disagreements ({disagreements.length}) + + + +
+ {disagreements.map((disagreement: any, idx: number) => ( +
+

{disagreement.summary}

+
+ {disagreement.user_ids?.map((userId: string) => ( + + {userId} + + ))} +
+
+ ))} +
+
+
+ )} + + {/* Questions */} + {questions.length > 0 && ( + + + + + Active Questions ({questions.length}) + + + +
+ {questions.map((question: any, idx: number) => ( +
+
{question.prompt}
+ {question.target_category && ( + + Target: {question.target_category} + + )} +
+ ))} +
+
+
+ )} + + {/* Generate Question */} + + + + + Generate Next Question + + + +

+ Generate a targeted question to guide participants toward undercovered areas. +

+ +
+
+
+ ); +}; diff --git a/client/src/components/UserLogin.tsx b/client/src/components/UserLogin.tsx index 5cffe05a..e010b730 100644 --- a/client/src/components/UserLogin.tsx +++ b/client/src/components/UserLogin.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useUser } from '@/context/UserContext'; import { useWorkshopContext } from '@/context/WorkshopContext'; -import { UsersService } from '@/client'; +import { UsersService, UserRole } from '@/client'; import { useCreateWorkshop } from '@/hooks/useWorkshopApi'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -17,7 +17,7 @@ export const UserLogin: React.FC = () => { const [formData, setFormData] = useState({ email: '', name: '', - role: 'participant' as 'facilitator' | 'sme' | 'participant' + role: UserRole.PARTICIPANT }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -28,7 +28,7 @@ export const UserLogin: React.FC = () => { setError(null); try { - const newUser = await UsersService.createUserUsersUsersPost({ + const newUser = await UsersService.createUserUsersPost({ ...formData, workshop_id: workshopId }); @@ -42,7 +42,7 @@ export const UserLogin: React.FC = () => { } }; - const handleQuickLogin = async (role: 'facilitator' | 'sme' | 'participant') => { + const handleQuickLogin = async (role: UserRole) => { setIsLoading(true); setError(null); @@ -53,7 +53,7 @@ export const UserLogin: React.FC = () => { let currentWorkshopId = workshopId; // If no workshop exists and user is facilitator, create one - if (!currentWorkshopId && role === 'facilitator') { + if (!currentWorkshopId && role === UserRole.FACILITATOR) { try { const newWorkshop = await createWorkshop.mutateAsync({ @@ -82,7 +82,7 @@ export const UserLogin: React.FC = () => { } // First, try to find existing user with this email and workshop - const existingUsers = await UsersService.listUsersUsersUsersGet(currentWorkshopId); + const existingUsers = await UsersService.listUsersUsersGet(currentWorkshopId); const existingUser = existingUsers.find(u => u.email === demoEmail); if (existingUser) { @@ -92,7 +92,7 @@ export const UserLogin: React.FC = () => { } else { // Create new user if doesn't exist - const newUser = await UsersService.createUserUsersUsersPost({ + const newUser = await UsersService.createUserUsersPost({ email: demoEmail, name: `Demo ${role.charAt(0).toUpperCase() + role.slice(1)}`, role, @@ -151,14 +151,14 @@ export const UserLogin: React.FC = () => {
- setFormData({ ...formData, role: value as UserRole })}> - Facilitator - Subject Matter Expert - Participant + Facilitator + Subject Matter Expert + Participant
@@ -180,21 +180,21 @@ export const UserLogin: React.FC = () => {
) : ( workshops.map((workshop) => ( -
handleSelectWorkshop(workshop)} > diff --git a/client/src/components/WorkshopHeader.tsx b/client/src/components/WorkshopHeader.tsx index f87e46bc..c9f30b20 100644 --- a/client/src/components/WorkshopHeader.tsx +++ b/client/src/components/WorkshopHeader.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { useQuery } from '@tanstack/react-query'; +import { WorkshopsService } from '@/client'; import { useWorkshopContext } from '@/context/WorkshopContext'; import { useWorkflowContext } from '@/context/WorkflowContext'; import { useWorkshop } from '@/hooks/useWorkshopApi'; @@ -21,6 +23,20 @@ export const WorkshopHeader: React.FC = ({ const { workshopId } = useWorkshopContext(); const { currentPhase } = useWorkflowContext(); const { data: workshop } = useWorkshop(workshopId!); + const { data: participants = [] } = useQuery({ + queryKey: ['workshop-participants', workshopId], + queryFn: async () => { + if (!workshopId) return []; + return WorkshopsService.getWorkshopParticipantsWorkshopsWorkshopIdParticipantsGet(workshopId); + }, + enabled: !!workshopId, + }); + + const participantCount = Array.isArray(participants) + ? participants.length + : Array.isArray((participants as any)?.users) + ? (participants as any).users.length + : 0; if (!workshop) { return ( @@ -85,7 +101,7 @@ export const WorkshopHeader: React.FC = ({ ID: {workshop.id.slice(0, 8)}... Created: {new Date(workshop.created_at).toLocaleDateString()} {showParticipantCount && ( - Participants: {workshop.users?.length || 0} + Participants: {participantCount} )}
)} @@ -95,7 +111,7 @@ export const WorkshopHeader: React.FC = ({
Participants
- {workshop.users?.length || 0} + {participantCount}
)} diff --git a/client/src/components/ui/input.tsx b/client/src/components/ui/input.tsx index 9d631e7f..7de2a032 100644 --- a/client/src/components/ui/input.tsx +++ b/client/src/components/ui/input.tsx @@ -2,8 +2,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -export interface InputProps - extends React.InputHTMLAttributes {} +export type InputProps = React.InputHTMLAttributes; const Input = React.forwardRef( ({ className, type, ...props }, ref) => { diff --git a/client/src/context/UserContext.tsx b/client/src/context/UserContext.tsx index 3b150018..e7e3bf3a 100644 --- a/client/src/context/UserContext.tsx +++ b/client/src/context/UserContext.tsx @@ -1,30 +1,6 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { UsersService } from '@/client'; - -export interface User { - id: string; - email: string; - name: string; - role: 'facilitator' | 'sme' | 'participant'; - workshop_id: string; - status: 'active' | 'inactive' | 'pending'; - created_at: string; - last_active?: string; -} - -export interface UserPermissions { - can_view_discovery: boolean; - can_create_findings: boolean; - can_view_all_findings: boolean; - can_create_rubric: boolean; - can_view_rubric: boolean; - can_annotate: boolean; - can_view_all_annotations: boolean; - can_view_results: boolean; - can_manage_workshop: boolean; - can_assign_annotations: boolean; -} +import { UsersService, type User, type UserPermissions, UserRole } from '@/client'; interface UserContextType { user: User | null; @@ -161,7 +137,7 @@ export const UserProvider: React.FC = ({ children }) => { const updateLastActive = async () => { if (user) { try { - await UsersService.updateLastActiveUsersUsersUserIdLastActivePut(user.id); + await UsersService.updateLastActiveUsersUserIdLastActivePut(user.id); } catch (error) { // Silent fail for last active updates } @@ -255,9 +231,9 @@ export const UserProvider: React.FC = ({ children }) => { export const useRoleCheck = () => { const { user, permissions } = useUser(); - const isFacilitator = user?.role === 'facilitator'; - const isSME = user?.role === 'sme'; - const isParticipant = user?.role === 'participant'; + const isFacilitator = user?.role === UserRole.FACILITATOR; + const isSME = user?.role === UserRole.SME; + const isParticipant = user?.role === UserRole.PARTICIPANT; const canViewDiscovery = permissions?.can_view_discovery ?? false; const canCreateFindings = permissions?.can_create_findings ?? false; diff --git a/client/src/context/WorkflowContext.tsx b/client/src/context/WorkflowContext.tsx index e0a7ed31..6d7ecf67 100644 --- a/client/src/context/WorkflowContext.tsx +++ b/client/src/context/WorkflowContext.tsx @@ -120,6 +120,7 @@ export function WorkflowProvider({ children }: WorkflowProviderProps) { // REMOVED: Auto-advancement that was causing phase/navigation confusion // Phase changes now only happen through explicit facilitator actions via API calls // This ensures frontend phase stays in sync with backend workshop phase + // eslint-disable-next-line react-hooks/exhaustive-deps -- completedPhases excluded to avoid infinite loop (this effect sets it), workshop partial dep is intentional }, [traces, findings, rubric, annotations, participants, workshopId, user, workshop?.current_phase]); const markPhaseComplete = (phase: string) => { diff --git a/client/src/context/WorkshopContext.tsx b/client/src/context/WorkshopContext.tsx index c8f66276..e37b1db0 100644 --- a/client/src/context/WorkshopContext.tsx +++ b/client/src/context/WorkshopContext.tsx @@ -2,7 +2,7 @@ * Workshop context for managing workshop state across the application */ -import React, { createContext, useContext, useState, ReactNode } from 'react'; +import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import type { Workshop } from '@/client'; import { useUser } from './UserContext'; @@ -30,7 +30,7 @@ const getWorkshopIdFromUrl = (): string | null => { const search = window.location.search; // Try URL path pattern: /workshop/id - let workshopMatch = path.match(/\/workshop\/([a-f0-9-]{36})/); + const workshopMatch = path.match(/\/workshop\/([a-f0-9-]{36})/); if (workshopMatch) { const workshopId = workshopMatch[1]; @@ -100,26 +100,22 @@ export function WorkshopProvider({ children, restoredWorkshopId }: WorkshopProvi const [workshop, setWorkshop] = useState(null); const [workflowMode, setWorkflowMode] = useState<'filled' | 'manual'>('filled'); - const handleSetWorkshopId = (id: string | null) => { + const handleSetWorkshopId = useCallback((id: string | null) => { if (id !== workshopId) { - - // Clear all cached queries when workshop ID changes queryClient.invalidateQueries(); queryClient.clear(); setWorkshopId(id); setWorkshop(null); - + // Persist workshop ID to localStorage if (id) { localStorage.setItem('workshop_id', id); - } else { localStorage.removeItem('workshop_id'); - } } - }; + }, [workshopId, queryClient]); const clearInvalidWorkshopId = () => { @@ -137,15 +133,14 @@ export function WorkshopProvider({ children, restoredWorkshopId }: WorkshopProvi if (user?.workshop_id && user.workshop_id !== workshopId) { handleSetWorkshopId(user.workshop_id); } - }, [user, workshopId]); + }, [user, workshopId, handleSetWorkshopId]); // Handle restored workshop ID from user context React.useEffect(() => { if (restoredWorkshopId && !workshopId) { - handleSetWorkshopId(restoredWorkshopId); } - }, [restoredWorkshopId, workshopId]); + }, [restoredWorkshopId, workshopId, handleSetWorkshopId]); // Force refresh when component mounts to ensure fresh data // REMOVED: This was causing old cached queries to refetch @@ -180,7 +175,7 @@ export function WorkshopProvider({ children, restoredWorkshopId }: WorkshopProvi return () => { window.removeEventListener('popstate', handleUrlChange); }; - }, [workshopId, queryClient]); + }, [workshopId, queryClient, handleSetWorkshopId]); return ( { export const useListDatabricksEndpoints = (config: DatabricksConfig | null) => { return useQuery({ - queryKey: ['databricks', 'endpoints', config?.workspace_url], + queryKey: ['databricks', 'endpoints', config], queryFn: () => listEndpoints(config!), enabled: !!config, staleTime: 5 * 60 * 1000, // 5 minutes @@ -184,7 +184,7 @@ export const useListDatabricksEndpoints = (config: DatabricksConfig | null) => { export const useGetDatabricksEndpointInfo = (endpointName: string, config: DatabricksConfig | null) => { return useQuery({ - queryKey: ['databricks', 'endpoint', endpointName, config?.workspace_url], + queryKey: ['databricks', 'endpoint', endpointName, config], queryFn: () => getEndpointInfo(endpointName, config!), enabled: !!config && !!endpointName, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/client/src/hooks/useWorkshopApi.test.ts b/client/src/hooks/useWorkshopApi.test.ts index 33fdd2c5..671a33fb 100644 --- a/client/src/hooks/useWorkshopApi.test.ts +++ b/client/src/hooks/useWorkshopApi.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { invalidateAllWorkshopQueries, refetchAllWorkshopQueries } from './useWorkshopApi'; // @spec DISCOVERY_TRACE_ASSIGNMENT_SPEC +// @req Assignment metadata properly tracks all context describe('workshop query helpers', () => { it('invalidateAllWorkshopQueries passes a predicate that matches workshop-related keys', () => { const queryClient = { diff --git a/client/src/hooks/useWorkshopApi.ts b/client/src/hooks/useWorkshopApi.ts index d6ef3574..835241f9 100644 --- a/client/src/hooks/useWorkshopApi.ts +++ b/client/src/hooks/useWorkshopApi.ts @@ -3,7 +3,7 @@ */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { WorkshopsService } from '@/client'; +import { DiscoveryService, WorkshopsService } from '@/client'; import { useRoleCheck } from '@/context/UserContext'; import type { Workshop, @@ -22,8 +22,10 @@ import type { // Query keys const QUERY_KEYS = { - workshops: () => ['workshops'], - workshopsForUser: (userId: string) => ['workshops', 'user', userId], + workshops: (facilitatorId?: string) => facilitatorId ? ['workshops', 'facilitator', facilitatorId] : ['workshops'], + workshopsForUser: (userId: string, facilitatorId?: string) => facilitatorId + ? ['workshops', 'user', userId, 'facilitator', facilitatorId] + : ['workshops', 'user', userId], workshop: (id: string) => ['workshop', id], traces: (workshopId: string) => ['traces', workshopId], findings: (workshopId: string, userId?: string) => ['findings', workshopId, userId], @@ -87,11 +89,9 @@ async function listWorkshopsApi(userId?: string, facilitatorId?: string): Promis export function useListWorkshops(options?: { userId?: string; facilitatorId?: string; enabled?: boolean }) { const { userId, facilitatorId, enabled = true } = options || {}; - + return useQuery({ - queryKey: userId - ? QUERY_KEYS.workshopsForUser(userId) - : QUERY_KEYS.workshops(), + queryKey: ['workshops', { userId, facilitatorId }], queryFn: () => listWorkshopsApi(userId, facilitatorId), enabled, staleTime: 30000, // Consider data stale after 30 seconds @@ -229,7 +229,7 @@ export function useInvalidateTraces() { export function useFindings(workshopId: string, userId?: string) { return useQuery({ queryKey: QUERY_KEYS.findings(workshopId, userId), - queryFn: () => WorkshopsService.getFindingsWorkshopsWorkshopIdFindingsGet(workshopId, userId), + queryFn: () => DiscoveryService.getFindingsWorkshopsWorkshopIdFindingsGet(workshopId, userId), enabled: !!workshopId, }); } @@ -238,7 +238,7 @@ export function useFindings(workshopId: string, userId?: string) { export function useUserFindings(workshopId: string, user: any) { return useQuery({ queryKey: QUERY_KEYS.findings(workshopId, user?.id), - queryFn: () => WorkshopsService.getFindingsWorkshopsWorkshopIdFindingsGet( + queryFn: () => DiscoveryService.getFindingsWorkshopsWorkshopIdFindingsGet( workshopId, user?.id // EVERYONE (including facilitators) gets only their own findings for personal progress ), @@ -255,7 +255,7 @@ export function useFacilitatorFindings(workshopId: string) { return useQuery({ queryKey: QUERY_KEYS.findings(workshopId, 'all_findings'), - queryFn: () => WorkshopsService.getFindingsWorkshopsWorkshopIdFindingsGet( + queryFn: () => DiscoveryService.getFindingsWorkshopsWorkshopIdFindingsGet( workshopId, undefined // No user filter - gets ALL findings ), @@ -269,7 +269,7 @@ export function useFacilitatorFindingsWithUserDetails(workshopId: string) { return useQuery({ queryKey: [...QUERY_KEYS.findings(workshopId, 'all_findings'), 'with_user_details'], - queryFn: () => WorkshopsService.getFindingsWithUserDetailsWorkshopsWorkshopIdFindingsWithUsersGet( + queryFn: () => DiscoveryService.getFindingsWithUserDetailsWorkshopsWorkshopIdFindingsWithUsersGet( workshopId, undefined // No user filter - gets ALL findings with user details ), @@ -282,7 +282,7 @@ export function useSubmitFinding(workshopId: string) { return useMutation({ mutationFn: (finding: DiscoveryFindingCreate) => - WorkshopsService.submitFindingWorkshopsWorkshopIdFindingsPost(workshopId, finding), + DiscoveryService.submitFindingWorkshopsWorkshopIdFindingsPost(workshopId, finding), onMutate: async (newFinding) => { // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: ['findings', workshopId, newFinding.user_id] }); @@ -588,6 +588,123 @@ export function useAggregateAllFeedback(workshopId: string) { }); } +// --------------------------------------------------------------------------- +// Assisted Facilitation v2 Hooks +// --------------------------------------------------------------------------- + +export function useTraceDiscoveryState(workshopId: string, traceId: string) { + return useQuery({ + queryKey: ['trace-discovery-state', workshopId, traceId], + queryFn: async () => { + const response = await fetch( + `/workshops/${workshopId}/traces/${traceId}/discovery-state` + ); + if (!response.ok) { + throw new Error('Failed to fetch trace discovery state'); + } + return response.json(); + }, + enabled: !!workshopId && !!traceId, + }); +} + +export function useDiscoveryProgress(workshopId: string) { + return useQuery({ + queryKey: ['discovery-progress', workshopId], + queryFn: async () => { + const response = await fetch(`/workshops/${workshopId}/discovery-progress`); + if (!response.ok) { + throw new Error('Failed to fetch discovery progress'); + } + return response.json(); + }, + enabled: !!workshopId, + refetchInterval: 5000, // Refresh every 5 seconds + }); +} + +export function useGenerateDiscoveryQuestion(workshopId: string, traceId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const response = await fetch( + `/workshops/${workshopId}/traces/${traceId}/generate-question`, + { method: 'POST' } + ); + if (!response.ok) { + throw new Error('Failed to generate question'); + } + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trace-discovery-state', workshopId, traceId] }); + }, + }); +} + +export function usePromoteFinding(workshopId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ findingId, promoterId }: { findingId: string; promoterId: string }) => { + const response = await fetch( + `/workshops/${workshopId}/findings/${findingId}/promote`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ finding_id: findingId, promoter_id: promoterId }), + } + ); + if (!response.ok) { + throw new Error('Failed to promote finding'); + } + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['draft-rubric', workshopId] }); + }, + }); +} + +export function useUpdateTraceThresholds(workshopId: string, traceId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (thresholds: Record) => { + const response = await fetch( + `/workshops/${workshopId}/traces/${traceId}/thresholds`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ thresholds }), + } + ); + if (!response.ok) { + throw new Error('Failed to update thresholds'); + } + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trace-discovery-state', workshopId, traceId] }); + }, + }); +} + +export function useDraftRubric(workshopId: string) { + return useQuery({ + queryKey: ['draft-rubric', workshopId], + queryFn: async () => { + const response = await fetch(`/workshops/${workshopId}/draft-rubric`); + if (!response.ok) { + throw new Error('Failed to fetch draft rubric'); + } + return response.json(); + }, + enabled: !!workshopId, + }); +} + // JSONPath Settings hooks interface JsonPathSettings { @@ -643,4 +760,32 @@ export function usePreviewJsonPath(workshopId: string) { return response.json(); }, }); +} + +// MLflow status hook - fetches intake status and config +export interface MLflowStatusResponse { + status: string; + total_traces: number; + recent_traces: number; + config?: { + databricks_host?: string; + databricks_token?: string; + experiment_id?: string; + max_traces?: number; + filter_string?: string; + }; +} + +export function useMLflowStatus(workshopId: string | null) { + return useQuery({ + queryKey: ['mlflow-status', workshopId], + queryFn: async (): Promise => { + const response = await fetch(`/workshops/${workshopId}/mlflow-status`); + if (!response.ok) { + throw new Error('Failed to fetch MLflow status'); + } + return response.json(); + }, + enabled: !!workshopId, + }); } \ No newline at end of file diff --git a/client/src/lib/utils.test.ts b/client/src/lib/utils.test.ts index 54b75630..7a381dcd 100644 --- a/client/src/lib/utils.test.ts +++ b/client/src/lib/utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { cn } from './utils'; // @spec DESIGN_SYSTEM_SPEC +// @req No hardcoded colors in components describe('cn', () => { it('merges classnames and tailwind conflicts', () => { expect(cn('p-2', 'p-4')).toBe('p-4'); diff --git a/client/src/main.tsx b/client/src/main.tsx index 97b8286f..c02a518f 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,14 +1,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import App from "./App.tsx"; +import App from "./App"; import "./index.css"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 0, // Consider data stale immediately - cacheTime: 5 * 60 * 1000, // 5 minutes cache time + gcTime: 5 * 60 * 1000, // 5 minutes cache time refetchOnWindowFocus: true, refetchOnMount: true, retry: 1, diff --git a/client/src/pages/AnnotationDemo.tsx b/client/src/pages/AnnotationDemo.tsx index 819d4d43..0b3334a4 100644 --- a/client/src/pages/AnnotationDemo.tsx +++ b/client/src/pages/AnnotationDemo.tsx @@ -5,7 +5,7 @@ * rate traces using the rubric questions with 1-5 Likert scale. */ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { TraceViewer, TraceData } from '@/components/TraceViewer'; import { TraceDataViewer } from '@/components/TraceDataViewer'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -120,27 +120,8 @@ export function AnnotationDemo() { const { canAnnotate } = useRoleCheck(); const currentUserId = user?.id || 'demo_user'; - - // Check if user is logged in - if (!user || !user.id) { - return ( -
-
- -
- Please Log In -
-
- You must be logged in to annotate traces. -
-
-
- ); - } - - // Fetch data - pass user ID for personalized trace ordering - // User is guaranteed to have an ID at this point - const { data: traces, isLoading: tracesLoading, error: tracesError } = useTraces(workshopId!, user.id); + // Fetch data - pass user ID for personalized trace ordering (must be before early returns) + const { data: traces, isLoading: tracesLoading, error: tracesError } = useTraces(workshopId!, user?.id); const { data: rubric, isLoading: rubricLoading } = useRubric(workshopId!); const { data: existingAnnotations } = useUserAnnotations(workshopId!, user); const { data: mlflowConfig } = useMLflowConfig(workshopId!); @@ -149,10 +130,18 @@ export function AnnotationDemo() { - // Convert traces to TraceData format - const traceData = traces?.map(convertTraceToTraceData) || []; + // Convert traces to TraceData format (memoized to prevent reference changes) + const traceData = useMemo( + () => traces?.map(convertTraceToTraceData) ?? [], + [traces] + ); const currentTrace = traceData[currentTraceIndex]; - const rubricQuestions = rubric ? parseRubricQuestions(rubric) : []; + + // Memoize rubricQuestions to prevent unnecessary recalculations + const rubricQuestions = useMemo( + () => rubric ? parseRubricQuestions(rubric) : [], + [rubric] + ); // Helper function to get legacy rating (first likert rating between 1-5, or default to 3) const getLegacyRating = (ratingsOverride?: Record): number => { @@ -215,20 +204,20 @@ export function AnnotationDemo() { }; // Helper function to parse combined comment back into separate parts - const parseLoadedComment = (loadedComment: string): { userComment: string; freeformData: Record } => { + const parseLoadedComment = useCallback((loadedComment: string): { userComment: string; freeformData: Record } => { const freeformData: Record = {}; let userComment = loadedComment; - + // Check for new JSON format first const jsonStartMarker = '|||FREEFORM_JSON|||'; const jsonEndMarker = '|||END_FREEFORM|||'; const jsonStartIndex = loadedComment.indexOf(jsonStartMarker); const jsonEndIndex = loadedComment.indexOf(jsonEndMarker); - + if (jsonStartIndex !== -1 && jsonEndIndex !== -1) { // Extract user comment (before the marker) userComment = loadedComment.substring(0, jsonStartIndex).trim(); - + // Extract and parse JSON const jsonStr = loadedComment.substring(jsonStartIndex + jsonStartMarker.length, jsonEndIndex); try { @@ -247,14 +236,14 @@ export function AnnotationDemo() { // Check for old format (backward compatibility) const freeformMarker = '--- Free-form Responses ---'; const markerIndex = loadedComment.indexOf(freeformMarker); - + if (markerIndex !== -1) { // Extract user comment (before the marker) userComment = loadedComment.substring(0, markerIndex).trim(); - + // Extract freeform section - old format was single-line only const freeformSection = loadedComment.substring(markerIndex + freeformMarker.length).trim(); - + // Parse each freeform response: [Title]: Response (single line) const lines = freeformSection.split('\n'); for (const line of lines) { @@ -270,12 +259,9 @@ export function AnnotationDemo() { } } } - - return { userComment, freeformData }; - }; - - + return { userComment, freeformData }; + }, [rubricQuestions]); // Reset annotation state when user changes useEffect(() => { @@ -363,12 +349,12 @@ export function AnnotationDemo() { return prev; }); } else { - + // No existing annotation for this trace - nothing to load } } - }, [currentTrace?.id, existingAnnotations, currentUserId]); + }, [currentTrace?.id, existingAnnotations, currentUserId, rubricQuestions, parseLoadedComment]); - // Initialize saved state from all existing annotations (runs once) + // Initialize saved state from all existing annotations useEffect(() => { if (existingAnnotations && existingAnnotations.length > 0 && rubricQuestions.length > 0) { existingAnnotations.forEach(annotation => { @@ -381,11 +367,11 @@ export function AnnotationDemo() { const firstQuestionId = rubricQuestions.length > 0 ? rubricQuestions[0].id : 'accuracy'; loadedRatings = { [firstQuestionId]: annotation.rating }; } - + // Parse comment to separate user comment from freeform responses const rawComment = annotation.comment || ''; const { userComment: loadedComment, freeformData } = parseLoadedComment(rawComment); - + savedStateRef.current.set(annotation.trace_id, { ratings: loadedRatings, freeformResponses: freeformData, @@ -393,7 +379,7 @@ export function AnnotationDemo() { }); }); } - }, [existingAnnotations?.length, rubricQuestions.length]); // Only run when counts change + }, [existingAnnotations, rubricQuestions, parseLoadedComment]); // Navigate to first incomplete trace on initial load const hasInitialized = useRef(false); @@ -447,7 +433,24 @@ export function AnnotationDemo() { hasInitialized.current = true; } - }, [existingAnnotations, traceData, hasNavigatedManually]); + }, [existingAnnotations, traceData, hasNavigatedManually, currentTrace?.id, currentUserId, rubricQuestions, parseLoadedComment]); + + // Check if user is logged in (after all hooks) + if (!user || !user.id) { + return ( +
+
+ +
+ Please Log In +
+
+ You must be logged in to annotate traces. +
+
+
+ ); + } // Save annotation function - can be called synchronously or asynchronously const saveAnnotation = async ( @@ -884,7 +887,7 @@ export function AnnotationDemo() { const mlflowUrl = `${host}/ml/experiments/${experiment_id}/traces?selectedEvaluationId=${trace_id}`; window.open(mlflowUrl, '_blank'); } else { - + // MLflow URL not configured - button is disabled anyway } }} className="flex items-center gap-2 text-xs" diff --git a/client/src/pages/AnnotationReviewPage.tsx b/client/src/pages/AnnotationReviewPage.tsx index 268c35e3..9f49b320 100644 --- a/client/src/pages/AnnotationReviewPage.tsx +++ b/client/src/pages/AnnotationReviewPage.tsx @@ -57,8 +57,13 @@ export function AnnotationReviewPage({ onBack }: AnnotationReviewPageProps) { const { workshopId } = useWorkshopContext(); const { user } = useUser(); const [currentTraceIndex, setCurrentTraceIndex] = useState(0); - - // Check if user is logged in + + // Fetch data - pass user ID for personalized trace ordering (must be before early returns) + const { data: traces, isLoading: tracesLoading } = useTraces(workshopId!, user?.id); + const { data: rubric, isLoading: rubricLoading } = useRubric(workshopId!); + const { data: userAnnotations } = useUserAnnotations(workshopId!, user); + + // Check if user is logged in (after all hooks) if (!user || !user.id) { return (
@@ -73,11 +78,6 @@ export function AnnotationReviewPage({ onBack }: AnnotationReviewPageProps) {
); } - - // Fetch data - pass user ID for personalized trace ordering - const { data: traces, isLoading: tracesLoading } = useTraces(workshopId!, user.id); - const { data: rubric, isLoading: rubricLoading } = useRubric(workshopId!); - const { data: userAnnotations } = useUserAnnotations(workshopId!, user); // Filter to only show traces that have annotations const annotatedTraces = traces?.filter(trace => @@ -201,10 +201,7 @@ export function AnnotationReviewPage({ onBack }: AnnotationReviewPageProps) { {currentTrace && ( - + )} diff --git a/client/src/pages/DBSQLExportPage.tsx b/client/src/pages/DBSQLExportPage.tsx index 4bcf5f2a..9e986897 100644 --- a/client/src/pages/DBSQLExportPage.tsx +++ b/client/src/pages/DBSQLExportPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -25,6 +25,7 @@ import { } from 'lucide-react'; import { useWorkshopContext } from '@/context/WorkshopContext'; import { useWorkshop } from '@/hooks/useWorkshopApi'; +import type { DBSQLExportResponse } from '@/client'; interface ExportStatus { workshop_id: string; @@ -92,15 +93,17 @@ export function DBSQLExportPage() { const [scrollPosition, setScrollPosition] = useState(0); // Use React Query to cache export results - const { data: exportResult } = useQuery({ + const { data: exportResult } = useQuery({ queryKey: ['dbsql-export-result', workshopId], queryFn: async () => { // This will be populated when export is successful return null; }, staleTime: Infinity, // Never consider stale - cacheTime: Infinity, // Never expire from cache + gcTime: Infinity, // Never expire from cache }); + const tablesExported = Array.isArray(exportResult?.tables_exported) ? exportResult?.tables_exported : []; + const exportErrors = Array.isArray(exportResult?.errors) ? exportResult?.errors : []; // Use React Query to cache export status const { data: exportStatus, refetch: refetchExportStatus } = useQuery({ @@ -118,9 +121,9 @@ export function DBSQLExportPage() { }); // Save state to localStorage whenever form fields change - const saveStateToStorage = (newState: any) => { + const saveStateToStorage = useCallback((newState: any) => { if (!workshopId) return; - + const storageKey = `dbsql-export-state-${workshopId}`; const stateToSave = { databricksHost, @@ -131,17 +134,17 @@ export function DBSQLExportPage() { scrollPosition, ...newState }; - + localStorage.setItem(storageKey, JSON.stringify({ state: stateToSave, timestamp: Date.now() })); - }; + }, [workshopId, databricksHost, databricksToken, httpPath, catalog, schemaName, scrollPosition]); // Save state when form fields change useEffect(() => { saveStateToStorage({}); - }, [databricksHost, databricksToken, httpPath, catalog, schemaName]); + }, [saveStateToStorage]); // Track scroll position useEffect(() => { @@ -153,7 +156,7 @@ export function DBSQLExportPage() { window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); - }, []); + }, [saveStateToStorage]); // Restore scroll position when component mounts useEffect(() => { @@ -577,19 +580,19 @@ export function DBSQLExportPage() {

{exportResult.message}

-

Total Rows: {exportResult.total_rows}

-

Tables Exported: {exportResult.tables_exported?.length || 0}

+

Total Rows: {exportResult.total_rows ?? 0}

+

Tables Exported: {tablesExported.length}

Target Location: {catalog}.{schemaName}

- {exportResult.tables_exported && exportResult.tables_exported.length > 0 && ( + {tablesExported.length > 0 && (

Exported Tables

- {exportResult.tables_exported.map((table: any, index: number) => ( + {tablesExported.map((table: any, index: number) => (
@@ -602,11 +605,11 @@ export function DBSQLExportPage() { )} - {exportResult.errors && exportResult.errors.length > 0 && ( + {exportErrors.length > 0 && (

Errors

- {exportResult.errors.map((error: string, index: number) => ( + {exportErrors.map((error: string, index: number) => (

{error}

))}
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/IRRResultsDemo.tsx b/client/src/pages/IRRResultsDemo.tsx index ce1b8dc1..92d73ee8 100644 --- a/client/src/pages/IRRResultsDemo.tsx +++ b/client/src/pages/IRRResultsDemo.tsx @@ -5,7 +5,7 @@ * Krippendorff's Alpha with interpretation, suggestions, and detailed analysis. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, 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'; @@ -175,8 +175,11 @@ export function IRRResultsDemo({ workshopId }: IRRResultsProps) { // Parse rubric questions const rubricQuestions = rubric ? parseRubricQuestions(rubric) : []; - // Extract per-metric scores from IRR result - const perMetricScores = irrResult?.details?.per_metric_scores || {}; + // Extract per-metric scores from IRR result (memoized to prevent reference changes) + const perMetricScores = useMemo( + () => irrResult?.details?.per_metric_scores ?? {}, + [irrResult?.details?.per_metric_scores] + ); const hasMetrics = Object.keys(perMetricScores).length > 0; // Traces start collapsed by default @@ -220,8 +223,7 @@ export function IRRResultsDemo({ workshopId }: IRRResultsProps) { // The navigation will be handled by the WorkshopDemoLanding component // when it detects the phase change } catch (error) { - - + // Silently ignore errors - toast.error is shown elsewhere } finally { setIsAdvancing(false); } diff --git a/client/src/pages/IntakePage.tsx b/client/src/pages/IntakePage.tsx index c088abfa..8cbf2598 100644 --- a/client/src/pages/IntakePage.tsx +++ b/client/src/pages/IntakePage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -11,6 +11,7 @@ import { useWorkshopContext } from '@/context/WorkshopContext'; import { toast } from 'sonner'; import { useWorkflowContext } from '@/context/WorkflowContext'; import { useQueryClient } from '@tanstack/react-query'; +import { useMLflowStatus } from '@/hooks/useWorkshopApi'; import { AlertDialog, AlertDialogAction, @@ -74,8 +75,7 @@ export function IntakePage() { }; const [config, setConfig] = useState(getInitialConfig); - - const [status, setStatus] = useState(null); + const [isIngesting, setIsIngesting] = useState(false); const [error, setError] = useState(null); const [csvFile, setCsvFile] = useState(null); @@ -83,6 +83,15 @@ export function IntakePage() { const [isDeleting, setIsDeleting] = useState(false); const [csvImportDestination, setCsvImportDestination] = useState<'discovery' | 'mlflow' | null>(null); + // Fetch MLflow status using TanStack Query + const { data: statusData, refetch: refetchStatus } = useMLflowStatus(workshopId); + + // Derive status with proper typing + const status: MLflowStatus | null = useMemo(() => { + if (!statusData) return null; + return statusData as unknown as MLflowStatus; + }, [statusData]); + // Save config to localStorage whenever it changes useEffect(() => { if (config.databricks_host || config.databricks_token || config.experiment_id) { @@ -94,39 +103,19 @@ export function IntakePage() { } }, [config]); - // Load existing configuration and status + // Merge backend config with local config when status loads useEffect(() => { - loadStatus(); - }, [workshopId]); - - const loadStatus = async () => { - if (!workshopId) { - - return; + if (statusData?.config) { + setConfig(prev => ({ + ...prev, + databricks_host: statusData.config?.databricks_host || prev.databricks_host, + databricks_token: statusData.config?.databricks_token || prev.databricks_token, + experiment_id: statusData.config?.experiment_id || prev.experiment_id, + max_traces: statusData.config?.max_traces || prev.max_traces, + filter_string: statusData.config?.filter_string || prev.filter_string + })); } - - try { - const response = await fetch(`/workshops/${workshopId}/mlflow-status`); - if (response.ok) { - const statusData = await response.json(); - setStatus(statusData); - - // Merge backend config with existing config (prefer backend values if present) - if (statusData.config) { - setConfig(prev => ({ - ...prev, - databricks_host: statusData.config.databricks_host || prev.databricks_host, - databricks_token: statusData.config.databricks_token || prev.databricks_token, - experiment_id: statusData.config.experiment_id || prev.experiment_id, - max_traces: statusData.config.max_traces || prev.max_traces, - filter_string: statusData.config.filter_string || prev.filter_string - })); - } - } - } catch (err) { - - } - }; + }, [statusData?.config]); const handleConfigChange = (field: keyof MLflowConfig, value: string | number) => { setConfig(prev => ({ @@ -185,7 +174,7 @@ export function IntakePage() { if (response.ok) { const result = await response.json(); - await loadStatus(); + await refetchStatus(); // Invalidate trace caches to ensure new traces are visible queryClient.invalidateQueries({ queryKey: ['traces', workshopId] }); @@ -237,7 +226,7 @@ export function IntakePage() { if (response.ok) { const result = await response.json(); - await loadStatus(); + await refetchStatus(); // Invalidate trace caches to ensure new traces are visible queryClient.invalidateQueries({ queryKey: ['traces', workshopId] }); @@ -325,7 +314,7 @@ export function IntakePage() { if (response.ok) { const result = await response.json(); - await loadStatus(); + await refetchStatus(); // Invalidate ALL workshop-related caches for a complete reset queryClient.invalidateQueries({ queryKey: ['traces', workshopId] }); diff --git a/client/src/pages/JudgeTuningPage.tsx b/client/src/pages/JudgeTuningPage.tsx index c9d1450a..1b3a25fc 100644 --- a/client/src/pages/JudgeTuningPage.tsx +++ b/client/src/pages/JudgeTuningPage.tsx @@ -34,6 +34,7 @@ import { Pagination } from '@/components/Pagination'; import { TraceDataViewer } from '@/components/TraceDataViewer'; import { toast } from 'sonner'; +import { JudgeType } from '@/client'; import type { JudgePrompt, JudgePromptCreate, @@ -41,7 +42,6 @@ import type { JudgePerformanceMetrics, JudgeEvaluationResult, JudgeExportConfig, - JudgeType, Rubric, Annotation, Trace @@ -79,7 +79,7 @@ export function JudgeTuningPage() { const selectedQuestion = parsedRubricQuestions[selectedQuestionIndex] || parsedRubricQuestions[0]; // Judge type - derived from the selected rubric question - const judgeType: JudgeType = selectedQuestion?.judgeType || (rubric?.judge_type || 'likert'); + const judgeType: JudgeType = selectedQuestion?.judgeType || (rubric?.judge_type || JudgeType.LIKERT); const binaryLabels: Record = rubric?.binary_labels || { pass: 'Pass', fail: 'Fail' }; // Track if current prompt differs from saved version @@ -224,7 +224,7 @@ export function JudgeTuningPage() { // Only reset if question actually changed if (prevQuestionIndexRef.current !== selectedQuestionIndex && selectedQuestion) { - const questionJudgeType = selectedQuestion.judgeType || 'likert'; + const questionJudgeType = selectedQuestion.judgeType || JudgeType.LIKERT; const template = defaultPromptTemplates[questionJudgeType]; let customizedTemplate = template; @@ -272,10 +272,12 @@ export function JudgeTuningPage() { }, [selectedQuestionIndex, selectedQuestion, workshopId]); // Load initial data + // TODO: Refactor loadInitialData to use TanStack Query hooks (useRubric, useMLflowConfig, etc.) useEffect(() => { if (workshopId) { loadInitialData(); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- loadInitialData is stable (only uses workshopId), needs TanStack Query refactor }, [workshopId]); // Load saved evaluations for the current question on mount and when question changes @@ -298,7 +300,8 @@ export function JudgeTuningPage() { } } } - }, [workshopId]); // Only run on mount, not on question change (that's handled by the other useEffect) + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes selectedQuestionIndex; only load on mount, question changes handled separately + }, [workshopId]); // Refetch annotations when page becomes visible (user navigates back) useEffect(() => { @@ -405,10 +408,10 @@ export function JudgeTuningPage() { // If rubric changed (e.g., from Likert to Binary), update prompt template const parsedQuestions = parseRubricQuestions(rubricData?.question || ''); const selectedQ = parsedQuestions[selectedQuestionIndex] || parsedQuestions[0]; - const currentRubricJudgeType = selectedQ?.judgeType || (rubricData?.judge_type || 'likert'); + const currentRubricJudgeType = selectedQ?.judgeType || (rubricData?.judge_type || JudgeType.LIKERT); // Check both the metadata judge_type AND the actual prompt content - const promptMetadataJudgeType = latestPrompt.judge_type || 'likert'; + const promptMetadataJudgeType = latestPrompt.judge_type || JudgeType.LIKERT; const promptContentJudgeType = detectPromptJudgeType(latestPrompt.prompt_text); // If rubric judge type doesn't match EITHER the metadata OR the actual content, regenerate @@ -471,15 +474,15 @@ export function JudgeTuningPage() { // Helper to detect judge type from prompt content const detectPromptJudgeType = (promptText: string): JudgeType => { if (promptText.includes('scale of 0-1') || promptText.includes('0 or 1') || promptText.includes('(PASS)') || promptText.includes('(FAIL)')) { - return 'binary'; + return JudgeType.BINARY; } if (promptText.includes('scale of 1-5') || promptText.includes('1 = Poor') || promptText.includes('5 = Excellent')) { - return 'likert'; + return JudgeType.LIKERT; } if (promptText.includes('qualitative feedback') || promptText.includes('detailed feedback') || promptText.includes('Key observations')) { - return 'freeform'; + return JudgeType.FREEFORM; } - return 'likert'; // default + return JudgeType.LIKERT; // default }; const createDefaultPrompt = (rubricQuestion: string, questionIndex: number = 0) => { @@ -489,10 +492,10 @@ export function JudgeTuningPage() { const questionText = targetQuestion ? `${targetQuestion.title}: ${targetQuestion.description}` : rubricQuestion; - const judgeType = targetQuestion?.judgeType || 'likert'; + const judgeType = targetQuestion?.judgeType || JudgeType.LIKERT; // Return different prompt templates based on judge type - if (judgeType === 'binary') { + if (judgeType === JudgeType.BINARY) { return `You are an expert evaluator. Please evaluate the following response based on this criteria: "${questionText}" Rate the response on a scale of 0-1, where: @@ -512,7 +515,7 @@ Example format: The response meets the criteria because...`; } - if (judgeType === 'freeform') { + if (judgeType === JudgeType.FREEFORM) { return `You are an expert evaluator. Please evaluate the following response based on this criteria: "${questionText}" Provide detailed qualitative feedback on how well the response addresses this criteria. @@ -757,7 +760,7 @@ The response partially meets the criteria because...`; const defaultPrompt = createDefaultPrompt(rubric.question, selectedQuestionIndex); setCurrentPrompt(defaultPrompt); setIsModified(true); - toast.success(`Prompt reset to ${judgeType === 'likert' ? 'Likert (1-5)' : judgeType === 'binary' ? 'Binary (0-1)' : 'Free-form'} template for "${selectedQuestion?.title}"`); + toast.success(`Prompt reset to ${judgeType === JudgeType.LIKERT ? 'Likert (1-5)' : judgeType === JudgeType.BINARY ? 'Binary (0-1)' : 'Free-form'} template for "${selectedQuestion?.title}"`); }; const handleEvaluatePrompt = async () => { @@ -1226,9 +1229,9 @@ The response partially meets the criteria because...`;
{index + 1}. {question.title} - {question.judgeType === 'likert' && 'Likert'} - {question.judgeType === 'binary' && 'Binary'} - {question.judgeType === 'freeform' && 'Free-form'} + {question.judgeType === JudgeType.LIKERT && 'Likert'} + {question.judgeType === JudgeType.BINARY && 'Binary'} + {question.judgeType === JudgeType.FREEFORM && 'Free-form'}
@@ -1597,7 +1600,7 @@ The response partially meets the criteria because...`; // Calculate aggregated rating for the selected question if (allRatings.length > 0) { - if (judgeType === 'binary') { + if (judgeType === JudgeType.BINARY) { // For binary: majority vote (0 or 1) const numPasses = allRatings.filter(r => r === 1).length; humanRating = numPasses > allRatings.length / 2 ? 1 : 0; diff --git a/client/src/pages/RubricCreationDemo.tsx b/client/src/pages/RubricCreationDemo.tsx index 6c33face..808dc26f 100644 --- a/client/src/pages/RubricCreationDemo.tsx +++ b/client/src/pages/RubricCreationDemo.tsx @@ -36,8 +36,8 @@ import { useRubric, useCreateRubric, useUpdateRubric, useUserFindings, useFacili import { FocusedAnalysisView, ScratchPadEntry } from '@/components/FocusedAnalysisView'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useQueryClient } from '@tanstack/react-query'; -import { WorkshopsService } from '@/client'; -import type { Rubric, RubricCreate, JudgeType } from '@/client'; +import { WorkshopsService, JudgeType } from '@/client'; +import type { Rubric, RubricCreate } from '@/client'; import { toast } from 'sonner'; import { parseRubricQuestions, formatRubricQuestions, QUESTION_DELIMITER, type RubricQuestion } from '@/utils/rubricUtils'; import { binaryLabelPresets } from '@/components/JudgeTypeSelector'; @@ -73,7 +73,7 @@ const useDiscoveryResponses = (findings: any[] | undefined, traces: any[] | unde return acc; }, {} as Record); - return Object.entries(groupedByTrace).map(([traceId, traceFindings]) => { + return (Object.entries(groupedByTrace) as Array<[string, any[]]>).map(([traceId, traceFindings]) => { const trace = traceMap[traceId]; return { @@ -175,7 +175,7 @@ export function RubricCreationDemo() { const [newQuestion, setNewQuestion] = useState>({ title: '', description: '', - judgeType: 'likert' + judgeType: JudgeType.LIKERT }); const [isEditingExisting, setIsEditingExisting] = useState(false); const [viewMode, setViewMode] = useState<'grid' | 'focused'>('focused'); @@ -184,7 +184,7 @@ export function RubricCreationDemo() { const [lastUpdatedQuestionId, setLastUpdatedQuestionId] = useState(null); // Judge type selection - const [judgeType, setJudgeType] = useState('likert'); + const [judgeType, setJudgeType] = useState(JudgeType.LIKERT); const [binaryLabels, setBinaryLabels] = useState>({ pass: 'Pass', fail: 'Fail' }); // Fetch data @@ -192,33 +192,19 @@ export function RubricCreationDemo() { // Use all traces for rubric creation page const { data: traces, refetch: refetchTraces } = useAllTraces(workshopId!); // Facilitators see all findings to create better rubric, others see their own - const { data: findings, refetch: refetchFindings, isRefetching: isRefetchingFindings } = isFacilitator - ? useFacilitatorFindingsWithUserDetails(workshopId!) - : useUserFindings(workshopId!, user); + // Note: We need to call both hooks unconditionally to satisfy rules-of-hooks + const facilitatorFindings = useFacilitatorFindingsWithUserDetails(workshopId!); + const userFindings = useUserFindings(workshopId!, user); + const { data: findings, refetch: refetchFindings, isRefetching: isRefetchingFindings } = isFacilitator + ? facilitatorFindings + : userFindings; const createRubric = useCreateRubric(workshopId!); const updateRubric = useUpdateRubric(workshopId!); - - // SECURITY: Block access if no valid user - if (!user || !user.id) { - return ( -
-
- -
- Authentication Required -
-
- You must be logged in to access rubric creation. -
-
-
- ); - } - // Get discovery responses from real findings data, enriched with trace information + // Get discovery responses from real findings data, enriched with trace information (must be before early returns) const discoveryResponses = useDiscoveryResponses(findings, traces); - - // Helper to save scratch pad immediately to localStorage + + // Helper to save scratch pad immediately to localStorage (must be before early returns) const saveScratchPadToStorage = useCallback((entries: ScratchPadEntry[]) => { if (!workshopId) return; const storageKey = `scratch-pad-${workshopId}`; @@ -232,8 +218,8 @@ export function RubricCreationDemo() { localStorage.removeItem(storageKey); } }, [workshopId]); - - // Wrapper that saves immediately when setting scratch pad + + // Wrapper that saves immediately when setting scratch pad (must be before early returns) const setScratchPad = useCallback((value: ScratchPadEntry[] | ((prev: ScratchPadEntry[]) => ScratchPadEntry[])) => { setScratchPadState(prev => { const newValue = typeof value === 'function' ? value(prev) : value; @@ -243,7 +229,7 @@ export function RubricCreationDemo() { }); }, [saveScratchPadToStorage]); - // Load scratch pad from localStorage on mount + // Load scratch pad from localStorage on mount (must be before early returns) useEffect(() => { if (workshopId) { const storageKey = `scratch-pad-${workshopId}`; @@ -263,8 +249,8 @@ export function RubricCreationDemo() { } } }, [workshopId]); - - // Initialize questions and judge type from API data + + // Initialize questions and judge type from API data (must be before early returns) useEffect(() => { if (rubric && !isEditingExisting) { setQuestions(convertApiRubricToQuestions(rubric)); @@ -278,6 +264,23 @@ export function RubricCreationDemo() { } }, [rubric, isEditingExisting]); + // SECURITY: Block access if no valid user (after all hooks) + if (!user || !user.id) { + return ( +
+
+ +
+ Authentication Required +
+
+ You must be logged in to access rubric creation. +
+
+
+ ); + } + const addQuestion = async () => { if (newQuestion.title.trim() && newQuestion.description.trim()) { try { @@ -298,7 +301,7 @@ export function RubricCreationDemo() { question: combinedQuestionText, created_by: 'facilitator', judge_type: judgeType, - binary_labels: judgeType === 'binary' ? binaryLabels : undefined, + binary_labels: judgeType === JudgeType.BINARY ? binaryLabels : undefined, rating_scale: 5 }; @@ -314,7 +317,7 @@ export function RubricCreationDemo() { setNewQuestion({ title: '', description: '', - judgeType: 'likert' + judgeType: JudgeType.LIKERT }); setIsAddingQuestion(false); setIsEditingExisting(false); @@ -322,7 +325,7 @@ export function RubricCreationDemo() { // Invalidate queries to refresh the UI queryClient.invalidateQueries({ queryKey: ['rubric', workshopId] }); } catch (error) { - + // Silently ignore errors - UI will show error via mutation state } } }; @@ -409,7 +412,7 @@ export function RubricCreationDemo() { }; await updateRubric.mutateAsync(apiRubric); } catch (error) { - + // Silently ignore errors - UI will show error via mutation state } } }; @@ -678,7 +681,7 @@ export function RubricCreationDemo() {
{/* Binary label customization - shown if any questions are binary */} - {questions.some(q => q.judgeType === 'binary') && ( + {questions.some(q => q.judgeType === JudgeType.BINARY) && ( Binary Label Settings @@ -771,23 +774,23 @@ export function RubricCreationDemo() {
updateQuestion(question.id, { judgeType: 'likert' })} + onClick={() => updateQuestion(question.id, { judgeType: JudgeType.LIKERT })} > Likert Scale updateQuestion(question.id, { judgeType: 'binary' })} + onClick={() => updateQuestion(question.id, { judgeType: JudgeType.BINARY })} > Binary updateQuestion(question.id, { judgeType: 'freeform' })} + onClick={() => updateQuestion(question.id, { judgeType: JudgeType.FREEFORM })} > Free-form @@ -797,7 +800,7 @@ export function RubricCreationDemo() { {/* Scale/Response Preview - varies by question's judge type */}
- {question.judgeType === 'likert' && ( + {question.judgeType === JudgeType.LIKERT && ( <>
Likert Scale Preview
@@ -829,7 +832,7 @@ export function RubricCreationDemo() { )} - {question.judgeType === 'binary' && ( + {question.judgeType === JudgeType.BINARY && ( <>
Binary Choice Preview
@@ -854,7 +857,7 @@ export function RubricCreationDemo() { )} - {question.judgeType === 'freeform' && ( + {question.judgeType === JudgeType.FREEFORM && ( <>
Free-form Feedback Preview
@@ -949,23 +952,23 @@ export function RubricCreationDemo() {
setNewQuestion({ ...newQuestion, judgeType: 'likert' })} + variant={newQuestion.judgeType === JudgeType.LIKERT ? 'default' : 'outline'} + className={`cursor-pointer justify-center py-1.5 ${newQuestion.judgeType !== JudgeType.LIKERT ? 'bg-white' : ''}`} + onClick={() => setNewQuestion({ ...newQuestion, judgeType: JudgeType.LIKERT })} > Likert Scale setNewQuestion({ ...newQuestion, judgeType: 'binary' })} + variant={newQuestion.judgeType === JudgeType.BINARY ? 'default' : 'outline'} + className={`cursor-pointer justify-center py-1.5 ${newQuestion.judgeType !== JudgeType.BINARY ? 'bg-white' : ''}`} + onClick={() => setNewQuestion({ ...newQuestion, judgeType: JudgeType.BINARY })} > Binary setNewQuestion({ ...newQuestion, judgeType: 'freeform' })} + variant={newQuestion.judgeType === JudgeType.FREEFORM ? 'default' : 'outline'} + className={`cursor-pointer justify-center py-1.5 ${newQuestion.judgeType !== JudgeType.FREEFORM ? 'bg-white' : ''}`} + onClick={() => setNewQuestion({ ...newQuestion, judgeType: JudgeType.FREEFORM })} > Free-form @@ -975,7 +978,7 @@ export function RubricCreationDemo() { {/* Preview based on selected judge type */}
- {newQuestion.judgeType === 'likert' && ( + {newQuestion.judgeType === JudgeType.LIKERT && ( <>
Likert Scale Preview:
@@ -1002,7 +1005,7 @@ export function RubricCreationDemo() { )} - {newQuestion.judgeType === 'binary' && ( + {newQuestion.judgeType === JudgeType.BINARY && ( <>
Binary Choice Preview:
@@ -1022,7 +1025,7 @@ export function RubricCreationDemo() { )} - {newQuestion.judgeType === 'freeform' && ( + {newQuestion.judgeType === JudgeType.FREEFORM && ( <>
Free-form Response Preview:
@@ -1063,19 +1066,19 @@ export function RubricCreationDemo() {

{questions.length} criterion{questions.length !== 1 ? 's' : ''} created: {' '} - {questions.filter(q => q.judgeType === 'likert').length > 0 && ( + {questions.filter(q => q.judgeType === JudgeType.LIKERT).length > 0 && ( - {questions.filter(q => q.judgeType === 'likert').length} Likert + {questions.filter(q => q.judgeType === JudgeType.LIKERT).length} Likert )} - {questions.filter(q => q.judgeType === 'binary').length > 0 && ( + {questions.filter(q => q.judgeType === JudgeType.BINARY).length > 0 && ( - {questions.filter(q => q.judgeType === 'binary').length} Binary + {questions.filter(q => q.judgeType === JudgeType.BINARY).length} Binary )} - {questions.filter(q => q.judgeType === 'freeform').length > 0 && ( + {questions.filter(q => q.judgeType === JudgeType.FREEFORM).length > 0 && ( - {questions.filter(q => q.judgeType === 'freeform').length} Free-form + {questions.filter(q => q.judgeType === JudgeType.FREEFORM).length} Free-form )}

diff --git a/client/src/pages/TraceViewerDemo.tsx b/client/src/pages/TraceViewerDemo.tsx index 401fce75..e8aaed29 100644 --- a/client/src/pages/TraceViewerDemo.tsx +++ b/client/src/pages/TraceViewerDemo.tsx @@ -20,7 +20,7 @@ import { toast } from 'sonner'; import { useUser, useRoleCheck } from '@/context/UserContext'; import { useTraces, useUserFindings, useSubmitFinding, refetchAllWorkshopQueries } from '@/hooks/useWorkshopApi'; import { useQueryClient } from '@tanstack/react-query'; -import { WorkshopsService } from '@/client'; +import { DiscoveryService } from '@/client'; import type { Trace } from '@/client'; // Convert API trace to TraceData format @@ -35,65 +35,98 @@ const convertTraceToTraceData = (trace: Trace): TraceData => ({ mlflow_experiment_id: trace.mlflow_experiment_id || undefined }); +type DiscoveryQuestion = { + id: string; + prompt: string; + placeholder?: string | null; + category?: string | null; +}; + +type DiscoveryCoverage = { + covered: string[]; + missing: string[]; +}; + +type DiscoveryQuestionsResponse = { + questions: DiscoveryQuestion[]; + can_generate_more: boolean; + stop_reason?: string | null; + coverage: DiscoveryCoverage; +}; + +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(); const { user } = useUser(); const { canCreateFindings, isFacilitator } = useRoleCheck(); - // Check if user is logged in with an ID - if (!user || !user.id) { - return ( -
-
- -
- Please Log In -
-
- You must be logged in to view discovery traces. -
-
-
- ); - } - - // CRITICAL SAFETY CHECK: Facilitators should not see discovery text boxes during discovery/annotation phases - if (isFacilitator && (currentPhase === 'discovery' || currentPhase === 'annotation')) { - return ( -
-
- -
- Facilitator Dashboard Required -
-
- As a facilitator during the {currentPhase} phase, you should use the monitoring dashboard instead of the participant interface. -
-

- Please navigate back to access the appropriate facilitator tools for monitoring and managing this phase. -

-
-
- ); - } + // All useState hooks must be called before early returns 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 [canGenerateMore, setCanGenerateMore] = useState(true); + const [stopReason, setStopReason] = useState(null); const previousTraceId = useRef(null); const hasAutoNavigated = useRef(false); const previousTraceCount = useRef(0); // Fetch data - pass user ID for personalized trace ordering - // User is guaranteed to have an ID at this point due to early return above const { data: traces, isLoading: tracesLoading, error: tracesError } = useTraces( - workshopId!, - user.id // User ID is required and guaranteed to exist + workshopId!, + user?.id // May be undefined - hook handles this gracefully ); const { data: existingFindings } = useUserFindings(workshopId!, user); // Secure user-isolated findings const submitFinding = useSubmitFinding(workshopId!); @@ -105,27 +138,80 @@ 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 : []); + setCanGenerateMore(data?.can_generate_more ?? true); + setStopReason(data?.stop_reason ?? null); + }) + .catch((err: any) => { + if (err?.name === 'AbortError') return; + console.error('Failed to fetch discovery questions:', err); + setDiscoveryQuestions([]); + setCanGenerateMore(true); + setStopReason(null); + 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 : []); + setCanGenerateMore(data?.can_generate_more ?? true); + setStopReason(data?.stop_reason ?? null); + } 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; - // Initialize saved state from all existing findings (runs once) + // Initialize saved state from all existing findings useEffect(() => { 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 + }, [existingFindings]); // Track existing findings for current trace and populate responses useEffect(() => { @@ -134,34 +220,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 +287,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 +307,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 +335,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 +359,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 +379,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 +413,7 @@ export function TraceViewerDemo() { response: error?.response?.data, status: error?.response?.status, traceId, - q1Length: q1Trimmed.length, - q2Length: q2Trimmed.length, + contentLength: contentTrimmed.length, isBackground }); @@ -357,25 +434,23 @@ export function TraceViewerDemo() { setIsSaving(false); } } - }, [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); + }, [submitFinding, user?.id, discoveryQuestions]); + + // 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 +459,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 +534,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 +551,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) @@ -555,7 +646,7 @@ export function TraceViewerDemo() {