From 0d7e6ed19f457fbee4e1ff45124ecefa0c1d47f9 Mon Sep 17 00:00:00 2001 From: smaubio <118258478+smaubio@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:03:23 +0100 Subject: [PATCH] feat: add prototype-based annotation view with single-choice rating --- drizzle/0007_light_spiral.sql | 9 + drizzle/meta/0007_snapshot.json | 637 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/actions/annotations.ts | 33 +- .../annotate/[datasetId]/[prototype]/page.tsx | 57 ++ src/app/annotate/[datasetId]/page.tsx | 76 +-- .../SingleChoiceView.tsx} | 77 +-- src/lib/model/annotation.ts | 9 +- 8 files changed, 779 insertions(+), 126 deletions(-) create mode 100644 drizzle/0007_light_spiral.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 src/app/annotate/[datasetId]/[prototype]/page.tsx rename src/components/{AnnotationView.tsx => AnnotationViews/SingleChoiceView.tsx} (78%) diff --git a/drizzle/0007_light_spiral.sql b/drizzle/0007_light_spiral.sql new file mode 100644 index 0000000..618e470 --- /dev/null +++ b/drizzle/0007_light_spiral.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `annotation` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `dataset_entry_id` integer NOT NULL, + `user_id` text NOT NULL, + `rating` integer NOT NULL, + `dialect_label` text NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`dataset_entry_id`) REFERENCES `dataset_entry`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..213e8be --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,637 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "071a0744-4d6e-4657-948b-7eda495b9756", + "prevId": "4e72f679-ef9b-47d5-a6fb-a1c45d5cbe74", + "tables": { + "annotation": { + "name": "annotation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "dataset_entry_id": { + "name": "dataset_entry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dialect_label": { + "name": "dialect_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "annotation_dataset_entry_id_dataset_entry_id_fk": { + "name": "annotation_dataset_entry_id_dataset_entry_id_fk", + "tableFrom": "annotation", + "tableTo": "dataset_entry", + "columnsFrom": [ + "dataset_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin": { + "name": "admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dataset_entry": { + "name": "dataset_entry", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "speaker_id": { + "name": "speaker_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "utterance_id": { + "name": "utterance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utterance_text": { + "name": "utterance_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dataset_id": { + "name": "dataset_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dialect": { + "name": "dialect", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rms_value": { + "name": "rms_value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "longest_pause": { + "name": "longest_pause", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "utmos_score": { + "name": "utmos_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wer_score": { + "name": "wer_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "dataset_entry_dataset_id_dataset_id_fk": { + "name": "dataset_entry_dataset_id_dataset_id_fk", + "tableFrom": "dataset_entry", + "tableTo": "dataset", + "columnsFrom": [ + "dataset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dataset": { + "name": "dataset", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0455de6..a85e5e6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1773406006276, "tag": "0006_special_junta", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1773410584481, + "tag": "0007_light_spiral", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/actions/annotations.ts b/src/app/actions/annotations.ts index c356461..e1c9e36 100644 --- a/src/app/actions/annotations.ts +++ b/src/app/actions/annotations.ts @@ -10,8 +10,7 @@ import { headers } from 'next/headers'; import type { AnnotationEntry } from '@/lib/dialects'; /** - * Returns shuffled unannotated entries for the given dataset and authenticated user. - * Consecutive entries will never share the same externalId. + * Returns unannotated entries for the given dataset and authenticated user. */ export async function getAnnotationEntries(datasetId: number): Promise { const session = await auth.api.getSession({ headers: await headers() }); @@ -48,7 +47,7 @@ export async function getAnnotationEntries(datasetId: number): Promise 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [arr[i], arr[j]] = [arr[j], arr[i]]; - } - - // Single-pass greedy fix - for (let i = 1; i < arr.length; i++) { - if (arr[i].externalId === arr[i - 1].externalId) { - for (let j = i + 1; j < arr.length; j++) { - if (arr[j].externalId !== arr[i - 1].externalId) { - [arr[i], arr[j]] = [arr[j], arr[i]]; - break; - } - } - } - } - - return arr; -} diff --git a/src/app/annotate/[datasetId]/[prototype]/page.tsx b/src/app/annotate/[datasetId]/[prototype]/page.tsx new file mode 100644 index 0000000..7ffc3b8 --- /dev/null +++ b/src/app/annotate/[datasetId]/[prototype]/page.tsx @@ -0,0 +1,57 @@ +import { redirect, notFound } from 'next/navigation'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { getAnnotationEntries } from '@/app/actions/annotations'; +import SingleChoiceView from '@/components/AnnotationViews/SingleChoiceView'; + +interface Props { + params: Promise<{ datasetId: string; prototype: string }>; +} + +export default async function AnnotatePage({ params }: Props) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect('/user/sign-in'); + + const { datasetId: datasetIdStr, prototype } = await params; + const datasetId = parseInt(datasetIdStr, 10); + + if (isNaN(datasetId)) { + return ( +
+

Ungültige Dataset-ID.

+ + ← Startseite + +
+ ); + } + + if (prototype !== 'single-choice') { + notFound(); + } + + const entries = await getAnnotationEntries(datasetId); + + if (entries.length === 0) { + return ( +
+
+

+ Alle Samples bewertet! +

+

+ Sie haben alle Samples in diesem Dataset bereits bewertet. Danke für Ihre Mitarbeit! +

+ + Zurück zur Startseite + +
+ ); + } + + return ; +} diff --git a/src/app/annotate/[datasetId]/page.tsx b/src/app/annotate/[datasetId]/page.tsx index 48b020f..ff66baf 100644 --- a/src/app/annotate/[datasetId]/page.tsx +++ b/src/app/annotate/[datasetId]/page.tsx @@ -2,52 +2,52 @@ import { redirect } from 'next/navigation'; import { headers } from 'next/headers'; import Link from 'next/link'; import { auth } from '@/lib/auth'; -import { getAnnotationEntries } from '@/app/actions/annotations'; -import AnnotationView from '@/components/AnnotationView'; + +const PROTOTYPES = [ + { + id: 'single-choice', + name: 'Single Choice', + description: 'Bewerten Sie jedes Sample auf einer Skala von 1–5.', + }, +]; interface Props { params: Promise<{ datasetId: string }>; } -export default async function AnnotatePage({ params }: Props) { +export default async function PrototypeSelectionPage({ params }: Props) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect('/user/sign-in'); - const { datasetId: datasetIdStr } = await params; - const datasetId = parseInt(datasetIdStr, 10); + const { datasetId } = await params; - if (isNaN(datasetId)) { - return ( -
-

Ungültige Dataset-ID.

- - ← Startseite - + return ( +
+ + ← Zurück zur Startseite + +

Prototyp auswählen

+

+ Wählen Sie einen Annotationsprototyp aus, um mit der Bewertung zu beginnen. +

+ +
+ {PROTOTYPES.map((proto) => ( + +
+
+

{proto.name}

+

{proto.description}

+
+ +
+ + ))}
- ); - } - - const entries = await getAnnotationEntries(datasetId); - - if (entries.length === 0) { - return ( -
-
-

- Alle Samples bewertet! -

-

- Sie haben alle Samples in diesem Dataset bereits bewertet. Danke für Ihre Mitarbeit! -

- - Zurück zur Startseite - -
- ); - } - - return ; +
+ ); } diff --git a/src/components/AnnotationView.tsx b/src/components/AnnotationViews/SingleChoiceView.tsx similarity index 78% rename from src/components/AnnotationView.tsx rename to src/components/AnnotationViews/SingleChoiceView.tsx index 3aae875..05564c7 100644 --- a/src/components/AnnotationView.tsx +++ b/src/components/AnnotationViews/SingleChoiceView.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState, useEffect, useTransition } from 'react'; import Link from 'next/link'; -import WaveformPlayer from './WaveformPlayer'; +import WaveformPlayer from '../WaveformPlayer'; import { saveAnnotations } from '@/app/actions/annotations'; import { type AnnotationEntry, DIALECT_LABELS } from '@/lib/dialects'; -const PAGE_SIZE = 5; +const PAGE_SIZE = 1; +const AUTO_ADVANCE_DELAY_MS = 600; const RATING_OPTIONS = (dialectLabel: string) => [ { value: 1, label: `Klingt überhaupt nicht nach ${dialectLabel}` }, @@ -16,12 +17,12 @@ const RATING_OPTIONS = (dialectLabel: string) => [ { value: 5, label: `Klingt eindeutig nach ${dialectLabel}` }, ]; -interface AnnotationViewProps { +interface SingleChoiceViewProps { entries: AnnotationEntry[]; datasetId: number; } -export default function AnnotationView({ entries, datasetId }: AnnotationViewProps) { +export default function SingleChoiceView({ entries, datasetId }: SingleChoiceViewProps) { const [isPending, startTransition] = useTransition(); @@ -29,7 +30,6 @@ export default function AnnotationView({ entries, datasetId }: AnnotationViewPro const [answers, setAnswers] = useState>({}); const [fullyPlayed, setFullyPlayed] = useState>(new Set()); const [activePlayerId, setActivePlayerId] = useState(null); - const [showHint, setShowHint] = useState(false); const [isComplete, setIsComplete] = useState(false); const totalPages = Math.ceil(entries.length / PAGE_SIZE); @@ -40,24 +40,25 @@ export default function AnnotationView({ entries, datasetId }: AnnotationViewPro pageEntries.length > 0 && pageEntries.every((e) => fullyPlayed.has(e.id) && answers[e.id] !== undefined); - const handleWeiter = () => { - if (!allCurrentDone) { - setShowHint(true); - return; - } - setShowHint(false); + useEffect(() => { + if (!allCurrentDone || isPending) return; - const batch = pageEntries.map((e) => ({ entryId: e.id, rating: answers[e.id], dialectLabel: e.dialect })); - startTransition(async () => { - await saveAnnotations(batch); - if (currentPage + 1 >= totalPages) { - setIsComplete(true); - } else { - setCurrentPage((p) => p + 1); - setActivePlayerId(null); - } - }); - }; + const timer = setTimeout(() => { + const batch = pageEntries.map((e) => ({ entryId: e.id, rating: answers[e.id], dialectLabel: e.dialect })); + startTransition(async () => { + await saveAnnotations(batch); + if (currentPage + 1 >= totalPages) { + setIsComplete(true); + } else { + setCurrentPage((p) => p + 1); + setActivePlayerId(null); + } + }); + }, AUTO_ADVANCE_DELAY_MS); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allCurrentDone]); if (isComplete) { return ( @@ -80,10 +81,10 @@ export default function AnnotationView({ entries, datasetId }: AnnotationViewPro return (
{/* ── Top bar ─────────────────────────────────────────── */} -
+
- Seite {currentPage + 1} von {totalPages} + Sample {currentPage + 1} von {totalPages} {RATING_OPTIONS(dialectLabel).map(({ value, label }) => { const selected = currentRating === value; - const disabled = !isListened; + const disabled = !isListened || isPending; return ( -
); } diff --git a/src/lib/model/annotation.ts b/src/lib/model/annotation.ts index 26f393b..47e0057 100644 --- a/src/lib/model/annotation.ts +++ b/src/lib/model/annotation.ts @@ -15,14 +15,7 @@ export const annotation = sqliteTable( createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .default(sql`(unixepoch())`), - }, - (table) => [ - uniqueIndex('annotation_user_entry_dialect_idx').on( - table.userId, - table.datasetEntryId, - table.dialectLabel - ), - ] + } ); export const annotationRelations = relations(annotation, ({ one }) => ({