implement calibration scoring and display features
This commit is contained in:
@@ -9,11 +9,63 @@ 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 { getDialectScoresFromCalibration, isCalibrationDone } from '@/app/actions/calibration-scoring';
|
||||||
import { participant } from '@/lib/model/participant';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts entries by weighted dialect score with fuzzy interleaving (deterministic round-robin).
|
||||||
|
* Higher-scoring dialects appear more frequently based on their relative performance.
|
||||||
|
*/
|
||||||
|
function sortEntriesByWeightedDialectScore(
|
||||||
|
entries: DatasetEntryForAnnotation[],
|
||||||
|
dialectScores: Record<string, number>
|
||||||
|
): DatasetEntryForAnnotation[] {
|
||||||
|
const dialectGroups = new Map<string, DatasetEntryForAnnotation[]>();
|
||||||
|
|
||||||
|
// Group entries by dialect
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!dialectGroups.has(entry.dialect)) {
|
||||||
|
dialectGroups.set(entry.dialect, []);
|
||||||
|
}
|
||||||
|
dialectGroups.get(entry.dialect)!.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort groups by dialect score (highest first)
|
||||||
|
const sortedGroups = Array.from(dialectGroups.entries())
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aScore = dialectScores[a[0]] || 0;
|
||||||
|
const bScore = dialectScores[b[0]] || 0;
|
||||||
|
return bScore - aScore;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate weight multipliers based on scores (higher score = more samples per cycle)
|
||||||
|
const maxScore = Math.max(...sortedGroups.map(g => dialectScores[g[0]] || 0), 0.1);
|
||||||
|
const groupWeights = sortedGroups.map(([dialect, entries]) => {
|
||||||
|
const score = dialectScores[dialect] || 0;
|
||||||
|
const weight = Math.max(1, Math.round((score / maxScore) * 5)); // 1-5x multiplier based on score
|
||||||
|
return { dialect, entries, weight, index: 0 }; // Track current position in group
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interleave entries with weighted round-robin for fuzzy distribution
|
||||||
|
const result: DatasetEntryForAnnotation[] = [];
|
||||||
|
const totalEntries = entries.length;
|
||||||
|
|
||||||
|
while (result.length < totalEntries) {
|
||||||
|
for (const group of groupWeights) {
|
||||||
|
// Add 'weight' entries from this group (or until we run out)
|
||||||
|
for (let w = 0; w < group.weight && group.index < group.entries.length && result.length < totalEntries; w++) {
|
||||||
|
result.push(group.entries[group.index]);
|
||||||
|
group.index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns unannotated entries for the given dataset and authenticated user.
|
* Returns unannotated entries for the given dataset and authenticated user.
|
||||||
|
* Sorts entries by calibration performance - dialects the user identified correctly and confidently appear first.
|
||||||
*/
|
*/
|
||||||
export async function getAnnotationEntries(experimentId: number): Promise<DatasetEntryForAnnotation[]> {
|
export async function getAnnotationEntries(experimentId: number): Promise<DatasetEntryForAnnotation[]> {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
@@ -52,6 +104,9 @@ export async function getAnnotationEntries(experimentId: number): Promise<Datase
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get dialect scores from calibration
|
||||||
|
const dialectScores = await getDialectScoresFromCalibration(experimentId);
|
||||||
|
|
||||||
const mapped: DatasetEntryForAnnotation[] = allDatasetEntries.map((e) => ({
|
const mapped: DatasetEntryForAnnotation[] = allDatasetEntries.map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
externalId: e.externalId,
|
externalId: e.externalId,
|
||||||
@@ -63,7 +118,7 @@ export async function getAnnotationEntries(experimentId: number): Promise<Datase
|
|||||||
annotation: annotationEntries.find((a) => a.datasetEntryId === e.id)?.rating || null,
|
annotation: annotationEntries.find((a) => a.datasetEntryId === e.id)?.rating || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return mapped;
|
return sortEntriesByWeightedDialectScore(mapped, dialectScores);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,60 +190,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
137
src/app/actions/calibration-scoring.ts
Normal file
137
src/app/actions/calibration-scoring.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import db from '@/lib/db';
|
||||||
|
import { experiment_calibration } from '@/lib/model/experiment_calibration';
|
||||||
|
import { participant } from '@/lib/model/participant';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates dialect performance scores based on the participant's calibration answers.
|
||||||
|
* Score = (correctness × confidence) averaged per dialect
|
||||||
|
* Range: -1 to 1, where:
|
||||||
|
* 1 = perfect identification with full confidence
|
||||||
|
* 0 = neutral (correct but unsure, or incorrect and unsure)
|
||||||
|
* -1 = completely wrong with full confidence (penalizes false confidence)
|
||||||
|
*/
|
||||||
|
export async function getDialectScoresFromCalibration(
|
||||||
|
experimentId: number
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error('Nicht angemeldet');
|
||||||
|
|
||||||
|
// Fetch calibration items and participant's answers
|
||||||
|
const calibrationItems = await db
|
||||||
|
.select()
|
||||||
|
.from(experiment_calibration)
|
||||||
|
.where(eq(experiment_calibration.experimentId, experimentId));
|
||||||
|
|
||||||
|
const participantRecord = await db
|
||||||
|
.select()
|
||||||
|
.from(participant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(participant.experimentId, experimentId),
|
||||||
|
eq(participant.userId, session.user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Calculate dialect scores based on calibration performance
|
||||||
|
const dialectScores: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (participantRecord.length > 0 && participantRecord[0].calibrationAnswers) {
|
||||||
|
const calibrationAnswers = participantRecord[0].calibrationAnswers as Record<
|
||||||
|
number,
|
||||||
|
{ dialectLabel: string; confidence: number }
|
||||||
|
>;
|
||||||
|
|
||||||
|
// For each dialect, calculate accuracy and average confidence
|
||||||
|
for (const item of calibrationItems) {
|
||||||
|
const answer = calibrationAnswers[item.id];
|
||||||
|
if (!answer) continue;
|
||||||
|
|
||||||
|
const isCorrect = answer.dialectLabel === item.dialectLabel ? 1 : -1;
|
||||||
|
const confidence = answer.confidence / 5; // Normalize to 0-1
|
||||||
|
|
||||||
|
// Score = correctness * confidence
|
||||||
|
// Correct answers: +confidence (0 to 1)
|
||||||
|
// Incorrect answers: -confidence (-1 to 0) - penalizes false confidence
|
||||||
|
const itemScore = isCorrect * confidence;
|
||||||
|
|
||||||
|
if (!dialectScores[item.dialectLabel]) {
|
||||||
|
dialectScores[item.dialectLabel] = 0;
|
||||||
|
}
|
||||||
|
dialectScores[item.dialectLabel] += itemScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average the scores by number of items per dialect
|
||||||
|
const dialectCounts: Record<string, number> = {};
|
||||||
|
for (const item of calibrationItems) {
|
||||||
|
dialectCounts[item.dialectLabel] = (dialectCounts[item.dialectLabel] || 0) + 1;
|
||||||
|
}
|
||||||
|
for (const dialect in dialectScores) {
|
||||||
|
dialectScores[dialect] = dialectScores[dialect] / (dialectCounts[dialect] || 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialectScores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,7 +2,8 @@ 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, isCalibrationDone } from '@/app/actions/annotations';
|
import { getAnnotationEntries} from '@/app/actions/annotations';
|
||||||
|
import { isCalibrationDone } from '@/app/actions/calibration-scoring';
|
||||||
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';
|
import CalibrationPhase from '@/components/CalibrationViews/CalibrationPhase';
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, useTransition, JSX } from 'react';
|
import { useState, useMemo, useTransition, JSX, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { saveAnnotations } from '@/app/actions/annotations';
|
import { saveAnnotations } from '@/app/actions/annotations';
|
||||||
|
import { getDialectScoresFromCalibration } from '@/app/actions/calibration-scoring';
|
||||||
import { DIALECT_LABELS, type DatasetEntryForAnnotation } from '@/lib/dialects';
|
import { DIALECT_LABELS, type DatasetEntryForAnnotation } from '@/lib/dialects';
|
||||||
import SingleChoiceEntryView from './SingleChoiceEntryView';
|
import SingleChoiceEntryView from './SingleChoiceEntryView';
|
||||||
import SingleChoiceBinaryEntryView from './SingleChoiceBinaryEntryView';
|
import SingleChoiceBinaryEntryView from './SingleChoiceBinaryEntryView';
|
||||||
import AnnotationSidebarNavigation from './AnnotationSidebarNavigation';
|
import AnnotationSidebarNavigation from './AnnotationSidebarNavigation';
|
||||||
|
import CalibrationScoresModal from '@/components/CalibrationScoresModal';
|
||||||
|
|
||||||
export interface EntryViewProps {
|
export interface EntryViewProps {
|
||||||
entry: DatasetEntryForAnnotation;
|
entry: DatasetEntryForAnnotation;
|
||||||
@@ -29,6 +31,21 @@ export default function AnnotationPageView({
|
|||||||
}: AnnotationPageViewProps) {
|
}: AnnotationPageViewProps) {
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [isCalibrationModalOpen, setIsCalibrationModalOpen] = useState(false);
|
||||||
|
const [dialectScores, setDialectScores] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// Load calibration scores on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadScores = async () => {
|
||||||
|
try {
|
||||||
|
const scores = await getDialectScoresFromCalibration(experimentId);
|
||||||
|
setDialectScores(scores);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading calibration scores:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadScores();
|
||||||
|
}, [experimentId]);
|
||||||
|
|
||||||
const getAnnotationConfig = (dialectLabel: string) => {
|
const getAnnotationConfig = (dialectLabel: string) => {
|
||||||
switch (viewType) {
|
switch (viewType) {
|
||||||
@@ -245,6 +262,15 @@ export default function AnnotationPageView({
|
|||||||
>
|
>
|
||||||
← Startseite
|
← Startseite
|
||||||
</Link>
|
</Link>
|
||||||
|
{Object.keys(dialectScores).length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCalibrationModalOpen(true)}
|
||||||
|
className="text-sm px-3 py-1.5 bg-purple-100 hover:bg-purple-200 text-purple-700 rounded-md transition-colors"
|
||||||
|
title="Kalibrierungsergebnisse anzeigen"
|
||||||
|
>
|
||||||
|
📊 Kalibrierung
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -275,6 +301,13 @@ export default function AnnotationPageView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Calibration Scores Modal */}
|
||||||
|
<CalibrationScoresModal
|
||||||
|
isOpen={isCalibrationModalOpen}
|
||||||
|
onClose={() => setIsCalibrationModalOpen(false)}
|
||||||
|
dialectScores={dialectScores}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DatasetEntryForAnnotation } from '@/lib/dialects';
|
import { DatasetEntryForAnnotation, DIALECT_LABELS } from '@/lib/dialects';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface AnnotationSidebarNavigationProps {
|
interface AnnotationSidebarNavigationProps {
|
||||||
@@ -77,7 +77,7 @@ export default function AnnotationSidebarNavigation({
|
|||||||
/>
|
/>
|
||||||
{/* Entry number and filename */}
|
{/* Entry number and filename */}
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{`Sample ${index + 1}`}
|
{`Sample ${index + 1} - ${DIALECT_LABELS[entry.dialect]}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
53
src/components/CalibrationScoresDisplay.tsx
Normal file
53
src/components/CalibrationScoresDisplay.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DIALECT_LABELS } from '@/lib/dialects';
|
||||||
|
|
||||||
|
interface CalibrationScoresDisplayProps {
|
||||||
|
dialectScores: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalibrationScoresDisplay({
|
||||||
|
dialectScores,
|
||||||
|
}: CalibrationScoresDisplayProps) {
|
||||||
|
if (Object.keys(dialectScores).length === 0) return null;
|
||||||
|
|
||||||
|
const sortedScores = Object.entries(dialectScores).sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sortedScores.map(([dialect, score]) => {
|
||||||
|
// Normalize score from -1 to 1 range to 0 to 100 percentage
|
||||||
|
const percentage = ((score + 1) / 2) * 100;
|
||||||
|
const isGood = score > 0.5;
|
||||||
|
const isFair = score > -0.5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dialect}>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="font-medium text-gray-800">{DIALECT_LABELS[dialect]}</span>
|
||||||
|
<span className={`text-sm font-semibold ${
|
||||||
|
isGood ? 'text-green-600' : isFair ? 'text-yellow-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{(score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 ${
|
||||||
|
isGood ? 'bg-green-500' : isFair ? 'bg-yellow-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.max(0, Math.min(100, percentage))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-6">
|
||||||
|
Die Scores basieren auf der Genauigkeit und dem Vertrauen in Ihre Kalibrierergebnisse.
|
||||||
|
Dialekte mit höheren Scores werden zuerst in der Annotation angezeigt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/CalibrationScoresModal.tsx
Normal file
39
src/components/CalibrationScoresModal.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import { DIALECT_LABELS } from '@/lib/dialects';
|
||||||
|
import CalibrationScoresDisplay from './CalibrationScoresDisplay';
|
||||||
|
|
||||||
|
interface CalibrationScoresModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
dialectScores: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalibrationScoresModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
dialectScores,
|
||||||
|
}: CalibrationScoresModalProps) {
|
||||||
|
if (!isOpen || Object.keys(dialectScores).length === 0) return null;
|
||||||
|
|
||||||
|
const sortedScores = Object.entries(dialectScores).sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-96 overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center p-6 border-b border-gray-200 sticky top-0 bg-white">
|
||||||
|
<h2 className="text-xl font-bold">Ihre Kalibrierungsergebnisse</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CalibrationScoresDisplay dialectScores={dialectScores} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/CalibrationViews/CalibrationInfoView.tsx
Normal file
100
src/components/CalibrationViews/CalibrationInfoView.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface CalibrationInfoViewProps {
|
||||||
|
onContinue: () => void;
|
||||||
|
hasExistingAnswers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalibrationInfoView({
|
||||||
|
onContinue,
|
||||||
|
hasExistingAnswers,
|
||||||
|
}: CalibrationInfoViewProps) {
|
||||||
|
const handleContinue = () => {
|
||||||
|
onContinue();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-white">
|
||||||
|
<div className="flex-1 flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-2xl w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-12 mt-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Kalibrierungsphase</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-8 mb-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Was ist die Kalibrierungsphase?</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Die Kalibrierungsphase dient dazu, Ihre individuellen Stärken und Schwächen bei der Dialekterkennung zu verstehen. Sie hören verschiedene Audiosamples und geben an, welchen Dialekt Sie hören und wie sicher Sie sich dabei sind.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Dies hilft uns, Ihre Annotationsergebnisse später besser zu bewerten und zu
|
||||||
|
verstehen. Die Annotationen werden auf Basis Ihrer Kalibrierungsergebnisse gewichtet, sodass Ihre Stärken stärker berücksichtigt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Wie funktioniert es?</h3>
|
||||||
|
<ol className="space-y-3 text-gray-700">
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
|
||||||
|
1
|
||||||
|
</span>
|
||||||
|
<span>Sie hören ein Audiosample</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
|
||||||
|
2
|
||||||
|
</span>
|
||||||
|
<span>Sie wählen den Dialekt aus, den Sie gehört haben</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<span>Sie bewerten Ihr Vertrauen in die Identifikation (von 1-5)</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
|
||||||
|
4
|
||||||
|
</span>
|
||||||
|
<span>Sie wiederholen dies für alle Audiosamples</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Important Note */}
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6 mb-8">
|
||||||
|
<h4 className="font-semibold text-amber-900 mb-2">ℹ️ Wichtig</h4>
|
||||||
|
<p className="text-amber-800 text-sm">
|
||||||
|
Sie können diese Seite jederzeit verlassen und später zurückkehren, um Ihre
|
||||||
|
Kalibrierung fortzusetzen. Ihre Antworten werden automatisch gespeichert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{hasExistingAnswers && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-8">
|
||||||
|
<p className="text-green-800 text-sm">
|
||||||
|
✓ Sie haben diese Einführung bereits gesehen. Sie können Ihre Kalibrierung
|
||||||
|
fortsetzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
className="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors text-lg"
|
||||||
|
>
|
||||||
|
{hasExistingAnswers ? 'Kalibrierung fortsetzen' : 'Kalibrierung starten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@
|
|||||||
import { useState, useTransition, useMemo, useEffect } from 'react';
|
import { useState, useTransition, useMemo, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { saveCalibrationAnswers, getCalibrationAnswers } from '@/app/actions/calibration';
|
import { saveCalibrationAnswers, getCalibrationAnswers } from '@/app/actions/calibration';
|
||||||
|
import { getDialectScoresFromCalibration } from '@/app/actions/calibration-scoring';
|
||||||
import { ExperimentCalibration } from '@/lib/model/experiment_calibration';
|
import { ExperimentCalibration } from '@/lib/model/experiment_calibration';
|
||||||
import CalibrationEntryView from './CalibrationEntryView';
|
import CalibrationEntryView from './CalibrationEntryView';
|
||||||
|
import CalibrationScoresDisplay from '@/components/CalibrationScoresDisplay';
|
||||||
|
import { DIALECT_LABELS } from '@/lib/dialects';
|
||||||
|
|
||||||
interface CalibrationPageViewProps {
|
interface CalibrationPageViewProps {
|
||||||
entries: ExperimentCalibration[];
|
entries: ExperimentCalibration[];
|
||||||
@@ -27,6 +30,7 @@ export default function CalibrationPageView({
|
|||||||
const [answers, setAnswers] = useState<CalibrationAnswers>({});
|
const [answers, setAnswers] = useState<CalibrationAnswers>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [dialectScores, setDialectScores] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
// Load existing answers on component mount
|
// Load existing answers on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,6 +69,21 @@ export default function CalibrationPageView({
|
|||||||
const isComplete = entries.length > 0 && completeAnswers.length === entries.length;
|
const isComplete = entries.length > 0 && completeAnswers.length === entries.length;
|
||||||
const progressPct = Math.round((completeAnswers.length / entries.length) * 100);
|
const progressPct = Math.round((completeAnswers.length / entries.length) * 100);
|
||||||
|
|
||||||
|
// Load dialect scores when calibration is complete
|
||||||
|
useEffect(() => {
|
||||||
|
if (isComplete && !isLoading) {
|
||||||
|
const loadScores = async () => {
|
||||||
|
try {
|
||||||
|
const scores = await getDialectScoresFromCalibration(experimentId);
|
||||||
|
setDialectScores(scores);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading dialect scores:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadScores();
|
||||||
|
}
|
||||||
|
}, [isComplete, isLoading, experimentId]);
|
||||||
|
|
||||||
// Check if current entry has complete answers
|
// Check if current entry has complete answers
|
||||||
const currentEntryComplete = currentEntry &&
|
const currentEntryComplete = currentEntry &&
|
||||||
answers[currentEntry.id]?.dialectLabel &&
|
answers[currentEntry.id]?.dialectLabel &&
|
||||||
@@ -142,40 +161,44 @@ export default function CalibrationPageView({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
if (isComplete) {
|
||||||
|
// Sort dialect scores from highest to lowest
|
||||||
|
const sortedScores = Object.entries(dialectScores).sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-white">
|
<div className="flex flex-col h-full bg-white">
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto text-center py-20">
|
<div className="max-w-3xl mx-auto py-20 px-4">
|
||||||
<div className="text-5xl mb-4">🎉</div>
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-3xl font-bold text-green-600 mb-3">
|
<div className="text-5xl mb-4">🎉</div>
|
||||||
Kalibrierung abgeschlossen!
|
<h1 className="text-3xl font-bold text-green-600 mb-3">
|
||||||
</h1>
|
Kalibrierung abgeschlossen!
|
||||||
<p className="text-gray-600 mb-8">
|
</h1>
|
||||||
Sie haben alle Kalibrierungssamples bewertet. Vielen Dank! Sie können jetzt mit der
|
<p className="text-gray-600 mb-8">
|
||||||
Annotation beginnen.
|
Sie haben alle Kalibrierungssamples bewertet. Vielen Dank! Sie können jetzt mit der
|
||||||
</p>
|
Annotation beginnen.
|
||||||
<button
|
</p>
|
||||||
onClick={handleCompleteCalibration}
|
</div>
|
||||||
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"
|
{/* Dialect Scores Summary */}
|
||||||
>
|
{sortedScores.length > 0 && (
|
||||||
{isPending ? 'Wird gespeichert...' : 'Zur Annotation'}
|
<div className="mb-12">
|
||||||
</button>
|
<h2 className="text-lg font-semibold text-gray-900 mb-6">Ihre Kalibrierungsergebnisse</h2>
|
||||||
|
|
||||||
|
<CalibrationScoresDisplay dialectScores={dialectScores} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getCalibrationEntries } from '@/app/actions/calibration';
|
import { getCalibrationEntries, getCalibrationAnswers } from '@/app/actions/calibration';
|
||||||
import { ExperimentCalibration } from '@/lib/model/experiment_calibration';
|
import { ExperimentCalibration } from '@/lib/model/experiment_calibration';
|
||||||
import CalibrationPageView from './CalibrationPageView';
|
import CalibrationPageView from './CalibrationPageView';
|
||||||
|
import CalibrationInfoView from './CalibrationInfoView';
|
||||||
|
|
||||||
interface CalibrationPhaseProps {
|
interface CalibrationPhaseProps {
|
||||||
experimentId: number;
|
experimentId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalibrationPhase({ experimentId }: CalibrationPhaseProps) {
|
export default function CalibrationPhase({ experimentId }: CalibrationPhaseProps) {
|
||||||
const [entries, setEntries] = useState<ExperimentCalibration[] | null>(null);
|
const [entries, setEntries] = useState<ExperimentCalibration[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [showInfoPage, setShowInfoPage] = useState(true);
|
||||||
|
const [hasExistingAnswers, setHasExistingAnswers] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await getCalibrationEntries(experimentId);
|
// Load calibration entries
|
||||||
if (result.success && result.data) {
|
const entriesResult = await getCalibrationEntries(experimentId);
|
||||||
|
if (entriesResult.success && entriesResult.data) {
|
||||||
// Sort by order
|
// Sort by order
|
||||||
const sorted = [...result.data].sort((a, b) => a.order - b.order);
|
const sorted = [...entriesResult.data].sort((a, b) => a.order - b.order);
|
||||||
setEntries(sorted);
|
setEntries(sorted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load existing answers to check if user has seen the info page before
|
||||||
|
const answersResult = await getCalibrationAnswers(experimentId);
|
||||||
|
if (answersResult.success && answersResult.data) {
|
||||||
|
const answers = answersResult.data as Record<number, any> | null;
|
||||||
|
if (answers && Object.keys(answers).length > 0) {
|
||||||
|
setHasExistingAnswers(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Fehler beim Laden der Kalibrierungssamples');
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -34,27 +45,28 @@ export default function CalibrationPhase({ experimentId }: CalibrationPhaseProps
|
|||||||
load();
|
load();
|
||||||
}, [experimentId]);
|
}, [experimentId]);
|
||||||
|
|
||||||
|
// Show loading view
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto py-16 text-center">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-5xl mb-4">⏳</div>
|
<div className="text-center">
|
||||||
<h1 className="text-2xl font-bold mb-4">Lädt Kalibrierungssamples...</h1>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
<p className="text-gray-600">Bitte warten Sie...</p>
|
<p className="text-gray-600">Loading calibration...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !entries || entries.length === 0) {
|
// Show info page first
|
||||||
|
if (showInfoPage) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto py-16 text-center">
|
<CalibrationInfoView
|
||||||
<div className="text-5xl mb-4">❌</div>
|
onContinue={() => setShowInfoPage(false)}
|
||||||
<h1 className="text-2xl font-bold mb-4">Kalibrierung nicht verfügbar</h1>
|
hasExistingAnswers={hasExistingAnswers}
|
||||||
<p className="text-gray-600 mb-8">
|
/>
|
||||||
{error || 'Für dieses Experiment sind keine Kalibrierungssamples vorhanden.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show calibration page
|
||||||
return <CalibrationPageView entries={entries} experimentId={experimentId} />;
|
return <CalibrationPageView entries={entries} experimentId={experimentId} />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user