feat: Add experiments framework and novelty-driven agent loop
- Add complete experiments directory with pilot study infrastructure - 5 experimental conditions (direct, expert-only, attribute-only, full-pipeline, random-perspective) - Human assessment tool with React frontend and FastAPI backend - AUT flexibility analysis with jump signal detection - Result visualization and metrics computation - Add novelty-driven agent loop module (experiments/novelty_loop/) - NoveltyDrivenTaskAgent with expert perspective perturbation - Three termination strategies: breakthrough, exhaust, coverage - Interactive CLI demo with colored output - Embedding-based novelty scoring - Add DDC knowledge domain classification data (en/zh) - Add CLAUDE.md project documentation - Update research report with experiment findings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
13
experiments/assessment/frontend/index.html
Normal file
13
experiments/assessment/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Creative Idea Assessment</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4221
experiments/assessment/frontend/package-lock.json
generated
Normal file
4221
experiments/assessment/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
experiments/assessment/frontend/package.json
Normal file
32
experiments/assessment/frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "assessment-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"antd": "^6.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
109
experiments/assessment/frontend/src/App.tsx
Normal file
109
experiments/assessment/frontend/src/App.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Main application component for the assessment interface.
|
||||
*/
|
||||
|
||||
import { ConfigProvider, theme, Spin } from 'antd';
|
||||
import { useAssessment } from './hooks/useAssessment';
|
||||
import { RaterLogin } from './components/RaterLogin';
|
||||
import { InstructionsPage } from './components/InstructionsPage';
|
||||
import { AssessmentPage } from './components/AssessmentPage';
|
||||
import { CompletionPage } from './components/CompletionPage';
|
||||
|
||||
function App() {
|
||||
const assessment = useAssessment();
|
||||
|
||||
const renderContent = () => {
|
||||
// Show loading spinner for initial load
|
||||
if (assessment.loading && !assessment.rater) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (assessment.view) {
|
||||
case 'login':
|
||||
return (
|
||||
<RaterLogin
|
||||
onLogin={assessment.login}
|
||||
loading={assessment.loading}
|
||||
error={assessment.error}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'instructions':
|
||||
return (
|
||||
<InstructionsPage
|
||||
dimensions={assessment.dimensions}
|
||||
onStart={assessment.startAssessment}
|
||||
loading={assessment.loading}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'assessment':
|
||||
if (!assessment.rater || !assessment.currentQuery || !assessment.currentIdea || !assessment.dimensions) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<Spin size="large" tip="Loading..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AssessmentPage
|
||||
raterId={assessment.rater.rater_id}
|
||||
queryId={assessment.currentQuery.query_id}
|
||||
queryText={assessment.currentQuery.query_text}
|
||||
idea={assessment.currentIdea}
|
||||
ideaIndex={assessment.currentIdeaIndex}
|
||||
totalIdeas={assessment.currentQuery.total_count}
|
||||
dimensions={assessment.dimensions}
|
||||
progress={assessment.progress}
|
||||
onNext={assessment.nextIdea}
|
||||
onPrev={assessment.prevIdea}
|
||||
onShowDefinitions={assessment.showInstructions}
|
||||
onLogout={assessment.logout}
|
||||
canGoPrev={assessment.currentIdeaIndex > 0}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'completion':
|
||||
return (
|
||||
<CompletionPage
|
||||
raterId={assessment.rater?.rater_id ?? ''}
|
||||
progress={assessment.progress}
|
||||
onLogout={assessment.logout}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Main assessment page for rating ideas.
|
||||
*/
|
||||
|
||||
import { Card, Button, Space, Alert, Typography } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
ArrowRightOutlined,
|
||||
ForwardOutlined,
|
||||
BookOutlined,
|
||||
LogoutOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { IdeaForRating, DimensionDefinitions, RaterProgress } from '../types';
|
||||
import { useRatings } from '../hooks/useRatings';
|
||||
import { IdeaCard } from './IdeaCard';
|
||||
import { RatingSlider } from './RatingSlider';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface AssessmentPageProps {
|
||||
raterId: string;
|
||||
queryId: string;
|
||||
queryText: string;
|
||||
idea: IdeaForRating;
|
||||
ideaIndex: number;
|
||||
totalIdeas: number;
|
||||
dimensions: DimensionDefinitions;
|
||||
progress: RaterProgress | null;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onShowDefinitions: () => void;
|
||||
onLogout: () => void;
|
||||
canGoPrev: boolean;
|
||||
}
|
||||
|
||||
export function AssessmentPage({
|
||||
raterId,
|
||||
queryId,
|
||||
queryText,
|
||||
idea,
|
||||
ideaIndex,
|
||||
totalIdeas,
|
||||
dimensions,
|
||||
progress,
|
||||
onNext,
|
||||
onPrev,
|
||||
onShowDefinitions,
|
||||
onLogout,
|
||||
canGoPrev
|
||||
}: AssessmentPageProps) {
|
||||
const {
|
||||
ratings,
|
||||
setRating,
|
||||
isComplete,
|
||||
submit,
|
||||
skip,
|
||||
submitting,
|
||||
error
|
||||
} = useRatings({
|
||||
raterId,
|
||||
queryId,
|
||||
ideaId: idea.idea_id,
|
||||
onSuccess: onNext
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await submit();
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
await skip();
|
||||
};
|
||||
|
||||
// Calculate query progress
|
||||
const queryProgress = progress?.queries.find(q => q.query_id === queryId);
|
||||
const queryCompleted = queryProgress?.completed_count ?? ideaIndex;
|
||||
const queryTotal = totalIdeas;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
|
||||
{/* Header with query info and overall progress */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong style={{ fontSize: 16 }}>Query: "{queryText}"</Text>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<BookOutlined />}
|
||||
onClick={onShowDefinitions}
|
||||
size="small"
|
||||
>
|
||||
Definitions
|
||||
</Button>
|
||||
<Button
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={onLogout}
|
||||
size="small"
|
||||
danger
|
||||
>
|
||||
Exit
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<ProgressBar
|
||||
completed={queryCompleted}
|
||||
total={queryTotal}
|
||||
label="Query Progress"
|
||||
/>
|
||||
{progress && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ProgressBar
|
||||
completed={progress.total_completed}
|
||||
total={progress.total_ideas}
|
||||
label="Overall Progress"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<Alert
|
||||
message={error}
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Idea card */}
|
||||
<IdeaCard
|
||||
ideaNumber={ideaIndex + 1}
|
||||
text={idea.text}
|
||||
queryText={queryText}
|
||||
/>
|
||||
|
||||
{/* Rating inputs */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<RatingSlider
|
||||
dimension={dimensions.originality}
|
||||
value={ratings.originality}
|
||||
onChange={(v) => setRating('originality', v)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<RatingSlider
|
||||
dimension={dimensions.elaboration}
|
||||
value={ratings.elaboration}
|
||||
onChange={(v) => setRating('elaboration', v)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<RatingSlider
|
||||
dimension={dimensions.coherence}
|
||||
value={ratings.coherence}
|
||||
onChange={(v) => setRating('coherence', v)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<RatingSlider
|
||||
dimension={dimensions.usefulness}
|
||||
value={ratings.usefulness}
|
||||
onChange={(v) => setRating('usefulness', v)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onPrev}
|
||||
disabled={!canGoPrev || submitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ForwardOutlined />}
|
||||
onClick={handleSkip}
|
||||
loading={submitting}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ArrowRightOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!isComplete()}
|
||||
>
|
||||
Submit & Next
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Completion page shown when all ideas have been rated.
|
||||
*/
|
||||
|
||||
import { Card, Button, Typography, Space, Result, Statistic, Row, Col } from 'antd';
|
||||
import { CheckCircleOutlined, BarChartOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import type { RaterProgress } from '../types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface CompletionPageProps {
|
||||
raterId: string;
|
||||
progress: RaterProgress | null;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export function CompletionPage({ raterId, progress, onLogout }: CompletionPageProps) {
|
||||
const completed = progress?.total_completed ?? 0;
|
||||
const total = progress?.total_ideas ?? 0;
|
||||
const percentage = progress?.percentage ?? 0;
|
||||
|
||||
const isFullyComplete = completed >= total;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: 24
|
||||
}}>
|
||||
<Card style={{ maxWidth: 600, width: '100%' }}>
|
||||
<Result
|
||||
status={isFullyComplete ? 'success' : 'info'}
|
||||
icon={isFullyComplete ? <CheckCircleOutlined /> : <BarChartOutlined />}
|
||||
title={isFullyComplete ? 'Assessment Complete!' : 'Session Summary'}
|
||||
subTitle={
|
||||
isFullyComplete
|
||||
? 'Thank you for completing the assessment.'
|
||||
: 'You have made progress on the assessment.'
|
||||
}
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
key="logout"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
Exit
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Row gutter={16} style={{ marginTop: 24 }}>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="Ideas Rated"
|
||||
value={completed}
|
||||
suffix={`/ ${total}`}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="Progress"
|
||||
value={percentage}
|
||||
suffix="%"
|
||||
precision={1}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="Rater ID"
|
||||
value={raterId}
|
||||
valueStyle={{ fontSize: 16 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{progress && progress.queries.length > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Title level={5}>Progress by Query</Title>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{progress.queries.map((q) => (
|
||||
<div
|
||||
key={q.query_id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 0'
|
||||
}}
|
||||
>
|
||||
<Text>{q.query_id}</Text>
|
||||
<Text type={q.completed_count >= q.total_count ? 'success' : 'secondary'}>
|
||||
{q.completed_count} / {q.total_count}
|
||||
{q.completed_count >= q.total_count && ' ✓'}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Result>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
experiments/assessment/frontend/src/components/IdeaCard.tsx
Normal file
36
experiments/assessment/frontend/src/components/IdeaCard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Card displaying a single idea for rating.
|
||||
*/
|
||||
|
||||
import { Card, Typography, Tag } from 'antd';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface IdeaCardProps {
|
||||
ideaNumber: number;
|
||||
text: string;
|
||||
queryText: string;
|
||||
}
|
||||
|
||||
export function IdeaCard({ ideaNumber, text, queryText }: IdeaCardProps) {
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text strong>IDEA #{ideaNumber}</Text>
|
||||
<Tag color="blue">Query: {queryText}</Tag>
|
||||
</div>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Paragraph style={{
|
||||
fontSize: 16,
|
||||
lineHeight: 1.8,
|
||||
margin: 0,
|
||||
padding: '8px 0'
|
||||
}}>
|
||||
"{text}"
|
||||
</Paragraph>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Instructions page showing dimension definitions.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, Button, Typography, Space, Checkbox, Divider, Tag } from 'antd';
|
||||
import { PlayCircleOutlined } from '@ant-design/icons';
|
||||
import type { DimensionDefinitions } from '../types';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
interface InstructionsPageProps {
|
||||
dimensions: DimensionDefinitions | null;
|
||||
onStart: () => void;
|
||||
onBack?: () => void;
|
||||
loading: boolean;
|
||||
isReturning?: boolean;
|
||||
}
|
||||
|
||||
export function InstructionsPage({
|
||||
dimensions,
|
||||
onStart,
|
||||
onBack,
|
||||
loading,
|
||||
isReturning = false
|
||||
}: InstructionsPageProps) {
|
||||
const [acknowledged, setAcknowledged] = useState(isReturning);
|
||||
|
||||
if (!dimensions) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Text>Loading instructions...</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dimensionOrder = ['originality', 'elaboration', 'coherence', 'usefulness'] as const;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxWidth: 800,
|
||||
margin: '0 auto',
|
||||
padding: 24
|
||||
}}>
|
||||
<Card>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Title level={2}>Assessment Instructions</Title>
|
||||
<Paragraph type="secondary">
|
||||
You will rate creative ideas on 4 dimensions using a 1-5 scale.
|
||||
Please read each definition carefully before beginning.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{dimensionOrder.map((key) => {
|
||||
const dim = dimensions[key];
|
||||
return (
|
||||
<Card
|
||||
key={key}
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<Tag color="blue">{dim.name}</Tag>
|
||||
<Text type="secondary">{dim.question}</Text>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
gap: '8px 16px',
|
||||
fontSize: 14
|
||||
}}>
|
||||
{([1, 2, 3, 4, 5] as const).map((score) => (
|
||||
<>
|
||||
<Tag
|
||||
key={`score-${score}`}
|
||||
color={score <= 2 ? 'red' : score === 3 ? 'orange' : 'green'}
|
||||
>
|
||||
{score}
|
||||
</Tag>
|
||||
<Text key={`text-${score}`}>
|
||||
{dim.scale[score]}
|
||||
</Text>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text type="secondary">{dim.low_label}</Text>
|
||||
<Text type="secondary">{dim.high_label}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{!isReturning && (
|
||||
<Checkbox
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
>
|
||||
I have read and understood the instructions
|
||||
</Checkbox>
|
||||
)}
|
||||
|
||||
<Space style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{onBack && (
|
||||
<Button onClick={onBack}>
|
||||
Back to Assessment
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={onStart}
|
||||
loading={loading}
|
||||
disabled={!acknowledged}
|
||||
>
|
||||
{isReturning ? 'Continue Rating' : 'Begin Rating'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Progress bar component showing assessment progress.
|
||||
*/
|
||||
|
||||
import { Progress, Typography, Space } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ProgressBarProps {
|
||||
completed: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ProgressBar({ completed, total, label }: ProgressBarProps) {
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
{label && (
|
||||
<Space style={{ marginBottom: 4, justifyContent: 'space-between', width: '100%' }}>
|
||||
<Text type="secondary">{label}</Text>
|
||||
<Text type="secondary">
|
||||
{completed}/{total} ({percentage}%)
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
<Progress
|
||||
percent={percentage}
|
||||
showInfo={!label}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
experiments/assessment/frontend/src/components/RaterLogin.tsx
Normal file
116
experiments/assessment/frontend/src/components/RaterLogin.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Rater login component.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Input, Button, Typography, Space, List, Alert } from 'antd';
|
||||
import { UserOutlined, LoginOutlined } from '@ant-design/icons';
|
||||
import * as api from '../services/api';
|
||||
import type { Rater } from '../types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface RaterLoginProps {
|
||||
onLogin: (raterId: string, name?: string) => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function RaterLogin({ onLogin, loading, error }: RaterLoginProps) {
|
||||
const [raterId, setRaterId] = useState('');
|
||||
const [existingRaters, setExistingRaters] = useState<Rater[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.listRaters()
|
||||
.then(setExistingRaters)
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleLogin = () => {
|
||||
if (raterId.trim()) {
|
||||
onLogin(raterId.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickLogin = (rater: Rater) => {
|
||||
onLogin(rater.rater_id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: 24
|
||||
}}>
|
||||
<Card
|
||||
style={{ width: 400, maxWidth: '100%' }}
|
||||
styles={{ body: { padding: 32 } }}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>
|
||||
Creative Idea Assessment
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
Enter your rater ID to begin
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert message={error} type="error" showIcon />
|
||||
)}
|
||||
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="Enter your rater ID"
|
||||
prefix={<UserOutlined />}
|
||||
value={raterId}
|
||||
onChange={(e) => setRaterId(e.target.value)}
|
||||
onPressEnter={handleLogin}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<LoginOutlined />}
|
||||
onClick={handleLogin}
|
||||
loading={loading}
|
||||
disabled={!raterId.trim()}
|
||||
block
|
||||
>
|
||||
Start Assessment
|
||||
</Button>
|
||||
|
||||
{existingRaters.length > 0 && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Existing raters:
|
||||
</Text>
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={existingRaters}
|
||||
renderItem={(rater) => (
|
||||
<List.Item
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleQuickLogin(rater)}
|
||||
>
|
||||
<Text code>{rater.rater_id}</Text>
|
||||
{rater.name && rater.name !== rater.rater_id && (
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
({rater.name})
|
||||
</Text>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Rating input component with radio buttons for 1-5 scale.
|
||||
*/
|
||||
|
||||
import { Radio, Typography, Space, Tooltip, Button } from 'antd';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import type { DimensionDefinition } from '../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface RatingSliderProps {
|
||||
dimension: DimensionDefinition;
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function RatingSlider({ dimension, value, onChange, disabled }: RatingSliderProps) {
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong style={{ marginRight: 8 }}>
|
||||
{dimension.name.toUpperCase()}
|
||||
</Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<p style={{ marginBottom: 8 }}>{dimension.question}</p>
|
||||
{([1, 2, 3, 4, 5] as const).map((score) => (
|
||||
<div key={score} style={{ marginBottom: 4 }}>
|
||||
<strong>{score}:</strong> {dimension.scale[score]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
overlayStyle={{ maxWidth: 400 }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<QuestionCircleOutlined />}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Text type="secondary" style={{ minWidth: 80, textAlign: 'right' }}>
|
||||
{dimension.low_label}
|
||||
</Text>
|
||||
|
||||
<Radio.Group
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Space size="large">
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<Radio key={score} value={score}>
|
||||
{score}
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
<Text type="secondary" style={{ minWidth: 80 }}>
|
||||
{dimension.high_label}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
experiments/assessment/frontend/src/hooks/useAssessment.ts
Normal file
272
experiments/assessment/frontend/src/hooks/useAssessment.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Hook for managing the assessment session state.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type {
|
||||
AppView,
|
||||
DimensionDefinitions,
|
||||
QueryInfo,
|
||||
QueryWithIdeas,
|
||||
Rater,
|
||||
RaterProgress,
|
||||
} from '../types';
|
||||
import * as api from '../services/api';
|
||||
|
||||
interface AssessmentState {
|
||||
view: AppView;
|
||||
rater: Rater | null;
|
||||
queries: QueryInfo[];
|
||||
currentQueryIndex: number;
|
||||
currentQuery: QueryWithIdeas | null;
|
||||
currentIdeaIndex: number;
|
||||
progress: RaterProgress | null;
|
||||
dimensions: DimensionDefinitions | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: AssessmentState = {
|
||||
view: 'login',
|
||||
rater: null,
|
||||
queries: [],
|
||||
currentQueryIndex: 0,
|
||||
currentQuery: null,
|
||||
currentIdeaIndex: 0,
|
||||
progress: null,
|
||||
dimensions: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useAssessment() {
|
||||
const [state, setState] = useState<AssessmentState>(initialState);
|
||||
|
||||
// Load dimension definitions on mount
|
||||
useEffect(() => {
|
||||
api.getDimensionDefinitions()
|
||||
.then((dimensions) => setState((s) => ({ ...s, dimensions })))
|
||||
.catch((err) => console.error('Failed to load dimensions:', err));
|
||||
}, []);
|
||||
|
||||
// Login as a rater
|
||||
const login = useCallback(async (raterId: string, name?: string) => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const rater = await api.createOrGetRater({ rater_id: raterId, name });
|
||||
const queries = await api.listQueries();
|
||||
const progress = await api.getRaterProgress(raterId);
|
||||
|
||||
setState((s) => ({
|
||||
...s,
|
||||
rater,
|
||||
queries,
|
||||
progress,
|
||||
view: 'instructions',
|
||||
loading: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: err instanceof Error ? err.message : 'Login failed',
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start assessment (move from instructions to assessment)
|
||||
const startAssessment = useCallback(async () => {
|
||||
if (!state.rater || state.queries.length === 0) return;
|
||||
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
try {
|
||||
// Find first query with unrated ideas
|
||||
let queryIndex = 0;
|
||||
let queryData: QueryWithIdeas | null = null;
|
||||
|
||||
for (let i = 0; i < state.queries.length; i++) {
|
||||
const unrated = await api.getUnratedIdeas(state.queries[i].query_id, state.rater.rater_id);
|
||||
if (unrated.ideas.length > 0) {
|
||||
queryIndex = i;
|
||||
queryData = unrated;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!queryData) {
|
||||
// All done
|
||||
setState((s) => ({
|
||||
...s,
|
||||
view: 'completion',
|
||||
loading: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setState((s) => ({
|
||||
...s,
|
||||
view: 'assessment',
|
||||
currentQueryIndex: queryIndex,
|
||||
currentQuery: queryData,
|
||||
currentIdeaIndex: 0,
|
||||
loading: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: err instanceof Error ? err.message : 'Failed to start assessment',
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}, [state.rater, state.queries]);
|
||||
|
||||
// Move to next idea
|
||||
const nextIdea = useCallback(async () => {
|
||||
if (!state.currentQuery || !state.rater) return;
|
||||
|
||||
const nextIndex = state.currentIdeaIndex + 1;
|
||||
|
||||
if (nextIndex < state.currentQuery.ideas.length) {
|
||||
// More ideas in current query
|
||||
setState((s) => ({ ...s, currentIdeaIndex: nextIndex }));
|
||||
} else {
|
||||
// Query complete, try to move to next query
|
||||
const nextQueryIndex = state.currentQueryIndex + 1;
|
||||
|
||||
if (nextQueryIndex < state.queries.length) {
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
try {
|
||||
const unrated = await api.getUnratedIdeas(
|
||||
state.queries[nextQueryIndex].query_id,
|
||||
state.rater.rater_id
|
||||
);
|
||||
|
||||
if (unrated.ideas.length > 0) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
currentQueryIndex: nextQueryIndex,
|
||||
currentQuery: unrated,
|
||||
currentIdeaIndex: 0,
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
// Try to find next query with unrated ideas
|
||||
for (let i = nextQueryIndex + 1; i < state.queries.length; i++) {
|
||||
const nextUnrated = await api.getUnratedIdeas(
|
||||
state.queries[i].query_id,
|
||||
state.rater.rater_id
|
||||
);
|
||||
if (nextUnrated.ideas.length > 0) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
currentQueryIndex: i,
|
||||
currentQuery: nextUnrated,
|
||||
currentIdeaIndex: 0,
|
||||
loading: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// All queries complete
|
||||
setState((s) => ({
|
||||
...s,
|
||||
view: 'completion',
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: err instanceof Error ? err.message : 'Failed to load next query',
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// All queries complete
|
||||
setState((s) => ({ ...s, view: 'completion' }));
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh progress
|
||||
try {
|
||||
const progress = await api.getRaterProgress(state.rater.rater_id);
|
||||
setState((s) => ({ ...s, progress }));
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh progress:', err);
|
||||
}
|
||||
}, [state.currentQuery, state.currentIdeaIndex, state.currentQueryIndex, state.queries, state.rater]);
|
||||
|
||||
// Move to previous idea
|
||||
const prevIdea = useCallback(() => {
|
||||
if (state.currentIdeaIndex > 0) {
|
||||
setState((s) => ({ ...s, currentIdeaIndex: s.currentIdeaIndex - 1 }));
|
||||
}
|
||||
}, [state.currentIdeaIndex]);
|
||||
|
||||
// Jump to a specific query
|
||||
const jumpToQuery = useCallback(async (queryIndex: number) => {
|
||||
if (!state.rater || queryIndex < 0 || queryIndex >= state.queries.length) return;
|
||||
|
||||
setState((s) => ({ ...s, loading: true }));
|
||||
try {
|
||||
const queryData = await api.getQueryWithIdeas(state.queries[queryIndex].query_id);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
currentQueryIndex: queryIndex,
|
||||
currentQuery: queryData,
|
||||
currentIdeaIndex: 0,
|
||||
view: 'assessment',
|
||||
loading: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: err instanceof Error ? err.message : 'Failed to load query',
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
}, [state.rater, state.queries]);
|
||||
|
||||
// Refresh progress
|
||||
const refreshProgress = useCallback(async () => {
|
||||
if (!state.rater) return;
|
||||
try {
|
||||
const progress = await api.getRaterProgress(state.rater.rater_id);
|
||||
setState((s) => ({ ...s, progress }));
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh progress:', err);
|
||||
}
|
||||
}, [state.rater]);
|
||||
|
||||
// Show definitions
|
||||
const showInstructions = useCallback(() => {
|
||||
setState((s) => ({ ...s, view: 'instructions' }));
|
||||
}, []);
|
||||
|
||||
// Return to assessment
|
||||
const returnToAssessment = useCallback(() => {
|
||||
setState((s) => ({ ...s, view: 'assessment' }));
|
||||
}, []);
|
||||
|
||||
// Logout
|
||||
const logout = useCallback(() => {
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
// Get current idea
|
||||
const currentIdea = state.currentQuery?.ideas[state.currentIdeaIndex] ?? null;
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentIdea,
|
||||
login,
|
||||
startAssessment,
|
||||
nextIdea,
|
||||
prevIdea,
|
||||
jumpToQuery,
|
||||
refreshProgress,
|
||||
showInstructions,
|
||||
returnToAssessment,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
133
experiments/assessment/frontend/src/hooks/useRatings.ts
Normal file
133
experiments/assessment/frontend/src/hooks/useRatings.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Hook for managing rating submission.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { RatingState, DimensionKey } from '../types';
|
||||
import * as api from '../services/api';
|
||||
|
||||
interface UseRatingsOptions {
|
||||
raterId: string | null;
|
||||
queryId: string | null;
|
||||
ideaId: string | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function useRatings({ raterId, queryId, ideaId, onSuccess }: UseRatingsOptions) {
|
||||
const [ratings, setRatings] = useState<RatingState>({
|
||||
originality: null,
|
||||
elaboration: null,
|
||||
coherence: null,
|
||||
usefulness: null,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Set a single rating
|
||||
const setRating = useCallback((dimension: DimensionKey, value: number | null) => {
|
||||
setRatings((prev) => ({ ...prev, [dimension]: value }));
|
||||
}, []);
|
||||
|
||||
// Reset all ratings
|
||||
const resetRatings = useCallback(() => {
|
||||
setRatings({
|
||||
originality: null,
|
||||
elaboration: null,
|
||||
coherence: null,
|
||||
usefulness: null,
|
||||
});
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Check if all ratings are set
|
||||
const isComplete = useCallback(() => {
|
||||
return (
|
||||
ratings.originality !== null &&
|
||||
ratings.elaboration !== null &&
|
||||
ratings.coherence !== null &&
|
||||
ratings.usefulness !== null
|
||||
);
|
||||
}, [ratings]);
|
||||
|
||||
// Submit rating
|
||||
const submit = useCallback(async () => {
|
||||
if (!raterId || !queryId || !ideaId) {
|
||||
setError('Missing required information');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isComplete()) {
|
||||
setError('Please rate all dimensions');
|
||||
return false;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.submitRating({
|
||||
rater_id: raterId,
|
||||
idea_id: ideaId,
|
||||
query_id: queryId,
|
||||
originality: ratings.originality,
|
||||
elaboration: ratings.elaboration,
|
||||
coherence: ratings.coherence,
|
||||
usefulness: ratings.usefulness,
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
resetRatings();
|
||||
onSuccess?.();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit rating');
|
||||
return false;
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [raterId, queryId, ideaId, ratings, isComplete, resetRatings, onSuccess]);
|
||||
|
||||
// Skip idea
|
||||
const skip = useCallback(async () => {
|
||||
if (!raterId || !queryId || !ideaId) {
|
||||
setError('Missing required information');
|
||||
return false;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.submitRating({
|
||||
rater_id: raterId,
|
||||
idea_id: ideaId,
|
||||
query_id: queryId,
|
||||
originality: null,
|
||||
elaboration: null,
|
||||
coherence: null,
|
||||
usefulness: null,
|
||||
skipped: true,
|
||||
});
|
||||
|
||||
resetRatings();
|
||||
onSuccess?.();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to skip idea');
|
||||
return false;
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [raterId, queryId, ideaId, resetRatings, onSuccess]);
|
||||
|
||||
return {
|
||||
ratings,
|
||||
setRating,
|
||||
resetRatings,
|
||||
isComplete,
|
||||
submit,
|
||||
skip,
|
||||
submitting,
|
||||
error,
|
||||
};
|
||||
}
|
||||
43
experiments/assessment/frontend/src/index.css
Normal file
43
experiments/assessment/frontend/src/index.css
Normal file
@@ -0,0 +1,43 @@
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background-color: #f5f5f5;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
10
experiments/assessment/frontend/src/main.tsx
Normal file
10
experiments/assessment/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
116
experiments/assessment/frontend/src/services/api.ts
Normal file
116
experiments/assessment/frontend/src/services/api.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* API client for the assessment backend.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DimensionDefinitions,
|
||||
QueryInfo,
|
||||
QueryWithIdeas,
|
||||
Rater,
|
||||
RaterCreate,
|
||||
RaterProgress,
|
||||
Rating,
|
||||
RatingSubmit,
|
||||
SessionInfo,
|
||||
Statistics,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(error.detail || 'API request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Rater API
|
||||
export async function listRaters(): Promise<Rater[]> {
|
||||
return fetchJson<Rater[]>('/raters');
|
||||
}
|
||||
|
||||
export async function createOrGetRater(data: RaterCreate): Promise<Rater> {
|
||||
return fetchJson<Rater>('/raters', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRater(raterId: string): Promise<Rater> {
|
||||
return fetchJson<Rater>(`/raters/${encodeURIComponent(raterId)}`);
|
||||
}
|
||||
|
||||
// Query API
|
||||
export async function listQueries(): Promise<QueryInfo[]> {
|
||||
return fetchJson<QueryInfo[]>('/queries');
|
||||
}
|
||||
|
||||
export async function getQueryWithIdeas(queryId: string): Promise<QueryWithIdeas> {
|
||||
return fetchJson<QueryWithIdeas>(`/queries/${encodeURIComponent(queryId)}`);
|
||||
}
|
||||
|
||||
export async function getUnratedIdeas(queryId: string, raterId: string): Promise<QueryWithIdeas> {
|
||||
return fetchJson<QueryWithIdeas>(
|
||||
`/queries/${encodeURIComponent(queryId)}/unrated?rater_id=${encodeURIComponent(raterId)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Rating API
|
||||
export async function submitRating(rating: RatingSubmit): Promise<{ saved: boolean }> {
|
||||
return fetchJson<{ saved: boolean }>('/ratings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(rating),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRating(raterId: string, ideaId: string): Promise<Rating | null> {
|
||||
try {
|
||||
return await fetchJson<Rating>(`/ratings/${encodeURIComponent(raterId)}/${encodeURIComponent(ideaId)}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRatingsByRater(raterId: string): Promise<Rating[]> {
|
||||
return fetchJson<Rating[]>(`/ratings/rater/${encodeURIComponent(raterId)}`);
|
||||
}
|
||||
|
||||
// Progress API
|
||||
export async function getRaterProgress(raterId: string): Promise<RaterProgress> {
|
||||
return fetchJson<RaterProgress>(`/progress/${encodeURIComponent(raterId)}`);
|
||||
}
|
||||
|
||||
// Statistics API
|
||||
export async function getStatistics(): Promise<Statistics> {
|
||||
return fetchJson<Statistics>('/statistics');
|
||||
}
|
||||
|
||||
// Dimension definitions API
|
||||
export async function getDimensionDefinitions(): Promise<DimensionDefinitions> {
|
||||
return fetchJson<DimensionDefinitions>('/dimensions');
|
||||
}
|
||||
|
||||
// Session info API
|
||||
export async function getSessionInfo(): Promise<SessionInfo> {
|
||||
return fetchJson<SessionInfo>('/info');
|
||||
}
|
||||
|
||||
// Health check
|
||||
export async function healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await fetchJson<{ status: string }>('/health');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
142
experiments/assessment/frontend/src/types/index.ts
Normal file
142
experiments/assessment/frontend/src/types/index.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* TypeScript types for the assessment frontend.
|
||||
*/
|
||||
|
||||
// Rater types
|
||||
export interface Rater {
|
||||
rater_id: string;
|
||||
name: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface RaterCreate {
|
||||
rater_id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Query types
|
||||
export interface QueryInfo {
|
||||
query_id: string;
|
||||
query_text: string;
|
||||
category: string;
|
||||
idea_count: number;
|
||||
}
|
||||
|
||||
export interface IdeaForRating {
|
||||
idea_id: string;
|
||||
text: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface QueryWithIdeas {
|
||||
query_id: string;
|
||||
query_text: string;
|
||||
category: string;
|
||||
ideas: IdeaForRating[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
// Rating types
|
||||
export interface RatingSubmit {
|
||||
rater_id: string;
|
||||
idea_id: string;
|
||||
query_id: string;
|
||||
originality: number | null;
|
||||
elaboration: number | null;
|
||||
coherence: number | null;
|
||||
usefulness: number | null;
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
export interface Rating {
|
||||
id: number;
|
||||
rater_id: string;
|
||||
idea_id: string;
|
||||
query_id: string;
|
||||
originality: number | null;
|
||||
elaboration: number | null;
|
||||
coherence: number | null;
|
||||
usefulness: number | null;
|
||||
skipped: number;
|
||||
timestamp: string | null;
|
||||
}
|
||||
|
||||
// Progress types
|
||||
export interface QueryProgress {
|
||||
rater_id: string;
|
||||
query_id: string;
|
||||
completed_count: number;
|
||||
total_count: number;
|
||||
started_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface RaterProgress {
|
||||
rater_id: string;
|
||||
queries: QueryProgress[];
|
||||
total_completed: number;
|
||||
total_ideas: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// Statistics types
|
||||
export interface Statistics {
|
||||
rater_count: number;
|
||||
rating_count: number;
|
||||
skip_count: number;
|
||||
rated_ideas: number;
|
||||
}
|
||||
|
||||
// Dimension definition types
|
||||
export interface DimensionScale {
|
||||
1: string;
|
||||
2: string;
|
||||
3: string;
|
||||
4: string;
|
||||
5: string;
|
||||
}
|
||||
|
||||
export interface DimensionDefinition {
|
||||
name: string;
|
||||
question: string;
|
||||
scale: DimensionScale;
|
||||
low_label: string;
|
||||
high_label: string;
|
||||
}
|
||||
|
||||
export interface DimensionDefinitions {
|
||||
originality: DimensionDefinition;
|
||||
elaboration: DimensionDefinition;
|
||||
coherence: DimensionDefinition;
|
||||
usefulness: DimensionDefinition;
|
||||
}
|
||||
|
||||
// Session info
|
||||
export interface SessionInfo {
|
||||
experiment_id: string;
|
||||
total_ideas: number;
|
||||
query_count: number;
|
||||
conditions: string[];
|
||||
randomization_seed: number;
|
||||
}
|
||||
|
||||
// UI State types
|
||||
export type AppView = 'login' | 'instructions' | 'assessment' | 'completion';
|
||||
|
||||
export interface RatingState {
|
||||
originality: number | null;
|
||||
elaboration: number | null;
|
||||
coherence: number | null;
|
||||
usefulness: number | null;
|
||||
}
|
||||
|
||||
export const EMPTY_RATING_STATE: RatingState = {
|
||||
originality: null,
|
||||
elaboration: null,
|
||||
coherence: null,
|
||||
usefulness: null,
|
||||
};
|
||||
|
||||
export type DimensionKey = keyof RatingState;
|
||||
|
||||
export const DIMENSION_KEYS: DimensionKey[] = ['originality', 'elaboration', 'coherence', 'usefulness'];
|
||||
20
experiments/assessment/frontend/tsconfig.json
Normal file
20
experiments/assessment/frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
experiments/assessment/frontend/vite.config.ts
Normal file
16
experiments/assessment/frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8002',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user