participant-calibration flow implemented
This commit is contained in:
@@ -9,6 +9,8 @@ import { auth } from '@/lib/auth';
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import type { DatasetEntryForAnnotation } from '@/lib/dialects';
|
import type { DatasetEntryForAnnotation } from '@/lib/dialects';
|
||||||
import { experiment } from '@/lib/model/experiment';
|
import { experiment } from '@/lib/model/experiment';
|
||||||
|
import { experiment_calibration } from '@/lib/model/experiment_calibration';
|
||||||
|
import { participant } from '@/lib/model/participant';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns unannotated entries for the given dataset and authenticated user.
|
* Returns unannotated entries for the given dataset and authenticated user.
|
||||||
@@ -133,3 +135,60 @@ export async function getAnnotationProgress(
|
|||||||
return { total: allEntries.length, done: annotated.length };
|
return { total: allEntries.length, done: annotated.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if calibration is required and completed for the current user in an experiment.
|
||||||
|
* Returns true if calibration is completed or not required, false if calibration is pending.
|
||||||
|
*/
|
||||||
|
export async function isCalibrationDone(experimentId: number): Promise<boolean> {
|
||||||
|
console.log('Checking calibration status for experimentId:', experimentId);
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error('Nicht angemeldet');
|
||||||
|
|
||||||
|
// Get calibration items for this experiment
|
||||||
|
const calibrationItems = await db
|
||||||
|
.select()
|
||||||
|
.from(experiment_calibration)
|
||||||
|
.where(eq(experiment_calibration.experimentId, experimentId));
|
||||||
|
|
||||||
|
// If no calibration items are defined, calibration is not required
|
||||||
|
if (calibrationItems.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if participant exists for this user and experiment
|
||||||
|
const participantRecord = await db
|
||||||
|
.select()
|
||||||
|
.from(participant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(participant.experimentId, experimentId),
|
||||||
|
eq(participant.userId, session.user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// If no participant record exists, calibration is not done
|
||||||
|
if (participantRecord.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if calibration answers are filled
|
||||||
|
const calibrationAnswers = participantRecord[0].calibrationAnswers as Record<number, { dialectLabel: string; confidence: number }> | null;
|
||||||
|
|
||||||
|
if (!calibrationAnswers) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that all calibration items have complete answers
|
||||||
|
for (const item of calibrationItems) {
|
||||||
|
const answer = calibrationAnswers[item.id];
|
||||||
|
|
||||||
|
// Check if answer exists and has both required fields
|
||||||
|
if (!answer || !answer.dialectLabel || !answer.confidence) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import db from '@/lib/db';
|
import db from '@/lib/db';
|
||||||
import { experiment_calibration } from '@/lib/model/experiment_calibration';
|
import { experiment_calibration } from '@/lib/model/experiment_calibration';
|
||||||
|
import { participant } from '@/lib/model/participant';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { requireAdmin } from '@/lib/auth';
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
import { unlink } from 'fs/promises';
|
import { unlink } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
@@ -22,6 +25,33 @@ export async function getCalibrationEntries(experimentId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCalibrationAnswers(experimentId: number) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error('Nicht angemeldet');
|
||||||
|
|
||||||
|
const participantRecord = await db
|
||||||
|
.select()
|
||||||
|
.from(participant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(participant.experimentId, experimentId),
|
||||||
|
eq(participant.userId, session.user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (participantRecord.length === 0) {
|
||||||
|
return { success: true, data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: participantRecord[0].calibrationAnswers };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching calibration answers:', error);
|
||||||
|
throw new Error('Failed to fetch calibration answers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteCalibrationEntry(calibrationId: number, experimentId: number) {
|
export async function deleteCalibrationEntry(calibrationId: number, experimentId: number) {
|
||||||
const result = await requireAdmin();
|
const result = await requireAdmin();
|
||||||
if (!result.authenticated || !result.admin) {
|
if (!result.authenticated || !result.admin) {
|
||||||
@@ -133,3 +163,35 @@ export async function deleteAllCalibrationEntries(experimentId: number) {
|
|||||||
throw new Error('Failed to delete all calibration entries');
|
throw new Error('Failed to delete all calibration entries');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves calibration answers for the current user in an experiment.
|
||||||
|
* Creates or updates a participant record with the calibration answers as JSON.
|
||||||
|
*/
|
||||||
|
export async function saveCalibrationAnswers(
|
||||||
|
experimentId: number,
|
||||||
|
answers: Record<number, { calibrationItemId: number; dialectLabel: string; confidence: number }>
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error('Nicht angemeldet');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upsert participant record with calibration answers
|
||||||
|
await db
|
||||||
|
.insert(participant)
|
||||||
|
.values({
|
||||||
|
experimentId,
|
||||||
|
userId: session.user.id,
|
||||||
|
calibrationAnswers: answers,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [participant.experimentId, participant.userId],
|
||||||
|
set: {
|
||||||
|
calibrationAnswers: answers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving calibration answers:', error);
|
||||||
|
throw new Error('Failed to save calibration answers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { redirect, notFound } from 'next/navigation';
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { getAnnotationEntries } from '@/app/actions/annotations';
|
import { getAnnotationEntries, isCalibrationDone } from '@/app/actions/annotations';
|
||||||
import { getExperimentById } from '@/app/actions/experiment';
|
import { getExperimentById } from '@/app/actions/experiment';
|
||||||
import AnnotationPageView from '@/components/AnnotationViews/AnnotationPageView';
|
import AnnotationPageView from '@/components/AnnotationViews/AnnotationPageView';
|
||||||
|
import CalibrationPhase from '@/components/CalibrationViews/CalibrationPhase';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ experimentId: string}>;
|
params: Promise<{ experimentId: string}>;
|
||||||
@@ -16,7 +17,6 @@ export default async function AnnotatePage({ params }: Props) {
|
|||||||
|
|
||||||
const { experimentId: experimentIdStr } = await params;
|
const { experimentId: experimentIdStr } = await params;
|
||||||
const experimentId = parseInt(experimentIdStr, 10);
|
const experimentId = parseInt(experimentIdStr, 10);
|
||||||
const prototype = await getExperimentById(experimentId).then(exp => exp?.annotationTool || null);
|
|
||||||
|
|
||||||
if (isNaN(experimentId)) {
|
if (isNaN(experimentId)) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +29,13 @@ export default async function AnnotatePage({ params }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if calibration is required and not yet done
|
||||||
|
const calibrationDone = await isCalibrationDone(experimentId);
|
||||||
|
if (!calibrationDone) {
|
||||||
|
return <CalibrationPhase experimentId={experimentId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prototype = await getExperimentById(experimentId).then(exp => exp?.annotationTool || null);
|
||||||
const entries = await getAnnotationEntries(experimentId);
|
const entries = await getAnnotationEntries(experimentId);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
|
|||||||
165
src/components/CalibrationViews/CalibrationEntryView.tsx
Normal file
165
src/components/CalibrationViews/CalibrationEntryView.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import WaveformPlayer from '../WaveformPlayer';
|
||||||
|
import { DIALECT_LABELS, DIALECT_LABELS_WITHOUT_DE } from '@/lib/dialects';
|
||||||
|
import { ExperimentCalibration } from '@/lib/model/experiment_calibration';
|
||||||
|
|
||||||
|
interface CalibrationEntryViewProps {
|
||||||
|
entry: ExperimentCalibration;
|
||||||
|
onDialectSelect: (dialectLabel: string) => Promise<void>;
|
||||||
|
onConfidenceSelect: (confidence: number) => Promise<void>;
|
||||||
|
isSaving: boolean;
|
||||||
|
existingAnswer?: { dialectLabel: string; confidence: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIDENCE_OPTIONS = [
|
||||||
|
{ value: 1, label: 'Sehr unsicher' },
|
||||||
|
{ value: 2, label: 'Eher unsicher' },
|
||||||
|
{ value: 3, label: 'Neutral' },
|
||||||
|
{ value: 4, label: 'Eher sicher' },
|
||||||
|
{ value: 5, label: 'Sehr sicher' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CalibrationEntryView({
|
||||||
|
entry,
|
||||||
|
onDialectSelect,
|
||||||
|
onConfidenceSelect,
|
||||||
|
isSaving,
|
||||||
|
existingAnswer,
|
||||||
|
}: CalibrationEntryViewProps) {
|
||||||
|
const [selectedDialect, setSelectedDialect] = useState<string | null>(existingAnswer?.dialectLabel || null);
|
||||||
|
const [selectedConfidence, setSelectedConfidence] = useState<number | null>(existingAnswer?.confidence || null);
|
||||||
|
const [fullyPlayed, setFullyPlayed] = useState<boolean>(existingAnswer ? true : false);
|
||||||
|
|
||||||
|
const audioSrc = entry.file;
|
||||||
|
|
||||||
|
const handleDialectChange = async (dialect: string) => {
|
||||||
|
setSelectedDialect(dialect);
|
||||||
|
await onDialectSelect(dialect);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfidenceChange = async (confidence: number) => {
|
||||||
|
setSelectedConfidence(confidence);
|
||||||
|
await onConfidenceSelect(confidence);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset state when entry changes
|
||||||
|
setSelectedDialect(existingAnswer?.dialectLabel || null);
|
||||||
|
setSelectedConfidence(existingAnswer?.confidence || null);
|
||||||
|
setFullyPlayed(existingAnswer ? true : false);
|
||||||
|
}, [entry, existingAnswer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border rounded-xl p-5 bg-white shadow-sm transition-colors duration-300 ${
|
||||||
|
fullyPlayed && selectedDialect !== null && selectedConfidence !== null
|
||||||
|
? 'border-green-300'
|
||||||
|
: 'border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Sample header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||||||
|
Kalibrierungssample - {entry.order}
|
||||||
|
</span>
|
||||||
|
{fullyPlayed && (
|
||||||
|
<span className="text-xs text-green-600 font-medium">
|
||||||
|
✓ Gehört
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waveform player */}
|
||||||
|
<WaveformPlayer
|
||||||
|
src={audioSrc}
|
||||||
|
durationMs={null}
|
||||||
|
onFullyPlayed={() => setFullyPlayed(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Must-listen hint */}
|
||||||
|
{!fullyPlayed && (
|
||||||
|
<p className="text-xs text-amber-600 mt-2">
|
||||||
|
Bitte das Sample vollständig anhören, bevor Sie eine Bewertung abgeben.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialect question */}
|
||||||
|
<div className="mt-6 mb-4">
|
||||||
|
<div className="text-sm font-semibold text-gray-700 mb-3">
|
||||||
|
Welcher Dialekt ist es?
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{Object.entries(DIALECT_LABELS_WITHOUT_DE).map(([dialectKey, dialectName]) => {
|
||||||
|
const selected = selectedDialect === dialectKey;
|
||||||
|
const disabled = !fullyPlayed || isSaving;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dialectKey}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleDialectChange(dialectKey)}
|
||||||
|
className={`flex items-center gap-3 w-full text-left px-4 py-2.5 rounded-lg border text-sm transition-colors ${
|
||||||
|
disabled
|
||||||
|
? 'opacity-40 cursor-not-allowed border-gray-200 bg-gray-50 text-gray-500'
|
||||||
|
: selected
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-800 font-medium'
|
||||||
|
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
||||||
|
selected
|
||||||
|
? 'border-blue-500 bg-blue-500 text-white'
|
||||||
|
: 'border-gray-300 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<span>{dialectName}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confidence question */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="text-sm font-semibold text-gray-700 mb-3">
|
||||||
|
Wie sicher sind Sie?
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CONFIDENCE_OPTIONS.map(({ value, label }) => {
|
||||||
|
const selected = selectedConfidence === value;
|
||||||
|
const disabled = !fullyPlayed || isSaving;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => handleConfidenceChange(value)}
|
||||||
|
className={`flex items-center gap-3 w-full text-left px-4 py-2.5 rounded-lg border text-sm transition-colors ${
|
||||||
|
disabled
|
||||||
|
? 'opacity-40 cursor-not-allowed border-gray-200 bg-gray-50 text-gray-500'
|
||||||
|
: selected
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-800 font-medium'
|
||||||
|
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
||||||
|
selected
|
||||||
|
? 'border-blue-500 bg-blue-500 text-white'
|
||||||
|
: 'border-gray-300 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
src/components/CalibrationViews/CalibrationPageView.tsx
Normal file
241
src/components/CalibrationViews/CalibrationPageView.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition, useMemo, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { saveCalibrationAnswers, getCalibrationAnswers } from '@/app/actions/calibration';
|
||||||
|
import { ExperimentCalibration } from '@/lib/model/experiment_calibration';
|
||||||
|
import CalibrationEntryView from './CalibrationEntryView';
|
||||||
|
|
||||||
|
interface CalibrationPageViewProps {
|
||||||
|
entries: ExperimentCalibration[];
|
||||||
|
experimentId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalibrationAnswers {
|
||||||
|
[key: number]: {
|
||||||
|
calibrationItemId: number;
|
||||||
|
dialectLabel: string;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalibrationPageView({
|
||||||
|
entries,
|
||||||
|
experimentId,
|
||||||
|
}: CalibrationPageViewProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [answers, setAnswers] = useState<CalibrationAnswers>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
|
||||||
|
// Load existing answers on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadExistingAnswers = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getCalibrationAnswers(experimentId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setAnswers(result.data as CalibrationAnswers);
|
||||||
|
|
||||||
|
// Find the first unanswered entry
|
||||||
|
const firstUnanswered = entries.findIndex((e) => {
|
||||||
|
const answer = (result.data as CalibrationAnswers)[e.id];
|
||||||
|
return !answer || !answer.dialectLabel || !answer.confidence;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (firstUnanswered !== -1) {
|
||||||
|
setCurrentIndex(firstUnanswered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading calibration answers:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadExistingAnswers();
|
||||||
|
}, [experimentId, entries]);
|
||||||
|
|
||||||
|
const currentEntry = entries[currentIndex];
|
||||||
|
|
||||||
|
// Count only complete answers (both dialect and confidence filled)
|
||||||
|
const completeAnswers = Object.values(answers).filter(
|
||||||
|
(answer) => answer.dialectLabel && answer.confidence > 0
|
||||||
|
);
|
||||||
|
const isComplete = entries.length > 0 && completeAnswers.length === entries.length;
|
||||||
|
const progressPct = Math.round((completeAnswers.length / entries.length) * 100);
|
||||||
|
|
||||||
|
// Check if current entry has complete answers
|
||||||
|
const currentEntryComplete = currentEntry &&
|
||||||
|
answers[currentEntry.id]?.dialectLabel &&
|
||||||
|
answers[currentEntry.id]?.confidence > 0;
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
setCurrentIndex((i) => i - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentIndex + 1 < entries.length) {
|
||||||
|
setCurrentIndex((i) => i + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEntry = async (dialectLabel: string, confidence: number) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
// Build updated answers
|
||||||
|
const updatedAnswers = { ...answers };
|
||||||
|
|
||||||
|
// Initialize entry if it doesn't exist
|
||||||
|
if (!updatedAnswers[currentEntry.id]) {
|
||||||
|
updatedAnswers[currentEntry.id] = {
|
||||||
|
calibrationItemId: currentEntry.id,
|
||||||
|
dialectLabel: '',
|
||||||
|
confidence: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update with new values (keep existing values if not provided)
|
||||||
|
if (dialectLabel) {
|
||||||
|
updatedAnswers[currentEntry.id].dialectLabel = dialectLabel;
|
||||||
|
}
|
||||||
|
if (confidence > 0) {
|
||||||
|
updatedAnswers[currentEntry.id].confidence = confidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnswers(updatedAnswers);
|
||||||
|
|
||||||
|
// Save to database immediately
|
||||||
|
try {
|
||||||
|
await saveCalibrationAnswers(experimentId, updatedAnswers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving calibration answers:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-advance to next entry only if both answers are selected
|
||||||
|
const entry = updatedAnswers[currentEntry.id];
|
||||||
|
if (entry.dialectLabel && entry.confidence > 0) {
|
||||||
|
// Don't auto-advance if it's the last entry
|
||||||
|
if (currentIndex < entries.length - 1) {
|
||||||
|
handleNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialectSelect = async (dialect: string) => {
|
||||||
|
const currentConfidence = answers[currentEntry.id]?.confidence || 0;
|
||||||
|
await handleSaveEntry(dialect, currentConfidence);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfidenceSelect = async (confidence: number) => {
|
||||||
|
const currentDialect = answers[currentEntry.id]?.dialectLabel || '';
|
||||||
|
await handleSaveEntry(currentDialect, confidence);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompleteCalibration = async () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
// All answers are already saved, just trigger a page reload or redirect
|
||||||
|
// Reload the page to check calibration status again
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-white">
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl mb-4">⏳</div>
|
||||||
|
<p className="text-gray-600">Laden Sie Ihre vorherigen Antworten...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-white">
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto text-center py-20">
|
||||||
|
<div className="text-5xl mb-4">🎉</div>
|
||||||
|
<h1 className="text-3xl font-bold text-green-600 mb-3">
|
||||||
|
Kalibrierung abgeschlossen!
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
Sie haben alle Kalibrierungssamples bewertet. Vielen Dank! Sie können jetzt mit der
|
||||||
|
Annotation beginnen.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCompleteCalibration}
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? 'Wird gespeichert...' : 'Zur Annotation'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-white">
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Header with progress */}
|
||||||
|
<div className="sticky top-0 z-50 bg-white border-b border-gray-200 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Kalibrierungsphase</h1>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{completeAnswers.length} / {entries.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto py-8 px-4">
|
||||||
|
{currentEntry && (
|
||||||
|
<CalibrationEntryView
|
||||||
|
entry={currentEntry}
|
||||||
|
onDialectSelect={handleDialectSelect}
|
||||||
|
onConfidenceSelect={handleConfidenceSelect}
|
||||||
|
isSaving={isPending}
|
||||||
|
existingAnswer={answers[currentEntry.id]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation footer */}
|
||||||
|
<div className="sticky bottom-0 bg-white border-t border-gray-200 px-6 py-4 flex gap-3 justify-between">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={currentIndex === 0 || isPending}
|
||||||
|
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
← Vorherige
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={isPending || !currentEntryComplete}
|
||||||
|
title={!currentEntryComplete ? 'Bitte beantworten Sie beide Fragen, bevor Sie fortfahren' : ''}
|
||||||
|
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{currentIndex === entries.length - 1 ? 'Fertig' : 'Nächste →'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/CalibrationViews/CalibrationPhase.tsx
Normal file
60
src/components/CalibrationViews/CalibrationPhase.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getCalibrationEntries } from '@/app/actions/calibration';
|
||||||
|
import { ExperimentCalibration } from '@/lib/model/experiment_calibration';
|
||||||
|
import CalibrationPageView from './CalibrationPageView';
|
||||||
|
|
||||||
|
interface CalibrationPhaseProps {
|
||||||
|
experimentId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalibrationPhase({ experimentId }: CalibrationPhaseProps) {
|
||||||
|
const [entries, setEntries] = useState<ExperimentCalibration[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getCalibrationEntries(experimentId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
// Sort by order
|
||||||
|
const sorted = [...result.data].sort((a, b) => a.order - b.order);
|
||||||
|
setEntries(sorted);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Kalibrierungssamples');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, [experimentId]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-16 text-center">
|
||||||
|
<div className="text-5xl mb-4">⏳</div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Lädt Kalibrierungssamples...</h1>
|
||||||
|
<p className="text-gray-600">Bitte warten Sie...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !entries || entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-16 text-center">
|
||||||
|
<div className="text-5xl mb-4">❌</div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Kalibrierung nicht verfügbar</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
{error || 'Für dieses Experiment sind keine Kalibrierungssamples vorhanden.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CalibrationPageView entries={entries} experimentId={experimentId} />;
|
||||||
|
}
|
||||||
@@ -9,6 +9,16 @@ export const DIALECT_LABELS: Record<string, string> = {
|
|||||||
de: 'Deutsch',
|
de: 'Deutsch',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DIALECT_LABELS_WITHOUT_DE: Record<string, string> = {
|
||||||
|
ch_be: 'Bern',
|
||||||
|
ch_bs: 'Basel',
|
||||||
|
ch_gr: 'Graubünden',
|
||||||
|
ch_in: 'Innerschweiz',
|
||||||
|
ch_os: 'Ostschweiz',
|
||||||
|
ch_vs: 'Wallis',
|
||||||
|
ch_zh: 'Zürich'
|
||||||
|
};
|
||||||
|
|
||||||
export type DatasetEntryForAnnotation = {
|
export type DatasetEntryForAnnotation = {
|
||||||
id: number;
|
id: number;
|
||||||
externalId: string;
|
externalId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user