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:
2026-01-20 10:16:21 +08:00
parent 26a56a2a07
commit 43c025e060
81 changed files with 18766 additions and 2 deletions

View 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>

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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,
};
}

View 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,
};
}

View 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;
}

View 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>,
)

View 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;
}
}

View 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'];

View 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"]
}

View 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
}
}
},
})